Repository navigation
node:fs: call back before return for an already-aborted signal in readFile, writeFile and appendFile - #47489
node:fs: call back before return for an already-aborted signal in readFile, writeFile and appendFile#47489robobun wants to merge 1 commit into
Conversation
…dFile, writeFile and appendFile
|
Status Reproduced on main (
CI (build 135864) has finished: 183 jobs passed and 5 failed.
PR: #47489 |
|
Updated 12:45 PM PT - Oct 30th, 2026
❌ @robobun, your commit e710ab5 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 47489That installs a local version of the PR into your bun-47489 --bun |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Early error delivery src/runtime/node/node_fs.rs, src/runtime/node/node_fs_binding.rs, src/runtime/node/node_fs_binding.bind.ts, src/js/private.d.ts, src/codegen/builtins/host/runtime-dependencies.builtins.ts |
The binding carries an EarlyDelivery mode with rejected async arguments. Callback calls either return the error before invoking the callback or invoke the callback on the next tick. ReadFile selects delivery based on whether its argument is a file descriptor or path. Callback declarations and documentation describe returned errors for appendFile, readFile, and writeFile. |
JavaScript wrapper and trace handling src/js/node/fs.ts, src/js/internal/trace_events.ts |
The three filesystem wrappers invoke the callback when the binding returns an error. The trace wrapper ends async events when the binding returns a non-undefined value. |
Abort behavior tests test/aot/parallel/native-callbacks/callbacks-fs-signal-aborted-before-the-call.ts, test/js/node/fs/fs-file-signal.test.ts, test/js/node/trace_events/trace-events.test.ts |
Tests cover already-aborted and live signals, callback timing and context, validation, promise behavior, file-descriptor reads, and matching filesystem trace span begin and end counts. |
Suggested reviewers: jarred-sumner, dylan-conway, cirospaciari
Merge Risk: 
Minimal · up to e710a
-
The already-aborted callback behavior has matching wrapper handling and targeted tests. No concrete merge-blocking issue remains; merge after normal checks pass.
Pre-merge checks: 
4
-
Full details: Description check
-
Explanation
The description follows the repository template with both required sections, "What does this PR do?" (titled Problem, Fix, Background, Downsides) and "How did you verify your code works?" (titled Verified, Tests, Cost). It states the problem, the fix, the trade-offs, and a detailed verification plan with concrete test counts and a reproduction script. Gaps: the verification section says no tests ran on Windows or macOS before the push, and the robobun evidence note flags that the platform-specific test file has not been run locally. CI covers those platforms, so this is a minor gap, not a missing section.
-
- 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.
Comment @coderabbitai help to get the list of available commands.
Problem
fs.readFile,fs.writeFileandfs.appendFilecall back on the next tick whenoptions.signalis already aborted. Node v26.12.1 calls back before the call returns (checkAborted), so a throw from the callback reaches the caller.reject_before_schedule(src/runtime/node/node_fs_binding.rs:136) queues every early error withprocess.nextTick.Fix
AbortErrorand queues nothing.fs.tscalls the program's callback with it.fs.readFile(fd)stay on the next tick, as in node.test/js/node/fs/fs-file-signal.test.ts(51 cases, main fails 37),trace-events.test.ts, one--aotprogram,fs.test.ts, 357 node fs tests.Background
guardCallback, a wrapper that turns a throw into anuncaughtException. Sofs.tscalls the callback itself.bun build --compile --aotreads each binding's result type fromnode_fs_binding.bind.ts. The three now returnundefinedor an object.fs.ts: each call readsoptions.signaltwice. Considered a native call of an unwrapped callback: every fs completion then needs a native uncaught report.Downsides
fs.readFilegrows from 74 to 91 bytecode bytes (DFG inline limit: 80).Notes
Where this comes from. No Bun user reported it. A comparison with node v26.12.1 found it. #43728 and #47310 both list it as not done. #47310 makes
fs.stat,fs.lstatandfs.fstatcall back before they return, with a helper infs.ts. One maintainer decision on before-return delivery applies to both PRs.Repro.
AbortError before the call returnedf7607a3d35AbortError after the call returnedAbortError before the call returnedfs.writeFile(p, "x", { signal: AbortSignal.abort() }, () => process.exit(3)); process.exit(0)exits with 3 in node and here, and with 0 on main. A call made inside an'exit'listener behaves the same way: main never runs the callback.What changes. A 46-row probe compares each call form with node v26.12.1 (what is thrown, what the callback gets, and when). 34 rows differ on main and 18 here. The 16 rows that change are the 12 timing rows and the 4 rows where the callback throws.
fs.writeFile(fd)andfs.appendFile(fd)are among them: node checks the signal of an fd inwriteAll.What stays different from node. Each row differs on main too, except the second one.
args::ReadFile::from_jsandargs::WriteFile::from_js_with_default_flag. #46789 replaces that parse API, and #46575 and #43563 change the same orderflaggetter offs.readFilethat aborts the signal: the callback runs before the call returns. Node readsflagafter its check, so it calls back later. Main calls back on the next tick. One case hasexpectFailurefor itfs.readFile(fd, { signal }):AbortErroron the next tick. Node defers this form and then loses the abort: it calls back with(null, <empty Buffer>)fs.stat,fs.lstat,fs.fstatfs.existswith an invalid path: node calls back before it returns, and a throw reaches the caller. Bun calls back later, and the throw is anuncaughtExceptionAbortError, node the error of the operation{ signal: null }and objects that look like a signalnode.fs.asynctrace span for an aborted call. Bun records a complete one, as on mainThe first shape of this change. It was withdrawn after the self-review, before a push. It called the program's callback from native code, without
guardCallback, and it sent every fs completion through a new native call that reports a throw inside the callback's async context. It also kept the error of a path or a flag during the parse, so that theAbortErrorcould win. The review found three changes in one: the kept error is built onArgumentsSlice, which #46789 deletes, it changedfs.promises.readFilefor aflaggetter that aborts the signal, and the native report is a new primitive with one caller. That shape measured 1,798 instructions and 3 JS cells less perfs.readFilecallback call (JIT off). That is the cost ofguardCallback, and removing it is a separate change.Cost. Release builds of main
f7607a3d35and of this branch, from one tree, Linux x64.fs.readFile(f, cb)with no signal (perf_event_open, slope between 20,000 and 60,000 calls, least of 5, two rounds): JIT off 10,249.8 and 10,252.4 on main, 10,294.9 and 10,294.4 here. With an options object: 9,562.7 and 9,560.8, 9,605.3 and 9,604.2 here. Default JIT: 7,170.6 and 7,178.2, 7,194.8 and 7,187.1 here. With options: 6,391.1 and 6,391.3, 6,403.6 and 6,404.5 here.heapStats, 2,000 calls).readFile74 to 91 bytes (16 to 20 instructions),writeFile77 to 94,appendFile83 to 100. The call-inline limit is 80 for the DFG and 170 for the FTL..text4,352 bytes less.reject_before_scheduleis now#[cold]and out of line. It was inlined into each of the 40 instantiations ofrun_async.require("node:fs")at start: 16,520,636 to 16,522,864 instructions (+0.013%).hello.jsdiffers by 0.05% between the two binaries. The generatednode/fs.jsis 142 bytes larger.AbortErrornow nests. With no end it stops at aRangeErrorat depth 12,864 (release) and 10,917 (debug with ASAN). Node v26.12.1 stops at 1,901. On main such a loop never nests.Tests.
fs-file-signal.test.tsusesnode:testandnode:assert, so the same file runs under node: 51 cases, 51 pass on v26.12.1. Its last case runs it there when that node is installed. On main 37 of its cases fail.this, the argument count, the cause, theAsyncLocalStoragestore, the order against a tick and a microtask,util.promisify, a nested call, a process that exits, a throwing callback, arguments that still throw, options that abort the signal while they are read, and what stays after the return.trace-events.test.ts: the new case fails on main, and also whenwrapFsCallbackMethoddoes not end the span (4 begin events and no end event for one call).test/aot/parallel/native-callbacks/callbacks-fs-signal-aborted-before-the-call.tsmust print under--compile --aotwhat it prints under plain bun. On a build without the result types innode_fs_binding.bind.tsthe compiled program never calls thereadFileandwriteFilecallbacks, and this program fails.fs.test.ts(631 pass),promises.test.js,fs-path-length.test.ts, the new cases withBUN_JSC_validateExceptionChecks=1, 357 of node's tests (test-fs-*, trace events,util.promisify,util.callbackify,FileHandle,stream.pipeline),test/internal/builtins-table/,test/aot/builtins/, andbun src/codegen/check-types-of-modules.ts.bun run rust:check-allforx86_64-pc-windows-msvcandaarch64-apple-darwin: both pass. Before the push, no test ran on Windows or macOS. In CI the three new tests pass on Windows x64, Windows aarch64, macOS x64 and macOS aarch64.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-file-signal.test.ts