fs: clamp out-of-range libuv error results instead of truncating into errno

Closed
RO
2mo ago
+200-51

What does this PR do?

Fixes a Windows-only Panic: integer does not fit in destination type in the async node:fs completion path, along with two sibling defects at the same conversion boundary: truncated errnos that alias unrelated error codes, and failed libuv results silently treated as success.

Stack (from shipped 1.3.14 Windows x64 builds)

Panic: integer does not fit in destination type
uv_callback      src/runtime/node/node_fs.zig:264
uv__work_done    libuv threadpool.c:340
uv__process_reqs libuv win/core.c:605
uv_run           libuv win/core.c:737
tickWithTimeout  src/uws_sys/Loop.zig:250

Cause

On Windows, async node:fs operations complete through uv_callback, which hands req.result to the per-op handlers (uv_open, uv_read, uv_write, uv_close, uv_readv, uv_writev, uv_statfs). Each handler converted a negative result with a raw narrowing cast into the u16 Error.errno field:

if rc < 0 {
    return Err(sys::Error { errno: (-rc) as _, from_libuv: true, .. });
}

Normal libuv error codes are small negatives (-4095..-1) and fit. But libuv's uv_translate_sys_error returns already-negative inputs unchanged (vendor/libuv/src/win/error.c), so req->result can carry raw system codes (NTSTATUS-shaped values) whose magnitude does not fit u16. The shipped Zig implementation used @intCast, which panics in release-safe builds: that is the crash above, seen steadily in the wild on 1.3.13/1.3.14 Windows. The Rust port's as cast truncates instead, which trades the crash for a lie: -0x10002 reads back as errno 2 and the user sees ENOENT for an I/O failure that was nothing of the sort.

Auditing the same boundary turned up two more defects of the same class:

  • ReturnCode::errno() returned None for a negative but unmapped code. Every if let Some(errno) = rc.errno() caller treats None as success: the sync uv_fs_futime / uv_fs_mkdtemp / uv_fs_realpath / uv_fs_utime / uv_fs_lutime sites in node_fs, Fd::close, and everything behind ReturnCodeExt::to_error / to_result (named pipes, fs watcher, blob copy). An unmapped failure was silently swallowed; realpath then reads the req.ptr libuv never populated.
  • ReturnCodeI64::err_enum() and err_enum_e() cast i64 to c_int with as, so a result below i32 range could wrap and alias a valid UV_E* code. Error::from_code_int had the same narrowing with .unwrap() (a panic), and IntoErrnoInt for i32 the same truncation.

Fix

One total conversion, bun_libuv_sys::uv_raw_errno(rc: i64) -> Option<u16>: negative results keep their raw |UV_E*| magnitude when it fits u16 and clamp to |UV_UNKNOWN| (4094) when it does not, so they resolve to EUNKNOWN downstream instead of panicking, aliasing, or being dropped. The seven node_fs uv handlers, ReturnCode::raw_errno(), and ReturnCodeI64::errno() all route through it.

ReturnCode::errno() and ReturnCodeI64::err_enum() now resolve unmapped negative codes to the E::UNKNOWN discriminant instead of None, so a failed result can never be mistaken for success. err_enum_e saturates its i64-to-c_int conversion, and from_code_int / IntoErrnoInt for i32 clamp to UNKNOWN instead of panicking or truncating.

The load-bearing hunks are src/libuv_sys/lib.rs (the new conversion) and the errno() / err_enum() fallbacks in src/libuv_sys/libuv.rs; the node_fs and Error.rs changes reroute existing call sites through them.

Verification

There is no JS-reachable way to inject an out-of-range req.result (the triggering environments involve unusual Windows error sources such as network filesystems or filter drivers), so this follows the pattern from #29571 / #29651: the conversions are exposed through bun:internal-for-testing (uvRawErrno, uvTranslatedErrno) and pinned in test/js/node/fs/translate-uv-error-windows.test.ts:

  • uvRawErrno runs on every platform: in-range magnitudes pass through unchanged; -65536, -65538, -1073741804 (NTSTATUS 0xC0000014 as i32), and -(2**53) clamp to 4094, where the old cast produced 0, 2 (ENOENT), 20 (ENOTDIR), and 0.
  • uvTranslatedErrno (Windows lanes): unmapped -4021 and -123456 resolve to 134 (EUNKNOWN) instead of disappearing as success; known mappings (-4058 to ENOENT) are unchanged.
  • The test file fails on the unfixed build (the bun:internal-for-testing exports do not exist there) and passes with this change.
  • cargo check --workspace is clean on x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, and Linux.

Out of scope, noted for completeness: a few call sites store the raw |UV_E*| magnitude without setting from_libuv (Error::from_uv_rc64, the write_file completion), which makes in-range codes display with an UNKNOWN name. That is a pre-existing mislabeling with different user-visible behavior and is not touched here.