fetch: don't double-release the request-stream ref when a native body sink ends inline - #36939
Conversation
… sink ends inline wire_native_sink sets the sink's source handle before it can return EndedInline, so a ByteStream request body that errored or finished between fetch() and the can_stream tick left the sink installed as a live native sink with ended == false. start_request_stream had already released the request-stream ref via write_end_request, and the terminal cancel_request_body_sink then took the native arm and released it again, collapsing the FetchTasklet refcount and freeing it while the completion path (and potentially the HTTP thread) was still using it. Leave the sink in the same state end_from_stream leaves it (ended, source and task detached) so the terminal cancel treats it as already ended.
|
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 (3)
Comment |
There was a problem hiding this comment.
No issues found, but this is a refcount-balance fix in the FetchTasklet lifecycle (memory-safety in a production-critical path), so it warrants a human look.
What was reviewed:
- Traced
wire_native_sink(ReadableStream.rs:328/337/352/359): ByteStream path callsset_sourcebefore everyEndedInlinereturn, sosink.sourceis left populated — confirming the double-release viacancel_request_body_sink'sis_nativearm. - Confirmed the new state (
ended=true,source.clear(),task=None) matchesFetchRequestBodySink::end_from_streamand hits the existingif sink.ended { return }guard incancel_request_body_sink. - Checked the sibling synchronous
Fulfilled/Rejectedarms and the FileReaderEndedInlinepath — neither leaves a nativesink.sourceinstalled, so they were already safe from this bug.
Extended reasoning...
Overview
Two-line state fix in FetchTasklet::start_request_stream's EndedInline arm (sink.ended = true; sink.source.clear();), plus an ASAN-gated regression test with a subprocess fixture. Addresses a Sentry crash cluster (BUN-3BZF, ~3k events) where a native ByteStream request body that finishes inside wire_native_sink caused the request-stream +1 to be released twice — once by the EndedInline arm's write_end_request, then again by cancel_request_body_sink seeing a still-"live" native sink.
Verification of the mechanism
I traced the claimed flow against the current source:
start_request_streamtakes the+1at FetchTasklet.rs:636 and installsself.sinkat :662 before callingwire_native_sink.wire_native_sink(ReadableStream.rs) invokes theset_sourceclosure for the ByteStream branch before any of the threeEndedInlinereturns (:337/:352/:359), so onEndedInlinethe sink'ssourceisByteStream(_). (The FileReader branch callsset_sourceafter itsEndedInlinereturns, so it was never affected.)cancel_request_body_sink(FetchTasklet.rs:2347) early-returns only whensink.ended; otherwise it computesis_nativefromsink.sourceand, when native, callswrite_end_request(Some(reason))— the second release.write_end_requestunconditionally derefs the tasklet on every path.
The fix leaves the sink in exactly the state FetchRequestBodySink::end_from_stream (:211-232) leaves it — ended=true, source cleared, task taken — so the terminal cleanup hits the existing if sink.ended { return } guard. I also checked that the synchronous JS-pump Fulfilled/Rejected arms (which similarly only clear task) don't share the bug: they never set a native source, so is_native is false in cancel_request_body_sink.
Security risks
None identified. This is a lifetime/refcount correctness fix; no new input parsing, trust boundaries, or auth surfaces.
Level of scrutiny
High. Per the repo's review guidance, native memory safety and refcount balance on terminal paths is the most-blocked category. The change is tiny and the reasoning checks out end-to-end, but a wrong assumption here is a UAF in every fetch with a piped body — a human should confirm the ref accounting.
Other factors
- Test follows house patterns (subprocess fixture,
skipIf(!isASAN), concurrent, drains stdout/stderr/exited together, asserts stderr for AddressSanitizer and exact stdout before exit code). The fixture uses randomized 1-8ms delays and 100 iterations to hit the race window; PR reports 8/8 ASAN repros pre-fix. - The fixture's
Bun.listenupstream is not closed viausing, butupstream.stop(true)is called at the end and the whole thing runs in a subprocess that exits, so no CI-runner leakage. - No prior human review comments; CodeRabbit was rate-limited.
Crash
Sentry BUN-3BZF (2,975 events since 2026-05-25, macOS-dominant):
Panic: called Option::unwrap() on a None valueatFetchTasklet::callback'stask_ref.http.as_mut().unwrap(), reached from the HTTP thread's result dispatch (us_internal_ssl_on_data -> HTTPClient::fail -> dispatch_result_and_reset -> AsyncHTTP::on_async_http_callback_raw -> FetchTasklet::callback).httpis set once at creation and cleared only at deinit, so the panic means the callback ran against a freedFetchTasklet.Cause
start_request_streamtakes a+1on the tasklet that must be released exactly once bywrite_end_request. For a nativeByteStreamrequest body (an upstream response body piped intofetch()),wire_native_sinkinstalls the sink'ssourcehandle before any of itsEndedInlinereturns (ReadableStream.rs:328vs:337/:352/:359), so a stream that picked up an error or its last chunk betweenfetch()and thecan_streamtick comes backEndedInlinewith a native source attached.The
EndedInlinearm released the+1(viawrite_end_request) but leftself.sinkinstalled withended == false. Every terminal path then runscancel_request_body_sink, which saw a "live" native sink and took its native arm:abort_task()plus a secondwrite_end_request— releasing the same+1again.The double release collapses the refcount while the other owners (the JS-side initial ref and the HTTP thread's in-flight ref) still use the tasklet. Under ASAN the deterministic form is the trace below (deinit runs inside
cancel_request_body_sink, thenon_progress_updatekeeps usingself). In release builds the same imbalance frees the tasklet while it is still in use (or double-frees, handing a live tasklet's block back to the allocator), which surfaces as downstream crashes in the fetch completion path — the BUN-3BZF unwrap is the tasklet'shttpfield read from freed/recycled memory.Fix
Leave the sink in the same state
end_from_stream(the normal native termination) leaves it:ended = true, source and task detached. The terminalcancel_request_body_sinkthen hits its existingif sink.ended { return }guard and cannot release the ref a second time (it also no longer spuriously aborts a request whose body simply ended inline).Verification
fetch-stream-body-ended-inline-fixture.tsdrives the window: an upstream server that advertises a largercontent-lengththan it sends and closes a few ms later, piped as the body of a TLSfetch()(the handshake keeps the wire-attempt window open), 100 iterations.bun bd test test/js/web/fetch/fetch-abort-stream-body.test.tspasses (5 pass, 1 pre-existing skip), including the new test.test/js/web/fetch/body-stream.test.ts: 9086 pass / 0 fail.fetch.test.tsandfetch.stream.test.ts: identical pass/fail counts to an unfixed baseline in the same container (the failures are pre-existing network/timeout issues).skipIf(!isASAN): the release build corrupts silently, so only sanitizer lanes can observe the failure.