fetch: reject an unreadable Bun.file() request body with a TypeError

RO
3mo ago
+73-3

What does this PR do?

fetch() with a Bun.file() request body that cannot be read rejects with a plain Error. Since #35855 the network errors fetch() produces are TypeErrors that carry the system error fields, and this rejection is the one remaining system error on the request side that was left out:

try {
  await fetch("http://127.0.0.1:1/", { method: "POST", body: Bun.file("/definitely/missing/upload") });
} catch (e) {
  console.log(e.constructor.name, e instanceof TypeError, e.code);
}
// before: Error false ENOENT
// after:  TypeError true ENOENT
// for comparison, fetch("http://127.0.0.1:1/") rejects with TypeError { code: "ConnectionRefused" }

The same applies to a directory as the body (EISDIR from the read), a descriptor that is not open (EBADF from the dup), and a Request whose body is the Bun.file().

Cause: in the needs_to_read_file() block of src/runtime/webcore/fetch.rs, both the open/dup failure and the read_file failure converted the bun_sys::Error with to_js(), which is SystemError::to_error_instance, i.e. a plain Error.

Fix: both sites now go through a small helper that builds the same SystemError with to_type_error_instance (the binding #35855 added, and what ValueError::SystemTypeError uses for the network errors from FetchTasklet::on_reject). code, errno, syscall, path/fd and the message are unchanged; only the prototype differs. The SysErrorJsc import goes away because these were its only uses in the file.

Why a TypeError

The fetch spec turns a request body that fails to be read into a network error (HTTP-network fetch, processBodyError), and fetch() rejects every network error with a TypeError. Node does the same for the equivalent input: a body stream over a missing file rejects with TypeError: fetch failed (cause ENOENT). Bun's convention since #35855 is to put the system error fields directly on the TypeError rather than under cause, and that is the shape this change produces, so err instanceof TypeError now identifies all of fetch()'s own failures regardless of whether the body or the connection failed.

Out of scope and unchanged here: a FormData body holding an unreadable Bun.file() (a body extraction error shared with the Request/Response constructors) and data: URL failures still reject with a plain Error; those are separate code paths. The rejected promise is still created the same way as before, so this composes with #37434, which changes how these early rejections are reported but not their class.

How did you verify your code works?

New describe block in test/js/bun/http/fetch-file-upload.test.ts covering the open failure (missing file, also via a Request), the read failure (directory) and the dup failure (descriptor that is not open). Each test checks that the promise comes back already rejected (Bun.peek.status), that the error is instanceof TypeError, and that the system error fields are still there. All four fail on the current release (Expected constructor: TypeError, received the plain Error with the same fields) and pass with this change; the rest of the file passes as well. cargo clippy -p bun_runtime is clean.