fetch: don't double-release the request-stream ref when a native body sink ends inline

Merged
RO
3mo ago
+99-0

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.ts drives the window: an upstream server that advertises a larger content-length than it sends and closes a few ms later, piped as the body of a TLS fetch() (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.ts passes (5 pass, 1 pre-existing skip), including the new test.
  • test/js/web/fetch/body-stream.test.ts: 9086 pass / 0 fail. fetch.test.ts and fetch.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.