node:fs: pin the directory fd at opendir time - #35928
Conversation
fs.opendir/opendirSync previously returned a path-bound Dir: no file descriptor was opened until the first read(), which re-opened the path each time. Node opens the directory eagerly (uv_fs_opendir) and reads entries through that fd, so the handle pins the inode. The observable differences: - renaming or swapping the target between opendir and read made Bun iterate the new object at that path; Node iterates the original - removing the directory after opendir made Bun's read() throw ENOENT (syscall 'scandir'); Node returns end-of-stream - open-time errors (EACCES, EMFILE) surfaced at the first read() as scandir errors instead of at opendir time Add native opendir (openat O_DIRECTORY / NtCreateFile iterable) and an fd-based readdir so Dir holds a real descriptor and iterates through it. Also treat ENOENT from getdents64/__getdirentries64 on an unlinked but still-open directory as EOF (POSIX requirement; glibc readdir does the same, which is what node inherits).
|
Warning Review limit reached
Next review available in: 16 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (10)
Comment |
|
Updated 11:27 AM PT - Jul 26th, 2026
❌ @robobun, your commit b0657d2 has 2 failures in
Add 🧪 To try this PR locally: bunx bun-pr 35928That installs a local version of the PR into your bun-35928 --bun |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Heads up from #35931: with the constructor now doing |
The binary-size baseline is build #79916 (ae4b17d, 2026-07-25). Since then 12 commits landed on main including node:quic (#32602), node:repl (#31827), node:inspector (#31823) and the tls overhaul (#34598); other PRs branched from current main see the same ~550KB delta (e.g. build 82225). This PR adds two small node:fs ops.
|
CI status on b0657d2 (build 82484):
The diff is green; ready for review. |
There was a problem hiding this comment.
All six rounds of prior feedback have been addressed and this pass found nothing new, but I'd still like a human to sign off — this changes Dir from path-bound to owning a real fd, adds two native ops wired through the task-tag dispatch table, and touches the shared dir_iterator.rs ENOENT handling on Linux/macOS. The Windows open_dir_at_windows_a path is unverified locally per the PR description.
Checked this pass: readdir_with_entries does not close the caller-supplied fd (so fdreaddir correctly leaves ownership with Dir); the Windows iterator's RestartScan=TRUE on each fdreaddir is fine because the JS side caches #entries after one call; the ENOENT→EOF mapping in dir_iterator.rs applies to all iterator callers but matches glibc readdir() and the pre-existing FreeBSD branch.
Extended reasoning...
Overview
Reworks fs.opendir/fs.opendirSync from a path-bound stat check to Node's fd-bound model: a new native opendir op (openat(O_DIRECTORY|O_RDONLY|O_CLOEXEC) on POSIX, NtCreateFile iterable on Windows) returns a real fd stored on Dir, and a new internal fdreaddir op reads entries through it. Touches ten files across the event-loop task table (ConcurrentTask.rs, dispatch.rs), the fs binding surface (node_fs.rs, node_fs_binding.rs, node.classes.ts), the shared directory iterator (dir_iterator.rs), the syscall tag table (sys/lib.rs), the built-in JS Dir class (fs.ts, fs.promises.ts), and adds five new tests in dir.test.ts.
Security risks
None identified. The change narrows a TOCTOU window (path swap between opendir and read()) rather than opening one. O_CLOEXEC was added in an earlier round; the new fd is read-only and O_DIRECTORY-gated. No user-controlled data reaches size/index arithmetic.
Level of scrutiny
High. This is the commit where Dir gains ownership of a real OS resource, so every lifecycle exit (close/closeSync, dispose/asyncDispose, async-iterator finally, GC via FinalizationRegistry) had to be audited — and was, across six prior review rounds that landed the registry, the #owned guard, null-before-close ordering, and the > 2 guard removal. The dir_iterator.rs ENOENT→EOF change is a shared-helper edit that affects every directory iteration in the runtime, not just fs.Dir. The Windows branch calls open_dir_at_windows_a and relies on fs.closeSync accepting the resulting HANDLE; the author notes this was deferred to CI.
Other factors
The PR has been iterated extensively in response to review; all prior inline threads are resolved with fix commits, CI on 816de4e was green on every dir.test.ts lane, and the new tests cover the rename-swap TOCTOU, rm-after-open EOF, fd census, and GC-warning paths (the GC test was moved out of the !isPosix gate in b0657d2 so it now runs on Windows too). The remaining reason to defer is scope, not open concerns: cross-platform fd lifecycle changes in node:fs warrant a maintainer's eye even when clean.
What
fs.opendir()/fs.opendirSync()now open a real directory descriptor and iterate entries through it, matching Node'suv_fs_opendir/uv_fs_readdirmodel.Why
Previously the
Dirhandle was path-bound:opendironly did astatcheck, and the firstread()re-opened the path withreaddirSync. Consequences verified against Node v26.3.0:opendirandread()made Bun iterate the new object; Node iterates the original.read()threwENOENTwithsyscall: 'scandir'; Node returns end-of-stream.opendirSyncon an unreadable directory or atRLIMIT_NOFILEsucceeded, deferringEACCES/EMFILEto the firstread()as ascandirerror instead of theopendirerror Node throws.Dirhandles pinned 0 fds in Bun, +40 in Node.How
opendirop (openat(O_DIRECTORY|O_RDONLY)on POSIX,NtCreateFileiterable on Windows) returns the fd withsyscall: "opendir"on failure.fdreaddirop reads entries from an already-open fd without closing it.Dirstores the real fd, reads throughfdreaddir, and closes it onclose()/closeSync(). Recursive iteration still goes by path (subdirectories must be opened individually), but the root is opened eagerly so open-time errors surface.FinalizationRegistrycloses the fd and emits Node'sClosing directory handle on garbage collectionwarning when aDiris collected withoutclose(), so the newly-held descriptor can't leak (mirrorsFileHandle).ENOENTfromgetdents64/__getdirentries64on an unlinked but still-open directory as EOF. POSIX requires this and glibc'sreaddir()does the same, which is where Node gets its behavior. The FreeBSD iterator already handled it.Verification
test/js/node/fs/dir.test.ts(28 pass),test-fs-opendir.js, andtest/js/node/fs/fs.test.tspass on Linux.no test proof · iteration 2 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/fs/dir.test.ts