Skip to content

fs: keep WriteStream short-write retries at the current offset - #36136

Closed
robobun wants to merge 1 commit into
mainfrom
farm/2fbac61b/writestream-short-write-nan-position
Closed

fs: keep WriteStream short-write retries at the current offset#36136
robobun wants to merge 1 commit into
mainfrom
farm/2fbac61b/writestream-short-write-nan-position

Conversation

@robobun

@robobun robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Summary

A fs.WriteStream that takes a short write (EFBIG, ENOSPC, a partial writev) could silently overwrite the head of its output file with the unwritten tail and still emit "finish".

Reproduction

Under a 1 MiB RLIMIT_FSIZE, write a single 4 MiB chunk of A|B|C|D 1 MiB blocks:

const s = fs.createWriteStream(p);
s.on("error", e => ev.push("error:" + e.code));
s.on("finish", () => ev.push("finish"));
s.write(Buffer.concat([..."ABCD"].map(c => Buffer.alloc(1 << 20, c))));
s.end();
events bytesWritten fileSize head prefix
bun (before) ["finish"] 4194304 1048576 DDDDDDDD no
node v26.3.0 ["error:EFBIG"] 1048576 1048576 AAAAAAAA yes
bun (after) ["error:EFBIG"] 524288 524288 AAAAAAAA yes

Cause

Two bugs compound:

(a) writeAll/writevAll retry arithmetic (src/js/internal/fs/streams.ts): with no start, _write/_writev pass this.pos (which is undefined) as the initial pos. After a partial write the retry computed pos = undefined + bytesWritten, i.e. NaN, and reissued fs.write(fd, rest, 0, size, NaN, cb). writevAll additionally read this.pos (already advanced past the whole payload by _writev) instead of the captured pos parameter.

(b) fs.write position coercion (src/runtime/node/node_fs.rs): the argument parser ran i52::from_js(position) which calls JSValue::to_int64, and to_int64(NaN) returns 0. With 0 >= 0 the retry became pwrite64(fd, rest, 0), stamping the tail over offset 0. Node's GetOffset instead treats any non-safe-integer as -1 (current offset), so its identical writeAll arithmetic is masked.

Fix

  • streams.ts: advance pos only when it is defined (matching Node's lib/internal/fs/streams.js), and pass the captured pos parameter, not this.pos, to fs.writev.
  • node_fs.rs: a new write_position_from_js mirrors Node's GetOffset (IsSafeJsInt(v) ? v : -1): a non-negative safe integer selects pwrite; NaN, ±Infinity, non-integers and negatives fall back to the current file offset. BigInt keeps the existing i52 path.

Either half alone prevents the corruption; both are needed for full Node parity on fs.write(fd, buf, off, len, NaN, cb).

Verification

New tests in test/js/node/fs/fs.test.ts:

  • writeSync > treats position NaN/Infinity/-Infinity/-1/1.5 as the current file offset: writes land at the advanced cursor, not offset 0, across writeSync(fd, buf, ...), fs.write(fd, buf, ...), and writeSync(fd, string, pos). NaN, -Infinity, and 1.5 fail on main.
  • createWriteStream > retries a partial write/writev at the correct offset: a custom fs forces a short first write over {start: undefined, start: 0}; the retry must not pass NaN and must land at the right offset. write/undefined and writev/0 fail on main.
  • createWriteStream > surfaces EFBIG from a short write instead of overwriting the file head (Linux): spawns the fixture under ulimit -f 1024; asserts error:EFBIG, no finish, and that the on-disk bytes are a byte-exact prefix of the source. Fails on main with head: "DDDDDDDD", isPrefix: false.

Existing fs.write*/writeSync/createWriteStream coverage and the Node test-fs-write* / test-fs-writev* parallel scripts still pass.

Overlaps #31764 on the streams.ts retry-arithmetic piece (that PR is primarily about _writev batching and does not touch the native position parser).


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/fs/fs.test.ts

A WriteStream with no start option passes pos = undefined to
writeAll/writevAll. On a partial write the retry computed
undefined += bytesWritten, which is NaN, and the fs.write argument
parser coerced NaN to position 0. The retry then issued a pwrite at
offset 0, stamping the unwritten tail over the head of the file and
reporting success.

streams.ts: only advance pos when it is a number, and pass the
captured pos (not this.pos, which _writev has already advanced past
the unwritten tail) to fs.writev.

node_fs.rs: parse the fs.write/fs.writeSync position the way Node's
GetOffset does: a non-negative safe integer selects pwrite; NaN,
Infinity, -Infinity, non-integers and negatives fall back to the
current file offset instead of becoming offset 0.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0d28e4d3-d055-4cbe-9e33-abc01640c879

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb6f99 and e45615e.

📒 Files selected for processing (4)
  • src/js/internal/fs/streams.ts
  • src/runtime/node/node_fs.rs
  • test/js/node/fs/fs.test.ts
  • test/js/node/fs/write-stream-fsize-fixture.js

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Ip-location-api updatedb.mjs broken as of bun 1.2.0 #18662 - Script using createWriteStream with backpressure produces corrupted output files, consistent with the writeAll retry computing NaN position and pwrite64 overwriting offset 0
  2. Stream never finishes, downloaded files are corrupted #17115 - Piping download stream to createWriteStream produces files ~4x expected size with corrupted data, consistent with short-write retry duplicating data at offset 0

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #18662
Fixes #17115

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fs: short write in createWriteStream overwrites head of file (NaN position coerced to 0) #36135 - Fixes the same two bugs (NaN position in writeAll/writevAll retries and i52::from_js(NaN) coercing to offset 0 in node_fs.rs), also extends the fix to readv/writev position parsing
  2. fs: batch small WriteStream writes via _writev and fix retry position #31764 - Fixes the same writeAll/writevAll retry-position arithmetic in streams.ts (NaN from undefined + bytesWritten, this.pos vs captured pos parameter), bundled with _writev batching

🤖 Generated with Claude Code

@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #36135 (opened a few minutes earlier). The writevAll piece this PR adds on top is noted there; branch farm/2fbac61b/writestream-short-write-nan-position kept for the tests.

@robobun robobun closed this Jul 27, 2026
Comment on lines +10225 to +10238
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 }
}

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants