fetch: don't double-release the request-stream ref when a native body sink ends inline
Crash
Sentry BUN-3BZF (2,975 events since 2026-05-25, macOS-dominant): Panic: called Option::unwrap() on a None value at FetchTasklet::callback's task_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). http is set once at creation and cleared only at deinit, so the panic means the callback ran against a freed FetchTasklet.
Cause
start_request_stream takes a +1 on the tasklet that must be released exactly once by write_end_request. For a native ByteStream request body (an upstream response body piped into fetch()), wire_native_sink installs the sink's source handle before any of its EndedInline returns (ReadableStream.rs:328 vs :337/:352/:359), so a stream that picked up an error or its last chunk between fetch() and the can_stream tick comes back EndedInline with a native source attached.
The EndedInline arm released the +1 (via write_end_request) but left self.sink installed with ended == false. Every terminal path then runs cancel_request_body_sink, which saw a "live" native sink and took its native arm: abort_task() plus a second write_end_request — releasing the same +1 again.
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, then on_progress_update keeps using self). 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's http field read from freed/recycled memory.
READ of size 8 ... core::mem::replace::<bun_jsc::js_promise::Strong>
#2 FetchTasklet::on_progress_update FetchTasklet.rs:1158
freed by thread T0 here:
#12 FetchTasklet::deinit FetchTasklet.rs:509
#16 FetchTasklet::write_end_request FetchTasklet.rs:2281
#17 FetchTasklet::cancel_request_body_sink FetchTasklet.rs:2368
#18 FetchTasklet::on_progress_update FetchTasklet.rs:1143
Fix
Leave the sink in the same state end_from_stream (the normal native termination) leaves it: ended = true, source and task detached. The terminal cancel_request_body_sink then hits its existing if 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
- New fixture
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. - Unfixed debug+ASAN build: heap-use-after-free with the trace above, 8/8 runs.
- Fixed build:
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).- The test is
skipIf(!isASAN): the release build corrupts silently, so only sanitizer lanes can observe the failure.