Repository navigation
Conversation
…eturn fetch(url)` When a Bun.serve fetch handler returns a fetch() Response directly, the response body is a native ByteStream piped to the client via on_pipe. If the client disconnected mid-stream, on_abort never cancelled that stream: the response_weakref body lookup returns None because the Source::Bytes arm of do_render_with_body moved the readable out of the Response body and set it to Used, and this.sink is only set for the JS ReadableStream path. The upstream fetch kept pulling the entire body into a dead socket. Handle this.byte_stream in on_abort the same way this.sink is handled: unpipe, cancel the stream held in response_body_readable_stream_ref (which fires the FetchTasklet cancel handler and aborts the upstream connection), and balance the ref the pipe setup took.
|
Warning Review limit reached
Next review available in: 1 minute Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 10:16 AM PT - Jul 25th, 2026
⏳ @robobun, your commit 5539f7a is still building in
|
on_pipe's is_done + backpressure branch drops the pipe ref via the _ref guard but left byte_stream set, so on_abort's byte_stream branch would deref a ref that was already gone. Clearing byte_stream at is_done keeps the invariant on_abort relies on: byte_stream Some implies the pipe ref is still outstanding. Also wire client error/close to reject and wrap upstream/client cleanup in try/finally so unexpected socket termination fails fast instead of hanging to timeout.
There was a problem hiding this comment.
ee6e696 addresses both prior findings — on_pipe now clears byte_stream alongside the is_done guard's deref, so the invariant (byte_stream Some ⟹ pipe ref outstanding) holds on all paths I traced, and the test now wires error/close to reject and cleans up in try/finally. No new issues found. Deferring only because this is ref-counting in RequestContext and a maintainer should confirm the balance trace; the two comment-cop flags on the new comments are also still open (though the comments read as invariant docs to me, not workaround justification).
Extended reasoning...
Overview
Two-file change: src/runtime/server/RequestContext.rs adds a new branch in on_abort to cancel the upstream ByteStream when a client disconnects from a proxied return fetch(url) response, plus a paired change in on_pipe to clear byte_stream when is_done fires. test/js/bun/http/serve.test.ts adds a regression test that pumps 256 MB from a raw-socket upstream through a Bun.serve proxy and asserts the upstream stops after the client disconnects at ~1 MB.
Prior findings and how ee6e696 resolves them
My earlier 🔴 finding was that the original on_abort branch's unconditional deref() could double-drop the pipe ref when on_pipe(is_done, backpressure) had already dropped it while leaving byte_stream set. The follow-up commit clears byte_stream (and unpipes) at the top of on_pipe's is_done path, so the invariant now holds: byte_stream is only Some while the ref_() at :3232 is outstanding. I re-traced the three sites that clear byte_stream (:1466, :1560, :3298) and the two sites that drop the pipe ref (_ref guard at :3293, deref() at :1474) — each clear pairs with exactly one drop, and finalize_without_deinit's take at :1560 is now defensive-only. The 🟡 test nit is also addressed: connect/data awaits reject on error/close, and both client and upstream are torn down in nested try/finally.
Security risks
None specific to this change. It narrows resource consumption (stops an unbounded upstream pull on client abort), which is a mild DoS-resilience improvement.
Level of scrutiny
High. RequestContext ref-counting is exactly the "reference counts provably balanced on every terminal path" category REVIEW.md calls out as most-blocked. The fix looks correct, but the correctness argument is a multi-path ref-count trace across on_abort / on_pipe / end_stream / on_writable_response_buffer, and a wrong balance here is a pooled-slot UAF in release. That's not something I should approve without a maintainer signing off on the trace.
Other factors
The comment-cop action left two open flags on the 4–5 line comments at :1461-1465 and :3295-3297. Those comments document a non-local ref-count invariant, which REVIEW.md's "comments carry only durable non-obvious content: invariants, ownership/lifetime contracts" explicitly endorses — but the bot flags are still unaddressed on the thread and a human should make the call. The test now runs the raw-socket upstream in-process with port: 0, wires all failure events, and asserts ranToCompletion === false / sentChunks < TOTAL_CHUNKS after upstreamClosed resolves, so the known regression path fails cleanly.
There was a problem hiding this comment.
Both earlier findings are addressed — the ref-count now balances on the mid-stream-abort, is_done+WantMore, and is_done+backpressure→abort paths, and the test wires error/close to reject with try/finally cleanup. Deferring only because RequestContext ref-counting is the most-blocked review category and worth a maintainer sanity-check.
What was reviewed:
- Traced ref_count through on_abort's new byte_stream branch:
deref()+ the line-1408 guard drop take [2]→[0], matching create+pipe refs. - Verified ee6e696's on_pipe change restores the invariant (byte_stream Some ⇔ pipe ref live), so the is_done+backpressure→abort path no longer double-derefs.
- Confirmed
readable_stream::Strong::deinit()andunpipe_without_derefare idempotent, so the follow-onfinalize_without_deinitis safe.
Extended reasoning...
Overview
Two-file change: ~20 lines in src/runtime/server/RequestContext.rs adding a byte_stream branch to on_abort (unpipe → abort the held ReadableStream → deinit strong ref → deref the pipe ref) plus a matching byte_stream.take() in on_pipe when is_done fires; and a ~75-line regression test in test/js/bun/http/serve.test.ts that proxies a 256 MB upstream via return fetch(url), drops the client after 1 MB, and asserts the upstream socket closes early.
Security risks
None identified. This is resource-lifecycle plumbing on the server response path; no auth, crypto, or untrusted-input parsing is touched. The only risk vector is memory safety (ref-count imbalance / UAF on the pooled RequestContext), which is what the review focused on.
Level of scrutiny
High. RequestContext ref-counting is exactly the pooled-slot + u8-refcount pattern REVIEW.md's memory-safety section calls out as the most-blocked category, and the first draft of this PR did have an imbalance (double-deref when is_done hit backpressure before abort). That was fixed in ee6e696 by clearing byte_stream in on_pipe when is_done, so the on_abort branch's extra deref is now gated on the pipe ref actually being outstanding.
I re-traced the paths after the fix:
- Mid-stream abort (byte_stream Some, pipe ref live): entry ref_count=2 →
this.deref()at 1470 → 1 →_refguard at 1408 drops → 0 → finalize+deinit. Balanced. - is_done + WantMore: on_pipe takes byte_stream, unpipes,
end_stream(),_refguard drops pipe ref. Later abort sees byte_stream=None, falls through to pre-existing path. - is_done + backpressure → abort: on_pipe takes byte_stream,
_refguard drops pipe ref → ref_count=1. Abort sees byte_stream=None, falls through;_refat 1408 drops → 0. Balanced (this is the path that was broken pre-ee6e696). - Double-cleanup:
response_body_readable_stream_ref.deinit()is called in the new branch and again infinalize_without_deinit;strong::Optional::deinituseshandle.take()so it's idempotent.unpipe_without_derefjust nulls two fields.finalize_without_deinitis documented multi-call-safe.
Other factors
The test now rejects on client error/close and wraps upstream/client teardown in try/finally (ee6e696), addressing the earlier nit. The comment-cop bot's multi-line-comment complaints were trimmed in c0f9c52. The byte_stream_unpipe shim's doc-comment still says "lone caller" but there are now three call sites — cosmetic only. CI build #80254 hadn't reported a result in the timeline I fetched.
Given the change sits in pooled-request lifecycle code and already needed one ref-count correction, a maintainer pass on the terminal-path enumeration is worth the extra eyes even though I found nothing further.
There was a problem hiding this comment.
The ref-count over-deref I flagged earlier is correctly addressed — ee6e696 clears byte_stream in on_pipe when is_done fires, so the "byte_stream Some ⇒ pipe ref live" invariant now holds and on_abort's extra deref() is balanced on every path I traced. The test hardening from that round is also in. That said, this is manual ref-counting on the pooled RequestContext in the server hot path (the exact category REVIEW.md calls the most-blocked), and the first cut had a real over-deref — worth a human pass.
What was reviewed
- Ref-count trace across
Source::Bytessetup (3228) →on_pipe(is_done)both WantMore/backpressure arms →on_abortnew branch →_refguard at 1408; balanced on all paths after ee6e696. byte_stream_unpipefrom insideon_pipe: safe —ByteStream::on_datasnapshots the pipe fn/ctx before dispatch and returns immediately after.- Re-entrancy of
stream.abort()at 1466: pipe already cleared at 1463, so noon_pipere-entry;response_body_readable_stream_refkeeps the ByteStream alive across the call. - Test: error/close now reject the awaits, upstream/client teardown is try/finally-guarded.
Extended reasoning...
Overview
The PR fixes a resource leak where Bun.serve({ fetch: () => fetch(upstream) }) keeps pulling the entire upstream body after the client disconnects. It adds a branch to RequestContext::on_abort that handles the Source::Bytes pipe path (this.byte_stream set): unpipe the ByteStream, abort the ReadableStream held in response_body_readable_stream_ref (fires FetchTasklet::on_stream_cancelled_callback, closing the upstream connection), release the strong ref, and drop the pipe ref taken in do_render_with_body's Source::Bytes arm. A companion change in on_pipe clears byte_stream and unpipes when is_done fires so that "byte_stream is Some" is a reliable proxy for "the pipe ref is still outstanding" — this was the fix for the over-deref I flagged in the first review round. A regression test in serve.test.ts sets up a raw-socket upstream that pumps 256 MB, a proxy that returns fetch(upstreamUrl), and a raw client that disconnects after 1 MB, then asserts the upstream socket closed before completion.
Verification of the ee6e696 follow-up
I re-traced the ref count for the specific scenario my earlier comment described (on_pipe(is_done=true) hits backpressure, then client aborts before on_writable_response_buffer). With the new on_pipe block at 3290-3295, byte_stream is taken and unpiped before the backpressure branch registers on_writable, and the _ref guard at 3289 drops the pipe ref on scope exit. So when on_abort subsequently runs, byte_stream is None, the new branch is skipped, and control falls through to is_dead_request() → finalize_without_deinit() → guard at 1408 drops the last ref. No double-deref. I also checked the mid-stream abort path (the actual bug being fixed): setup leaves ref_count = 2 (initial + pipe); on_abort enters the new branch, deref() at 1470 drops to 1, guard at 1408 drops to 0. Balanced.
I also confirmed that calling byte_stream_unpipe from inside the pipe callback is safe: ByteStream::on_data (ByteStream.rs:189-196) copies (pipe.ctx, pipe.on_pipe) to the stack before invoking the handler and does nothing with the ByteStream after the handler returns, so clearing pipe mid-callback can't invalidate the dispatch. And stream.abort() at 1466 can't re-enter on_pipe because the unpipe at 1463 already cleared the callback.
Security risks
None in the sense of injection/auth. The risk category here is memory safety: an unbalanced deref on a pooled RequestContext slot is a use-after-free on the next request that reuses the slot (release) or a debug-assert crash. That's exactly what the first iteration would have hit under is_done + backpressure + abort ordering, and it's fixed.
Level of scrutiny
High. This is manual ref-counting on the server request object, in on_abort — a callback that runs concurrently with (in event-loop terms) every other completion path. REVIEW.md explicitly calls memory safety the most-blocked category, and "reference counts provably balanced on every terminal path" is the bar. My previous review found a real over-deref in the first cut; the follow-up fixes it, but that history plus the hot-path location means a maintainer should sign off on the invariant rather than take my trace alone.
Other factors
The test now wires error/close to reject on both client-side awaits and wraps upstream/client teardown in try/finally, addressing the second inline comment. The comment-cop bot's paragraph-comment complaints were resolved in c0f9c52. CI shows "step failed outside runner" on several build jobs — looks like infra, but the test lane hasn't reported yet.
|
Status: ready for review; CI failures are build infrastructure, not this change. The diff compiles and tests pass on every lane that produced a binary:
Locally verified (ASAN debug build):
The ref-count balance across |
|
Closing in favor of #36700. It is the same fix (cancel the natively piped upstream body in |
Problem
When a
Bun.servefetch handler proxies by returning afetch()Response directly:and the client disconnects mid-response, Bun keeps pulling the entire upstream body into a dead socket. A client that took 1 MB and left would still cause the proxy to pull all 512 MB from the origin.
The workarounds that already cancel correctly are
fetch(url, { signal: req.signal })and rewrapping the body vianew Response(res.body, res).Repro
Cause
do_render_with_bodyroutes a fetch() Response body (Locked{task: FetchTasklet, readable: None}) through theSource::Bytesarm: it materializes the ByteStream, stores it inthis.byte_stream/this.response_body_readable_stream_ref, takes a ref for the pipe, and sets the Response body toUsed.On client disconnect,
on_aborttries two things:this.sink: only set for the JS ReadableStream (do_render_stream) path, so it'sNonehere.response_weakref.get_body_readable_stream(): checks the JS-side cached stream slot (never populated for a direct fetch Response) andValue::Locked.readable(body isUsed), so it returnsNone.Neither
this.byte_streamnorthis.response_body_readable_stream_refis consulted, so the stream'scancel_handler(FetchTasklet::on_stream_cancelled_callback) never fires and the upstream connection stays open.on_pipekeeps receiving chunks and silently discards them (it early-returns onis_aborted_or_ended()).Fix
Handle
this.byte_streaminon_abortalongsidethis.sink: unpipe, abort the stream held inresponse_body_readable_stream_ref(which fires the FetchTasklet cancel handler and closes the upstream connection), and balance the ref the pipe setup took (sinceon_pipe'sis_donederef won't fire once the stream is cancelled).Verification
New test in
test/js/bun/http/serve.test.tsfails on main (ranToCompletion: true), passes with the fix.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/bun/http/serve.test.ts