fs: port Node.js v26.3.0 fs tests and fix the gaps they surface — cp error semantics, watcher event delivery, watch ignore+AbortSignal, FileHandle pull/writer, glob port, opendir/Dir, mkdtempDisposable, rmdir-recursive end-of-life, mock.fn (+119 tests) - #31830
Conversation
WalkthroughRefactors fs.cp/cpSync with Node-like errors and fast paths, adds fs.watch ignore and AbortSignal handling, implements iterable streams (from/pull/consumers/transform), updates node:fs APIs and runtime/native wiring, introduces experimental node:stream/iter and node:zlib/iter, and adds extensive tests and fixtures. ChangesCore implementation and test coverage
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Updated 6:44 PM PT - Jun 18th, 2026
❌ @cirospaciari, your commit ce686d2 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 31830That installs a local version of the PR into your bun-31830 --bun |
|
Found 13 issues this PR may fix:
🤖 Generated with Claude Code |
36e1de4 to
d333262
Compare
There was a problem hiding this comment.
Actionable comments posted: 15
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/js/internal/fs/cp-sync.ts`:
- Around line 246-252: The error objects created by the fsEisdirError(...) calls
incorrectly set errno to EINVAL while code is "EISDIR"; update those
fsEisdirError(...) invocations in cp-sync (the block throwing the directory
error and the other matching block) to use errno: EISDIR so errno and code are
consistent, and make the same change in the async sibling cp where
fsEisdirError(...) is called so both sync and async branches export errno ===
"EISDIR".
In `@src/js/internal/fs/watch.ts`:
- Around line 14-35: The ignore-matcher uses mutable global/prototype methods;
update createIgnoreMatcher to use the module's tamper-resistant intrinsics:
replace Array.isArray with the $-prefixed primordial (e.g. $isArray), use
$StringPrototypeIncludes.call(matcher, "/") instead of matcher.includes, use
$RegExpPrototypeExec.call(matcher, filename) instead of matcher.exec, and use
the primordial-safe function check (e.g. $isFunction) for the function branch;
also ensure basename is called via its primordial-safe reference if one exists
and keep existing error throws ($ERR_INVALID_ARG_VALUE / $ERR_INVALID_ARG_TYPE)
unchanged.
In `@src/js/internal/streams/iter/transform.ts`:
- Around line 18-25: The current makeBufferedTransformAsync (and the sibling
buffered helpers referenced in 25-60, 64-98, 111-143) buffer the entire input
and call a synchronous processFn at EOF, causing O(total input) memory use and a
blocking final step; replace this design with a true streaming transform: create
a Transform that feeds incoming chunks into a streaming codec (e.g. Node zlib
streaming API like zlib.createGzip/createGunzip or any async streaming codec),
pipe chunks through that codec and push emitted chunks immediately to respect
backpressure, and only on stream end ensure the codec is flushed (honoring
emitOnEmpty by deciding whether to emit flush/headers when no input seen);
propagate codec errors and preserve the same function signature
(makeBufferedTransformAsync(processFn, emitOnEmpty)) so callers remain
unchanged, and apply the same streaming rewrite to the other buffered helper
variants mentioned.
In `@src/js/node/fs.promises.ts`:
- Around line 997-1048: The code currently mutates the shared pos and
bytesRemaining before the async write completes in write() and writev(), causing
incorrect state if writeAll/writevAll reject or abort; change the logic so you
compute the intended position and decrement amount locally but do not assign to
the shared pos or bytesRemaining until the write promise resolves successfully:
call writeAll(chunk, ..., position, signal) / writevAll(chunks, position,
signal) first, then in a .then() (or await) update pos and bytesRemaining (using
the local totalSize for writev) and return the resolved value, leaving
pos/bytesRemaining untouched on rejection so state stays consistent. Ensure you
still validate signal (signal.aborted) before calling the async write and
reference the functions/variables write, writev, writeAll, writevAll, pos,
bytesRemaining in your changes.
- Around line 64-65: Validate that options.signal is a real AbortSignal before
using it or creating the native watcher: check the signal (e.g., via instanceof
AbortSignal or the project’s isAbortSignal helper) immediately after const
signal = options?.signal and again before creating the watcher/fs.watch() to
avoid creating a native watcher with an invalid signal; if the signal is not a
valid AbortSignal, throw/ignore appropriately so the pre-abort fast path and the
watcher creation cannot proceed with an invalid value (refer to the local
variable signal and the watcher/fs.watch() creation points).
- Around line 288-295: In rmdir (async function rmdir) ensure you validate that
options.recursive, when present, is a boolean before using it to decide the
error path: if options?.recursive exists but is not a boolean, throw a type
error (using the same error helper pattern) instead of treating truthy
non-boolean values as the "use fs.promises.rm instead" case; only when
options.recursive === true should you throw the $ERR_INVALID_ARG_VALUE message
directing callers to fs.promises.rm, otherwise perform normal type validation
and proceed.
In `@src/js/node/fs.ts`:
- Around line 1137-1140: The iterator finally block should call the async
close() instead of closeSync() to avoid ERR_DIR_CONCURRENT_OPERATION when other
queued async read()/close() operations exist; update the finally in the Dir
async iterator to replace "if (this.#handle >= 0) this.closeSync();" with an
awaited async close (e.g., "if (this.#handle >= 0) await this.close();") or
otherwise schedule this.close() (e.g., return this.close().catch(() => {})) so
teardown uses Dir.prototype.close() rather than the synchronous closeSync()
path.
- Around line 91-94: Restore the callback validation in rmdir(): ensure the user
callback is validated via ensureCallback before it is passed into
nullcallback/fs.rmdir so that calling fs.rmdir(path) without a function throws
the proper fs callback-argument error; specifically, in the rmdir implementation
(look for function rmdir and the nullcallback(callback) usage) call
ensureCallback(callback) (or assign callback = ensureCallback(callback)) prior
to invoking nullcallback(callback) and fs.rmdir so missing/invalid callbacks
produce the intended TypeError.
In `@src/js/node/test.ts`:
- Line 124: The function mockFn currently declares an unused parameter options
which triggers eslint(no-unused-vars); remove the options parameter from the
signature or rename it to a deliberately unused identifier (e.g., _options) so
the linter recognizes it as intentionally unused; update any callers if you
remove the parameter and keep the function name mockFn unchanged.
- Around line 164-198: The restore function currently always redefines the
descriptor on objectOrFunction, which leaves an own property when the original
descriptor was inherited; modify the code that computes target/descriptor to
record whether the descriptor came from the object itself (e.g., capture a
boolean like isOwn = (target === objectOrFunction)), then change restore() so
that if isOwn is true it restores via Object.defineProperty(objectOrFunction,
methodName, descriptor!), otherwise it deletes any temporary own property
created by the mock (e.g., delete objectOrFunction[methodName]); update
createMockFunction invocation and kMockRestorers usage to use this corrected
restore logic so inherited descriptors are not shadowed after restore.
In `@test/js/node/fs/fs.test.ts`:
- Around line 2041-2042: The rejection expectation for promises.rmdir is
currently fire-and-forget; change it to await the assertion (e.g., add await
before expect(promises.rmdir(path, { recursive: true
})).rejects.toMatchObject(...)) and likewise ensure any other .rejects
assertions (such as those involving promises.rm or other async rejection checks)
are awaited or returned so the test actually verifies the rejection rather than
racing to completion.
In
`@test/js/node/test/parallel/test-fs-cp-async-dereference-force-false-silent-fail.mjs`:
- Around line 17-21: The test claims to exercise the "force is false" path but
neither cpSync nor cp call sets force:false; update the cpSync call using
mustNotMutateObjectDeep({ dereference: true, recursive: true }) and the cp call
that passes { dereference: true, recursive: true } so both option objects
explicitly include force: false (i.e., { dereference: true, recursive: true,
force: false }) so the cpSync and cp code paths for force=false are actually
exercised; ensure you keep the same wrappers (mustNotMutateObjectDeep and
mustCall) around the modified option objects.
In `@test/js/node/test/parallel/test-fs-cp-sync-copy-socket-error.mjs`:
- Around line 28-33: The test races because cpSync(sock, dest) may run before
the server is actually listening; modify the test around server.listen, waiting
for the server to be ready (use the listen callback or server.once('listening'))
and only then call assert.throws(() => cpSync(sock, dest), { code:
'ERR_FS_CP_SOCKET' }) and finally call server.close() inside that readiness
handler; target the server.listen(...) call and the
cpSync/assert.throws/server.close sequence when making this change.
In `@test/js/node/test/parallel/test-fs-cp-sync-dereference-twice.mjs`:
- Around line 1-2: The test claims to exercise cpSync with dereference: true and
force: false but the two cpSync calls (cpSync(..., { dereference: true,
recursive: true })) omit force; update both cpSync calls in
test-fs-cp-sync-dereference-twice.mjs (the two cpSync invocations that currently
pass { dereference: true, recursive: true }) to include force: false so the
option matrix actually covers the silent-fail behavior when force is false while
keeping dereference: true and recursive: true.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: b72bf81a-0968-495e-a86e-2c72f3d2a2c8
📒 Files selected for processing (205)
src/js/internal/fs/cp-sync.tssrc/js/internal/fs/cp.tssrc/js/internal/fs/glob.tssrc/js/internal/fs/watch.tssrc/js/internal/streams/iter/consumers.tssrc/js/internal/streams/iter/from.tssrc/js/internal/streams/iter/pull.tssrc/js/internal/streams/iter/transform.tssrc/js/internal/streams/iter/types.tssrc/js/internal/streams/iter/utils.tssrc/js/node/fs.promises.tssrc/js/node/fs.tssrc/js/node/stream.iter.tssrc/js/node/test.tssrc/js/node/worker_threads.tssrc/js/node/zlib.iter.tssrc/jsc/ErrorCode.rssrc/jsc/bindings/BunProcess.cppsrc/jsc/bindings/ErrorCode.tssrc/jsc/bindings/NodeDirent.cppsrc/jsc/bindings/NodeValidator.cppsrc/jsc/bindings/isBuiltinModule.cppsrc/jsc/modules/NodeModuleModule.cppsrc/jsc/modules/NodeProcessModule.hsrc/resolve_builtins/HardcodedModule.rssrc/resolve_builtins/HardcodedModule.zigsrc/runtime/node/path_watcher.rssrc/runtime/node/win_watcher.rstest/fixtures/copy/kitchen-sinktest/js/bun/bun-object/write.spec.tstest/js/node/fs/cp.test.tstest/js/node/fs/dir.test.tstest/js/node/fs/fs.test.tstest/js/node/fs/glob.test.tstest/js/node/test/common/fs.jstest/js/node/test/common/index.jstest/js/node/test/common/index.mjstest/js/node/test/common/watch.jstest/js/node/test/parallel/test-fs-append-file.jstest/js/node/test/parallel/test-fs-chown-negative-one.jstest/js/node/test/parallel/test-fs-copyfile-respect-permissions.jstest/js/node/test/parallel/test-fs-cp-async-async-filter-function.mjstest/js/node/test/parallel/test-fs-cp-async-copy-non-directory-symlink.mjstest/js/node/test/parallel/test-fs-cp-async-dereference-force-false-silent-fail.mjstest/js/node/test/parallel/test-fs-cp-async-dereference-symlink.mjstest/js/node/test/parallel/test-fs-cp-async-dest-symlink-points-to-src-error.mjstest/js/node/test/parallel/test-fs-cp-async-dir-exists-error-on-exist.mjstest/js/node/test/parallel/test-fs-cp-async-dir-to-file.mjstest/js/node/test/parallel/test-fs-cp-async-error-on-exist.mjstest/js/node/test/parallel/test-fs-cp-async-file-to-dir.mjstest/js/node/test/parallel/test-fs-cp-async-file-to-file.mjstest/js/node/test/parallel/test-fs-cp-async-file-url.mjstest/js/node/test/parallel/test-fs-cp-async-filter-child-folder.mjstest/js/node/test/parallel/test-fs-cp-async-filter-function.mjstest/js/node/test/parallel/test-fs-cp-async-identical-src-dest.mjstest/js/node/test/parallel/test-fs-cp-async-invalid-mode-range.mjstest/js/node/test/parallel/test-fs-cp-async-invalid-options-type.mjstest/js/node/test/parallel/test-fs-cp-async-nested-files-folders.mjstest/js/node/test/parallel/test-fs-cp-async-no-errors-force-false.mjstest/js/node/test/parallel/test-fs-cp-async-no-recursive.mjstest/js/node/test/parallel/test-fs-cp-async-overwrites-force-true.mjstest/js/node/test/parallel/test-fs-cp-async-preserve-timestamps-readonly-file.mjstest/js/node/test/parallel/test-fs-cp-async-preserve-timestamps.mjstest/js/node/test/parallel/test-fs-cp-async-same-dir-twice.mjstest/js/node/test/parallel/test-fs-cp-async-skip-validation-when-filtered.mjstest/js/node/test/parallel/test-fs-cp-async-socket.mjstest/js/node/test/parallel/test-fs-cp-async-subdirectory-of-self.mjstest/js/node/test/parallel/test-fs-cp-async-symlink-dest-points-to-src.mjstest/js/node/test/parallel/test-fs-cp-async-symlink-over-file.mjstest/js/node/test/parallel/test-fs-cp-async-symlink-points-to-dest.mjstest/js/node/test/parallel/test-fs-cp-async-with-mode-flags.mjstest/js/node/test/parallel/test-fs-cp-promises-async-error.mjstest/js/node/test/parallel/test-fs-cp-promises-file-url.mjstest/js/node/test/parallel/test-fs-cp-promises-invalid-mode.mjstest/js/node/test/parallel/test-fs-cp-promises-mode-flags.mjstest/js/node/test/parallel/test-fs-cp-promises-nested-folder-recursive.mjstest/js/node/test/parallel/test-fs-cp-promises-options-validation.mjstest/js/node/test/parallel/test-fs-cp-sync-apply-filter-function.mjstest/js/node/test/parallel/test-fs-cp-sync-async-filter-error.mjstest/js/node/test/parallel/test-fs-cp-sync-copy-directory-to-file-error.mjstest/js/node/test/parallel/test-fs-cp-sync-copy-directory-without-recursive-error.mjstest/js/node/test/parallel/test-fs-cp-sync-copy-file-to-directory-error.mjstest/js/node/test/parallel/test-fs-cp-sync-copy-file-to-file-path.mjstest/js/node/test/parallel/test-fs-cp-sync-copy-socket-error.mjstest/js/node/test/parallel/test-fs-cp-sync-copy-symlink-not-pointing-to-folder.mjstest/js/node/test/parallel/test-fs-cp-sync-copy-symlink-over-file-error.mjstest/js/node/test/parallel/test-fs-cp-sync-copy-symlinks-to-existing-symlinks.mjstest/js/node/test/parallel/test-fs-cp-sync-copy-to-subdirectory-error.mjstest/js/node/test/parallel/test-fs-cp-sync-dereference-directory.mjstest/js/node/test/parallel/test-fs-cp-sync-dereference-file.mjstest/js/node/test/parallel/test-fs-cp-sync-dereference-twice.mjstest/js/node/test/parallel/test-fs-cp-sync-dereference.jstest/js/node/test/parallel/test-fs-cp-sync-dest-name-prefix-match.mjstest/js/node/test/parallel/test-fs-cp-sync-dest-parent-name-prefix-match.mjstest/js/node/test/parallel/test-fs-cp-sync-directory-not-exist-error.mjstest/js/node/test/parallel/test-fs-cp-sync-error-on-exist.mjstest/js/node/test/parallel/test-fs-cp-sync-file-url.mjstest/js/node/test/parallel/test-fs-cp-sync-filename-too-long-error.mjstest/js/node/test/parallel/test-fs-cp-sync-incompatible-options-error.mjstest/js/node/test/parallel/test-fs-cp-sync-mode-flags.mjstest/js/node/test/parallel/test-fs-cp-sync-mode-invalid.mjstest/js/node/test/parallel/test-fs-cp-sync-nested-files-folders.mjstest/js/node/test/parallel/test-fs-cp-sync-no-overwrite-force-false.mjstest/js/node/test/parallel/test-fs-cp-sync-options-invalid-type-error.mjstest/js/node/test/parallel/test-fs-cp-sync-overwrite-force-true.mjstest/js/node/test/parallel/test-fs-cp-sync-parent-symlink-dest-points-to-src-error.mjstest/js/node/test/parallel/test-fs-cp-sync-preserve-timestamps-readonly.mjstest/js/node/test/parallel/test-fs-cp-sync-preserve-timestamps.mjstest/js/node/test/parallel/test-fs-cp-sync-resolve-relative-symlinks-default.mjstest/js/node/test/parallel/test-fs-cp-sync-resolve-relative-symlinks-false.mjstest/js/node/test/parallel/test-fs-cp-sync-src-dest-identical-error.mjstest/js/node/test/parallel/test-fs-cp-sync-src-parent-of-dest-error.mjstest/js/node/test/parallel/test-fs-cp-sync-symlink-dest-points-to-src-error.mjstest/js/node/test/parallel/test-fs-cp-sync-symlink-points-to-dest-error.mjstest/js/node/test/parallel/test-fs-cp-sync-unicode-dest.mjstest/js/node/test/parallel/test-fs-cp-sync-unicode-folder-names.mjstest/js/node/test/parallel/test-fs-cp-sync-verbatim-symlinks-invalid.mjstest/js/node/test/parallel/test-fs-cp-sync-verbatim-symlinks-true.mjstest/js/node/test/parallel/test-fs-fchown-negative-one.jstest/js/node/test/parallel/test-fs-fmap.jstest/js/node/test/parallel/test-fs-glob-throw.mjstest/js/node/test/parallel/test-fs-glob.mjstest/js/node/test/parallel/test-fs-internal-assertencoding.jstest/js/node/test/parallel/test-fs-lchown-negative-one.jstest/js/node/test/parallel/test-fs-long-path.jstest/js/node/test/parallel/test-fs-mkdir-recursive-eaccess.jstest/js/node/test/parallel/test-fs-mkdtempDisposableSync.jstest/js/node/test/parallel/test-fs-open.jstest/js/node/test/parallel/test-fs-opendir.jstest/js/node/test/parallel/test-fs-promises-file-handle-pull.jstest/js/node/test/parallel/test-fs-promises-file-handle-pullsync.jstest/js/node/test/parallel/test-fs-promises-file-handle-read-worker.jstest/js/node/test/parallel/test-fs-promises-file-handle-writer.jstest/js/node/test/parallel/test-fs-promises-mkdtempDisposable.jstest/js/node/test/parallel/test-fs-promises-readfile-empty.jstest/js/node/test/parallel/test-fs-promises-statfs-validate-path.jstest/js/node/test/parallel/test-fs-promises-watch-ignore-function.mjstest/js/node/test/parallel/test-fs-promises-watch-ignore-glob.mjstest/js/node/test/parallel/test-fs-promises-watch-ignore-invalid.mjstest/js/node/test/parallel/test-fs-promises-watch-ignore-mixed.mjstest/js/node/test/parallel/test-fs-promises-watch-ignore-regexp.mjstest/js/node/test/parallel/test-fs-promises-watch-iterator.jstest/js/node/test/parallel/test-fs-promises-writefile.jstest/js/node/test/parallel/test-fs-read-offset-null.jstest/js/node/test/parallel/test-fs-read-stream-encoding.jstest/js/node/test/parallel/test-fs-read-stream-err.jstest/js/node/test/parallel/test-fs-read-stream-inherit.jstest/js/node/test/parallel/test-fs-read-stream-pos.jstest/js/node/test/parallel/test-fs-read-stream-throw-type-error.jstest/js/node/test/parallel/test-fs-read-stream.jstest/js/node/test/parallel/test-fs-read-zero-length.jstest/js/node/test/parallel/test-fs-readdir-recursive.jstest/js/node/test/parallel/test-fs-readfile-eof.jstest/js/node/test/parallel/test-fs-readfile-fd.jstest/js/node/test/parallel/test-fs-readfile-pipe-large.jstest/js/node/test/parallel/test-fs-readfile-utf8-fast-path.jstest/js/node/test/parallel/test-fs-realpath.jstest/js/node/test/parallel/test-fs-rmSync-special-char.jstest/js/node/test/parallel/test-fs-rmdir-recursive-error.jstest/js/node/test/parallel/test-fs-rmdir-recursive-sync-warns-not-found.jstest/js/node/test/parallel/test-fs-rmdir-recursive-sync-warns-on-file.jstest/js/node/test/parallel/test-fs-rmdir-recursive-warns-not-found.jstest/js/node/test/parallel/test-fs-rmdir-recursive-warns-on-file.jstest/js/node/test/parallel/test-fs-rmdir-recursive.jstest/js/node/test/parallel/test-fs-rmdir-throws-not-found.jstest/js/node/test/parallel/test-fs-rmdir-throws-on-file.jstest/js/node/test/parallel/test-fs-stat-abort-test.jstest/js/node/test/parallel/test-fs-stat-bigint.jstest/js/node/test/parallel/test-fs-stat-date.mjstest/js/node/test/parallel/test-fs-stat-temporal.mjstest/js/node/test/parallel/test-fs-symlink-dir-junction.jstest/js/node/test/parallel/test-fs-watch-ignore-function.jstest/js/node/test/parallel/test-fs-watch-ignore-glob.jstest/js/node/test/parallel/test-fs-watch-ignore-invalid.jstest/js/node/test/parallel/test-fs-watch-ignore-mixed.jstest/js/node/test/parallel/test-fs-watch-ignore-recursive-glob-subdirectories.jstest/js/node/test/parallel/test-fs-watch-ignore-recursive-glob.jstest/js/node/test/parallel/test-fs-watch-ignore-recursive-mixed.jstest/js/node/test/parallel/test-fs-watch-ignore-recursive-regexp.jstest/js/node/test/parallel/test-fs-watch-ignore-regexp.jstest/js/node/test/parallel/test-fs-watch-recursive-add-file-to-existing-subfolder.jstest/js/node/test/parallel/test-fs-watch-recursive-add-file-to-new-folder.jstest/js/node/test/parallel/test-fs-watch-recursive-add-file-with-url.jstest/js/node/test/parallel/test-fs-watch-recursive-add-file.jstest/js/node/test/parallel/test-fs-watch-recursive-add-folder.jstest/js/node/test/parallel/test-fs-watch-recursive-delete.jstest/js/node/test/parallel/test-fs-watch-recursive-promise.jstest/js/node/test/parallel/test-fs-watch-recursive-symlink.jstest/js/node/test/parallel/test-fs-watch-recursive-watch-file.jstest/js/node/test/parallel/test-fs-watch-stop-async.jstest/js/node/test/parallel/test-fs-watchfile.jstest/js/node/test/parallel/test-fs-write-optional-params.jstest/js/node/test/parallel/test-fs-write-stream-change-open.jstest/js/node/test/parallel/test-fs-write-stream-eagain.mjstest/js/node/test/parallel/test-fs-write-stream-encoding.jstest/js/node/test/parallel/test-fs-write-stream-err.jstest/js/node/test/parallel/test-fs-write-stream-throw-type-error.jstest/js/node/test/parallel/test-fs-write-stream.jstest/js/node/test/parallel/test-fs-write-sync-optional-params.jstest/js/node/test/parallel/test-fs-writestream-open-write.jstest/js/node/test/parallel/test-fs-writesync-crash.jstest/js/node/test/sequential/test-fs-opendir-recursive.jstest/js/node/test/sequential/test-fs-readdir-recursive.jstest/js/node/test/sequential/test-fs-watch.jstest/js/node/watch/fs.watch.test.ts
💤 Files with no reviewable changes (7)
- test/js/node/test/parallel/test-fs-promises-writefile.js
- test/js/node/test/parallel/test-fs-rmdir-recursive-sync-warns-on-file.js
- test/js/node/test/parallel/test-fs-rmdir-recursive-warns-not-found.js
- test/js/node/test/parallel/test-fs-rmdir-recursive-sync-warns-not-found.js
- test/js/node/test/parallel/test-fs-rmdir-recursive-warns-on-file.js
- test/js/node/test/parallel/test-fs-rmdir-recursive.js
- src/jsc/bindings/NodeDirent.cpp
602f027 to
e12f031
Compare
7088bad to
2befe5f
Compare
dc2fe78 to
43f541b
Compare
43f541b to
cea21ad
Compare
4dd0c03 to
a879d95
Compare
Follow-up to #31830. That PR routed all recursive `fs.cp`/`fs.cpSync`/`fs.promises.cp` calls through the node-ported JS walker so relative-symlink rewriting and the `ERR_FS_CP_*` error codes match node, which meant recursive copies on macOS no longer used a single whole-tree `clonefile()`. This restores the fast path for the cases where it is indistinguishable from the walker. ## Summary - Recursive `fs.cp`/`cpSync`/`promises.cp` go back to the native path (one `clonefile()` of the whole tree on macOS) when: the options are the defaults already required for the existing single-file fast path (no filter/dereference/preserveTimestamps/verbatimSymlinks/mode/errorOnExist, force), the destination does not exist (so node's merge semantics never come into play), and a metadata-only readdir scan of the source tree finds nothing but regular files and directories. - Anything else still uses the ported walker: symlinks (node rewrites relative targets against the source tree), FIFOs/sockets/devices (node-specific `ERR_FS_CP_*` errors), entries whose type the filesystem does not report, an existing destination, or a scan error. Non-macOS platforms are completely unchanged — the directory branch still returns `ok: false` there, and the scan call is dead-code-eliminated. - The decision lives in `tryNativeFastPathSync`/`tryNativeFastPath` in `src/js/internal/fs/cp-sync.ts` / `cp.ts`; node's path validation (`checkPaths`, `checkParentPaths`, EISDIR) still runs before the native handoff, and the bail-out path keeps reusing the already-computed stats via `checked` exactly as before. - Adds `bench/fs-cp/cp.mjs`: recursive copy of a 256-file tree, sync and promises, with and without a symlink in the tree (the symlink variant always exercises the walker), runnable under both bun and node. ## Test plan - [x] New tests in `test/js/node/fs/cp.test.ts` for both `fs.cpSync` and `fs.promises.cp`, asserting the behaviors the scan must protect: relative in-tree symlink targets are rewritten to absolute paths resolved against the source tree (verified against node v26.3.0), file and directory modes are preserved into a fresh destination, and a FIFO inside the tree is rejected with `ERR_FS_CP_FIFO_PIPE`. On macOS these run against trees that bail out of the fast path; the existing cp suite plus the ported node tests cover the trees that take it. - [x] `bun bd test test/js/node/fs/cp.test.ts` — 46 pass, 0 fail (Linux debug build; Linux behavior is unchanged by this PR). - [x] `bench/fs-cp/cp.mjs` runs under both node 26.3.0 and the debug build. - [ ] macOS CI green (the platform where the new branch is actually taken).
…gh symlinks (#32853) ## What Fixes two cases where `Bun.Glob.scan()` silently returns an empty result for patterns that look obviously correct and work in every reference implementation (bash, picomatch, minimatch, fast-glob): ```js // 1. Explicitly-named dotfile segment new Bun.Glob(".dotdir/inner.txt").scanSync(".") // before: [] (dot:false hides an EXPLICITLY-named dotfile) // after: [".dotdir/inner.txt"] // 2. Literal path segment through a symlinked directory new Bun.Glob("linkdir/file.txt").scanSync(".") // linkdir -> realdir // before: [] (followSymlinks:false blocks even a literal path) // after: ["linkdir/file.txt"] ``` Both are silent empty results, not errors. Glob is how people select files for builds, tests, deploys and uploads, so "no matches" quietly excludes files. A project that lives behind a symlink (pnpm layouts, mounted volumes, `/tmp` on macOS) or a config that names a dotfile explicitly would process nothing. ## Why **Explicit dotfiles.** `match_pattern_dir` and `match_pattern_impl` in `GlobWalker.rs` rejected any entry whose name starts with `.` whenever `dot: false`, without looking at the pattern segment. The ecosystem convention is that the `dot` option only governs whether *wildcards* match dotfiles; a segment whose pattern text itself starts with `.` is an explicit request for that name and matches regardless: | pattern | candidate | picomatch | minimatch | fast-glob | bash | bun before | bun after | |---|---|---|---|---|---|---|---| | `.dotdir/inner.txt` | `.dotdir/inner.txt` | ✓ | ✓ | ✓ | ✓ | ✗ | ✓ | | `.*/inner.txt` | `.dotdir/inner.txt` | ✓ | ✓ | ✓ | ✓ | ✗ | ✓ | | `*/inner.txt` | `.dotdir/inner.txt` | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | | `**/inner.txt` | `.dotdir/inner.txt` | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ | **Literal path through a symlink.** The `SymLink` arm of the directory iterator only descended when `follow_symlinks` was set. But `followSymlinks` is documented (and implemented everywhere else) as a *wildcard-traversal* option: whether `*`/`**` should walk through symlinked directories. A segment that names the symlink literally is an explicit path the user wrote; fast-glob resolves it regardless of `followSymbolicLinks`: | pattern | fast-glob `followSymbolicLinks:false` | bun `followSymlinks:false` before | bun after | |---|---|---|---| | `linkdir/file.txt` | `["linkdir/file.txt"]` | `[]` | `["linkdir/file.txt"]` | | `linkdir/*.txt` | `["linkdir/file.txt"]` | `[]` | `["linkdir/file.txt"]` | | `*/file.txt` | `["realdir/file.txt"]` | `["realdir/file.txt"]` | `["realdir/file.txt"]` | | `**/file.txt` | `["realdir/file.txt"]` | `["realdir/file.txt"]` | `["realdir/file.txt"]` | ## How - `match_pattern_impl`: bypass the dot filter when the pattern component's own text starts with `.`. - `match_pattern_dir`: move the dot check after the `**`-advances-to-next-segment check so `**/.dotdir/...` can advance; `**` on its own still never descends through a hidden entry. - `SymLink` entry handling: when `follow_symlinks` is off, compute the subset of active components that are `SyntaxHint::Literal` and match the entry name; if non-empty, push the symlink work item with *only* that subset. Wildcard components stay out of the propagated set, so `*`/`**` still respect `followSymlinks:false` and cycles reached via wildcards cannot loop. ## Tests Added to `test/js/bun/glob/scan.test.ts`: positive cases for each fix plus negative cases proving wildcards still hide dotfiles, wildcards still respect `followSymlinks:false`, and a `loop -> .` symlink cycle reached via `**` under a literally-named parent does not recurse. ``` # before (USE_SYSTEM_BUN=1): 9 fail / 9 pass # after (bun bd): 18 pass # full test/js/bun/glob/scan.test.ts: 190 pass, 0 fail ``` ## Not in this PR Four other `Bun.Glob` divergences from the ecosystem surfaced alongside these, all in the pattern matcher rather than the walker: single-item `{a}` braces expanding instead of being literal, unterminated `{` and `[` not falling back to literal, and `scan()` not honouring the same backslash escaping as `match()`. Those live in `src/glob/matcher.rs` / component classification and deserve their own change. Fixes #28021 (the `Bun.Glob` scanner-level dot suppression described there; the `fs.glob` symptom was separately sidestepped by the minimatch port in #31830, but `Bun.Glob` itself still had the bug). --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
node:test's mock.fn has been implemented since #31830, so the hand-rolled call counter is no longer needed. Import mock from node:test as upstream does; the file is now byte-identical to node v26.3.0's copy, which is the point of vendoring it. fn.mock.calls.length reads the same in both runtimes. The remaining `// BUN:` note in test-worker-environmentdata.js is not stale: require('internal/worker') is still unavailable without --expose-internals.
…ility tests and fix the gaps they surface — ALS run/disable + withScope/defaultValue, http client ALS across reused agent sockets, http2 ALS context, AsyncResource.bind, EventEmitterAsyncResource, timerify (+22 tests) (#31825) Brings the async-tagged portion of the Node test suite in line with Node v26.3.0 by porting upstream tests verbatim and fixing the runtime gaps they expose across `async_hooks`, `events`, `http`, `http2`, and `perf_hooks`. | | parallel | sequential | **total** | |---|---|---|---| | **async** | 111/169 (65.7%) | 0/2 | **111/171 (64.9%)** | 22 upstream tests added (the rest of the original 56 have since landed via #31826 / #31830; coverage 32% → 65%; every vendored test passes). ### Behavior changes - **`AsyncLocalStorage.run` context corruption**: the context slot was restored by an index captured before the callback, so a `disable()` during the callback spliced the array and the restore landed on the wrong store (or resurrected a disabled one). Restore is now by identity, matching Node's `enterWith(prior)` semantics, including re-adding and re-enabling a previous value. - **`AsyncLocalStorage` v26 API surface**: the `defaultValue` and `name` constructor options, and `withScope()` returning a disposable `RunScope`. - **`AsyncResource.bind`** preserved neither the wrapped function's arity (`fn.length`) nor call-time `this` (it forced the resource as `thisArg`); both now match Node. - **`EventEmitterAsyncResource`** was missing the `asyncId`/`triggerAsyncId`/`asyncResource` getters and the `asyncResource.eventEmitter` back-reference; rewritten to Node's class shape (`EventEmitterReferencingAsyncResource`). - **http client `AsyncLocalStorage` across reused agent sockets**: the llhttp `HTTPParser` binding ignores the async-resource argument, so a keep-alive socket reused by a second request dispatched parser callbacks (and the `'response'`/`'data'`/`'end'` chain) in the first request's ALS context. `tickOnSocket` now snapshots the active async-context frame on the request and each socket listener (`data`/`end`/`error`/`close`/`drain`/`timeout`) runs inside it via `internal/async_context_frame.run` (`test-async-local-storage-http-agent`). The frame is captured in `onSocket()` as well as `tickOnSocket`: `onSocket` attaches the socket's `error` listener synchronously but `tickOnSocket` only runs a tick later, and `runInFrame` **installs** the frame it is given rather than leaving the ambient one alone — so an error arriving in that window used to run the user's `'error'` handler in the root context and lose the store, where Node keeps it via the socket's own AsyncWrap. - **http2 client streams lost the `AsyncLocalStorage` context** active at `request()` time — `Http2Stream` captures the frame at construction and native `#Handlers` are wrapped via `withStreamFrame`; the session captures its own frame so `'close'` doesn't inherit the last stream's. Both frames are released once their last read is done (stream in `_destroy`, session after the emit in `emitSessionCloseNT`) so a stream or session retained past its terminal event does not pin the store — the http1 counterpart of `closeRequest()`'s cleanup. - **`perf_hooks`**: `performance.timerify` (and the top-level `timerify` export), the `'function'` `PerformanceObserver` entry type (JS-side dispatch wrapping the WebCore observer), and `PerformanceNodeEntry`. - **`perf_hooks` export surface** now matches v26.3.0's 13 keys: `PerformanceNodeEntry` is no longer exported (Node names the class but deliberately does not export it — an earlier revision of this PR did), and the top-level `eventLoopUtilization` Node exports was missing and is added. A test pins the surface against the real v26.3.0 key list. `PerformanceNodeTiming` is knowingly left as a bun-only extra: it predates this branch and removing a public export is a breaking change that belongs in its own PR. - **`run()` on a disabled storage now restores on the way out.** The restore block was gated on `!wasDisabled`, so `als.disable(); als.run('Y', cb)` left `'Y'` installed after `run()` returned where Node yields the `defaultValue` (Node's finally is an unconditional `enterWith(prior)`). The gate dates to #7015, which guarded a `disable()` *during* the callback by reading the flag at exit; snapshotting it at entry silently widened it to skip restoration for storages disabled *before* the call. The was-absent case is already handled by the identity-relocating branches, so the gate is gone. - **`perf_hooks` option defaults are pollution-safe.** `timerify()` and `createHistogram()` defaulted options to a plain `{}`, so a polluted `Object.prototype.histogram`/`figures` made them throw where Node — which defaults both to `kEmptyObject` — succeeds. `createHistogram` additionally took `options || {}`, silently accepting `null`/`0`/`'x'`/`[]` that Node rejects with `ERR_INVALID_ARG_TYPE`; it now validates like `timerify` already did. - **`AsyncLocalStorage` store comparison is SameValue**, matching Node's primordial `ObjectIs`. Stores were compared with `===`, so `NaN` was unusable as a store value (`run(NaN)`, `enterWith(NaN)` and `{ defaultValue: NaN }` all tripped the debug restore assertions Node has no trouble with). `run()`'s unchanged-value short-circuit also read `Object.is` off the mutable global: userland patching `Object.is` made `run()` return the wrong store in **release** builds, where Node is immune. Both now use a pure-operator SameValue helper — a load-time `Object.is` capture would not fix it, since builtins load lazily and would inherit a patch applied before the first `require`. - **`validateObject`**: defer the `isArray` probe (which can throw on a Proxy) until after the cheap `null`/callable rejections, keeping the existing `RETURN_IF_EXCEPTION`. - **test/common**: vendor `common/repl.js`, export `hasQuic` from `index.mjs`, add test-runner fixtures. ### Test adaptations (commented in-file) - Stack-overflow tests use non-tail recursion — JSC has proper tail calls, so upstream's tail-recursive overflow never overflows. - `test-async-local-storage-weak-asyncwrap-leak` uses a `FinalizationRegistry` instead of `v8.queryObjects`. - webcrypto skips the unimplemented ML-KEM/`getPublicKey` methods. ### Known limitations / follow-ups - ~30 unported tests assert the `createHook` `init`/`before`/`after`/`destroy` lifecycle, which Bun intentionally does not implement; the rest need `internalBinding`/`--expose-internals`, inspector async stack traces, `--trace-events`, `vm.Module.hasAsyncGraph`, TLS 1.2 legacy session resumption, the experimental `stream/iter` module, or Node's python harness. - 3 repl async tests become portable once `repl.start()` lands (separate workstream). - The `fs.cp` and `ClientRequest.destroy(err)` work originally in this PR has landed separately via #31830 and #31587 and is dropped from this diff. ### Testing No performance regression: a release A/B (interleaved across fresh processes, same native objects, only the JS swapped) puts `AsyncLocalStorage.run()` at **9.12ns vs 9.75ns** (-6.5%; the pure-operator SameValue inlines where the `Object.is` host call did not) and `http.request()` over a keep-alive agent at **52.8µs vs 53.1µs** (-0.55%, within noise). All 111 vendored async tests pass with the debug build, plus the unit suites for every touched module (`async_hooks/`, `events/`, `perf_hooks/`, `http2/`, `http/`); remaining failures in those suites reproduce on a clean-main binary (pre-existing). --------- Co-authored-by: robobun <robobun@oven.sh> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Alistair Smith <hi@alistair.sh> Co-authored-by: robobun <117481402+robobun@users.noreply.github.com>
Brings
node:fscompatibility in line with Node v26.3.0 by porting upstream tests verbatim and fixing the gaps they expose.119 verbatim upstream tests added, 37 refreshed to their v26.3.0 content, 5 retired (the
rmdir-recursive*tests Node deleted at DEP0147 end-of-life). Excluding the--expose-internalstests that cannot run by design, coverage is 331/333 (99.4%).Behavior changes
fs.cp/cpSync/promises.cperror semantics: node's full validation layer (validateCpOptions,getValidMode) and SystemError-shapedERR_FS_CP_*codes (EINVAL,EEXIST,DIR_TO_NON_DIR,NON_DIR_TO_DIR,SOCKET,FIFO_PIPE,SYMLINK_TO_SUBDIRECTORY,UNKNOWN,ERR_FS_EISDIR), v26 message wording,errorOnExiston existing directories, and the directory-gated symlink-subdir checks. The native clonefile fast path is now used only for plain regular-file→file copies; symlinks, directories, and special files route through the node-ported walker so relative symlink targets are resolved the way node resolves them. The callback form validates synchronously and callscallback(null)on success.fs.watchevent delivery: the per-handler duplicate suppression inpath_watcher.rs/win_watcher.rssuppressed different files' same-type events within the same millisecond (the hash was only consulted when the event type differed), so two files written back-to-back delivered only one event. It now suppresses exact duplicates only — node delivers both.fs.watch/fs.promises.watchignoreoption (string glob with matchBase, RegExp, function, or array) with node'sERR_INVALID_ARG_TYPE/ERR_INVALID_ARG_VALUEvalidation, and AbortSignal support on the promises async iterator (ABORT_ERRwithcause).FileHandle.prototype.pull/pullSync/writeron top of main'snode:stream/iter(--experimental-stream-iter-gated; the vendored tests carry the flag in their// Flags:header and re-spawn through it).fs.glob/globSync/promises.globreplaced with a faithful port of node'slib/internal/fs/glob.jsovernode:fsreaddir/lstat, with node'sdeps/minimatchvendored verbatim — fixes extglobs,.//..pattern segments, trailing-slash directory semantics, brace+**interaction,withFileTypes, exclude-function semantics, and error propagation from a throwing callback (dispatched once viaprocess.nextTick). The header documents why this isn't backed byBun.Globyet (328/448 oftest-fs-glob.mjsfail with theBun.Glob.scan-backed version on main — withFileTypes Dirents, exclude-array rules, symlink walking, dotfile rules); to be swapped once those gaps close natively.fs.opendir/Dir: eagerENOTDIR/ENOENTat open,bufferSizeand encoding validation,ERR_INVALID_THISbrand check on thepathgetter, promise-formclose(), node's operation queue (ERR_DIR_CONCURRENT_OPERATIONfor sync ops with async reads in flight), and the async iterator auto-closes on early exit. Entry iteration is index-based (noArray#shift()).fs.mkdtempDisposableSync/fs.promises.mkdtempDisposable(new node API): returns{ path, remove, [Symbol.dispose/asyncDispose] };remove()is idempotent viaforceand resolves the path eagerly soprocess.chdir()cannot redirect removal.fs.rmdir/rmdirSync/promises.rmdirwithrecursivedefined now throwERR_INVALID_ARG_VALUEwith node's verbatim "is no longer supported" message (DEP0147 end-of-life in v26; usefs.rm). Bun's own tests are migrated torm/rmSync, and the five legacytest-fs-rmdir-recursive*vendored tests (removed upstream in nodejs/node@eec03020880) are replaced by the upstreamtest-fs-rmdir-recursive-error.js.fs.rmSyncreports node'sERR_FS_EISDIR(withinfo/path/syscall) for non-recursive directory removal, including non-ASCII paths the native path mishandled.writeSyncaccepts the options-object form ({offset, length, position}), validates buffer types with node'sERR_INVALID_ARG_TYPE, and replicates node's error-context assignment contract so accessors installed onObject.prototypeobserve the error instead of crashing the process.fs.statrejects withAbortErrorwhen called with an already-aborted signal.FileHandletransfer toworker_threads:kTransfer/kTransferList/kDeserializeper node's protocol, withDataCloneErrorwhen the handle is in use; the JS Worker wrapper packs/unpacks JSTransferables since bun's structured clone has no native hook.FSWatcher._handlewhitebox surface (anFSEventhandle delegating to the native watcher; replacing it trips node's exactERR_INTERNAL_ASSERTION).node:test'smock.fn/mock.method/mock.getter/mock.setterbacked by a node-shapedMockFunctionContext(port oflib/internal/test_runner/mock/mock.js):methodNamevalidated as string|symbol,mockDescriptor.configurablemirrors the original descriptor, and call records are pushed after invocation (node-matching reentrancy semantics).common.isInsideDirWithUnusualCharsandcommon/fs.jsadded to the vendored test harness;ERR_OPERATION_FAILEDadded to the error-code registry (with the checked-inErrorCode.rsdiscriminants regenerated).Code style
Every
src/js/file ported fromnodejs/nodecarries agithub.com/nodejs/node/blob/<v26.3.0-sha>/...permalink to its source, and the vendored minimatch block is marked third-party (ISC license). Allsrc/js/additions in this PR use hoisted named functions (or class private methods +.bind), no inline arrow closures.Known limitations / follow-ups
FileHandletransfer is wired only into the Worker constructor (workerData+transferList) and the worker-sideworkerDatareception;worker.postMessage({fh}, [fh])/parentPort.postMessage/ arbitraryMessagePort.postMessagestill hit native structured clone with no FileHandle hook. Better solved natively in a follow-up.mock.fn(MyClass)does not preserve the wrapped class's prototype/statics (node's#setupMockreturns a Proxy withconstruct/apply/gettraps; this PR's plain-function wrapper covers function/method mocking but not class-constructor mocking). Tracked for a Proxy-based follow-up.--expose-internals/internal/test/binding.test-fs-write.jsneeds V8 externalizable strings andtest-fs-promises.jsasserts V8-styleat asyncstack frames — both effectively out of reach on JSC.fs.cpno longer uses clonefile on macOS (node-correct relative-symlink rewriting requires the walker); plain file→file copies keep the native path. Worth revisiting with a symlink-free fast-path detection if the perf matters.test/js/node/fs/fs.test.tsintermittently panicsDeadlock detectedin nativeAsyncReaddirRecursiveTask::perform_work(reproduces byte-for-byte on an unmodified baseline binary), and thereaddirSync recursive x 100tests time out under the debug build.Testing
Validated locally with the release build before pushing:
test-fs-glob.mjs448/448; full vendored fs sweep (test-fs-*parallel + sequential, runner-equivalent semantics) passes.fs/fs.test.ts265/0,fs/cp.test.ts43/0,fs/dir.test.ts20/0,fs/glob.test.ts27/0,node/watch/,worker.test.ts24/0,module/node-module-module.test.js30/0 — 0 failures.