Skip to content

node:fs: accept any object with aborted as options.signal in readFile, writeFile and appendFile - #48124

Open
robobun wants to merge 5 commits into
robobun/b1ac2169/fs-sync-forms-ignore-signalfrom
robobun/28cbd53e/fs-options-signal-node-rule
Open

robobun wants to merge 5 commits into
robobun/b1ac2169/fs-sync-forms-ignore-signalfrom
robobun/28cbd53e/fs-options-signal-node-rule

Conversation

@robobun

@robobun robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #47993 (the sync rule). Merge it first.

Problem

  • fs.readFile, fs.writeFile and fs.appendFile accept only Bun's own AbortSignal as options.signal. Node v26.3.0 accepts any object that has aborted.
  • Under bun test with happy-dom, new AbortController().signal throws The "signal" argument must be of type AbortSignal. Received an instance of AbortSignal.
  • Cause: the class test in args::signal_option (src/runtime/node/node_fs.rs:4491).

Fix

  • After a failed class test, one cold C++ export applies Node's validateAbortSignal rule. The JS-visible validator shares it and throws Node's sentence.
  • An async call keeps a non-native signal GC-protected. The JS thread reads aborted before the schedule and at completion.
  • Verified: test/js/node/fs/promises.test.js (36 of 39 new tests fail on main), fs.test.ts, 89 upstream files, 519 test/aot/parallel programs.
  • Self-reviewed: 21 concerns raised, 19 addressed. Not taken (Notes): signal before flag, a real happy-dom test, the root on AsyncFSJs.

Background

  • A pool thread cannot read a JavaScript property, so nothing polls a non-native signal meanwhile.
  • +213/-124 production lines, half to keep and re-read the signal. Reading aborted once and keeping nothing is about 30 lines (not built).
  • bindgen: delete b.any and .variadic #46789 rewrites these parsers. There the rule is one schema member.

Downsides

  • An abort during the operation does not stop the I/O for a non-native signal or for FileHandle#appendFile. The call rejects with the data maybe on disk.
  • validateAbortSignal now says an instance of AbortSignal for every caller and refuses a function with aborted. Matches on the old text break.
  • The three async tasks grow 8 bytes (same 320-byte mimalloc class), the binary 1,971 bytes.
Notes

The stack and the tracker

Self-review

  • The first round reviewed another shape: a JavaScript copy of the three operations for a non-native signal. It failed for a file embedded in a compiled executable (ENOENT), had none of the native size checks, and used the fixed chunk size that fs.readFile: honor AbortSignal between read chunks #36259 was asked not to use. That shape is gone.
  • Not taken from the second round:
    • signal before flag and mode in the native parsers. Main's order is kept, and bindgen: delete b.any and .variadic #46789 writes the same order. With { signal: 1, flag: "zz" } Node names the signal, Bun names the flag.
    • A real-package happy-dom case in test/cli/test/test-environment.test.ts. The real-package tests of that file time out at 5 s in a plain debug run. The new test uses a stand-in package, as plugins.test.ts and import-meta-env.test.ts do. With the real happy-dom 17.0.3 (@happy-dom/global-registrator, run by hand on the release build) the nine forms answer as in Node.
    • The root on AsyncFSJs, the JS-thread half of the task. It adds 8 bytes to all 41 async fs tasks. In the arguments only three grow. A test counts the protected objects while 50 calls are pending and after each way they end.

Other files in the diff

  • SSLConfig.rs, Blob.rs and fetch.rs build args::ReadFile with ..Default::default(). The struct has no Drop impl any more (FileSignal releases the signal), so clippy asks for that form.
  • mordant-baseline.toml gets one entry, field_valid_only_when for args::ReadFile::flag. With that form of the three callers, mordant can see that flag matters for a path only. The struct is as on main.

What a non-native signal gets

  • Sync forms: the check only. Node's sync forms never read aborted.
  • Async forms: one read of aborted before the task is scheduled (parse_async_args), and one when it completes (AsyncFSTask::then, after the arguments are dropped, because the read runs code of the program). reason is read if aborted is truthy. An error that a getter throws is the error of the call.
  • That is the JS-thread half of what a native signal gets. A native signal also has its flag polled by the pool thread.
  • A native signal that follows the abort event of the object would let the pool thread stop early too. It needs a listener per call or a map per signal, and an object that sends no event gets nothing from it. It is not built.
  • The check at the end also runs for an operation that failed, as on main: a pool thread that sees the abort returns an errno, and the check turns it into the AbortError. So an AbortError can take the place of ENOENT for a signal that is aborted by then.
  • The rule is one function in NodeValidatorBodies.h. The JS-visible validateAbortSignal (42 call sites) and the fs option both call it.

Answers that only Bun gives, pinned and labelled in the tests

  • signal: null and signal: "" are no signal, as on main. Node throws ERR_INVALID_ARG_TYPE. fast_get in place of fast_get_truthy in signal_option changes that, if a maintainer wants the break.
  • An error that a getter throws at the end of fs.readFile goes to the callback. Node raises it as an uncaught exception.
  • FileHandle#appendFile with an AbortSignal that is aborted after the call: Node and main resolve with the data appended (main ignored the signal). Now it rejects with AbortError, as fs.promises.appendFile does, and the data was in the file in 19 of 20 runs.

Measurements (release builds of main b1305dd and this branch, linux-x64)

valgrind, perf, strace and bloaty are not in the container. A perf_event_open counter of user instructions, gdb breakpoints by address, an inotify counter of opens, size and nm replace them.

  • instructions per call, per shape (200,000 calls, mean of 5 runs, 3 for the callback shape. The spread of the runs is at most 17 for the sync shapes and at most 118 for the async shapes):
shape main PR
readFileSync(p) 1555 1546
readFileSync(p, "utf8") 1890 1892
readFileSync(p, { encoding }) 3257 2931
readFileSync(p, { signal }) 2945 2629
writeFileSync(p, d) 1600 1594
writeFileSync(p, d, { encoding }) 4405 4073
writeFileSync(p, d, { signal }) 4109 3758
readdirSync(dir, { withFileTypes }) 4550 4551
fs.promises.readFile(p) 9233 9172
fs.promises.readFile(p, { encoding }) 9783 9515
fs.promises.readFile(p, { signal }) 10491 10288
fs.readFile(p, { signal }, cb) 9285 8997
  • The 300 to 350 instructions less per call with an options object come from one change: fast_get_truthy looks signal up with the identifier that the VM already has. It is also the malloc: readFileSync(p, {}) 2.03 -> 1.03 per call, writeFileSync(p, d, {}) 4.01 -> 3.01.
  • a call with a non-native signal: readFileSync 3,030 instructions, fs.promises.readFile 12,156 (10,288 with a native signal).
  • args::ReadFile 96 -> 104 bytes, args::WriteFile 136 -> 144. Job<ReadFile> 272 -> 280, Job<WriteFile> and Job<AppendFile> 296 -> 304, Job<Access> 224 -> 224. Release binary: text +1,971 bytes, 0 bytes on disk. 1 new C++ export, 0 host functions, no JS module.
  • calls into AbortSignal per sync call with a native signal: 6 -> 1 (node:fs: keep options.signal only for the async forms of readFile and writeFile #47993 has this too). Promise read: 8 -> 8. Live AbortSignal after 100k calls: 1. Protected objects after 10k calls with a non-native signal: 4, as before the loop.
  • traps of a proxy signal per call: has 1 in every form, get 0 in the sync forms and 2 in the async forms (Node: 2 to 4). openat of the target before a rejection: 0. Calls of the export without a non-native signal: 0.
  • the script of the report: 4 of 16 lines differ from Node (16 on main), the null and "" lines. Matrix of 24 values of signal in every form: 59 of 323 rows differ (264 on main). Polyfill rows equal: 4 of 4. Existing assertions edited: 1 (sql-reserve-abort.test.ts, the sentence of validateAbortSignal). AOT table: 6 rows of the binding changed.
  • bun build --compile --aot executables: 0 bytes of difference for 12 programs (no fs, sync fs, promise and callback forms without a signal, a native signal given to the sync, promise and callback forms, to fetch, to Bun.serve, with a listener).

The 59 rows that still differ from Node

  • 26: null and "" in the 13 forms. Kept as they are on main.
  • 20: a Proxy of a native signal in the async forms. The proxy is no AbortSignal cell, so aborted is read through it, and Bun's getter throws for a this that is a proxy. The sync forms do the I/O, as in Node.
  • 12: a signal that is aborted before the call (native, { aborted: true }, { aborted: true, reason }). In the 3 callback forms the callback comes on the next tick (node:fs: call back before return for an already-aborted signal in readFile, writeFile and appendFile #47489). In fs.readFile(fd) Node's ReadFileContext drops the AbortError for a descriptor of the caller.
  • 1: Object.create(AbortSignal.prototype) in the async forms.

Siblings left out

Suites run on the debug build

  • test/js/node/fs/promises.test.js 69 pass, sql-reserve-abort.test.ts 9 pass, abort-signal-leak-read-write-file.test.ts, fs-path-length, fs-promises-writeFile-async-iterator, fs-leak, event-emitter.test.ts, the abort tests of node-http2.test.js.
  • fs.test.ts: 636 pass with the 16 cases of node:fs: keep options.signal only for the async forms of readFile and writeFile #47993. 13 tests time out on this machine (readdir ... x 100, the WebAssembly.Memory reads and others), the same 13 on main's build.
  • Upstream test/js/node/test/parallel: 41 test-fs-* files for readFile, writeFile, appendFile and fs.promises, 26 files about abort signals (events, timers, streams, child_process, fs.watch), 22 http2 files about abort and destroy.

Suites run on the release build

  • test/internal/source-lints 194 pass, test/internal/builtins-table 133 pass.
  • 519 programs of test/aot/parallel (each one with a bound on what the build counts, or that uses node:fs): the same result as on main. The new program builtin-modules/signal-that-is-no-abort-signal-given-to-read-file-and-write-file.mjs passes, also with --aot-validate and the checks of the run time.

…File, writeFile and appendFile

`args::signal_option` accepts what Node's `validateAbortSignal` accepts.
After its class test fails, `validators::validate_abort_signal` asks the
rule that the JS-visible `validateAbortSignal` uses. `undefined`, `null`
and `""` stay absent.

An async call keeps a signal that is no `AbortSignal` GC-protected in
`args::FileSignal`. The JS thread asks it at the two points where it asks
an `AbortSignal`: before the task is scheduled and when it completes.

`validateAbortSignal` now throws Node's sentence ("must be an instance of
AbortSignal") and refuses a function that has `aborted`, for every caller.
`FileHandle#appendFile` forwards `signal`. `fs.promises.writeFile` checks
it for iterable data, after the encoding. The `AbortError` for a signal
has an own `cause`, also for a reason that is `undefined`.
@github-actions github-actions Bot added the claude label Nov 7, 2026
@robobun

robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:25 PM PT - Nov 7th, 2026

❌ @robobun, your commit d18ff00 has 38 failures in Build #138377 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 48124

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

bun-48124 --bun

@robobun

robobun commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on main b1305dd: fs.promises.readFile(file, { signal: { aborted: false } }) rejects with The "signal" argument must be of type AbortSignal. Received an instance of Object. Node v26.3.0 reads the file.
  • The same error comes for new AbortController().signal in a test file that runs under the happy-dom environment of bun test. test/js/node/fs/promises.test.js has that case.
  • This PR is stacked on node:fs: keep options.signal only for the async forms of readFile and writeFile #47993. Its diff is the commits on top.
  • CI on d18ff00 (build 138377, finished): 29 test files are red, all of them AOT tests (test/aot/... and test/bundler/compile-aot-size.test.ts). The same files are red on main (build 138109). No test that this PR adds or changes is red. The clippy, format, type check, source lint and mordant checks pass.
  • Main has moved since: AOT: front end, test runs and tests for the rewritten compiler #47942 rewrote src/runtime/node/node_fs_binding.bind.ts, and this branch conflicts with main in that one file. It needs a rebase when node:fs: keep options.signal only for the async forms of readFile and writeFile #47993 lands or is rebased. The three promise rows must then say, in the new form of that file, that they keep the signal member and read it when the operation completes.

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

Beyond the inline findings, a few things were checked and ruled out: the switch to ERR::INVALID_ARG_INSTANCE in NodeValidatorBodies.h keeps the ERR_INVALID_ARG_TYPE code (only the "an instance of" wording changes, which is how Node formats a capitalized expected type); the FileSignal::Native pending-activity ref is released by its Drop on every early-return path of the three from_js parsers, so the removed scopeguards do not unbalance it; and removing the undefined-cause branch from Bun__wrapAbortError only affects callers that pass undefined (the h2 caller passes a real reason, and Node's AbortError installs an own cause: undefined too).

Extended reasoning...

The change touches the native node:fs argument parsers (node_fs.rs, node_fs_binding.rs), the shared C++ validateAbortSignal, the AbortError wrapper, fs.promises.ts and a vendored Node test file; it touches no auth, crypto or injection surface. Five verified findings are posted inline, including a Node-compat divergence for FileHandle#appendFile and a type-check failure in fs.promises.ts, so a human needs to weigh them; this note only records the adjacent concerns that were examined and found not to be issues.

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/js/node/fs.promises.ts Resolved
Comment thread src/js/node/fs.promises.ts Resolved
Comment thread src/runtime/node/node_fs.rs Resolved
Comment thread src/runtime/node/node_fs.rs Resolved
Comment thread src/runtime/node/node_fs.rs Resolved
…re it is typed

`signal === ""` compared a variable of type `AbortSignal | null` with a
string. The type check of the built-in modules refuses that comparison.
Comment thread src/jsc/bindings/NodeValidator.cpp Resolved
Comment thread src/jsc/bindings/NodeValidatorBodies.h Resolved
Comment thread src/runtime/node/node_fs.rs Resolved
Comment thread src/runtime/node/node_fs.rs Resolved
Comment thread src/runtime/node/node_fs.rs Resolved
Comment thread src/runtime/node/node_fs.rs Resolved
Comment thread src/runtime/node/util/validators.rs Resolved
Comment thread src/runtime/node/util/validators.rs Resolved

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

Commit 77f22a0 resolves the AbortSignal | null vs "" comparison flagged on the previous push (the untyped given is compared now, so the built-in module type check is satisfied).

Beyond the inline note, I also checked: the refcount pairing after the Drop impls and scopeguards were replaced — FileSignal::drop releases the pending activity and AbortSignalRef (ExternalShared) releases the signal ref itself, so parse-error, success and abort paths stay balanced; the Bun__wrapAbortError change also reaches h2_frame_parser.rs, where it only makes cause: undefined an own property as Node's AbortError does; and the edit to test-fs-promises-file-handle-append-file.js matches the upstream Node version of that file rather than a Bun-authored addition.

Extended reasoning...

The change spans Rust argument parsing in src/runtime/node/node_fs.rs (new FileSignal enum with a GC-protected non-native variant and a two-point JS-thread read), a new C++ export shared with the JS-visible validateAbortSignal that changes its error code and now rejects callables for all 42 call sites, the AbortError constructor helper, and roughly 500 lines of new tests. It touches no auth, crypto or injection surface. It is not approved because it is a large native change with deliberate, documented divergences from Node (null/"" as absent, completion-time rejection for FileHandle#appendFile) and a behavior change to a widely shared validator, which a maintainer should weigh; the four optional/pre-existing threads from the prior review were author-resolved with replies whose text is not visible here.

Comment thread test/js/node/fs/promises.test.js Resolved
…e test

The test for a signal that is aborted after the call flipped the signal
of the three FileHandle forms before their call, while the file was still
being opened. It now flips it once the method has been called.

@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 reviewed the latest push and found no bugs; both points from my earlier inline comments (the TS2367 comparison in writeFileAsyncIterator and the FileHandle rows aborting before the call) are addressed in the current code. Because this changes the shared validateAbortSignal contract for every caller and moves a GC-protected user object across the pool round-trip, a human look is still worthwhile.

What was reviewed:

  • FileSignal ownership and thread affinity: the pending-activity ref is released in its own Drop on every exit; the NonNative Protected is only read (aborted/reason) and dropped on the JS thread, since Job is always freed there (job.rs take/release_unrun) and the pool only reads the native flag.
  • Exception handling in validateNonNativeAbortSignal (hasProperty, name.toWTFString) and in AsyncFSTask::then, where getter errors now reject the completion after the arguments are dropped; INVALID_ARG_INSTANCE keeps code ERR_INVALID_ARG_TYPE with Node's "must be an instance of AbortSignal" sentence and rejecting callables matches Node's typeof check.
  • Removing the undefined-cause special case in Bun__wrapAbortError: the only other caller (h2_frame_parser) always passes a defined reason.
  • The edit to test-fs-promises-file-handle-append-file.js restores Node's upstream doAppendAndCancel verbatim rather than diverging from it.
Extended reasoning...

The change replaces the AbortSignalRef fields and scopeguards in src/runtime/node/node_fs.rs with an args::FileSignal enum (native ref or GC-protected arbitrary object), adds a C++ export Bun__validateNonNativeAbortSignal shared with the JS-visible validateAbortSignal (NodeValidatorBodies.h, 42 call sites), makes AsyncFSTask::then run user getters after dropping the arguments, forwards signal from FileHandle#appendFile and validates it on the iterable writeFile path in fs.promises.ts, and adds roughly 570 lines of tests. It touches no auth, crypto or injection surface; the sensitive part is native memory and thread affinity of a user object held across the work pool. Deciding against approval: the validator's message changes for every caller (one existing test assertion updated), several Bun-only divergences from Node are pinned deliberately (null and "" accepted as no signal, callback-form getter errors delivered to the callback, FileHandle#appendFile rejecting after data landed), and soundness of the NonNative variant rests on an unsafe ThreadIsolatedArg impl; the hunt ran dry with no findings and the prior inline feedback is addressed in db5aeaa.

…llers

`args::ReadFile` has no `Drop` impl any more, so clippy asks for the
initializer in place of assignments after `default()`.

@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 finding, I also checked three things in this push and they hold up: the removed undefined-cause early return in Bun__wrapAbortError does not change the two h2_frame_parser.rs callers (their reason comes from a native signal, never undefined) and for a non-native signal with reason === undefined the resulting own cause: undefined matches Node's new AbortError(undefined, { cause }); FileSignal::NonNative's Protected is only dropped on the JS thread, since Job<C> is freed by its completion or by release_unrun, both JS-thread; and ERR::INVALID_ARG_INSTANCE still emits code ERR_INVALID_ARG_TYPE, so the new "must be an instance of AbortSignal" text matches Node's validateAbortSignal wording and code.

Extended reasoning...

This push reshapes options.signal handling for readFile/writeFile/appendFile across the Rust parsers, the C++ validator, and the fs.promises builtin, replacing hand-written Drop impls with a FileSignal enum that owns the native ref or a GC-protected JS object. The checks above (shared-helper callers, drop thread affinity, error code of the shared validator) were ruled out from the code and are not covered by the posted inline finding or earlier reviews.

The three callers now build `args::ReadFile` with `..Default::default()`.
One of them reads a descriptor, so mordant can see that `flag` matters for
a path only. The shape of the struct is as on main.

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

Code review found no issues

No high-confidence issues detected in this change.

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.

1 participant