Skip to content

node:fs: fs.writev and fs.readv take the last argument as the callback - #46460

Open
robobun wants to merge 1 commit into
mainfrom
robobun/5e93f88b/fs-callback-arg-wins
Open

robobun wants to merge 1 commit into
mainfrom
robobun/5e93f88b/fs-callback-arg-wins

Conversation

@robobun

@robobun robobun commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • fs.writev(fd, bufs, fn, cb) and fs.readv(fd, bufs, fn, cb) call fn and never cb. Node calls cb. util.promisify(fs.writev)(fd, bufs, fn) resolves on Node and stays pending on Bun.
  • The cause is if (typeof position === "function") callback = position in src/js/node/fs.ts. It overwrites a callback that the caller gave. Node has callback ||= position (lib/fs.js:755).

Fix

  • Both wrappers use Node's line, callback ||= position. The native parser already reads a function in the position slot as the current position.
  • Both report an invalid fd or buffers before an invalid callback, as Node does. Only an invalid callback reaches that check.
  • Verified: test/js/node/fs/fs.test.ts, one new block (30 tests, 13 fail on main). Other suites: Notes.
  • Self-reviewed: 14 concerns raised, 11 addressed. Not taken: a node:test copy of the rows. Two belong to node:fs: take a function in the options slot as the default options #46369.

Background

  • Seven Node fs functions take the last argument as the callback, even after a function. Bun had three (readFile, writeFile, write). This PR adds two.
  • The other two, appendFile and realpath.native, leave the function in the options slot. They need node:fs: take a function in the options slot as the default options #46369 first.
  • Considered the native binding: it gets a callback that JS already selected.

Downsides

  • fs.writev(fd, bufs, fn, "x") called fn. It now throws ERR_INVALID_ARG_TYPE before any I/O, as Node does.
  • Embedded JS grows by 494 B. .text does not change. A valid call runs 5 fewer bytecodes.
  • fs.write and other fd functions still check the callback first. Notes lists what stays different from Node.
Notes

Repro. Node v26.3.0, a release build of main 9634520, and a release build of this change.

const fs = require("fs"), os = require("os"), path = require("path"), { promisify } = require("util");
const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "wv-")), "f.txt");
fs.writeFileSync(file, "0123456789");
const fd = fs.openSync(file, "r+");
const bufs = () => [Buffer.from("ab")];
const who = (label, start) => new Promise(done => start(() => done(label + ": fn"), () => done(label + ": cb")));
const thrown = (label, run) => { try { run(); return label + ": no throw"; } catch (e) { return `${label}: ${e.code} ${e.message}`; } };
(async () => {
  console.log(await who("writev(fd, bufs, fn, cb)", (fn, cb) => fs.writev(fd, bufs(), fn, cb)));
  console.log(await who("readv(fd, bufs, fn, cb) ", (fn, cb) => fs.readv(fd, bufs(), fn, cb)));
  const pending = new Promise(done => setImmediate(() => setTimeout(done, 200, "pending")));
  console.log("promisify(writev)(fd, bufs, fn):", await Promise.race([promisify(fs.writev)(fd, bufs(), () => {}).then(v => "resolves " + Object.keys(v)), pending]));
  console.log(thrown("writev(fd, bufs, fn, 'str') ", () => fs.writev(fd, bufs(), () => {}, "str")));
  console.log(thrown("writev(-1, bufs, fn, 'str') ", () => fs.writev(-1, bufs(), () => {}, "str")));
  console.log(thrown("writev(-1, bufs)            ", () => fs.writev(-1, bufs())));
  console.log(thrown("writev(fd, 'x', fn, 'str')  ", () => fs.writev(fd, "x", () => {}, "str")));
  fs.closeSync(fd);
})();

Node v26.3.0:

writev(fd, bufs, fn, cb): cb
readv(fd, bufs, fn, cb) : cb
promisify(writev)(fd, bufs, fn): resolves bytesWritten,buffer
writev(fd, bufs, fn, 'str') : ERR_INVALID_ARG_TYPE The "cb" argument must be of type function. Received type string ('str')
writev(-1, bufs, fn, 'str') : ERR_OUT_OF_RANGE The value of "fd" is out of range. It must be >= 0 && <= 2147483647. Received -1
writev(-1, bufs)            : ERR_OUT_OF_RANGE The value of "fd" is out of range. It must be >= 0 && <= 2147483647. Received -1
writev(fd, 'x', fn, 'str')  : ERR_INVALID_ARG_TYPE The "buffers" argument must be an ArrayBufferView[]. Received type string ('x')

main 9634520:

writev(fd, bufs, fn, cb): fn
readv(fd, bufs, fn, cb) : fn
promisify(writev)(fd, bufs, fn): pending
writev(fd, bufs, fn, 'str') : no throw
writev(-1, bufs, fn, 'str') : ERR_OUT_OF_RANGE The value of "fd" is out of range. It must be >= 0 && <= 2147483647. Received -1
writev(-1, bufs)            : ERR_INVALID_ARG_TYPE The "cb" argument must be of type function. Received undefined
writev(fd, 'x', fn, 'str')  : ERR_INVALID_ARG_TYPE Expected ArrayBufferView[]

This branch prints Node's lines, except for line 3: resolves bytesWritten,buffers (see "Not changed here").

The promisify case. It is a parity gap in these two functions, not a general hang. util.promisify puts its callback last, so promisify(fs.writev)(fd, bufs, fn) is fs.writev(fd, bufs, fn, cb). In a slot-first function such as fs.stat or fs.readdir, the same shape stays pending on Node too. This PR does not change those.

Native binding. fs.writevCb takes the callback as argument 0 (split_callback in src/runtime/node/node_fs_binding.rs), so the choice between position and callback has to happen in the wrapper, where Node makes it.

Check order. callback ||= position alone would move fs.writev(-1, bufs, fn, "str") from Node's fd error to the cb error. throwInvalidVectorCallback keeps Node's order (fd, buffers, callback) for writev and readv. It also repairs fs.writev(-1, bufs), which reported cb on main. The helper is for these two functions only. fs.write(-1, buf, fn, "str") still reports cb where Node reports fd, and so do other functions: in a probe of 22 doubly invalid calls, 10 differ from Node on main (access, chmod, copyFile, open, rm, mkdir, truncate, ftruncate, write, fstat). Node checks the callback at a different point in each function, so no single place fixes the class. Open #44476 reorders fs.read and fs.write.

Not changed here. Each row is the same on main and on this branch.

Call Node v26.3.0 Bun Where
fs.realpath.native(p, fn, cb) calls cb calls fn #46369
fs.appendFile(p, d, fn, "x") throws ERR_INVALID_ARG_TYPE calls fn needs #46369 first
util.promisify(fs.writev)(fd, bufs) resolves { bytesWritten, buffer } resolves { bytesWritten, buffers } it.failing in this PR marks it
fs.readv(fd, [], cb) cb(EINVAL) cb(null, 0, []) #34395, closed unmerged
fs.writev(fd, "x", cb) The "buffers" argument must be an ArrayBufferView[]. Received type string ('x') Expected ArrayBufferView[] native message
fs.writev(2147483647, bufs, cb) err.syscall is write err.syscall is writev native, same for readv
fs.write(-1, buf, fn, "str") ERR_OUT_OF_RANGE for fd ERR_INVALID_ARG_TYPE for cb #44476
fs.read(fd, buf, 0, 1, 0, "str") throws ERR_INVALID_ARG_TYPE returns, then an uncaught TypeError #44476
fs.fstat(fd, "str") throws ERR_INVALID_ARG_TYPE returns, then an uncaught TypeError no PR yet

appendFile and realpath.native need #46369 first because callback ||= options leaves the function in the options slot, and the native parser reads option properties off it. On main, fs.appendFile(p, d, fn, cb) with a flush property on fn throws ERR_INVALID_ARG_TYPE for flush. With callback ||= options alone, fs.appendFile(p, d, cb) would do the same for a debounced callback. #46369 makes the parsers take a function as the default options. Its open review threads already ask for the callback || options form in realpath.native.

History and precedent. The two wrappers came with #3419, with if (typeof position === "function") { callback = position; position = null; }. Node's lines are lib/fs.js:755 (readv) and :936 (writev). #12412 wrote callback ||= options into readFile and writeFile. #39552 made the same kind of change in node:dns. #46021 took the error path of these two wrappers (callback(err, 0, buffers)) from #41440.

Measurements. Release builds (LTO) of main 9634520 and of this change on top of it, same tree and build directory. The branch is now rebased onto 7a74841, which does not change src/js/node/fs.ts.

  • Bytecodes executed per valid call, wrapper plus ensureCallback (BUN_JSC_dumpGeneratedBytecodes=1): 30 to 25 with a position, 32 to 27 without. The wrapper tests the callback itself and calls wrapFsCallback directly, so ensureCallback (8 bytecodes) no longer runs.
  • JS heap objects per pending call (heapStats after 1,000 pending calls): 2 Function and 2 JSLexicalEnvironment, 0 Promise, before and after.
  • Size (llvm-size -A): .bun_builtins 2,712,743 to 2,713,237 (+494 B), .text 81,932,069 unchanged. bloaty is not installed in my container.
  • Rows that differ from Node in a 44-row table of writev and readv call shapes (position and callback of each type): 10 on main, 0 on this branch.

Tests. The block has 30 tests. On main 13 fail. The other 17 are controls: readFile, writeFile and write already take the last argument, slot-first functions (realpath, readdir, stat, mkdir) keep calling the function in the slot, a falsy last argument falls back to the function before it, and one it.failing marks the promisify key. Each expected value is what Node v26.3.0 gives for the same call. I reverted each of the 10 clauses of the change in turn (the two ||= lines, ||= to ??=, each check of the helper, their order): each revert fails at least one test.

Also run on a debug ASAN build of this branch: the vendored test-fs-* files for read, write, readv, writev and streams (97 files, all pass), dir.test.ts, promises.test.js, fs-promises-writeFile-async-iterator.test.ts, util-promisify.test.js, trace-events.test.ts (all pass) and AsyncLocalStorage.test.ts (62 pass, and one test, a loop of 100,000 iterations with no fs call, times out on the debug build in my container).

The whole of fs.test.ts on the release build: 652 pass, 1 fails. The failure is "keeps a non-throwing callback in the same place in the event loop". It fails in 7 of 8 runs on a release build of main too in my container: it races a thread-pool fs.stat against setImmediate.

Node's own tests do not pass two functions, so no vendored test covers this change.

Not run. Windows and macOS. The change is JS only and has no platform branch.


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

fs.writev(fd, buffers, fn, cb) and fs.readv(fd, buffers, fn, cb) took the
function in the position slot as the callback and never called cb. The
result went to the wrong function, and util.promisify(fs.writev)(fd, buffers,
fn) never resolved. Both wrappers now use node's line, `callback ||= position`.
The native parser already reads a function in the position slot as the
current position.

Both functions report an invalid fd or buffers before an invalid callback, as
node does. Only a call with an invalid callback runs that check.
@robobun

robobun commented Oct 22, 2026

Copy link
Copy Markdown
Collaborator Author

Status

@robobun

robobun commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:31 AM PT - Oct 22nd, 2026

❌ @robobun, your commit 04f04fe has 1 failures in Build #130731 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46460

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

bun-46460 --bun

@coderabbitai

coderabbitai Bot commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: a3e80671-2c51-4273-8dc9-54d54e7725e8
📥 Commits

Reviewing files that changed from the base of the PR and between 7382b38 and 04f04fe.

📒 Files selected for processing: 2
  • src/js/node/fs.ts
  • test/js/node/fs/fs.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

readv and writev now use position as a callback fallback and validate vector arguments when no callable callback is found. Tests cover callback selection across filesystem APIs and vector I/O behavior.

Changes

Filesystem callbacks

Layer / File(s) Summary
Vector I/O callback handling
src/js/node/fs.ts
readv and writev use position as a callback fallback. When neither callback argument is callable, they validate the file descriptor and buffers before throwing an invalid-callback error.
Callback selection and vector I/O tests
test/js/node/fs/fs.test.ts
Tests cover callback selection for filesystem APIs, vector I/O results and errors, promisification, callback-slot error messages, and validation order.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 04f04

No merge-blocking issue is identified in the vector I/O callback change; proceed with normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely identifies the main change: making fs.writev and fs.readv use the last argument as the callback.
Description check Passed The description explains the problem, fix, scope, known differences, and verification results. It does not use the template headings exactly, but it provides the required information, including how th…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description explains the problem, fix, scope, known differences, and verification results. It does not use the template headings exactly, but it provides the required information, including how the code was tested.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline note, I checked the one behavior change the refactor introduces: position is now forwarded to fs.writevCb/fs.readvCb even when it holds a function. The native parser (FdVectorIo::from_js in src/runtime/node/node_fs.rs, via i52::offset_from_js -> JSValue::get_number) yields None for any non-number, so a function in that slot is "no position" as before. I also confirmed the array form $ERR_INVALID_ARG_TYPE("buffers", ["ArrayBufferView[]"], ...) renders Node's "must be an ArrayBufferView[]" text (src/jsc/bindings/ErrorCode.cpp list rendering), matching the new assertions.

Extended reasoning...

The diff replaces the position/callback reshuffle in fs.writev and fs.readv with Node's callback ||= position, adds a helper that reproduces Node's fd-then-buffers-then-cb check order on the invalid-callback path, and adds a 30-test block covering last-argument-wins, falsy fallbacks, error ordering, promisify and EBADF delivery. It touches no security-sensitive surface. The native parser already ignores a non-number position and the error-code formatting matches Node, so the remaining concerns were ruled out from the code.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/js/node/fs.ts — pre-existing nit: users of util.promisify(fs.writev) still get a result keyed buffers, so Node-style code reading result.buffer sees undefined. src/js/node/fs.ts:659 registers ["bytesWritten", "buffers"] while Node's lib/fs.js v26.3.0 L953-L957 registers ['bytesWritten', 'buffer'] for writev (readv keeps buffers). Fix: change the writev entry at fs.ts:659 to ["bytesWritten", "buffer"] and turn the new it.failing at test/js/node/fs/fs.test.ts:2527 into a passing test, leaving readv at fs.ts:657 as buffers. The PR adds an it.failing for this instead of the one-line fix it is next to.

    Why this was flagged

    A caller does const { bytesWritten, buffer } = await util.promisify(fs.writev)(fd, bufs), the shape Node documents and returns. In Bun, defineCustomPromisifyArgs(writev, ["bytesWritten", "buffers"]) at src/js/node/fs.ts:659 makes util.promisify build the object with key buffers, so buffer is undefined. The base branch behaves the same; the PR touches writev and adds it.failing("util.promisify(fs.writev) resolves { bytesWritten, buffer } like node") at test/js/node/fs/fs.test.ts:2527 that certifies the gap rather than fixing it. Node v26.3.0 lib/fs.js L953-L957 (cited by the test comment) uses 'buffer' for writev and 'buffers' for readv, so only the writev entry needs to change.

    Verification: pre-existing (nit). Trigger: any caller doing const { bytesWritten, buffer } = await util.promisify(fs.writev)(fd, bufs) (Node's key name). Mechanism: src/js/node/fs.ts:659 defineCustomPromisifyArgs(writev, ["bytesWritten", "buffers"]); so promisify builds { bytesWritten, buffers } and result.buffer is undefined. The base branch has the identical line, and the diff does not touch it, so merging makes nothing worse.

This branch has not been deployed

No deployments
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