fetch: error the response body stream when a fully-buffered response is aborted - #35093
Conversation
…is aborted Fetch spec 'abort a fetch' step 4 requires erroring response's body stream with the abort reason. FetchTasklet detaches its abort listener once the body is fully received, so aborting after that was a no-op on the Response body: a ByteBlobLoader-backed stream drained the full body and the backing store was only released by GC finalization. Give the Response its own abort-signal listener (attached in FetchTasklet::on_resolve, detached in Response::destroy) that errors any existing body stream and replaces the body with the abort reason. Adds a ReadableStream__error FFI export so a native-backed stream can be put into the errored state (rejecting pending reads) rather than closed. Follow-up to #32659.
WalkthroughChangesThe PR adds native and Rust Fetch abort propagation
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 7:13 AM PT - Jul 28th, 2026
❌ @robobun, your commit bb33c55 has some failures in 🧪 To try this PR locally: bunx bun-pr 35093That installs a local version of the PR into your bun-35093 --bun |
…unref cancel_all_timeout_objects (pre-destructOnExit) releases the extra ref that AbortSignal::timeout() took. clean_native_bindings on the last listener of a timeout signal also releases it via eventListenersDidChange, so dropping the Response listener in the exit sweep was a second release when m_timeout is still set. pending_activity suppresses that path, matching FetchTasklet's clear_abort_signal. Also: keep each get_body_value() borrow statement-scoped (R-2), run the AbortSignal leak check in a subprocess.
There was a problem hiding this comment.
No bugs found; earlier findings (R-2 aliased &mut BodyValue, missing heapStats leak coverage, ASAN timeout) are all addressed. Deferring to a human because this adds a new native-refcount lifetime edge (Response → AbortSignal via BodyAbortListener) whose correctness depends on the pending_activity_ref / cancel_all_timeout_objects / eventListenersDidChange teardown ordering — worth a maintainer eye on that specific interaction.
What was reviewed:
BodyAbortListener::dropmatchesFetchTasklet::clear_abort_signalorder (clean_native_bindings→pending_activity_unref→unref);attach_abort_signalmatches the tasklet's setup order.on_abortre-derivesget_body_value()per statement (no held&mutacrossget_body_readable_stream/readable.error()); copiesresponse/globalout of*ctxand brackets withref_/unrefso re-entrantnative_responseunref cannot free the box mid-call.ReadableStream__errormirrorsReadableStream__cancelWithReason's catch-scope pattern;webStreamControllerErroron an already-errored/closed stream is a no-op, so double-fire (tasklet listener + response listener both attached mid-stream) is benign.- Leak test uses
heapStats().objectTypeCounts.AbortSignalin a subprocess with theisASAN ? 30_000 : 5_000timeout, and exercises exit-teardown with a pendingAbortSignal.timeout().
Extended reasoning...
Overview
Adds a Response-owned native AbortSignal listener so aborting after the fetch body is fully buffered errors the body stream and releases the store, per Fetch spec "abort a fetch" step 4. Touches: a new C++ FFI export ReadableStream__error + Rust ReadableStream::error(); new BodyAbortListener struct on Response with Drop, an unsafe extern "C" callback, and an attach_abort_signal unsafe fn taking *mut Response; a call site in FetchTasklet::on_resolve; behavior tests in fetch-abort-stream-body.test.ts and a heapStats leak test in fetch-leak.test.ts.
Security risks
None identified — no user-controlled parsing, no auth/crypto/permission surfaces. The change is internal lifetime management on the JS thread.
Level of scrutiny
High. This is the memory-safety category REVIEW.md flags as most-blocked: a new intrusive-refcount edge (AbortSignal::ref_/unref + pending_activity_ref/unref), a raw *mut Response backref stored in a heap box, an FFI callback that runs JS and must survive re-entrant teardown, and GC-finalizer / exit-sweep ordering (the cancel_all_timeout_objects justification for taking pending_activity_ref). The pattern does mirror FetchTasklet::clear_abort_signal and other in-tree AbortSignal listener owners (subprocess, RequestContext), which reduces risk, but the specific claim that pending_activity_ref is required to avoid a double-unref of a timeout signal during destructOnExit is subtle enough that someone who owns that machinery should confirm it.
Other factors
Three prior review rounds' findings are all resolved: the R-2 aliased-&mut BodyValue was fixed by re-deriving per statement (7733604); the leak-claim now has a heapStats subprocess test; the ASAN timeout was added (786fdb5) and behavior assertions moved next to existing buffered-body abort coverage. Tests cover the four Node-compat cases from the PR table plus custom-reason passthrough plus the exit-teardown path with a pending AbortSignal.timeout(). on_resolve runs synchronously on the JS thread after the tasklet's own abort check, so add_listener won't fire the callback synchronously here; even if it did, the box's fields are populated before registration and on_abort never reads response.abort_listener, so ordering is safe. The double-listener window (tasklet + response both attached while streaming) errors the stream twice at worst, which webStreamControllerError no-ops on the second call, and to_error_instance is guarded by the BodyValue::Error exclusion.
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Is there a simpler & more reliable fix?
|
I considered a few alternatives; here's why I landed on the listener. Lazy check (no listener): store Keep the The listener as written is the One real simplification I can offer: drop |
Jarred-Sumner
left a comment
There was a problem hiding this comment.
Can we use a better RAII type than manually incrementing/decrementing a ref count?
|
Switched to |
There was a problem hiding this comment.
Beyond the inline nit, two candidates were examined and ruled out: (1) on_abort double-processing a Locked body — readable.error() transitions the JS stream but does not itself flip BodyValue, so the trailing to_error_instance is the single state write; (2) ReadableStream__error being a no-op on a NativePending stream — get_body_readable_stream returns None for that case (no cached stream, body not Locked), so to_error_instance alone handles it and later .body access builds an already-errored stream.
Extended reasoning...
This run's remaining finding is a narrow race (queued success task clobbering the Error body variant) and is a nit — the primary observable is now correct. The two ruled-out candidates above were the other concerns raised against the current on_abort shape after the R-2 re-derive fix in 7733604 and the AbortSignalRef/scopeguard refactor in 475bd90. Deferring rather than approving because the change adds a new Response→AbortSignal lifetime edge with paired ref/pending_activity_ref bookkeeping and a re-entrant JS call inside a native abort callback, and the author's Jul 22 comment leaves an open design choice (keep ReadableStream__error for Node parity vs. drop it and let the second read see {done:true}) that a maintainer should weigh in on.
There was a problem hiding this comment.
No further issues found — the four earlier review rounds (R-2 aliasing in on_abort, heapStats leak coverage, ASAN timeout, on_body_received clobber guard) are all addressed. Deferring to a human because this adds a new refcount/lifetime edge (Response → AbortSignal via BodyAbortListener, with pending_activity_ref ordering against cancel_all_timeout_objects) and the author left an open design question about dropping ReadableStream__error in favor of cancel-only.
What was reviewed:
BodyAbortListenerbox address stability acrossadd_listener→abort_listener.set→Drop::clean_native_bindings— heap-stable, ctx pointer matches.on_abortre-entrancy:Response::ref_+ scopeguard keeps the box's owner live acrossreadable.error();get_body_value()borrows are now statement-scoped.- Teardown pattern matches
FetchTasklet::clear_abort_signal(clean_native_bindings→pending_activity_unref→ field-dropunref);Response::destroyclears the listener. on_body_receivedearly-return when body ≠Locked—scheduled_response_buffertake moved after the guard so bytes aren't dropped on the floor for the still-Lockedpath only.
Extended reasoning...
Overview
Adds a Response-owned AbortSignal listener so aborting a fetch() after the body is fully buffered still errors the body stream (Fetch spec §abort-a-fetch step 4). New ReadableStream__error FFI + Rust wrapper; new BodyAbortListener struct holding an AbortSignalRef (+1) and a raw *mut Response backref, installed in FetchTasklet::on_resolve and torn down in Response::destroy. on_body_received gains a guard so a queued success callback can't overwrite an Error body the abort listener installed. Tests cover the four behavior cases and a heapStats-based AbortSignal leak check with an AbortSignal.timeout() exit-teardown probe.
Security risks
None. This is fetch-abort semantics; no untrusted-input parsing, auth, or crypto.
Level of scrutiny
High. This is native memory-safety code in the most-blocked review category: intrusive C++ refcounting (ref_/unref/pending_activity_ref), a raw-pointer backref whose validity depends on the box living inside its referent, JS re-entrancy inside a native abort callback, and Drop ordering that interacts with cancel_all_timeout_objects during destructOnExit. The pattern is copied verbatim from FetchTasklet::clear_abort_signal, which reduces risk, but a maintainer familiar with the AbortSignal/timeout teardown path should confirm the pending_activity_ref bracketing is the right shape here too.
Other factors
- Four prior review rounds; every finding addressed with a commit and the author's explanations are precise.
- The author's design-alternatives comment offers to drop
ReadableStream__error(cancel-only,{done:true}instead of reject on the second-read case) — that's a Node-compat vs. simplicity tradeoff a maintainer should decide. #[repr(C)] Responsegains a trailing field; C++ only holds it as an opaquem_ctxpointer, so no layout mirror to update.- Tests follow harness conventions (subprocess,
isASANtimeout branch, heapStats baseline+delta with slack).
|
CI status for bb33c55: the diff is green on every lane that ran, including x64-asan. Build #84083's only non-flaky red is |
There was a problem hiding this comment.
No issues found on this pass — all four prior findings (the R-2 get_body_value() overlapping borrow, the missing heapStats leak assertion, the ASAN timeout, the on_body_received race guard, and its buffer_reset ordering) are addressed in the current diff. That said, this adds a new Response→AbortSignal refcount edge with a ParentRef backref, re-entrancy handling in on_abort, and a new FFI export, so it's worth a human sign-off (Jarred already weighed in on the pointer type).
What was reviewed:
BodyAbortListener::on_abortre-entrancy: fields copied out before JS runs;ref_/scopeguard keeps the Response alive acrossreadable.error();get_body_value()re-derived per statement.Dropordering:clean_native_bindingsbeforepending_activity_unref;AbortSignalReffield-drop releases the +1;destroy()setsabort_listenertoNone.on_body_receivedguard now sits abovebuffer_reset.set(false)so the defer still resets the buffer on the abort-raced early return.ReadableStream__errormirrors the__cancelexception-scope pattern; leak test usesheapStatsin a subprocess with the ASAN timeout.
Extended reasoning...
Overview
Adds a Response-owned AbortSignal listener so aborting after the body is fully buffered still errors the stream (Fetch spec "abort a fetch" step 4). Touches Response.rs (new BodyAbortListener struct + Drop, new abort_listener field, attach_abort_signal, destroy teardown), FetchTasklet.rs (attach in on_resolve; early-return guard in on_body_received so a queued success callback can't overwrite an Error body), ReadableStream.rs + WebStreamsExports.cpp (new ReadableStream__error FFI + error() method), and two test files.
Security risks
None user-facing. The risk surface is memory safety: a new intrusive-refcount edge (AbortSignalRef + pending_activity_ref/_unref), a parent backref (ParentRef<Response>), and a callback that can re-enter JS and reach Response::unref. All of these were iterated on across five commits in response to review.
Level of scrutiny
High. This is native GC-adjacent lifetime code — REVIEW.md's most-blocked category. The on_abort path copies state out of a box that could otherwise be freed mid-call, holds a scopeguard keepalive on the Response, runs webStreamControllerError (which rejects user promises and can synchronously re-enter host fns), then mutates the body. The pending_activity_ref pairing exists specifically for the AbortSignal.timeout() exit-teardown ordering, mirroring FetchTasklet::clear_abort_signal. These are the right patterns, but they warrant a maintainer's eyes.
Other factors
- Jarred already engaged (asked for an RAII pointer type; addressed with
ParentRefin 850c348) but has not approved. - Five fix commits landed in response to review; every thread is resolved.
- CI on 2878c52 was green on all completed lanes per robobun; b296b3d is the current head with a build in progress.
- Tests cover the four behavioral cases against Node, plus a
heapStats-based AbortSignal leak check and theAbortSignal.timeout()exit-teardown path.
What I can still report, correctedWe are a Bun-based multi-provider AI proxy (tombii/better-ccflare, leak tracked in #273). Re-reading this PR properly: the fix is broader than its title suggests. Why it happens not to help our case — offered as a data point about a real-world consumer, not as a criticism of the fix: Our retention comes from responses that are discarded without ever being aborted. On a failover or retry we obtain a So for this specific shape the buffer stays owned by the Our mitigation is application-side: explicitly draining discarded bodies in 16 KiB chunks, which closes the source deterministically instead of waiting for finalisation. Within-build on Bun 1.3.2 that measured 142.7 → 18.8 KB/req, and ~2.6x faster than If a "response dropped without abort or read" case is considered in scope for a future change, we would be glad to test it — we have the workload and can measure against a canary build. Happy to share the (now-fixed) harness if useful. |
|
Thanks — reproduced locally with a similar harness (500 sequential fetches of a 73,015-byte body from a local server, RSS delta / N, 10 interleaved rounds, medians below). One row your table is missing changes the read: a plain canary of
The |
|
Thanks for the measurements. Agreed on attribution: this PR only touches the |
|
@robobun fix conflicts |
ccflare-41 shipped the prior mitigation (cancelDiscardedResponseBody) on branch `fix/bun-leak-273-cancel-discarded-bodies` (commit 7f99aba) with body.cancel() as the primitive. ccflare-42 then measured that on every released Bun (1.3.2, 1.3.14, and earlier), body.cancel() is a NO-OP — the leak measured at 78-83 KB/req with cancel, indistinguishable from no cancel at all. The mitigation was inert on every Bun a user can install. The fix is to drain the body instead, which actually closes the upstream source and frees the off-heap buffer on stock Bun. This commit re-uses ccflare-41's 12-site classification verbatim (the same 8 return-null discard sites + 4 retry-loop overwrite sites in proxy-operations.ts) and replaces the body.cancel() primitive with a chunked drain loop (drainBody). Function name and call-site pattern preserved so the negative-control static check still works. Tradeoff measurement (bench/drain-strategy-harness.ts, Bun 1.3.2, 73 015-byte pinned body, N=500): arrayBuffer(): 64.94 KB/req RSS growth, 247.9 ms/iter chunked 16 KiB drain: 18.78 KB/req RSS growth, 93.8 ms/iter chunked drain wins: 71% less RSS growth, 2.6x faster. arrayBuffer() materialises the entire body in V8 heap before releasing the native source — defeats the point of the fix on large responses. Chunked drain reads in 16 KiB chunks; each chunk is released as soon as the next is read, so the concurrent peak is bounded to one chunk (~16 KiB) regardless of body size. Real-network harness measurement (BUN_LEAK_273_RUN=1, Bun 1.3.2, MiniMax API): [harness/no-drain] rss per req: 142.7 KB (Bun 1.3.2 runs) [harness/with-drain] rss per req: 29.6 KB Ratio: 4.82x reduction on stock Bun — drain closes the upstream source, releasing the buffer that body.cancel() left pinned. Why drain and not `body.cancel()` (ccflare-42 measurement): Bun 1.3.2: cancel() leaks 83.34 KB/req, drain reduces ~85% Bun 1.3.14: cancel() leaks 78.54 KB/req, drain reduces ~85% The upstream PR oven-sh/bun#35093 makes cancel() work; on a PR-shipped Bun the difference disappears. The ccflare fix runs on every Bun regardless of whether the upstream PR has shipped. HARD BOUNDARY preserved: only the 12 discard sites are wired. The streaming forwarder, the non-streaming forwarder, and the `withSanitizedProxyHeaders(rawResponse)` model-not-found pass-through are all untouched. Draining a body mid-forward would re-introduce the silent-stream-truncation bug class this repo's `fix/silent-stream-truncation` branch shipped to repair. Helper is safe on null body, locked body, and already-drained body. The drain is fire-and-forget — we do not await, because the failover path should return `null` as fast as possible and the ReadableStream spec guarantees the source is released as soon as the reader reaches done. Three tests cover the helper, call-site coverage, and live-stream safety (bun-leak-273-regression.test.ts, bun-leak-273-safety.test.ts). A fourth (bun-leak-273-harness.test.ts, gated by BUN_LEAK_273_RUN=1) runs the real-network RSS measurement. Negative-control transcript (verbatim, on Bun 1.3.2, this worktree): STATE A (helper intact, drain call present): regression test: 9 pass, 0 fail safety test: 3 pass, 0 fail harness: 2 pass, 0 fail (informational — see below) STATE B (drain call removed from helper): regression test: 8 pass, 1 fail FAIL: Group A > PRODUCTION helper reads the body to done Expected: done=true after drain Received: done=false (drain removed, body still readable) safety test: 3 pass, 0 fail harness: 2 pass, 0 fail (informational) STATE C (helper restored): regression test: 9 pass, 0 fail safety test: 3 pass, 0 fail harness: 2 pass, 0 fail The regression test Group A is the precise negative control — removing the drain call makes it cleanly RED on the first read of the body post-helper-call. The safety test confirms the fix does not touch the forward path. The harness has no strict ratio assertion because Bun 1.3.2's mimalloc reclaims the off-heap buffer in unpredictable bursts during the measurement window — same-process ratio varies 0.5x to 5x across runs on otherwise-identical inputs. The controlled measurement is in bench/drain-strategy-harness.ts (chunked drain is 71% smaller than arrayBuffer and 2.6x faster on a fixed body). The harness logs the absolute per-request RSS growth so an operator can see the magnitude of the leak + drain effect on stock Bun. Acceptance: bun test packages/proxy/src/__tests__/ → 12 pass, 1 skip (harness gated), 0 fail Co-Authored-By: Claude <noreply@anthropic.com>
…ing reader.cancel() Bun's reader.cancel() is a no-op on fetch response bodies (oven-sh/bun#35093), so cancelling teeStream's output on client disconnect never released the upstream response's native buffer — causing multi-GB RSS growth over time (tombii#382). Drain the reader to completion instead, matching the pattern already used in discard-body-cancel.ts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bun's reader.cancel() is a documented no-op (oven-sh/bun#35093) that never releases the native off-heap buffer backing a fetch() response body. PR tombii#392 fixed this in stream-tee.ts for client-disconnect cancellation, but three more call sites had the same pattern and kept leaking after that fix shipped: - anthropic-terminal-recovery.ts's cancelUpstream() cancelled the reader not just on client disconnect but on every normal recovery/timeout completion, abandoning unread upstream bytes. - extractUsageInfo() in both base-anthropic-compatible.ts and anthropic/provider.ts calls reader.cancel() in a finally block that runs on essentially every streaming request, since its read loop typically breaks early once usage data is found. - transformStreamToOpenAIFormat()'s stream cancel() handler in anthropic/provider.ts had the same disconnect-triggered leak as the original stream-tee.ts bug. All four now drain the reader to done instead of cancelling it, matching the fix already established in stream-tee.ts and handlers/discard-body-cancel.ts. Fixes tombii#382
response.body?.cancel() is a documented no-op on every released Bun (oven-sh/bun#35093) and never releases the native buffer backing the response. Drain to done via the existing drainReader() helper instead, matching the pattern already used in the Anthropic provider. Fixes tombii#382. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bun's ReadableStream.cancel() is a no-op (oven-sh/bun#35093), so discarding the upstream response body via reader.cancel() in cancelUpstreamOnce left the underlying connection open, leaking memory over time. Replace it with a bounded drainUpstream() helper that reads the stream to completion (or an abort deadline via CODEX_STREAM_DRAIN_DEADLINE_MS), mirroring the same pattern already applied to the OpenAI provider in 2344d63. Threads a new optional drainAbort AbortController through Provider.processResponse (4th param) so proxyWithAccount can bound the drain when tearing down a stream. Fixes tombii#382
The module comment asserted, in the present tense, that cancelling a discarded fetch body does not reliably return Bun's native allocation and that only reading to EOF does. That was measured on Bun 1.3.x and no longer reproduces on the runtime we actually deploy. Re-measured on 1.4.0-canary.1+8326d1bd3 with the arms interleaved, 5 x 400 requests, medians: cancelling a fully-unread body retains 0.21 KB/req and a near-EOF body -0.24 KB/req, against a positive control where the same harness reports a known 71 KB/req retention as 75 KB/req. Measuring the arms sequentially instead produces a spurious ~3 KB/req penalty for cancel, because each arm then samples a different point on the process's RSS growth curve. Bun closed the gap upstream: body.cancel() fell from ~93 KB/req on 1.3.2 to ~9 KB/req on main, and that landed before oven-sh/bun#35093, which is about AbortController.abort() rather than cancel(). The distinction is worth writing down because it is already being misread in the wild: better-ccflare v3.5.59 rewrote two call sites from cancel to drain citing that issue as evidence that cancel is a no-op on every released Bun. Behaviour is unchanged. Drain-then-cancel stays, because it measures at or below plain cancel here so there is nothing to win by reverting, and it is the only variant that is also correct on the 1.3.x runtimes this code may be pinned back to. The tee-branch half of the comment is re-confirmed rather than scoped: an awaited cancel on a discarded branch whose twin has never been read still fails to settle. A twin that is actively being read settles in about 5ms, which is why that hazard hides in tests, so the comment now names the shape that hangs.
…nstead of ending it (#42125) ### Problem - After `req.clone()` in `Bun.serve`, or `res.clone()` on a `fetch()` response, a reader on the original body ends with `{ done: true }` when the body fails mid-stream. 100 KB of an announced 160 KB reads as complete. The clone, and an un-cloned body, reject. - Cause: `Body::Value::to_error_instance` (`src/runtime/webcore/Body.rs:1366`) errors a native `ByteStream` but cancels any other stream. After `clone()` the body holds a tee branch, and cancel closes it. ### Fix - `to_error_instance` calls `ReadableStream::error()` with the body's error instead. Every caller is a producer that reports a failed body, so none wants a clean end. - The streamed `maxRequestBodySize` arm in `RequestContext.rs` now rejects through the body first, as the buffering arm does. Otherwise the original's branch got `The connection was closed.` and the clone got `Request body exceeded maxRequestBodySize`. - Self-reviewed: 2 concerns raised, 1 addressed (the parity fix above), 1 declined (see Notes). - Verified: six new tests in `test/js/web/fetch/body-clone.test.ts`. The three original-side tests fail on main `b5ba14b6` and pass here. Neighbouring suites stay green (list in Notes). ### Background - A streamed body is `Value::Locked` and holds a `ReadableStream`. For an incoming request or a fetch response its source is a native `ByteStream`. - `clone()` tees that stream: the body keeps branch 1, the clone gets branch 2. When the body fails, the server or client errors the source. The tee forwards that to both branches, one microtask after `to_error_instance` ran on branch 1. - `cancel()` is the consumer-side end: pending reads resolve `done: true`. `error()` is the producer-side failure: pending reads reject. The fetch abort listener already uses it (#35093). <details><summary>Notes</summary> - Found while re-checking the `req.clone()` abort work from #42017. That PR settles the native source so the clone's branch rejects. The original's branch still went through the `abort()` arm here. - Callers of `to_error_instance`: `RequestContext::end_request_streaming` and the two `maxRequestBodySize` arms, `FetchTasklet` on failure, the fetch `AbortSignal` listener in `Response.rs`, `HTMLRewriter` `fail()`. - Repro on main `b5ba14b6` (release and debug+ASAN), 102400 of 163840 bytes delivered, then the peer goes away. `Bun.serve` + `req.clone()`, reader on `req.body`: `done:true` at 102400. `fetch()` + `res.clone()`, reader on `res.body`: `done:true` at 102400. Reading the clone instead, or not cloning: rejects (`AbortError: The connection was closed.` / `ECONNRESET`). With the fix all of them reject. A `fetch()` aborted through its `AbortSignal` already rejected on both sides (the abort listener errors the branch itself). - The six tests: serve client disconnect, serve chunked upload over `maxRequestBodySize`, fetch server disconnect, each for the original and the clone. The clone-side cells pass on main too and pin the symmetry. - In the streamed cap arm the byte stream is errored through the held ref only when the body did not already reach it (`has_received_last_chunk`), the same guard `end_request_streaming` uses since #42017, so the un-cloned path does not see a second error. - `HTMLRewriter`: `transform(res).clone()` with a failing input already rejected on both sides before this change, because `fail()` errors the output `ByteStream` directly and the tee forwards it. No change there. - Declined review concern: rename `ReadableStream::abort()` (which cancels) at its three remaining callers. It stays. Those callers (`FetchTasklet::start_request_stream` on an already-aborted signal, `RequestContext::on_abort` for the response body, `FileSink::handle_reject_stream`) are consumers cancelling their source, which is what cancel is for. - Erroring a tee branch from outside the tee is safe: `readableStreamDefaultControllerEnqueue`/`Close` check `CanCloseOrEnqueue` and `readableStreamDefaultControllerError` returns early on a non-readable stream, so the tee's later chunk, close, and error steps for that branch are no-ops. `ReadableStream__error` goes through `webStreamControllerError`, which is a no-op on a closed or errored stream. - On the server disconnect path the original and the clone reject with two distinct `AbortError` objects (one from the body error, one from the source error through the tee). That was already true for a pending `.text()` on the original versus a read on the clone. - Suites run on the debug+ASAN build: body-clone (85), html-rewriter (180), serve-body-leak (15), http-server-chunking (13), serve-http2-lifecycle (23), fetch-file-upload (11), fetch-abort-stream-body, body-mixin-errors, textstream-wpt, serve-pending-promise-abort-leak (27), regression/22353, `serve.test.ts -t "request body|streaming|abort|clone|maxRequestBodySize"`. - Local-only failures seen while running neighbouring suites, identical on the unfixed release build in this container: `fetch.stream.test.ts` "Content-Length response works (multiple parts)" (5 s timeouts when the eight variants run concurrently under debug+ASAN, 0.8 s alone), `express-memory-leak.test.ts` (20 s budget, the body-less variant alone takes 19.9 s here), and `fetch.test.ts` "abort should work even if the socket was closed before the redirect" (connects to `[::]`, which this container's egress proxy refuses). </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-clone.test.ts <!-- robobun:evidence:end --> --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
) ### What does this PR do? `fetch()` fixes for proxying, TLS identity and error reporting, plus one feature: a connection session object, `Bun.FetchSession`. ### Bugs | # | Before | After | |---|---|---| | B1 | A proxy's non-200 reply to `CONNECT` resolved as the https origin's `Response` (status, headers, body). | Any non-2xx reply rejects with `ERR_PROXY_TUNNEL`; the error carries the proxy's `status`, `statusText` and `headers`. A 2xx reply starts the tunnel (RFC 9110 §9.3.6), so a forged `2xx` + body fails the TLS handshake instead. A 2xx reply now returns before any of its header fields are interpreted: they used to leak into the state that parses the tunneled response (`204` left `Content-Length: 0` behind, `Connection: close` cost the tunnel its keep-alive, `Content-Encoding` selected a decoder). `handle_response_metadata` in `src/http/lib.rs`. A `101` reply is a refusal like any other non-2xx status (it was `UnrequestedUpgrade`). Invalid `tls` options on a tunneled request report what they report on a direct one (`FailedToOpenSocket` / `InvalidCRL`, not `ECONNREFUSED`), and a failed handshake inside the tunnel is a certificate error only when the peer got as far as sending a certificate. | | B2 | `fetch(url, { unix })` with `HTTP_PROXY` set wrote an absolute-form request line and `Proxy-Authorization` down the unix socket. | A `unix` request never reads the proxy env (`FetchTasklet::get`). | | B3 | `https://[::1]:port` verified the certificate against `[::1]` and sent it as SNI. | #30674 landed the same fix on `main` while this was open; this branch now uses that one `strip_ipv6_brackets` (moved to `bun_url` and re-exported from `bun_http`) and extends it to HTTP/3's host and SNI and to NO_PROXY's bracketed entries. | | B4 | Two requests with different `tls.checkServerIdentity` closures shared a pooled socket; the second callback never ran. | A request that passes its own callback neither takes nor returns a pooled socket on its `https:` hops (`Flags::bypass_pool`); an `http:` hop, where the callback cannot run, pools as usual. A `Bun.FetchSession`'s callback runs once per connection and the session's requests reuse it. This gives up the connection reuse #40308 added for per-request callbacks (one handshake per request again); moving the callback to a session gets it back, and that is what the #40308 test now covers. | | B5 | Reported: a response stream aborted shortly after the head ends cleanly. | Not reproducible on `main` (nor on 1.4.1) across reader / `for await` / `text()` / clone / tee / `pipeThrough` consumers, sync / microtask / macrotask aborts, plain, TLS and CONNECT-tunnel transports; #35093 and #42125 fixed the known paths. Added the consumer × abort-timing matrix as a test. | | B6 | `Header 'Authorization' has invalid value: 'Bearer …'`. | `Header 'Authorization' has invalid value`. | | B7 | `fetch("file://any.host/path")` read the local file. | Rejects with `ERR_INVALID_FILE_URL_HOST` unless the host is empty or `localhost`, on Windows too: Node reads such a URL as a UNC path there, which `fetch()` never did (it dropped the host and read the local path). `fileURLToPath`'s other rule comes with it: a path with an encoded `/` (on Windows also `\`) rejects with `ERR_INVALID_FILE_URL_PATH`, where it used to be decoded into a real separator (`file:///tmp/..%2F..%2Fetc/hosts` read `/etc/hosts`). | | B8 | `delete process.env.HTTPS_PROXY` (and any assignment after a delete) had no effect on fetch. Overwrites already worked. | `process.env`'s `put` and `deleteProperty` match the proxy variable names and sync the native env map, the way `TZ` and `NODE_TLS_REJECT_UNAUTHORIZED` are handled. That makes the write-through accessors unnecessary, so they are gone: a proxy variable is an ordinary property, a write to an object that merely inherits from `process.env` stays on that object, `"HTTP_PROXY" in process.env` is `false` when it was never set (it used to be `true`), and a delete removes it. On Windows the sync moved into `editWindowsEnvVar`, next to the `SetEnvironmentVariableW` call; I could not run that path. `SHARE_ENV` workers delete through the same helper. `ALL_PROXY` is synced the same way; `ProxyEnvSlots` became an array indexed by one list of names. The same now holds for `TZ` and `NODE_TLS_REJECT_UNAUTHORIZED`: a write to an object inheriting from `process.env` used to change the process's timezone, or turn certificate verification off for the parent, and leave nothing on the object. | | B9 | `NO_PROXY`: commas only, no `*.`, no CIDR, IPv6 only bracketed, IP literals suffix-matched (`0.0.1` matched `127.0.0.1`), one casing read. | One matcher, `bun_dotenv::no_proxy` (the duplicate in `bun_http` is gone): comma or whitespace separated, `*.example.com`, CIDR for v4 and v6, bracketed or bare IPv6, `host:port` compares the effective port (for `WebSocket` too, which now passes it), an IP-literal host only matches an address or block (`::ffff:a.b.c.d` counts as `a.b.c.d`; addresses are parsed strictly and identically on every platform, so `127.1` is not one), `no_proxy` is the list and `NO_PROXY` is read when that is unset or empty, as curl, node and undici do; ports and CIDR prefixes are ASCII digits only. `test` still matches `example.test`, like curl and Go. | | B10 | see below | | B10: - `ALL_PROXY` / `all_proxy` is the fallback, for `http:` and `https:` targets alike, when the scheme-specific variable is unset. A `socks*://` value is ignored since the client cannot speak it; a value with no scheme is an HTTP proxy, as for curl and for `HTTP_PROXY` (going direct instead would silently bypass the proxy). curl reads `ALL_PROXY`; node does not, and the docs say so. - URL userinfo is sent as `Authorization: Basic …` unless the request has an `Authorization` header (node's `fetch` rejects such a URL; the docs note the difference), and the `path` of a connection error leaves it out. The existing cross-origin redirect rule strips it; that rule compared `URL::origin`, which includes the userinfo, so a same-host `Location` without it counted as cross-origin. It compares scheme, host and port now. `URL::parse` (the internal one) now takes userinfo from the authority when the input has a scheme: everything before the last `@` ahead of the first `/?#`, split at the first `:`. `http://user@host:8080` used to parse as the hostname `user@host`. Scheme-less scp-like `git@host:path` keeps its old reading. - `checkServerIdentity` runs under `rejectUnauthorized: false` / `NODE_TLS_REJECT_UNAUTHORIZED=0` when the chain verifies, as Node does; its verdict is not enforced. A request pinned to `protocol: "http2" | "http3"` goes ahead without the advisory call, as before (the callback only runs on HTTP/1.1). - `code` is errno-style for socket failures: `ECONNREFUSED` (or, for a host with one address, whatever `connect(2)` failed with: `ETIMEDOUT`, `EHOSTUNREACH`, ...; when every address of a name fails uSockets reports a refusal), `ECONNRESET`, `ETIMEDOUT`. A TLS handshake the peer did not complete used to be `ConnectionRefused` too; it is its own `http::Error::TLSHandshakeFailed` now and reports `EPROTO`, which is what Node reports for it. A synchronous `socket()`/`connect()`/poll-registration failure reports its errno instead of `FailedToOpenSocket` on POSIX; an unloadable CA and Windows keep `FailedToOpenSocket`. Messages start with the code. - The 5 minute idle timeout is unchanged and already documented on `timeout`. `NO_PROXY` still applies to an explicit `proxy` by default; `proxy: { url, respectNoProxy: false }` insists on the proxy and `proxy: false` opts out of the environment. `null`, `""` and `undefined` keep meaning "inherit"; `proxy: true` throws `ERR_INVALID_ARG_TYPE`. ### Features **`Bun.FetchSession`** (`session.fetch(url, init)`, or `fetch(url, { session })`): `session.fetch` is `fetch()` with the session, bound, so it goes wherever a `fetch` function is accepted (`new SomeClient({ fetch: session.fetch })`) and a `Request` cannot lose the session the way a `session` member of its init can. It has no `preconnect`, so it is typed as the call signature, not `typeof fetch`. A request is one count on its session from the moment `fetch()` reads the `session` option (the option is a property of `init`, so a getter read after it could otherwise collect the session), which keeps a `JsRef` to its wrapper that is strong only while the count is not zero: one Strong per busy session, none per request. The constructor's dictionary is `FetchSessionInit`: `tls`, `proxy` (`{ url, headers, respectNoProxy }` or `false`), `keepAlive` (`false` or `{ idleTimeout, maxIdleSockets }`), `unix`. Request options win over the session's: a request's `tls` replaces the session's as a whole, a request's `unix` drops the session's explicit proxy and a request's explicit `proxy` drops the session's `unix`, and `keepalive: true` on the request wins over `keepAlive: false`. Each session is a partition of the keep-alive pool: `PoolOptions::id` is part of the pool key for pooled sockets, HTTP/2 sessions and pending connects, and HTTP/3 sessions, so connections are never shared across sessions or with plain `fetch()`. `close()` / `using` closes the idle connections, HTTP/3 ones included (`us_quic_socket_close` got a Rust binding); so does collecting the session, which a request in flight prevents. `keepAlive.idleTimeout` / `maxIdleSockets` do not apply to HTTP/3, and `maxIdleSockets` counts per kind of connection (plain, TLS, each `tls` configuration, unix). **No `lookup` hook.** An earlier state of this branch had a per-request `lookup(hostname, { port })` callback for dialing a vetted address. It is gone: released Bun already does that with the address in the URL, the name in `Host` and `tls.serverName` (SNI and certificate verification follow `serverName`, `:authority` follows `Host`), and the callback was the only thing here that made the HTTP thread wait on JS. What was missing for that recipe was a way to keep the environment proxy from being judged against the address, which `proxy: false` now is. The docs describe the recipe and tests pin it: https and http, IPv4 and `[::1]`, HTTP/1.1 and HTTP/2, a `serverName` the certificate does not list being rejected, `proxy: false` winning over `HTTP_PROXY` / `HTTPS_PROXY` for an IP-literal URL and being part of what a connection is shared by. ### Other consumers of the HTTP client `fetch.preconnect()` and `--fetch-preconnect` no longer dial an origin the environment proxies. They used to open a direct connection that bypassed the proxy and that no request could use. `bun install`, `bun upgrade`, S3 and the other `AsyncHTTP` users now see a refused `CONNECT` as the error `ProxyConnectFailed` instead of a response with the proxy's status. `bun install` prints the status, `error: ProxyConnectFailed (407) downloading package manifest bar` (was `GET https://… - 407`), and retries only a 5xx reply, as before. The blocking `send_sync` callers (`bun info`, `bun upgrade`, `bun create`, ...) fail with `ProxyConnectFailed`. They take no session, and their keep-alive pool is partition 0 as before. `Env::get_http_proxy` lost its three constant parameters. `Bun.s3` now resolves the environment proxy per request URL through the same `get_http_proxy_for` as everything else: the variable for the endpoint's scheme (with the `ALL_PROXY` fallback) unless `NO_PROXY` exempts it. It used `HTTP_PROXY` for every endpoint, https ones included, and never consulted `NO_PROXY`. `fetch("s3://…", { proxy: false })` connects directly; an explicit `proxy` there is used as given, as before. ### Cost Measured with the CI release binaries of this branch and of its merge-base with `main`, client pinned to 8 cores, server in another process, `perf stat -e instructions:u` (wall time on the box was noise). | request | main (`c8b9b58`) | this PR (`9df87a7`) | Δ | |---|---|---|---| | `fetch(url)`, keep-alive | 33,201 | 33,619 | +1.3% | | same, 64 in flight | 24,753 | 25,037 | +1.1% | | `fetch(url, { method, headers, redirect })` | 50,218 | 45,991 | -8.4% | | `POST` with a string body | 42,855 | 38,783 | -9.5% | | `keepalive: false` | 41,729 | 37,537 | -10.0% | | https, `tls: { ca }`, keep-alive | 75,569 | 71,428 | -5.5% | | same, 64 in flight | 68,564 | 64,556 | -5.8% | | https, new handshake per request | 1,528,639 | 1,524,690 | -0.3% | Instructions per request, medians of 5 interleaved runs with the startup cost subtracted. Wall time per request was within run-to-run noise in every row. `RssAnon` after 20,000 requests is 12–15 MB with either binary. `RssAnon` at startup is identical (3.78 MB both). The larger `RssFile` of this PR's binary is a property of every PR build's text layout (an unrelated PR's binary shows the same 27 MB against the main build's 13 MB), not of this change. An earlier state of this branch cost +4% to +9% in these rows. What was done about it: the twelve option keys `fetch()` probes on its init object are atomized once per VM (they are `BunCommonStrings.h` entries, read with `JSValue::get_common_string`) instead of allocating and atomizing a `StringImpl` per probe, and `method` / `signal` use the existing builtin names; `URL::parse` looks for an `@` with one scan before it looks for the authority; the HTTP thread's new queue (sessions to close) sits behind an atomic flag; the CONNECT reply a failed `HTTPClientResult` can carry is boxed. ### Where this can go The shape follows what the platform already has so that it could be standardized later: a scoped object with its own `fetch()` (as `ServiceWorkerGlobalScope` and `BackgroundFetchManager` have), a session being a caller-defined *network partition key* in the Fetch spec's terms. A cookie jar would plug into the existing `credentials` member and a cache into the existing `cache` modes, which Bun ignores today; neither needs a new per-request option. ### How did you verify your code works? - `test/js/web/fetch/fetch-session.test.ts` (new): session pool isolation, `close()`, `keepAlive`, option precedence, proxy policy, pinning a request to an address, `session.fetch`. - `proxyInternals` in `bun:internal-for-testing` exposes the NO_PROXY matcher, the env proxy resolution and the client's URL parser; `proxy.test.ts` has the tables (about 150 NO_PROXY cases, 50 environments, 22 URLs). - Existing files: `proxy.test.ts` (B1, B8, B9, `ALL_PROXY`, 2xx tunnels, per-session tunnels, workers, `s3://`), `bun-install.test.ts` and `bun-info.test.ts` (refused `CONNECT`), `fetch-http2-client.test.ts` (advisory callback), `fetch-http3-client.test.ts` (session isolation and `close()`), `fetch.unix.test.ts` (B2), `fetch.tls.test.ts` (B3, B4, B10), `fetch.stream.test.ts` (B5), `fetch_headers.test.js` (B6), `fetch.test.ts` (B7, userinfo, error codes). - Tests that asserted the old behaviour are updated: refused `CONNECT` surfaced as a `Response` (`proxy.test.ts`, `proxy-stress-errors.test.ts`, `proxy-stress-lifecycle.test.ts`), per-request callbacks sharing a connection (`fetch.tls.test.ts`, `proxy.test.ts`), PascalCase codes and un-prefixed messages. Every file above passes on a debug build (`bun bd test <file>`), along with `websocket-proxy.test.ts`, `worker_threads.test.ts`, the `test-process-env*.js` node tests, `proxy-stress-*.test.ts`, the bun-types test, the source lints and `bun run rust:check-all`. The new tests fail on a build of `main`. `fetch.test.ts` has the same 18–20 debug-build timeouts and root-only failures with and without this change. Windows ran in CI only. The first run there showed a refused connect reporting `ENOTCONN` (the `WSAENOTCONN` of uSockets' zero-byte `recv()` probe); that path now reads `SO_ERROR` for the cause and a bare `WSAENOTCONN` falls back to `ECONNREFUSED`. The `process.env` Proxy's `set` trap now leaves a write to an inheriting object on that object. Not run locally: `cargo test -p bun_url` does not link locally (`highway_memmem`), so the `URL::parse` change is covered through the JS tests; C++ was not clang-formatted locally.
### What does this PR do?
Fixes a leak: a `fetch()` `Response` that is reachable only from an
`abort` listener on the signal it was fetched with is never collected,
and neither is the signal or anything the listener closes over.
```js
const controller = new AbortController();
const response = await fetch(url, { signal: controller.signal });
controller.signal.addEventListener("abort", () => console.log(response.status));
// drop controller and response: both stay alive forever
```
A signal with an abort listener and a pending-activity count is a GC
root, and two things held that count for as long as the `Response`
lived, which closes the cycle signal → listener → `Response` → count:
- the `Response`'s own abort listener (added in #35093 so that abort
still errors a fully-buffered body), until the `Response` was finalized;
- the fetch task, for as long as it follows the signal
(`AbortHandle::follow`, shared with `Bun.spawn`). A body past the
receive high-water mark that is never read keeps the fetch in flight
until the `Response` is finalized.
Both counts are removed. Neither was what kept anything working: each
holder keeps a reference to the native signal, and the native callback
it registers already counts as an abort listener and as a timeout
observer, which is what keeps an `AbortSignal.timeout()` or
`AbortSignal.any()` signal alive while it can still fire. A controller
roots its own signal. Native abort delivery does not go through the JS
wrapper. Server request signals, which native code aborts, keep their
count.
### How did you verify your code works?
- `fetch-leak.test.ts`: responses held only by their signal's listener,
with a small read body, a small unread body and a 1 MB unread body. On
1.4.2 it leaves 49 `Response` and 49 `AbortSignal` cells after GC; with
this change it passes. With only the `Response` change, the 1 MB shape
still leaked 40 of 40.
- `fetch-leak.test.ts`: responses outlive the wrapper of the signal they
were fetched with (controller dropped, responses held): the wrappers are
collected (21 cells on 1.4.2), and the bodies and clones still read.
- `fetch-abort-stream-body.test.ts`: after forced GCs, abort still
reaches an in-flight `fetch()` whose signal nothing else references,
through the two routes that need a JS wrapper to stay alive: inline
`any([timeout])` (nothing native holds the timeout source), and an
inline timeout signal with a JS listener that must still run.
- `spawn-signal.test.ts`: after forced GCs, an inline `any([timeout])`
still kills the child.
The last two pass before and after the change. All of
`fetch-abort-stream-body.test.ts` and `spawn-signal.test.ts`, and the
signal tests in `fetch-leak.test.ts`, pass on the debug build.
…#43699) ### Problem - A `fetch()` is aborted while its body still arrives. A native reader of `res.body` does not get `signal.reason`, as it does on 1.3.14 and Node. One that already reads gets a fresh `AbortError: The operation was aborted.`. One that starts later gets a clean, empty body: `text()` gives `""`, `Bun.serve` answers 200. - The cause is `ReadableStream::error` (`src/runtime/webcore/ReadableStream.rs:291`), called by `BodyAbortListener::on_abort` since #35093. It errors the JS stream, then cancels the `ByteStream`. `ByteStream::on_cancel` ends a native reader with a generic `UserAbort`, and a later one never looks at the JS stream. ### Fix - `ReadableStream::error` fails what reads natively right now (a wired sink, a buffer action, a parked pull) with the reason. The reason is never kept natively: a `Strong` roots a reason that references its own `Response`. - An errored JS stream hides its native source (`nativePtrForJS()`, the Rust tag), so a later reader gets the stored error. - `FileReader` gets the same hook, and its `on_cancel` now fails a sink that is still wired (3980be6). No caller of `error` holds a File source. - Verified: `test/js/web/fetch/fetch-abort-stream-body.test.ts` (68 new cases, main fails 55). Suites: see Notes. ### Background - A `ByteStream` is the native source of a streamed body. The fetch tasklet pushes chunks, or one terminal error, into it. - A native reader takes the bytes with no JS reader: a buffer action (`new Response(res.body).text()`), a wired sink (`Bun.write`, a fetch upload, `HTMLRewriter`, `Bun.serve`), or `Readable.fromWeb()`. - `BodyAbortListener` is a fetch `Response`'s `AbortSignal` listener. It errors the body on abort. <details><summary>Notes</summary> **Repro (from the report).** A server sends a head and 40 of 100 body bytes, then stalls. `AbortSignal.timeout(150)` or `ac.abort(new Error("custom"))` fires. | reader of `res.body` | main, reader started before the abort | main, reader starts after the abort | this PR, both | | --- | --- | --- | --- | | `res.text()`, reader loop, `pipeTo`, `pipeThrough`, `tee` | `signal.reason` | `signal.reason` | `signal.reason` | | `new Response(res.body).text()` / `json` / `bytes` / `blob` / `arrayBuffer` | fresh `AbortError` | resolves `""` / empty | `signal.reason` | | `new Request(url, { body: res.body }).text()` | fresh `AbortError` | resolves `""` | `signal.reason` | | `Bun.readableStreamToText` / `Bytes` / `ArrayBuffer` / `JSON` / `Blob`, `res.body.text()` | fresh `AbortError` | resolves `""` / empty | `signal.reason` | | `Bun.write(path, new Response(res.body))` | fresh `AbortError` | resolves `0` | `signal.reason` | | `fetch(url, { method: "POST", body: res.body })` | fresh `AbortError` | uploads an empty body, resolves | `signal.reason` | | `new HTMLRewriter().transform(new Response(res.body)).text()` | fresh `AbortError` | resolves `""` | `signal.reason` | | `Readable.fromWeb(res.body)` | `end`, as if the body were complete | `end` after 0 bytes | `signal.reason` (see the idle case below) | | `Bun.serve` handler returns `new Response(res.body)` | reports the generic `AbortError`, cuts the connection | 200 with an empty body | reports `signal.reason`, cuts the connection | **History of this PR.** The first commit handled only a reader that already reads. The review found the late reader (second column). The second commit stored the reason in the ByteStream for it. The review then found that this `Strong` is a GC root: with `abort(new Error("x", { cause: res }))`, `res.body` touched and `await res.text().catch(() => {})`, main collects every `Response` and that head kept 200 of 200. The last source commit stores nothing and hides the native source of an errored stream instead. The `ByteStream::to_any_blob` and `Bun.serve` changes of the second commit are gone. **The idle `Readable.fromWeb()`.** It already holds the native source, so the errored JS stream cannot stop it, and it has no pull parked, so nothing can take the reason when the abort fires. It now fails with a generic `AbortError` on its next pull. On main it ends cleanly. It cannot get the reason without a traced slot for it. #43410 adds such a slot (`pendingError`). **The `FileReader` commit (3980be6).** It mirrors `ByteStream`: `error_native_consumer` for a wired sink or a parked pull, and `on_cancel` ends a sink that is still wired with `UserAbort`, so the sink does not wait for an end that never comes. `ReadableStream::error` has two callers (`Response.rs:155`, `Body.rs:1374`) and neither holds a File source, so the `error` arm is parity only and no public API can test it. **Other notes.** - A `Bun.serve` response of an errored stream is reported on the server and the connection is cut. That is what `Bun.serve` does for any JS stream that errors, also before the first byte. This PR does not change that policy. - `res.body; await res.text()` is not affected on main: the body mixin drops `Locked.readable` once it starts the buffer action, so `on_abort` finds no stream and the tasklet delivers the reason. - Primitive reasons (`null`, `42`, `"str"`, a symbol) and the default `abort()` reason reach the reader unchanged. The test covers them with the abort before and after the reader starts. - Node v26.3.0 rejects the upload case with `TypeError: fetch failed` and `cause === signal.reason`. Bun rejects an upload with the body stream's error itself. This PR keeps that and restores the 1.3.14 result. - Heap probes (200 aborted fetches each: body never read, read late, parked reader, and a reason that references its `Response` followed by `res.text()`) retain the same as main. A reason that references its `Response` is retained on main already when the body is never read, through the body's `Value::Error`. #43521 addresses that. - Not covered here, reported separately: a body that fails with no abort (a connection reset) before the reader starts. On main a late JS reader gets a clean `done` (`ByteStream::on_start` ignores the stored error), `Bun.write(path, new Response(res.body))` resolves `0`, and a `Bun.serve` handler that returns `new Response(res.body)` answers 200 with an empty body (`ByteStream::to_any_blob` and the `Bun.serve` ByteStream path ignore it too). - Suites run on a debug build: `fetch-abort-stream-body`, `fetch-backpressure`, `body-stream`, `body`, `body-clone`, `body-mixin-errors`, `fetch-stream-cancel-leak`, `fetch-cyclic-reference`, `fetch-leak -t abort`, `streams`, `native-source-onclose-leak`, `sync-pull-fast-path`, `node-stream`, `html-rewriter`, `html-rewriter-leak`, `proxy-stress-lifecycle`, `proxy-stress-errors`, `async-iterator-stream`, `serve`, and the node `test-webstreams-*` / `test-whatwg-readablestream*` / `test-*readable-from-web*` files. An earlier head of the test file also passed on a Windows x64 debug build. - Local failures that also fail without this change: `serve.test.ts` "root range port" and "/bun:info to loopback clients" (the container runs as root), `fetch-leak.test.ts` "Sending URLSearchParams does not leak" (120 s timeout), some `fetch.stream.test.ts` "Content-Length response works (multiple parts)" cases (5 s timeout). </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/fetch-abort-stream-body.test.ts <!-- robobun:evidence:end --> --------- Co-authored-by: Jarred Sumner <jarred@jarredsumner.com>
Follow-up to #32659 / #32662.
Problem
When a
fetch()response body is fully received before the user touchesresponse.body, aborting viaAbortControlleris a no-op on the body. A reader drains the full buffered bytes and ends with{ done: true };arrayBuffer()/text()resolve with the content. The backing store is only released when theResponsebecomes unreachable and theNewSource<ByteBlobLoader>wrapper is finalized.Cause
FetchTaskletowns the only abort-signal listener, andclear_abort_signal()runs as part of its teardown once the last body chunk has been delivered. TheResponseitself has no connection to the signal, so aborting after teardown has nothing to dispatch to.response.bodyon the now-InternalBlobvalue builds aNewSource<ByteBlobLoader>whoseOption<StoreRef>holds a +1 on the whole body until the source is cancelled or finalized.#32662 fixes the still-streaming (
ByteStream) case, where the tasklet's listener is still attached.Fix
Give the
Responseits own abort-signal listener, attached inFetchTasklet::on_resolveand detached inResponse::destroy. On abort it errors any existing body stream (rejecting pending reads and releasing the native source) and replaces the body with the abort reason so later body consumers reject.To error an existing native-backed stream (rather than close it), a
ReadableStream__errorFFI export is added that driveswebStreamControllerError;ReadableStream::error()calls it and thendone()to release the source.The listener takes a +1 ref and a pending-activity count on the
AbortSignal, both released inDrop. The pending-activity count is required forAbortSignal.timeout():cancel_all_timeout_objects(pre-destructOnExitteardown) already releases the extra ref thattimeout()took, so dropping the listener in the exit sweep must not also triggereventListenersDidChange's last-observerderef()on a signal whosem_timeoutis still set. This matchesFetchTasklet::clear_abort_signal.Why this is correct
The Fetch spec's "abort a fetch" step 4 reads: if response's body is non-null and is readable, error response's body with error. Node (undici) follows this regardless of whether the body has finished arriving. After this change Bun matches Node on every observed case:
.body.getReader().read()AbortError{ value: <bytes> }AbortError.body.getReader().read(), abort, read againAbortError{ done: true }AbortErrorarrayBuffer()AbortErrorAbortErrorabort(customReason)then readcustomReason{ value: <bytes> }customReasonTests
test/js/web/fetch/fetch-abort-stream-body.test.ts:abort() errors a fully-buffered fetch response bodycovers all four cases above.test/js/web/fetch/fetch-leak.test.ts:fetch Response's abort-signal listener does not leak the AbortSignalchecks viaheapStatsthat the listener's +1 on the signal is released after GC, and exercises the exit-teardown path with a pendingAbortSignal.timeout()attached.Existing
fetch-abort-*,fetch-response-finalizer-sweep,fetch-tls-abortsignal-timeout,serve-plugins-dev-server,response,body, andbody-streamsuites pass unchanged.no test proof · iteration 6 · 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