Skip to content

webcore: free drained buffer in FileReader.onPull memcpy path#29924

Merged
Jarred-Sumner merged 3 commits into
mainfrom
farm/116443d4/filereader-onpull-drain-leak
Apr 29, 2026
Merged

webcore: free drained buffer in FileReader.onPull memcpy path#29924
Jarred-Sumner merged 3 commits into
mainfrom
farm/116443d4/filereader-onpull-drain-leak

Conversation

@robobun

@robobun robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

What

FileReader.onPull leaked the drained buffer when copying it into the JS-provided pull array.

Why

drain() moves ownership of this.buffered (or the reader's internal buffer) into the returned ByteList via moveFromList, which resets the source to empty. After the memcpy into the JS array, the old code called this.buffered.clearAndFree(...) — a no-op on the already-emptied list — so the ByteList returned by drain() was never freed.

var drained = this.drain();                       // moves out of this.buffered
...
@memcpy(buffer[0..drained.len], drained.slice());
this.buffered.clearAndFree(bun.default_allocator); // no-op: already empty
// `drained` is dropped on the floor

The fix frees drained itself (capturing .len first since deinit() invalidates the struct).

Reachability

Most consumers route through a JS-side handle.drain() guard that intercepts buffered data before onPull sees it. The branch is reached when onPull is invoked directly while this.buffered is non-empty — e.g. a child_process stdout NativeReadable whose _read() goes straight to ptr.pull() after the first read, with the Node buffer already full so _read wasn't auto-rescheduled between the pending-fulfilling chunk and the next one.

Test

test/js/bun/spawn/spawn-stdout-iterate-leak.test.ts constructs that sequence with cat + 32 KiB writes + highWaterMark = 32 KiB, so every cycle hits onPull(32768) = 32768 (the memcpy branch). The fixture subprocess samples RSS across five equal 1000-cycle runs and reports the first-to-last delta.

The test is gated to debug/ASAN builds: under ASAN each orphaned 32 KiB block is quarantined and cannot be reused, so the leak shows as clean linear RSS growth. On release builds mimalloc recycles the orphaned blocks into later same-size allocations, making RSS growth sub-linear and too close to allocator noise to threshold reliably across all platforms.

RSS delta (first→last sample)
before (debug+ASAN) 148–162 MB
after (debug+ASAN) −8 to 12 MB

Threshold is 32 MB.

FileReader.drain() moves ownership of this.buffered (or the reader
buffer) into the returned ByteList and leaves the source empty. When
onPull copies that data into the JS-provided array it then called
this.buffered.clearAndFree(), which is a no-op on the now-empty list,
orphaning the moved allocation on every such pull.

Free the drained ByteList instead, capturing its length first since
deinit() invalidates the struct.
@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

Warning

Rate limit exceeded

@robobun has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 1 minute and 28 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 36b62612-3304-498e-9afe-02992838dd37

📥 Commits

Reviewing files that changed from the base of the PR and between a2c1de8 and 4ea2153.

📒 Files selected for processing (1)
  • test/js/bun/spawn/spawn-stdout-iterate-leak.fixture.ts

Walkthrough

Refactors FileReader.onPull memory handling to transfer ownership of drained allocations to local scope for explicit deallocation. Introduces tests to verify memory behavior during repeated stdout reading from spawned processes.

Changes

Cohort / File(s) Summary
FileReader Memory Management
src/bun.js/webcore/FileReader.zig
Updated onPull to treat drain() as transferring ownership of its allocation into a local drained variable. Explicitly deallocates drained and uses drained_len for return values instead of reading drained.len after cleanup.
Memory Leak Test Suite
test/js/bun/spawn/spawn-stdout-iterate-leak.fixture.ts, test/js/bun/spawn/spawn-stdout-iterate-leak.test.ts
Introduces fixture and test to verify memory behavior during repeated spawning of processes with piped stdout. Measures RSS memory samples to ensure growth remains within acceptable bounds, isolating the FileReader.onPull code path.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main fix: freeing the drained buffer in the FileReader.onPull memcpy path, which is the core change addressing the memory leak.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description is comprehensive and follows the template structure with clear 'What' and 'Why' sections, plus additional context on reachability and testing.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Review rate limit: 0/5 reviews remaining, refill in 1 minute and 28 seconds.

Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:24 AM PT - Apr 29th, 2026

@robobun, your commit 4ea2153 has 4 failures in Build #48978 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29924

That installs a local version of the PR into your bun-29924 executable, so you can run:

bun-29924 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Memory leak with octet-streams #16529 - Memory leak with octet-streams: http.get() response piping uses NativeReadable internally, and memory grows proportionally to data read (280MB→3200MB for 1GB), consistent with the per-pull ByteList leak in FileReader.onPull's memcpy path
  2. Memory leak with streaming downloads from azure blob storage #7272 - Memory leak with streaming downloads from Azure blob storage: streams large files via node:http (NativeReadable), with memory growing proportionally to data streamed (~1.5GB+ for 2GB file), matching unfree'd ByteList allocations accumulating on every pull

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #16529
Fixes #7272

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — surgical leak fix: drain() moves ownership out of this.buffered, so freeing drained (with .len captured pre-deinit) is the right call.

Extended reasoning...

Overview

This PR fixes a memory leak in FileReader.onPull (src/bun.js/webcore/FileReader.zig). drain() uses ByteList.moveFromList to transfer ownership of either this.buffered or this.reader.buffer() into the returned ByteList, leaving the source empty. The old code then called this.buffered.clearAndFree(...) after the memcpy — a no-op on the already-emptied list — so the moved allocation was orphaned each time this branch was hit. The fix changes drained to var, captures drained.len (since BabyList.deinit sets this.* = undefined), and calls drained.deinit(bun.default_allocator). A new regression test (spawn-stdout-iterate-leak.test.ts) drives 2000 iterations through the exact memcpy path via a cat subprocess and asserts RSS growth stays under 50 MB (vs. ~80–130 MB pre-fix).

Security risks

None. This is a pure deallocation fix with no change to control flow, parsing, auth, or external inputs. Both possible sources of the drained buffer — this.buffered (unmanaged, allocated everywhere in this file with bun.default_allocator) and PosixBufferedReader._buffer (std.array_list.Managed(u8).init(bun.default_allocator)) — use bun.default_allocator, so the allocator passed to deinit matches and there is no double-free or allocator-mismatch risk. The data is memcpy'd into the JS buffer before the free, and the captured drained_len avoids any read-after-invalidate.

Level of scrutiny

Low-to-moderate. The Zig change is 6 effective lines, mechanical, and the PR description precisely explains the ownership-move semantics that made the old free a no-op. I verified moveFromList and BabyList.deinit in src/collections/baby_list.zig and the reader buffer's allocator in src/io/PipeReader.zig to confirm correctness. No CODEOWNERS entries cover this path.

Other factors

The bug-hunting system found no issues. The regression test is somewhat timing-dependent (relies on setImmediate ticks and poking _readableState.highWaterMark) and is correctly gated with todoIf(isWindows) since the leak is in the POSIX poll-reader path; the 50 MB threshold gives ~2× headroom over the observed post-fix noise (16–28 MB), so flakiness risk seems acceptable for a leak regression test.

Release builds recycle the orphaned 32 KiB blocks into later same-size
allocations, so RSS growth is sub-linear and flaky to threshold (failed
on Alpine 3.23). Under ASAN each leaked block is quarantined and cannot
be reused, so the leak shows as clean linear growth (~150 MB over 4x1000
iterations vs <12 MB fixed). Gate the test to debug/ASAN builds and
sample RSS across five equal runs in an isolated subprocess.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/js/bun/spawn/spawn-stdout-iterate-leak.fixture.ts`:
- Around line 56-59: The stdout stream is resumed before its "data" listener is
attached which can drop buffered bytes and make total flaky; move the
proc.stdout!.on("data", (d: Buffer) => void (total += d.length)) call to occur
before proc.stdout!.resume() (keep proc.stdin!.end() and await once(proc,
"close") as-is) so the data handler is registered prior to resuming the stream.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 018257b0-c924-4fad-af98-78d5932aacfa

📥 Commits

Reviewing files that changed from the base of the PR and between 9079d5b and a2c1de8.

📒 Files selected for processing (3)
  • src/bun.js/webcore/FileReader.zig
  • test/js/bun/spawn/spawn-stdout-iterate-leak.fixture.ts
  • test/js/bun/spawn/spawn-stdout-iterate-leak.test.ts

Comment thread test/js/bun/spawn/spawn-stdout-iterate-leak.fixture.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fix looks correct — drain() moves ownership out via moveFromList, so freeing the local drained (with .len captured first) is right. Deferring since this is a hot-path allocator change in core streaming I/O and the last reported CI run (a2c1de8) shows build-zig failures that haven't been re-reported for 4ea2153 yet.

Extended reasoning...

Overview

This PR fixes a memory leak in src/bun.js/webcore/FileReader.zig's onPull memcpy branch. drain() returns a ByteList whose backing allocation was moved out of this.buffered (or the reader's internal buffer) via moveFromList, leaving the source empty. The old code then called this.buffered.clearAndFree(...) — a no-op on the already-emptied list — and dropped drained on the floor. The fix changes drained to var, captures drained.len into a local before calling drained.deinit(bun.default_allocator) (since deinit sets *this = undefined), and uses the captured length in the return value. Two new test files exercise the path under ASAN/debug builds via a cat subprocess with a tuned highWaterMark.

Security risks

None. This is purely an internal allocator-ownership fix; no user-controlled input affects the freed pointer, and both source buffers (this.buffered and the posix reader _buffer) are allocated with bun.default_allocator, so the deinit allocator matches.

Level of scrutiny

Medium-high. The diff is ~10 lines of Zig plus tests, and the reasoning is sound — I verified moveFromList resets the source list and that BabyList.deinit requires a mutable receiver and invalidates the struct (hence the drained_len capture). However, this sits in a hot streaming-I/O path where an ownership mistake would convert a leak into a UAF/double-free, so a maintainer familiar with the FileReader/ByteList ownership model should sign off.

Other factors

  • The one CodeRabbit comment (listener-before-resume ordering in the fixture) was addressed in 4ea2153 and resolved.
  • robobun's last CI status update is for a2c1de8 and shows build-zig failures across all platforms; no updated status is posted yet for 4ea2153. Worth confirming CI is green before merge.
  • The regression test is gated to isDebug || isASAN (skipped on Windows and release), which the PR description justifies — mimalloc recycles same-size-class blocks on release so RSS doesn't grow linearly. The test asserts expect(stderr).toBe("") on a JS-executing subprocess under bunEnv; per repo conventions this is acceptable when BUN_DEBUG_QUIET_LOGS=1 suppresses ASAN noise.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

After:

bun test v1.3.14-canary.1 (4ea2153d)

test/js/bun/spawn/spawn-stdout-iterate-leak.test.ts:
RSS samples=[307.7, 307.8, 313.1, 312.6, 314.2]MB delta=6.6MB
✓ FileReader.onPull frees the drained buffer after memcpy [856.07ms]

 1 pass
 0 fail
 3 expect() calls
Ran 1 test across 1 file. [1261.00ms]

Before:

bun test v1.3.14-canary.1 (c209e3db)

test/js/bun/spawn/spawn-stdout-iterate-leak.test.ts:
RSS samples=[314.2, 342.7, 371.7, 378.6, 403.9]MB delta=89.7MB
43 |
44 |     // Without the fix, each of the 4×1000 iterations between the first and
45 |     // last sample orphans a ~32 KiB allocation, so RSS climbs another
46 |     // ~65-120 MB (higher under ASAN). With the fix the samples plateau and
47 |     // the first-to-last delta is noise (<10 MB).
48 |     expect(delta).toBeLessThan(32);
                       ^
error: expect(received).toBeLessThan(expected)

Expected: < 32
Received: 89.7109375

      at <anonymous> (/root/bun-5/test/js/bun/spawn/spawn-stdout-iterate-leak.test.ts:48:19)
✗ FileReader.onPull frees the drained buffer after memcpy [961.19ms]

 0 pass
 1 fail
 3 expect() calls
Ran 1 test across 1 file. [1398.00ms]

@Jarred-Sumner Jarred-Sumner merged commit f62e43d into main Apr 29, 2026
56 of 58 checks passed
@Jarred-Sumner Jarred-Sumner deleted the farm/116443d4/filereader-onpull-drain-leak branch April 29, 2026 04:52
xhjkl pushed a commit to xhjkl/bun that referenced this pull request May 14, 2026
…h#29924)

## What

`FileReader.onPull` leaked the drained buffer when copying it into the
JS-provided pull array.

## Why

`drain()` moves ownership of `this.buffered` (or the reader's internal
buffer) into the returned `ByteList` via `moveFromList`, which resets
the source to empty. After the memcpy into the JS array, the old code
called `this.buffered.clearAndFree(...)` — a no-op on the
already-emptied list — so the `ByteList` returned by `drain()` was never
freed.

```zig
var drained = this.drain();                       // moves out of this.buffered
...
@memcpy(buffer[0..drained.len], drained.slice());
this.buffered.clearAndFree(bun.default_allocator); // no-op: already empty
// `drained` is dropped on the floor
```

The fix frees `drained` itself (capturing `.len` first since `deinit()`
invalidates the struct).

## Reachability

Most consumers route through a JS-side `handle.drain()` guard that
intercepts buffered data before `onPull` sees it. The branch is reached
when `onPull` is invoked directly while `this.buffered` is non-empty —
e.g. a `child_process` stdout `NativeReadable` whose `_read()` goes
straight to `ptr.pull()` after the first read, with the Node buffer
already full so `_read` wasn't auto-rescheduled between the
pending-fulfilling chunk and the next one.

## Test

`test/js/bun/spawn/spawn-stdout-iterate-leak.test.ts` constructs that
sequence with `cat` + 32 KiB writes + `highWaterMark = 32 KiB`, so every
cycle hits `onPull(32768) = 32768` (the memcpy branch). The fixture
subprocess samples RSS across five equal 1000-cycle runs and reports the
first-to-last delta.

The test is gated to debug/ASAN builds: under ASAN each orphaned 32 KiB
block is quarantined and cannot be reused, so the leak shows as clean
linear RSS growth. On release builds mimalloc recycles the orphaned
blocks into later same-size allocations, making RSS growth sub-linear
and too close to allocator noise to threshold reliably across all
platforms.

|           | RSS delta (first→last sample) |
|-----------|-----------|
| before (debug+ASAN) | 148–162 MB |
| after  (debug+ASAN) | −8 to 12 MB |

Threshold is 32 MB.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants