Report EBADF instead of UV_EBADF for async libuv fs errors on Windows

Closed
RO
2mo ago
+183-20

Repro (Windows only)

const fs = require("fs");
const fd = fs.openSync("existing.txt", "r");
Bun.write(Bun.file(fd), "abc").catch(e => console.log(e.code, "|", e.message));
Windows:  UV_EBADF | UV_EBADF: unknown error, write
POSIX:    EBADF    | EBADF: bad file descriptor, write
Node:     EBADF    | EBADF: bad file descriptor, write   (errno -4083 on Windows, same as Bun)

Bun.write(Bun.file(fd), new Uint8Array(3)), Bun.write(Bun.file(fd), Bun.stdin) with a piped stdin, Bun.file(fd).writer() and Bun.file(fd).stream() on a write-only fd all report the same UV_E* code on Windows. node:fs on the same fd, and Bun.file(fd).text(), already report EBADF. User code checking err.code === "EBADF" (or any other errno name) therefore fails on Windows for these Bun APIs only.

Cause

libuv hands back an error either as the c_int return value of a synchronous call (ReturnCode) or, for asynchronous requests, in uv_fs_t.result (ReturnCodeI64). ReturnCode::errno() maps the negative UV_E* code to the POSIX E discriminant that bun_sys::Error.errno stores; ReturnCodeI64::errno() returned the raw |UV_E*| magnitude (4083 for UV_EBADF) instead. Every consumer treats the two the same way and stores the value with from_libuv unset: Error::from_uv_rc64 (behind ReturnCodeI64::to_error, used by CopyFileWindows's read/write loop and the PipeWriter/PipeReader file completions that back FileSink and Bun.file().stream()), and WriteFileWindows::on_write_complete directly.

Nothing catches this because on Windows the E enum also declares the sparse UV_E* tail variants at exactly those magnitudes, so Error::resolve_system_errno turns 4083 into E::UV_EBADF: strum names it UV_EBADF, and the libuv message table only has labels for the E* variants, hence "unknown error". Rust-side comparisons such as err.get_errno() == E::EPIPE (for example the shell's broken-pipe bookkeeping in IOWriter::fail_pending_writers) also never matched errors produced by these paths on Windows. The asymmetry between the two return-code types predates the Rust port.

Fix

ReturnCodeI64::errno() translates the code exactly like ReturnCode::errno(); err_enum() is now an alias of it, as it already was on ReturnCode. The translation is shared through one helper that folds codes missing from the table to E::UNKNOWN (the fallback translate_uv_error_to_e already uses), so a negative result can never come back as None and be mistaken for success. That matters for the async callers, which go on to try_from(rc.int()).expect(..) on the "success" path; every code in libuv's errno.h is in the table today, so this only changes what would happen on a future libuv addition. A compile-time block pins both types to the same contract.

Fixing the producer rather than teaching resolve_system_errno to fold UV_E* variants keeps Error.errno holding one kind of value for from_libuv == false, which is what get_errno()/is_retry() assume. The node:fs completion handlers, which store the raw magnitude with from_libuv: true, are a separate convention and are left alone; #32066 is reworking those and independently adds the same unmapped-code fallback to ReturnCode::errno(), while explicitly leaving this mislabeling out of scope, so the two overlap only in that hunk.

Verification

New tests cover each affected producer: Bun.write(Bun.file(fd), string | Uint8Array) and Bun.write(Bun.file(fd), Bun.stdin) in test/js/bun/io/bun-write.test.js, Bun.file(fd).writer() in test/js/bun/util/filesink.test.ts, Bun.file(fd).stream() in test/js/bun/util/bun-file.test.ts. The write tests assert the same code/errno/message on every platform (errno is -4083 on Windows and -9 elsewhere, matching Node); the pipe-source and stream tests are Windows only because POSIX takes different paths there (a pipe source is rejected earlier, and a failing stream() read does not settle yet).

On Windows x64, all five cases fail with bun 1.4.0 ("code": "UV_EBADF", "message": "UV_EBADF: unknown error, write") and pass with this branch; bun-write.test.js, filesink.test.ts, bun-file.test.ts, spawn.test.ts, translate-uv-error-windows.test.ts and error-name-from-libuv.test.ts pass on the debug build. On POSIX the new tests pass before and after, since the changed code is #[cfg(windows)]. cargo check and clippy are clean for bun_libuv_sys/bun_sys on x86_64-pc-windows-msvc.