From bb5a7e2c6925a946a03b5d780feea30e13b65f67 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 11:02:21 +0000 Subject: [PATCH 1/5] Bun.serve: honour handler-set Content-Length for ReadableStream bodies render_metadata()/do_write_headers() stripped Content-Length on every response, then only re-emitted it for the sendfile path. A Response with a ReadableStream body and an explicit Content-Length therefore went out as Transfer-Encoding: chunked, so proxies and download endpoints that already knew the size could never tell the client (no progress bar, no resumable downloads, 411 from S3 presigned PUT targets). Capture the handler's Content-Length in render_metadata() before it is stripped, only when the body is a ReadableStream (sink/byte_stream set) and the handler did not also set Transfer-Encoding. Emit it and mark the uWS response so write()/end() use content-length framing instead of chunked. The sink's single-write try_end() fast path now routes to end() when the header is already marked, avoiding a duplicate Content-Length. HEAD over a stream body frames the same way. In-memory and file bodies are unchanged: their size is known and Bun keeps framing from it. The fetch-gzip stream test set Content-Length: 1 on a larger gzip stream to rely on the header being discarded; drop that header so the test keeps its actual subject (gzip decoding over a streamed body). Fixes #10507 --- src/runtime/server/RequestContext.rs | 32 ++- src/runtime/webcore/streams.rs | 13 ++ test/js/bun/http/bun-serve-headers.test.ts | 235 +++++++++++++++++++++ test/js/web/fetch/fetch-gzip.test.ts | 1 - 4 files changed, 279 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index f85ad8e6a6a9..f76fb24733d0 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2600,8 +2600,21 @@ where this.end_without_body(this.should_close_connection()); } Body::Value::Locked(_) => { + // GET honours a handler-set Content-Length for a stream body + // (#10507); HEAD must frame the same way. Read it before + // `render_metadata()` swaps out and strips the init headers. + let user_content_length = response.get_init_headers_mut().and_then(|h| { + if h.fast_has(jsc::HTTPHeaderName::TransferEncoding) { + return None; + } + let cl = h.fast_get(jsc::HTTPHeaderName::ContentLength)?; + let cl_str = cl.to_slice(); + bun_core::parse_int::(cl_str.slice(), 10).ok() + }); this.render_metadata(); - if !HTTP3 { + if let Some(cl) = user_content_length { + resp.write_header_int(b"content-length", cl); + } else if !HTTP3 { // SAFETY: FFI handle resp.write_header(b"transfer-encoding", b"chunked"); } @@ -3632,9 +3645,23 @@ where }); let mut has_content_disposition = false; let mut has_content_range = false; + // A ReadableStream body has no size for uWS to frame from, so the + // handler's Content-Length (#10507) is the only thing that can give + // the client a length. `do_write_headers` strips it; capture it first. + let mut user_content_length: Option = None; if let Some(mut headers_) = response.swap_init_headers() { has_content_disposition = headers_.fast_has(jsc::HTTPHeaderName::ContentDisposition); has_content_range = headers_.fast_has(jsc::HTTPHeaderName::ContentRange); + if (self.sink.is_some() || self.byte_stream.is_some()) + && !self.flags.needs_content_length() + && !headers_.fast_has(jsc::HTTPHeaderName::TransferEncoding) + { + if let Some(cl) = headers_.fast_get(jsc::HTTPHeaderName::ContentLength) { + let cl_str = cl.to_slice(); + user_content_length = bun_core::parse_int::(cl_str.slice(), 10).ok(); + drop(cl_str); + } + } // For .slice()-driven ranges, only promote to 206 if the user // also set Content-Range (preserves the old contract). For an // incoming Range: header (sendfile.total > 0) we always 206. @@ -3729,6 +3756,9 @@ where resp.write_header_int(b"content-length", size as u64); resp.mark_wrote_content_length_header(); self.flags.set_needs_content_length(false); + } else if let Some(cl) = user_content_length { + resp.write_header_int(b"content-length", cl); + resp.mark_wrote_content_length_header(); } if needs_content_range && !has_content_range { diff --git a/src/runtime/webcore/streams.rs b/src/runtime/webcore/streams.rs index d4dfdc47f1cf..c3eb95d67d82 100644 --- a/src/runtime/webcore/streams.rs +++ b/src/runtime/webcore/streams.rs @@ -1218,6 +1218,12 @@ impl HTTPServerWritable { if self.requested_end && !res.state().is_http_write_called() { self.handle_first_write_if_necessary(); + if res.state().has_written_content_length_header() { + res.end(buf, false); + self.has_backpressure = false; + self.handle_wrote(buf.len()); + return true; + } let success = res.try_end(buf, self.end_len, false); if success { self.has_backpressure = false; @@ -1292,6 +1298,13 @@ impl HTTPServerWritable { if self.requested_end && !res.state().is_http_write_called() { self.handle_first_write_if_necessary(); + if res.state().has_written_content_length_header() { + let buf_len = self.buffer.len().saturating_sub(base); + res.end(&self.buffer[base..], false); + self.has_backpressure = false; + self.handle_wrote(buf_len); + return true; + } let end_len = self.end_len; let success = res.try_end(&self.buffer[base..], end_len, false); if success { diff --git a/test/js/bun/http/bun-serve-headers.test.ts b/test/js/bun/http/bun-serve-headers.test.ts index 46ee49cc1756..f9facee21971 100644 --- a/test/js/bun/http/bun-serve-headers.test.ts +++ b/test/js/bun/http/bun-serve-headers.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import { once } from "node:events"; import * as net from "node:net"; +import { Readable } from "node:stream"; // https://github.com/oven-sh/bun/issues/9180 test("weird headers", async () => { @@ -160,3 +161,237 @@ describe("response Connection: close closes the socket", () => { } }); }); + +// https://github.com/oven-sh/bun/issues/10507 +// Bun.serve stripped a handler-set Content-Length on ReadableStream bodies and +// fell back to Transfer-Encoding: chunked, so clients never saw the size the +// handler already knew (proxies, downloads). +describe("response Content-Length for ReadableStream bodies", () => { + async function rawGET(port: number, method = "GET"): Promise<{ head: string; body: Buffer }> { + const socket = net.connect(port, "127.0.0.1"); + try { + await once(socket, "connect"); + socket.write(`${method} / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); + const chunks: Buffer[] = []; + const { promise, resolve } = Promise.withResolvers(); + let done = false; + const finish = () => { + if (done) return; + done = true; + resolve(); + }; + socket.on("data", c => { + chunks.push(c); + // Resolve as soon as the response is complete so we don't depend on + // Connection: close actually closing the socket. + const raw = Buffer.concat(chunks); + const sep = raw.indexOf("\r\n\r\n"); + if (sep === -1) return; + const head = raw.subarray(0, sep).toString("latin1"); + const body = raw.subarray(sep + 4); + if (method === "HEAD") return finish(); + const m = head.match(/^content-length:\s*(\d+)\r?$/im); + if (m) { + if (body.length >= Number(m[1])) finish(); + } else if (/^transfer-encoding:\s*chunked/im.test(head)) { + if (body.includes("\r\n0\r\n\r\n") || body.subarray(0, 5).equals(Buffer.from("0\r\n\r\n"))) finish(); + } + }); + socket.on("close", finish); + await promise; + const raw = Buffer.concat(chunks); + const sep = raw.indexOf("\r\n\r\n"); + return { head: raw.subarray(0, sep).toString("latin1"), body: raw.subarray(sep + 4) }; + } finally { + socket.destroy(); + } + } + + function contentLength(head: string): string[] { + return [...head.matchAll(/^content-length:\s*(\d+)\r?$/gim)].map(m => m[1]); + } + + test.concurrent("async pull stream", async () => { + const chunk = Buffer.alloc(1024, "A"); + const total = 5 * chunk.length; + using server = Bun.serve({ + port: 0, + development: false, + fetch() { + let left = 5; + return new Response( + new ReadableStream({ + async pull(c) { + if (left-- === 0) return c.close(); + await new Promise(r => setImmediate(r)); + c.enqueue(chunk); + }, + }), + { headers: { "Content-Length": String(total) } }, + ); + }, + }); + + const { head, body } = await rawGET(server.port); + expect(head.toLowerCase()).not.toContain("transfer-encoding"); + expect(contentLength(head)).toEqual([String(total)]); + expect(body.length).toBe(total); + expect(body.subarray(0, 4).toString()).toBe("AAAA"); + }); + + test.concurrent("Readable.toWeb stream (node:stream source)", async () => { + const payload = Buffer.alloc(4096, "C"); + using server = Bun.serve({ + port: 0, + development: false, + fetch() { + const r = Readable.from([payload.subarray(0, 2048), payload.subarray(2048)]); + return new Response(Readable.toWeb(r) as ReadableStream, { + headers: { "Content-Length": String(payload.length) }, + }); + }, + }); + + const { head, body } = await rawGET(server.port); + expect(head.toLowerCase()).not.toContain("transfer-encoding"); + expect(contentLength(head)).toEqual([String(payload.length)]); + expect(body.equals(payload)).toBe(true); + }); + + test.concurrent("proxied fetch().body", async () => { + const payload = Buffer.alloc(8000, "D"); + using upstream = Bun.serve({ + port: 0, + development: false, + fetch: () => new Response(payload), + }); + using proxy = Bun.serve({ + port: 0, + development: false, + async fetch() { + const r = await fetch(upstream.url); + return new Response(r.body, { + headers: { "Content-Length": r.headers.get("content-length")! }, + }); + }, + }); + + const res = await fetch(proxy.url); + expect(res.headers.get("content-length")).toBe(String(payload.length)); + expect(res.headers.has("transfer-encoding")).toBe(false); + const body = Buffer.from(await res.arrayBuffer()); + expect(body.equals(payload)).toBe(true); + }); + + test.concurrent("HEAD matches GET framing", async () => { + using server = Bun.serve({ + port: 0, + development: false, + fetch() { + let left = 3; + return new Response( + new ReadableStream({ + pull(c) { + if (left-- === 0) return c.close(); + c.enqueue(new Uint8Array(10)); + }, + }), + { headers: { "Content-Length": "30" } }, + ); + }, + }); + + const { head, body } = await rawGET(server.port, "HEAD"); + expect(head.toLowerCase()).not.toContain("transfer-encoding"); + expect(contentLength(head)).toEqual(["30"]); + expect(body.length).toBe(0); + }); + + test.concurrent("no Content-Length still uses chunked", async () => { + using server = Bun.serve({ + port: 0, + development: false, + fetch() { + let left = 2; + return new Response( + new ReadableStream({ + async pull(c) { + if (left-- === 0) return c.close(); + await new Promise(r => setImmediate(r)); + c.enqueue(new TextEncoder().encode("abc")); + }, + }), + ); + }, + }); + + const { head } = await rawGET(server.port); + expect(head.toLowerCase()).toContain("transfer-encoding: chunked"); + expect(contentLength(head)).toEqual([]); + }); + + // Unchanged: for in-memory bodies Bun frames from the actual byte length, + // not the handler's header. + test.concurrent("in-memory body still framed from actual size", async () => { + using server = Bun.serve({ + port: 0, + development: false, + fetch: () => new Response("hi", { headers: { "Content-Length": "999" } }), + }); + + const { head, body } = await rawGET(server.port); + expect(contentLength(head)).toEqual(["2"]); + expect(body.toString()).toBe("hi"); + }); + + test.concurrent("keep-alive: second request parses after a CL-framed stream body", async () => { + const payload = Buffer.alloc(256, "E"); + let handled = 0; + using server = Bun.serve({ + port: 0, + development: false, + fetch() { + handled++; + let left = 2; + return new Response( + new ReadableStream({ + async pull(c) { + if (left-- === 0) return c.close(); + await new Promise(r => setImmediate(r)); + c.enqueue(payload.subarray(0, 128)); + }, + }), + { headers: { "Content-Length": String(payload.length) } }, + ); + }, + }); + + const socket = net.connect(server.port, "127.0.0.1"); + try { + await once(socket, "connect"); + socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + const chunks: Buffer[] = []; + const { promise, resolve } = Promise.withResolvers(); + let sent2 = false; + socket.on("data", c => { + chunks.push(c); + const raw = Buffer.concat(chunks); + const sep = raw.indexOf("\r\n\r\n"); + if (!sent2 && sep !== -1 && raw.length >= sep + 4 + payload.length) { + sent2 = true; + socket.write("GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + } + if ((raw.toString("latin1").match(/HTTP\/1\.1 200/g) ?? []).length === 2) resolve(); + }); + socket.on("close", resolve); + await promise; + const raw = Buffer.concat(chunks).toString("latin1"); + const head1 = raw.split("\r\n\r\n")[0]; + expect(contentLength(head1)).toEqual([String(payload.length)]); + expect((raw.match(/HTTP\/1\.1 200/g) ?? []).length).toBe(2); + expect(handled).toBe(2); + } finally { + socket.destroy(); + } + }); +}); diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts index 701fbf4208d8..2780d64061b5 100644 --- a/test/js/web/fetch/fetch-gzip.test.ts +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -112,7 +112,6 @@ it("fetch() with a gzip response works (one chunk, streamed, with a delay)", asy headers: { "Content-Encoding": "gzip", "Content-Type": "text/html; charset=utf-8", - "Content-Length": "1", }, }, ); From 157c5aed52d882b765261f5dac3a3fb98107b5fd Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:46:21 +0000 Subject: [PATCH 2/5] force-close when a stream errors after Content-Length is committed Addresses review: render_metadata() now commits the handler's Content-Length before the stream pump starts, so a stream that errors before writing any body bytes would leave the client waiting on the promised bytes over keep-alive. handle_reject, handle_reject_stream and render_missing now force-close in that state (same treatment as the existing mid-body error case). Also adds a single-write sub-highWaterMark test that exercises the end() branch in HTTPServerWritable, and makes the proxy test stream its upstream body so the byte_stream path is taken deterministically. --- src/runtime/server/RequestContext.rs | 22 ++++-- test/js/bun/http/bun-serve-headers.test.ts | 81 +++++++++++++++++++++- 2 files changed, 96 insertions(+), 7 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index f76fb24733d0..8c622432cd1c 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -934,7 +934,9 @@ where } } let state = resp.state(); - if state.is_http_write_called() && state.is_response_pending() { + if state.is_response_pending() + && (state.is_http_write_called() || state.has_written_content_length_header()) + { ctx.force_close(); } else { ctx.end_stream(ctx.should_close_connection()); @@ -992,6 +994,13 @@ where // `RequestContext` threaded through cork user-data. let ctx = unsafe { &mut *ctx }; if let Some(resp) = ctx.resp { + // A committed Content-Length with no body bytes cannot be completed + // gracefully; close so the client sees a truncated message instead + // of waiting on the promised bytes. + if ctx.flags.has_written_status() && resp.state().has_written_content_length_header() { + ctx.force_close(); + return; + } if !DEBUG_MODE { if !ctx.flags.has_written_status() { resp.write_status(b"204 No Content"); @@ -1151,9 +1160,8 @@ where ctx_log!("endStream"); if let Some(resp) = self.resp { self.detach_response(); - // This will send a terminating 0\r\n\r\n chunk to the client - // We only want to do that if they're still expecting a body - // We cannot call this function if the Content-Length header was previously set + // Chunked framing: terminating 0\r\n\r\n. With Content-Length + // marked, sendTerminatingChunk()/internalEnd() just markDone. if resp.state().is_response_pending() { resp.end_stream(close_connection); } @@ -3078,9 +3086,13 @@ where // Body bytes were already written: close without the terminating chunk // (RFC 9112 section 7) so the client sees an incomplete message, not a // truncated body that looks like a complete, successful response. + // Same when a Content-Length is already on the wire (#10507): sending + // a 0-byte body against a non-zero promise would hang keep-alive. if let Some(resp) = req.resp { let state = resp.state(); - if state.is_http_write_called() && state.is_response_pending() { + if state.is_response_pending() + && (state.is_http_write_called() || state.has_written_content_length_header()) + { req.force_close(); return; } diff --git a/test/js/bun/http/bun-serve-headers.test.ts b/test/js/bun/http/bun-serve-headers.test.ts index f9facee21971..066045043c12 100644 --- a/test/js/bun/http/bun-serve-headers.test.ts +++ b/test/js/bun/http/bun-serve-headers.test.ts @@ -239,6 +239,69 @@ describe("response Content-Length for ReadableStream bodies", () => { expect(body.subarray(0, 4).toString()).toBe("AAAA"); }); + // Exercises HTTPServerWritable's single-write fast path: a sub-highWaterMark + // stream that enqueues once and closes synchronously buffers then ends in one + // send, and end() must not let try_end() add a second Content-Length. + test.concurrent("sub-highWaterMark stream, single write", async () => { + const payload = Buffer.alloc(100, "S"); + using server = Bun.serve({ + port: 0, + development: false, + fetch() { + return new Response( + new ReadableStream({ + start(c) { + c.enqueue(payload); + c.close(); + }, + }), + { headers: { "Content-Length": String(payload.length) } }, + ); + }, + }); + + const { head, body } = await rawGET(server.port); + expect(head.toLowerCase()).not.toContain("transfer-encoding"); + expect(contentLength(head)).toEqual([String(payload.length)]); + expect(body.equals(payload)).toBe(true); + }); + + // The stream errors after the Content-Length is already on the wire: the + // server must close the socket rather than leave the client waiting on the + // promised bytes. + test.concurrent("stream that errors after Content-Length is committed closes the socket", async () => { + using server = Bun.serve({ + port: 0, + development: false, + fetch() { + return new Response( + new ReadableStream({ + async pull() { + await new Promise(r => setImmediate(r)); + throw new Error("boom"); + }, + }), + { headers: { "Content-Length": "100" } }, + ); + }, + error() {}, + }); + + const socket = net.connect(server.port, "127.0.0.1"); + try { + socket.on("error", () => {}); + await once(socket, "connect"); + socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + const chunks: Buffer[] = []; + socket.on("data", c => chunks.push(c)); + await once(socket, "close"); + const head = Buffer.concat(chunks).toString("latin1").split("\r\n\r\n")[0] ?? ""; + expect(contentLength(head).length).toBeLessThanOrEqual(1); + } finally { + socket.destroy(); + } + }); + test.concurrent("Readable.toWeb stream (node:stream source)", async () => { const payload = Buffer.alloc(4096, "C"); using server = Bun.serve({ @@ -263,7 +326,21 @@ describe("response Content-Length for ReadableStream bodies", () => { using upstream = Bun.serve({ port: 0, development: false, - fetch: () => new Response(payload), + fetch() { + // Stream the body so the proxy cannot have it fully buffered before + // rendering (the byte_stream path, not the blob fast path). + let sent = 0; + return new Response( + new ReadableStream({ + async pull(c) { + if (sent >= payload.length) return c.close(); + await new Promise(r => setImmediate(r)); + c.enqueue(payload.subarray(sent, (sent += 2000))); + }, + }), + { headers: { "Content-Length": String(payload.length) } }, + ); + }, }); using proxy = Bun.serve({ port: 0, @@ -271,7 +348,7 @@ describe("response Content-Length for ReadableStream bodies", () => { async fetch() { const r = await fetch(upstream.url); return new Response(r.body, { - headers: { "Content-Length": r.headers.get("content-length")! }, + headers: { "Content-Length": String(payload.length) }, }); }, }); From 60772afa2f6d2aad0d34d300e569ca5d1651e1b5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:25:45 +0000 Subject: [PATCH 3/5] force-close errored byte streams after Content-Length is committed The ByteStream error path (a proxied fetch().body whose upstream dies) routed through on_pipe, which ended the response cleanly even when a committed Content-Length could no longer be satisfied, leaving keep-alive clients waiting on the promised bytes. Apply the same close-instead-of-end rule handle_reject uses. Also await the socket close event directly in the error-path tests: once(socket, "close") rejects on the ECONNRESET a forced close surfaces on Windows. Trim review-flagged comments. --- src/runtime/server/RequestContext.rs | 28 +++++----- test/js/bun/http/bun-serve-headers.test.ts | 64 +++++++++++++++++++++- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 8c622432cd1c..dc7d7317188b 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -994,9 +994,7 @@ where // `RequestContext` threaded through cork user-data. let ctx = unsafe { &mut *ctx }; if let Some(resp) = ctx.resp { - // A committed Content-Length with no body bytes cannot be completed - // gracefully; close so the client sees a truncated message instead - // of waiting on the promised bytes. + // A committed Content-Length with no body bytes can only be cut short. if ctx.flags.has_written_status() && resp.state().has_written_content_length_header() { ctx.force_close(); return; @@ -1160,8 +1158,7 @@ where ctx_log!("endStream"); if let Some(resp) = self.resp { self.detach_response(); - // Chunked framing: terminating 0\r\n\r\n. With Content-Length - // marked, sendTerminatingChunk()/internalEnd() just markDone. + // Terminating 0\r\n\r\n chunk; a bare markDone when Content-Length was written. if resp.state().is_response_pending() { resp.end_stream(close_connection); } @@ -2608,9 +2605,7 @@ where this.end_without_body(this.should_close_connection()); } Body::Value::Locked(_) => { - // GET honours a handler-set Content-Length for a stream body - // (#10507); HEAD must frame the same way. Read it before - // `render_metadata()` swaps out and strips the init headers. + // Read before render_metadata() strips it, so HEAD frames like GET (#10507). let user_content_length = response.get_init_headers_mut().and_then(|h| { if h.fast_has(jsc::HTTPHeaderName::TransferEncoding) { return None; @@ -3086,8 +3081,6 @@ where // Body bytes were already written: close without the terminating chunk // (RFC 9112 section 7) so the client sees an incomplete message, not a // truncated body that looks like a complete, successful response. - // Same when a Content-Length is already on the wire (#10507): sending - // a 0-byte body against a non-zero promise would hang keep-alive. if let Some(resp) = req.resp { let state = resp.state(); if state.is_response_pending() @@ -3305,6 +3298,17 @@ where } let resp = this.resp.expect("infallible: resp bound"); + // An errored stream cannot complete the committed framing; close like handle_reject. + if matches!(stream, WebCore::streams::Result::Err(_)) { + let state = resp.state(); + if state.is_response_pending() + && (state.is_http_write_called() || state.has_written_content_length_header()) + { + this.force_close(); + return; + } + } + let chunk = stream.slice(); // on failure, it will continue to allocate // we can't do buffering ourselves here or it won't work @@ -3657,9 +3661,7 @@ where }); let mut has_content_disposition = false; let mut has_content_range = false; - // A ReadableStream body has no size for uWS to frame from, so the - // handler's Content-Length (#10507) is the only thing that can give - // the client a length. `do_write_headers` strips it; capture it first. + // The handler's Content-Length is the only length a stream body has (#10507). let mut user_content_length: Option = None; if let Some(mut headers_) = response.swap_init_headers() { has_content_disposition = headers_.fast_has(jsc::HTTPHeaderName::ContentDisposition); diff --git a/test/js/bun/http/bun-serve-headers.test.ts b/test/js/bun/http/bun-serve-headers.test.ts index 066045043c12..7eb494b81c0a 100644 --- a/test/js/bun/http/bun-serve-headers.test.ts +++ b/test/js/bun/http/bun-serve-headers.test.ts @@ -170,6 +170,7 @@ describe("response Content-Length for ReadableStream bodies", () => { async function rawGET(port: number, method = "GET"): Promise<{ head: string; body: Buffer }> { const socket = net.connect(port, "127.0.0.1"); try { + socket.on("error", () => {}); await once(socket, "connect"); socket.write(`${method} / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n`); const chunks: Buffer[] = []; @@ -290,11 +291,15 @@ describe("response Content-Length for ReadableStream bodies", () => { const socket = net.connect(server.port, "127.0.0.1"); try { socket.on("error", () => {}); + // A forced close can surface as ECONNRESET before "close", which would + // reject once(socket, "close"); await the event directly instead. + const closed = Promise.withResolvers(); + socket.on("close", () => closed.resolve()); await once(socket, "connect"); socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); const chunks: Buffer[] = []; socket.on("data", c => chunks.push(c)); - await once(socket, "close"); + await closed.promise; const head = Buffer.concat(chunks).toString("latin1").split("\r\n\r\n")[0] ?? ""; expect(contentLength(head).length).toBeLessThanOrEqual(1); } finally { @@ -360,6 +365,62 @@ describe("response Content-Length for ReadableStream bodies", () => { expect(body.equals(payload)).toBe(true); }); + // The upstream dies mid-body after the proxy committed the Content-Length: + // the proxy must close its own socket, not end the response cleanly. + test.concurrent("proxied fetch().body whose upstream errors closes the socket", async () => { + let upstreamSocket: net.Socket | undefined; + const upstream = net.createServer(s => { + upstreamSocket = s; + s.on("error", () => {}); + s.write("HTTP/1.1 200 OK\r\nContent-Length: 8000\r\n\r\n"); + s.write(Buffer.alloc(2000, "F")); + }); + await new Promise(r => upstream.listen(0, "127.0.0.1", r)); + const upstreamPort = (upstream.address() as net.AddressInfo).port; + using proxy = Bun.serve({ + port: 0, + development: false, + async fetch() { + const r = await fetch(`http://127.0.0.1:${upstreamPort}/`); + return new Response(r.body, { headers: { "Content-Length": "8000" } }); + }, + error() {}, + }); + + const socket = net.connect(proxy.port, "127.0.0.1"); + try { + socket.on("error", () => {}); + // A forced close can surface as ECONNRESET before "close", which would + // reject once(socket, "close"); await the event directly instead. + const closed = Promise.withResolvers(); + socket.on("close", () => closed.resolve()); + await once(socket, "connect"); + // Keep-alive on purpose: a clean end would leave this socket open + // waiting on the remaining promised bytes. + socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + const chunks: Buffer[] = []; + let killed = false; + socket.on("data", c => { + chunks.push(c); + const raw = Buffer.concat(chunks); + const sep = raw.indexOf("\r\n\r\n"); + if (!killed && sep !== -1 && raw.length >= sep + 4 + 2000) { + killed = true; + upstreamSocket!.destroy(); + } + }); + await closed.promise; + const raw = Buffer.concat(chunks); + const sep = raw.indexOf("\r\n\r\n"); + const head = raw.subarray(0, sep).toString("latin1"); + expect(contentLength(head)).toEqual(["8000"]); + expect(raw.length - (sep + 4)).toBeLessThan(8000); + } finally { + socket.destroy(); + upstream.close(); + } + }); + test.concurrent("HEAD matches GET framing", async () => { using server = Bun.serve({ port: 0, @@ -445,6 +506,7 @@ describe("response Content-Length for ReadableStream bodies", () => { const socket = net.connect(server.port, "127.0.0.1"); try { + socket.on("error", () => {}); await once(socket, "connect"); socket.write("GET / HTTP/1.1\r\nHost: x\r\n\r\n"); const chunks: Buffer[] = []; From c90b0f373eeb18ce90a3f472d43edf4c47cef852 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:25:54 +0000 Subject: [PATCH 4/5] dedupe the framing guards into helpers Extract handler_content_length (Transfer-Encoding skip + Content-Length parse) shared by the HEAD locked-body path and render_metadata, and force_close_if_framing_committed shared by handle_reject, handle_reject_stream and the on_pipe error arm. Check the committed head strictly in the stream-error test when it was received; a reset can beat the header flush, so the close event itself stays the unconditional assertion. --- src/runtime/server/RequestContext.rs | 78 +++++++++++----------- test/js/bun/http/bun-serve-headers.test.ts | 11 ++- 2 files changed, 49 insertions(+), 40 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index dc7d7317188b..25c5d12ca66a 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -933,12 +933,7 @@ where .run_error_handler(value, None); } } - let state = resp.state(); - if state.is_response_pending() - && (state.is_http_write_called() || state.has_written_content_length_header()) - { - ctx.force_close(); - } else { + if !ctx.force_close_if_framing_committed() { ctx.end_stream(ctx.should_close_connection()); } return; @@ -1234,6 +1229,23 @@ where } } + /// Framing (a chunked write or a Content-Length) is already on the wire but + /// the body cannot complete; close so the client sees a truncated message + /// instead of waiting on the promised bytes over keep-alive. + fn force_close_if_framing_committed(&mut self) -> bool { + let Some(resp) = self.resp else { + return false; + }; + let state = resp.state(); + if state.is_response_pending() + && (state.is_http_write_called() || state.has_written_content_length_header()) + { + self.force_close(); + return true; + } + false + } + /// # Safety /// `this` must be the live `RequestContext` user-data pointer registered with uWS. pub(crate) fn on_writable_response_buffer( @@ -2605,15 +2617,9 @@ where this.end_without_body(this.should_close_connection()); } Body::Value::Locked(_) => { - // Read before render_metadata() strips it, so HEAD frames like GET (#10507). - let user_content_length = response.get_init_headers_mut().and_then(|h| { - if h.fast_has(jsc::HTTPHeaderName::TransferEncoding) { - return None; - } - let cl = h.fast_get(jsc::HTTPHeaderName::ContentLength)?; - let cl_str = cl.to_slice(); - bun_core::parse_int::(cl_str.slice(), 10).ok() - }); + // Read before render_metadata() strips it, so HEAD frames like GET. + let user_content_length = + response.get_init_headers_mut().and_then(handler_content_length); this.render_metadata(); if let Some(cl) = user_content_length { resp.write_header_int(b"content-length", cl); @@ -3081,14 +3087,8 @@ where // Body bytes were already written: close without the terminating chunk // (RFC 9112 section 7) so the client sees an incomplete message, not a // truncated body that looks like a complete, successful response. - if let Some(resp) = req.resp { - let state = resp.state(); - if state.is_response_pending() - && (state.is_http_write_called() || state.has_written_content_length_header()) - { - req.force_close(); - return; - } + if req.force_close_if_framing_committed() { + return; } req.end_stream(req.should_close_connection()); } @@ -3298,15 +3298,11 @@ where } let resp = this.resp.expect("infallible: resp bound"); - // An errored stream cannot complete the committed framing; close like handle_reject. - if matches!(stream, WebCore::streams::Result::Err(_)) { - let state = resp.state(); - if state.is_response_pending() - && (state.is_http_write_called() || state.has_written_content_length_header()) - { - this.force_close(); - return; - } + // An errored stream cannot complete the committed framing. + if matches!(stream, WebCore::streams::Result::Err(_)) + && this.force_close_if_framing_committed() + { + return; } let chunk = stream.slice(); @@ -3668,13 +3664,8 @@ where has_content_range = headers_.fast_has(jsc::HTTPHeaderName::ContentRange); if (self.sink.is_some() || self.byte_stream.is_some()) && !self.flags.needs_content_length() - && !headers_.fast_has(jsc::HTTPHeaderName::TransferEncoding) { - if let Some(cl) = headers_.fast_get(jsc::HTTPHeaderName::ContentLength) { - let cl_str = cl.to_slice(); - user_content_length = bun_core::parse_int::(cl_str.slice(), 10).ok(); - drop(cl_str); - } + user_content_length = handler_content_length(&mut headers_); } // For .slice()-driven ranges, only promote to 206 if the user // also set Content-Range (preserves the old contract). For an @@ -4602,6 +4593,17 @@ fn get_content_type(headers: Option<&mut FetchHeaders>, blob: &AnyBlob) -> (Mime (content_type, needs_content_type, content_type_needs_free) } +/// Handler-set Content-Length for framing a stream body (#10507); None when the +/// handler also set Transfer-Encoding or the value does not parse. +fn handler_content_length(headers: &mut FetchHeaders) -> Option { + if headers.fast_has(jsc::HTTPHeaderName::TransferEncoding) { + return None; + } + let cl = headers.fast_get(jsc::HTTPHeaderName::ContentLength)?; + let cl_str = cl.to_slice(); + bun_core::parse_int::(cl_str.slice(), 10).ok() +} + // `ServerLike` lives in `crate::server` (mod.rs) and is impl'd for the four // `NewServer` monomorphizations. diff --git a/test/js/bun/http/bun-serve-headers.test.ts b/test/js/bun/http/bun-serve-headers.test.ts index 7eb494b81c0a..165fe9325cd8 100644 --- a/test/js/bun/http/bun-serve-headers.test.ts +++ b/test/js/bun/http/bun-serve-headers.test.ts @@ -300,8 +300,15 @@ describe("response Content-Length for ReadableStream bodies", () => { const chunks: Buffer[] = []; socket.on("data", c => chunks.push(c)); await closed.promise; - const head = Buffer.concat(chunks).toString("latin1").split("\r\n\r\n")[0] ?? ""; - expect(contentLength(head).length).toBeLessThanOrEqual(1); + // The close is the invariant under test; a reset may beat the header + // flush, so the head is only checked when it made it out. + const raw = Buffer.concat(chunks); + const sep = raw.indexOf("\r\n\r\n"); + if (sep !== -1) { + const head = raw.subarray(0, sep).toString("latin1"); + expect(contentLength(head)).toEqual(["100"]); + expect(raw.length - (sep + 4)).toBeLessThan(100); + } } finally { socket.destroy(); } From eeba92ae7dfa649a92b7035ac4d851722ef21782 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:27:33 +0000 Subject: [PATCH 5/5] [autofix.ci] apply automated fixes --- src/runtime/server/RequestContext.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 25c5d12ca66a..ed057a482c8e 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2618,8 +2618,9 @@ where } Body::Value::Locked(_) => { // Read before render_metadata() strips it, so HEAD frames like GET. - let user_content_length = - response.get_init_headers_mut().and_then(handler_content_length); + let user_content_length = response + .get_init_headers_mut() + .and_then(handler_content_length); this.render_metadata(); if let Some(cl) = user_content_length { resp.write_header_int(b"content-length", cl);