Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 59 additions & 16 deletions src/libuv_sys/libuv.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2362,6 +2362,21 @@ pub const fn uv_err_to_e_discriminant(code: c_int) -> Option<u16> {
})
}

/// `E::UNKNOWN` discriminant (see the `UV_UNKNOWN` row above).
const E_UNKNOWN_DISCRIMINANT: u16 = 134;

/// [`uv_err_to_e_discriminant`] for a code that is already known to be a
/// failure: codes missing from the table fold to `E::UNKNOWN` (the same
/// fallback as `bun_errno::translate_uv_error_to_e`) instead of disappearing,
/// so a negative libuv result is always reported as an error.
#[inline]
const fn uv_err_to_e_discriminant_lossy(code: c_int) -> u16 {
match uv_err_to_e_discriminant(code) {
Some(discriminant) => discriminant,
None => E_UNKNOWN_DISCRIMINANT,
}
}

/// Reverse of [`uv_err_to_e_discriminant`]: map a `bun.sys.E` / `bun_errno::E`
/// discriminant to the negative `UV_E*` code node reports in `err.errno` on
/// Windows (`2` → `UV_ENOENT (-4058)`). Same layering rule (no `bun_errno`
Expand Down Expand Up @@ -2516,15 +2531,14 @@ impl ReturnCode {
}
/// When negative, map the
/// `UV_E*` code to the small POSIX `bun.sys.E` discriminant (e.g.
/// `UV_ENOENT (-4058)` → `2`). Returns `None` for non-negative *or*
/// unmapped negative codes. Downstream callers
/// `UV_ENOENT (-4058)` → `2`); `None` when non-negative. Downstream callers
/// (`node_fs`, `sys::Fd`, `write_file`, …) store this directly into
/// `bun_sys::Error.errno`, so it MUST be the translated value, not the raw
/// `|UV_E*|` magnitude — see [`raw_errno`] for the latter.
#[inline]
pub const fn errno(self) -> Option<u16> {
if self.0 < 0 {
uv_err_to_e_discriminant(self.0)
Some(uv_err_to_e_discriminant_lossy(self.0))
} else {
None
}
Expand Down Expand Up @@ -2566,25 +2580,28 @@ impl ReturnCodeI64 {
pub const fn int(self) -> i64 {
self.0
}
/// Same contract as [`ReturnCode::errno`]: a negative `req.result` is a
/// `UV_E*` code, translated to the `bun.sys.E` discriminant that
/// `bun_sys::Error.errno` stores. Storing the raw `|UV_E*|` magnitude there
/// instead resolves to the sparse `E::UV_E*` tail variants on Windows, which
/// JS then sees as `code: "UV_EBADF"` with an "unknown error" message.
#[inline]
pub const fn errno(self) -> Option<u16> {
if self.0 < 0 {
Some(self.0.unsigned_abs() as u16)
} else {
None
if self.0 >= 0 {
return None;
}
Some(if self.0 < c_int::MIN as i64 {
E_UNKNOWN_DISCRIMINANT
} else {
uv_err_to_e_discriminant_lossy(self.0 as c_int)
})
}
/// Translated `bun_sys::E`
/// discriminant via [`uv_err_to_e_discriminant`] (matching
/// [`ReturnCode::err_enum`]). For the typed `bun_sys::E` use
/// `bun_sys::ReturnCodeExt::err_enum_e` (layering: `E` lives upstream).
/// Same translated value as [`errno`] (matching [`ReturnCode::err_enum`]).
/// For the typed `bun_sys::E` use `bun_sys::ReturnCodeExt::err_enum_e`
/// (layering: `E` lives upstream).
#[inline]
pub const fn err_enum(self) -> Option<u16> {
if self.0 < 0 {
uv_err_to_e_discriminant(self.0 as c_int)
} else {
None
}
self.errno()
}
/// `req.result` after a successful `uv_fs_open` is the
/// CRT fd. Returns the raw `uv_file`; caller wraps with `Fd::from_uv`
Expand All @@ -2601,6 +2618,32 @@ impl fmt::Display for ReturnCodeI64 {
}
}

// Both return-code types hand out the same translated errno, and a failure
// never reads as success.
const _: () = {
assert!(matches!(ReturnCode::from_raw(UV_EBADF).errno(), Some(9)));
assert!(matches!(
ReturnCodeI64::init(UV_EBADF as i64).errno(),
Some(9)
));
assert!(ReturnCode::from_raw(0).errno().is_none());
assert!(ReturnCodeI64::init(0).errno().is_none());
assert!(ReturnCodeI64::init(i64::MAX).errno().is_none());
// -4000 sits in a gap no UV_E* constant occupies.
assert!(matches!(
ReturnCode::from_raw(-4000).errno(),
Some(E_UNKNOWN_DISCRIMINANT)
));
assert!(matches!(
ReturnCodeI64::init(-4000).errno(),
Some(E_UNKNOWN_DISCRIMINANT)
));
assert!(matches!(
ReturnCodeI64::init(i64::MIN).errno(),
Some(E_UNKNOWN_DISCRIMINANT)
));
};

// ──────────────────────────────────────────────────────────────────────────
// `O` — `UV_FS_O_*` flag namespace + `from_bun_o`/`to_bun_o` translation.
// The `bun.O.*` POSIX-like values are passed in by the
Expand Down
5 changes: 3 additions & 2 deletions src/sys/Error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,9 @@ impl Error {
})
}

/// `Some(err)` when a libuv `ReturnCodeI64` is negative; `None` on success.
/// `from_libuv` left at default `false`.
/// `Some(err)` when a libuv `ReturnCodeI64` (an async `uv_fs_t.result`) is
/// negative; `None` on success. Like [`from_uv_rc`], `ReturnCodeI64::errno()`
/// already yields the POSIX `E` discriminant, so `from_libuv` stays `false`.
#[cfg(windows)]
#[inline]
pub(crate) fn from_uv_rc64(
Expand Down
65 changes: 65 additions & 0 deletions test/js/bun/io/bun-write.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,71 @@ const IS_UV_FS_COPYFILE_DISABLED =
}
});

// On Windows the write happens in a libuv completion callback. The error it
// reported used to carry the raw libuv code, which JS saw as code "UV_EBADF"
// and message "UV_EBADF: unknown error, write"; Node and POSIX say EBADF.
describe("Bun.write(Bun.file(read-only fd), data) rejects with EBADF", () => {
it.each([
["string", "abc"],
["Uint8Array", new Uint8Array(3)],
])("%s", async (_label, data) => {
using dir = tempDir("bun-write-ebadf", { "target.txt": "original" });
const target = join(String(dir), "target.txt");
const fd = fs.openSync(target, "r");
let caught;
try {
await Bun.write(Bun.file(fd), data);
} catch (e) {
caught = e;
} finally {
fs.closeSync(fd);
}
expect(caught).toBeDefined();
const { code, errno, syscall, message } = caught;
expect({ code, errno, syscall, message }).toEqual({
code: "EBADF",
errno: isWindows ? -4083 : -9,
syscall: "write",
message: "EBADF: bad file descriptor, write",
});
expect(fs.readFileSync(target, "utf8")).toBe("original");
});

// A pipe source cannot go through uv_fs_copyfile, so this exercises the
// uv_fs_read/uv_fs_write loop instead of the single write above. POSIX
// rejects a pipe source earlier with a different error, so Windows only.
it.skipIf(!isWindows)("Bun.stdin pipe source", async () => {
using dir = tempDir("bun-write-ebadf-pipe", { "target.txt": "original" });
const target = join(String(dir), "target.txt");
const fixture = `
const fs = require("fs");
const fd = fs.openSync(${JSON.stringify(target)}, "r");
try {
await Bun.write(Bun.file(fd), Bun.stdin);
console.log("resolved");
} catch (e) {
console.log(JSON.stringify({ code: e.code, errno: e.errno, syscall: e.syscall, message: e.message }));
}
`;
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", fixture],
env: bunEnv,
stdin: new Blob(["hello from the pipe"]),
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(JSON.parse(stdout)).toEqual({
code: "EBADF",
errno: -4083,
syscall: "write",
message: "EBADF: bad file descriptor, write",
});
expect(exitCode).toBe(0);
expect(fs.readFileSync(target, "utf8")).toBe("original");
});
});

describe.each(["plain-ascii-missing.txt", "surro-\ud800-gate.txt"])(
"Bun.write(dest, Bun.file(missing source)) rejects with ENOENT (%s)",
basename => {
Expand Down
27 changes: 26 additions & 1 deletion test/js/bun/util/bun-file.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { expect, test } from "bun:test";
import fsPromises from "fs/promises";
import { bunEnv, bunExe, tempDir } from "harness";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import { join } from "path";

test("delete() and stat() should work with unicode paths", async () => {
Expand Down Expand Up @@ -62,6 +62,31 @@ test("Bun.file() read errors include async stack frames", async () => {
expect(caught.stack).toContain("at async level1");
});

// Windows reads the stream through a libuv callback, whose error used to reach
// JS as code "UV_EBADF" with an "unknown error" message. Bun.file(fd).text()
// is not affected; only stream() takes this path. On POSIX a failing read does
// not settle the pull at all yet, so the test is Windows-only.
test.skipIf(!isWindows)("Bun.file(write-only fd).stream() rejects with EBADF", async () => {
await using dir = tempDir("bun-file-stream-ebadf", { "target.txt": "some content" });
const handle = await fsPromises.open(join(dir, "target.txt"), "a");
let caught: any;
try {
await Bun.file(handle.fd).stream().getReader().read();
} catch (e) {
caught = e;
} finally {
await handle.close();
}
expect(caught).toBeDefined();
const { code, errno, syscall, message } = caught;
expect({ code, errno, syscall, message }).toEqual({
code: "EBADF",
errno: -4083,
syscall: "read",
message: "EBADF: bad file descriptor, read",
});
});

test("Bun.write() errors include async stack frames", async () => {
// Use a file-as-directory-component path so it fails on both POSIX and
// Windows. Bun.write recursively creates directories, so a plain
Expand Down
31 changes: 30 additions & 1 deletion test/js/bun/util/filesink.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createSocketPair, fileSinkInternals } from "bun:internal-for-testing";
import { describe, expect, it } from "bun:test";
import { bunEnv, bunExe, fileDescriptorLeakChecker, isLinux, isPosix, isWindows, tmpdirSync } from "harness";
import { bunEnv, bunExe, fileDescriptorLeakChecker, isLinux, isPosix, isWindows, tempDir, tmpdirSync } from "harness";
import { mkfifo } from "mkfifo";
import { join } from "node:path";

Expand Down Expand Up @@ -463,6 +463,35 @@ if (isWindows) {
});
}

// On Windows the file write completes in a libuv callback, whose error used
// to reach JS as code "UV_EBADF" with an "unknown error" message.
it("writing to a read-only fd reports EBADF", async () => {
using dir = tempDir("filesink-ebadf", { "target.txt": "original" });
const target = join(String(dir), "target.txt");
const fd = fs.openSync(target, "r");
let caught: any;
try {
const sink = Bun.file(fd).writer();
sink.write("abc");
await sink.end();
} catch (e) {
caught = e;
} finally {
try {
fs.closeSync(fd);
} catch {}
}
expect(caught).toBeDefined();
const { code, errno, syscall, message } = caught;
expect({ code, errno, syscall, message }).toEqual({
code: "EBADF",
errno: isWindows ? -4083 : -9,
syscall: "write",
message: "EBADF: bad file descriptor, write",
});
expect(fs.readFileSync(target, "utf8")).toBe("original");
});

// When a write to a pollable fd returns `.pending`, FileSink takes a
// `must_be_kept_alive_until_eof` ref on itself so it survives until the
// buffered data is drained. If the write later fails (e.g. EPIPE because the
Expand Down