Skip to content

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

Merged
cirospaciari merged 1 commit into
mainfrom
ciro/port-node-fs-tests
Jun 19, 2026

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jun 4, 2026

Copy link
Copy Markdown
Member

Brings node:fs compatibility in line with Node v26.3.0 by porting upstream tests verbatim and fixing the gaps they expose.

parallel sequential total
fs 327/342 (95.6%) 4/4 331/346 (95.7%)

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-internals tests that cannot run by design, coverage is 331/333 (99.4%).

Behavior changes

  • fs.cp/cpSync/promises.cp error semantics: node's full validation layer (validateCpOptions, getValidMode) and SystemError-shaped ERR_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, errorOnExist on 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 calls callback(null) on success.
  • fs.watch event delivery: the per-handler duplicate suppression in path_watcher.rs/win_watcher.rs suppressed 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.watch ignore option (string glob with matchBase, RegExp, function, or array) with node's ERR_INVALID_ARG_TYPE/ERR_INVALID_ARG_VALUE validation, and AbortSignal support on the promises async iterator (ABORT_ERR with cause).
  • FileHandle.prototype.pull/pullSync/writer on top of main's node: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.glob replaced with a faithful port of node's lib/internal/fs/glob.js over node:fs readdir/lstat, with node's deps/minimatch vendored verbatim — fixes extglobs, .//.. pattern segments, trailing-slash directory semantics, brace+** interaction, withFileTypes, exclude-function semantics, and error propagation from a throwing callback (dispatched once via process.nextTick). The header documents why this isn't backed by Bun.Glob yet (328/448 of test-fs-glob.mjs fail with the Bun.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: eager ENOTDIR/ENOENT at open, bufferSize and encoding validation, ERR_INVALID_THIS brand check on the path getter, promise-form close(), node's operation queue (ERR_DIR_CONCURRENT_OPERATION for sync ops with async reads in flight), and the async iterator auto-closes on early exit. Entry iteration is index-based (no Array#shift()).
  • fs.mkdtempDisposableSync / fs.promises.mkdtempDisposable (new node API): returns { path, remove, [Symbol.dispose/asyncDispose] }; remove() is idempotent via force and resolves the path eagerly so process.chdir() cannot redirect removal.
  • fs.rmdir/rmdirSync/promises.rmdir with recursive defined now throw ERR_INVALID_ARG_VALUE with node's verbatim "is no longer supported" message (DEP0147 end-of-life in v26; use fs.rm). Bun's own tests are migrated to rm/rmSync, and the five legacy test-fs-rmdir-recursive* vendored tests (removed upstream in nodejs/node@eec03020880) are replaced by the upstream test-fs-rmdir-recursive-error.js.
  • fs.rmSync reports node's ERR_FS_EISDIR (with info/path/syscall) for non-recursive directory removal, including non-ASCII paths the native path mishandled.
  • writeSync accepts the options-object form ({offset, length, position}), validates buffer types with node's ERR_INVALID_ARG_TYPE, and replicates node's error-context assignment contract so accessors installed on Object.prototype observe the error instead of crashing the process.
  • fs.stat rejects with AbortError when called with an already-aborted signal.
  • FileHandle transfer to worker_threads: kTransfer/kTransferList/kDeserialize per node's protocol, with DataCloneError when the handle is in use; the JS Worker wrapper packs/unpacks JSTransferables since bun's structured clone has no native hook.
  • FSWatcher._handle whitebox surface (an FSEvent handle delegating to the native watcher; replacing it trips node's exact ERR_INTERNAL_ASSERTION).
  • node:test's mock.fn/mock.method/mock.getter/mock.setter backed by a node-shaped MockFunctionContext (port of lib/internal/test_runner/mock/mock.js): methodName validated as string|symbol, mockDescriptor.configurable mirrors the original descriptor, and call records are pushed after invocation (node-matching reentrancy semantics).
  • common.isInsideDirWithUnusualChars and common/fs.js added to the vendored test harness; ERR_OPERATION_FAILED added to the error-code registry (with the checked-in ErrorCode.rs discriminants regenerated).

Code style

Every src/js/ file ported from nodejs/node carries a github.com/nodejs/node/blob/<v26.3.0-sha>/... permalink to its source, and the vendored minimatch block is marked third-party (ISC license). All src/js/ additions in this PR use hoisted named functions (or class private methods + .bind), no inline arrow closures.

Known limitations / follow-ups

  • FileHandle transfer is wired only into the Worker constructor (workerData + transferList) and the worker-side workerData reception; worker.postMessage({fh}, [fh]) / parentPort.postMessage / arbitrary MessagePort.postMessage still 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 #setupMock returns a Proxy with construct/apply/get traps; this PR's plain-function wrapper covers function/method mocking but not class-constructor mocking). Tracked for a Proxy-based follow-up.
  • The remaining unvendored parallel tests need --expose-internals/internal/test/binding. test-fs-write.js needs V8 externalizable strings and test-fs-promises.js asserts V8-style at async stack frames — both effectively out of reach on JSC.
  • Recursive fs.cp no 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.
  • Pre-existing and unrelated: test/js/node/fs/fs.test.ts intermittently panics Deadlock detected in native AsyncReaddirRecursiveTask::perform_work (reproduces byte-for-byte on an unmodified baseline binary), and the readdirSync recursive x 100 tests time out under the debug build.

Testing

Validated locally with the release build before pushing:

  • test-fs-glob.mjs 448/448; full vendored fs sweep (test-fs-* parallel + sequential, runner-equivalent semantics) passes.
  • Bun regression gates: fs/fs.test.ts 265/0, fs/cp.test.ts 43/0, fs/dir.test.ts 20/0, fs/glob.test.ts 27/0, node/watch/, worker.test.ts 24/0, module/node-module-module.test.js 30/0 — 0 failures.
  • Watcher fix verified with a two-file write repro (previously only the first event arrived) and the full 40-test watch subset.

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

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

Changes

Core implementation and test coverage

Layer / File(s) Summary
All implementation, wiring, and tests
src/js/internal/fs/*, src/js/internal/streams/iter/*, src/js/node/*, src/jsc/*, src/runtime/node/*, src/resolve_builtins/*, src/sys/*, tests/**/*, fixtures
Node-compatible cp/watch, iterable streams, Node shims/runtime updates, builtin/module additions, and broad test suite updates/additions.

Suggested reviewers

  • Jarred-Sumner
  • alii
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title comprehensively summarizes the main change: porting Node.js v26.3.0 fs tests, fixing gaps with proper cp error semantics, watcher behavior, watch ignore/AbortSignal, FileHandle APIs, glob port, opendir/Dir, mkdtempDisposable, rmdir-recursive removal, and mock.fn addition.
Description check ✅ Passed The PR description comprehensively covers what is being done, how it was verified, and includes detailed behavioral changes, testing validation, and known limitations.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@robobun

robobun commented Jun 4, 2026

Copy link
Copy Markdown
Collaborator
Updated 6:44 PM PT - Jun 18th, 2026

@cirospaciari, your commit ce686d2 has 1 failures in Build #63416 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31830

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

bun-31830 --bun

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Found 13 issues this PR may fix:

  1. fs.rmSync returns EFAULT instead of EISDIR when removing a directory without recursive #28958 - PR fixes fs.rmSync to report ERR_FS_EISDIR instead of EFAULT for non-recursive directory removal
  2. fs.glob differs from node and incorrectly follows symlinks #29699 - PR replaces fs.glob with a faithful port of Node's glob.js, which does not follow symlinks
  3. fs.glob never matches dot files, even with explicit dot patterns #28021 - PR replaces the Bun.Glob.scan()-backed fs.glob with Node's implementation, which handles dot patterns correctly
  4. fs.glob does not support options.withFileTypes #22018 - PR explicitly adds withFileTypes support in the new glob implementation
  5. Bun's Glob.scan and fs.glob do not detect unix domain sockets #17506 - PR replaces fs.glob with Node's readdir-based implementation, which finds all entry types including sockets (note: Bun.Glob itself is not fixed)
  6. globSync(‘{*/*}’) always returns empty array #24000 - PR fixes brace expansion and ** interaction in the new glob implementation
  7. Bun's globbing with nonexistent cwd throws an error in contrast to Node.js #22628 - PR's Node-faithful glob.js silently yields no results for nonexistent cwd, matching Node behavior
  8. node:fs.watch: Watch sends change event twice #21646 - PR fixes duplicate event suppression to hash event type AND filename, preventing spurious duplicate change events
  9. fs.Dir behaves differently in bun compared to node when using async iteration #28894 - PR adds fs.Dir async iterator auto-close on early exit, matching Node behavior
  10. opendirSync successfully returns for a non-existent directory #17581 - PR adds eager ENOTDIR/ENOENT validation at opendirSync open time
  11. node:fs/promises missing mkdtempDisposable export (Bun 1.3.13) — blocks production CLI startup #31400 - PR implements mkdtempDisposable / mkdtempDisposableSync in node:fs and node:fs/promises
  12. fs.mkdtempDisposable() not found in module node:fs/promises #24499 - PR implements fs.mkdtempDisposable() (same fix as node:fs/promises missing mkdtempDisposable export (Bun 1.3.13) — blocks production CLI startup #31400)
  13. Support node:test mock #24255 - PR implements node:test mock.fn / mock.method with MockFunctionContext

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #28958
Fixes #29699
Fixes #28021
Fixes #22018
Fixes #17506
Fixes #24000
Fixes #22628
Fixes #21646
Fixes #28894
Fixes #17581
Fixes #31400
Fixes #24499
Fixes #24255

🤖 Generated with Claude Code

Comment thread src/js/node/fs.promises.ts Outdated
Comment thread src/js/internal/streams/iter/utils.ts Outdated
Comment thread src/js/node/fs.ts
Comment thread src/js/node/fs.promises.ts
Comment thread src/js/node/fs.ts Outdated
@cirospaciari cirospaciari force-pushed the ciro/port-node-fs-tests branch from 36e1de4 to d333262 Compare June 5, 2026 01:04
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/internal/streams/iter/consumers.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between ef89527 and d333262.

📒 Files selected for processing (205)
  • src/js/internal/fs/cp-sync.ts
  • src/js/internal/fs/cp.ts
  • src/js/internal/fs/glob.ts
  • src/js/internal/fs/watch.ts
  • src/js/internal/streams/iter/consumers.ts
  • src/js/internal/streams/iter/from.ts
  • src/js/internal/streams/iter/pull.ts
  • src/js/internal/streams/iter/transform.ts
  • src/js/internal/streams/iter/types.ts
  • src/js/internal/streams/iter/utils.ts
  • src/js/node/fs.promises.ts
  • src/js/node/fs.ts
  • src/js/node/stream.iter.ts
  • src/js/node/test.ts
  • src/js/node/worker_threads.ts
  • src/js/node/zlib.iter.ts
  • src/jsc/ErrorCode.rs
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/ErrorCode.ts
  • src/jsc/bindings/NodeDirent.cpp
  • src/jsc/bindings/NodeValidator.cpp
  • src/jsc/bindings/isBuiltinModule.cpp
  • src/jsc/modules/NodeModuleModule.cpp
  • src/jsc/modules/NodeProcessModule.h
  • src/resolve_builtins/HardcodedModule.rs
  • src/resolve_builtins/HardcodedModule.zig
  • src/runtime/node/path_watcher.rs
  • src/runtime/node/win_watcher.rs
  • test/fixtures/copy/kitchen-sink
  • test/js/bun/bun-object/write.spec.ts
  • test/js/node/fs/cp.test.ts
  • test/js/node/fs/dir.test.ts
  • test/js/node/fs/fs.test.ts
  • test/js/node/fs/glob.test.ts
  • test/js/node/test/common/fs.js
  • test/js/node/test/common/index.js
  • test/js/node/test/common/index.mjs
  • test/js/node/test/common/watch.js
  • test/js/node/test/parallel/test-fs-append-file.js
  • test/js/node/test/parallel/test-fs-chown-negative-one.js
  • test/js/node/test/parallel/test-fs-copyfile-respect-permissions.js
  • test/js/node/test/parallel/test-fs-cp-async-async-filter-function.mjs
  • test/js/node/test/parallel/test-fs-cp-async-copy-non-directory-symlink.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dereference-force-false-silent-fail.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dereference-symlink.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dest-symlink-points-to-src-error.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dir-exists-error-on-exist.mjs
  • test/js/node/test/parallel/test-fs-cp-async-dir-to-file.mjs
  • test/js/node/test/parallel/test-fs-cp-async-error-on-exist.mjs
  • test/js/node/test/parallel/test-fs-cp-async-file-to-dir.mjs
  • test/js/node/test/parallel/test-fs-cp-async-file-to-file.mjs
  • test/js/node/test/parallel/test-fs-cp-async-file-url.mjs
  • test/js/node/test/parallel/test-fs-cp-async-filter-child-folder.mjs
  • test/js/node/test/parallel/test-fs-cp-async-filter-function.mjs
  • test/js/node/test/parallel/test-fs-cp-async-identical-src-dest.mjs
  • test/js/node/test/parallel/test-fs-cp-async-invalid-mode-range.mjs
  • test/js/node/test/parallel/test-fs-cp-async-invalid-options-type.mjs
  • test/js/node/test/parallel/test-fs-cp-async-nested-files-folders.mjs
  • test/js/node/test/parallel/test-fs-cp-async-no-errors-force-false.mjs
  • test/js/node/test/parallel/test-fs-cp-async-no-recursive.mjs
  • test/js/node/test/parallel/test-fs-cp-async-overwrites-force-true.mjs
  • test/js/node/test/parallel/test-fs-cp-async-preserve-timestamps-readonly-file.mjs
  • test/js/node/test/parallel/test-fs-cp-async-preserve-timestamps.mjs
  • test/js/node/test/parallel/test-fs-cp-async-same-dir-twice.mjs
  • test/js/node/test/parallel/test-fs-cp-async-skip-validation-when-filtered.mjs
  • test/js/node/test/parallel/test-fs-cp-async-socket.mjs
  • test/js/node/test/parallel/test-fs-cp-async-subdirectory-of-self.mjs
  • test/js/node/test/parallel/test-fs-cp-async-symlink-dest-points-to-src.mjs
  • test/js/node/test/parallel/test-fs-cp-async-symlink-over-file.mjs
  • test/js/node/test/parallel/test-fs-cp-async-symlink-points-to-dest.mjs
  • test/js/node/test/parallel/test-fs-cp-async-with-mode-flags.mjs
  • test/js/node/test/parallel/test-fs-cp-promises-async-error.mjs
  • test/js/node/test/parallel/test-fs-cp-promises-file-url.mjs
  • test/js/node/test/parallel/test-fs-cp-promises-invalid-mode.mjs
  • test/js/node/test/parallel/test-fs-cp-promises-mode-flags.mjs
  • test/js/node/test/parallel/test-fs-cp-promises-nested-folder-recursive.mjs
  • test/js/node/test/parallel/test-fs-cp-promises-options-validation.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-apply-filter-function.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-async-filter-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-copy-directory-to-file-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-copy-directory-without-recursive-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-copy-file-to-directory-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-copy-file-to-file-path.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-copy-socket-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-copy-symlink-not-pointing-to-folder.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-copy-symlink-over-file-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-copy-symlinks-to-existing-symlinks.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-copy-to-subdirectory-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-dereference-directory.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-dereference-file.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-dereference-twice.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-dereference.js
  • test/js/node/test/parallel/test-fs-cp-sync-dest-name-prefix-match.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-dest-parent-name-prefix-match.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-directory-not-exist-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-error-on-exist.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-file-url.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-filename-too-long-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-incompatible-options-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-mode-flags.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-mode-invalid.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-nested-files-folders.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-no-overwrite-force-false.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-options-invalid-type-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-overwrite-force-true.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-parent-symlink-dest-points-to-src-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-preserve-timestamps-readonly.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-preserve-timestamps.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-resolve-relative-symlinks-default.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-resolve-relative-symlinks-false.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-src-dest-identical-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-src-parent-of-dest-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-symlink-dest-points-to-src-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-symlink-points-to-dest-error.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-unicode-dest.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-unicode-folder-names.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-verbatim-symlinks-invalid.mjs
  • test/js/node/test/parallel/test-fs-cp-sync-verbatim-symlinks-true.mjs
  • test/js/node/test/parallel/test-fs-fchown-negative-one.js
  • test/js/node/test/parallel/test-fs-fmap.js
  • test/js/node/test/parallel/test-fs-glob-throw.mjs
  • test/js/node/test/parallel/test-fs-glob.mjs
  • test/js/node/test/parallel/test-fs-internal-assertencoding.js
  • test/js/node/test/parallel/test-fs-lchown-negative-one.js
  • test/js/node/test/parallel/test-fs-long-path.js
  • test/js/node/test/parallel/test-fs-mkdir-recursive-eaccess.js
  • test/js/node/test/parallel/test-fs-mkdtempDisposableSync.js
  • test/js/node/test/parallel/test-fs-open.js
  • test/js/node/test/parallel/test-fs-opendir.js
  • test/js/node/test/parallel/test-fs-promises-file-handle-pull.js
  • test/js/node/test/parallel/test-fs-promises-file-handle-pullsync.js
  • test/js/node/test/parallel/test-fs-promises-file-handle-read-worker.js
  • test/js/node/test/parallel/test-fs-promises-file-handle-writer.js
  • test/js/node/test/parallel/test-fs-promises-mkdtempDisposable.js
  • test/js/node/test/parallel/test-fs-promises-readfile-empty.js
  • test/js/node/test/parallel/test-fs-promises-statfs-validate-path.js
  • test/js/node/test/parallel/test-fs-promises-watch-ignore-function.mjs
  • test/js/node/test/parallel/test-fs-promises-watch-ignore-glob.mjs
  • test/js/node/test/parallel/test-fs-promises-watch-ignore-invalid.mjs
  • test/js/node/test/parallel/test-fs-promises-watch-ignore-mixed.mjs
  • test/js/node/test/parallel/test-fs-promises-watch-ignore-regexp.mjs
  • test/js/node/test/parallel/test-fs-promises-watch-iterator.js
  • test/js/node/test/parallel/test-fs-promises-writefile.js
  • test/js/node/test/parallel/test-fs-read-offset-null.js
  • test/js/node/test/parallel/test-fs-read-stream-encoding.js
  • test/js/node/test/parallel/test-fs-read-stream-err.js
  • test/js/node/test/parallel/test-fs-read-stream-inherit.js
  • test/js/node/test/parallel/test-fs-read-stream-pos.js
  • test/js/node/test/parallel/test-fs-read-stream-throw-type-error.js
  • test/js/node/test/parallel/test-fs-read-stream.js
  • test/js/node/test/parallel/test-fs-read-zero-length.js
  • test/js/node/test/parallel/test-fs-readdir-recursive.js
  • test/js/node/test/parallel/test-fs-readfile-eof.js
  • test/js/node/test/parallel/test-fs-readfile-fd.js
  • test/js/node/test/parallel/test-fs-readfile-pipe-large.js
  • test/js/node/test/parallel/test-fs-readfile-utf8-fast-path.js
  • test/js/node/test/parallel/test-fs-realpath.js
  • test/js/node/test/parallel/test-fs-rmSync-special-char.js
  • test/js/node/test/parallel/test-fs-rmdir-recursive-error.js
  • test/js/node/test/parallel/test-fs-rmdir-recursive-sync-warns-not-found.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-warns-on-file.js
  • test/js/node/test/parallel/test-fs-rmdir-recursive.js
  • test/js/node/test/parallel/test-fs-rmdir-throws-not-found.js
  • test/js/node/test/parallel/test-fs-rmdir-throws-on-file.js
  • test/js/node/test/parallel/test-fs-stat-abort-test.js
  • test/js/node/test/parallel/test-fs-stat-bigint.js
  • test/js/node/test/parallel/test-fs-stat-date.mjs
  • test/js/node/test/parallel/test-fs-stat-temporal.mjs
  • test/js/node/test/parallel/test-fs-symlink-dir-junction.js
  • test/js/node/test/parallel/test-fs-watch-ignore-function.js
  • test/js/node/test/parallel/test-fs-watch-ignore-glob.js
  • test/js/node/test/parallel/test-fs-watch-ignore-invalid.js
  • test/js/node/test/parallel/test-fs-watch-ignore-mixed.js
  • test/js/node/test/parallel/test-fs-watch-ignore-recursive-glob-subdirectories.js
  • test/js/node/test/parallel/test-fs-watch-ignore-recursive-glob.js
  • test/js/node/test/parallel/test-fs-watch-ignore-recursive-mixed.js
  • test/js/node/test/parallel/test-fs-watch-ignore-recursive-regexp.js
  • test/js/node/test/parallel/test-fs-watch-ignore-regexp.js
  • test/js/node/test/parallel/test-fs-watch-recursive-add-file-to-existing-subfolder.js
  • test/js/node/test/parallel/test-fs-watch-recursive-add-file-to-new-folder.js
  • test/js/node/test/parallel/test-fs-watch-recursive-add-file-with-url.js
  • test/js/node/test/parallel/test-fs-watch-recursive-add-file.js
  • test/js/node/test/parallel/test-fs-watch-recursive-add-folder.js
  • test/js/node/test/parallel/test-fs-watch-recursive-delete.js
  • test/js/node/test/parallel/test-fs-watch-recursive-promise.js
  • test/js/node/test/parallel/test-fs-watch-recursive-symlink.js
  • test/js/node/test/parallel/test-fs-watch-recursive-watch-file.js
  • test/js/node/test/parallel/test-fs-watch-stop-async.js
  • test/js/node/test/parallel/test-fs-watchfile.js
  • test/js/node/test/parallel/test-fs-write-optional-params.js
  • test/js/node/test/parallel/test-fs-write-stream-change-open.js
  • test/js/node/test/parallel/test-fs-write-stream-eagain.mjs
  • test/js/node/test/parallel/test-fs-write-stream-encoding.js
  • test/js/node/test/parallel/test-fs-write-stream-err.js
  • test/js/node/test/parallel/test-fs-write-stream-throw-type-error.js
  • test/js/node/test/parallel/test-fs-write-stream.js
  • test/js/node/test/parallel/test-fs-write-sync-optional-params.js
  • test/js/node/test/parallel/test-fs-writestream-open-write.js
  • test/js/node/test/parallel/test-fs-writesync-crash.js
  • test/js/node/test/sequential/test-fs-opendir-recursive.js
  • test/js/node/test/sequential/test-fs-readdir-recursive.js
  • test/js/node/test/sequential/test-fs-watch.js
  • test/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

Comment thread src/js/internal/fs/cp-sync.ts
Comment thread src/js/internal/fs/watch.ts Outdated
Comment thread src/js/internal/streams/iter/transform.ts Outdated
Comment thread src/js/node/fs.promises.ts
Comment thread src/js/node/fs.promises.ts
Comment thread src/js/node/test.ts Outdated
Comment thread test/js/node/fs/fs.test.ts Outdated
Comment thread test/js/node/test/parallel/test-fs-cp-sync-copy-socket-error.mjs
Comment thread test/js/node/test/parallel/test-fs-cp-sync-dereference-twice.mjs
Comment thread src/js/node/test.ts
Comment thread src/js/node/fs.ts Outdated
Comment thread src/js/node/fs.promises.ts Outdated
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/internal/fs/watch.ts
Comment thread src/js/node/fs.promises.ts
Comment thread test/js/node/test/common/fs.js
Comment thread src/js/node/fs.promises.ts
Comment thread src/js/node/fs.ts Outdated
Comment thread src/sys/sys_uv.rs
Comment thread src/js/node/fs.ts
@cirospaciari cirospaciari force-pushed the ciro/port-node-fs-tests branch from 7088bad to 2befe5f Compare June 5, 2026 23:11
Comment thread src/js/node/fs.promises.ts Outdated
Comment thread src/js/node/fs.ts Outdated
Comment thread src/js/node/test.ts
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/fs.ts Outdated
@cirospaciari cirospaciari force-pushed the ciro/port-node-fs-tests branch from dc2fe78 to 43f541b Compare June 6, 2026 01:36
Comment thread src/js/node/worker_threads.ts Outdated
@cirospaciari cirospaciari force-pushed the ciro/port-node-fs-tests branch from 43f541b to cea21ad Compare June 6, 2026 02:49
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/node/fs.ts
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts
Comment thread src/js/internal/streams/iter/transform.ts Outdated
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/fs.promises.ts
Comment thread src/js/node/fs.ts
Comment thread test/js/node/fs/promises.test.js Outdated
@cirospaciari cirospaciari force-pushed the ciro/port-node-fs-tests branch from 4dd0c03 to a879d95 Compare June 6, 2026 20:05
Comment thread src/js/internal/streams/iter/pull.ts
Comment thread src/js/node/fs.promises.ts
Comment thread test/js/node/worker_threads/worker_threads.test.ts Outdated
Comment thread src/js/node/fs.ts
Comment thread src/js/internal/fs/cp.ts Outdated
Comment thread src/js/node/test.ts
Comment thread src/js/node/fs.promises.ts
@cirospaciari cirospaciari force-pushed the ciro/port-node-fs-tests branch from 513c6ac to 57c5e57 Compare June 18, 2026 19:51
Comment thread src/js/internal/fs/glob.ts Outdated
Comment thread src/js/node/fs.promises.ts
Comment thread src/js/node/fs.promises.ts
Comment thread test/js/node/module/node-module-module.test.js Outdated
Comment thread src/js/node/test.ts
@cirospaciari cirospaciari force-pushed the ciro/port-node-fs-tests branch from 446e2a3 to a796572 Compare June 18, 2026 21:26
Comment thread src/js/node/test.ts
Comment thread src/js/node/test.ts Outdated
Comment thread src/js/node/fs.ts
@cirospaciari cirospaciari force-pushed the ciro/port-node-fs-tests branch from 5c4606c to 364c7a3 Compare June 18, 2026 23:00
Comment thread src/js/node/fs.ts Outdated
@cirospaciari cirospaciari changed the title 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) 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) Jun 18, 2026
Comment thread src/jsc/ErrorCode.rs
@cirospaciari cirospaciari force-pushed the ciro/port-node-fs-tests branch from 1b390b2 to ee93eca Compare June 19, 2026 00:05
Comment thread src/js/node/worker_threads.ts Outdated
Comment thread src/js/node/worker_threads.ts
@cirospaciari cirospaciari force-pushed the ciro/port-node-fs-tests branch from ee93eca to 3880fbb Compare June 19, 2026 00:45
Brings node:fs compatibility in line with Node v26.3.0 by porting
upstream tests verbatim and fixing the gaps they expose: 119 tests
added, 37 refreshed, 5 retired (rmdir-recursive end-of-life).

- fs.cp/cpSync/promises.cp: node's full validation layer and
  SystemError-shaped ERR_FS_CP_* codes; clonefile fast path only for
  plain file→file copies; symlinks/directories route through the
  node-ported walker.
- fs.watch event delivery: per-handler duplicate suppression in
  path_watcher.rs/win_watcher.rs now suppresses exact duplicates
  only (it dropped different files' same-type events within the
  same millisecond).
- fs.watch/fs.promises.watch ignore option (string glob with
  matchBase, RegExp, function, or array) and AbortSignal on the
  promises async iterator.
- FileHandle.prototype.pull/pullSync/writer on top of main's
  --experimental-stream-iter-gated node:stream/iter.
- fs.glob/globSync/promises.glob: faithful port of node's
  lib/internal/fs/glob.js with deps/minimatch vendored verbatim
  (header documents why Bun.Glob.scan can't back it yet — 328/448
  of test-fs-glob.mjs fail with the Bun.Glob version on main).
- fs.opendir/Dir: eager ENOTDIR/ENOENT at open, bufferSize and
  encoding validation, ERR_INVALID_THIS brand check, promise-form
  close(), node's operation queue (ERR_DIR_CONCURRENT_OPERATION),
  index-based entry iteration, and the async iterator auto-closes
  on early exit.
- fs.mkdtempDisposableSync / fs.promises.mkdtempDisposable.
- fs.rmdir/rmdirSync/promises.rmdir with recursive defined throw
  ERR_INVALID_ARG_VALUE with node's verbatim "is no longer
  supported" message (DEP0147 end-of-life).
- fs.rmSync reports node's ERR_FS_EISDIR for non-recursive directory
  removal; writeSync accepts the options-object form; fs.stat
  rejects on a pre-aborted signal.
- FileHandle transfer to worker_threads via kTransfer/kTransferList/
  kDeserialize; the JS Worker wrapper packs/unpacks JSTransferables.
- FSWatcher._handle whitebox surface; node:test mock.fn/mock.method/
  getter/setter backed by a node-shaped MockFunctionContext (port of
  lib/internal/test_runner/mock/mock.js).
- ErrorCode.rs: ERR_FS_CP_EEXIST, ERR_FS_CP_SYMLINK_TO_SUBDIRECTORY,
  ERR_DIR_CONCURRENT_OPERATION (constants, CODE_STR, and ERR_-prefixed
  aliases); ERR_OPERATION_FAILED registered.

Every src/js file ported from nodejs/node carries a
github.com/nodejs/node/blob/<v26.3.0-sha>/... permalink; the vendored
minimatch block is marked third-party (ISC license). All src/js
additions use hoisted named functions or class private methods
(.bind for captured state) — no inline arrow closures.

Follow-ups (noted in the PR body): mock.fn(MyClass) needs node's
Proxy-based #setupMock to preserve prototype/statics; macOS recursive
fs.cp no longer uses clonefile (a symlink-free fast-path detection
could restore it).

Verified: test-fs-glob.mjs 448/448, fs.test.ts 265/0, cp.test.ts 43/0,
dir.test.ts 20/0, glob.test.ts 27/0, node-module-module.test.js 30/0,
worker.test.ts 24/0, watch-ignore-glob 4/4, FileHandle pull/writer/
pullSync, opendir.
@cirospaciari cirospaciari force-pushed the ciro/port-node-fs-tests branch from 3880fbb to ce686d2 Compare June 19, 2026 00:50
Comment thread src/js/node/fs.promises.ts
Comment thread src/js/node/fs.ts
@cirospaciari cirospaciari merged commit dae2e87 into main Jun 19, 2026
75 of 78 checks passed
@cirospaciari cirospaciari deleted the ciro/port-node-fs-tests branch June 19, 2026 01:46
Comment on lines +1296 to +1306
endSync() {
if (error) return -1;
if (closed) return totalBytesWritten;
if (asyncPending) return -1;
closed = true;
handle[kLocked] = false;
releaseRef();
if (autoClose) {
handle[kCloseSync]();
}
return totalBytesWritten;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Extension of the open comment at line 1326 (#3439735887): the root cause — [kCloseSync]() (lines 1345-1354) does not check kRefs — also affects endSync() (line 1304) and pullSync()'s cleanup (line 879), which that comment's proposed fix doesn't cover. Both call handle[kCloseSync]() guarded only by the writer/puller's local asyncPending counter, which doesn't reflect refs taken by direct fh.read()/fh.write()/fh.stat() (those call this[kRef]() but never check kLocked) — so const w = fh.writer({autoClose:true}); w.writeSync(buf); const p = fh.read(buf2); w.endSync() does closeSync(fd) while the threadpool read is in flight, no race window needed. #3439735887's claim that "endSync() guards exactly this case" is incorrect for direct-fh-op refs, and its one-line fix (use handle.close() in fail()'s teardown) leaves endSync()/pullSync cleanup unfixed since they're sync-by-contract; the cleaner fix is at [kCloseSync]() itself: if (this[kRefs] > 1) fall back to fire-and-forget this.close(), covering all three sites at once.

Extended reasoning...

What the bug is

FileHandle[kCloseSync]() (fs.promises.ts:1345-1354) checks only kFd === -1 and kClosePromise before calling closeSync(fd) — it never consults this[kRefs]. By contrast, the async close() (line 668) does if (--this[kRefs] === 0) and defers if other refs are outstanding. Three call sites in the new iter API call kCloseSync() with autoClose: true: endSync() (line 1304), fail()'s teardown (line 1325 — already covered by open comment #3439735887), and pullSync()'s per-iterator cleanup() (line 879). All three guard only on a closure-local counter (asyncPending for the writer, cleanedUp for pullSync), which tracks the writer/puller's own in-flight async work — not the handle-wide kRefs taken by direct FileHandle operations.

Why the existing guards don't cover direct fh ops

Direct FileHandle methods — fh.read(), fh.readv(), fh.write(), fh.stat(), fh.truncate(), etc. (fs.promises.ts:480-655) — all do this[kRef]() → dispatch to the threadpool → finally { this[kUnref]() }. They check only throwEBADFIfNecessary(fd); they do not check this[kLocked] (grep confirms only pull() at :729, pullSync() at :832, and writer() at :935 check kLocked). So a direct fh.read() can run while a writer holds the lock, bumping kRefs without touching the writer's asyncPending counter. The open comment #3439735887 says "endSync() guards exactly this case at line 1267 (if (asyncPending) return -1)" — that's incorrect for this trigger: asyncPending is the writer's per-instance counter of its own in-flight writeAll/writevAll calls, not the handle's refcount.

Step-by-step proof

const fh = await open(path, 'r+');           // kRefs=1, kFd=5
const w = fh.writer({ autoClose: true });     // kLocked=true, refAcquired=false
w.writeSync(Buffer.from('hi'));               // acquireRef → kRefs 1→2; asyncPending stays 0 (sync write)
const p = fh.read(Buffer.alloc(10));          // NO kLocked check; kRef → kRefs 2→3; read(5,…) on threadpool
w.endSync();                                  // asyncPending===0 ✓ → releaseRef (kUnref: kRefs 3→2, not 0)
                                              //   → autoClose → handle[kCloseSync](): closeSync(5), kFd=-1
await p;  // ← EBADF, or silently reads from whatever was opened on fd 5 since
  1. writeSync calls acquireRef() (line 1203) → handle[kRef]()kRefs 1→2. The synchronous write completes; asyncPending is never touched.
  2. fh.read(buf) at line ~516: throwEBADFIfNecessary(5) passes; this[kRef]()kRefs 2→3. await read(5, buf, …) dispatches to the threadpool. kLocked is never checked.
  3. w.endSync() at line 1296: error null, closed false, asyncPending === 0 (the writer has no async write in flight). Sets closed=true, kLocked=false, releaseRef()kUnref--kRefs 3→2 (not 0, so no-op). autoClosehandle[kCloseSync]().
  4. [kCloseSync]() at line 1345: kFd !== -1, kClosePromise null → closeSync(5), kFd = -1. No kRefs check.
  5. The threadpool worker is mid-pread(5, …). Either EBADF, or — if fd 5 has been recycled — silent read from an unrelated file.

Unlike #3439735887's cross-writer trigger (which needs a second writer created in the kLocked=false-to-deferred-teardown window), this trigger works while kLocked is still true and needs no race window — direct fh ops simply don't honor the lock. The same trace applies to pullSync()'s cleanup (line 879): for (const _ of fh.pullSync({autoClose:true})) {} after a pending fh.write() does kCloseSync() with the write's ref still outstanding.

Why #3439735887's proposed fix is insufficient

That comment proposes "use handle.close() instead of handle[kCloseSync]() in fail()'s teardown, matching cleanup()". close() is kRefs-aware, so that's correct for fail(). But endSync() and pullSync()'s cleanup() are synchronous by contractendSync() returns a number, and pullSync()'s iterator cleanup runs inside next()/return() which return plain {value, done} objects. They cannot await close(). A PR author who applies #3439735887's one-line fix would leave both sync sites with the identical hole.

Impact and fix

Same severity class as the ~7 sibling fd-safety issues already fixed in this writer during review (#3368432710, #3368545528, #3368578523, #3366740312, etc.): EBADF (lucky) or silent fd-reuse corruption (worst case for I/O). The trigger requires opt-in autoClose: true plus mixing direct fh.read()/fh.write() with a locked iter source/sink — semantically unusual since autoClose says "the writer owns the handle's lifetime", but the API permits it (direct ops don't check kLocked by design, matching Node). Experimental API (--experimental-stream-iter-gated). Worth flagging primarily so the fix for #3439735887 isn't applied at fail() only.

Cleanest fix — at [kCloseSync]() itself, covering all three call sites at once:

[kCloseSync]() {
  if (this[kFd] === -1) return;
  if (this[kClosePromise]) throw $ERR_INVALID_STATE('The FileHandle is closing');
  if (this[kRefs] > 1) {
    // Other operations hold a ref — defer to the kRefs-aware close() so the
    // fd isn't pulled out from under an in-flight threadpool read/write.
    void this.close();
    return;
  }
  const fd = this[kFd];
  this[kFd] = -1;
  (nodeFsForIter ??= require('node:fs')).closeSync(fd);
  this.emit('close');
}

close() decrements kRefs and, if still > 0, parks on kCloseResolve until the last kUnref fires — exactly the deferral the sync callers can't express themselves. Fire-and-forget is fine here: endSync()/pullSync cleanup don't surface the close result anyway, and the user observes fh.fd === -1 only after the deferred close actually runs (matching the autoClose contract).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants