From 698bc9f300a6cb4f58888cc9eaaf490d646cacb4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:41:25 +0000 Subject: [PATCH 1/4] fetch: reject an unreadable Bun.file() request body with a TypeError The open and read failures for a Bun.file() request body were built with SystemError::to_error_instance, so they rejected as a plain Error while every network error fetch() produces is a TypeError carrying the same system error fields. Build them with to_type_error_instance instead; code, errno, syscall and path are unchanged. --- src/runtime/webcore/fetch.rs | 15 ++++-- test/js/bun/http/fetch-file-upload.test.ts | 57 ++++++++++++++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 2a41b66e2cc4..e17d4b0d238b 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -55,7 +55,7 @@ use bun_core::{String as BunString, Tag as BunStringTag, ZigStringSlice}; use bun_http::{self as http, FetchRedirect, Headers, HeadersExt as _, MimeType}; use bun_http_jsc::method_jsc; use bun_http_types::Method::Method; -use bun_jsc::{HTTPHeaderName, StringJsc as _, SysErrorJsc as _}; +use bun_jsc::{HTTPHeaderName, StringJsc as _}; use bun_paths::{self, PathBuffer}; use bun_sys::FdExt as _; // `FromJsEnum for FetchRedirect` lives in bun_http_jsc; importing the impl crate @@ -368,6 +368,15 @@ fn reject_on_exception( Ok(JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm(global_this, err)) } +/// Rejection for a `Bun.file()` request body that cannot be opened or read. +/// The fetch spec turns a request body that fails to be read into a network +/// error, so this has the same shape as the network errors from +/// `FetchTasklet::on_reject` (`ValueError::SystemTypeError`): a `TypeError` +/// that keeps the system error's `code`/`errno`/`syscall`/`path`. +fn request_body_file_error(err: &bun_sys::Error, global_this: &JSGlobalObject) -> JSValue { + jsc::SystemError::from(err.to_system_error()).to_type_error_instance(global_this) +} + // ────────────────────────────────────────────────────────────────────────── // URLType // ────────────────────────────────────────────────────────────────────────── @@ -1709,7 +1718,7 @@ fn fetch_impl( let opened_fd = match opened_fd_res { Err(err) => { - let err_js = err.to_js(global_this); + let err_js = request_body_file_error(&err, global_this); let rejected_value = JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm( global_this, @@ -1808,7 +1817,7 @@ fn fetch_impl( let rejected_value = JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm( global_this, - err.to_js(global_this), + request_body_file_error(&err, global_this), ); body.detach(); return Ok(rejected_value); diff --git a/test/js/bun/http/fetch-file-upload.test.ts b/test/js/bun/http/fetch-file-upload.test.ts index 38ea1ae6c323..9c391d327887 100644 --- a/test/js/bun/http/fetch-file-upload.test.ts +++ b/test/js/bun/http/fetch-file-upload.test.ts @@ -239,3 +239,60 @@ test("missing file throws the expected error", async () => { }); Bun.gc(true); }); + +// Like the network errors fetch() rejects with, a Bun.file() body that cannot +// be read rejects with a TypeError that still carries the system error fields. +describe.concurrent("Bun.file() body that cannot be read rejects with a TypeError", () => { + // The body is read before anything is connected, so the promise comes back + // already rejected. Checking that (and the system error fields) pins the + // rejection to the body, not to the connection the URL would refuse. + function earlyRejection(promise: Promise): Promise { + const error = promise.then( + () => { + throw new Error("fetch() resolved"); + }, + err => err, + ); + expect(Bun.peek.status(promise)).toBe("rejected"); + return error; + } + + test("file that does not exist (open fails)", async () => { + using dir = tempDir("fetch-unreadable-body", {}); + const path = join(String(dir), "missing.txt"); + + const err = await earlyRejection(fetch("http://127.0.0.1:1/", { method: "POST", body: Bun.file(path) })); + expect(err).toBeInstanceOf(TypeError); + expect(err).toMatchObject({ + code: "ENOENT", + syscall: "open", + path: expect.stringContaining("missing.txt"), + }); + }); + + test("file that does not exist, as the body of a Request", async () => { + using dir = tempDir("fetch-unreadable-body", {}); + const path = join(String(dir), "missing.txt"); + + const request = new Request("http://127.0.0.1:1/", { method: "POST", body: Bun.file(path) }); + const err = await earlyRejection(fetch(request)); + expect(err).toBeInstanceOf(TypeError); + expect(err).toMatchObject({ code: "ENOENT", syscall: "open" }); + }); + + test("directory (open succeeds, read fails)", async () => { + using dir = tempDir("fetch-unreadable-body", {}); + + const err = await earlyRejection(fetch("http://127.0.0.1:1/", { method: "POST", body: Bun.file(String(dir)) })); + expect(err).toBeInstanceOf(TypeError); + expect(err).toMatchObject({ code: "EISDIR", syscall: "read" }); + }); + + test("file descriptor that is not open (dup fails)", async () => { + const err = await earlyRejection(fetch("http://127.0.0.1:1/", { method: "POST", body: Bun.file(1 << 30) })); + expect(err).toBeInstanceOf(TypeError); + // Windows reports a different errno for a descriptor number that was never + // opened; posix dup() fails with EBADF. + if (!isWindows) expect(err).toMatchObject({ code: "EBADF" }); + }); +}); From 6bb24289a56ed7f43e6875c9547070caa59b8bbb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:48:30 +0000 Subject: [PATCH 2/4] test: also check errno and fd on the unreadable body rejections --- test/js/bun/http/fetch-file-upload.test.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/test/js/bun/http/fetch-file-upload.test.ts b/test/js/bun/http/fetch-file-upload.test.ts index 9c391d327887..98331b2f7efb 100644 --- a/test/js/bun/http/fetch-file-upload.test.ts +++ b/test/js/bun/http/fetch-file-upload.test.ts @@ -265,6 +265,7 @@ describe.concurrent("Bun.file() body that cannot be read rejects with a TypeErro expect(err).toBeInstanceOf(TypeError); expect(err).toMatchObject({ code: "ENOENT", + errno: expect.any(Number), syscall: "open", path: expect.stringContaining("missing.txt"), }); @@ -277,7 +278,12 @@ describe.concurrent("Bun.file() body that cannot be read rejects with a TypeErro const request = new Request("http://127.0.0.1:1/", { method: "POST", body: Bun.file(path) }); const err = await earlyRejection(fetch(request)); expect(err).toBeInstanceOf(TypeError); - expect(err).toMatchObject({ code: "ENOENT", syscall: "open" }); + expect(err).toMatchObject({ + code: "ENOENT", + errno: expect.any(Number), + syscall: "open", + path: expect.stringContaining("missing.txt"), + }); }); test("directory (open succeeds, read fails)", async () => { @@ -285,14 +291,16 @@ describe.concurrent("Bun.file() body that cannot be read rejects with a TypeErro const err = await earlyRejection(fetch("http://127.0.0.1:1/", { method: "POST", body: Bun.file(String(dir)) })); expect(err).toBeInstanceOf(TypeError); - expect(err).toMatchObject({ code: "EISDIR", syscall: "read" }); + expect(err).toMatchObject({ code: "EISDIR", errno: expect.any(Number), syscall: "read" }); }); test("file descriptor that is not open (dup fails)", async () => { - const err = await earlyRejection(fetch("http://127.0.0.1:1/", { method: "POST", body: Bun.file(1 << 30) })); + const fd = 1 << 30; + + const err = await earlyRejection(fetch("http://127.0.0.1:1/", { method: "POST", body: Bun.file(fd) })); expect(err).toBeInstanceOf(TypeError); - // Windows reports a different errno for a descriptor number that was never - // opened; posix dup() fails with EBADF. - if (!isWindows) expect(err).toMatchObject({ code: "EBADF" }); + expect(err).toMatchObject({ code: expect.any(String), errno: expect.any(Number) }); + // Windows does not report EBADF for a descriptor number that was never opened. + if (!isWindows) expect(err).toMatchObject({ code: "EBADF", fd }); }); }); From 8563f51f239a9fb937b72c9e18da5b2e97fed321 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:51:19 +0000 Subject: [PATCH 3/4] fetch: shorten the request_body_file_error doc comment --- src/runtime/webcore/fetch.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index e17d4b0d238b..8118aa0d8ce3 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -368,11 +368,8 @@ fn reject_on_exception( Ok(JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm(global_this, err)) } -/// Rejection for a `Bun.file()` request body that cannot be opened or read. -/// The fetch spec turns a request body that fails to be read into a network -/// error, so this has the same shape as the network errors from -/// `FetchTasklet::on_reject` (`ValueError::SystemTypeError`): a `TypeError` -/// that keeps the system error's `code`/`errno`/`syscall`/`path`. +/// A request body that cannot be read is a network error in fetch terms, so it +/// rejects with the same `TypeError` shape as `FetchTasklet::on_reject`. fn request_body_file_error(err: &bun_sys::Error, global_this: &JSGlobalObject) -> JSValue { jsc::SystemError::from(err.to_system_error()).to_type_error_instance(global_this) } From 20abf1187942bfa3b5491844484841de96b26f27 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 11 Aug 2026 12:53:42 +0000 Subject: [PATCH 4/4] fetch: one-line doc comment on request_body_file_error --- src/runtime/webcore/fetch.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/webcore/fetch.rs b/src/runtime/webcore/fetch.rs index 8118aa0d8ce3..03553ca68b02 100644 --- a/src/runtime/webcore/fetch.rs +++ b/src/runtime/webcore/fetch.rs @@ -368,8 +368,7 @@ fn reject_on_exception( Ok(JSPromise::dangerously_create_rejected_promise_value_without_notifying_vm(global_this, err)) } -/// A request body that cannot be read is a network error in fetch terms, so it -/// rejects with the same `TypeError` shape as `FetchTasklet::on_reject`. +/// Same `TypeError` shape as the network errors from `FetchTasklet::on_reject`. fn request_body_file_error(err: &bun_sys::Error, global_this: &JSGlobalObject) -> JSValue { jsc::SystemError::from(err.to_system_error()).to_type_error_instance(global_this) }