Repository navigation
Conversation
…nd writeFile `fs.readFileSync` and `fs.writeFileSync` threw `EINTR` with `syscall: "access"` when `options.signal` was already aborted, and the write did not happen. The parsers of `readFile` and `writeFile` kept the signal for a sync call too, and the abort checks that are there for a pool thread then returned their internal EINTR value to the sync caller. Node's sync forms type-check `options.signal` and never read it again. The two parsers now share `signal_option`. It type-checks the signal for every form and keeps it (with its pending activity) only for an async parse. A sync call takes no ref on the signal.
|
Status
|
|
No actionable comments were generated in the recent review. WalkthroughFilesystem read and write argument parsing now validates abort signals for sync and async calls. Synchronous calls do not retain valid signals. Tests cover sync behavior, async rejection, invalid signal values, and signal retention. ChangesFilesystem abort-signal handling
Possibly related PRs
Suggested reviewers: Merge Risk:
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I also checked that the new signal_option helper is ref-balanced against the old path: AbortSignal::ref_from_js is exactly from_js + opaque_deref(..).ref_() (src/jsc/AbortSignal.rs:231), and the release side (the Drop impls and scopeguards at src/runtime/node/node_fs.rs:4529-4555 and :4600-4635) is unchanged, so the async path still unrefs once. A grep confirms these two parsers are the only fs argument parsers that read signal, so no sibling site was missed.
Extended reasoning...
The change replaces two inline signal parses in the ReadFile/WriteFile argument parsers with a shared helper that returns None for sync calls, adds a debug_assert in run_sync, and adds a large test block; it touches no security-sensitive surface. The one inline finding (null/falsy signal accepted via get_truthy) is pre-existing behavior the author discloses, so the human judgment is whether that gap should ship separately.
This is the parse gate of #36485 alone. That PR conflicts with main.
Problem
fs.readFileSyncandfs.writeFileSyncthrowEINTR: interrupted system call, accesswhenoptions.signalis already aborted. The write does not happen. Node v26.3.0 reads and writes.src/runtime/node/node_fs.rs:4547,:4645) kept the signal for a sync call. The thread pool's abort checks (:7503,:7833) then returned their internalEINTRvalue.Fix
signal_optionreplaces both copies of the parse. It type-checks every signal and keeps it only for an async call. A debug assertion inrun_syncchecks that.getOptionstype-checks it, and the sync forms never read it again.test/js/node/fs/fs.test.ts(16 new cases, 12 fail on main),promises.test.js, the AbortSignal leak test, 36 upstreamtest-fs-*files. Self-reviewed: 9 concerns raised, 9 addressed.Background
signalfor the sync forms, but@types/nodedeclares it onwriteFileSyncandappendFileSync.NodeFSrun on the thread pool.AsyncFSTask::thenturns theirEINTRvalue into anAbortError.Flavor::Asynctest at the four checks: it costs every async call a branch.Downsides
EINTRthrow to skip the I/O now does the I/O.signal: nullis accepted and a signal-shaped object is rejected (Notes).Notes
Origin. Found by a differential run of
node:fsagainst Node v26.3.0. No user issue reports it. It needs an already-aborted signal on a sync form: a live signal already behaves like Node, and a call withoutsignalruns no changed line.Repro.
The same holds for the fd forms, a Buffer,
flag: "a", an empty file and a 1 MB file. Of 11 sync rows with an aborted signal, 9 differ from Node on main and 0 with this PR.Why the new file stayed empty.
write_file_with_path_bufferopens withO_CREATand withoutO_TRUNC(it resizes after the write). The abort check comes after the open.Node's code.
getOptionstype-checks the option for every form (utils.js#L340-L342).readFileSync(fs.js#L429-L486) andwriteFileSync(fs.js#L2386-L2437) never read it.Tests. The 16 cases: a path and an fd for each of the three sync forms, flags
w/a/r+/wx,AbortSignal.any(), an options getter that aborts the controller (4 keys), a call from inside the abort listener,EISDIRandENOENT, one options object shared withfs.promises(stillAbortError, no file created), a value that is not anAbortSignalfor all 9 forms (stillERR_INVALID_ARG_TYPE, no file created), and a heap count after 3000 sync calls. TheappendFileSync, invalid-signal and heap cases pass on main too: they pin what must not change. The 15 cases that need no Bun API also pass under Node v26.3.0, run through a small stand-in forbun:testandharness.A temporary change that kept the signal on a sync parse trips the new assertion in
run_sync(panic: a sync parse keeps no signal).Measurements. Linux x64. Release builds of main b1007f1 and of this PR from one toolchain, unless a line says debug.
perf_event_open, user instructions, N = 2e6, three runs, spread at most 1.5).readFileSync(p, "utf8")/readFileSync(p, { encoding })/writeFileSync(p, "hello")/appendFileSync(p, "x")/statSync(p): 1949 / 3310 / 1641 / 1539 / 1537 on main, 1948 / 3310 / 1641 / 1539 / 1538 on this PR.readFile2394 to 2401,writeFile4027 to 4035. Without a signal: 2318 and 3931 on both.fs.promisescall: 8 / 8 / 7 (main 8 / 8 / 7). Refs equal unrefs and increments equal decrements in every mode, also when the parse fails after it read the signal.writeFileSync(path)(gdbcatch syscall, N = 100, three agreeing runs), openat / write / ftruncate / close: no signal 1 / 1 / 1 / 1, live signal 1 / 1 / 1 / 1, aborted signal 1 / 1 / 1 / 1 (main with an aborted signal: 1 / 0 / 0 / 1).sizetext is 140,289,307 bytes on both. The two parser symbols are 1029 / 1650 bytes (main 979 / 1614). That is +86 bytes, inside the padding of the section.AbortSignalobjects after 3000 sync calls with 1000 fresh signals: 2 (the test allows 10). After 300,000 calls: 1.bench/snippets/read-file.mjsandwrite-file.mjs(mitata, 10 interleaved rounds, median of the round minimums). Read: 983 / 1053 / 2477 / 9766 ns on main, 989 / 1046 / 2475 / 9762 ns on this PR. Write: 3839 / 3827 / 4339 / 9356 ns on main, 3813 / 3848 / 4292 / 9376 ns on this PR. The round minimums of one binary spread by 72 to 1627 ns. No difference outside that.signal_optionis#[inline(always)]. As a plain function LLVM kept it out of line. Then each parse of an options object cost 1 more instruction, and a parse with a signal 12 to 13 more.strace,perfandbloatyare not installed on this machine. gdb, aperf_event_opencounter,sizeandnmstand in.Flavor::Asyncalternative also changes the signature ofwrite_file_with_path_buffer, which the bundler calls.Not in this PR. These differences from Node are the same on main and on this branch.
options.signalofreadFileSync/writeFileSyncnull,""ERR_INVALID_ARG_TYPE{ aborted: true },{ aborted: false }, aProxyof a signal,Object.create(AbortSignal.prototype)ERR_INVALID_ARG_TYPE1The "options.signal" property must be an instance of AbortSignal.The "signal" argument must be of type AbortSignal.null/""rows and the message. node:fs: accept AbortSignal-shaped objects in readFile/writeFile/watch #35275 (closed, not merged) had the signal-shaped objects. After this PR the check is one function.fs.promises.appendFile: Node rejects and appends nothing, Bun rejects withAbortErrorand appends in 100 of 100.fs.promises.writeFileto an existing file: Node leaves it empty, Bun keeps the old content in 97 and writes all of the data in 3. node:fs: run appendFile through writeFile's write path and report the { flush: true } fsync error #36485 has an abort check forappendFile. fs.writeFile: honor AbortSignal between write chunks #35021 (closed, not merged) had one between the chunks ofwriteFile. No open PR has thewriteFilepart.FileHandle#appendFiledrops the signal: fs.promises: forward options.signal from the FileHandle wrappers and validate it for iterable data #39272.fs.readFile(fd, { signal: aborted }, cb)calls back with(null, "")in Node and with anAbortErrorin Bun. Node loses the error for a caller-owned fd. Not changed.Related PRs on the same lines. #36485 (its two parse hunks and its sync test can go when it rebases on this), #39274, #46575.
Test placement. The heap check is a child process in the new
describeblock. It is not a new row inabort-signal-leak-read-write-file-fixture.ts, because #47542 rewrites that fixture around promise-returning calls.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