fetch: release the buffered response body and error the reader when a streaming response is aborted - #32662
Conversation
|
Updated 5:05 AM PT - Jul 22nd, 2026
✅ @robobun, your commit 4cf5f8078746ec54eee695af96347c9db476ad01 passed in 🧪 To try this PR locally: bunx bun-pr 32662That installs a local version of the PR into your bun-32662 --bun |
|
Found 3 issues this PR may fix:
🤖 Generated with Claude Code |
Walkthrough
ChangesFetch abort body memory leak fix
Possibly related PRs
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/web/fetch/fetch-abort-stream-leak-fixture.js`:
- Around line 25-27: The abort in fetch-abort-stream-leak-fixture.js is
currently gated only by Bun.sleep, which makes the test flaky and
timing-dependent. Update the fixture to wait for an observable buffered-body
condition before calling ac.abort(), using the existing fetch/stream setup in
the test instead of a fixed delay so the abort happens only after unread
response bytes are actually accumulating.
🪄 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: 120f271c-233c-44fb-94e6-7bcdac022264
📒 Files selected for processing (3)
src/runtime/webcore/ByteStream.rstest/js/web/fetch/fetch-abort-stream-leak-fixture.jstest/js/web/fetch/fetch-leak.test.ts
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@test/js/web/fetch/fetch-leak.test.ts`:
- Line 847: Remove the explicit per-test timeout from the fetch leak test so it
follows the repo policy and Bun’s default timeout handling. Update the test
definition around the fetch-leak case in fetch-leak.test.ts to no longer pass
120_000, and keep the existing test logic unchanged.
🪄 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: 481faf25-2686-495e-aa21-eb539e4d6c55
📒 Files selected for processing (1)
test/js/web/fetch/fetch-leak.test.ts
|
Reworked on current Fail-before / pass-after verified against the gate ( |
… streaming response is aborted When a streaming fetch() response is aborted via AbortController while bytes are buffered in the native ByteStream but no reader is waiting, the terminal Err result reached ByteStream::append's error arm, which stored the error in pending.result but left self.buffer intact. The buffered bytes were only reclaimed by on_cancel (reader.cancel()) or the GC finalizer, so an aborted response whose stream stayed reachable retained its unread body off-heap. on_pull also never consulted pending.result: once the buffer drained it returned Done, so a reader that kept reading after an abort saw a clean end of stream instead of the abort error. Release the buffered bytes in append's error arm (erroring a readable stream discards its queued chunks) and have on_pull surface the stored terminal error when the buffer is empty and the last chunk has been received.
e825d93 to
562c3bb
Compare
There was a problem hiding this comment.
Beyond the inline nit: confirmed all five earlier review threads are addressed in the current diff (helper inlined, ASAN_OPTIONS appended via filter/join, comments trimmed to the issue URL, per-test timeout removed, fixture now polls the server-side sent counter instead of a fixed sleep). On the runtime change I checked that streams::Result::is_done() includes Err(_), so has_received_last_chunk is always set before the stored error is consulted in on_pull, and that append is only reached from on_data — the new error-surfacing branch can't be bypassed by a non-empty buffer or an unset last-chunk flag.
Extended reasoning...
The author addressed every prior review comment (three of mine, two from CodeRabbit). This run's only new finding is a minor test-convention nit on expect(stderr).toBe("") — the same pattern already appears in three other tests in this file, so it's not blocking. I traced the two ByteStream.rs additions against is_done() and the append call graph to confirm the error is reachable from on_pull on every path that stores it; nothing further to flag, but leaving the semantics change (reader now rejects with AbortError instead of {done:true}) for a maintainer to sign off.
ASAN/debug builds can emit benign stderr warnings; the parsed stdout result and exit code already prove the child ran correctly.
If a regression ever stopped backpressure from stalling the server-side pull, the unbounded wait would hang until the runner killed it; a 1s cap lets the assertions report a meaningful failure instead.
There was a problem hiding this comment.
No issues found; all prior feedback (comment trimming, helper inlining, ASAN_OPTIONS append, exact-empty stderr, bounded poll loops) has been addressed. The 11-line native change is straightforward and mirrors the existing on_cancel/to_buffered_value patterns, but since it changes user-observable behavior on a hot fetch-streaming path (reader now rejects with AbortError instead of resolving {done: true}) and adds an RSS-threshold leak test, a human should sign off.
Checked: is_done() includes Err(_) so has_received_last_chunk is set and the new on_pull branch is reachable; the mem::replace swaps the stored error out exactly once so it isn't re-surfaced; the buffer clear in append(Err) matches on_cancel/finalize. Leak test branches its RSS bound on isASAN || isDebug and preserves harness ASAN_OPTIONS.
Extended reasoning...
Overview
Two additions to src/runtime/webcore/ByteStream.rs (11 lines total): (1) append()'s Err arm now clears + shrinks self.buffer before storing the terminal error in pending.result, matching the spec rule that erroring a readable stream discards queued chunks; (2) on_pull() now checks for a stored Err in pending.result when the buffer is empty and the last chunk has been received, returning it via mem::replace instead of Done. Plus a new fixture (fetch-abort-stream-leak-fixture.ts) and two tests in fetch-leak.test.ts: one behavioral (reader sees AbortError, drained bytes bounded) and one RSS-growth leak test over 60 aborts.
Security risks
None. No untrusted input parsing, no auth/crypto. The change releases memory earlier and surfaces an already-stored error to JS; it does not introduce new allocation, raw-pointer, or cross-thread state.
Level of scrutiny
Medium-high. ByteStream backs every streaming fetch() response body, so a mistake here would affect a very hot path. The change itself is small and mirrors byte-identical patterns already in on_cancel(), finalize(), and to_buffered_value() in the same file, and I confirmed StreamResult::is_done() includes Err(_) (streams.rs:701-710) so has_received_last_chunk is set when the error arrives, making the new on_pull branch reachable. The mem::replace(.., Done) consumes the stored error so a subsequent pull returns Done rather than a stale error. Still, this is a user-observable behavior change on a core Web API — a reader that previously drained to {done: true} after abort now rejects — and worth a human confirming that's the intended contract.
Other factors
This PR has been through five rounds of feedback (per-test timeout, comment length, single-use helper, ASAN_OPTIONS overwrite, exact-empty stderr assertion, unbounded poll loops) and each was addressed in a follow-up commit; all inline threads are resolved. The bug-hunting pass on the latest revision (35dd739) found nothing. The RSS leak test follows the file's established pattern (branches threshold on isASAN || isDebug, appends to bunEnv.ASAN_OPTIONS, drains stdout/stderr concurrently, asserts exit code last) and the readiness poll is now bounded at 200×5 ms. The remaining for (let i = 0; i < 5; i++) await Bun.sleep(1) after await serverAbort in the inline test is a short bounded settle, not a condition wait. Deferring solely because the touched path is critical enough that a maintainer should glance at the observable-behavior change and the RSS thresholds before merge.
Windows 11 aarch64 release measured 32.8MB of allocator retention over 60 aborts, just over the 30MB bound. The unfixed build retains >100MB here, so a flat 55MB bound keeps a wide detection margin on every lane.
There was a problem hiding this comment.
LGTM — all six rounds of feedback have been addressed and the bug hunter found nothing new this pass.
What was reviewed
append(Err)buffer release matches the existingon_cancel/finalizepattern;on_pull's stored-error surfacing is gated on empty-buffer +has_received_last_chunk(confirmedis_done()includesErr, so the guard is reachable).mem::replaceinon_pullmoves theErrout cleanly to the caller — ownership transfers, no double-release.- Tests: bounded poll loops, ASAN_OPTIONS appended, stderr ignored on the behavioral child, RSS bound (55 MB) documented against the measured Windows-release noise and well below the >100 MB unfixed leak.
Extended reasoning...
Overview
Two-site change in src/runtime/webcore/ByteStream.rs (13 lines): the append() error arm now clears+shrinks self.buffer before storing the terminal error, and on_pull() returns a stored Err instead of Done when the buffer is empty and the last chunk has arrived. Two new regression tests in fetch-leak.test.ts plus a fixture — one asserts the reader sees AbortError (not {done:true}) and drains <2 MB, the other holds 60 aborted responses and asserts RSS growth <55 MB.
Security risks
None. No new input parsing, no auth/crypto, no user-controlled sizes reaching allocation. The change releases memory earlier on an existing error path.
Level of scrutiny
Medium — it's the fetch streaming-body hot path and it changes user-observable behavior (readers now get an error instead of clean EOF after abort). But the change is spec-aligned (erroring a stream discards queued chunks), narrowly scoped, and the buffer-release pattern is byte-identical to on_cancel/finalize in the same file. I traced is_done() at streams.rs:701 to confirm it includes Err(_), so has_received_last_chunk is set on the error path and the new on_pull branch is reachable exactly when intended.
Other factors
This PR has been through three prior review rounds from me plus CodeRabbit, and every point has been addressed with a follow-up commit: per-test timeout removed, comment headers trimmed to the issue URL, the single-use helper inlined, ASAN_OPTIONS now appended via filter/join, the exact-empty stderr assertion dropped, and both backpressure poll loops capped at 200×5 ms. The PR description documents LSAN before/after (4.1 MB → 46 KB at 8 iterations, flat residue matching the cancel baseline) and confirms fetch.stream.test.ts and the existing abort tests pass unchanged. The latest commit (4cf5f807) widened the leak bound to 55 MB after Windows release-lane allocator noise measured 32.8 MB — the comment states the measured value, keeping the bound honest and still well under the >100 MB unfixed leak.
…40947) ### Problem - `test/js/web/fetch/body.test.ts` > "rejects a fetch textStream() when the connection drops after an empty decode" fails in the parallel CI batch with `received: ""` instead of `"A"`. The error code is the expected `ECONNRESET`. It passes when it runs alone. - The test server writes `"A"`, `0xF0`, `0x9F` (one `setImmediate` apart) and then destroys the socket. Under load, the HTTP thread sees all of that before the JS thread has read anything. `FetchTasklet::callback` coalesces the updates into one progress update with `fail` set, and `to_body_value` (`src/runtime/webcore/fetch/FetchTasklet.rs:1707`) or `on_body_received` (`:740`) turns the body into an errored stream. The buffered `"A"` is discarded by design. ### Fix - The server now waits until the client has consumed the first chunk before it writes the incomplete UTF-8 tail and drops the socket. A `Promise.withResolvers` resolved from the `for await` body is the handshake. A `finally` resolves it too, so the server still closes the socket if the stream ends early. - The drop still lands after the empty decodes (`0xF0`, `0x9F` decode to nothing), which is what the test guards: the stall fixed in #36180 surfaced as a hang, not a rejection. - Verified: 24 parallel loops of the old test body failed 1400 of 3600 iterations. The new body: 0 of 3600. With `bun test` under the same load: old 32 of 192 failures, new 0 of 192 (release) and 0 of 60 (debug build). The whole file passes with `bun bd test`. ### Background - `res.textStream()` on a fetch response is a native `ByteStream` source in text mode. The HTTP thread delivers body bytes and the terminal result to the JS thread through `FetchTasklet`. - `FetchTasklet::callback` posts at most one task at a time (`has_schedule_callback`). Results that arrive before the JS thread runs that task are merged: body bytes are appended to `scheduled_response_buffer`, and the latest `fail` or `has_more` wins. - When the merged update carries the response head and a failure, the body is `BodyValue::Error` and the bytes are dropped. When it carries a failure after the head was already delivered, `ByteStream::on_data(Err)` rejects the pending pull or, with no pull pending, `append(Err)` clears the buffer (#32662). Whether the consumer sees the bytes before the error depends on timing. <details><summary>Notes</summary> - Repro: `/tmp/repro-textstream.ts` style loop (the test body, 150 iterations) run 24 times in parallel on a 16-core box. Failure rate 24% to 59% per process, all with `received: ""` and `code: "ECONNRESET"`. The same loop with the handshake: 0 failures in 3600 iterations. - The single-process loop (no load) passes 300 of 300 with the old body, which is why the test passes alone. - The other cases in the same `test.each` table end with a clean `0\r\n\r\n`. A coalesced successful end delivers the bytes (`TemporaryAndDone`), so they are not affected. - The data-then-error order is timing dependent in bun for any streaming fetch that fails mid-body. Browsers deliver the bytes received before the error first. This PR does not change that behavior. It only removes the dependence from the test. </details> <!-- robobun:evidence:begin --> --- **no test proof** · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/fetch/body.test.ts <!-- robobun:evidence:end -->
Fixes #32659.
Aborting an in-flight streaming
fetch()viaAbortControllerretained the received-but-unread response body in off-heap memory while the response stream stayed referenced, and the reader saw a clean{ done: true }instead of the abort error.Cause
The response body streams into a native
ByteStreamwhosebuffer: Vec<u8>holds received-but-unread bytes. When the fetch is aborted, the HTTP thread's failure callback deliverson_data(StreamResult::Err), which (with no reader waiting) falls through toByteStream::append's error arm:self.bufferis left intact, andon_pullnever consultspending.result: once the buffer drains it returnsDone. So a reader that kept reading drained the retained bytes and then saw end-of-stream; a reader that stopped (the proxy-on-client-disconnect pattern) retained the buffer until GC finalization.reader.cancel()on the same body reacheson_cancel, which does clear the buffer, so it did not leak.Fix
In
append's error arm, release the buffered bytes (erroring a readable stream discards its queued chunks per spec). Inon_pull, surface the stored terminal error when the buffer is empty and the last chunk has been received, instead of reportingDone.Verification
LSAN on the reporter's serial repro (debug+ASAN,
detect_leaks=1):The residue matches
cancelmode (~48 KB) and is flat across iteration counts, i.e. exit-time noise rather than a per-request leak.New tests in
test/js/web/fetch/fetch-leak.test.ts:{ done: true }; now it drains only the JS-side queue (~260 KB) and rejects withAbortError.fetch.stream.test.ts,fetch-stream-cancel-leak.test.tsand the fetch abort tests pass unchanged.Not covered here
When the body is fully received before
response.bodyis accessed,.bodyproduces aByteBlobLoader(not aByteStream), andabort()never reaches it because theFetchTasklet's signal listener is gone by then. That path retains the body only until theResponsebecomes unreachable (GC-bounded), which currentmainalready handles, and wiringAbortSignalto a completed response's body is a separate lifecycle change.no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/web/fetch/fetch-leak.test.ts