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
6 changes: 3 additions & 3 deletions src/js/internal/fs/streams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ function writeAll(data, size, pos, cb, retries = 0) {

retries = bytesWritten ? 0 : retries + 1;
size -= bytesWritten;
pos += bytesWritten;
if (pos !== undefined) pos += bytesWritten;

// Try writing non-zero number of bytes up to 5 times.
if (retries > 5) {
Expand All @@ -542,7 +542,7 @@ function writeAll(data, size, pos, cb, retries = 0) {
}

function writevAll(chunks, size, pos, cb, retries = 0) {
this[kFs].writev(this.fd, chunks, this.pos, (er, bytesWritten, buffers) => {
this[kFs].writev(this.fd, chunks, pos, (er, bytesWritten, buffers) => {
// No data currently available and operation should be retried later.
if (er?.code === "EAGAIN") {
er = null;
Expand All @@ -557,7 +557,7 @@ function writevAll(chunks, size, pos, cb, retries = 0) {

retries = bytesWritten ? 0 : retries + 1;
size -= bytesWritten;
pos += bytesWritten;
if (pos !== undefined) pos += bytesWritten;

// Try writing non-zero number of bytes up to 5 times.
if (retries > 5) {
Expand Down
29 changes: 21 additions & 8 deletions src/runtime/node/node_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3852,19 +3852,13 @@
if !(current.is_number() || current.is_big_int()) {
break 'parse;
}
let position = i52::from_js(current);
if position >= 0 {
args.position = Some(position);
}
args.position = write_position_from_js(current);
arguments.eat();
}
// fs.write(fd, string[, position[, encoding]], callback)
_ => {
if current.is_number() {
let position = i52::from_js(current);
if position >= 0 {
args.position = Some(position);
}
args.position = write_position_from_js(current);
}
// Node consumes the position slot whatever its type
// (null, undefined, a non-number); the encoding is
Expand Down Expand Up @@ -10223,3 +10217,22 @@
(v.to_int64() << 12) >> 12
}
}

/// Node's `GetOffset` for `fs.write` / `fs.writeSync` position: only a
/// non-negative safe integer selects `pwrite`; NaN, ±Infinity, non-integers
/// and negatives fall back to the current file offset (None).
#[inline]
fn write_position_from_js(v: JSValue) -> Option<i64> {
if let Some(num) = v.get_number() {
if num.is_finite()
&& num.trunc() == num
&& num >= 0.0
&& num <= bun_jsc::MAX_SAFE_INTEGER as f64
{
return Some(num as i64);
}
return None;
}
let position = i52::from_js(v);
if position >= 0 { Some(position) } else { None }
}

Check warning on line 10238 in src/runtime/node/node_fs.rs

View check run for this annotation

Claude / Claude Code Review

fs.writev/readv position parser still coerces NaN to offset 0

Same-class site not covered: `FdVectorIo::from_js` (node_fs.rs:2829-2830, backing `fs.writev`/`writevSync`/`readv`/`readvSync`) still does `Some(pos_value.to_int64() as u64)` when `is_number()`, so `fs.writev(fd, bufs, NaN, cb)` still becomes `pwritev` at offset 0 instead of the current file offset. Node's `WriteBuffers`/`ReadBuffers` route position through the same `GetOffset` helper this PR mirrors, so `write_position_from_js` (adapted for `Option<u64>`) should replace the `to_int64()` coercio
Comment on lines +10225 to +10238

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Same-class site not covered: FdVectorIo::from_js (node_fs.rs:2829-2830, backing fs.writev/writevSync/readv/readvSync) still does Some(pos_value.to_int64() as u64) when is_number(), so fs.writev(fd, bufs, NaN, cb) still becomes pwritev at offset 0 instead of the current file offset. Node's WriteBuffers/ReadBuffers route position through the same GetOffset helper this PR mirrors, so write_position_from_js (adapted for Option<u64>) should replace the to_int64() coercion there too. The WriteStream corruption path is closed by the streams.ts fix (writevAll no longer passes NaN), so this only affects direct user calls with non-safe-integer positions — mentioning per REVIEW.md "fix the whole class in the same PR".

Extended reasoning...

What

The PR introduces write_position_from_js() to mirror Node's GetOffset semantics (only a non-negative safe integer selects positional I/O; NaN/±Infinity/non-integers/negatives fall back to the current file offset) and applies it to the fs.write/fs.writeSync argument parser at both call sites. However, the sibling parser FdVectorIo::from_js at node_fs.rs:2827-2830 — aliased as both args::Writev and args::Readv (:2848-2849) and therefore backing fs.writev, fs.writevSync, fs.readv, and fs.readvSync — still does:

if pos_value.is_number() {
    position = Some(pos_value.to_int64() as u64);
}

NaN passes is_number(), and JSValue::to_int64(NaN) returns 0 (as the PR description itself confirms for the fs.write case). So fs.writev(fd, bufs, NaN, cb) still issues pwritev at offset 0 rather than writev at the current file offset. ±Infinity and non-integers like 1.5 similarly get coerced to a positional offset rather than falling through to -1.

Why the JS layer doesn't intercept it

The fs.writev wrapper in src/js/node/fs.ts passes position straight through to the native binding with no coercion, so a user-supplied NaN reaches FdVectorIo::from_js unmodified.

Node parity

Node's binding.writeBuffers and binding.readBuffers (src/node_file.cc) both route args[2] through GetOffset, which is exactly the helper write_position_from_js was written to emulate: IsSafeJsInt(v) ? v->IntegerValue() : -1. So Node's fs.writev(fd, bufs, NaN, cb) writes at the current file offset, while Bun after this PR still writes at offset 0.

Step-by-step

  1. const fd = fs.openSync(p, 'r+') on a 10-byte file of AAAAAAAAAA.
  2. fs.readSync(fd, Buffer.alloc(5), 0, 5, null) — advances the fd cursor to offset 5.
  3. fs.writevSync(fd, [Buffer.from('XX')], NaN).
  4. JS wrapper passes NaN through unchanged → FdVectorIo::from_js.
  5. NaN.is_number() is true → position = Some(to_int64(NaN) as u64) = Some(0).
  6. pwritev_inner sees Some(0) → issues pwritev(fd, iov, 0).
  7. File becomes XXAAAAAAAA. Node writes at the cursor and produces AAAAAXXAAA.

The new writeSync > treats position %s as the current file offset test in this PR would fail if extended to cover fs.writevSync/fs.readvSync.

Impact / severity

This is nit severity. The primary bug this PR set out to fix — WriteStream retries stamping the tail over the file head — is fully closed: writevAll now passes the captured pos (which stays undefined on retry when no start was given), so NaN never reaches fs.writev from the WriteStream path anymore. The remaining divergence is only reachable via direct user calls to fs.writev/fs.readv with a pathological non-safe-integer position, which is pre-existing behavior and not a regression. Flagging it because REVIEW.md asks to "fix the whole class in the same PR — grep for every sibling site sharing the pattern", and this is the exact sibling of the parser the PR just fixed.

Fix

Replace the is_number()to_int64() branch in FdVectorIo::from_js with the same safe-integer gate used in write_position_from_js (adapted for Option<u64>, since this parser stores u64 rather than i64), and extend the new position-coercion test to cover writevSync/readvSync.

144 changes: 144 additions & 0 deletions test/js/node/fs/fs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2024,6 +2024,64 @@ describe("writeSync", () => {
closeSync(fd);
});

// Node's binding maps any non-safe-integer position to -1 (current file
// offset). NaN previously coerced to 0, overwriting the start of the file.
it.each([
["NaN", NaN],
["Infinity", Infinity],
["-Infinity", -Infinity],
["-1", -1],
["1.5", 1.5],
])("treats position %s as the current file offset", async (_label, position) => {
using dir = tempDir("write-position-current", {});
const p = join(String(dir), "f");
writeFileSync(p, Buffer.alloc(10, "A"));
const seed = Buffer.alloc(5);
const expected = "AAAAAXXAAA";

// writeSync(fd, buffer, offset, length, position)
{
const fd = openSync(p, "r+");
try {
readSync(fd, seed, 0, 5, null);
expect(writeSync(fd, Buffer.from("XX"), 0, 2, position as number)).toBe(2);
} finally {
closeSync(fd);
}
expect(readFileSync(p, "utf8")).toBe(expected);
}

// fs.write(fd, buffer, offset, length, position, callback)
writeFileSync(p, Buffer.alloc(10, "A"));
{
const fd = openSync(p, "r+");
try {
readSync(fd, seed, 0, 5, null);
const { promise, resolve, reject } = Promise.withResolvers<number>();
fs.write(fd, Buffer.from("XX"), 0, 2, position as number, (err, written) =>
err ? reject(err) : resolve(written),
);
expect(await promise).toBe(2);
} finally {
closeSync(fd);
}
expect(readFileSync(p, "utf8")).toBe(expected);
}

// writeSync(fd, string, position)
writeFileSync(p, Buffer.alloc(10, "A"));
{
const fd = openSync(p, "r+");
try {
readSync(fd, seed, 0, 5, null);
expect(writeSync(fd, "XX", position as number)).toBe(2);
} finally {
closeSync(fd);
}
expect(readFileSync(p, "utf8")).toBe(expected);
}
});

// writeSync(fd, string[, position[, encoding]]): the encoding used to be
// parsed but never applied, so utf16le/hex/base64/latin1 all wrote raw UTF-8.
it("honors the encoding argument for strings", () => {
Expand Down Expand Up @@ -3671,6 +3729,92 @@ describe("createWriteStream", () => {
}
});
});

// With no `start` the stream passes `pos = undefined` to writeAll/writevAll.
// A partial write must retry at the current file offset (pos stays
// undefined), not at `undefined + bytesWritten` (NaN), which the native
// layer used to coerce to pwrite offset 0 and stamp the tail over the head.
it.each([
["write", undefined],
["write", 0],
["writev", undefined],
["writev", 0],
] as const)("retries a partial %s at the correct offset with start %p", async (method, start) => {
using dir = tempDir("write-stream-partial", {});
const p = join(String(dir), "out");
const payload = Buffer.from("ABCDEFGHIJKLMNOPQRSTUVWXYZ");
const positions: unknown[] = [];
let first = true;

const customFs: any = {
open: fs.open,
close: fs.close,
write(fd, buf, offset, length, position, cb) {
positions.push(position);
if (first) {
first = false;
const half = Math.floor(length / 2);
fs.write(fd, buf, offset, half, position, (err, written) => cb(err, written, buf));
return;
}
fs.write(fd, buf, offset, length, position, cb);
},
writev(fd, chunks, position, cb) {
positions.push(position);
if (first) {
first = false;
fs.writev(fd, [chunks[0]], position, (err, written) => cb(err, written, chunks));
return;
}
fs.writev(fd, chunks, position, cb);
},
};

const stream = createWriteStream(p, { fs: customFs, start } as any);
const { promise, resolve, reject } = Promise.withResolvers<void>();
stream.on("error", reject);
stream.on("finish", resolve);
if (method === "writev") {
stream.cork();
stream.write(payload.subarray(0, 10));
stream.write(payload.subarray(10));
stream.uncork();
stream.end();
} else {
stream.end(payload);
}
await promise;

expect(readFileSync(p)).toEqual(payload);
expect(positions.some(v => typeof v === "number" && Number.isNaN(v))).toBe(false);
if (start === 0) {
expect(positions).toEqual(method === "writev" ? [0, 10] : [0, 13]);
} else {
expect(positions).toEqual([undefined, undefined]);
}
});

// End-to-end: a kernel-enforced short write (RLIMIT_FSIZE) must surface as
// an EFBIG 'error' and leave the file a byte-exact prefix of the source.
it.skipIf(!isLinux)("surfaces EFBIG from a short write instead of overwriting the file head", async () => {
using dir = tempDir("write-stream-fsize", {});
const out = join(String(dir), "out");
const fixture = join(import.meta.dir, "write-stream-fsize-fixture.js");
await using proc = Bun.spawn({
cmd: ["/bin/sh", "-c", `ulimit -f 1024 && exec "$0" "$1" "$2"`, bunExe(), fixture, out],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
const result = JSON.parse(stdout.trim());
expect({ head: result.head, isPrefix: result.isPrefix }).toEqual({ head: "AAAAAAAA", isPrefix: true });
expect(result.events).toContain("error:EFBIG");
expect(result.events).not.toContain("finish");
expect(result.fileSize).toBeLessThanOrEqual(1 << 20);
expect(exitCode).toBe(0);
});
});

describe("fs/promises", () => {
Expand Down
35 changes: 35 additions & 0 deletions test/js/node/fs/write-stream-fsize-fixture.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading