From c0fd8a6eabec1da2994cecf6dde1ad9f57d2951a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:05:43 +0000 Subject: [PATCH 01/10] Bun.serve: map Bun.file() open errors to 404/403 instead of 500 When a fetch handler returns new Response(Bun.file(path)) and the file cannot be opened, map the errno to an HTTP status instead of treating every open() failure as a 500: ENOENT / ENOTDIR -> 404 Not Found EACCES / EPERM -> 403 Forbidden anything else -> 500 (unchanged) The user's error handler is still invoked with the system error so the response can be overridden. When no error handler (or it returns undefined) the status above is written directly, without reporting an unhandled rejection or rendering the development error page. Fixes #4292 --- src/runtime/server/RequestContext.rs | 26 +++++- test/js/bun/http/bun-serve-file.test.ts | 108 +++++++++++++++++++++++- 2 files changed, 129 insertions(+), 5 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 845980f5cb61..913583e5730f 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1693,10 +1693,15 @@ where ) { bun_sys::Result::Ok(fd_) => fd_, bun_sys::Result::Err(err) => { + let status = match err.get_errno() { + bun_sys::E::ENOENT | bun_sys::E::ENOTDIR => 404, + bun_sys::E::EACCES | bun_sys::E::EPERM => 403, + _ => 500, + }; let js_err = err .with_path(file.pathlike.path().slice()) .to_js(global_this); - return self.run_error_handler(js_err); + return self.run_error_handler_with_status_code(js_err, status); } } }; @@ -3474,6 +3479,13 @@ where } self.end_without_body(self.should_close_connection()); } + 403 => { + if !self.flags.has_written_status() { + resp.write_status(b"403 Forbidden"); + self.flags.set_has_written_status(true); + } + self.end_without_body(self.should_close_connection()); + } _ => { const BODY: &[u8] = b"Something went wrong!"; if !self.flags.has_written_status() { @@ -3542,6 +3554,14 @@ where // `ServerLike::vm()` is the process-static VM `BackRef`; `as_mut()` is // the single audited `&mut VirtualMachine` accessor. let vm = server.vm().as_mut(); + // 404/403 are expected outcomes (e.g. Bun.file() for a missing or + // unreadable path), not handler faults: write the status directly and + // skip both the unhandled-rejection report and the dev error page. + if matches!(status, 403 | 404) { + self.render_production_error(status); + vm.log_mut().unwrap().reset(); + return; + } if DEBUG_MODE { let mut exception_list_upstream: jsc::ExceptionList = Vec::new(); let prev_exception_list = vm.on_unhandled_rejection_exception_list; @@ -3569,9 +3589,7 @@ where log.reset(); return; } - if status != 404 { - (vm.on_unhandled_rejection)(vm, global_this, value); - } + (vm.on_unhandled_rejection)(vm, global_this, value); self.render_production_error(status); vm.log_mut().unwrap().reset(); } diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 6b9fa842266d..425c8a2f4dd2 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -2,7 +2,7 @@ import type { Server } from "bun"; import { afterAll, beforeAll, describe, expect, it, mock, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isWindows, rmScope, rss, tempDir, tempDirWithFiles } from "harness"; import { mkfifo } from "mkfifo"; -import { unlinkSync } from "node:fs"; +import { chmodSync, unlinkSync } from "node:fs"; import { join } from "node:path"; const LARGE_SIZE = 1024 * 1024 * 8; @@ -1279,3 +1279,109 @@ test("file route serves a burst of concurrent requests after reloads", async () const a = await fetch(`${server.url}a`).then(r => r.text()); expect(a).toBe("a-new"); }); + +// https://github.com/oven-sh/bun/issues/4292 +// A Response(Bun.file(path)) returned from the fetch handler for a path that +// can't be opened should map the open() errno to an HTTP status (404/403) +// rather than treating it as a handler fault (500 + error page). +describe.each([true, false])("Response(Bun.file()) open-error status mapping (development: %p)", development => { + const isRoot = !isWindows && process.getuid?.() === 0; + + let server: Server; + let dir: string; + let errorsSeen: unknown[]; + + beforeAll(() => { + dir = tempDirWithFiles("serve-file-open-errno", { + "ok.txt": "ok", + "noperm.txt": "secret", + }); + if (!isWindows) chmodSync(join(dir, "noperm.txt"), 0o000); + errorsSeen = []; + server = Bun.serve({ + port: 0, + development, + fetch: req => new Response(Bun.file(join(dir, new URL(req.url).pathname.slice(1)))), + error: err => { + errorsSeen.push(err); + return undefined as any; + }, + }); + server.unref(); + }); + + afterAll(() => { + if (!isWindows) chmodSync(join(dir, "noperm.txt"), 0o644); + server?.stop(true); + using _ = rmScope(dir); + }); + + it("existing file -> 200", async () => { + const res = await fetch(new URL("/ok.txt", server.url)); + expect({ status: res.status, body: await res.text() }).toEqual({ status: 200, body: "ok" }); + }); + + it("ENOENT -> 404", async () => { + const before = errorsSeen.length; + const res = await fetch(new URL("/missing.txt", server.url)); + expect({ status: res.status, statusText: res.statusText }).toEqual({ status: 404, statusText: "Not Found" }); + // The error handler must still be invoked with the ENOENT error so + // users can customize the response. + expect(errorsSeen.length).toBe(before + 1); + expect((errorsSeen.at(-1) as any)?.code).toBe("ENOENT"); + }); + + it("ENOTDIR (path component is a file) -> 404", async () => { + const res = await fetch(new URL("/ok.txt/whatever", server.url)); + expect(res.status).toBe(404); + }); + + it.skipIf(isWindows || isRoot)("EACCES -> 403", async () => { + const before = errorsSeen.length; + const res = await fetch(new URL("/noperm.txt", server.url)); + expect({ status: res.status, statusText: res.statusText }).toEqual({ status: 403, statusText: "Forbidden" }); + expect(errorsSeen.length).toBe(before + 1); + expect((errorsSeen.at(-1) as any)?.code).toBe("EACCES"); + }); + + it("error handler can override the 404 with its own Response", async () => { + await using s = Bun.serve({ + port: 0, + development, + fetch: () => new Response(Bun.file(join(dir, "missing.txt"))), + error: err => new Response(`custom ${(err as any).code}`, { status: 410 }), + }); + const res = await fetch(s.url); + expect({ status: res.status, body: await res.text() }).toEqual({ status: 410, body: "custom ENOENT" }); + }); +}); + +// Same mapping without a user-provided `error` handler: the 404 must land +// without being reported as an unhandled error on stderr, in dev and prod. +test.concurrent.each([true, false])( + "Response(Bun.file(missing)) -> 404, no unhandled error (development: %p)", + async development => { + using dir = tempDir("serve-file-enoent-quiet", { "ok.txt": "ok" }); + const fixture = ` + const dir = ${JSON.stringify(String(dir))}; + const server = Bun.serve({ + port: 0, + development: ${development}, + fetch: () => new Response(Bun.file(dir + "/missing.txt")), + }); + const res = await fetch(server.url); + console.log(res.status, JSON.stringify(res.statusText)); + server.stop(true); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe('404 "Not Found"'); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }, +); From c3149499b9bee0184f745a2bdb79d8a22f3a5622 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:25:08 +0000 Subject: [PATCH 02/10] Report a throwing/rejecting error() handler as 500, not the mapped 404/403 When do_sendfile maps ENOENT/EACCES to 404/403 and the user's error() handler then throws (or returns a rejected promise), that handler fault must still be surfaced as a 500 with an unhandled-rejection report; the 404/403 short-circuit in finish_running_error_handler is only for the no-handler / handler-returns-undefined fallthrough. --- src/runtime/server/RequestContext.rs | 10 ++++-- test/js/bun/http/bun-serve-file.test.ts | 46 +++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 913583e5730f..482c837ec1c3 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -3616,7 +3616,11 @@ where let _keep = jsc::EnsureStillAlive(result); if !result.is_empty_or_undefined_or_null() { if let Some(err) = result.to_error() { - self.finish_running_error_handler(err, status); + // The error handler itself threw or returned an Error. + // That is a handler fault regardless of the status the + // original error would have produced, so report it as + // 500 instead of letting a 404/403 default swallow it. + self.finish_running_error_handler(err, 500); return; } else if let Some(promise) = result.as_any_promise() { Self::process_on_error_promise(self, result, promise, value, status); @@ -3712,7 +3716,9 @@ where return; } jsc::PromiseResult::Rejected(err) => { - ctx.finish_running_error_handler(err, status); + // The error handler's promise rejected: a handler fault, + // reported as 500 rather than the original default status. + ctx.finish_running_error_handler(err, 500); return; } } diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 425c8a2f4dd2..2b3ae6f610df 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -1354,8 +1354,54 @@ describe.each([true, false])("Response(Bun.file()) open-error status mapping (de const res = await fetch(s.url); expect({ status: res.status, body: await res.text() }).toEqual({ status: 410, body: "custom ENOENT" }); }); + + it("async error handler resolving to undefined falls through to 404", async () => { + await using s = Bun.serve({ + port: 0, + development, + fetch: () => new Response(Bun.file(join(dir, "missing.txt"))), + error: () => Promise.resolve(undefined as any), + }); + const res = await fetch(s.url); + expect(res.status).toBe(404); + }); }); +// An `error()` handler that itself throws while handling a Bun.file ENOENT is +// a handler fault, not an "expected 404": it must still be reported as an +// unhandled error and surface as 500. +test.concurrent.each([ + ["sync throw", `error: () => { throw new Error("boom in error handler") },`], + ["Promise.reject", `error: () => Promise.reject(new Error("boom in error handler")),`], +])( + "Response(Bun.file(missing)): throwing error handler is reported (%s)", + async (_label, errorClause) => { + using dir = tempDir("serve-file-enoent-handler-fault", { "ok.txt": "ok" }); + const fixture = ` + const dir = ${JSON.stringify(String(dir))}; + const server = Bun.serve({ + port: 0, + development: false, + fetch: () => new Response(Bun.file(dir + "/missing.txt")), + ${errorClause} + }); + const res = await fetch(server.url); + console.log(res.status); + server.stop(true); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe("500"); + expect(stderr).toContain("boom in error handler"); + expect(exitCode).toBe(1); + }, +); + // Same mapping without a user-provided `error` handler: the 404 must land // without being reported as an unhandled error on stderr, in dev and prod. test.concurrent.each([true, false])( From 187517a9d626a0392df37ee661ba67ec84bff325 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:26:53 +0000 Subject: [PATCH 03/10] [autofix.ci] apply automated fixes --- test/js/bun/http/bun-serve-file.test.ts | 31 +++++++++++-------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index 2b3ae6f610df..93999a00c6e8 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -1373,11 +1373,9 @@ describe.each([true, false])("Response(Bun.file()) open-error status mapping (de test.concurrent.each([ ["sync throw", `error: () => { throw new Error("boom in error handler") },`], ["Promise.reject", `error: () => Promise.reject(new Error("boom in error handler")),`], -])( - "Response(Bun.file(missing)): throwing error handler is reported (%s)", - async (_label, errorClause) => { - using dir = tempDir("serve-file-enoent-handler-fault", { "ok.txt": "ok" }); - const fixture = ` +])("Response(Bun.file(missing)): throwing error handler is reported (%s)", async (_label, errorClause) => { + using dir = tempDir("serve-file-enoent-handler-fault", { "ok.txt": "ok" }); + const fixture = ` const dir = ${JSON.stringify(String(dir))}; const server = Bun.serve({ port: 0, @@ -1389,18 +1387,17 @@ test.concurrent.each([ console.log(res.status); server.stop(true); `; - await using proc = Bun.spawn({ - cmd: [bunExe(), "-e", fixture], - env: bunEnv, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect(stdout.trim()).toBe("500"); - expect(stderr).toContain("boom in error handler"); - expect(exitCode).toBe(1); - }, -); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout.trim()).toBe("500"); + expect(stderr).toContain("boom in error handler"); + expect(exitCode).toBe(1); +}); // Same mapping without a user-provided `error` handler: the 404 must land // without being reported as an unhandled error on stderr, in dev and prod. From bc2397cf61415021e0eeaa89a41409486ab232a4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:27:29 +0000 Subject: [PATCH 04/10] Tighten error-path comments flagged by comment-cop --- src/runtime/server/RequestContext.rs | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 482c837ec1c3..3e6d6789c298 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -3554,9 +3554,8 @@ where // `ServerLike::vm()` is the process-static VM `BackRef`; `as_mut()` is // the single audited `&mut VirtualMachine` accessor. let vm = server.vm().as_mut(); - // 404/403 are expected outcomes (e.g. Bun.file() for a missing or - // unreadable path), not handler faults: write the status directly and - // skip both the unhandled-rejection report and the dev error page. + // 404/403 reaching here are expected outcomes (missing/unreadable + // Bun.file), not faults: no unhandled-rejection report, no dev page. if matches!(status, 403 | 404) { self.render_production_error(status); vm.log_mut().unwrap().reset(); @@ -3616,10 +3615,7 @@ where let _keep = jsc::EnsureStillAlive(result); if !result.is_empty_or_undefined_or_null() { if let Some(err) = result.to_error() { - // The error handler itself threw or returned an Error. - // That is a handler fault regardless of the status the - // original error would have produced, so report it as - // 500 instead of letting a 404/403 default swallow it. + // error() itself threw: a handler fault, not `status`. self.finish_running_error_handler(err, 500); return; } else if let Some(promise) = result.as_any_promise() { @@ -3716,8 +3712,7 @@ where return; } jsc::PromiseResult::Rejected(err) => { - // The error handler's promise rejected: a handler fault, - // reported as 500 rather than the original default status. + // error() rejected: a handler fault, not `status`. ctx.finish_running_error_handler(err, 500); return; } From 4ffa216225b742dd84fa511f9b5396b0b335b520 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 03:28:19 +0000 Subject: [PATCH 05/10] Compress 404/403 short-circuit comment to one line --- src/runtime/server/RequestContext.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 3e6d6789c298..7e73e3ded637 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -3554,8 +3554,7 @@ where // `ServerLike::vm()` is the process-static VM `BackRef`; `as_mut()` is // the single audited `&mut VirtualMachine` accessor. let vm = server.vm().as_mut(); - // 404/403 reaching here are expected outcomes (missing/unreadable - // Bun.file), not faults: no unhandled-rejection report, no dev page. + // 404/403 are expected outcomes (missing/unreadable Bun.file): no report, no dev page. if matches!(status, 403 | 404) { self.render_production_error(status); vm.log_mut().unwrap().reset(); From 35455994ca84827e471dcf92654e43f34897deba Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:12:57 +0000 Subject: [PATCH 06/10] Map EISDIR (directory as response body) to 404 as well On POSIX open() on a directory succeeds, so the error is detected in the post-open fstat branch of do_sendfile. Route it through the same 404 mapping as ENOENT/ENOTDIR. --- src/runtime/server/RequestContext.rs | 3 ++- test/js/bun/http/bun-serve-file.test.ts | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 7e73e3ded637..b0f92fbc3359 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1755,7 +1755,8 @@ where let mut sys: jsc::SystemError = err.to_system_error().into(); sys.message = BunString::static_("Cannot stream a directory as a response body").into(); - return self.run_error_handler(sys.to_error_instance(global_this)); + return self + .run_error_handler_with_status_code(sys.to_error_instance(global_this), 404); } (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 93999a00c6e8..6491137d2b8b 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -1295,6 +1295,7 @@ describe.each([true, false])("Response(Bun.file()) open-error status mapping (de dir = tempDirWithFiles("serve-file-open-errno", { "ok.txt": "ok", "noperm.txt": "secret", + "sub/.keep": "", }); if (!isWindows) chmodSync(join(dir, "noperm.txt"), 0o000); errorsSeen = []; @@ -1336,6 +1337,16 @@ describe.each([true, false])("Response(Bun.file()) open-error status mapping (de expect(res.status).toBe(404); }); + // On POSIX open() on a directory succeeds, so EISDIR is reported from the + // post-open fstat branch. Windows fails the open itself (EACCES -> 403). + it.skipIf(isWindows)("EISDIR (path is a directory) -> 404", async () => { + const before = errorsSeen.length; + const res = await fetch(new URL("/sub", server.url)); + expect(res.status).toBe(404); + expect(errorsSeen.length).toBe(before + 1); + expect((errorsSeen.at(-1) as any)?.code).toBe("EISDIR"); + }); + it.skipIf(isWindows || isRoot)("EACCES -> 403", async () => { const before = errorsSeen.length; const res = await fetch(new URL("/noperm.txt", server.url)); From 1cffdc96d374173af44e71b49d9b1663863d4c96 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:23:48 +0000 Subject: [PATCH 07/10] do_sendfile: keep 500 when error() handler's own Bun.file fails error: () => new Response(Bun.file('./404.html')) is the common custom 404-page pattern. When that file is itself missing, the nested do_sendfile was mapping its ENOENT to 404 and hitting the silent short-circuit, so the user never learned their 404.html was absent. Gate the mapping on !has_called_error_handler so a failure in the handler's own response body stays 500 and is reported. The errno->status mapping is factored into sendfile_errno_status() so both the open() and EISDIR branches share it. --- src/runtime/server/RequestContext.rs | 25 +++++++++++++++++++------ test/js/bun/http/bun-serve-file.test.ts | 18 ++++++++++++------ 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index b0f92fbc3359..87bbeee982e9 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1662,6 +1662,22 @@ where true } + /// Default HTTP status for a `Bun.file()` response body that couldn't be + /// opened. A missing file is an expected outcome, so ENOENT/ENOTDIR map to + /// 404 and EACCES/EPERM to 403 rather than 500. When `error()` has already + /// run, the file being served came from the handler itself and its failure + /// is a handler fault, so it stays 500 to keep the report visible. + fn sendfile_errno_status(&self, errno: bun_sys::E) -> u16 { + if self.flags.has_called_error_handler() { + return 500; + } + match errno { + bun_sys::E::ENOENT | bun_sys::E::ENOTDIR | bun_sys::E::EISDIR => 404, + bun_sys::E::EACCES | bun_sys::E::EPERM => 403, + _ => 500, + } + } + pub(crate) fn do_sendfile(&mut self, blob: Blob) { if self.is_aborted_or_ended() { return; @@ -1693,11 +1709,7 @@ where ) { bun_sys::Result::Ok(fd_) => fd_, bun_sys::Result::Err(err) => { - let status = match err.get_errno() { - bun_sys::E::ENOENT | bun_sys::E::ENOTDIR => 404, - bun_sys::E::EACCES | bun_sys::E::EPERM => 403, - _ => 500, - }; + let status = self.sendfile_errno_status(err.get_errno()); let js_err = err .with_path(file.pathlike.path().slice()) .to_js(global_this); @@ -1755,8 +1767,9 @@ where let mut sys: jsc::SystemError = err.to_system_error().into(); sys.message = BunString::static_("Cannot stream a directory as a response body").into(); + let status = self.sendfile_errno_status(bun_sys::E::EISDIR); return self - .run_error_handler_with_status_code(sys.to_error_instance(global_this), 404); + .run_error_handler_with_status_code(sys.to_error_instance(global_this), status); } (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 6491137d2b8b..fbae7cdd2b9d 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -1378,13 +1378,19 @@ describe.each([true, false])("Response(Bun.file()) open-error status mapping (de }); }); -// An `error()` handler that itself throws while handling a Bun.file ENOENT is +// An `error()` handler that itself fails while handling a Bun.file ENOENT is // a handler fault, not an "expected 404": it must still be reported as an -// unhandled error and surface as 500. +// unhandled error and surface as 500. Covers sync throw, async reject, and an +// error handler whose own Bun.file body can't be opened (custom-404-page case). test.concurrent.each([ - ["sync throw", `error: () => { throw new Error("boom in error handler") },`], - ["Promise.reject", `error: () => Promise.reject(new Error("boom in error handler")),`], -])("Response(Bun.file(missing)): throwing error handler is reported (%s)", async (_label, errorClause) => { + ["sync throw", `error: () => { throw new Error("boom in error handler") },`, "boom in error handler"], + ["Promise.reject", `error: () => Promise.reject(new Error("boom in error handler")),`, "boom in error handler"], + [ + "Bun.file body missing", + `error: () => new Response(Bun.file(dir + "/404-also-missing.html")),`, + "404-also-missing.html", + ], +])("Response(Bun.file(missing)): faulting error handler is reported (%s)", async (_label, errorClause, stderrHas) => { using dir = tempDir("serve-file-enoent-handler-fault", { "ok.txt": "ok" }); const fixture = ` const dir = ${JSON.stringify(String(dir))}; @@ -1406,7 +1412,7 @@ test.concurrent.each([ }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stdout.trim()).toBe("500"); - expect(stderr).toContain("boom in error handler"); + expect(stderr).toContain(stderrHas); expect(exitCode).toBe(1); }); From 07e45bd49cbf8905306a942f4738616b390eee78 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:25:50 +0000 Subject: [PATCH 08/10] [autofix.ci] apply automated fixes --- src/runtime/server/RequestContext.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index 87bbeee982e9..c5246bfb1adc 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1768,8 +1768,10 @@ where sys.message = BunString::static_("Cannot stream a directory as a response body").into(); let status = self.sendfile_errno_status(bun_sys::E::EISDIR); - return self - .run_error_handler_with_status_code(sys.to_error_instance(global_this), status); + return self.run_error_handler_with_status_code( + sys.to_error_instance(global_this), + status, + ); } (bun_io::FileType::File, false) }; From 8e5cca7744b4d8856fcaad25ee0d241a0d072090 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:48:11 +0000 Subject: [PATCH 09/10] Compress sendfile_errno_status doc comment --- 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 c5246bfb1adc..a69e539fb868 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -1662,12 +1662,9 @@ where true } - /// Default HTTP status for a `Bun.file()` response body that couldn't be - /// opened. A missing file is an expected outcome, so ENOENT/ENOTDIR map to - /// 404 and EACCES/EPERM to 403 rather than 500. When `error()` has already - /// run, the file being served came from the handler itself and its failure - /// is a handler fault, so it stays 500 to keep the report visible. + /// Default HTTP status when a `Bun.file()` response body can't be opened. fn sendfile_errno_status(&self, errno: bun_sys::E) -> u16 { + // After error() has run we're serving its file; that failing is a handler fault. if self.flags.has_called_error_handler() { return 500; } From 08acac020fdfd0334ea276015919f9fa1fba71a5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 1 Aug 2026 04:57:24 +0000 Subject: [PATCH 10/10] test: retitle already-fulfilled Promise case accurately Promise.resolve(undefined) is synchronously fulfilled, so this exercises the Fulfilled arm, not the Pending arm a real async handler would take. The Pending arm routes through the shared ON_RESOLVE and returns 204 (pre-existing; out of scope here alongside HEAD). --- test/js/bun/http/bun-serve-file.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/js/bun/http/bun-serve-file.test.ts b/test/js/bun/http/bun-serve-file.test.ts index fbae7cdd2b9d..ba5148c75f65 100644 --- a/test/js/bun/http/bun-serve-file.test.ts +++ b/test/js/bun/http/bun-serve-file.test.ts @@ -1366,7 +1366,7 @@ describe.each([true, false])("Response(Bun.file()) open-error status mapping (de expect({ status: res.status, body: await res.text() }).toEqual({ status: 410, body: "custom ENOENT" }); }); - it("async error handler resolving to undefined falls through to 404", async () => { + it("error handler returning an already-fulfilled Promise falls through to 404", async () => { await using s = Bun.serve({ port: 0, development,