From 8ea3583ae31405eb83a3b5d8f4807e3af9cba9a0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:21:59 +0000 Subject: [PATCH 01/13] Resolve a file body for HEAD the same way as for GET HEAD sized a file body from a bare stat, so a missing file or a directory got a 200 with a made-up Content-Length while GET reached error(). Route file bodies through do_sendfile, which opens and fstats the file, reports ENOENT and EISDIR, applies Range, and ends a HEAD response after the headers. --- src/runtime/server/RequestContext.rs | 10 +++++++++ test/js/bun/http/bun-serve-file.test.ts | 28 +++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 094f34edc381..b3e0b3a544f3 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2658,6 +2658,16 @@ where ); // TODO: properly propagate exception upwards return; } + // A file body resolves exactly like GET (open + fstat), so a + // missing file or a directory reaches `error()` with the same + // status, and Content-Length is the fstat size rather than a + // bare stat. `do_sendfile` ends a HEAD response after the headers. + if shim::blob_needs_to_read_file(blob) { + this.blob + .set(body_value.use_as_any_blob_allow_non_utf8_string()); + this.render_with_blob_from_body_value(); + return; + } // Size the blob *before* `render_metadata()`: it re-fetches the // Response from `response_weakref`, so no borrow of the Response // (here, `blob`) may still be live across it. Nothing is written diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index f21c68018c4d..f41d287664bf 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -794,6 +794,34 @@ describe("Bun.file in serve routes", () => { } }); + // HEAD answers with the status GET would produce (RFC 9110 §9.3.2). A + // file body is resolved with open + fstat for both, so a missing file or + // a directory reaches error() on HEAD too, instead of a 200 sized from a + // bare stat (0 for a missing file, the inode size for a directory). + it("HEAD of a missing file or a directory from the fetch handler fails like GET", async () => { + using handlerServer = Bun.serve({ + port: 0, + fetch: req => + new Response(Bun.file(new URL(req.url).pathname === "/dir" ? tempDir : join(tempDir, "nope.bin"))), + error: e => new Response(`err ${(e as NodeJS.ErrnoException).code}`, { status: 500 }), + }); + const probe = async (pathname: string, method: string) => { + const res = await fetch(new URL(pathname, handlerServer.url), { method }); + return { status: res.status, contentLength: res.headers.get("Content-Length"), text: await res.text() }; + }; + expect({ + "GET /missing": await probe("/missing", "GET"), + "HEAD /missing": await probe("/missing", "HEAD"), + "GET /dir": await probe("/dir", "GET"), + "HEAD /dir": await probe("/dir", "HEAD"), + }).toEqual({ + "GET /missing": { status: 500, contentLength: "10", text: "err ENOENT" }, + "HEAD /missing": { status: 500, contentLength: "10", text: "" }, + "GET /dir": { status: 500, contentLength: "10", text: "err EISDIR" }, + "HEAD /dir": { status: 500, contentLength: "10", text: "" }, + }); + }); + it("preserves custom status for empty files", async () => { const res = await fetch(new URL(`/empty-400.txt`, server.url)); expect(res.status).toBe(400); From 008fae446c6ca9ffa4381e61961681419853fd7b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:36:53 +0000 Subject: [PATCH 02/13] Shorten the HEAD file-body comment --- src/runtime/server/RequestContext.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index b3e0b3a544f3..8c5ceeb2306a 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2658,10 +2658,7 @@ where ); // TODO: properly propagate exception upwards return; } - // A file body resolves exactly like GET (open + fstat), so a - // missing file or a directory reaches `error()` with the same - // status, and Content-Length is the fstat size rather than a - // bare stat. `do_sendfile` ends a HEAD response after the headers. + // Same open + fstat as GET; `do_sendfile` ends HEAD after the headers. if shim::blob_needs_to_read_file(blob) { this.blob .set(body_value.use_as_any_blob_allow_non_utf8_string()); From 6046cb70ed74f6c274c3713e72b2900151358d36 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:35:18 +0000 Subject: [PATCH 03/13] Render a HEAD error() response from its own body, not the failed file do_sendfile leaves the file blob in this.blob when open fails. The error() Response then rendered its headers from that blob on HEAD, so the Content-Type and Content-Disposition came from the missing file. Hold the in-memory body in this.blob before render_metadata, as GET does. --- src/runtime/server/RequestContext.rs | 10 ++++++--- test/js/bun/http/bun-serve-file.test.ts | 27 +++++++++++++++++++------ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 8c5ceeb2306a..3dc9c03c18cb 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2610,8 +2610,12 @@ where let body_value = response.get_body_value(); match body_value { Body::Value::InternalBlob(_) | Body::Value::WTFStringImpl(_) => { - let mut blob = body_value.use_as_any_blob_allow_non_utf8_string(); - let size = blob.size(); + // `render_metadata` derives Content-Type from `this.blob`, so + // hold the body there as GET does. It replaces a file blob left + // by a `do_sendfile` that failed and ran `error()`. + this.blob + .set(body_value.use_as_any_blob_allow_non_utf8_string()); + let size = this.blob.get().size(); this.render_metadata(); if size == crate::webcore::blob::MAX_SIZE { @@ -2620,7 +2624,7 @@ where resp.write_header_int(b"content-length", size as u64); } this.end_without_body(this.should_close_connection()); - blob.detach(); + this.blob.with_mut(|b| b.detach()); } Body::Value::Blob(blob) => { diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index f41d287664bf..bdb56549d84a 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -798,27 +798,42 @@ describe("Bun.file in serve routes", () => { // file body is resolved with open + fstat for both, so a missing file or // a directory reaches error() on HEAD too, instead of a 200 sized from a // bare stat (0 for a missing file, the inode size for a directory). + // The error() Response's headers come from its own body, not from the + // file that failed: no text/html from "nope.html", no filename. it("HEAD of a missing file or a directory from the fetch handler fails like GET", async () => { using handlerServer = Bun.serve({ port: 0, fetch: req => - new Response(Bun.file(new URL(req.url).pathname === "/dir" ? tempDir : join(tempDir, "nope.bin"))), + new Response(Bun.file(new URL(req.url).pathname === "/dir" ? tempDir : join(tempDir, "nope.html"))), error: e => new Response(`err ${(e as NodeJS.ErrnoException).code}`, { status: 500 }), }); const probe = async (pathname: string, method: string) => { const res = await fetch(new URL(pathname, handlerServer.url), { method }); - return { status: res.status, contentLength: res.headers.get("Content-Length"), text: await res.text() }; + return { + status: res.status, + contentLength: res.headers.get("Content-Length"), + contentType: res.headers.get("Content-Type"), + contentDisposition: res.headers.get("Content-Disposition"), + text: await res.text(), + }; }; + const error = (text: string) => ({ + status: 500, + contentLength: "10", + contentType: "text/plain;charset=utf-8", + contentDisposition: null, + text, + }); expect({ "GET /missing": await probe("/missing", "GET"), "HEAD /missing": await probe("/missing", "HEAD"), "GET /dir": await probe("/dir", "GET"), "HEAD /dir": await probe("/dir", "HEAD"), }).toEqual({ - "GET /missing": { status: 500, contentLength: "10", text: "err ENOENT" }, - "HEAD /missing": { status: 500, contentLength: "10", text: "" }, - "GET /dir": { status: 500, contentLength: "10", text: "err EISDIR" }, - "HEAD /dir": { status: 500, contentLength: "10", text: "" }, + "GET /missing": error("err ENOENT"), + "HEAD /missing": error(""), + "GET /dir": error("err EISDIR"), + "HEAD /dir": error(""), }); }); From 566031b4422d4b109c54df29e2ef2493559cc3ec Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:37:29 +0000 Subject: [PATCH 04/13] Shorten the HEAD body comment --- src/runtime/server/RequestContext.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 3dc9c03c18cb..5657b320fc3f 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2610,9 +2610,7 @@ where let body_value = response.get_body_value(); match body_value { Body::Value::InternalBlob(_) | Body::Value::WTFStringImpl(_) => { - // `render_metadata` derives Content-Type from `this.blob`, so - // hold the body there as GET does. It replaces a file blob left - // by a `do_sendfile` that failed and ran `error()`. + // `render_metadata` reads `this.blob`, as GET does. this.blob .set(body_value.use_as_any_blob_allow_non_utf8_string()); let size = this.blob.get().size(); From 2d4bf97d8945b307acb4f8de020e9d068680116d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 08:19:17 +0000 Subject: [PATCH 05/13] Render every HEAD body arm from this.blob, keep a Blob body on the Response The HEAD arm for an in-memory Blob still called render_metadata with whatever this.blob held. After do_sendfile failed for HEAD, that was the file that failed, so an error() Response with a typed Blob body got the file's Content-Type and a Content-Disposition filename. Fold the three body arms into one that holds the body in this.blob first, as GET does. A Blob body is duped rather than taken, so HEAD leaves the Response body in place as before. Fold in the test from #41896 (error() receives the ENOENT with its path for HEAD, custom status and headers propagate), add a raw-socket check that HEAD of a file writes no body and honors Range like GET, and make the directory case return a typed Blob from error(). --- src/runtime/server/RequestContext.rs | 51 +++++----- test/js/bun/http/bun-serve-file.test.ts | 122 ++++++++++++++++++++++-- 2 files changed, 135 insertions(+), 38 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 5657b320fc3f..03ef6ced5bda 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2609,24 +2609,10 @@ where // handler-supplied Content-Length / Transfer-Encoding header) let body_value = response.get_body_value(); match body_value { - Body::Value::InternalBlob(_) | Body::Value::WTFStringImpl(_) => { - // `render_metadata` reads `this.blob`, as GET does. - this.blob - .set(body_value.use_as_any_blob_allow_non_utf8_string()); - let size = this.blob.get().size(); - this.render_metadata(); - - if size == crate::webcore::blob::MAX_SIZE { - resp.write_header_int(b"content-length", 0); - } else { - resp.write_header_int(b"content-length", size as u64); - } - this.end_without_body(this.should_close_connection()); - this.blob.with_mut(|b| b.detach()); - } - - Body::Value::Blob(blob) => { - if shim::blob_is_s3(blob) { + Body::Value::InternalBlob(_) | Body::Value::WTFStringImpl(_) | Body::Value::Blob(_) => { + if let Body::Value::Blob(blob) = body_value + && shim::blob_is_s3(blob) + { // we need to read the size asynchronously // in this case should always be a redirect so should not hit this path, but in case we change it in the future lets handle it // Ref for the S3 stat; adopted and released by @@ -2660,25 +2646,32 @@ where ); // TODO: properly propagate exception upwards return; } + // `render_metadata` reads `this.blob`, as GET does. A Blob body + // stays on the Response: a view of it is enough for the headers. + let body = match body_value { + Body::Value::Blob(blob) => AnyBlob::Blob(blob.dupe()), + _ => body_value.use_as_any_blob_allow_non_utf8_string(), + }; + this.blob.set(body); // Same open + fstat as GET; `do_sendfile` ends HEAD after the headers. - if shim::blob_needs_to_read_file(blob) { - this.blob - .set(body_value.use_as_any_blob_allow_non_utf8_string()); + if this.blob.get().needs_to_read_file() { this.render_with_blob_from_body_value(); return; } - // Size the blob *before* `render_metadata()`: it re-fetches the - // Response from `response_weakref`, so no borrow of the Response - // (here, `blob`) may still be live across it. Nothing is written - // to the socket in between, so the wire output is unchanged. - blob.resolve_size(); - let blob_size = blob.size.get(); + let size = { + let blob = this.blob.get(); + if let AnyBlob::Blob(blob) = blob { + blob.resolve_size(); + } + blob.size() + }; this.render_metadata(); + this.blob.with_mut(|b| b.detach()); - if blob_size == crate::webcore::blob::MAX_SIZE { + if size == crate::webcore::blob::MAX_SIZE { resp.write_header_int(b"content-length", 0); } else { - resp.write_header_int(b"content-length", blob_size as u64); + resp.write_header_int(b"content-length", size as u64); } this.end_without_body(this.should_close_connection()); } diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index bdb56549d84a..765e53be927c 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -275,6 +275,71 @@ describe("Bun.file in serve routes", () => { expect(res.status).toBe(200); expect(await res.text()).toBe("Hello, World!"); }); + + // A file body from the fetch handler takes the same open + fstat path for + // HEAD as for GET (RFC 9110 §9.3.2): same Content-Type, same Range + // handling, and nothing after the header section on the wire. + it("HEAD of a file from the fetch handler sends GET's headers and no body", async () => { + using handlerServer = Bun.serve({ + port: 0, + hostname: "127.0.0.1", + fetch: () => new Response(Bun.file(join(tempDir, "partial.txt"))), + }); + + // A raw socket, so that body bytes after the head would be visible. + const { promise: wireDone, resolve, reject } = Promise.withResolvers(); + let wire = ""; + const client = await Bun.connect({ + hostname: "127.0.0.1", + port: handlerServer.port, + socket: { + open(s) { + s.write("HEAD / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n"); + }, + data(_s, d) { + wire += Buffer.from(d).toString("latin1"); + }, + close() { + resolve(wire); + }, + error(_s, e) { + reject(e); + }, + }, + }); + const captured = await wireDone; + client.end(); + const [head, ...afterHead] = captured.split("\r\n\r\n"); + const header = (name: string) => + head + .split("\r\n") + .find(line => line.toLowerCase().startsWith(name + ":")) + ?.slice(name.length + 1) + .trim() ?? null; + + const ranged = await fetch(handlerServer.url, { method: "HEAD", headers: { Range: "bytes=2-5" } }); + const unsatisfiable = await fetch(handlerServer.url, { method: "HEAD", headers: { Range: "bytes=99-" } }); + + expect({ + status: head.split("\r\n")[0], + contentType: header("content-type"), + contentLength: header("content-length"), + body: afterHead.join("\r\n\r\n"), + ranged: { + status: ranged.status, + contentLength: ranged.headers.get("Content-Length"), + contentRange: ranged.headers.get("Content-Range"), + }, + unsatisfiable: { status: unsatisfiable.status, contentRange: unsatisfiable.headers.get("Content-Range") }, + }).toEqual({ + status: "HTTP/1.1 200 OK", + contentType: "text/plain;charset=utf-8", + contentLength: "16", + body: "", + ranged: { status: 206, contentLength: "4", contentRange: "bytes 2-5/16" }, + unsatisfiable: { status: 416, contentRange: "bytes */16" }, + }); + }); }); describe.concurrent("Custom headers and status", () => { @@ -798,14 +863,20 @@ describe("Bun.file in serve routes", () => { // file body is resolved with open + fstat for both, so a missing file or // a directory reaches error() on HEAD too, instead of a 200 sized from a // bare stat (0 for a missing file, the inode size for a directory). - // The error() Response's headers come from its own body, not from the - // file that failed: no text/html from "nope.html", no filename. + // The error() Response's headers come from its own body (a string for + // ENOENT, a typed Blob for EISDIR), not from the file that failed: no + // text/html from "nope.html", no filename from the directory. it("HEAD of a missing file or a directory from the fetch handler fails like GET", async () => { using handlerServer = Bun.serve({ port: 0, fetch: req => new Response(Bun.file(new URL(req.url).pathname === "/dir" ? tempDir : join(tempDir, "nope.html"))), - error: e => new Response(`err ${(e as NodeJS.ErrnoException).code}`, { status: 500 }), + error: e => { + const code = (e as NodeJS.ErrnoException).code; + return code === "EISDIR" + ? new Response(new Blob([`err ${code}`], { type: "text/x-error" }), { status: 500 }) + : new Response(`err ${code}`, { status: 500 }); + }, }); const probe = async (pathname: string, method: string) => { const res = await fetch(new URL(pathname, handlerServer.url), { method }); @@ -817,10 +888,10 @@ describe("Bun.file in serve routes", () => { text: await res.text(), }; }; - const error = (text: string) => ({ + const error = (contentType: string, text: string) => ({ status: 500, contentLength: "10", - contentType: "text/plain;charset=utf-8", + contentType, contentDisposition: null, text, }); @@ -830,10 +901,10 @@ describe("Bun.file in serve routes", () => { "GET /dir": await probe("/dir", "GET"), "HEAD /dir": await probe("/dir", "HEAD"), }).toEqual({ - "GET /missing": error("err ENOENT"), - "HEAD /missing": error(""), - "GET /dir": error("err EISDIR"), - "HEAD /dir": error(""), + "GET /missing": error("text/plain;charset=utf-8", "err ENOENT"), + "HEAD /missing": error("text/plain;charset=utf-8", ""), + "GET /dir": error("text/x-error", "err EISDIR"), + "HEAD /dir": error("text/x-error", ""), }); }); @@ -905,6 +976,39 @@ describe("Bun.file in serve routes", () => { expect(await res.text()).toBe(`fallback: ${server.url}will-be-deleted.txt`); expect(handler.mock.calls.length).toBe(previousCallCount + 1); }); + + it("a missing file returned from the fetch handler reaches error() for HEAD like for GET", async () => { + const missingPath = join(tempDir, "does-not-exist.txt"); + const errors: { code?: string; path?: string }[] = []; + using handlerServer = Bun.serve({ + port: 0, + fetch: () => new Response(Bun.file(missingPath)), + error(err) { + errors.push({ code: err.code, path: (err as ErrnoException).path }); + return new Response("from error()", { status: 404, headers: { "X-From": "error" } }); + }, + }); + const results: unknown[] = []; + for (const method of ["GET", "HEAD"]) { + const res = await fetch(handlerServer.url, { method }); + results.push({ + method, + status: res.status, + from: res.headers.get("X-From"), + contentLength: res.headers.get("Content-Length"), + body: await res.text(), + }); + } + // HEAD used to ignore the failed stat and answer 200 with content-length: 0. + expect(results).toEqual([ + { method: "GET", status: 404, from: "error", contentLength: "12", body: "from error()" }, + { method: "HEAD", status: 404, from: "error", contentLength: "12", body: "" }, + ]); + expect(errors).toEqual([ + { code: "ENOENT", path: missingPath }, + { code: "ENOENT", path: missingPath }, + ]); + }); }); describe.concurrent("Content-Type detection", () => { From fbf7ec04486d80458c3d50f7ce83ec112fd45e02 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:34:46 +0000 Subject: [PATCH 06/13] HEAD sends a used or errored body to error(), as GET does do_render_with_body routes a Response whose body is Used or Error to run_error_handler (ERR_BODY_ALREADY_USED since #33118, or the body's own error). The HEAD renderer treated both as bodiless: it honored a leftover Content-Length header or wrote content-length: 0 with the handler's status. Share the error construction in take_unsendable_body_error and call it from the HEAD pre-pass before any header is written, so HEAD reports the status GET reports here too. serve-reused-response.test.ts gains HEAD rows, including a used body that still carries a Content-Length header. --- src/runtime/server/RequestContext.rs | 60 +++++++++++-------- .../js/bun/http/serve-reused-response.test.ts | 52 +++++++++------- 2 files changed, 66 insertions(+), 46 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 03ef6ced5bda..d744325ee1f2 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2564,10 +2564,13 @@ where let body_decides_framing = { let body_value = response.get_body_value(); body_value.to_blob_if_possible(); - !matches!( - body_value, - Body::Value::Used | Body::Value::Null | Body::Value::Empty | Body::Value::Error(_) - ) + // A used or errored body reaches `error()`, as it does for GET. + if matches!(body_value, Body::Value::Used | Body::Value::Error(_)) { + let js_err = Self::take_unsendable_body_error(body_value, global_this); + this.run_error_handler(js_err); + return; + } + !matches!(body_value, Body::Value::Null | Body::Value::Empty) }; // `fast_get`/`fast_has` take `&mut self` (FFI shim), so use the `_mut` // accessor — `get_fetch_headers()` and `get_init_headers()` alias the @@ -2687,7 +2690,8 @@ where } this.end_without_body(this.should_close_connection()); } - Body::Value::Used | Body::Value::Null | Body::Value::Empty | Body::Value::Error(_) => { + // `Used` and `Error` went to `error()` above. + Body::Value::Null | Body::Value::Empty | Body::Value::Used | Body::Value::Error(_) => { this.render_metadata(); // SAFETY: FFI handle resp.write_header_int(b"content-length", 0); @@ -3099,6 +3103,29 @@ where true } + /// The `error()` argument for a body that cannot be sent. An errored body + /// yields its own error. A used one, usually the same Response object + /// returned for a second request, is an error too, not a silent empty 200. + fn take_unsendable_body_error( + value: &mut Body::Value, + global_this: &JSGlobalObject, + ) -> JSValue { + debug_assert!(matches!(value, Body::Value::Error(_) | Body::Value::Used)); + if let Body::Value::Error(err_ref) = value { + let js_err = err_ref.to_js(global_this); + let _ = value.use_(); + return js_err; + } + global_this + .err( + jsc::ErrorCode::BODY_ALREADY_USED, + format_args!( + "Response body already used. A Response body can only be sent once; create a new Response for each request." + ), + ) + .to_js() + } + pub(crate) fn do_render_with_body( &self, value: *mut Body::Value, @@ -3116,30 +3143,11 @@ where value.to_blob_if_possible(); let global_this = this.server().global_this(); match value { - Body::Value::Error(err_ref) => { - let js_err = err_ref.to_js(global_this); - let _ = value.use_(); - if this.is_aborted_or_ended() { - return; - } - this.run_error_handler(js_err); - return; - } - // The handler returned a Response whose body was already used, - // usually the same Response object returned for a second request. - // A disturbed body is an error, not a silent empty 200. - Body::Value::Used => { + Body::Value::Error(_) | Body::Value::Used => { + let js_err = Self::take_unsendable_body_error(value, global_this); if this.is_aborted_or_ended() { return; } - let js_err = global_this - .err( - jsc::ErrorCode::BODY_ALREADY_USED, - format_args!( - "Response body already used. A Response body can only be sent once; create a new Response for each request." - ), - ) - .to_js(); this.run_error_handler(js_err); return; } diff --git a/test/js/bun/http/serve-reused-response.test.ts b/test/js/bun/http/serve-reused-response.test.ts index eeb89f4185be..013f6371a27f 100644 --- a/test/js/bun/http/serve-reused-response.test.ts +++ b/test/js/bun/http/serve-reused-response.test.ts @@ -57,30 +57,42 @@ describe("returning a Response with an already-used body", () => { expect(await third.text()).toBe("handled"); expect(third.status).toBe(500); - expect(errors).toEqual([alreadyUsedError, alreadyUsedError]); + // HEAD reports the status GET reports (RFC 9110 §9.3.2), not an empty 200. + const head = await fetch(server.url, { method: "HEAD" }); + expect(await head.text()).toBe(""); + expect(head.status).toBe(500); + + expect(errors).toEqual([alreadyUsedError, alreadyUsedError, alreadyUsedError]); }, ); - it("returning a Response whose body was consumed before returning calls the error handler", async () => { - const errors: unknown[] = []; - await using server = serve({ - port: 0, - async fetch() { - const response = new Response("consumed before returning"); - await response.text(); - return response; - }, - error(err: any) { - errors.push({ code: err.code, name: err.constructor.name, message: err.message }); - return new Response("handled", { status: 500 }); - }, - }); + it.each(["GET", "HEAD"])( + "returning a Response whose body was consumed before returning calls the error handler (%s)", + async method => { + const errors: unknown[] = []; + await using server = serve({ + port: 0, + async fetch() { + // A Content-Length left on the used Response must not frame a HEAD 200 either. + const response = new Response("consumed before returning", { headers: { "Content-Length": "24" } }); + await response.text(); + return response; + }, + error(err: any) { + errors.push({ code: err.code, name: err.constructor.name, message: err.message }); + return new Response("handled", { status: 500 }); + }, + }); - const response = await fetch(server.url); - expect(await response.text()).toBe("handled"); - expect(response.status).toBe(500); - expect(errors).toEqual([alreadyUsedError]); - }); + const response = await fetch(server.url, { method }); + expect(await response.text()).toBe(method === "HEAD" ? "" : "handled"); + expect({ status: response.status, contentLength: response.headers.get("Content-Length") }).toEqual({ + status: 500, + contentLength: "7", + }); + expect(errors).toEqual([alreadyUsedError]); + }, + ); it("a Response that is not reused keeps working", async () => { const error = jest.fn(); From d569c1d49c29923b4819ef5660717f930d564394 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:42:28 +0000 Subject: [PATCH 07/13] Shorten two HEAD render comments --- src/runtime/server/RequestContext.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index d744325ee1f2..9203e1cae3fc 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -2649,8 +2649,7 @@ where ); // TODO: properly propagate exception upwards return; } - // `render_metadata` reads `this.blob`, as GET does. A Blob body - // stays on the Response: a view of it is enough for the headers. + // `render_metadata` reads `this.blob`; a Blob body stays on the Response. let body = match body_value { Body::Value::Blob(blob) => AnyBlob::Blob(blob.dupe()), _ => body_value.use_as_any_blob_allow_non_utf8_string(), @@ -3103,9 +3102,7 @@ where true } - /// The `error()` argument for a body that cannot be sent. An errored body - /// yields its own error. A used one, usually the same Response object - /// returned for a second request, is an error too, not a silent empty 200. + /// The `error()` argument for an errored or already-used body. fn take_unsendable_body_error( value: &mut Body::Value, global_this: &JSGlobalObject, From 0e68106ba5158ac06072d357ce7458224051422d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:58:50 +0000 Subject: [PATCH 08/13] ci: retrigger From feeedba713ea1be01d1bc901b331e8b4c5e81a2b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:32:32 +0000 Subject: [PATCH 09/13] Drop the failed file before error() renders its Response do_sendfile left the file blob in this.blob when open, fstat or the directory check failed. An error() Response with no body or a stream body then took Content-Type, and for a directory Content-Disposition filename=, from the file that failed, on GET and now on HEAD. Detach the blob at the three error exits before error() runs. --- src/runtime/server/RequestContext.rs | 13 +++++-- test/js/bun/http/bun-serve-file.test.ts | 46 +++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 762d55953363..934a09b6e1c6 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1758,6 +1758,12 @@ where true } + /// `error()` renders its own Response, which must not describe the file that failed. + fn fail_sendfile(&self, js_err: JSValue) { + self.blob.with_mut(|b| b.detach()); + self.run_error_handler(js_err); + } + pub(crate) fn do_sendfile(&self, blob: Blob) { if self.is_aborted_or_ended() { return; @@ -1792,7 +1798,7 @@ where let js_err = err .with_path(file.pathlike.path().slice()) .to_js(global_this); - return self.run_error_handler(js_err); + return self.fail_sendfile(js_err); } } }; @@ -1812,7 +1818,7 @@ where err.with_fd(*pathlike_fd).to_js(global_this) } }; - return self.run_error_handler(js_err); + return self.fail_sendfile(js_err); } }; @@ -1845,7 +1851,8 @@ where }; let mut sys: jsc::SystemError = err.to_system_error().into(); sys.message = BunString::static_("Cannot stream a directory as a response body"); - return self.run_error_handler(sys.to_error_instance(global_this)); + let js_err = sys.to_error_instance(global_this); + return self.fail_sendfile(js_err); } (bun_io::FileType::File, false) }; diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index a9d01f3db267..feae67f256b4 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -908,6 +908,52 @@ describe("Bun.file in serve routes", () => { }); }); + // An error() Response with no body, or with a stream body, has no body to + // take a Content-Type from. It must not fall back to the file that failed: + // its headers equal those of the same Response returned with no failure. + it("an error() Response without a typed body does not describe the file that failed", async () => { + const replies = { + stream: () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("streamed")); + controller.close(); + }, + }), + { status: 500 }, + ), + bodiless: () => new Response(null, { status: 404 }), + }; + const probe = async (server: { url: URL }, pathname: string, method: string) => { + const res = await fetch(new URL(pathname, server.url), { method }); + await res.text(); + return { + status: res.status, + contentType: res.headers.get("Content-Type"), + contentDisposition: res.headers.get("Content-Disposition"), + }; + }; + for (const [name, reply] of Object.entries(replies)) { + using control = Bun.serve({ port: 0, fetch: reply }); + using afterFailedFile = Bun.serve({ + port: 0, + fetch: req => + new Response(Bun.file(new URL(req.url).pathname === "/dir" ? tempDir : join(tempDir, "nope.html"))), + error: reply, + }); + for (const method of ["GET", "HEAD"]) { + const expected = await probe(control, "/", method); + expect({ + name, + method, + missing: await probe(afterFailedFile, "/missing", method), + dir: await probe(afterFailedFile, "/dir", method), + }).toEqual({ name, method, missing: expected, dir: expected }); + } + } + }); + it("preserves custom status for empty files", async () => { const res = await fetch(new URL(`/empty-400.txt`, server.url)); expect(res.status).toBe(400); From 5ced882d8257ebdf0f95a5022a0f55ab2ffa38f1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:06:38 +0000 Subject: [PATCH 10/13] Answer 200 for a file slice that holds no bytes A slice that is empty or starts past EOF got an automatic 206 with Content-Range: bytes 5-5/* and Content-Length: 0, a range of one byte for zero bytes. GET did this on main and HEAD mirrors GET now. render_metadata keeps such a response a 200 with Content-Length 0. A Content-Range the user set is left alone. Also restore the header-less input of the consumed-before-returning test and keep the leftover Content-Length input as a second row. --- src/runtime/server/RequestContext.rs | 8 +++-- test/js/bun/http/bun-serve-file.test.ts | 33 +++++++++++++++++++ .../js/bun/http/serve-reused-response.test.ts | 15 ++++++--- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 934a09b6e1c6..38a04f254838 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -3788,10 +3788,12 @@ where // `.deref()` here would resolve (via DerefMut) to the inherent // `FetchHeaders::deref` and double-free the C++ object. drop(headers_); - } else if needs_content_range { - status = 206; - self.do_write_status(status); } else { + // An empty range has no valid Content-Range: it stays a 200 with Content-Length 0. + needs_content_range = needs_content_range && sendfile.remain > 0; + if needs_content_range { + status = 206; + } self.do_write_status(status); } diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index feae67f256b4..b012ca557050 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -809,6 +809,39 @@ describe("Bun.file in serve routes", () => { }); }); + // A slice that holds no bytes (empty, or past EOF) is not a partial response: + // a 206 whose Content-Range names a byte ("bytes 5-5/*") contradicts Content-Length: 0. + it("answers 200 without Content-Range for a slice that holds no bytes", async () => { + using handlerServer = Bun.serve({ + port: 0, + fetch: req => { + const file = Bun.file(join(tempDir, "partial.txt")); + return new Response(new URL(req.url).pathname === "/past-eof" ? file.slice(100) : file.slice(5, 5)); + }, + }); + const probe = async (pathname: string, method: string) => { + const res = await fetch(new URL(pathname, handlerServer.url), { method }); + return { + status: res.status, + contentLength: res.headers.get("Content-Length"), + contentRange: res.headers.get("Content-Range"), + body: await res.text(), + }; + }; + const empty = { status: 200, contentLength: "0", contentRange: null, body: "" }; + expect({ + "GET slice(5, 5)": await probe("/empty", "GET"), + "HEAD slice(5, 5)": await probe("/empty", "HEAD"), + "GET slice(100)": await probe("/past-eof", "GET"), + "HEAD slice(100)": await probe("/past-eof", "HEAD"), + }).toEqual({ + "GET slice(5, 5)": empty, + "HEAD slice(5, 5)": empty, + "GET slice(100)": empty, + "HEAD slice(100)": empty, + }); + }); + // The slice is shorter than the file, so the byte budget runs out before // the reader reports EOF: the response completes inline while a deferred // completion still hops through the event loop. Repeated requests must diff --git a/test/js/bun/http/serve-reused-response.test.ts b/test/js/bun/http/serve-reused-response.test.ts index f4721514f5de..cae390c0fdd7 100644 --- a/test/js/bun/http/serve-reused-response.test.ts +++ b/test/js/bun/http/serve-reused-response.test.ts @@ -123,15 +123,20 @@ describe("returning a Response with an already-used body", () => { }, ); - it.each(["GET", "HEAD"])( - "returning a Response whose body was consumed before returning calls the error handler (%s)", - async method => { + // The second input leaves a Content-Length on the used Response: it must not frame a HEAD 200. + it.each([ + ["GET", undefined], + ["HEAD", undefined], + ["GET", { headers: { "Content-Length": "24" } }], + ["HEAD", { headers: { "Content-Length": "24" } }], + ] as const)( + "returning a Response whose body was consumed before returning calls the error handler (%s, init %j)", + async (method, init) => { const errors: unknown[] = []; await using server = serve({ port: 0, async fetch() { - // A Content-Length left on the used Response must not frame a HEAD 200 either. - const response = new Response("consumed before returning", { headers: { "Content-Length": "24" } }); + const response = new Response("consumed before returning", init); await response.text(); return response; }, From 734a0e4675a5c3db9696663b51fab165272370f3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:10:00 +0000 Subject: [PATCH 11/13] Label the consumed-before-returning test rows --- test/js/bun/http/serve-reused-response.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/test/js/bun/http/serve-reused-response.test.ts b/test/js/bun/http/serve-reused-response.test.ts index cae390c0fdd7..4c4772e76e6b 100644 --- a/test/js/bun/http/serve-reused-response.test.ts +++ b/test/js/bun/http/serve-reused-response.test.ts @@ -124,14 +124,15 @@ describe("returning a Response with an already-used body", () => { ); // The second input leaves a Content-Length on the used Response: it must not frame a HEAD 200. + const leftoverContentLength = { headers: { "Content-Length": "24" } }; it.each([ - ["GET", undefined], - ["HEAD", undefined], - ["GET", { headers: { "Content-Length": "24" } }], - ["HEAD", { headers: { "Content-Length": "24" } }], + ["GET", "no headers", undefined], + ["HEAD", "no headers", undefined], + ["GET", "a leftover Content-Length", leftoverContentLength], + ["HEAD", "a leftover Content-Length", leftoverContentLength], ] as const)( - "returning a Response whose body was consumed before returning calls the error handler (%s, init %j)", - async (method, init) => { + "returning a Response whose body was consumed before returning calls the error handler (%s, %s)", + async (method, _label, init) => { const errors: unknown[] = []; await using server = serve({ port: 0, From 7223ca4470e828a2d0166655c1c3fddb5d260bd8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:28:31 +0000 Subject: [PATCH 12/13] Keep the 206 for an empty file slice serve.test.ts ('empty range' and 'bad range' under 'should support Content-Range with Bun.file()') asserts that a Bun.file().slice() holding no bytes answers 206. Restore that. HEAD mirrors GET for it. --- src/runtime/server/RequestContext.rs | 8 +++--- test/js/bun/http/bun-serve-file.test.ts | 33 ------------------------- 2 files changed, 3 insertions(+), 38 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 38a04f254838..934a09b6e1c6 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -3788,12 +3788,10 @@ where // `.deref()` here would resolve (via DerefMut) to the inherent // `FetchHeaders::deref` and double-free the C++ object. drop(headers_); + } else if needs_content_range { + status = 206; + self.do_write_status(status); } else { - // An empty range has no valid Content-Range: it stays a 200 with Content-Length 0. - needs_content_range = needs_content_range && sendfile.remain > 0; - if needs_content_range { - status = 206; - } self.do_write_status(status); } diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index b012ca557050..feae67f256b4 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -809,39 +809,6 @@ describe("Bun.file in serve routes", () => { }); }); - // A slice that holds no bytes (empty, or past EOF) is not a partial response: - // a 206 whose Content-Range names a byte ("bytes 5-5/*") contradicts Content-Length: 0. - it("answers 200 without Content-Range for a slice that holds no bytes", async () => { - using handlerServer = Bun.serve({ - port: 0, - fetch: req => { - const file = Bun.file(join(tempDir, "partial.txt")); - return new Response(new URL(req.url).pathname === "/past-eof" ? file.slice(100) : file.slice(5, 5)); - }, - }); - const probe = async (pathname: string, method: string) => { - const res = await fetch(new URL(pathname, handlerServer.url), { method }); - return { - status: res.status, - contentLength: res.headers.get("Content-Length"), - contentRange: res.headers.get("Content-Range"), - body: await res.text(), - }; - }; - const empty = { status: 200, contentLength: "0", contentRange: null, body: "" }; - expect({ - "GET slice(5, 5)": await probe("/empty", "GET"), - "HEAD slice(5, 5)": await probe("/empty", "HEAD"), - "GET slice(100)": await probe("/past-eof", "GET"), - "HEAD slice(100)": await probe("/past-eof", "HEAD"), - }).toEqual({ - "GET slice(5, 5)": empty, - "HEAD slice(5, 5)": empty, - "GET slice(100)": empty, - "HEAD slice(100)": empty, - }); - }); - // The slice is shorter than the file, so the byte budget runs out before // the reader reports EOF: the response completes inline while a deferred // completion still hops through the event loop. Repeated requests must From 8371097214f20b3b753ca2c51284af18fec31524 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 24 Sep 2026 04:10:17 +0000 Subject: [PATCH 13/13] Frame a slice of an fd-backed file by the bytes it sends do_sendfile sets no Content-Range for an fd-backed file, so render_metadata took Content-Length from the blob, which held the whole file's size. GET of Bun.file(fd).slice(5, 10) announced 16 bytes and sent 5, and HEAD, which now shares this path, announced 16 where it said 5 before. Set the blob's size to the bytes that will be sent. --- src/runtime/server/RequestContext.rs | 4 ++++ test/js/bun/http/bun-serve-file.test.ts | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 934a09b6e1c6..eee6f4f59b60 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1888,6 +1888,10 @@ where .max(sendfile.offset) .min(stat_size) .saturating_sub(sendfile.offset); + // An fd-backed slice sets no Content-Range, so its Content-Length comes from the blob. + if !auto_close && let AnyBlob::Blob(b) = blob_ref { + b.size.set(sendfile.remain); + } } self.sendfile.set(sendfile); diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index feae67f256b4..36f001c12d3f 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -340,6 +340,26 @@ describe("Bun.file in serve routes", () => { unsatisfiable: { status: 416, contentRange: "bytes */16" }, }); }); + + // A slice of an fd-backed file sends no Content-Range, so Content-Length has to be the + // length of the slice, not the size of the whole file, for HEAD as for GET. + it("frames a slice of an fd-backed file by the bytes it sends", async () => { + const fd = openSync(join(tempDir, "partial.txt"), "r"); + try { + using handlerServer = Bun.serve({ + port: 0, + fetch: () => new Response(Bun.file(fd).slice(5, 10)), + }); + const probe = async (method: string) => { + const res = await fetch(handlerServer.url, { method }); + return { status: res.status, contentLength: res.headers.get("Content-Length"), body: await res.text() }; + }; + expect(await probe("HEAD")).toEqual({ status: 200, contentLength: "5", body: "" }); + expect(await probe("GET")).toEqual({ status: 200, contentLength: "5", body: "56789" }); + } finally { + closeSync(fd); + } + }); }); describe.concurrent("Custom headers and status", () => {