fs: port Node.js fs compatibility tests and fix the gaps they surface — cp error semantics, watcher event delivery, watch ignore+AbortSignal, stream/iter + FileHandle pull/writer, glob port, opendir/Dir, mkdtempDisposable, rmdir-recursive removal (+122 tests)#31830
fs: port Node.js fs compatibility tests and fix the gaps they surface — cp error semantics, watcher event delivery, watch ignore+AbortSignal, stream/iter + FileHandle pull/writer, glob port, opendir/Dir, mkdtempDisposable, rmdir-recursive removal (+122 tests)#31830cirospaciari wants to merge 33 commits into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (16)
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
✨ Finishing Touches⚔️ Resolve merge conflicts
|
|
Updated 3:10 AM PT - Jun 10th, 2026
❌ @robobun, your commit 867aeb4 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
…watch - glob: sort directory entries before traversal. The ported algorithm's seen-cache and "**/.." queueing are sensitive to the order entries are visited in, and some orders silently drop results (reproducible in node itself by feeding it the same order). Sorting makes traversal deterministic across filesystems; fixes a/!(symlink)/**/../* dropping a/c on some CI machines - lchown: stop returning a TODO error on Windows; route through uv_fs_lchown, which is a no-op success there like node - watch: when a recursive watcher attaches to a newly created directory, scan it and synthesize rename events for entries that were created before the inotify watch existed, like node's recursive watcher - replace the test/fixtures/copy/kitchen-sink symlink with real files; git checks symlinks out as plain text files on Windows CI, which made readdir fail with ENOTDIR - cp.test.ts: normalize separators in the filter assertion (cp resolves src so Windows sees backslashes) and expect ERR_FS_CP_EINVAL when copying a file onto itself (node behavior; the regression guarded against is EBUSY) - builtinModules count 76 -> 78 (stream/iter and zlib/iter are new) - permission test: add mkdtempDisposableSync to the supported list, matching upstream
node:test mocks: - implement options.times (revert to the original after N calls) for mock.fn/method/getter/setter, with node's validation - mock.getter/setter: shift an options-object out of the implementation slot before spreading the getter/setter flag, so the documented (object, methodName, options) overload works; reject getter/setter: false like node - mock.method: reject options.getter together with options.setter - restoreAll()/reset() now also restores bare mock.fn() mocks to their original function (previously only method mocks were tracked) - exclude null from the implementation->options argument shifts fs: - rmdir/rmdirSync/promises.rmdir reject any *defined* recursive option (node uses !== undefined, not truthiness) and fs.rmdir validates its callback again - promises.watch validates options.signal before creating the watcher, and removes its abort listener on every iterator exit path - FileHandle.writer: a failed async write/writev poisons the writer (like fail()) since the cursor/limit were advanced optimistically - Dir async iterator closes via the queued close() instead of closeSync(), avoiding ERR_DIR_CONCURRENT_OPERATION on early exit - opendir's synthesized stat-failure message no longer doubles the error code prefix - cp/cp-sync: the EISDIR fast-path errors now carry errno EISDIR (was EINVAL), matching node stream/iter: - zlib transforms (async variants) now drive node:zlib Transform streams incrementally with backpressure instead of buffering all input and running a one-shot codec at flush time; sync variants still buffer (a synchronous incremental write needs the native zlib handle, which builtins don't have) - drop two no-op type-guard branches left from the primordials port tests: cover the mock fixes; await two fire-and-forget .rejects assertions in fs.test.ts
The sync variants no longer buffer the whole input and run a one-shot codec at flush time. node:zlib's native handle already exposes the same synchronous chunk API node's binding has (writeSync + the shared Uint32Array write state), so drive it directly: construct the regular node:zlib stream object for its validated, initialized handle, then feed input chunk-by-chunk with the codec's process flag and yield output as the engine produces it, finalizing with the finish flag on the null flush signal. This is a direct port of node's makeZlibTransformSync loop, including the no-copy buffer handoff rules. Memory now stays bounded for the sync pipelines as well, and output is emitted mid-stream instead of only at the end. Empty-input semantics are unchanged: compressors still emit a valid empty container, decompressors emit nothing rather than erroring on an unfinished stream.
…pullSync lifecycle - cp/cpSync/promises.cp validate paths like node's getValidatedPath: URL instances convert via fileURLToPath, strings and Buffers pass through unresolved (no path.resolve, no 'file:'-prefix string sniffing), null bytes rejected. Filters now see the caller's relative paths like node; Buffer paths get past validation and behave like node's JS cp implementation downstream - watch ignore matchers coerce Buffer filenames (encoding: 'buffer') to strings instead of crashing in basename()/Glob.match() - pullSync acquires its ref when iteration starts (matching pull()) so an unconsumed iterable can't hang close(), and cleanup is idempotent per iterator so double iteration can't double-unref - writevAll retries the writev on zero-byte writes instead of falling into the concat path with a never-tripping retry counter - Dir.read(callback) returns undefined like node (and close(cb)) - Windows Syscall::lchown in bun_sys delegates to sys_uv::lchown like chown/fchown instead of a second hardcoded no-op
cp no longer resolves its arguments (matching node's getValidatedPath), so the .path on ERR_FS_EISDIR / ERR_FS_CP_EEXIST is the caller's string verbatim - on Windows that's the mixed-separator concatenation the test built, not the join()-normalized form. Verified against node's JS cp implementation, which echoes the unnormalized caller path the same way. Same conclusion and fix as the cp tests on the node-v26-async-tests branch.
- FileHandle.writer() defers its handle ref to the first actual write (like pull/pullSync defer it to iteration), so an unused writer can't pin the handle and hang close(); end()/fail()/cleanup only unref if the ref was taken - fs.opendir's eager path check (ENOTDIR/ENOENT at open time) now runs on an async stat in the callback and promises forms, so the JS thread isn't blocked and the callback never fires synchronously; argument validation still throws synchronously like node, and opendirSync keeps the sync check - worker_threads: if a transferList item's transfer or the WebWorker construction throws after FileHandles were already neutered, restore their fds so the handles stay usable instead of orphaning the fds - node:test mocks: validateBoolean for options.getter/options.setter and node's onCall validation in mockImplementationOnce (integer, no earlier than the next call) - cp: thread the stats from the native fast-path probe into the ported fallback so the top-level checkPaths/checkParentPaths syscalls don't run twice for recursive directory copies with default options tests: worker transfer restore (sync construct failure and partial transfer), async opendir callback timing and ENOTDIR-via-callback, unused writer + close
…ta references
Structured clone keeps a transfer marker shared between graph positions
as one object, but the unpack pass deserialized it once per encounter,
so workerData = { a: fh, b: fh } produced two FileHandle instances
wrapping the same fd - closing one left the other pointing at a closed
(or recycled) descriptor. Replace the cycle-detection Set with a memo
Map so each marker deserializes exactly once, like node's host-object
back-references; containers map to themselves for cycle handling.
…nced ones - transferList: [fh, fh] now throws DataCloneError like node (and the HTML spec) instead of neutering the handle twice, where the second kTransfer() read the already-cleared fd and clobbered the real marker; the rollback restores the handle so it stays usable after the throw - a handle in transferList but never referenced from workerData is detached like node, but since no marker will deserialize it on the worker side, close the orphaned fd instead of leaking it - fs.opendir's async path invokes the user callback via process.nextTick, matching the rest of the file (a throwing callback is an uncaughtException, not an unhandledRejection)
…succeeds The orphan-fd cleanup (for handles in transferList but not referenced from workerData) ran inside packJSTransferables, before the WebWorker is constructed. If construction then threw (e.g. non-cloneable workerData), the rollback re-installed the already-closed fd number into the user's FileHandle - EBADF on the lucky path, fd-reuse corruption or a double close on the unlucky one. Node fully restores the handle in this case (verified: fd intact and readable after the DataCloneError), so defer the orphan close to a finalize step that the Worker constructor invokes only after the WebWorker is successfully created; the rollback then always finds the fd still open.
Structured clone walks Map and Set entries (keys included), so a transferred handle inside one is reachable on the receive side - but the pack/unpack walkers only recursed into arrays and plain objects. A FileHandle inside a Map was neutered by the transfer loop, never replaced by its marker, treated as unreferenced (fd closed), and the worker got a dead clone. Walk Map and Set in both directions like the serializer does, with the same memo/cycle handling. Also: use the $isArray intrinsic in the two walkers that still used Array.isArray (consistent with the rest of the block), and drain the zlib transform pending queues by array swap instead of repeated shift() (O(N) instead of O(N^2) for high-ratio decompression bursts).
- writer()/pull()/pullSync() capture the fd when created, and since the deferred-ref change an unused source no longer blocks close() - so a source used after close() would issue I/O on a closed (or recycled) descriptor. All entry points now check handle's fd first and throw or reject ERR_INVALID_STATE (async write/writev reject, sync variants and iteration throw) - fs.rm and fs.promises.rm now run the same JS-side lstat -> ERR_FS_EISDIR validation rmSync got, so all three forms report node's error shape for non-recursive directory removal (the callback form routes through promises.rm)
…n tests Three .rejects.toMatchObject assertions were fire-and-forget, so a non-rejecting promise would have surfaced as a late unhandled rejection instead of a test failure (and the rm one raced the cleanup that follows). Same fix as the earlier fs.test.ts instance of this pattern.
- pull's pre-aborted-signal source is now an explicit single-shot
rejecting iterator instead of a generator that never yields
- drop a no-op ("use strict") expression and the unused
validateFunction import left in the vendored-minimatch wrapper
- use endsWith instead of a dollar-anchored regex in the vendored brace
expansion (verified identical ${...}-literal semantics against node)
…ck leak - pipeToSync treated writeSync/writevSync returning false as a successful write; the FileHandle writer returns false to mean 'fall back to async write()', which a sync pipe doesn't have - so oversized chunks (or a swallowed first-write failure) were silently counted and dropped. A refused writevSync now falls back to per-chunk writes (the batch total can exceed the sync threshold even when each chunk fits), and a refused single chunk throws instead of lying about the byte count - fh.pull() with an already-aborted signal returns a rejecting iterator without locking the handle: with transforms, the pipeline's pre-abort branch never consumes the source, so the unlock in its finally never ran and the handle stayed locked forever (autoClose is still honored on first next, matching the old no-transform behavior) - worker test helper uses harness tmpdirSync instead of a local mkdtempSync wrapper
…in onLink - the withFileTypes exclude path in #addSubpattern stat'ed the relative path: the stat cache is keyed by absolute paths so the lookup always missed, and the fallback lstat resolved against process.cwd() instead of options.cwd - returning null and silently skipping the user's exclude callback whenever cwd differed. Use the fullpath like the sibling lookups two lines up (upstream node passes the relative path here too). Regression test proves the old code skipped the callback - cp onLink stat'ed src twice on the dest-exists path; hoist the single stat and reuse it for both directory-gated checks (a dangling src symlink still surfaces ENOENT, as before)
…essage - FileHandle[kUnref] routed the deferred close through this.close(), which short-circuits on the already-cleared kFd and resolved the pending close() promise without ever closing the descriptor - every close()-while-an-op-is-in-flight leaked the fd. Close the captured fd directly. Regression test proves the old path leaked - the Dir constructor rejects non-object/string options with ERR_INVALID_ARG_TYPE like node (found while verifying opendir option handling against node: fsPromises.opendir(dir, 42) now rejects instead of resolving) - pipeToSync's refusal error uses a generic message: writevSync also returns false for an exhausted limit or a failed first write, where 'increase chunkSize' was misleading - the worker orphan-fd test accepts EBADF or a recycled descriptor (another thread can reuse the number before the assertion runs)
…tring options
- fail() and the async end()->cleanup() path released the writer's ref
(and with autoClose, closed the handle) without checking asyncPending,
so a write still running on the threadpool could hit a closed or
recycled fd - endSync() already guarded this. Both paths now defer the
unref/close to the write's finally via a teardown hook; end() resolves
with the full byte count once the pending write lands. Regression test
covers fail()-during-write (write completes, no EBADF, handle closed
after) and end()-during-write
- the Dir constructor normalizes a string options argument to
{ encoding } like node's getOptions, so opendirSync(path, 'latin1')
actually applies the encoding and an invalid shorthand throws
ERR_INVALID_ARG_VALUE (encoding: 'buffer' dirents remain a
pre-existing native readdir gap, present in releases)
- asyncPending was a boolean, so with two unawaited write() calls the first one to finish ran the deferred teardown (unref + autoClose) while the second was still on the threadpool against the closed fd - the exact race the teardown hook exists to prevent, reintroduced for the concurrent case. It's a counter now; teardown runs when the last in-flight write lands. Regression test: fail() with two pending writes, both complete, no EBADF - FSWatcher.ref()/unref() go through this._handle (honouring a replaced handle) and return this for chaining, like node; the FSEvent ref/unref delegates are live again
On Windows the native readdir always emits UTF-8 names (fs.readdirSync ignores the encoding option there as well - a pre-existing gap), so the latin1 byte-reinterpretation half of the opendir string-shorthand test is POSIX-only now; the invalid-shorthand validation half runs everywhere.
…alidation dedup - ERR_FS_CP_EEXIST and ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY join the ErrorCode registry (appended, so the committed ErrorCode.rs mirror is updated append-only) and their helpers construct through the $ERR_* intrinsics like the six sibling cp error codes - rmSync and fs.promises.rm build their ERR_FS_EISDIR through cp-sync's fsEisdirError, so the error is a SystemError with node's exact message/info shape instead of a bare Error with a hand-assigned code - the JSTransferable array walkers skip holes so sparse workerData arrays stay sparse across the clone like node; documented why the transfer marker is an in-band string key (not a privilege boundary) - validateCpOptions brands its normalized result so the callback fs.cp -> promises.cp delegation doesn't re-validate the same object
… options
- restoreAll() no longer disassociates mocks from the tracker - only
reset() clears the registry, like node ('Unlike mock.reset(),
mock.restoreAll() does not disassociate the mocks')
- restore() now mirrors node exactly (verified by probing): a method
mock reinstalls the original descriptor but the context keeps its
implementation, a bare fn mock reverts to the original, queued
once-implementations survive, and restore() is re-runnable so a
still-tracked context can be restored again by a later reset()
- mock.method validates a non-object options argument with
ERR_INVALID_ARG_TYPE like mock.fn already did
tests mirror the probed node outputs (restoreAll/reset association
lifecycle, surviving once-implementations, options validation)
…ia registry - trackMockCall pushed the call record before invoking, so a reentrant implementation saw callCount() === N+1 where node reports N, recursive calls recorded in start order, and the stack was captured pre-invoke. The record is pushed in a finally after the implementation runs, like node's proxy handlers (verified against node: callCount() inside the impl is 0). Test added - ERR_DIR_CONCURRENT_OPERATION joins the ErrorCode registry (appended; ErrorCode.rs mirror updated append-only) and Dir's helper constructs through the $ERR_* intrinsic like the sibling ERR_DIR_CLOSED; the observable shape (name 'Error', same code/message) matches node
e82d207 to
f5d20e0
Compare
Brings node:fs compatibility in line with Node by porting upstream v26.3.0 tests verbatim and fixing the gaps they expose.
122 verbatim upstream tests added, 39 refreshed to their v26.3.0 content; the full vendored fs suite (332 files) passes 332/332. Excluding the 13
--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).stream/iter(require("stream/iter")/node:stream/iter) ported from node, plusFileHandle.prototype.pull/pullSync/writerandzlib/iter; registered as builtins alongsidestream/promises.fs.glob/globSync/promises.globreplaced with a faithful port of node'slib/internal/fs/glob.jsovernode:fsreaddir/lstat, with node's minimatch vendored inline — 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).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.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.rmdirwithrecursive: truenow throwsERR_INVALID_ARG_VALUElike node (the option was removed upstream in v16; usefs.rm). Bun's own tests are migrated torm/rmSync, and the seven legacytest-fs-rmdir-recursive*vendored tests (removed upstream) are retired.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), andnode:test'smock.fn/mock.methodnamespace backed by a node-shapedMockFunctionContext.common.isInsideDirWithUnusualChars,common/fs.js, and a--experimental-stream-iterflag branch added to the vendored test harness;ERR_OPERATION_FAILEDadded to the error-code registry (with the checked-inErrorCode.rsdiscriminants regenerated).Known limitations / follow-ups
--expose-internals/internal/test/binding; a minimal shim could unlock roughly five of them.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.zlib/itertransforms buffer input and run the one-shot codec at the flush signal instead of driving the raw zlib binding incrementally (not exposed to builtins); observable round-trip behavior is identical.push(),duplex(),broadcast/share,merge(), and classic-stream interop fromstream/iterare not ported (nothing vendored reaches them). Zstd not ported.test/js/node/fs/fs.test.tsintermittently panicsDeadlock detectedin nativeAsyncReaddirRecursiveTask::perform_workat module scope (reproduces byte-for-byte on an unmodified baseline binary), and thereaddirSync recursive x 100tests time out under the debug build. The recursive-readdir family needs its own investigation.Testing
Validated locally with the debug build before pushing:
test-fs-*parallel + sequential, runner-equivalent semantics with per-testTEST_SERIAL_IDisolation): 332/332 pass.fs/cp.test.ts,fs/dir.test.ts,fs/glob.test.ts(corrected 4 expectations against a real node v26.3.0 oracle),node/watch/,bun-object/write.spec.ts,worker_threads/— 0 failures (one pre-existing worker eval-leak failure reproduced on baseline).