Skip to content

fs: batch small WriteStream writes via _writev and fix retry position - #31764

Open
robobun wants to merge 9 commits into
mainfrom
farm/c227241e/fs-writestream-writev-batching
Open

fs: batch small WriteStream writes via _writev and fix retry position#31764
robobun wants to merge 9 commits into
mainfrom
farm/c227241e/fs-writestream-writev-batching

Conversation

@robobun

@robobun robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

fs.WriteStream was ~2x slower than Node for workloads that emit many small chunks (reported with JSZip's generateNodeStream, #31763). The stream drained one chunk per _write()fs.write() syscall instead of coalescing, and enabling the existing batched path surfaced an EINVAL once a batch crossed IOV_MAX (1024) iovecs.

Closes #31763.
Fixes #21252 — same root cause: that issue bisected a 1.1.x→1.2.0 regression in line-by-line writes to fs.createWriteStream (~1.34M small writes, ~7x slower over a network share). The fs.WriteStream rewrite that introduced this._writev = undefined (#16422) first shipped in 1.2.0, which matches the bisect exactly; re-enabling _writev restores the batching that made 1.1.x fast.

Reproduction

# 50000 × 53-byte writes to a createWriteStream, with backpressure
                       Node    Bun (before)   Bun (after)
50000 × 53B            ~42ms   ~600–890ms     ~21ms

JSZip repro from the issue (13442 files):

                                      Node     Bun (before)   Bun (after)
generateNodeStream + write            ~541ms   ~1135ms        ~507ms

Cause

src/js/internal/fs/streams.ts set this._writev = undefined in the WriteStream constructor. Node's Writable only batches buffered chunks into a single _writev(chunks, cb) call when stream._writev is truthy (clearBuffer: bufferedLength > 1 && stream._writev). With it nulled out, every buffered chunk went individually through _writefs.write(). For createWriteStream("out.zip") (the JSZip case) tens of thousands of chunks meant tens of thousands of trips through the per-chunk state machine and syscall.

Node disables _writev only when the underlying fs has no writev; the default fs has it, so Node batches.

Fix

  1. Keep _writev enabled on the default path, and for a custom options.fs disable it only when that fs has no writev — matching Node. The already-present writeStreamPrototype._writev then coalesces buffered chunks into one fs.writev.

  2. writevAll offset fixes: it passed this.pos instead of the pos parameter, so partial-write retries wrote at the wrong offset; and with no start, both writeAll/writevAll computed undefined + bytesWritten (NaN, coerced to offset 0) on retry, which would overwrite the file head.

Note on scope: an earlier revision of this PR also batched writev/pwritev and capped readv/preadv at IOV_MAX in bun_sys. Main has since landed the same handling one layer up (#33695, node:fs chunks at IOV_MAX matching libuv), so those changes and their duplicate tests were dropped after the rebase; this PR now only touches the WriteStream path and adds boundary coverage (1023/1024/1025/5000) to the existing IOV_MAX test block.

Verification

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

  • createWriteStream > coalesces many small writes via _writev (issue #31763) — 5000 small writes produce byte-for-byte correct output, _writev is exercised, and at least one batch exceeds 1024 iovecs.
  • createWriteStream > partial write/writev retry does not corrupt the file (issue #31763) — a custom fs forces a short write, over a matrix of start: undefined and start: 0; the retry must not pass a NaN position and must resume from the captured pos, not this.pos.
  • writevSync writes all %d buffers at the IOV_MAX boundary (1023/1024/1025/5000), added to the existing writev/readv with more than IOV_MAX buffers block.

The stream tests fail on stock bun (_writev is undefined; NaN retry position) and pass with this change. Existing fs.WriteStream, createWriteStream, and writev/readv IOV_MAX coverage still passes, as do the Node parallel test-fs-write-stream*.js / test-fs-writev*.js scripts.


no test proof · iteration 7 · 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

@robobun

robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:10 PM PT - Jul 26th, 2026

@robobun, your commit 38cf319 has 1 failures in Build #82765 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31764

That installs a local version of the PR into your bun-31764 executable, so you can run:

bun-31764 --bun

@github-actions github-actions Bot added the claude label Jun 3, 2026
@github-actions

github-actions Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. pino is slower in Bun than in Node #6355 - Pino's SonicBoom writes many small log lines via Node streams; with _writev disabled, each became an individual syscall instead of being batched, explaining the ~10x slowdown vs Node
  2. Ip-location-api updatedb.mjs broken as of bun 1.2.0 #18662 - fs.createWriteStream hits backpressure (.write() returns false) much sooner in Bun than Node; without _writev, chunks drain one-at-a-time, filling the buffer faster and triggering premature backpressure
  3. Slow copy over network on bun 1.2.x #21252 - Line-by-line writes to createWriteStream over a network share regressed 7x in 1.2.x; without _writev batching, each small write is a separate syscall with full network round-trip latency
  4. Does not behave the same as nodejs when using pg-copy-streams and pipeline #10273 - Piping pg-copy-streams into fs.createWriteStream via pipeline never finishes; many small chunks draining one-at-a-time without _writev causes backpressure/drain misbehavior

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

Fixes #6355
Fixes #18662
Fixes #21252
Fixes #10273

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

JS WriteStream disables unsupported _writev and guards pos updates; Rust adds POSIX_IOV_MAX and single-call pwritev_one/writev_one helpers, batching vecs into POSIX_IOV_MAX-sized chunks and preserving short-write/error semantics; tests exercise streaming, partial-write retries, and >IOV_MAX cases.

Changes

WriteStream and IOV_MAX Batching

Layer / File(s) Summary
WriteStream custom fs and position tracking
src/js/internal/fs/streams.ts
Disable _writev when custom fs lacks writev; guard position increments in writeAll/writevAll; call fs.writev with the local pos argument so retries and batching advance the correct offset.
pwritev single-call helper
src/sys/lib.rs
Add POSIX_IOV_MAX = 1024 and implement pwritev_one(fd, vecs, offset) with per-OS single-call behavior (macOS nocancel, Linux/Android linux_syscall, other Unix EINTR retry).
pwritev batching loop
src/sys/lib.rs
Batch pwritev when vecs.len() > POSIX_IOV_MAX into sequential pwritev_one calls, accumulate total_written, update offset/position across chunks, and stop on short writes or errors after partial progress.
writev single-call helper
src/sys/lib.rs
Add writev_one(fd, vecs) with platform-specific single-call semantics to centralize behavior across Unix targets.
writev batching loop
src/sys/lib.rs
Batch writev into POSIX_IOV_MAX-sized chunks, call writev_one per chunk, accumulate total bytes, and stop on short writes or errors once partial progress occurred.
Regression and IOV_MAX limit tests
test/js/node/fs/fs.test.ts
Add tests asserting WriteStream _writev batching and correctness, parameterized partial-write retry tests for write/writev using a custom fs shim, and fs.writev tests that cover counts at/above IOV_MAX (sync and async with explicit positions).
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR addresses both #31763 and #21252 by re-enabling _writev batching in WriteStream and fixing POSIX writev/pwritev to handle IOV_MAX correctly, restoring Node-like performance for many small writes.
Out of Scope Changes check ✅ Passed All changes are directly scoped to the stated objectives: WriteStream._writev batching, IOV_MAX handling in sys/lib.rs, offset fixes in writevAll, and targeted regression tests.
Title check ✅ Passed The title clearly matches the main change: WriteStream batching via _writev and retry-position fixes.
Description check ✅ Passed The description covers the PR purpose and verification steps, though it uses custom headings instead of the template's exact section names.

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

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/sys/lib.rs`:
- Around line 4641-4667: The loop in pwritev batching sets position = offset and
always does position += bytes_written, which loses a negative-position sentinel
(offset == -1) used by sys_uv::pwritev to mean "use current file offset"; update
the loop so you only advance position when it is non-negative (e.g., check if
position >= 0 before doing position += bytes_written as i64), preserving the
negative sentinel across chunks and leaving other variables and the call to
pwritev_one unchanged.

In `@test/js/node/fs/fs.test.ts`:
- Around line 2662-2670: The async test using fs.writev may skip closeSync(fd)
if the write rejects; wrap the awaited Promise.withResolvers promise in a
try/finally and call closeSync(fd) in the finally block so the descriptor opened
by openSync(p, "w") is always closed; keep the existing fs.writev callback
resolution logic (Promise.withResolvers<number>), and ensure fd is declared
before the try so finally can always reference it.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: dcc0724e-c855-45bd-8dc8-8a67e6d51688

📥 Commits

Reviewing files that changed from the base of the PR and between d2a6506 and b26608b.

📒 Files selected for processing (3)
  • src/js/internal/fs/streams.ts
  • src/sys/lib.rs
  • test/js/node/fs/fs.test.ts

Comment thread src/sys/lib.rs Outdated
Comment thread test/js/node/fs/fs.test.ts Outdated
@robobun

robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks. I checked each:

So just #21252 here.

Comment thread src/js/internal/fs/streams.ts

@claude claude Bot left a comment

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.

Thanks — the NaN-offset guard in 62d7811 addresses my earlier concern and the regression tests cover both retry paths. I'm not auto-approving because this re-enables a default fs.WriteStream code path and reworks the cross-platform writev/pwritev batching loop in src/sys/lib.rs, which is worth a human pass (and Build #60157 showed failures on 62d7811 before the retrigger).

Extended reasoning...

Overview

This PR re-enables _writev batching on the default fs.WriteStream path (it was explicitly nulled out in the constructor since #16422 / Bun 1.2.0), and adds an IOV_MAX batching loop to bun_sys::writev / bun_sys::pwritev so >1024-iovec arrays don't EINVAL. It also fixes writevAll to pass the local pos parameter instead of this.pos, and — in response to my earlier inline comment — guards pos += bytesWritten in both writeAll and writevAll so a partial-write retry with this.pos === undefined doesn't compute NaN (which Bun's binding coerces to offset 0). Three files: src/js/internal/fs/streams.ts, src/sys/lib.rs, test/js/node/fs/fs.test.ts.

Security risks

None identified. This is local file-I/O syscall plumbing and stream batching; no auth, crypto, network, or untrusted-input parsing is touched. The earlier silent-data-corruption risk (NaN→offset-0 retry) has been fixed and now has a regression test.

Level of scrutiny

Moderate-to-high. The Rust change refactors per-platform #[cfg] branches (macOS ``, Linux/Android linux_syscall, generic POSIX with EINTR retry) into `writev_one`/`pwritev_one` helpers and wraps them in a new accumulation loop that introduces a partial-result path (return bytes-so-far rather than the error if a later batch fails). That semantic is intentional and mirrors libuv / `sys_uv::pwritev`, but it's a behavioral change at the syscall wrapper layer that every `fs.writev`/`fs.WriteStream` call on POSIX now goes through. The JS side flips a default that was deliberately set in 1.2.0, so the original reason for disabling `_writev` (whatever motivated #16422) is worth a maintainer confirming as no longer applicable.

Other factors

  • All three earlier review threads (coderabbit's negative-offset sentinel, fd cleanup in test, and my NaN-retry corruption) are resolved in the current diff; I verified the guards and the position >= 0 check are present.
  • Test coverage is good: a 5000-chunk _writev end-to-end test that crosses IOV_MAX, parameterized partial-write retry tests for both write and writev using a custom-fs shim, and writevSync/pwritevSync/async fs.writev tests at 1023/1024/1025/2000/3000/5000 buffers.
  • The robobun build comment reports failures on Build #60157 for commit 62d7811; c88610b is a ci: retrigger so the failure may be flaky, but a human should confirm CI is green before merge.
  • The Rust refactor is largely a code-motion of the existing per-OS bodies into helpers plus a straightforward chunking loop; the <= POSIX_IOV_MAX fast path keeps the common case identical to before.

Given the cross-platform syscall surface, the re-enabling of a previously-disabled default path, and the pending CI status, this should get a human review rather than an auto-approval.

@robobun

robobun commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator Author

For a reviewer — on "why was _writev disabled in #16422, and is that still applicable":

It looks like an oversight in the original port rather than a deliberate correctness decision. In #16422 (the commit that created src/js/internal/fs/streams.ts, first shipped in 1.2.0), writeStreamPrototype._writev was fully implemented (with the writevAll helper), but the WriteStream constructor then unconditionally set this._writev = undefined, shadowing it — with no comment explaining why. Node's own fs.WriteStream keeps _writev enabled whenever the underlying fs provides writev (it only nulls it when a custom options.fs lacks writev), which is exactly the rule this PR restores.

Two things corroborate that the default _writev path was never actually exercised:

  • writevAll passed this.pos instead of its pos parameter (fixed here), so partial-write retries would have written at the wrong offset.
  • pos += bytesWritten was unguarded, so a partial-write retry with this.pos === undefined produced NaN (coerced to offset 0 by the binding) — the corruption claude flagged. Both are now fixed with regression tests.

So re-enabling it matches Node, and the latent bugs that would have bitten if it were ever live are fixed. Happy to adjust if there's history I'm missing.

@robobun
robobun force-pushed the farm/c227241e/fs-writestream-writev-batching branch from c88610b to 6860162 Compare July 16, 2026 18:59
@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main at 6ad467f (the src/sys/lib.rs conflict was just the comment cleanup from #33909). No code changes beyond the rebase; bun run rust:check-all and the WriteStream/writev tests still pass locally.

Comment thread src/sys/lib.rs Outdated

@claude claude Bot left a comment

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.

No new issues found after the readv/preadv cap in 9c2e3fe, but this re-enables a previously-disabled _writev path on the default createWriteStream and rewrites the POSIX writev/pwritev/readv/preadv batching in src/sys/lib.rs, so it warrants a human look — particularly on the open question you raised about why #16422 disabled _writev in the first place.

Checked: the IOV_MAX batching loops preserve short-write/partial-error semantics and the negative-offset sentinel; writeAll/writevAll retry offsets are now guarded against undefined → NaN; readv/preadv cap (not loop) matching libuv. Also looked at the custom-fs-with-writev-but-no-write case — Node doesn't null _write there either, so leaving it as-is is correct.

Extended reasoning...

Overview

This PR touches three files: src/js/internal/fs/streams.ts (re-enables _writev on the default WriteStream path and guards partial-write retry offsets), src/sys/lib.rs (extracts writev_one/pwritev_one helpers and adds IOV_MAX batching loops for writes plus an IOV_MAX cap for reads), and test/js/node/fs/fs.test.ts (~220 lines of new coverage for batching, partial-write retries, and >1024-iovec writev/readv).

Security risks

None identified. No parsing of untrusted input, no auth/crypto/permissions surface. The syscall wrappers slice a caller-owned iovec array; slicing is bounds-checked Rust. The only user-influenced quantity is the iovec count, which is now capped rather than passed through unchecked — strictly a hardening.

Level of scrutiny

High. src/sys/lib.rs is the shared syscall layer for all POSIX file I/O in the runtime, and src/js/internal/fs/streams.ts is a hot-path built-in module. Re-enabling _writev activates a code path (writevAll) that had never run against Bun's native binding on the default createWriteStream(path) — two latent bugs in that path were surfaced and fixed during review (the this.pos vs pos parameter and the undefined + n → NaN → offset 0 corruption). The batching loop also introduces a new partial-result contract (error-after-progress returns Ok(total) rather than the error), which is the libuv behavior but changes what callers of bun_sys::writev/pwritev observe on mid-batch failure.

Other factors

  • The author explicitly flagged an open question for a human reviewer: whether #16422 disabled _writev deliberately or as an oversight. That's a design/history judgment a bot shouldn't settle.
  • All prior bot feedback (CodeRabbit's negative-offset sentinel and fd-cleanup nits, my NaN-offset corruption finding, and the readv/preadv sibling gap) has been addressed with tests. No outstanding review comments.
  • Cross-platform: the Rust changes are #[cfg(unix)]-gated with per-OS branches (macOS nocancel, Linux syscall, generic libc EINTR loop); Windows already batches in sys_uv. The extraction into *_one helpers is a mechanical move of the pre-existing per-OS bodies. rust:check-all reportedly passes.
  • Test coverage looks solid (boundary counts 1023/1024/1025/2000/5000, sync + async, position + no-position, partial-write retry via a custom fs shim, readv short-read semantics). The readv tests are correctly skipIf(isWindows).
  • Not a simple/mechanical change per the approval bar; deferring to human review.

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for build 82765 (latest push): seven lanes never ran because Buildkite agents failed to provision (infrastructure, not this change), and the only test failures are three known-flaky tests on darwin lanes (no-orphans, regression/20144, test-stream-pipeline), all unrelated to fs. Every lane that ran passes the fs tests from this PR. Ready for review.

@robobun
robobun force-pushed the farm/c227241e/fs-writestream-writev-batching branch from 9c2e3fe to 36c7025 Compare July 16, 2026 21:07
Comment thread test/js/node/fs/fs.test.ts Outdated

@claude claude Bot left a comment

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.

All three of my earlier inline comments (NaN retry offset, readv/preadv IOV_MAX cap, comment length) have been addressed and no new issues surfaced this pass. I'm not auto-approving because this re-enables a WriteStream code path that #16422 deliberately shadowed and reworks the shared sys::writev/pwritev/readv/preadv wrappers — worth a maintainer confirming the #16422 history question the author raised.

What was reviewed:

  • writev/pwritev batching loop: ≤1024-iovec fast path is byte-identical to the old body; short-write and mid-batch error both stop and report partial progress; negative-offset sentinel preserved across batches.
  • Other sys::pwritev callers (RuntimeTranspilerCache) use small fixed iovec counts, so they hit the unchanged fast path.
  • writeAll/writevAll retry: pos guarded against undefined, and writevAll now forwards its pos parameter instead of this.pos.
  • Custom options.fs with writev but no write: _write stays enabled (Node also keeps it) — ruled out as a compat gap.
Extended reasoning...

Overview

Three files: src/js/internal/fs/streams.ts stops nulling _writev on the default WriteStream path (only nulls it when a custom options.fs lacks writev, matching Node), fixes writevAll to pass its pos parameter instead of this.pos, and guards pos += bytesWritten in both writeAll/writevAll so an undefined position doesn't become NaN on partial-write retry. src/sys/lib.rs extracts the existing per-platform writev/pwritev bodies into *_one helpers unchanged, wraps them in an IOV_MAX batching loop for >1024 iovecs, and caps readv/preadv at IOV_MAX (short read, matching libuv). test/js/node/fs/fs.test.ts adds ~200 lines of coverage: _writev coalescing past 1024 iovecs, partial-write retry for both write and writev via a custom fs, writevSync/pwritevSync/async writev at 1023/1024/1025/2000/3000/5000 buffers, and readvSync/preadv/async readv at 1025 buffers.

Security risks

None identified. No auth, crypto, or untrusted-input parsing. The iovec count comes from a JS array length; the new code only reduces what's passed to the kernel (caps/batches at 1024), so it strictly narrows the syscall surface relative to before. The chunk_capacity sum is usize over caller-owned buffer lengths and only gates the short-write break — overflow would require >2^64 bytes of buffers.

Level of scrutiny

Medium-high. src/sys/lib.rs is a shared syscall layer, and fs.WriteStream is a heavily-used Node compat API. The refactor is defensive (≤1024 iovecs early-return to code identical to the old body), and I checked the other in-tree sys::pwritev caller (RuntimeTranspilerCache) — it uses a small fixed iovec array in its own retry loop, so it takes the unchanged fast path. Still, this changes runtime behavior for a widely-used stream and re-enables a path that was explicitly disabled in #16422; the author left an open note for a reviewer asking whether that disabling had history they're missing. That's a question for a maintainer, not a bot.

Other factors

All prior review feedback (mine and CodeRabbit's) is resolved: the negative-offset sentinel is preserved across pwritev batches, the async writev test closes its fd in finally, the NaN-retry-offset bug is fixed with regression tests for both write and writev, readv/preadv are capped, and the test header comments were trimmed. The bug-hunting pass this run found nothing new; the one candidate raised (custom fs with writev but no write should disable _write) was checked against Node's implementation and ruled out — Node keeps _write too. Test coverage is thorough across the boundary (1023/1024/1025) and both sync/async/positional variants. The remaining reason to defer is scope, not a specific concern.

Jarred-Sumner pushed a commit that referenced this pull request Jul 16, 2026
## Problem

Every vectored-I/O entry point in `node:fs` (`writevSync` / `writev` /
`promises.writev` / `FileHandle.writev` / `FileHandle.readv` /
`readvSync`) throws `EINVAL` the moment the buffer array crosses
`IOV_MAX` (1024 on Linux and macOS). Node.js handles any count:

```js
import * as fsp from "node:fs/promises";
import * as fs from "node:fs";
for (const n of [1024, 1025, 2000]) {
  const fh = await fsp.open(p, "w+");
  await fh.writev(Array.from({ length: n }, (_, i) => Buffer.from([i & 255])));
  // ...
}
```

```
node v26.3.0:  n=1024 writev:1024 readv:1024 | n=1025 writev:1025 readv:1024 | n=2000 writev:2000 readv:1024
bun (before):  n=1024 writev:1024 readv:1024 | n=1025 writev:EINVAL readv:EINVAL | n=2000 writev:EINVAL readv:EINVAL
bun (after):   n=1024 writev:1024 readv:1024 | n=1025 writev:1025 readv:1024 | n=2000 writev:2000 readv:1024
```

## Cause

`NodeFS::{writev,pwritev,readv,preadv}_inner` pass the full iovec array
to a single `writev(2)` / `preadv(2)`. POSIX kernels reject `iovcnt >
IOV_MAX` with `EINVAL`.

Node's libuv handles this in `uv__fs_write_all` (loops `IOV_MAX`-sized
batches, accumulates bytes, returns the partial total on a mid-loop
error) and `uv__fs_read` (caps `nbufs` at `IOV_MAX`, single syscall).

## Fix

Mirror libuv in the `node:fs` layer (`src/runtime/node/node_fs.rs`):

- `writev_inner` / `pwritev_inner`: loop `IOV_MAX`-sized slices of the
iovec array, accumulating `bytes_written`. An error or short write after
the first batch returns the accumulated total; `pwritev` advances the
position by bytes written each batch.
- `readv_inner` / `preadv_inner`: slice the iovec array to at most
`IOV_MAX` entries and issue one syscall.

`IOV_MAX` is taken from `libc::UIO_MAXIOV` on Linux and `libc::IOV_MAX`
elsewhere (both 1024 on supported targets). Windows has no kernel iovec
limit; the constant is `c_uint::MAX` there so the loop degenerates to a
single call into `sys_uv`, which already batches.

A small `bun_sys::platform_iovec_len` helper is added so the
chunk-capacity sum compiles on both the `libc::iovec` (unix) and
`uv_buf_t` (windows) field layouts.

## Tests

`test/js/node/fs/fs.test.ts` gains a `writev/readv with more than
IOV_MAX buffers` block covering all entry points with 2000 one-byte
buffers: `writevSync`, `writevSync` with position, callback `fs.writev`,
`FileHandle.writev`, `readvSync`, `readvSync` with position, and
`FileHandle.readv`. Each writev case asserts the full byte count and
file contents; each readv case asserts `bytesRead == 1024` on POSIX (the
libuv cap). All seven fail with `EINVAL` on stock bun and pass with this
change.

`bun run rust:check-all` passes on every target.

## Related

#31764 overlaps on the `writev` side (it batches inside
`bun_sys::writev`/`pwritev` as part of a larger `fs.WriteStream` change)
but does not cover `readv`/`preadv`. This PR places the chunking in the
`node:fs` layer where the libuv semantics belong and leaves the raw
`bun_sys` wrappers as single-syscall primitives.

@claude claude Bot left a comment

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.

I didn't find any bugs, but this enables the previously-disabled _writev path on default fs.WriteStream and reworks the POSIX writev/pwritev/readv/preadv syscall wrappers — worth a human look given how hot createWriteStream is.

Checked: the pwritev batching loop stops on short writes and preserves the negative-offset sentinel; writevAll's this.pospos fix is correct (the caller advances this.pos before the callback fires); the .len vs .iov_len field accesses match PlatformIoVecConst vs libc::iovec; the fast-path branch still nulls _writev so stdio streams are unaffected.

Extended reasoning...

Overview

This PR fixes GitHub issue #31763 across three layers:

  • src/sys/lib.rs: Extracts pwritev_one/writev_one helpers and wraps them in a batching loop capped at POSIX_IOV_MAX (1024), so callers passing >1024 iovecs no longer get EINVAL from the kernel. readv/preadv are capped to one batch and return a short read (matching libuv). The pwritev loop advances an explicit offset per batch but preserves a negative sentinel, and reports partial progress rather than an error if a later batch fails.
  • src/js/internal/fs/streams.ts: Stops unconditionally disabling _writev on default-fs WriteStream, so buffered writes now coalesce into fs.writev (Node-matching). Also fixes two latent bugs in the newly-live writevAll path: it was reading this.pos (already advanced by _writev) instead of the captured pos, and both writeAll/writevAll computed undefined + bytesWrittenNaN on retry when no start was set. For custom fs without writev, _writev is now correctly disabled (previously it stayed on the prototype and would have called undefined(...)).
  • test/js/node/fs/fs.test.ts: ~200 lines covering _writev batching past IOV_MAX, the NaN retry regression for both write and writev, and writevSync/readvSync at the 1023/1024/1025/2000/5000 boundaries.

Security risks

None. This is I/O batching and offset arithmetic on file descriptors the caller already owns; no new user-controlled input reaches a trust boundary.

Level of scrutiny

Medium-high. fs.createWriteStream is one of the most heavily used Node APIs, and this PR flips its default write path from per-chunk _write to batched _writev — a code path that was previously dead for the default case (and had at least two latent bugs, both fixed here). The Rust changes also touch the shared syscall wrappers used by every vectored I/O caller in the runtime. Nothing looks wrong, but the blast radius is large enough that a maintainer should confirm the behavioral change is intended and that there are no other consumers of bun_sys::writev/pwritev that depend on single-syscall semantics.

Other factors

  • Test coverage is thorough and hits the exact boundaries (1023/1024/1025) plus the retry/NaN case for both write variants.
  • I verified the field names in the batching loops (v.len on PlatformIoVecConst, v.iov_len on libc::iovec) match the struct definitions.
  • The $fastPath branch still sets this._writev = undefined, so stdio and Bun.file().writer() paths are unchanged.
  • The commit history shows iterative fixes (negative-offset sentinel, NaN guard, readv cap) landed as separate commits, suggesting the author already caught and addressed the obvious edge cases.

robobun added 6 commits July 26, 2026 17:37
fs.WriteStream unconditionally set `this._writev = undefined`, disabling
Node's Writable writev batching. Many small writes (e.g. JSZip's
generateNodeStream emitting tens of thousands of tiny chunks) were then
drained one chunk per `_write()` -> `fs.write()` syscall instead of being
coalesced, making it 20-50x slower than Node.

Keep `_writev` enabled on the default path and disable it for a custom
`fs` only when that fs has no `writev`, matching Node. This lets the
existing `writeStreamPrototype._writev` coalesce buffered chunks into a
single `fs.writev`.

Coalescing exposed a second bug: a batch can exceed IOV_MAX (1024)
iovecs, which writev(2)/pwritev(2) reject with EINVAL. Node's fs.writev
handles >1024 buffers transparently. Batch oversized iovec arrays in
bun_sys::writev/pwritev into groups of at most IOV_MAX, accumulating
bytes and stopping on a short write — mirroring the existing Windows
sys_uv path. This fixes raw fs.writev/fs.writevSync with many buffers
too.

Also fix writevAll passing `this.pos` instead of the `pos` parameter,
which wrote at the wrong offset on partial-write retries.
… test

Address review feedback:
- In the batched pwritev path, only advance `position` when it is
  non-negative. A negative offset is the "use the current file offset"
  sentinel; incrementing it would switch later batches to explicit
  offsets on the oversized-iovec path. Matches sys_uv::pwritev.
- Close the fd via try/finally in the async fs.writev test so it is not
  leaked if the write rejects.
On a partial write, writeAll/writevAll retried the unwritten tail with
`pos += bytesWritten`. For the default createWriteStream(path) (no
`start`), `this.pos` is undefined, so the retry computed
`undefined + bytesWritten === NaN`. Bun's fs binding coerces a NaN
position to offset 0, so the retry wrote the tail at the start of the
file, silently overwriting the head.

This path became reachable for plain createWriteStream once `_writev`
was re-enabled, and the new IOV_MAX batching loop adds a partial-result
path that can trigger it. Only advance an explicit position, leaving
undefined ("current file offset") intact on retry — matching the guards
already in `_write`/`_writev`. Add regression tests that simulate a
short write for both the write and writev paths.
@robobun
robobun force-pushed the farm/c227241e/fs-writestream-writev-batching branch from b36e46c to 368a3e9 Compare July 26, 2026 17:46
Comment thread src/js/internal/fs/streams.ts Outdated
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main at 44f6469. The conflict was in the customFs handling in src/js/internal/fs/streams.ts: main now already matches Node's if (!write) this._write = null; if (!writev) this._writev = null;, so I kept main's version there. The net diff of this PR against main is now just:

  • drop the this._writev = undefined; from the default-fs else branch,
  • fix writevAll to forward its pos parameter (was this.pos),
  • guard pos += bytesWritten against undefined in writeAll/writevAll,
  • the IOV_MAX batching/cap in src/sys/lib.rs,
  • the three test blocks in test/js/node/fs/fs.test.ts.

Locally with this branch:

fs.createWriteStream: 10000 x 100 B writes (no backpressure)
  node       0 fs.write, 1 fs.writev
  bun main   10000 fs.write, 0 fs.writev
  this PR    0 fs.write, 1 fs.writev

cork(); 100 x write(); uncork()
  node       0 fs.write, 1 fs.writev
  bun main   100 fs.write, 0 fs.writev
  this PR    0 fs.write, 1 fs.writev

All test/js/node/test/parallel/test-*write-stream*.{js,mjs}, test-fs-writev*.js, and the 27 createWriteStream|WriteStream tests in fs.test.ts pass. bun run rust:check-all passes across all 10 targets.

The previous build's red (74155) was all unrelated: bun-create.test.ts hit a live registry outage ("NPMIsDown") across every lane, plus two pre-existing ASAN failures (timer-heap-race, test-worker-message-port-transfer-terminate) on the x64-asan lane and scattered known-flaky tests.

Comment thread src/js/internal/fs/streams.ts Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
Comment thread src/sys/lib.rs Outdated
@robobun robobun changed the title fs: batch small WriteStream writes via _writev and handle IOV_MAX fs: batch small WriteStream writes via _writev and fix retry position Jul 26, 2026
Comment thread test/js/node/fs/fs.test.ts Outdated

@claude claude Bot left a comment

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.

LGTM — the diff is now down to four production lines in streams.ts plus tests, and every prior review round's feedback has been addressed.

What was reviewed:

  • _writev re-enable: confirmed the fast-path branch still sets this._writev = undefined, so only the default fs.writev-backed path is affected; matches Node's rule of disabling only when a custom fs lacks writev.
  • writevAll this.pos → pos and the two if (pos !== undefined) guards: traced the retry with start: 0 and start: undefined — the new 4-case matrix makes each clause load-bearing.
  • Coalescing test: pump loop + 16KB HWM guarantees a >1024-chunk batch; #33695's node_fs IOV_MAX chunking handles it, so no EINVAL.
Extended reasoning...

Overview

After several rescoping rounds, this PR is now four production-line changes to src/js/internal/fs/streams.ts plus ~130 lines of tests in test/js/node/fs/fs.test.ts:

  1. Delete this._writev = undefined from the default-fs else branch, so WriteStream inherits the prototype _writev and Writable's clearBuffer batches buffered chunks into one fs.writev (Node's behavior).
  2. writevAll: pass the pos parameter instead of this.pos to this[kFs].writev.
  3. writeAll/writevAll: guard pos += bytesWritten with if (pos !== undefined) so a retry with no start doesn't produce NaN → offset 0.

The src/sys/lib.rs IOV_MAX batching that was in earlier revisions has been dropped (superseded by #33695 in node_fs.rs), and the duplicate test blocks were folded into the existing writev/readv with more than IOV_MAX buffers describe. All comment-length nits from comment-cop and the earlier review rounds are resolved. My last round's feedback (the writev retry test being vacuous without start: 0) was addressed in 38cf319 by expanding the matrix to [write, writev] × [undefined, 0].

Security risks

None. This is a Node-compat performance/correctness fix in the fs WriteStream layer. No parsing of untrusted input, no auth/crypto/permissions surface. The re-enabled _writev path routes through the same fs.writev binding that has always been reachable via a custom options.fs that supplies writev.

Level of scrutiny

Medium. The line count is tiny, but the behavioral surface is real: every default createWriteStream() that buffers multiple chunks will now go through fs.writev instead of per-chunk fs.write. That warranted checking: (a) the fast-path ($fastPath) still nulls _writev explicitly, so stdio/FileSink streams are unaffected; (b) fileHandleStreamFs supplies writev (or undefined, which the customFs branch already handles via if (!writev) this._writev = null); (c) #33695 already chunks >IOV_MAX at the node_fs layer, so the 5000-chunk coalescing test won't EINVAL; (d) the writevAll retry logic is now correct for both positional and current-offset writes.

Other factors

  • Test coverage is solid and each production clause is load-bearing in at least one test (verified in the previous review round; the author confirmed the writev + start: 0 variant fails on main's streams.ts).
  • Node parallel test-fs-write-stream*.js / test-fs-writev*.js reportedly pass.
  • All bot/inline threads are resolved; no outstanding human review comments.
  • The bug-hunting system found nothing on this revision.

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.

Slow copy over network on bun 1.2.x JSZip + Node stream adapter performs significantly slower in Bun than Node under identical workload.

2 participants