Repository navigation
Conversation
…e node The native option parsers for readFile/readFileSync, writeFile/appendFile (and their sync and promises forms) and fs.watch reported a bad signal as 'The "signal" argument must be of type AbortSignal' (fs.watch: 'signal is not of type AbortSignal') and silently ignored null and "". Node's getOptions() runs validateAbortSignal(options.signal, "options.signal") on every defined value, so the message is 'The "options.signal" property must be an instance of AbortSignal. Received ...' and only undefined means no signal. The three sites now read the option with get() instead of get_truthy() and throw through a shared validators::throw_err_invalid_abort_signal. The Rust ERR_INVALID_ARG_TYPE renderers in JSGlobalObject render a dotted name as a property, the rule Node and the C++ Message::addParameter already apply.
|
Warning Review limit reached
Next review available in: 30 minutes Limit details: You’ve used all 5 included reviews currently available under your plan. 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 (6)
Comment |
|
Status: ready for review (current head: 9863825). Reproduced on main (a42889a, debug build) and on the released bun 1.4.0 with a script that runs Since the first push: the brand check and the error moved into one |
There was a problem hiding this comment.
LGTM — focused Node.js compat fix for options.signal validation in fs readFile/writeFile/watch, with the Rust ERR_INVALID_ARG_TYPE renderer brought in line with Node's argument/property rule.
What was reviewed:
- Verified
JSValue::getreturnsNonefor undefined/missing (JSValue.rs:1136), so theget_truthy→getswap correctly rejects only defined non-signals while leavingundefinedas "no signal". - Traced the
InvalidArgTypeNamerenderer's blast radius: dotted-name callers in SocketAddress, h2_frame_parser, node_cluster, node_fs (maxRetries/retryDelay), fetch compress_body, and socket_body all move fromargument→property; grep found no test pinning the old wording, and the change matches both Node's rule and the existing C++ErrorCode.cpprenderer. - Confirmed
validatorsis already in scope innode_fs.rsandstrings::contains_charexists inbun_core. - Tests cover a 10-value matrix, 13 entry points, positive cases (undefined + real signal), and assert no I/O on rejection.
Extended reasoning...
Overview
This PR aligns options.signal validation in node:fs (readFile/writeFile/appendFile in all sync/callback/promises/FileHandle forms, plus fs.watch) with Node v26.3.0. Three call sites in node_fs.rs and node_fs_watcher.rs switch from get_truthy to get (so null/"" are now rejected like Node does) and route through a new validators::throw_err_invalid_abort_signal helper. To render Node's exact text ("options.signal" property must be an instance of AbortSignal), the three Rust ERR_INVALID_ARG_TYPE renderers in JSGlobalObject.rs gain an InvalidArgTypeName formatter implementing Node's rule (dotted name → property, argument suffix → verbatim, else → argument).
Security risks
None. This is error-message wording plus tightening of an option validator (rejecting more inputs, not accepting more). No auth, crypto, path handling, or resource acquisition changes.
Level of scrutiny
Medium. The three call-site changes are trivially correct once JSValue::get's semantics are confirmed (done — it returns None for undefined and missing properties, so undefined still means "no signal"). The shared-renderer change in JSGlobalObject.rs has broader reach: it changes error text for every Rust caller passing a dotted argname. I enumerated those callers (~15 sites across SocketAddress, h2_frame_parser, node_cluster, node_fs rmSync options, fetch compress.encoding, and socket rule.*). For the node-compat callers this is strictly a move toward Node's text, and it matches the rule the C++ ErrorCode.cpp renderer already applies. For the two Bun-native callers the wording change is still more accurate. Grep confirms no test in the tree asserts the old "<dotted>" argument phrasing, and the PR description lists the relevant node parallel tests (test-socketaddress, test-http2-client-request-options-errors, test-fs-rm, fetch-compress.test.ts) as passing.
Other factors
- The PR description is unusually thorough: it cites Node source lines, documents the renderer side-effect and the trial-merge with the overlapping #38663 (which carries the identical
InvalidArgTypeNamechange), and lists every test file re-run. - Test coverage is strong: a 10-value type matrix through
readFileSync, a 13-entry-point matrix with{ signal: null }, positive cases forundefinedand a real signal, an assertion that the output file is not created (validation precedes I/O), and an equivalent fs.watch matrix that closes created watchers. - The behavior change (rejecting
{ signal: null }/{ signal: "" }) is intentional Node alignment and happens before any file is opened. - No caller passes a name already ending in
argument, so that branch ofInvalidArgTypeNameis currently dead but harmless (it exists for parity with Node and the C++ renderer). - The bug hunting system found no issues.
|
On the one observation in the review (no Rust caller currently passes a name ending in |
|
Updated 1:04 AM PT - Aug 16th, 2026
✅ @robobun, your commit 9863825baa5130d9b9634169012a70ae07da3ea4 passed in 🧪 To try this PR locally: bunx bun-pr 39274That installs a local version of the PR into your bun-39274 --bun |
…lper validators::validate_abort_signal returns the native AbortSignalRef or throws node's validateAbortSignal error, so the three fs sites share the accept condition and the message. fs.watch's Arguments now owns a ref to the signal instead of borrowing it from the JS value until FSWatcher::init takes its own.
14b7ca1 to
9863825
Compare
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Because it changes AbortSignal refcount ownership in fs.watch and widens the ERR_INVALID_ARG_TYPE renderer's argument/property rule to every Rust caller, a human look would still be worthwhile.
What was reviewed:
AbortSignalRefrefcount balance acrossArguments::from_js→FSWatcher::init(clone) →detach()(replace-None drop), including thepath_watcher::watcherror path — balanced, and oneunsafeadopt removed.JSValue::getreturnsNonefor both missing andundefined, so onlyundefinedbypasses validation (matches node'soptions.signal !== undefined).InvalidArgTypeNameusesstrings::contains_charand mirrorsBun::Message::addParameter; existing non-dotted callers keep the old"x" argumenttext.
Extended reasoning...
Overview
This PR aligns options.signal validation in fs.readFile/writeFile/appendFile (all forms) and fs.watch with Node.js. It adds validators::validate_abort_signal returning an AbortSignalRef, switches the three call sites from get_truthy to get so null/""/0 are validated (and rejected) like node does, and adds an InvalidArgTypeName formatter to the three Rust throw_invalid_argument_type_value* renderers so dotted names read "options.signal" property instead of "options.signal" argument. fs.watch's Arguments struct now holds Option<AbortSignalRef> instead of a borrowed &AbortSignal, and FSWatcher::init clones it — removing an unsafe { AbortSignalRef::adopt(s.ref_()) } and the ensure_still_alive dance. Two test files gain a value-matrix and entry-point-matrix pinning node v26.3.0's exact error text.
Security risks
None identified. This is input validation for a documented option; the change makes validation stricter (rejecting more values), not laxer. No path handling, no privilege boundaries, no untrusted-data parsing beyond a JS brand check.
Level of scrutiny
Medium-high. Two aspects push this beyond "mechanical":
- Refcount ownership change:
fs.watchnow takes an ownedAbortSignalRefinArgumentsand clones it into theFSWatcher. I traced the refcount:ref_from_js(+1) → stored inargs.signal→.clone()intoFSWatcher.signal(+1) →argsdropped (−1) →detach()'sreplace(None)(−1). The error path ininit(whenpath_watcher::watchfails) callsfinalize→detachbefore returning, so the cloned ref is released there too. This is balanced and matches the author's heapStats probe, but it's a change to intrusive-refcount lifetime in a GC-interacting struct — the kind of thing REVIEW.md calls out for maintainer sanity-checking. - Repo-wide renderer change:
InvalidArgTypeNameapplies to every Rust caller of the threethrow_invalid_argument_type_value*helpers. The PR body says the affected dotted-name callers were spot-checked against node and no test pinned the old text, and the change mirrors whatErrorCode.cppalready does — but the blast radius is wider than the three fs sites.
Other factors
CI (Build #99007) is green. Tests are thorough (10-value matrix through readFileSync, 13-entry-point matrix with {signal: null}, plus the negative check that no I/O happened). The comment-cop bot flagged three doc comments; the author responded to each with justification (node-compat guidance requires citing the upstream source for ported rules; the _value2 doc is two lines) and trimmed one. Those responses look reasonable to me but a maintainer may want to weigh in on whether the 5-line InvalidArgTypeName doc should be trimmed further. There's also stated overlap with #38663 (same renderer hunk) and #35275 (same three call sites) that a maintainer coordinating those PRs would want to be aware of.
Problem
fs.writeFileSync(p, "x", { signal: 1 })throwsThe "signal" argument must be of type AbortSignal. Received type number (1); node v26.3.0 throwsThe "options.signal" property must be an instance of AbortSignal. Received type number (1). The code (ERR_INVALID_ARG_TYPE) already matches, the text differs in the name and inof typevsan instance of. Same forreadFile/readFileSync,writeFile/appendFileand their sync forms,fs.promises.readFile/writeFile/appendFile, andFileHandle#readFile.fs.watch(p, { signal: 1 })throwssignal is not of type AbortSignal(right code, none of node's wording).get_truthy, so{ signal: null }and{ signal: "" }are silently accepted and the operation runs; node rejects them with the same error (Received null,Received type string ('')), since onlyundefinedmeans "no signal". This is the one user-observable behavior change in the PR.args::ReadFile::from_jsandargs::WriteFile::from_js_with_default_flaginsrc/runtime/node/node_fs.rs(lines 4077 and 4186 on main) each brand-check the value themselves and callthrow_invalid_argument_type_value(b"signal", b"AbortSignal", ..);Arguments::from_jsinsrc/runtime/node/node_fs_watcher.rs:704does the same with a hand-written message. Node runs one function for all of them,validateAbortSignal(options.signal, "options.signal")fromgetOptions()(lib/internal/fs/utils.js#L340-L342,lib/internal/validators.js#L444-L451).ERR_INVALID_ARG_TYPErenderers (JSGlobalObject::throw_invalid_argument_type_value*) always print"<name>" argument, so even with the right name they could not produce node'sproperty. The C++ renderer (Message::addParameterinErrorCode.cpp) already applies node's rule; the Rust one never did.flushmessage. The upstream fs tests in the tree only assert the error code here, so they pass before and after; the tests below are what pins the text.Fix
validators.rs: newvalidate_abort_signal(ctx, value, name) -> JsResult<AbortSignalRef>, the port of node's validator: returns a ref to the native signal, otherwise throwsERR_INVALID_ARG_TYPErendered asan instance of AbortSignalthroughthrow_invalid_argument_type_value2. This is now the only place that decides what counts as a signal for the native fs code and how the rejection reads; node:fs: accept AbortSignal-shaped objects in readFile/writeFile/watch #35275 (accepting AbortSignal-shaped objects) would be a change to this one function instead of to three sites.get(undefined or missing means absent, anything else is validated, mirroring node'soptions.signal !== undefined) and call the helper; the per-site brand checks and messages are deleted.fs.watch'sArgumentsnow holds the returnedAbortSignalRefinstead of a&AbortSignalborrowed from the JS value, andFSWatcher::initclones it; this removes theensure_still_alivedance and anunsafeadopt, and the refcount stays balanced (2 liveAbortSignalwrappers after GC for 10,000 watchers created with signals, same as bun 1.4.0; the fixture is in the details below).JSGlobalObject.rs: the three Rust renderers format the name throughInvalidArgTypeName, node's rule ("x" argument,"a.b" property, a name already ending inargumentverbatim;lib/internal/errors.js#L1407-L1414), the same rule the C++ renderer applies. Side effect: the 15 other Rust call sites that pass a dotted name (listed in the details below; every other caller passes a plain name and is unchanged) now also sayproperty. For the node APIs among them that is node's text (fs.rmSync(p, { maxRetries: "a" }),{ retryDelay: "a" },new SocketAddress({ address: 1 }),{ flowlabel: "x" }diff clean against node); no test in the tree pinned the old"<dotted>" argumentwording. node: render the Rust validators' ERR_INVALID_ARG_TYPE messages like Node #38663 carries the same renderer change for a different set of callers; the text here is taken from it verbatim, and a trial merge in both orders is conflict-free and ends up with a singleInvalidArgTypeName. It is duplicated rather than stacked so that neither PR has to wait for the other.node:fsnode's output is the contract, and with this change a 40-call matrix (10 bad values throughreadFileSyncandwriteFileSync,{ signal: 1 }through every sync/callback/promises/FileHandle entry point,fs.watchwith1,{},0,null, plus valid and undefined signals) is byte-identical to node v26.3.0, except for two entry points that never reach this code and are fixed elsewhere:FileHandle#appendFile/#writeFilerebuild their options in JS (fs.promises: forward options.signal from the FileHandle wrappers and validate it for iterable data #39272) andfs.promises.watchvalidates in JS through the C++ renderer (node errors: render a single class name in ERR_INVALID_ARG_TYPE as "an instance of" like node #38694 fixes itsof type). Rejectingnull/""happens before any file is opened (the test checks the output file is not created).getOptions()also validatesoptions.signalfor functions that then ignore it (readdir,readlink,realpath,mkdtemp, ...); this PR only changes the three sites that use the signal. Accepting AbortSignal-shaped objects stays with node:fs: accept AbortSignal-shaped objects in readFile/writeFile/watch #35275, which edits the same three sites on main; whichever of the two lands second has a mechanical rebase (with this PR the change becomes the helper's accept condition).test/js/node/fs/promises.test.js,options.signal validation matches node: exact message for ten non-signal values throughreadFileSync;{ signal: null }through all 13 entry points that reach the binding (readFileSync,readFile,promises.readFileby path and by handle,FileHandle#readFile,writeFileSync,writeFile,promises.writeFileby path and by handle,appendFileSync,appendFile,promises.appendFileby path and by handle); undefined and a real signal still accepted; no I/O performed.test/js/node/watch/fs.watch.test.ts:fs.watchwith1,null,"",{}and anAbortController; undefined and a real signal still create a watcher.promises.test.js,fs.watch.test.ts,fs.watch.close-exit.test.ts,fs.test.ts,socketaddress.spec.ts,parse-args.test.mjs,scrypt.test.ts, thepathtests,fetch-compress.test.ts, the abort-signal leak fixture for readFile/writeFile, and the upstreamtest-fs-readfile,test-fs-promises-readfile,test-fs-promises-writefile,test-fs-promises-file-handle-readFile/-writeFile,test-fs-write-file,test-fs-writefile-with-fd,test-fs-watch,test-fs-watch-abort-signal,test-fs-watch-recursive-promise,test-fs-promises-watch,test-fs-rm,test-socketaddress,test-blocklist,test-http2-client-request-options-errors.cargo fmt --checkis clean.Background
ERR_INVALID_ARG_TYPE(name, expected, actual)in node rendersThe <subject> must be <expected>. Received <actual>. The subject is"name" argument, or"name" propertywhen the name contains a.(an option path such asoptions.signal). A class name asexpectedrenders asan instance of X; a primitive type name asof type x.validateAbortSignal(signal, name)is node's validator for signal options:undefinedpasses, anything else that is not an object with anabortedproperty throwsERR_INVALID_ARG_TYPE(name, "AbortSignal", signal).getOptions()in node's fs layer calls it withoptions.signal, which is why all the entry points above share one message. The native port accepts only a realAbortSignal, because that is the only kind the native abort paths can subscribe to; the comment on the helper says so.ERR_INVALID_ARG_TYPErenderers: the C++ one inErrorCode.cpp, used by C++ and by the JS builtins ($ERR_INVALID_ARG_TYPE,validateAbortSignal), and the Rust helpers onJSGlobalObject, used by natively parsed options such as these.throw_invalid_argument_type_valueprintsof type <typename>;throw_invalid_argument_type_value2takes the whole phrase aftermust be, which is how a class-name expectation is rendered from Rust.determine_specific_type(shared with C++) renders the received value.AbortSignalRefis the intrusive smart pointer over the C++AbortSignal(ref()on clone,unref()on drop);AbortSignal::ref_from_jsdowncasts a JS value and takes one.JSValue::getreturnsNonefor a missing orundefinedproperty;get_truthyadditionally dropsnulland"", which is the filter that let those values through.src/runtime/node/util/validators.rsis the Rust port of node'slib/internal/validators.js; the new helper sits with the othervalidate_*functions there.bun 1.4.0 vs node v26.3.0 for a few of the calls (after this change every bun line equals the node line)
Every Rust call site whose text changes from "argument" to "property"
Found by listing all callers of the three renderers and keeping those whose name contains a
.(the callers that pass a name through a variable, invalidators.rs,csrf_jsc.rs,s3/credentials_jsc.rs,ffi_body.rsandvalidate_object, all receive plain names such ascallback,expiresIn,options; the only dotted names reaching them are the twofs.rmones).node_fs.rsviavalidate_integer(2)options.maxRetries,options.retryDelayfs.rm/rmSync/promises.rm(matches node)socket/SocketAddress.rs(3)options.address(x2),options.flowlabelnet.SocketAddress(matches node)api/bun/h2_frame_parser.rs(6)options.silent,options.endStream,options.exclusive,options.parent,options.weight,options.signalhttp2request options (node's JS validators saypropertyfor these)node/node_cluster_binding.rs(1)handle.fdsocket/socket_body.rs(2)rule.syscall,rule.actionwebcore/fetch/compress_body.rs(1)compress.encodingfetchcompress optionRefcount check for the fs.watch ownership change
2,000 iterations of: a watcher with a live signal closed explicitly, one created with a pre-aborted signal, one aborted after creation, one call rejected with
{ signal: {} }, and one whose options object returns a fresh signal from a getter. AfterBun.gc(true),heapStats().objectTypeCounts.AbortSignalis 2 on this branch and 2 on bun 1.4.0 (debug/ASAN build for this branch).Earlier revision
The first push kept the brand check at each of the three sites and only shared the error (
throw_err_invalid_abort_signal); the watcher still borrowed the signal from the JS value. A review pass pointed out that this left three copies of the accept condition, so the second commit moved check and error intovalidate_abort_signaland made the watcher own its ref. Tests are unchanged between the two revisions.