Repository navigation
Conversation
…'s length `asyncWrap` gave each export the `length` of its native operation. That number counts the callback of the callback form, and it is 0 while a `node.fs` trace category is on. 23 of the 32 functions that Node also exports reported a `length` that Node does not (24 on macOS). - 16 functions (17 on macOS) and `readFile` declare the parameters that Node declares and call the native operation by name, as `rm` and `rmdir` do. - `chown`, `lchown`, `utimes`, `lutimes` and the ten functions that only Bun exports keep `asyncWrap`, which passes on the caller's exact arguments. It takes the length as an argument and no longer reads it from the operation. - `writeFile`, `appendFile`, `open` and `opendir` keep their text and get a stated length. - `FileHandle.prototype.writeFile` declares `options` with no default, so its length is 2.
|
Updated 12:05 AM PT - Nov 11th, 2026
❌ @robobun, your commit 917e717 has some failures in 🧪 To try this PR locally: bunx bun-pr 48555That installs a local version of the PR into your bun-48555 --bun |
|
Status Reproduced on main (89452d2, Linux x64) against Node v26.12.1: bun -p 'require("node:fs/promises").mkdir.length' # 3
node -p 'require("node:fs/promises").mkdir.length' # 2The script in the Notes of the PR body prints the length of every function. On main, 23 of the 32 functions that Node also exports differ from Node, and 25 differ under PR: #48555 |
Pre-merge checks:
|
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 checked the main risk of the declared wrappers — forwarding an explicit undefined where the caller passed nothing — against each native parser in src/runtime/node/node_fs.rs for the 18 functions moved off asyncWrap (chmod/fchmod use unwrap_or(UNDEFINED), access/copyFile go through from_js_number_only which maps undefined to the default, truncate uses next_eat().unwrap_or(UNDEFINED), stat/lstat/statfs gate on is_object(), mkdtemp/readlink/realpath use parse_encoding_arg, symlink checks is_undefined_or_null): none distinguishes missing from undefined, so no error text or default changes for those. Also confirmed FileHandle.prototype.writeFile still defaults encoding to utf8 with the = "utf8" default removed, and there is no second copy of fs.promises under src/js/ahead-of-time to keep in sync. I did not run the new tests; the per-argument sync-vs-promise comparison in the "arguments" block is the check that would catch what reading cannot.
Extended reasoning...
The change replaces asyncWrap with declared async wrappers in src/js/node/fs.promises.ts so node:fs/promises functions report Node's .length, and adds length/argument-forwarding tests. It touches no security-sensitive surface. The one confirmed inline finding (bun:test treating higher-length bound functions as done-callback tests) is a disclosed behavior change rather than a code defect, and the remaining risk I examined — native parsers reacting differently to an explicit undefined — was ruled out by reading each parser.
Problem
require("node:fs/promises").mkdir.lengthis 3. Node v26.12.1 gives 2. 23 of the 32 shared functions differ (24 on macOS).asyncWrapcopies the native operation'slength(src/js/node/fs.promises.ts:462): the callback form's arity, or 0 under--trace-event-categories node.fs._.curry(fsPromises.mkdir)(dir, options)returns a function and makes no directory.Fix
readFiledeclare Node's parameters and call the native operation by name, likenode:os(One bindgen: replace bindgen v1 and v2, add classes, generate the Rust side #45104).asyncWraptakes the length as an argument. Four more state theirs.test/js/node/fs/promises.test.js(32 new tests, 5 fail on main),test/js/node/fs, 370 upstream files, AOT checks.chownand five more now changes ten error texts.Background
lengthcounts the parameters before the first default or rest parameter.= undefinedstands where Node has a default.undefined(uid is required) until bindgen: deleteb.anyand.variadic#46789. Their exports pass on the caller's exact arguments.asyncWrapsites: no call path changes, but each number stands beside...args.Downsides
appendFile,open,opendir,readFile,writeFile).test("x", fsPromises.readFile.bind(null, file))passes underbun testtoday, then waits fordone, as under Jest.node:fsstill differ (fs.stat).Notes
Origin. A differential run of
node:fs/promisesagainst Node found this. No user reported it, so there is no issue (#44716). #45104 fixed thelengthof sevennode:osfunctions the same way and pins them with a table (test/js/node/os/os.test.js:323).Script.
The 23 lengths (Linux x64, main 89452d2). Node v26.3.0 and v26.12.1 print the same numbers.
accessaccess(path, mode = F_OK)appendFileappendFile(path, data, options)chmodchmod(path, mode)chownchown(path, uid, gid)asyncWrapargumentcopyFilecopyFile(src, dest, mode)lchownlchown(path, uid, gid)asyncWrapargumentlinklink(existingPath, newPath)lutimeslutimes(path, atime, mtime)asyncWrapargumentmkdirmkdir(path, options)mkdtempmkdtemp(prefix, options)openopen(path, flags, mode)opendirpromisify(opendir), which copies the 3 ofopendir(path, options, callback)readFilereadFile(path, options)readdirreaddir(path, options)readlinkreadlink(path, options)realpathrealpath(path, options)renamerename(oldPath, newPath)statfsstatfs(path, options = { ... })symlinksymlink(target, path, type)truncatetruncate(path, len = 0)unlinkunlink(path)utimesutimes(path, atime, mtime)asyncWrapargumentwriteFilewriteFile(path, data, options)statandlstatwere 1 and stay 1. They are declared now, so the number no longer comes from the native table.lchmodwent throughasyncWrapand took 3 where Node has 2. It is declared now. I read that arm and did not run it.FileHandle.prototype.writeFilegoes from 1 to 2: itsoptionsparameter had a default that the body did not need. The other 18 shared methods were equal.lib/internal/fs/promises.js#L1418-L2366andlib/internal/fs/dir.js#L330.Lengths in six ways to load the module. Shared names that differ from Node v26.12.1, of 32:
requireimport *fs.promises--trace-event-categories node.fsrequireWith a
node.fstrace category on, main wraps each native operation in a function of length 0, andasyncWrapcopies that. Therestatandlstatread 0 too.Why declared parameters. Node states 31 of its 32 numbers as parameter lists.
rm,rmdir,cpandmkdtempDisposablein this file already are declared functions that call the native operation by name, and #45104 gavenode:osthis form. A declared list cannot drift from its number. It does not depend on the native table or on the trace wrappers, and it needs noObject.definePropertycall. The cost of a call is not the reason: see Measurements.Why six functions pass on the caller's exact arguments. The native parser of each tells a missing argument from
undefined, and Node does not:undefinedin that placechown(path),lchown(path)uid is requiredThe "uid" argument must be of type number. Received undefinedchown(path, 0),lchown(path, 0)gid is requiredThe "gid" argument must be of type number. Received undefinedutimes(path),lutimes(path)atime is requiredatime must be a number or a Dateutimes(path, 1),lutimes(path, 1)mtime is requiredmtime must be a number or a DatewriteFile(path),appendFile(path)data is requiredThe "data" argument must be of type string or an instance of Buffer, TypedArray, or DataViewA declared parameter list would turn the first column into the second. This PR changes no error text. #46789 (open) removes the first column from the native parsers. After it these six can take Node's parameter lists too, and
asyncWrapis left to the ten Bun-only functions. No test here names a text of either column, so this PR and #46789 can merge in either order.The functions that only Bun exports.
close1,exists0,fchmod3,fchown4,fdatasync2,fstat1,fsync2,ftruncate1,futimes4,read6,readv3,write6,writev3. They keep their numbers. Seven of them (fchmod,fchown,fdatasync,fsync,futimes,read,write) still count the callback of the callback form, sofchownreads 4 besidechown3. Node has no function of these names infs/promisesto take a number from, and this change is about the 32 shared names. On main the ten that go throughasyncWrapread 0 under anode.fstrace category. Here they keep their numbers there.Tests. 32 new tests in
test/js/node/fs/promises.test.js(33 on macOS, withlchmod).FileHandlemethod lengths, the table in a child with--trace-event-categories node.fs, the table in a child that replacedObject.definePropertybefore the firstrequire, and the trace events with tracing enabled after the firstrequire.AsyncFunctionkind and attributes. One checks the trace events with the command-line flag. 23 check one function each against its sync form.readFile, and the six that get the exact arguments is called with each prefix of a list, each prefix followed byundefined, and the whole list with two more arguments. The last argument of a list is one whose effect shows ({ bigint: true },{ encoding: "hex" },COPYFILE_EXCLonto an existing file, an access mode of 8). The promise form must settle as the sync form returns or throws for the same arguments, and leave the same entries on disk. The sync form gets the caller's list as it is. 175 lists agree on main and on this PR.Object.defineProperty, a copiedfn.length): 80 fail a new test. The other 16 change nothing that a caller can see: 15 pass one more argument to a parser that reads none past Node's list, and one calls the other realpath operation of the binding, which answers the same for these paths.Measurements. Release builds of main (89452d2) and of this PR on it, Linux x64. Instructions are user-mode instructions of the main thread (
perf_event_open), withBUN_JSC_useConcurrentJIT=0and the collector off. A second series of main is beside each result.size): text 143,094,946 to 143,095,992 (+1,046 bytes). data and bss are equal. The bundled module grows from 43,824 to 44,870 bytes. The generated native binding is unchanged.require("node:fs/promises"), median of 12 interleaved runs: 12,198,880 to 12,199,262 (+382). Main again: -2,192.nare of different declared functions (median of 9 runs):nstat,readdir,mkdir,access,readFile, one awaited call of each for each iteration, median of 7 runs). Each function is compiled on its own, here on the main thread. So the total is higher until about 1,000 iterations and lower after that, where main's one shared body serves five different operations:BUN_JSC_useJIT=0)BUN_JSC_useDFGJIT=0)statmkdirreadFileopenandclosestat,readdir,mkdir,access,copyFile: -627 to -792 in the interpreter, -526 to -607 in Baseline, -390 to -486 with default flags.readFile: -1,933 to -2,189, -1,699 to -1,930, -1,421 to -1,544.chown,fstat,open,opendir,writeFile: -8 to +4.FunctionCodeBlockcells afterwards, collector on: 18 to 49. After one call of each: 12 to 27.heapStats().objectTypeCounts):statandmkdirlose 1Array.readFileloses 2Arraycells and 1 butterfly.chown,fstat,writeFileandopenare equal.Object.definePropertyon a function during the firstrequire: with keylength30 to 0, with keyname47 to 17.Details of the text that a measurement decided. The first three are from builds of an earlier form of this change on main 2596ac5.
{ value }, as on main. A{ __proto__: null, value }literal cost about 1,700 instructions each at load (+50,000 for the module), and one shared null-prototype descriptor cost +67,000.openkeeps its text and gets a stated length. With(path, flags, mode)and two=== undefinedchecks, one rejected call cost +58 instructions in the interpreter and +249 with default flags.appendFileandwriteFile. Named ones cost +19,000 instructions at load and 12 to 19 more per call.FileHandleconstructor keeps its length of 3 (Node: 1). Two default parameters there costopen+110 instructions per call in the interpreter.Other shapes that I measured.
asyncWrapsites. No call path changes and no first call costs more. The numbers then stand beside...args, 30 definitions stay at load, and tracing that starts late sees none of them. If no measured case may move, this is the shape to take.require.What changes besides the numbers.
bun test. Five kinds of call that pass on main fail here, and three that fail on main pass here. The next paragraph has the table.require(trace_events.createTracing().enable(), the usual order in an ES module, where the import runs first): a declared function reads the native operation at each call, so it is traced. Of 25 exports called once, main traces 2 and this PR 18. Node v26.12.1 traces all 25. Still not traced in that order:chown,lchown,utimes,lutimes,writeFile,appendFile, the ten Bun-only functions and theFileHandlemethods that call them.statfsis traced in no order. With--trace-event-categoriesthe events are the same as on main.Object.definePropertybefore the firstrequirewith a function that defines nothing on a function: on main 25 of the 32 shared names then read a wrongname(wrapped) orlength. Here none does.Function.prototype.toStringof a declared function prints its own text.A bound function as the body of a test.
bun testpassesdoneto a test callback with alengthof 1 or more and waits for it (src/runtime/test_runner/ScopeFunctions.rs:463). Jest does the same. Sotest("x", fsPromises.readFile.bind(null, file))passes on main, where the bound function has a length of 0. Here the length is 1 and the test fails:this test timed out after 5000ms, before its done callback was called. If a done callback was not intended, remove the last parameter from the test callback function. Jest 30.5.2 on Node v26.12.1 fails the same test:Exceeded timeout of 5000 ms for a test while waiting for `done()` to be called. 11 bound calls as test bodies, each with the length of the bound function:fsPromises.readFile.bind(null, file)fsPromises.writeFile.bind(null, file, "data")fsPromises.appendFile.bind(null, file, "data")fsPromises.open.bind(null, file)fsPromises.open.bind(null, file, "r", 0o666)fsPromises.opendir.bind(null, dir)fsPromises.opendir.bind(null, dir, {})fsPromises.mkdir.bind(null, dir, { recursive: true })fsPromises.unlink.bind(null, file)fsPromises.access.bind(null, file, 0)fs.readFile.bind(null, file)Main differs from Jest in 8 of the 11 outcomes, and this PR in none. The rule of the test runner stays as it is: Jest has it, and the last row needs it.
The same class outside this PR.
node:fs: 9 of 100 shared function exports differ from Node inlength(Dir3/4,Stats18/0,close1/2,fstat1/3,lstat1/3,openAsBlob1/2,stat1/3,statfs1/3,statfsSync1/2, Node/Bun), andfs.realpathSync.native2/3. Sostatreads 1 in its promise form and 3 in its callback form. Seven of them are one-line edits of the same kind. They are not in this PR:node:fshas a second text for--compile --aotsince AOT: the next base #48120, and a default parameter onfs.statandfs.closeneeds numbers of its own.FileHandleconstructor: 1/3. See above.node:fsexports (31 sync functions andopendir) read 0 under--trace-event-categories node.fs, on main and here. The wrappers insrc/js/internal/trace_events.tsdo not copylength.node:*modules: 173 of 853 top-level function exports that both runtimes have differ inlengthon main, and 150 with this PR (Node v26.12.1, modules that load without a flag, an alias module counted again). process: giveemitWarningand 15 more functions thelengthNode gives them #48051 changesprocess.Open PRs on the same lines.
readFile,writeFileandappendFileto forwardarguments, and keeps theirlengthat 1 until this change. After this PR itsreadFilepart is not needed: the declaredreadFilecalls no array iterator. ItswriteFileandappendFileforms fit under the stated length 3.b.anyand.variadic#46789: see above. Either order works.stat,lstatandfstat, and node:fs: accept the readdir recursive/withFileTypes values node accepts #39330 and node:fs: match Node's recursive readdir symlink and cycle handling #31418 changereaddir. Each keeps Node's parameter list of its entry when it is rebased, and the table test says so if it does not.Self-review. 23 concerns. 11 are addressed in full, 11 in part, and 1 is not taken.
FileHandle.prototype.writeFileis in, andnode:fsand the constructor are named above with the reasons. The Bun-only lengths stay and are named. The trace wrappers are named, not changed. The list of open PRs names the ones that I read.chown,lchown,utimes,lutimes,writeFileandappendFilenow. That changes ten error texts before bindgen: deleteb.anyand.variadic#46789 does.Search for programs that read these lengths. A GitHub code search for
promises.<name>.lengthran for 6 of the 23 names (writeFile,readFile,open,opendir,access,readdir) before the search quota ended. It found no program that branches on one of the numbers.fs2copiesfs.promises.open.lengthonto its own wrapper.What ran.
bun bd test test/js/node/fs/promises.test.json the debug ASAN build: 62 pass, 5 skip (all five were skipped before), 0 fail. On main's release build: 57 pass, 5 fail.test/js/node/fs,test/js/node/trace_events, the two visible-shape files, the readline history test,worker_threads.test.ts): all pass on this PR. On main the 5 tests above fail, and one test offs.test.ts(keeps a non-throwing callback in the same place in the event loop) fails in 6 to 8 of 10 runs on either build.test-fs-*andtest-trace-events-*files, each run asbun <file>: 359 exit 0 on both builds, and the same 11 do not on both.test/aot/library-sweeps/names-lengths-every-function-walk-finds.jsandtest/aot/every-library-module-runs-under-the-checks.test.tspass.bun src/codegen/check-types-of-modules.tsreports no new error.test/internal/source-lintspasses.nulland with a wrong type in each place. 0 outcomes differed. The declared bodies are the same text now.