Skip to content

fetch: error the response body stream when a fully-buffered response is aborted - #35093

Merged
Jarred-Sumner merged 12 commits into
mainfrom
farm/9f9f8032/fetch-abort-buffered-body
Jul 28, 2026
Merged

Jarred-Sumner merged 12 commits into
mainfrom
farm/9f9f8032/fetch-abort-buffered-body

Conversation

@robobun

@robobun robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Follow-up to #32659 / #32662.

Problem

When a fetch() response body is fully received before the user touches response.body, aborting via AbortController is 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 the Response becomes unreachable and the NewSource<ByteBlobLoader> wrapper is finalized.

const ac = new AbortController();
const res = await fetch(url, { signal: ac.signal });
await setImmediate();      // body arrives with headers
const r = res.body.getReader();
await r.read();            // { done: false, value: <all bytes> }
ac.abort();
await r.read();            // { done: true }   (Node: rejects AbortError)

Cause

FetchTasklet owns the only abort-signal listener, and clear_abort_signal() runs as part of its teardown once the last body chunk has been delivered. The Response itself has no connection to the signal, so aborting after teardown has nothing to dispatch to. response.body on the now-InternalBlob value builds a NewSource<ByteBlobLoader> whose Option<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 Response its own abort-signal listener, attached in FetchTasklet::on_resolve and detached in Response::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__error FFI export is added that drives webStreamControllerError; ReadableStream::error() calls it and then done() to release the source.

The listener takes a +1 ref and a pending-activity count on the AbortSignal, both released in Drop. The pending-activity count is required for AbortSignal.timeout(): cancel_all_timeout_objects (pre-destructOnExit teardown) already releases the extra ref that timeout() took, so dropping the listener in the exit sweep must not also trigger eventListenersDidChange's last-observer deref() on a signal whose m_timeout is still set. This matches FetchTasklet::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:

case Node Bun before Bun after
abort then .body.getReader().read() rejects AbortError { value: <bytes> } rejects AbortError
.body.getReader().read(), abort, read again rejects AbortError { done: true } rejects AbortError
abort then arrayBuffer() rejects AbortError resolves rejects AbortError
abort(customReason) then read rejects customReason { value: <bytes> } rejects customReason

Tests

  • test/js/web/fetch/fetch-abort-stream-body.test.ts: abort() errors a fully-buffered fetch response body covers all four cases above.
  • test/js/web/fetch/fetch-leak.test.ts: fetch Response's abort-signal listener does not leak the AbortSignal checks via heapStats that the listener's +1 on the signal is released after GC, and exercises the exit-teardown path with a pending AbortSignal.timeout() attached.

Existing fetch-abort-*, fetch-response-finalizer-sweep, fetch-tls-abortsignal-timeout, serve-plugins-dev-server, response, body, and body-stream suites 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

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

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

The PR adds native and Rust ReadableStream.error plumbing, retains an abort listener on resolved fetch responses, propagates abort reasons to response bodies, prevents late buffered data from overwriting terminal state, and adds regression coverage for abort rejection and AbortSignal cleanup.

Fetch abort propagation

Layer / File(s) Summary
ReadableStream error plumbing
src/jsc/bindings/webcore/streams/WebStreamsExports.cpp, src/runtime/webcore/ReadableStream.rs
Adds the ReadableStream__error FFI entry point and corresponding Rust method, which errors the stream and finalizes it.
Response abort listener lifecycle
src/runtime/webcore/Response.rs
Stores a native abort listener on each response, errors eligible body streams with the abort reason, and removes references during teardown.
Fetch wiring and regression coverage
src/runtime/webcore/fetch/FetchTasklet.rs, test/js/web/fetch/fetch-abort-stream-body.test.ts, test/js/web/fetch/fetch-leak.test.ts
Attaches abort signals to resolved responses, guards terminal buffered bodies from late resolution, and tests abort rejection, custom reasons, and signal reference cleanup.

Possibly related PRs

  • oven-sh/bun#32627: Adds related Web Streams controller-error plumbing used by abort-signal handling.
  • oven-sh/bun#32662: Updates related fetch and ReadableStream abort/error handling for buffered response bodies.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the core change: aborting a fully buffered fetch response now errors the body stream.
Description check ✅ Passed The description covers the change and verification, though it uses custom headings instead of the template's exact section names.

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

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:13 AM PT - Jul 28th, 2026

❌ @robobun, your commit bb33c55 has some failures in Build #84083 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 35093

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

bun-35093 --bun

Comment thread src/runtime/webcore/Response.rs Outdated
Comment thread test/js/web/fetch/fetch-leak.test.ts Outdated
robobun and others added 2 commits July 22, 2026 07:58
…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.
Comment thread test/js/web/fetch/fetch-leak.test.ts

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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::drop matches FetchTasklet::clear_abort_signal order (clean_native_bindings → pending_activity_unref → unref); attach_abort_signal matches the tasklet's setup order.
  • on_abort re-derives get_body_value() per statement (no held &mut across get_body_readable_stream/readable.error()); copies response/global out of *ctx and brackets with ref_/unref so re-entrant native_response unref cannot free the box mid-call.
  • ReadableStream__error mirrors ReadableStream__cancelWithReason's catch-scope pattern; webStreamControllerError on 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.AbortSignal in a subprocess with the isASAN ? 30_000 : 5_000 timeout, and exercises exit-teardown with a pending AbortSignal.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 Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Is there a simpler & more reliable fix?

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

I considered a few alternatives; here's why I landed on the listener.

Lazy check (no listener): store Option<AbortSignalRef> on the Response and check signal.aborted() at the top of to_readable_stream / on_pull / body consumers. No callback, so no refcount edge beyond the plain ref()/Drop::unref. The problem is the common path: a small body comes back as one IntoArrayAndDone chunk, so after the first read() the JS stream is already Closed and on_pull is never called again. abort() after that point has nothing to hook into, and the ByteBlobLoader store is still held (it only clears in on_cancel/deinit). So the lazy check fixes res.arrayBuffer() but not the reader path, which is the leak the issue is about.

Keep the FetchTasklet listener alive longer: don't detach in clear_data; instead keep the tasklet alive until on_response_finalize and have abort_listener error the body. That means every fetch({signal}) pins the whole FetchTasklet (http, buffers, Strongs) for the Response's lifetime, and it moves the same detach into the Weak-finalizer path anyway. More retained state, same teardown shape.

The listener as written is the FetchTasklet::clear_abort_signal pattern verbatim (ref_ + pending_activity_ref + add_listener on setup, clean_native_bindings + pending_activity_unref + unref on teardown), just owned by the Response instead of the tasklet. The pending_activity_ref is what makes it safe with AbortSignal.timeout() at exit, and the tasklet already depends on it for the same reason.

One real simplification I can offer: drop ReadableStream__error and the readable.error() call. on_abort then just calls to_error_instance, which cancel()s the stream. The store is released (fixes the leak) and arrayBuffer()/text() reject, but a reader that already has the stream sees {done: true} on its next read() instead of rejecting. That diverges from Node but is arguably spec-compliant (the stream is Closed, not Readable, so step 4 doesn't apply). It removes the new FFI and one moving piece from on_abort. Happy to go that way if the Node-match on that specific read is not worth it.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can we use a better RAII type than manually incrementing/decrementing a ref count?

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Switched to AbortSignalRef (the existing ExternalShared<AbortSignal>) for the signal ref, so unref is field-drop instead of explicit; and a scopeguard for the Response keepalive in on_abort. The one pair that stays explicit is pending_activity_ref/_unref (no existing RAII wrapper; it's just the pair bracketing the listener box's lifetime). 475bd90.

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

Comment thread src/runtime/webcore/Response.rs

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

  • BodyAbortListener box address stability across add_listener → abort_listener.set → Drop::clean_native_bindings — heap-stable, ctx pointer matches.
  • on_abort re-entrancy: Response::ref_ + scopeguard keeps the box's owner live across readable.error(); get_body_value() borrows are now statement-scoped.
  • Teardown pattern matches FetchTasklet::clear_abort_signal (clean_native_bindings → pending_activity_unref → field-drop unref); Response::destroy clears the listener.
  • on_body_received early-return when body ≠ Locked — scheduled_response_buffer take moved after the guard so bytes aren't dropped on the floor for the still-Locked path 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)] Response gains a trailing field; C++ only holds it as an opaque m_ctx pointer, so no layout mirror to update.
  • Tests follow harness conventions (subprocess, isASAN timeout branch, heapStats baseline+delta with slack).

@robobun

robobun commented Jul 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status for bb33c55: the diff is green on every lane that ran, including x64-asan. Build #84083's only non-flaky red is darwin-14-x64-test-bun exiting 1 with no test-failure annotation (runner issue); earlier builds #78472 / #78555 / #78612 were the same with the darwin-14-aarch64 lane expiring. All other test failures across those builds are retry-passes in unrelated areas (complex-workspace, fastutf8stream-reopen, webview-chrome, inspect, no-orphans, fetch-keepalive, fetch-http2-client, etc.). The new tests in fetch-abort-stream-body.test.ts and fetch-leak.test.ts passed on every completed lane.

Comment thread src/runtime/webcore/Response.rs Outdated
Comment thread src/runtime/webcore/fetch/FetchTasklet.rs Outdated

@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 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_abort re-entrancy: fields copied out before JS runs; ref_/scopeguard keeps the Response alive across readable.error(); get_body_value() re-derived per statement.
  • Drop ordering: clean_native_bindings before pending_activity_unref; AbortSignalRef field-drop releases the +1; destroy() sets abort_listener to None.
  • on_body_received guard now sits above buffer_reset.set(false) so the defer still resets the buffer on the abort-raced early return.
  • ReadableStream__error mirrors the __cancel exception-scope pattern; leak test uses heapStats in 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 ParentRef in 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 the AbortSignal.timeout() exit-teardown path.

@zenprocess

zenprocess commented Jul 28, 2026 •

Copy link
Copy Markdown

Correction (2026-07-29). This comment originally presented a cross-binary RSS table claiming this PR's build "still leaks ~77 KB/req" on a held path and that "only the cancel path is fixed." Both claims were unsupported and I have removed the table. Two defects in my harness, found on re-audit:

  1. The held arm pushed every Response into a retained array in all three cases, so ~71 KiB of the ~73–77 KB/req figure was deliberately-retained live data, not a leak. Real cross-build excess was 2–6 KB.
  2. The runs called bare fetch(url) with no AbortSignal, so attach_abort_signal never ran and this PR's BodyAbortListener was never constructed in any measurement. The build labelled "fetch: error the response body stream when a fully-buffered response is aborted #35093" was also 1.4.0-canary (~1266 commits past v1.3.14, spanning the Zig→Rust webcore port), so its deltas are not attributable to this PR.

Apologies for the noise on your PR — the numbers should not have been posted in that form.


What I can still report, corrected

We 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. BodyAbortListener is attached unconditionally in FetchTasklet::on_resolve, and on_abort handles the Locked/ByteStream case, so it covers streaming bodies as well as fully-buffered ones. I originally assumed a buffered-only scope and that was wrong.

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 Response, decide not to forward it, and drop the reference; the body is never read, cancelled, or aborted. Our only AbortController guards time-to-first-byte and its timeout is cleared the moment fetch() resolves on headers, so it cannot fire during body lifetime, and client disconnect propagates via reader.cancel() rather than abort(). self.abort_signal() is therefore None on those requests and the listener is never attached.

So for this specific shape the buffer stays owned by the Response until GC finalisation, which is orthogonal to what this PR changes. That is not a gap in the PR — the abort semantics it implements look correct and match the Fetch spec's "abort a fetch" step 4.

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 await arrayBuffer() since it never materialises the whole body.

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.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

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 main without this PR.

case 1.3.2 1.3.14 main canary (d54984555) this PR (b296b3d5)
await body.cancel() 93.1 KB/req 89.3 9.4 7.6
ac.abort() after resolve 61.6 37.2 47.5 7.7

The body.cancel() improvement is already on main — that one isn't this PR. What this PR adds is the abort() row.

@robobun

robobun commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the measurements. Agreed on attribution: this PR only touches the ac.abort() path; the body.cancel() improvement on main is the existing on_cancel → clear_data() path (plus whatever GC-pressure work landed separately). The PR body's "cause" section already frames it that way (reader.cancel() already releases the store; abort() did not), so I'll leave the body as-is unless you'd like the table added.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun fix conflicts

tombii pushed a commit to tombii/better-ccflare that referenced this pull request Jul 31, 2026
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>
@tombii tombii mentioned this pull request Aug 6, 2026
1 of 6 tasks
pull Bot pushed a commit to tqa24/better-ccflare that referenced this pull request Aug 9, 2026
…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>
pull Bot pushed a commit to tqa24/better-ccflare that referenced this pull request Aug 11, 2026
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
pull Bot pushed a commit to tqa24/better-ccflare that referenced this pull request Aug 18, 2026
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>
pull Bot pushed a commit to tqa24/better-ccflare that referenced this pull request Aug 18, 2026
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
d4rken added a commit to d4rken/clankermux that referenced this pull request Aug 19, 2026
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.
Jarred-Sumner pushed a commit that referenced this pull request Sep 9, 2026
…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>
Jarred-Sumner added a commit that referenced this pull request Sep 16, 2026
)

### 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.
dylan-conway added a commit that referenced this pull request Sep 17, 2026
### 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.
Jarred-Sumner added a commit that referenced this pull request Sep 21, 2026
…#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants