From 8bfccf75b43ed7932414f548b2d97eaa4fcc97ff Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 21:59:14 +0000 Subject: [PATCH 1/7] fetch: cap the GC-triggered drain of an abandoned Response body When a fetch() Response is dropped without its body being read, the Weak finalizer on the JS wrapper (FetchTasklet::on_response_finalize, scenario 3) previously flipped the transport to BodyReceiveMode::Ignore and resumed it with no bound, so the HTTP thread read the entire remaining body just to discard it and then pooled the connection. A 32 MiB response abandoned this way was drained in full. on_response_finalize now checks the remaining Content-Length and only drains when it is bounded and under 256 KiB (undici's Readable.dump limit is 128 KiB). Larger or chunked/unknown bodies set the aborted signal and schedule a shutdown before entering Ignore, so ignore_remaining_response_body does not re-arm the read and the socket is closed instead of drained. The close arm avoids tracker.did_cancel so no JSCell is touched during the sweep. For the small-body drain that remains, the idle timeout is no longer re-armed per read while in Ignore mode: resume_receive arms a 5 s timer once and on_data leaves it alone, making it an absolute deadline for the discard-drain rather than a per-read budget that a trickling origin could keep resetting. --- src/http/Signals.rs | 7 ++ src/http/lib.rs | 18 ++- src/runtime/webcore/fetch/FetchTasklet.rs | 35 +++++- .../fetch-response-abandoned-drain.test.ts | 111 ++++++++++++++++++ 4 files changed, 166 insertions(+), 5 deletions(-) create mode 100644 test/js/web/fetch/fetch-response-abandoned-drain.test.ts diff --git a/src/http/Signals.rs b/src/http/Signals.rs index 8a249592abf6..2b82f5037871 100644 --- a/src/http/Signals.rs +++ b/src/http/Signals.rs @@ -79,6 +79,13 @@ impl Signals { .map(bun_ptr::BackRef::from) .is_some_and(|a| a.load(Ordering::Acquire) == BodyReceiveMode::Paused as u8) } + + #[inline] + pub fn is_receive_ignored(self) -> bool { + self.body_receive_mode + .map(bun_ptr::BackRef::from) + .is_some_and(|a| a.load(Ordering::Acquire) == BodyReceiveMode::Ignore as u8) + } } pub struct Store { diff --git a/src/http/lib.rs b/src/http/lib.rs index eca6a662c26a..e68b634f7a41 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -293,6 +293,12 @@ pub fn normalize_idle_timeout_seconds(raw: u64) -> c_uint { pub const END_OF_CHUNKED_HTTP1_1_ENCODING_RESPONSE_BODY: &[u8] = b"0\r\n\r\n"; +/// Upper bound on the discard-drain that runs after a `Response` is abandoned +/// (body_receive_mode = `Ignore`). The per-read idle timeout is not re-armed in +/// that mode, so this is the wall-clock deadline for the whole drain rather +/// than a per-read budget. +pub const IGNORE_DRAIN_TIMEOUT_SECONDS: c_uint = 5; + /// HTTP-thread-only scratch buffer for building NUL-terminated hostnames. pub static TEMP_HOSTNAME: bun_core::RacyCell<[u8; 8192]> = bun_core::RacyCell::new([0; 8192]); @@ -3837,7 +3843,7 @@ impl<'a> HTTPClient<'a> { self.handle_on_data_headers::(incoming_data, ctx, socket); } ResponseStage::Body => { - if !self.state.flags.receive_paused { + if !self.state.flags.receive_paused && !self.signals.is_receive_ignored() { self.set_timeout(&socket); } @@ -3855,7 +3861,7 @@ impl<'a> HTTPClient<'a> { } } ResponseStage::BodyChunk => { - if !self.state.flags.receive_paused { + if !self.state.flags.receive_paused && !self.signals.is_receive_ignored() { self.set_timeout(&socket); } @@ -4042,7 +4048,13 @@ impl<'a> HTTPClient<'a> { // timeout disabled and the body promise pending forever. let _ = socket.resume_stream(); bun_core::scoped_log!(fetch, "resume receive {}", self.async_http_id); - self.set_timeout(&socket); + if self.signals.is_receive_ignored() { + // Armed once here and never re-armed in `on_data` while ignored, so + // this is the absolute deadline for the whole discard-drain. + socket.set_timeout(IGNORE_DRAIN_TIMEOUT_SECONDS); + } else { + self.set_timeout(&socket); + } } pub fn drain_response_body(&mut self, socket: HttpSocket) { diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 9bff5a530a83..a0b12af127cb 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -2502,7 +2502,38 @@ impl FetchTasklet { } } +/// When the `Response` is dropped without the body being read, drain at most +/// this many remaining bytes to keep the connection poolable; beyond that the +/// socket is closed. undici's only drain-to-reuse path (`Readable.dump`) uses +/// 128 KiB; we allow one extra window. +const ABANDONED_RESPONSE_DRAIN_MAX_BYTES: usize = 256 * 1024; + impl FetchTasklet { + /// Scenario 2b/3 of [`on_response_finalize`]: the JS `Response` was + /// collected with the body neither streamed nor buffered. Drain-to-reuse + /// only when the remaining body is small and bounded; otherwise abort the + /// transport first so [`ignore_remaining_response_body`] does not re-arm + /// the read and pull the entire body just to discard it. + /// + /// Runs inside a JSC Weak finalizer, so this must not touch any `JSCell`; + /// the regular [`abort_task`] is avoided because `tracker.did_cancel` + /// reaches the inspector. + fn abandon_response_body_from_finalizer(&mut self) { + let drain = match self.body_size { + http::BodySize::ContentLength(n) => { + n.saturating_sub(self.scheduled_response_buffer.list.len()) + <= ABANDONED_RESPONSE_DRAIN_MAX_BYTES + } + http::BodySize::TotalReceived(_) | http::BodySize::Unknown => false, + }; + if !drain && !self.signal_store.aborted.swap(true, Ordering::Relaxed) { + if let Some(http_) = self.http.as_mut() { + http::http_thread().schedule_shutdown(http_); + } + } + self.ignore_remaining_response_body(true); + } + #[bun_uws::uws_callback(export = "Bun__FetchResponse_finalize", no_catch)] pub(crate) fn on_response_finalize(&mut self) { bun_output::scoped_log!(FetchTasklet, "onResponseFinalize"); @@ -2531,11 +2562,11 @@ impl FetchTasklet { if let Some(promise) = locked.promise { if promise.is_empty_or_undefined_or_null() { // Scenario 2b. - this.ignore_remaining_response_body(true); + this.abandon_response_body_from_finalizer(); } } else { // Scenario 3. - this.ignore_remaining_response_body(true); + this.abandon_response_body_from_finalizer(); } } } diff --git a/test/js/web/fetch/fetch-response-abandoned-drain.test.ts b/test/js/web/fetch/fetch-response-abandoned-drain.test.ts new file mode 100644 index 000000000000..d196875ae63d --- /dev/null +++ b/test/js/web/fetch/fetch-response-abandoned-drain.test.ts @@ -0,0 +1,111 @@ +// When a fetch() Response is dropped without the body being read, the Weak +// finalizer on the JS Response wrapper reaches +// FetchTasklet::on_response_finalize (scenario 3: Locked body, no stream, no +// promise). Previously that path unconditionally drained the remaining body so +// the connection could be pooled, with no upper bound: an abandoned 32 MB +// response was read in full and the socket reused. Now the drain is capped at +// 256 KiB of remaining Content-Length; anything larger (or chunked/unknown) +// closes the connection instead. + +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows } from "harness"; + +// The server writes the body in 16 KiB chunks, pausing on backpressure so +// `bodyWritten` tracks what the client actually pulled plus at most one kernel +// send buffer. The client fetches, drops the Response, forces GC so the Weak +// finalizer runs, and then waits until the server has either sent the whole +// body or seen the socket close. +function fixture(contentLength: number) { + return /* js */ ` + const net = require("node:net"); + + const contentLength = ${contentLength}; + const chunk = Buffer.alloc(16 * 1024, 0x61); + let bodyWritten = 0; + let conn; + const { promise: done, resolve: onDone } = Promise.withResolvers(); + + const server = net.createServer(socket => { + conn = socket; + socket.on("error", () => {}); + socket.on("close", onDone); + let replied = false; + socket.on("data", () => { + if (replied) return; + replied = true; + socket.write( + "HTTP/1.1 200 OK\\r\\n" + + "Content-Length: " + contentLength + "\\r\\n" + + "Connection: keep-alive\\r\\n" + + "\\r\\n", + ); + const writeMore = () => { + while (bodyWritten < contentLength && !socket.destroyed) { + const n = Math.min(chunk.length, contentLength - bodyWritten); + const ok = socket.write(n === chunk.length ? chunk : chunk.subarray(0, n)); + bodyWritten += n; + if (!ok) return socket.once("drain", writeMore); + } + onDone(); + }; + writeMore(); + }); + }); + await new Promise(r => server.listen(0, "127.0.0.1", r)); + const port = server.address().port; + + let collected = false; + const reg = new FinalizationRegistry(() => { collected = true; }); + async function once() { + const res = await fetch("http://127.0.0.1:" + port + "/"); + if (res.status !== 200) throw new Error("status " + res.status); + reg.register(res, 0); + // Drop the Response without touching .body / .text() etc. + } + await once(); + while (!collected) { + Bun.gc(true); + await new Promise(r => setImmediate(r)); + } + + await done; + await new Promise(r => setImmediate(r)); + + console.log(bodyWritten + " " + (conn.destroyed ? "closed" : "open")); + conn.destroy(); + server.close(); + `; +} + +async function run(contentLength: number) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture(contentLength)], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: expect.any(String), exitCode: 0 }); + const [bodyWritten, state] = stdout.trim().split(" "); + return { bodyWritten: Number(bodyWritten), state }; +} + +// Platform-independent code path; collectContinuously-style GC tests are slow +// on Windows CI so keep this off there (matches fetch-response-finalizer-sweep). +describe.skipIf(isWindows)("fetch: abandoned Response body drain policy", () => { + test("small body (64 KiB) is drained so the connection stays poolable", async () => { + const { bodyWritten, state } = await run(64 * 1024); + expect({ bodyWritten, state }).toEqual({ bodyWritten: 64 * 1024, state: "open" }); + }, 60_000); + + test("large body (32 MiB) is closed instead of drained in full", async () => { + const total = 32 * 1024 * 1024; + const { bodyWritten, state } = await run(total); + // The server front-loads one kernel send buffer before the client ever + // pauses, so `bodyWritten` includes that window on top of the 256 KiB cap. + // Without the cap the full 32 MiB is drained; a quarter of that is well + // above any loopback send buffer and well below the unbounded drain. + expect(bodyWritten).toBeLessThan(total / 4); + expect(state).toBe("closed"); + }, 60_000); +}); From f4bdff37ae814040691897ca14a0a9db17fffba5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:36:19 +0000 Subject: [PATCH 2/7] fetch: lock mutex around body_size read and compare wire bytes directly Addresses review on the abandoned-body drain cap: - body_size is written by the HTTP-thread callback() under self.mutex; reading it on the JS thread during the Weak finalizer without the lock is a data race on a two-word enum. Lock around the read, matching on_progress_update / on_start_streaming. - Content-Length is the wire (post-encoding) byte count while scheduled_response_buffer holds decompressed bytes; subtracting them mixes units. Compare n directly against the cap instead. - on_data's proxy_tunnel arm now matches the Body/BodyChunk arms and stops re-arming the idle timeout while in Ignore mode; the resume_receive comment notes that tunnels never reach the 5 s arm. --- src/http/lib.rs | 8 ++++++-- src/runtime/webcore/fetch/FetchTasklet.rs | 19 ++++++++++++------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index e68b634f7a41..c019a93d3f69 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -3824,7 +3824,9 @@ impl<'a> HTTPClient<'a> { if self.proxy_tunnel.is_some() { // if we have a tunnel we dont care about the other stages, we will just tunnel the data - self.set_timeout(&socket); + if !self.signals.is_receive_ignored() { + self.set_timeout(&socket); + } self.proxy_tunnel_mut().unwrap().receive(incoming_data); return; } @@ -4050,7 +4052,9 @@ impl<'a> HTTPClient<'a> { bun_core::scoped_log!(fetch, "resume receive {}", self.async_http_id); if self.signals.is_receive_ignored() { // Armed once here and never re-armed in `on_data` while ignored, so - // this is the absolute deadline for the whole discard-drain. + // this is the absolute deadline for the whole discard-drain. Proxy + // tunnels never pause receive and so never reach here; their drain + // runs under the previously-armed idle timeout. socket.set_timeout(IGNORE_DRAIN_TIMEOUT_SECONDS); } else { self.set_timeout(&socket); diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index a0b12af127cb..7ff4bd219854 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -2519,13 +2519,18 @@ impl FetchTasklet { /// the regular [`abort_task`] is avoided because `tracker.did_cancel` /// reaches the inspector. fn abandon_response_body_from_finalizer(&mut self) { - let drain = match self.body_size { - http::BodySize::ContentLength(n) => { - n.saturating_sub(self.scheduled_response_buffer.list.len()) - <= ABANDONED_RESPONSE_DRAIN_MAX_BYTES - } - http::BodySize::TotalReceived(_) | http::BodySize::Unknown => false, - }; + // `body_size` is assigned by the HTTP-thread `callback()` under + // `self.mutex`; take the same lock here so the enum read is not torn. + self.mutex.lock(); + let body_size = self.body_size; + self.mutex.unlock(); + // `Content-Length` is the wire (post-encoding) size; compare it + // directly. `scheduled_response_buffer` holds decompressed bytes, so + // subtracting it from the wire count would mix units. + let drain = matches!( + body_size, + http::BodySize::ContentLength(n) if n <= ABANDONED_RESPONSE_DRAIN_MAX_BYTES + ); if !drain && !self.signal_store.aborted.swap(true, Ordering::Relaxed) { if let Some(http_) = self.http.as_mut() { http::http_thread().schedule_shutdown(http_); From 9d481e491db0ae27ab1c8af1edaac0f908a35aba Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:40:40 +0000 Subject: [PATCH 3/7] trim doc comments --- src/http/lib.rs | 12 ++++-------- src/runtime/webcore/fetch/FetchTasklet.rs | 24 ++++++----------------- 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index c019a93d3f69..f7ff91972c75 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -293,10 +293,8 @@ pub fn normalize_idle_timeout_seconds(raw: u64) -> c_uint { pub const END_OF_CHUNKED_HTTP1_1_ENCODING_RESPONSE_BODY: &[u8] = b"0\r\n\r\n"; -/// Upper bound on the discard-drain that runs after a `Response` is abandoned -/// (body_receive_mode = `Ignore`). The per-read idle timeout is not re-armed in -/// that mode, so this is the wall-clock deadline for the whole drain rather -/// than a per-read budget. +/// Wall-clock cap on the `BodyReceiveMode::Ignore` discard-drain (not re-armed +/// per read in `on_data`). pub const IGNORE_DRAIN_TIMEOUT_SECONDS: c_uint = 5; /// HTTP-thread-only scratch buffer for building NUL-terminated hostnames. @@ -4051,10 +4049,8 @@ impl<'a> HTTPClient<'a> { let _ = socket.resume_stream(); bun_core::scoped_log!(fetch, "resume receive {}", self.async_http_id); if self.signals.is_receive_ignored() { - // Armed once here and never re-armed in `on_data` while ignored, so - // this is the absolute deadline for the whole discard-drain. Proxy - // tunnels never pause receive and so never reach here; their drain - // runs under the previously-armed idle timeout. + // Not re-armed in `on_data` while ignored: absolute drain deadline. + // Proxy tunnels never pause so never reach this arm. socket.set_timeout(IGNORE_DRAIN_TIMEOUT_SECONDS); } else { self.set_timeout(&socket); diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index 7ff4bd219854..a34b69d7a72b 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -2502,31 +2502,19 @@ impl FetchTasklet { } } -/// When the `Response` is dropped without the body being read, drain at most -/// this many remaining bytes to keep the connection poolable; beyond that the -/// socket is closed. undici's only drain-to-reuse path (`Readable.dump`) uses -/// 128 KiB; we allow one extra window. +/// Drain-to-reuse cap for a GC-abandoned body (undici's `Readable.dump` is 128 KiB). const ABANDONED_RESPONSE_DRAIN_MAX_BYTES: usize = 256 * 1024; impl FetchTasklet { - /// Scenario 2b/3 of [`on_response_finalize`]: the JS `Response` was - /// collected with the body neither streamed nor buffered. Drain-to-reuse - /// only when the remaining body is small and bounded; otherwise abort the - /// transport first so [`ignore_remaining_response_body`] does not re-arm - /// the read and pull the entire body just to discard it. - /// - /// Runs inside a JSC Weak finalizer, so this must not touch any `JSCell`; - /// the regular [`abort_task`] is avoided because `tracker.did_cancel` - /// reaches the inspector. + /// Scenario 2b/3 of [`on_response_finalize`]. Runs inside a Weak finalizer: + /// no `JSCell` may be touched, so the close arm inlines `abort_task` minus + /// `tracker.did_cancel`. fn abandon_response_body_from_finalizer(&mut self) { - // `body_size` is assigned by the HTTP-thread `callback()` under - // `self.mutex`; take the same lock here so the enum read is not torn. + // `body_size` is written by HTTP-thread `callback()` under `self.mutex`. self.mutex.lock(); let body_size = self.body_size; self.mutex.unlock(); - // `Content-Length` is the wire (post-encoding) size; compare it - // directly. `scheduled_response_buffer` holds decompressed bytes, so - // subtracting it from the wire count would mix units. + // `Content-Length` is wire bytes; `scheduled_response_buffer` is decompressed. let drain = matches!( body_size, http::BodySize::ContentLength(n) if n <= ABANDONED_RESPONSE_DRAIN_MAX_BYTES From a9e3a17eb2bb9f9e690b05d2d40f4f914930f49a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:39:59 +0000 Subject: [PATCH 4/7] fetch: drop the mutex lock in the finalizer drain check (JS-thread re-entrancy deadlock) The lock added in f4bdff37 deadlocks when the Weak finalizer fires for the same tasklet whose on_progress_update already holds self.mutex: on_body_received delivering the final chunk clears readable_stream_ref and then calls bytes.on_data(terminal), which allocates and can GC; the Response is collected, on_response_finalize reaches scenario 3 (body still Locked, stream ref gone, no promise), and abandon_response_body_from_finalizer tried to lock the held mutex. Reproduces as a 100 s timeout in fetch-leak.test.ts fixture #2 on release builds. Reading body_size without the lock is sound here: scenario 2b/3 is only reached with the transport Paused (HTTP thread not in callback()) or with the body already delivered (HTTP thread finished), so there is no concurrent writer. Also let on_timeout close an Ignore-mode drain even under disable_timeout, so the 5 s deadline applies to fetch({timeout:false}) once the user-visible promise has resolved. --- src/http/lib.rs | 2 +- src/runtime/webcore/fetch/FetchTasklet.rs | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index f7ff91972c75..e447db5480ce 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -2102,7 +2102,7 @@ impl<'a> HTTPClient<'a> { } pub fn on_timeout(&mut self, socket: HttpSocket) { - if self.flags.disable_timeout { + if self.flags.disable_timeout && !self.signals.is_receive_ignored() { return; } bun_core::scoped_log!(fetch, "Timeout {}\n", BStr::new(self.url.href)); diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index a34b69d7a72b..a9511cd4e910 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -2510,13 +2510,12 @@ impl FetchTasklet { /// no `JSCell` may be touched, so the close arm inlines `abort_task` minus /// `tracker.did_cancel`. fn abandon_response_body_from_finalizer(&mut self) { - // `body_size` is written by HTTP-thread `callback()` under `self.mutex`. - self.mutex.lock(); - let body_size = self.body_size; - self.mutex.unlock(); - // `Content-Length` is wire bytes; `scheduled_response_buffer` is decompressed. + // Scenario 2b/3 implies the transport is Paused or already done, so the + // HTTP-thread `callback()` is not concurrently assigning `body_size`; + // and this finalizer can re-enter on the JS thread while + // `on_progress_update` holds `self.mutex`, so locking here deadlocks. let drain = matches!( - body_size, + self.body_size, http::BodySize::ContentLength(n) if n <= ABANDONED_RESPONSE_DRAIN_MAX_BYTES ); if !drain && !self.signal_store.aborted.swap(true, Ordering::Relaxed) { From 188fd7ca3b055bfc4a308197fe8e2048f7f5d3bb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:25:09 +0000 Subject: [PATCH 5/7] fetch: read content-length via an atomic in the finalizer; guard on_writable re-arm The finalizer must not take self.mutex (JS-thread re-entrancy deadlock), but the header_progress path can deliver headers with an empty body buffer, leaving the mode at AutoPause and the socket un-paused; callback #2 on the HTTP thread can then be assigning body_size while the finalizer reads it. Mirror the Content-Length into an AtomicUsize that callback() writes and abandon_response_body_from_finalizer reads, so no multi-word enum is accessed across threads without a lock. Also guard on_writable's RequestStage::Body arm the same way as the on_data arms so a still-uploading request body cannot re-arm the idle timeout over the 5 s Ignore-mode drain deadline. --- src/http/lib.rs | 2 +- src/runtime/webcore/fetch/FetchTasklet.rs | 24 ++++++++++++++--------- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index e447db5480ce..0f9010236e75 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -3340,7 +3340,7 @@ impl<'a> HTTPClient<'a> { } RequestStage::Body => { bun_core::scoped_log!(fetch, "send body"); - if !self.state.flags.receive_paused { + if !self.state.flags.receive_paused && !self.signals.is_receive_ignored() { self.set_timeout(&socket); } diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index a9511cd4e910..ac3b4c0105ba 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -1,5 +1,5 @@ use core::ffi::c_void; -use core::sync::atomic::{AtomicBool, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use bun_boringssl as boringssl; use bun_cares_sys::c_ares_draft as c_ares; @@ -107,6 +107,10 @@ pub struct FetchTasklet { pub signals: Signals, pub signal_store: http::signals::Store, pub has_schedule_callback: AtomicBool, + /// Atomic mirror of `BodySize::ContentLength(n)` for the Weak finalizer + /// (`usize::MAX` = unknown/chunked). `body_size` itself is written by the + /// HTTP thread under `mutex`, which the finalizer cannot take. + pub response_content_length: AtomicUsize, // must be stored because AbortSignal stores reason weakly pub abort_reason: StrongOptional, @@ -1896,6 +1900,7 @@ impl FetchTasklet { signals: Signals::default(), signal_store: http::signals::Store::default(), has_schedule_callback: AtomicBool::new(false), + response_content_length: AtomicUsize::new(usize::MAX), abort_reason: StrongOptional::empty(), check_server_identity: fetch_options.check_server_identity, reject_unauthorized: fetch_options.reject_unauthorized, @@ -2394,6 +2399,11 @@ impl FetchTasklet { } task_ref.body_size = task_ref.result.body_size; + if let http::BodySize::ContentLength(n) = task_ref.body_size { + task_ref + .response_content_length + .store(n, Ordering::Release); + } let success = task_ref.result.is_success(); // `result.body` always aliases `task_ref.response_buffer` (the @@ -2510,14 +2520,10 @@ impl FetchTasklet { /// no `JSCell` may be touched, so the close arm inlines `abort_task` minus /// `tracker.did_cancel`. fn abandon_response_body_from_finalizer(&mut self) { - // Scenario 2b/3 implies the transport is Paused or already done, so the - // HTTP-thread `callback()` is not concurrently assigning `body_size`; - // and this finalizer can re-enter on the JS thread while - // `on_progress_update` holds `self.mutex`, so locking here deadlocks. - let drain = matches!( - self.body_size, - http::BodySize::ContentLength(n) if n <= ABANDONED_RESPONSE_DRAIN_MAX_BYTES - ); + // `self.mutex` cannot be taken here: this finalizer can re-enter on + // the JS thread while `on_progress_update` already holds it. + let drain = self.response_content_length.load(Ordering::Acquire) + <= ABANDONED_RESPONSE_DRAIN_MAX_BYTES; if !drain && !self.signal_store.aborted.swap(true, Ordering::Relaxed) { if let Some(http_) = self.http.as_mut() { http::http_thread().schedule_shutdown(http_); From 420c493e69c8eab3171d09dfdafdeedc0e46e126 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:27:18 +0000 Subject: [PATCH 6/7] [autofix.ci] apply automated fixes --- src/runtime/webcore/fetch/FetchTasklet.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/runtime/webcore/fetch/FetchTasklet.rs b/src/runtime/webcore/fetch/FetchTasklet.rs index ac3b4c0105ba..49279541057e 100644 --- a/src/runtime/webcore/fetch/FetchTasklet.rs +++ b/src/runtime/webcore/fetch/FetchTasklet.rs @@ -2400,9 +2400,7 @@ impl FetchTasklet { task_ref.body_size = task_ref.result.body_size; if let http::BodySize::ContentLength(n) = task_ref.body_size { - task_ref - .response_content_length - .store(n, Ordering::Release); + task_ref.response_content_length.store(n, Ordering::Release); } let success = task_ref.result.is_success(); From f81d23d6403c7c9186fb9f94655bfe66f82b9214 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 26 Jul 2026 04:09:07 +0000 Subject: [PATCH 7/7] ci: retrigger