Skip to content

Bun.serve: cancel the piped upstream fetch when the client aborts while backpressured - #36700

Open
robobun wants to merge 3 commits into
mainfrom
farm/7cc528e9/serve-fetch-abort-followup
Open

Bun.serve: cancel the piped upstream fetch when the client aborts while backpressured#36700
robobun wants to merge 3 commits into
mainfrom
farm/7cc528e9/serve-fetch-abort-followup

Conversation

@robobun

@robobun robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #36087, carrying over the abort-path fix from the now-closed #35547 that the SinkHandle rewrite did not pick up.

Repro

Bun.serve({ fetch: () => fetch(upstream) });

Upstream streams a 64 MiB body. Client pauses after the first chunk so the proxy's write_chunk returns Backpressure and the ByteStream parks with sink_paused = true. Client then destroys the socket.

On main (f91d5c9): the upstream ReadableStream's cancel() never fires; pulls stay at whatever count they reached when backpressure engaged, and the proxy's RequestContext plus the upstream FetchTasklet/socket stay parked until the upstream server's idle timeout.

Cause

on_abort handles this.sink (the JS-stream sink path) but not this.byte_stream (the native SinkHandle::ServerResponse path). The else branch's response_body_stream() returns None because the body was already set to Used. The sink-install ref taken in do_render_with_body is only dropped in end_chunk, which is reached via sink.end() from ByteStream::resume(), and resume() only runs from on_writable_byte_stream, which never fires on a closed socket.

Fix

  • on_abort: when byte_stream is set, call cancel_from_sink() on it (detaches the sink and closes the producer, which aborts the upstream fetch), deinit the held response_body_readable_stream_ref, and drop the sink-install ref.
  • end_chunk: clear this.byte_stream so the field tracks whether that ref is still held, keeping the two release sites mutually exclusive.

Verification

test/js/bun/http/serve-fetch-body-abort-backpressure.test.ts stalls a raw-socket client against a proxy child, destroys the socket while the upstream is held paused, and asserts the upstream ReadableStream.cancel() fires and the proxy child is still alive. On main it fails with { cancelled: false, pulls: ~22 }; with the fix it passes in ~1s.


[review] gate passed · iteration 17 · 3 files touched

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/serve-fetch-body-abort-backpressure.test.ts
bun test v1.4.0 (96b99d407)

test/js/bun/http/serve-fetch-body-abort-backpressure.test.ts:
91 | 
92 |   // The proxy's on_abort should cancel its fetch to the upstream; the
93 |   // upstream's serve then cancels the ReadableStream. Poll for that signal.
94 |   for (let i = 0; i < 200 && !cancelled; i++) await Bun.sleep(10);
95 | 
96 |   expect({ cancelled, pullsUnderCap: pulls < CAP_CHUNKS, pulls }).toMatchObject({
                                                                       ^
error: expect(received).toMatchObject(expected)

  {
-   "cancelled": true,
+   "cancelled": false,
+   "pulls": 23,
    "pullsUnderCap": true,
  }

- Expected  - 1
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/bun/http/serve-fetch-body-abort-backpressure.test.ts:96:67)
(fail) client abort while a fetch() body is backpressured cancels the upstream [3134.15ms]

 0 pass
 1 fail
 2 expect() calls
Ran 1 test across 1 file. [5.33s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: all passed
bun test v1.4.0-canary.1 (9ccdf0d66)

test/js/bun/http/serve-fetch-body-abort-backpressure.test.ts:
(pass) client abort while a fetch() body is backpressured cancels the upstream [381.40ms]

 1 pass
 0 fail
 4 expect() calls
Ran 1 test across 1 file. [528.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/bun/http/serve-fetch-body-abort-backpressure.test.ts
bun test v1.4.0 (96b99d407)

test/js/bun/http/serve-fetch-body-abort-backpressure.test.ts:
(pass) client abort while a fetch() body is backpressured cancels the upstream [1065.84ms]

 1 pass
 0 fail
 4 expect() calls
Ran 1 test across 1 file. [3.29s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 774ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/22] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 238 extern-C blocks audited
[2/22] gen JS modules (bundle-modules)
Preprocess modules (9121ms)
Bundle modules (42ms)
Postprocesss modules (217ms)
Bundle Functions (724ms)
Generate Code (34ms)

[10.16s] Bundled "src/js" for production
  2558 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[2/8] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_s
... (truncated)
diff hotspot
src/runtime/server/RequestContext.rs               |  10 +-
 .../serve-fetch-body-abort-backpressure-fixture.ts |  11 +++
 .../serve-fetch-body-abort-backpressure.test.ts    | 102 +++++++++++++++++++++
 3 files changed, 122 insertions(+), 1 deletion(-)

gate history · 5 passed · 0 rejected · iteration 17

evidence per changed file
file                                                      reads  edits  tests
src/runtime/server/RequestContext.rs                         18     19      0
…bun/http/serve-fetch-body-abort-backpressure-fixture.ts      0      2      0
…js/bun/http/serve-fetch-body-abort-backpressure.test.ts      1      2      0

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 3 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 941d15ea-7ab6-4f10-a034-4951ff9df7cb

📥 Commits

Reviewing files that changed from the base of the PR and between 5ad56ed and 96b99d4.

📒 Files selected for processing (3)
  • src/runtime/server/RequestContext.rs
  • test/js/bun/http/serve-fetch-body-abort-backpressure-fixture.ts
  • test/js/bun/http/serve-fetch-body-abort-backpressure.test.ts

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

@github-actions github-actions Bot added the claude label Aug 1, 2026
@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main 5ad56ed at 9d70b74 (see #36700 (comment)). Fail-before on main: { cancelled: false, pulls: 39 } (upstream parked, never cancelled). Pass-after: cancelled: true in ~1.1s. serve-async-stream-client-abort.test.ts (same byte_stream path) still passes.

Build #88706 on 96b99d4: serve-fetch-body-abort-backpressure.test.ts passes on every lane. The only red so far is [flaky] retries in unrelated areas (hash.test.js, napi) and the broken :eyes: pipeline step. Diff is green.

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Bun.serve: cancel upstream fetch body when client disconnects from return fetch(url) #35552 - Fixes the same resource leak: on_abort in RequestContext.rs does not cancel byte_stream/upstream fetch when the client disconnects from a return fetch(url) proxy, leaving the upstream ReadableStream alive

🤖 Generated with Claude Code

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

On the duplicate flag for #35552: that PR applies the same on_abort cancellation but is based on the pre-#36087 code (on_pipe / shim::byte_stream_unpipe / stream.abort()), which #36087 replaced with the SinkHandle structure. This PR is the version rebased onto current main (uses cancel_from_sink(), adds the end_chunk invariant and the reset_timeout() in on_writable_byte_stream that #35552 does not carry). Happy to close whichever one is redundant.

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 3:05 AM PT - Aug 4th, 2026

@robobun, your commit 96b99d4 has some failures in Build #88706 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 36700

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

bun-36700 --bun

@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, but this touches the intrusive refcount lifecycle on Bun.serve's abort path, so it warrants a human look at the ref balance.

What was reviewed:

  • Traced the sink-install ref_() (RequestContext.rs:3227) against the new deref() in on_abort and the RequestContextRef in end_chunk — the .take() in both plus byte_stream = None in end_chunk keeps them mutually exclusive, no double-deref.
  • cancel_from_sink sets sink = None before closing the producer, so it can't re-enter end_chunk; response_body_readable_stream_ref keeps the ByteStream alive across the BackRef use and its deinit() is idempotent for the fall-through into finalize_without_deinit.
  • _ref (line 1385) holds one ref across the whole body, so this.deref() at 1441 can't drop the count to zero mid-function.
  • resp.reset_timeout() matches the uWS onWritable contract; resp is the live handle uWS just passed in and the aborted check runs first.
Extended reasoning...

Overview

Three changes to src/runtime/server/RequestContext.rs plus a new subprocess-fixture test:

  1. on_abort: when byte_stream is set, .take() it, call cancel_from_sink(None), deinit response_body_readable_stream_ref, and deref() the sink-install ref before falling through to the existing is_dead_request() tail.
  2. end_chunk: clear this.byte_stream = None so the field precisely tracks whether the sink-install ref is outstanding.
  3. on_writable_byte_stream: call resp.reset_timeout() before resume(), since uWS zeroes the socket timeout before invoking the writable callback and resume() only posts a task rather than writing synchronously.

Security risks

None identified. This is resource-lifecycle cleanup on client disconnect; no parsing of untrusted input, no auth/crypto surface. The failure mode being fixed is a resource leak (parked upstream fetch + RequestContext until idle timeout), not a safety violation.

Level of scrutiny

High. This is the intrusive-refcount teardown path for every Bun.serve request that pipes a native ByteStream body. REVIEW.md flags "reference counts provably balanced on every terminal path" as the most-blocked category. I traced the balance and believe it's correct:

  • The sink-install ref is taken exactly once at RequestContext.rs:3227 alongside byte_stream = Some(...).
  • It was previously released only via end_chunk's RequestContextRef. The new on_abort branch releases it on the abort path instead; both sites .take() / null the field, so at most one runs.
  • finalize_without_deinit still handles a residual byte_stream via byte_stream_unpipe without deref'ing (correct — it runs when the count is already zero).
  • cancel_from_sink detaches SinkHandle first, so the producer close cannot re-enter write_chunk/end_chunk on this context.
  • readable_stream::Strong::deinit is idempotent, so the fall-through into finalize_without_deinit calling it again is safe.

That said, someone who owns the #36087 SinkHandle rewrite should confirm this is the intended abort contract for SinkHandle::ServerResponse — in particular that cancel_from_sink's producer.close(None) cannot run JS that would need any_js_calls.set(true) here.

Other factors

  • ASAN debug build passes the new test; the fail-before evidence on main f91d5c9 shows cancelled: false as expected.
  • The release without fix evidence used a canary at 1498d7b (pre-#36087) where pulls hit the cap — that's the system-bun check, not main; the release-with-fix log was truncated before showing the test result, so the release-mode assertion pulls < CAP_CHUNKS/2 hasn't been visibly confirmed on a fast release build.
  • CI build #87084 is still in progress; the one failure so far (bun-upgrade.test.ts on Windows aarch64) is unrelated.
  • #35552 is a stale duplicate on pre-#36087 code; this PR supersedes it.

@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 nits, I traced the new on_abort branch for a double-deref of the sink-install ref: cancel_from_sink() clears ByteStream::sink before anything else so end_chunk cannot fire afterward, and the new end_chunk line nulls this.byte_stream so on_abort skips the branch after normal completion — the pairing looks balanced under ASAN. Deferring to a human for the RequestContext ref-count/abort-path change and to reconcile with #35552.

Extended reasoning...

The core fix — taking byte_stream, calling cancel_from_sink(), deiniting response_body_readable_stream_ref, and dropping the sink-install ref inside on_abort — is ref-counting on the abort path of RequestContext, which REVIEW.md flags as the most-blocked category. I verified the ref taken at do_render_with_body (line 3228) is now released on exactly one of end_chunk or the new on_abort branch (each clears byte_stream and cancel_from_sink nulls the ByteStream's sink handle first), and that _ref at the top of on_abort keeps this alive across the new this.deref(). That said, this is production-critical server lifecycle code with a duplicate open PR (#35552) to reconcile, so it warrants a human sign-off rather than bot approval.

Comment thread src/runtime/server/RequestContext.rs Outdated
Comment thread test/js/bun/http/serve-fetch-body-abort-backpressure.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.

Both earlier nits are addressed in afc89c6 and I didn't find anything further, but this adds a deref() on the client-abort teardown path in RequestContext, so it's worth a human look at the ref-count balance.

What was reviewed:

  • Traced the sink-install ref (ref_() at the Source::Bytes install site) to its two release sites — end_chunk and the new on_abort block — and confirmed they're mutually exclusive: end_chunk now nulls byte_stream, and cancel_from_sink() clears sink before anything that could re-enter, so end_chunk can't be reached after the abort path derefs.
  • Checked that this.deref() at the new site can't drop the last ref before the fall-through is_dead_request() read — _ref still holds one at that point.
  • response_body_readable_stream_ref.deinit() is idempotent, so the second call from finalize_without_deinit on the same abort path is fine.
Extended reasoning...

Overview

The PR adds a 6-line block to RequestContext::on_abort that handles the case where this.byte_stream is set (the native SinkHandle::ServerResponse path used when Bun.serve returns a fetch() Response directly). It calls cancel_from_sink() on the ByteStream, deinits the held response_body_readable_stream_ref, and releases the sink-install ref taken in do_render_with_body. A one-line change to end_chunk clears this.byte_stream so the field tracks whether that ref is still outstanding, keeping the two release sites mutually exclusive. A new subprocess-based test reproduces the parked-upstream leak and asserts both cancellation and that the proxy child stayed alive.

Security risks

None identified. This is resource-lifecycle cleanup on the server abort path; no parsing, auth, or trust-boundary changes.

Level of scrutiny

High. Per REVIEW.md this is the most-blocked category — ref-counting and teardown on an abort path in native server code. The change is small and the mechanism is clearly explained, but a mistake here is a UAF or a leak that only reproduces under load. I traced the ref balance and re-entrancy and it looks correct: cancel_from_sink sets sink = None before running any callbacks, so sink.end()/end_chunk cannot be reached from inside it; byte_stream.take() and the new end_chunk null keep the two deref() sites exclusive; and the scope-exit _ref guard in on_abort guarantees at least one ref remains across the fall-through to is_dead_request()/finalize_without_deinit(). Still, a maintainer who owns the RequestContext lifecycle should confirm this matches the intended ownership model post-#36087.

Other factors

Both nits from the previous pass were addressed in afc89c6 (the redundant reset_timeout() was reverted; the test now asserts proxy.exitCode/signalCode are null so a crash-on-abort would fail it). The author reports fail-before on main and pass-after, and that the sibling serve-async-stream-client-abort.test.ts still passes. The bug hunter found nothing new this run. There's an open near-duplicate (#35552) targeting the pre-#36087 code that should be closed if this lands.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun is this still needed? if yes rebase else close

…le backpressured

Follow-up to #36087, carrying over the abort-path fix from the
now-closed #35547 that the SinkHandle rewrite did not pick up.

on_abort handles this.sink (the JS-stream sink path) but not
this.byte_stream (the native SinkHandle::ServerResponse path). When a
client aborts while the ByteStream sink is paused for backpressure, the
upstream FetchTasklet stays in Paused with no wake path and no
cancellation, and the ref taken when the sink was installed is never
released (end_chunk only runs via sink.end(), which requires a drain
that never comes on a closed socket).

on_abort now calls cancel_from_sink() on the held ByteStream (detaches
the sink and closes the producer, aborting the upstream fetch), deinits
the Strong, and drops the sink-install ref. end_chunk clears
this.byte_stream so the field tracks whether that ref is still held,
keeping the two release sites mutually exclusive.
@robobun
robobun force-pushed the farm/7cc528e9/serve-fetch-abort-followup branch from afc89c6 to 9d70b74 Compare August 4, 2026 04:28
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Still needed on current main (5ad56ed): on_abort has no byte_stream handling, so a client that aborts while the proxy's SinkHandle::ServerResponse is paused for backpressure still leaves the upstream parked (cancelled: false, pulls: 39 on main without the fix).

Rebased onto 5ad56ed in 9d70b74 (branch reset + force-push). The structure changed since the original commit (Cell-wrapped fields, RequestContextRef::adopt, shared-ref end_chunk), so this is a fresh application of the same two changes:

  • on_abort: take byte_stream, cancel_from_sink(), deinit the Strong, drop the sink-install ref
  • end_chunk: clear byte_stream so the two release sites stay mutually exclusive

Fail-before/pass-after verified locally; serve-async-stream-client-abort.test.ts still passes.

Comment thread src/runtime/server/RequestContext.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.

All three prior review points are addressed in 96b99d4; no new findings on the rebased version. Deferring to a human for the final look since this is ref-counting in Bun.serve's abort path.

What was reviewed:

  • Traced the sink-install ref (line 3272 ref_()) to its two release sites — end_chunk's RequestContextRef::adopt and the new on_abort deref() — and confirmed they're mutually exclusive: end_chunk clears byte_stream before any re-entrant call, and cancel_from_sink clears SinkHandle first so end_chunk cannot fire after on_abort.
  • Checked readable_stream::Strong::deinit() is idempotent (*self = Empty), so the second deinit in finalize_without_deinit after the new on_abort block is a no-op, not a double-free.
  • Test now asserts proxy exitCode/signalCode are null, so a crash-on-abort would fail it.
Extended reasoning...

Overview

The PR adds an 8-line branch to RequestContext::on_abort that mirrors the existing this.sink handling for the this.byte_stream case (native SinkHandle::ServerResponse path used when a handler returns fetch(upstream) directly). It calls cancel_from_sink() on the held ByteStream, deinits the response body's ReadableStreamStrong, and drops the sink-install ref taken at do_render_with_body line 3272. A one-line change in end_chunk moves byte_stream.set(None) to the top so the field tracks whether that ref is still outstanding, and the now-dead conditional set(None) in the error branch was removed per my last review comment. The test spawns a proxy child, stalls a raw-socket client to force backpressure, destroys the socket, and polls for the upstream ReadableStream.cancel() to fire — with post-assertion liveness checks on the proxy child.

Security risks

None identified. This is teardown/resource-release on client disconnect; no new input parsing, no auth surface.

Level of scrutiny

High — this is manual ref-counting in Bun.serve's abort path, the most-blocked category per REVIEW.md. I traced the ref balance:

  • do_render_with_body takes +1 (this.ref_(), line 3272) and stores byte_stream.
  • Normal completion: end_chunk adopts that +1 via RequestContextRef::adopt (line 3392), and now clears byte_stream at line 3396 so any later on_abort skips the new block.
  • Abort while parked: on_abort takes byte_stream (line 1499) and deref()s (line 1504). cancel_from_sink sets SinkHandle::None before calling on_cancel/producer close(), so the ByteStream cannot reach sink.end()end_chunk afterwards.
  • finalize_without_deinit (which on_abort may fall through to) sees byte_stream == None and its response_body_readable_stream_ref.deinit() is idempotent.

The pre-existing _ref = RequestContextRef::adopt at on_abort line 1447 is unchanged and accounts for a separate ref; the new deref() releases only the sink-install ref. I did not find a path where both release sites fire on the same ref.

Other factors

This PR has been through three review rounds from me (redundant reset_timeout, missing proxy-liveness assertion, dead byte_stream.set(None)), all addressed and resolved. Jarred asked whether it's still needed a few hours ago and the author rebased onto current main in response, so a maintainer is already engaged. Given the ref-counting sensitivity and active maintainer attention, deferring rather than approving.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants