Repository navigation
Conversation
nullcallback() bound the user callback to null and handed the result to .then(), so the promise's resolved value arrived as a second argument and every void operation (chmod, utimes, unlink, writeFile, ...) called back with (null, undefined) where node calls back with (null). Bind a helper that drops the value instead. mkdir keeps forwarding the first created directory of a recursive mkdir, which node also passes along. cp now calls back with (null, undefined), matching node's util.callbackify-based implementation.
|
Warning Review limit reached
Next review available in: 26 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Updated 2:35 PM PT - Aug 13th, 2026
✅ @robobun, your commit 659bc008581d08a453e97f1435f50a982c9f5a91 passed in 🧪 To try this PR locally: bunx bun-pr 38085That installs a local version of the PR into your bun-38085 --bun |
|
Reproduced on bun 1.4.0 against node v26.3.0: every void operation wired through Related PRs: |
There was a problem hiding this comment.
LGTM — narrow, well-tested Node compat fix.
What was reviewed:
nullcallback()now bindscallOnceWithNullso the promise's resolved value is dropped and callbacks receive exactly(null); traced every caller and confirmed the error path is unchanged.mkdir's new handler correctly forwardsfirstDirCreatedonly when defined (matchesFSReqCallback::Resolve);cpswitching tocallOnceWithNullThenyields(null, undefined)per node'sutil.callbackify.- Moving
callOnceWithNull/callOnceWithNullThenis a no-op (hoisted function declarations); existingDir#read/Dir#closecall sites still resolve them. - Tests:
toStrictEqual([null])distinguishes arity,chown -1 -1has precedent in this file,fs.lchmodis guarded, andpath.toNamespacedPathhandles the Windows mkdir result.
Extended reasoning...
Overview
This PR fixes a Node.js compatibility gap in src/js/node/fs.ts: the callback form of 22 void fs operations was passing (null, undefined) on success where Node passes (null) alone. The root cause was nullcallback() returning callback.bind(undefined, null), which .then() then invokes with the resolved value as an extra trailing argument. The fix binds the existing callOnceWithNull helper instead, so the fulfillment value is discarded. mkdir gets its own handler (recursive mkdir may resolve with a path, which Node forwards), and cp flips to callOnceWithNullThen because Node implements it via util.callbackify and calls back with (null, undefined). The callOnceWithNull/callOnceWithNullThen declarations move next to nullcallback; being hoisted function declarations, this is purely organizational and their existing call sites in Dir#read/Dir#close are unaffected.
Security risks
None. This changes only the number of arguments passed to user-supplied callbacks on the success path of local filesystem operations. No new inputs are parsed, no validation is weakened, and the error path (.then(..., callback)) is untouched.
Level of scrutiny
Low-to-moderate. The source change is ~15 lines of pure-JS glue in the Node compat layer, mechanically derived from Node's documented FSReqCallback::Resolve semantics (with source links). The only observable difference is arguments.length / rest-param length in success callbacks — code reading arguments[1] still sees undefined. I traced each modified path: nullcallback callers all go through ensureCallback first so the wrapped callback is what gets bound; the mkdir closure captures the already-wrapped callback; cp was already using callOnceWithNull.bind(null, callback) so switching to callOnceWithNullThen is a one-token swap.
Other factors
Test coverage is thorough: an it.each table exercises every affected operation (including fd-based ones with proper try/finally cleanup) asserting toStrictEqual([null]), plus dedicated cases for recursive mkdir returning the created path and cp's [null, undefined]. All needed imports (fs, path, tempDir, openSync, closeSync, mkdirSync, join) are already present in the test file. Cross-platform concerns are handled: chown/lchown/fchown use -1, -1 (already precedented at line ~5445 without a Windows skip), lchmod is added conditionally, and the recursive-mkdir assertion uses path.toNamespacedPath for Windows. The PR description documents verification against Node v26.3.0 for every operation and full/related test-file passes. No prior human review comments to address.
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Neither of the flagged PRs covers this change:
|
Use the module's captured Function.prototype.bind for cp, as nullcallback does, so a user replacing Function.prototype.bind cannot intercept the internal handler. Also cover chmod, mkdir and cp in the callback-throw table, one for each way a void operation now reaches its callback.
| // The resolved value must not reach the callback: node's void operations call | ||
| // back with `null` alone (FSReqCallback::Resolve drops an undefined result), | ||
| // https://github.com/nodejs/node/blob/v26.3.0/src/node_file.cc#L736-L741 |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Only a recursive mkdir that created something resolves with a path; | ||
| // node passes it along and otherwise calls back with `null` alone. |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
| // Unlike the other void operations, node's fs.cp is util.callbackify(cpFn), | ||
| // so a successful copy calls back with (null, undefined): | ||
| // https://github.com/nodejs/node/blob/v26.3.0/lib/fs.js#L1098 |
There was a problem hiding this comment.
If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code
There was a problem hiding this comment.
LGTM — narrow, well-tested Node.js compat fix.
What was reviewed:
nullcallbacknow bindscallOnceWithNullso the promise's resolved value is dropped and void ops call back with exactly(null);guardCallbackforwards arity viaarguments.lengthswitch so the count survives the wrapper.mkdir's inline handler andcp'scallOnceWithNullThenbind both go through the already-guarded callback, and the added rows in the uncaughtException table cover all three new success-path shapes.callOnceWithNull/callOnceWithNullThenare hoisted function declarations, so moving them aboveDiris safe;FunctionPrototypeBindis read at call time, no TDZ.- Test table uses
toStrictEqual(distinguishes[null]from[null, undefined]), guardslchmodon availability, uses-1,-1for chown so it runs unprivileged, andtoNamespacedPathfor the recursive-mkdir assertion on Windows.
The three comment-cop flags fire on 2–3-line comments that cite the upstream node source for why mkdir/cp deliberately differ from the other void ops — that's the reference-implementation citation the repo's own review guidance asks for, not a workaround justification, so I'm treating them as false positives.
Extended reasoning...
Overview
Changes nullcallback() in src/js/node/fs.ts from callback.bind(undefined, null) (which forwarded the promise's resolved value as a second argument) to callOnceWithNull.bind(undefined, callback) (which drops it), so the 21 void node:fs operations call back with exactly (null) on success like Node. mkdir gets an inline handler that forwards firstDirCreated only when defined (recursive mode), and cp switches to callOnceWithNullThen so it calls back with (null, undefined) matching node's util.callbackify(cpFn). The two helpers move from below cp to next to nullcallback; both are hoisted function declarations so Dir.prototype.read/close still resolve them. The test file gains a 25-row toStrictEqual([null]) table plus dedicated cases for recursive mkdir and cp, and three new rows in the existing uncaughtException table covering each new success-path shape.
Security risks
None. Success-path callback arity only; no input parsing, no error-path changes, no auth/crypto/permissions.
Level of scrutiny
Low-to-medium. ~15 lines of net source change in the JS shim layer over the native binding; the mechanism (.bind prepends bound args and forwards call-time args) is well understood and the fix is the minimal reshape. Verified guardCallback (internal/shared.ts:163) forwards the exact argument count via a switch on arguments.length, so callback(null) reaches the user callback as one argument and callback(null, undefined) as two — the arity fix is not undone by the guard wrapper. FunctionPrototypeBind is the captured primordial already used by the old nullcallback, read at call time so no TDZ. No CODEOWNERS cover the touched files.
Other factors
Tests are thorough: every affected op has a row, toStrictEqual is used specifically because toEqual would not catch the extra undefined, lchmod is gated on availability, chown/lchown/fchown use -1,-1 so they succeed unprivileged, fd-based cases close in finally, each case runs in a fresh using tempDir, and the recursive-mkdir assertion uses path.toNamespacedPath for Windows. The uncaughtException table gains chmod/mkdir/cp so all three new fulfillment shapes are proven to still route callback throws to uncaughtException via guardCallback. The PR description shows the new table fails 24/27 on the released binary and passes on this branch, and the full fs.test.ts plus adjacent cp/mkdir/dir suites and the ported node tests pass.
The only outstanding review activity is three automated comment-cop flags on the 2–3-line source comments citing node_file.cc/lib/fs.js for why mkdir and cp diverge from the other void ops. Those are reference-implementation citations (encouraged by the repo's node-compat review guidance), not workaround justifications — without them a future reader would "fix" cp to match the others. I'm treating them as false positives rather than blocking on them.
… alone nullcallback() bound null as the first argument of the user callback, so the promise machinery appended the resolution value and the 22 operations built on it called back with (null, undefined). Node calls back with (null): FSReqCallback::Resolve drops an undefined result. async's waterfall and seq hand the extra undefined to the next task as its first argument, so writeFile -> chmod -> unlink threw ERR_INVALID_ARG_TYPE. The helper now applies node's rule: (null) when the result is undefined, (null, result) otherwise, which is what a recursive mkdir needs. access and symlink use the helper and no longer inline its body. The test table is taken from #38085, with rows added for access and both symlink overloads.
|
#40916 now contains the The |
…ction (#43728) ### Problem - Inside an async `node:fs` callback, a microtask the callback queues runs before a `process.nextTick()` it queues. Node runs the nextTick first: it prints `cb0 tick0 mt0 cb1 tick1 mt1`, and bun prints `cb0 tick0 mt0 cb1 mt1 tick1`. - The binding only returns promises, so `src/js/node/fs.ts` ran each callback from a promise reaction. A reaction is a microtask, so the callback's microtasks ran before the tick queue. ### Fix - The binding gets a third arm, `xCb(callback, ...args)`. The task keeps the callback in a `Strong` and calls it from the completion with `EventLoop::run_callback`, as `node:crypto` does. - `run_callback` then runs the tick queue and the microtasks, which is node's order. A callback API creates no promise, reaction or microtask. - `node:fs/promises` stays on the promise arm. Callback arguments do not change. - Verified: `test/js/node/fs/fs.test.ts` (49 new tests), node's `test-fs-*` files, Windows x64. ### Background - A checkpoint runs the tick queue (`process.nextTick`), then the microtask queue (promise reactions, `queueMicrotask`). Node calls an fs callback from the event loop, outside both. - `fs.promises` forwards every user argument, so a trailing function cannot select the callback arm. The arm needs its own entry points. ### Downsides - Seven callback APIs are JS promise chains with no binding operation (`cp`, `rm`, recursive `rmdir`, `Dir.read`, `Dir.close`, `glob`, `opendir`). They still use `process.nextTick`, so they report a `TickObject` to `async_hooks` and wait on a replaced `process.nextTick`. - Binary size: stripped Linux x64 release `bun` +53,248 bytes (+0.066%), `.text` +49,152, for 41 new host functions. - `fs.access` allocates one more closure per call (1 → 2), for a JS adapter that keeps its callback arguments identical. Others allocate fewer (Notes). <details><summary>Notes</summary> **Repro** ```js import fs from "node:fs"; const seq = []; let n = 0; function cb() { const i = n++; seq.push("cb" + i); process.nextTick(() => seq.push("tick" + i)); queueMicrotask(() => seq.push("mt" + i)); } for (let k = 0; k < 2; k++) fs.stat(".", cb); setTimeout(() => console.log(seq.join(" ")), 200); ``` The first callback of a process looks correct without the fix. The tick queue does not exist until the first `process.nextTick()` call. While it does not exist, the `onEachMicrotaskTick` hook (`ZigGlobalObject.cpp`, `checkIfNextTickWasCalledDuringMicrotask`) drains it right after the microtask that created it. **History of this PR** The first version kept the promise and passed the callback to `process.nextTick` from the reaction. A maintainer asked for a native callback arm instead ([review](#43728 (review))). The hop kept the whole promise path and added a tick. It also had two costs that the native arm removes for the operations of the binding, both measured on one `fs.stat` callback: | | released bun | hop | native arm | node v26.3.0 | |---|---|---|---|---| | `async_hooks` `init` events | `{}` | `{"TickObject":1}` | `{}` | `{"FSREQCALLBACK":1}` | | `process.nextTick` replaced before `node:fs` loads | callback runs | callback held | callback runs | callback runs | Bun reports no `FSREQCALLBACK` resource, before or after this PR. #33366 was an earlier native attempt, closed as stale without a review. **Shape of the callback arm** - `FsCompletion { Promise(JSPromiseStrong), Callback(Strong) }` replaces the promise in `AsyncFSTask`, the recursive readdir task and the Windows `UVFSRequest`. `NewAsyncCpTask` keeps its promise, because `fs.cp` is a JS promise chain. - The callback is stored with `with_async_context_if_needed` and called with `ContextId::NONE`, the pairing `run_callback` documents for a callback that carries its own `AsyncLocalStorage` frame. - An error found before the operation is scheduled (an aborted signal, a path that is too long) goes to the callback on the next tick. The callback goes in bare, because `Process::queueNextTick` asserts against an `AsyncContextFrame`. - The task releases its arguments before it calls back. They pin their buffers, and the callback now runs inside the completion, so a transfer of the buffer from the callback would copy it. Main and node detach it. The Windows `UVFSRequest` takes the completion out, frees the task, and then calls back. It takes `*mut Self` for that reason. - `FsCompletion::reject` builds the error value as `JSPromise::reject` does: it creates an out-of-memory error and does not take one, it returns a termination, and it unwraps a thrown `Exception` cell to its `Error`. - The entry points are named `xCb`, not `xCallback`. The class generator names the C++ host wrapper of a method `x` `Prototype__xCallback`, so a method named `xCallback` collides with it. - Ten operations keep a small JS adapter so that their argument lists stay identical: `access`, `close`, `exists`, `read`, `write` (both overloads), `readv`, `writev`, `symlink`. `fs.symlink` with a callback that is not callable stays on the promise arm, as before. - `internal/trace_events.ts` wraps `binding[method + "Cb"]` for each operation in its existing table, so `node.fs.async` spans still close. Without this, `test-trace-events-fs-async.js` gets no traces. **Size and performance** Two release builds from one tree and toolchain, each in its own build directory: main `bf80d21` and this PR. Linux x64. | | main | PR | delta | |---|---|---|---| | `bun` (stripped) | 80,770,592 | 80,823,840 | +53,248 (+0.066%) | | `bun-profile` | 169,212,560 | 169,315,968 | +103,408 (+0.061%) | | `.text` | 58,086,812 | 58,135,964 | +49,152 | | `.rodata` | 19,821,204 | 19,825,364 | +4,160 | | `.data`, `.data.rel.ro`, `.bss` | | | 0 | JS heap objects allocated per operation. This count is exact and has no timing in it: the script starts 1000 operations in one synchronous turn and reads `heapStats()` in the same turn. No operation can complete before control returns to the event loop, so each one is pending and roots what it allocated. Three runs of each binary give identical numbers. | per operation | `Promise` | `Function` | |---|---|---| | callback `fs.stat` | 2 → 0 | 2 → 1 | | callback `fs.readFile` | 2 → 0 | 2 → 1 | | callback `fs.read` | 2 → 0 | 3 → 2 | | callback `fs.access` | 2 → 0 | 1 → 2 | | `fs.promises.stat` | 3 → 3 | 0 → 0 | | `fs.promises.readFile` | 3 → 3 | 0 → 0 | On main each callback operation allocates two promises: the one the binding returns, and the one `.then()` derives. Eight operations keep a JS adapter so that their callback arguments stay identical. For `fs.access` the adapter is one more closure than main, which passed the callback to `.then()` directly. For `fs.read` it replaces two closures that main created. I measured only these two of the eight. Process CPU time per operation (user + system, µs), 200,000 operations, 256 in flight, 10 alternating runs per binary. This is statistical, not exact. The spread between runs of one binary is 2 to 4 µs, which is larger than the differences, and the distributions overlap. Read it as a direction. | | main median | PR median | main min | PR min | |---|---|---|---|---| | callback `fs.stat` | 12.36 | 10.41 | 10.27 | 8.46 | | callback `fs.read` | 10.47 | 9.50 | 8.67 | 8.64 | | `fs.promises.stat` | 11.51 | 11.88 | 8.57 | 9.79 | `fs.stat` is about 16% cheaper by the median and by the minimum. `fs.read` is not conclusive: 9% by the median, no difference by the minimum. `fs.promises.stat` is the control, because its code path and its allocations did not change. Its 3% difference shows the noise of the method, and it gives no evidence of a regression. I could not count CPU instructions on this machine. `valgrind` has no install candidate, and `perf_event_open` returns `EPERM`. **Not in this PR** - `dns.lookup` has the same order problem (`cb mt tick`). - Void operations call back with `(null, undefined)` where node passes `(null)`. Same on main, #38085 changes it. - `fs.symlink` calls back with `(undefined)` on success. Same on main, #40916 changes it. - `fs.fstat`, `fs.read` and the 4-argument `fs.symlink` do not check the callback synchronously as node does. Same on main. - `fs.readFile` with a signal that is already aborted calls back synchronously in node and asynchronously in bun. **Verification** - `test/js/node/fs/fs.test.ts`: the 45 order tests give 0 pass with main's `src/` and 45 pass with this PR. The 4 transfer tests guard a bug that an earlier commit of this PR had (6174ba5 fails them with `source: 8`). They pass on main. Whole file: 614 pass, 0 fail. - The same cases as a plain script pass on node v26.3.0. - 15 other files in `test/js/node/fs/`: 240 pass, 0 fail. `abort-signal-leak-read-write-file.test.ts` is a 300 s timeout under the ASAN debug build and uses only `fs.promises`. - Node's `test/parallel` files for fs, trace events, `util.promisify`, `util.callbackify`, http2 `respondWithFile`, `Utf8Stream`, `stream.pipeline`, readline and `FileHandle`: 413 files, all pass. `test-fs-cp-async-file-url.mjs` needs the working directory `test/js/node`. - `AsyncLocalStorage` stores reach the callback on 10 paths (native, adapter, error, recursive readdir, the early abort path, and the JS chains), the same as node. - `bun run rust:check-all`: 12 of 12 targets. - Windows x64 debug build: the order tests are 42 pass, 0 fail and the transfer tests 4 pass, on the final commit. An earlier run of the whole file was 598 pass, 2 fail. Both fail the same way on main without this PR on the same machine: `keeps a non-throwing callback in the same place in the event loop` (`setImmediate` runs before the `fs.stat` callback) and `readSync > works on large files` (5 s timeout). </details> <!-- robobun:evidence:begin --> --- **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 <!-- robobun:evidence:end --> --------- Co-authored-by: Alistair Smith <hi@alistair.sh>
|
Closing in favor of #40916. #43728 (merged 2026-09-22, fe927c3) moved the The defect is still on main. A build of main at 6d504dd prints One part of this PR is not covered there. |
Problem
node:fsoperation calls back with two arguments on success,(null, undefined); node calls back with one,(null):nullcallback(): appendFile, chmod, chown, copyFile, fchmod, fchown, fdatasync, fsync, ftruncate, futimes, lchmod, lchown, link, lutimes, mkdir, rename, rm, rmdir, truncate, unlink, utimes, writeFile.nullcallback()(src/js/node/fs.ts:50) returnscallback.bind(undefined, null), and callers pass that straight to.then(), e.g.fs.chmod(path, mode).then(nullcallback(callback), callback)(fs.ts:141). The promise machinery invokes the bound function with the resolved value, so the user callback receives(null, undefined).fs.cphas the opposite mismatch: bun calls back with(null), node with(null, undefined).arguments.lengthand rest parameters, e.g. multi-argument promisify wrappers (pify'smultiArgs) resolve to[undefined]in bun and[]in node.Fix
nullcallback()now bindscallOnceWithNull(the existing helper used byDir.prototype.close) with the callback, so the fulfillment value is dropped and the callback receives exactly(null). This is what the comment abovenullcallbackalways claimed it was equivalent to; thecallOnceWithNull/callOnceWithNullThendeclarations move up next to it.mkdirgets its own fulfillment handler: a recursive mkdir resolves with the first directory it created, and node passes that along as a second argument, so it is forwarded when defined and dropped otherwise. That is the same rule as node'sFSReqCallback::Resolve(see Background); the other 21 operations always resolve withundefined, so they only needcallOnceWithNull.cpswitches tocallOnceWithNullThen, so it calls back with(null, undefined)like node, whosefs.cpisutil.callbackify(cpFn)(lib/fs.js#L1098 in v26.3.0). Matching the observable behavior rather than the other operations' shape is deliberate; a code comment says why.fs.accessandfs.symlinkhand the native promise's value to the callback directly and are handled in node:fs: accessSync() returns undefined, promises.access() resolves to undefined, symlink callback gets null #38065; they are left alone here.accessappears in the new table only because it already matches.test/js/node/fs/fs.test.ts, newdescribe("a void operation's callback receives the same arguments as in node"): one row per operation assertingtoStrictEqual([null])(toEqualwould accept the extraundefined), plus recursive mkdir still passing[null, firstDirCreated]and cp passing[null, undefined]. 24 of the 27 cases fail on the released binary (1.4.0), all pass with the fix.test/js/node/fs/fs.test.ts: 537 pass, 0 fail.test/js/node/fs/{cp,cp-symlink-target,dir,fs-mkdir}.test.ts,promises.test.js: 123 pass, 0 fail.Background
node:fsin bun is a thin layer over the native promise-returning binding:binding.op(...).then(onSuccess, callback).onSuccessis always invoked with the promise's resolved value, so whatever function is passed there decides what the user callback sees.Function.prototype.bindprepends the bound arguments and still forwards the call-time arguments.callback.bind(undefined, null)therefore callscallback(null, resolvedValue); binding a helper that takes the callback as its bound argument and ignores the rest is what yieldscallback(null).FSReqCallback::Resolve(src/node_file.cc#L736-L741 in v26.3.0): it calls the callback with justnullwhen the result isundefined, and with(null, result)otherwise. Recursivemkdiris the only operation touched here whose result can be defined.fs.cpis the exception because node implements it in JS viautil.callbackify, which always callscb(null, ret), even whenretisundefined.Probe: callback argument lists, node v26.3.0 vs this branch
Each operation is run on a fresh file/directory and the callback's
argumentsare printed. The two runtimes now print identical output for all of these; thebun 1.4.0column is what the released binary prints.Unchanged and already matching node:
dir.read((null, dirent)/(null, null)),dir.close((null)), and the value-returning operations ((null, value)).