Skip to content

fetch: release the buffered response body and error the reader when a streaming response is aborted - #32662

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/b85e3894/fetch-abort-stream-leak
Jul 22, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/b85e3894/fetch-abort-stream-leak

Conversation

@robobun

@robobun robobun commented Jun 24, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #32659.

Aborting an in-flight streaming fetch() via AbortController retained 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 ByteStream whose buffer: Vec<u8> holds received-but-unread bytes. When the fetch is aborted, the HTTP thread's failure callback delivers on_data(StreamResult::Err), which (with no reader waiting) falls through to ByteStream::append's error arm:

streams::Result::Err(err) => {
    if self.buffer_action.get().is_some() { panic!(...); }
    self.pending.with_mut(|p| p.result = streams::Result::Err(err));
}

self.buffer is left intact, and on_pull never consults pending.result: once the buffer drains it returns Done. 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 reaches on_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). In on_pull, surface the stored terminal error when the buffer is empty and the last chunk has been received, instead of reporting Done.

Verification

LSAN on the reporter's serial repro (debug+ASAN, detect_leaks=1):

iterations before after
8 4,183,934 B leaked 46,782 B
32 15,115,422 B leaked 36,326 B

The residue matches cancel mode (~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:

  1. Single abort with the body buffered, then reads: before the fix the reader drained ~1 MB and got { done: true }; now it drains only the JS-side queue (~260 KB) and rejects with AbortError.
  2. 60 aborts with every response and reader held: RSS growth ~95 MB before, ~50 MB after (threshold 55 MB under ASAN).

fetch.stream.test.ts, fetch-stream-cancel-leak.test.ts and the fetch abort tests pass unchanged.

Not covered here

When the body is fully received before response.body is accessed, .body produces a ByteBlobLoader (not a ByteStream), and abort() never reaches it because the FetchTasklet's signal listener is gone by then. That path retains the body only until the Response becomes unreachable (GC-bounded), which current main already handles, and wiring AbortSignal to 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

@robobun

robobun commented Jun 24, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:05 AM PT - Jul 22nd, 2026

✅ @robobun, your commit 4cf5f8078746ec54eee695af96347c9db476ad01 passed in Build #77642! 🎉


🧪   To try this PR locally:

bunx bun-pr 32662

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

bun-32662 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Native RSS grows linearly under sustained AWS SDK v3 Kinesis GetRecords; identical Node 22 workload is flat #30415 - AWS SDK v3 Kinesis streaming fetch shows linear off-heap RSS growth with flat heap, matching the ByteStream buffer retention pattern
  2. Memory leak with streaming downloads from azure blob storage #7272 - Azure Blob Storage SDK streaming download leaks memory proportional to file size via fetch, consistent with unreleased ByteStream buffers
  3. Memory leak when downloading file (both @google-cloud/storage and Bun's S3) #20487 - Google Cloud Storage SDK streaming download shows same pattern: RSS never reclaimed, heap flat, off-heap retention

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

Fixes #30415
Fixes #7272
Fixes #20487

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 24, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

ByteStream now clears buffered body capacity on append errors. A new fixture repeatedly aborts streaming fetches while retaining objects to expose retained RSS growth, and a regression test runs that fixture against a never-ending server response.

Changes

Fetch abort body memory leak fix

Layer / File(s) Summary
ByteStream release_buffer fix on error path
src/runtime/webcore/ByteStream.rs
Adds release_buffer(&self) to clear and shrink the internal buffer, and calls it from ByteStream::append when streaming append returns an error.
Fixture and regression test for abort body leak
test/js/web/fetch/fetch-abort-stream-leak-fixture.js, test/js/web/fetch/fetch-leak.test.ts
Adds a stress fixture that aborts in-flight streaming fetches, measures RSS growth after GC, and exits nonzero on excess growth; adds a regression test that spawns the fixture against a never-closing streamed response and checks the reported output and exit status.

Possibly related PRs

  • oven-sh/bun#31963: Modifies ByteStream::append and parked pending.result error-release logic in src/runtime/webcore/ByteStream.rs, directly overlapping with the same error-handling path changed in this PR.

Suggested reviewers

  • Jarred-Sumner
  • cirospaciari
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The fix releases buffered bytes on abort and the regression test checks RSS stays bounded, matching #32659.
Out of Scope Changes check ✅ Passed The changes are in scope: one runtime fix and one regression test for the abort-leak issue.
Title check ✅ Passed The title clearly summarizes the main change: releasing buffered response body data and surfacing abort errors for streaming fetches.
Description check ✅ Passed The description explains the bug, fix, and verification steps, though it uses custom headings instead of the template's exact sections.

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

@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 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

📥 Commits

Reviewing files that changed from the base of the PR and between 15bc359 and 54c99af.

📒 Files selected for processing (3)
  • src/runtime/webcore/ByteStream.rs
  • test/js/web/fetch/fetch-abort-stream-leak-fixture.js
  • test/js/web/fetch/fetch-leak.test.ts

Comment thread test/js/web/fetch/fetch-abort-stream-leak-fixture.js Outdated

@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 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

📥 Commits

Reviewing files that changed from the base of the PR and between 54c99af and e825d93.

📒 Files selected for processing (1)
  • test/js/web/fetch/fetch-leak.test.ts

Comment thread test/js/web/fetch/fetch-leak.test.ts Outdated
Comment thread test/js/web/fetch/fetch-leak.test.ts Outdated
Comment thread src/runtime/webcore/ByteStream.rs Outdated
Comment thread test/js/web/fetch/fetch-leak.test.ts Outdated
@robobun

robobun commented Jun 24, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reworked on current main. The earlier "don't merge" concern no longer applies: the append(Err) arm is reached for the streaming path (traced with scoped logging), and the patch now also makes on_pull surface the stored error so the reader sees AbortError instead of { done: true }.

Fail-before / pass-after verified against the gate (git stash -- src/ && bun bd test ...). The fully-buffered ByteBlobLoader path is noted in the PR body as out of scope.

… 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.
@robobun
robobun force-pushed the farm/b85e3894/fetch-abort-stream-leak branch from e825d93 to 562c3bb Compare July 22, 2026 06:15
@robobun robobun changed the title fetch: release the buffered response body when a streaming response is aborted fetch: release the buffered response body and error the reader when a streaming response is aborted Jul 22, 2026

@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.

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.

Comment thread test/js/web/fetch/fetch-leak.test.ts Outdated
ASAN/debug builds can emit benign stderr warnings; the parsed stdout result
and exit code already prove the child ran correctly.
Comment thread test/js/web/fetch/fetch-abort-stream-leak-fixture.ts
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.

@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.

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.

@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 — 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 existing on_cancel/finalize pattern; on_pull's stored-error surfacing is gated on empty-buffer + has_received_last_chunk (confirmed is_done() includes Err, so the guard is reachable).
  • mem::replace in on_pull moves the Err out 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.

@Jarred-Sumner
Jarred-Sumner merged commit 4b72416 into main Jul 22, 2026
53 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/b85e3894/fetch-abort-stream-leak branch July 22, 2026 23:04
Jarred-Sumner pushed a commit that referenced this pull request Aug 30, 2026
…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 -->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

2 participants