From 38955001e435f206da871419122605ffcdc7bba7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:05:28 +0000 Subject: [PATCH 1/6] fetch: make the idle timer an absolute deadline for the response header block The HTTP client re-armed its socket idle timer on every partial header read (the short_read! path in handle_on_data_headers), so a server that trickled one header byte per interval shorter than the configured idle timeout could keep a request alive indefinitely. A silent stall in the same phase was already bounded; only the drip defeated it. Stop re-arming on a partial header read. The timer stays as armed by on_open / on_writable (the last outbound write), which turns it into an absolute deadline for the header block to complete, matching undici's headersTimeout semantics. The body path continues to re-arm per chunk (undici bodyTimeout semantics), so a slow-but-steady body is still accepted. --- src/http/lib.rs | 18 +++++--- test/js/web/fetch/fetch.test.ts | 80 +++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index eca6a662c26a..d190ca21ec8a 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -3618,11 +3618,18 @@ impl<'a> HTTPClient<'a> { buffer.list.as_slice() }; - // Persist the unparsed tail for the next `on_data` and re-arm the - // receive timeout. When `needs_move`, `to_read` is a suffix of - // `incoming_data` and is copied into the (currently empty) accumulation - // buffer; otherwise `to_read` is a suffix of `buffer`, so the consumed - // prefix is drained and `buffer` is moved back into state. + // Persist the unparsed tail for the next `on_data`. When `needs_move`, + // `to_read` is a suffix of `incoming_data` and is copied into the + // (currently empty) accumulation buffer; otherwise `to_read` is a suffix + // of `buffer`, so the consumed prefix is drained and `buffer` is moved + // back into state. + // + // Intentionally does NOT re-arm the idle timer: a server that trickles + // one header byte per interval shorter than `idle_timeout_seconds` + // would otherwise keep the request alive forever. Leaving the timer as + // armed by `on_open` / `on_writable` makes it an absolute deadline for + // the header block to complete (undici `headersTimeout` semantics); the + // body path (`on_data` Body/BodyChunk) still re-arms per chunk. macro_rules! short_read { () => {{ bun_core::scoped_log!(fetch, "handleShortRead"); @@ -3638,7 +3645,6 @@ impl<'a> HTTPClient<'a> { .drain_front(buffer.list.len().saturating_sub(keep)); self.state.response_message_buffer = buffer; } - self.set_timeout(&socket); return; }}; } diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 88180c6d016b..99a10bce05dc 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -3068,3 +3068,83 @@ it("an explicit numeric `timeout` extends the socket idle deadline past the defa expect(out.withDefault).toStartWith("ERR:"); expect(exitCode).toBe(0); }, 60_000); + +it("the idle timer is an absolute deadline for the response header block (not re-armed by a byte drip)", async () => { + // A server that trickles one response-header byte at a time, each interval + // shorter than the request's idle timeout, must not be able to keep the + // request alive indefinitely. The idle timer is armed when the request is + // written and is not re-armed on partial header reads, so it bounds how long + // the header block may take to arrive in total (undici `headersTimeout` + // semantics). Once the header block completes the body path re-arms per + // chunk, so a slow-but-steady body is still accepted. + const HEAD = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n"; + const BODY = "hello"; + const DRIP_MS = 2_000; + const DRIP_N = 10; // header drip sends this many single bytes, then the rest at once + const IDLE_MS = 5_000; + + const sockets = new Set(); + const intervals = new Set>(); + const reset = () => { + for (const iv of intervals) clearInterval(iv); + intervals.clear(); + for (const s of sockets) s.destroy(); + sockets.clear(); + }; + const server = net.createServer(sock => { + sockets.add(sock); + sock.on("close", () => sockets.delete(sock)); + sock.on("error", () => {}); + sock.once("data", chunk => { + // /h drips DRIP_N header bytes then bursts the rest + body. + // /b bursts the header block then drips the body byte-by-byte. + const headerDrip = chunk.includes("/h "); + if (!headerDrip) sock.write(HEAD); + const dripped = headerDrip ? HEAD.slice(0, DRIP_N) : BODY; + const tail = headerDrip ? HEAD.slice(DRIP_N) + BODY : ""; + let i = 0; + const iv = setInterval(() => { + if (sock.destroyed) { + clearInterval(iv); + intervals.delete(iv); + return; + } + if (i < dripped.length) { + sock.write(dripped[i++]); + } else { + clearInterval(iv); + intervals.delete(iv); + sock.end(tail); + } + }, DRIP_MS); + intervals.add(iv); + }); + }); + await new Promise(r => server.listen(0, "127.0.0.1", () => r())); + const port = (server.address() as AddressInfo).port; + + try { + const settle = (path: string) => + fetch(`http://127.0.0.1:${port}${path}`, { timeout: IDLE_MS }).then( + async r => ({ ok: true as const, status: r.status, body: await r.text() }), + e => ({ ok: false as const, name: e?.name as string, message: String(e?.message ?? e) }), + ); + + // Header drip: DRIP_N bytes * DRIP_MS = ~20s of drip before the response + // would complete. The 5s idle deadline (served by uSockets' 4s-tick sweep, + // so worst case ~9s) must fire first. On a build that re-arms the timer on + // every partial header read this resolves with 200 after the full drip. + const hdr = await settle("/h"); + expect(hdr).toEqual({ ok: false, name: "TimeoutError", message: "The operation timed out." }); + reset(); + + // Body drip: headers arrive in one write, then the 5-byte body trickles at + // DRIP_MS/byte (~10s total). Each body chunk re-arms the idle timer, so + // this resolves despite taking longer than IDLE_MS overall. + const bod = await settle("/b"); + expect(bod).toEqual({ ok: true, status: 200, body: "hello" }); + } finally { + reset(); + await new Promise(r => server.close(() => r())); + } +}, 60_000); From b27816923a182a4ac0cd5c0ddd630b7663f3b6ac Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:24:31 +0000 Subject: [PATCH 2/6] http: gate the proxy-tunnel on_data re-arm on the body phase too The outer on_data re-armed the idle timer on every tunnelled byte before decryption, so the header-phase absolute deadline did not hold for HTTPS-through-CONNECT-proxy requests. Gate the re-arm on response_stage == Body/BodyChunk, mirroring the non-proxy dispatch. Also update the IDLE_TIMEOUT_SECONDS doc comment to match the new header-phase semantics. --- src/http/lib.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index d190ca21ec8a..cb64f6cec7e5 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -261,9 +261,10 @@ pub static OVERRIDDEN_DEFAULT_USER_AGENT: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new(); /// Idle timeout for HTTP client sockets, in seconds. The timer is armed in -/// `on_open` (so it covers the TLS handshake) and re-armed on every read/write; -/// if no bytes move in either direction for this long the request fails with -/// `error.Timeout`. 0 disables the timer (matching `disable_timeout = true`). +/// `on_open` (so it covers the TLS handshake) and re-armed on writes and on +/// body-phase reads; response-header reads do not re-arm it, so it is an +/// absolute deadline for the header block to complete (undici `headersTimeout` +/// semantics). 0 disables the timer (matching `disable_timeout = true`). /// Overridable via `BUN_CONFIG_HTTP_IDLE_TIMEOUT`. Default is 5 minutes — the /// previous hard-coded value — so unchanged environments see identical /// behaviour except that the handshake phase is now also covered. Values @@ -3823,8 +3824,17 @@ 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); + // Re-arm only once the origin's header block has completed, same as + // the non-proxy dispatch below: the inner TLS handshake and the + // origin's response headers are bounded absolutely (the timer stays + // as armed by `on_writable`), and body chunks re-arm per read. + if matches!( + self.state.response_stage, + ResponseStage::Body | ResponseStage::BodyChunk + ) && !self.state.flags.receive_paused + { + self.set_timeout(&socket); + } self.proxy_tunnel_mut().unwrap().receive(incoming_data); return; } From 245fc837d7ef8d8d7887c16dc7270f78b31f12ae Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:26:34 +0000 Subject: [PATCH 3/6] http: shorten header-phase re-arm comments, point at IDLE_TIMEOUT_SECONDS --- src/http/lib.rs | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index cb64f6cec7e5..8cfa65627a7f 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -3623,14 +3623,8 @@ impl<'a> HTTPClient<'a> { // `to_read` is a suffix of `incoming_data` and is copied into the // (currently empty) accumulation buffer; otherwise `to_read` is a suffix // of `buffer`, so the consumed prefix is drained and `buffer` is moved - // back into state. - // - // Intentionally does NOT re-arm the idle timer: a server that trickles - // one header byte per interval shorter than `idle_timeout_seconds` - // would otherwise keep the request alive forever. Leaving the timer as - // armed by `on_open` / `on_writable` makes it an absolute deadline for - // the header block to complete (undici `headersTimeout` semantics); the - // body path (`on_data` Body/BodyChunk) still re-arms per chunk. + // back into state. Does not re-arm the idle timer (header phase is an + // absolute deadline; see [`IDLE_TIMEOUT_SECONDS`]). macro_rules! short_read { () => {{ bun_core::scoped_log!(fetch, "handleShortRead"); @@ -3824,10 +3818,8 @@ impl<'a> HTTPClient<'a> { } if self.proxy_tunnel.is_some() { - // Re-arm only once the origin's header block has completed, same as - // the non-proxy dispatch below: the inner TLS handshake and the - // origin's response headers are bounded absolutely (the timer stays - // as armed by `on_writable`), and body chunks re-arm per read. + // Body phase only, mirroring the non-proxy dispatch below (header + // phase is an absolute deadline; see [`IDLE_TIMEOUT_SECONDS`]). if matches!( self.state.response_stage, ResponseStage::Body | ResponseStage::BodyChunk From 94b065e856eaac5528d3a6147cc1e8945a6c7509 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:29:59 +0000 Subject: [PATCH 4/6] http: re-arm the idle timer once at headers-complete Gives the body phase a fresh idle window instead of whatever was left of the header-phase deadline, matching undici where headersTimeout and bodyTimeout are independent. Folded from #36146. --- src/http/lib.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/http/lib.rs b/src/http/lib.rs index 8cfa65627a7f..de3e50f6e282 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -3727,6 +3727,8 @@ impl<'a> HTTPClient<'a> { return; } }; + // Headers complete: start the body-idle window fresh (see [`IDLE_TIMEOUT_SECONDS`]). + self.set_timeout(&socket); if (self.state.content_encoding_i as usize) < response.headers.list.len() && !self.state.flags.did_set_content_encoding From 7869e08ceb8a4e65f7da7aadbad07920dcf06503 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:44:14 +0000 Subject: [PATCH 5/6] http: drop redundant BodyChunk re-arm; run drip test cases concurrently The headers-complete re-arm in 94b065e made the BodyChunk-arm set_timeout in handle_on_data_headers redundant (same value, same on_data call). Drip test: run /h and /b concurrently and shrink the body to 3 bytes so wall-clock drops from ~18s to ~8s. --- src/http/lib.rs | 1 - test/js/web/fetch/fetch.test.ts | 38 ++++++++++++++------------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index de3e50f6e282..4c83f1423a45 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -3785,7 +3785,6 @@ impl<'a> HTTPClient<'a> { return; } } else if self.state.response_stage == ResponseStage::BodyChunk { - self.set_timeout(&socket); let report_progress = match self.handle_response_body_chunked_encoding(to_read) { Ok(b) => b, Err(err) => { diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 99a10bce05dc..d4bd8359e8dc 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -3077,20 +3077,14 @@ it("the idle timer is an absolute deadline for the response header block (not re // the header block may take to arrive in total (undici `headersTimeout` // semantics). Once the header block completes the body path re-arms per // chunk, so a slow-but-steady body is still accepted. - const HEAD = "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n"; - const BODY = "hello"; + const BODY = "abc"; + const HEAD = `HTTP/1.1 200 OK\r\nContent-Length: ${BODY.length}\r\n\r\n`; const DRIP_MS = 2_000; const DRIP_N = 10; // header drip sends this many single bytes, then the rest at once const IDLE_MS = 5_000; const sockets = new Set(); const intervals = new Set>(); - const reset = () => { - for (const iv of intervals) clearInterval(iv); - intervals.clear(); - for (const s of sockets) s.destroy(); - sockets.clear(); - }; const server = net.createServer(sock => { sockets.add(sock); sock.on("close", () => sockets.delete(sock)); @@ -3130,21 +3124,21 @@ it("the idle timer is an absolute deadline for the response header block (not re e => ({ ok: false as const, name: e?.name as string, message: String(e?.message ?? e) }), ); - // Header drip: DRIP_N bytes * DRIP_MS = ~20s of drip before the response - // would complete. The 5s idle deadline (served by uSockets' 4s-tick sweep, - // so worst case ~9s) must fire first. On a build that re-arms the timer on - // every partial header read this resolves with 200 after the full drip. - const hdr = await settle("/h"); - expect(hdr).toEqual({ ok: false, name: "TimeoutError", message: "The operation timed out." }); - reset(); - - // Body drip: headers arrive in one write, then the 5-byte body trickles at - // DRIP_MS/byte (~10s total). Each body chunk re-arms the idle timer, so - // this resolves despite taking longer than IDLE_MS overall. - const bod = await settle("/b"); - expect(bod).toEqual({ ok: true, status: 200, body: "hello" }); + // /h: DRIP_N bytes * DRIP_MS = ~20s of drip before the response would + // complete; the 5s idle deadline (uSockets 4s-tick sweep, so ~5-9s) must + // fire first. A build that re-arms on every partial header read resolves + // 200 after the full drip instead. + // /b: headers arrive in one write, then the 3-byte body trickles at + // DRIP_MS/byte (~8s). Each body chunk re-arms the idle timer, so this + // resolves despite taking longer than IDLE_MS overall. + const [hdr, bod] = await Promise.all([settle("/h"), settle("/b")]); + expect({ hdr, bod }).toEqual({ + hdr: { ok: false, name: "TimeoutError", message: "The operation timed out." }, + bod: { ok: true, status: 200, body: BODY }, + }); } finally { - reset(); + for (const iv of intervals) clearInterval(iv); + for (const s of sockets) s.destroy(); await new Promise(r => server.close(() => r())); } }, 60_000); From 97e21033b3ac22682523ed9de9a3d3a469b8d736 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:11:39 +0000 Subject: [PATCH 6/6] http: replace dead receive_paused conjunct with debug_assert in proxy on_data maybe_pause_receive early-returns when proxy_tunnel.is_some(), so receive_paused is never true inside the proxy on_data branch. Document the invariant with a debug_assert instead of a conjunct that can never be false. --- src/http/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/http/lib.rs b/src/http/lib.rs index 4c83f1423a45..21cf833f100f 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -3821,11 +3821,11 @@ impl<'a> HTTPClient<'a> { if self.proxy_tunnel.is_some() { // Body phase only, mirroring the non-proxy dispatch below (header // phase is an absolute deadline; see [`IDLE_TIMEOUT_SECONDS`]). + debug_assert!(!self.state.flags.receive_paused); // maybe_pause_receive bails on proxy_tunnel if matches!( self.state.response_stage, ResponseStage::Body | ResponseStage::BodyChunk - ) && !self.state.flags.receive_paused - { + ) { self.set_timeout(&socket); } self.proxy_tunnel_mut().unwrap().receive(incoming_data);