Skip to content

node:fs: skip symlink cycles in recursive readdir instead of failing with ELOOP - #33432

Closed
robobun wants to merge 2 commits into
mainfrom
farm/4da0443c/readdir-recursive-eloop
Closed

robobun wants to merge 2 commits into
mainfrom
farm/4da0443c/readdir-recursive-eloop

Conversation

@robobun

@robobun robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator

Every recursive node:fs listing API throws ELOOP and returns nothing if the tree contains a symlink cycle anywhere: readdirSync(root, { recursive: true }) (paths and withFileTypes), fs.promises.readdir({ recursive: true }), and recursive opendirSync(). A single ln -s . x or a broken package link in node_modules is enough to break the whole listing.

Repro

import fs from "node:fs";
import os from "node:os";

const root = fs.mkdtempSync(os.tmpdir() + "/rd-");
fs.mkdirSync(`${root}/d1/d2`, { recursive: true });
fs.writeFileSync(`${root}/d1/f`, "x");
fs.symlinkSync("missing", `${root}/dangling`); // ENOENT: tolerated by both
fs.symlinkSync("lpB", `${root}/lpA`);          // ELOOP: only bun aborts
fs.symlinkSync("lpA", `${root}/lpB`);

console.log(fs.readdirSync(root, { recursive: true }).length);
node v26.3.0: 6
bun 1.4.0   : ELOOP: too many symbolic links encountered, scandir '/tmp/rd-XXXXXX'

Cause

The recursive walker opens every symlink and directory entry with openat(O_DIRECTORY | O_RDONLY) and treats that open as the "is this a traversable directory" probe. Its tolerated-errno filter only listed ENOENT | ENOTDIR | EPERM, so a dangling symlink (ENOENT) and a symlink to a file (ENOTDIR) were skipped, but a symlink cycle (ELOOP) propagated as the error for the entire call. Node's recursive walkers ignore per-entry stat failures and return the complete listing.

Fix

Add ELOOP to the skipped set in both copies of the walker, readdir_with_entries_recursive_async and readdir_with_entries_recursive_sync in src/runtime/node/node_fs.rs. A real directory can never produce ELOOP, so this only affects unresolvable symlink entries. fs.promises.readdir and recursive fs.opendir both route through readdir, so all five variants are covered by the one change. EACCES still propagates, matching Node (which throws when it tries to read a directory it lacks permission for).

All five variants now return the same entries as Node, in the same count, and a symlink to a real directory is still walked into.

Tests

test/js/node/fs/fs.test.ts gains recursive readdir skips symlink cycles instead of failing with ELOOP. The fixture has a two-link cycle, a self-referential symlink and a dangling symlink, and the test asserts the full listing for all six recursive entry points: readdirSync (paths and dirents), promises.readdir (paths and dirents), opendirSync and promises.opendir. Every listing matches node v26.3.0 entry for entry. A non-vacuous guard first asserts the fixture's cycle really does open with ELOOP.

A second test, recursive readdir still descends into a symlink to a real directory, is the negative contract: widening the skipped-errno set must not skip entries the walker can open.

Three existing tests used a self-referential symlink purely as a way to make a subdirectory open fail, which is exactly what this change stops doing. They now use an unreadable directory (EACCES), dropping to an unprivileged uid first since root bypasses the permission check, and keep asserting the invariants they were written for:

  • readdirSync({encoding: 'buffer', recursive: true}) frees entries safely when a subdir fails to open
  • promises.readdir({recursive: true}) settles when multiple subtasks fail
  • readdirSync-recursive-error-leak-fixture.js (Dirent.path leak on the sync error path)
Verification
$ USE_SYSTEM_BUN=1 bun test test/js/node/fs/fs.test.ts -t "skips symlink cycles"
(fail) recursive readdir skips symlink cycles instead of failing with ELOOP
ELOOP: too many symbolic links encountered, scandir '/tmp/readdir-symlink-cycle_QseqFu'

$ bun bd test test/js/node/fs/fs.test.ts
 381 pass, 6 skip, 0 fail

$ bun bd test test/js/node/fs/readdirSync-recursive-error-leak.test.ts
 1 pass, 0 fail

$ bun bd test test/js/node/fs/dir.test.ts test/js/node/fs/glob.test.ts
 50 pass, 0 fail

cargo check -p bun_runtime is clean for x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc and aarch64-apple-darwin.

@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:06 AM PT - Jul 6th, 2026

@robobun, your commit b4c14f7 is building: #68879

@github-actions github-actions Bot added the claude label Jul 6, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:fs: match Node's recursive readdir symlink and cycle handling #31418 - Also adds ELOOP tolerance to recursive readdir in node_fs.rs and additionally implements Node's full symlink descent semantics; a superset of this PR's changes that would conflict if both landed

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 33 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 525fe00c-99a5-45c0-87a9-9868ef1f0405

📥 Commits

Reviewing files that changed from the base of the PR and between c8ec67d and b4c14f7.

📒 Files selected for processing (4)
  • src/runtime/node/node_fs.rs
  • test/js/node/fs/fs.test.ts
  • test/js/node/fs/readdirSync-recursive-error-leak-fixture.js
  • test/js/node/fs/readdirSync-recursive-error-leak.test.ts

Walkthrough

Recursive readdirWithEntries implementations in the Rust runtime now treat ELOOP as a skippable, non-fatal entry error in both async and sync paths. Corresponding tests and a leak-detection fixture are updated to reproduce failures via unreadable directories (EACCES with privilege drop) instead of symlink loops.

Changes

ELOOP skip and EACCES test coverage

Layer / File(s) Summary
Skip ELOOP in recursive readdir
src/runtime/node/node_fs.rs
Both async and sync recursive readdir entry-open error handling add E::ELOOP to the skip set alongside ENOENT, ENOTDIR, EPERM, avoiding failure on symlink loops.
fs.test.ts EACCES scenarios
test/js/node/fs/fs.test.ts
Skipped tests for buffer-encoding recursive readdirSync and multi-subtask promises.readdir are rewritten to create unreadable directories and drop privileges in spawned children, expecting EACCES instead of ELOOP, with permission restoration in cleanup.
Leak-detection fixture and test comments
test/js/node/fs/readdirSync-recursive-error-leak-fixture.js, test/js/node/fs/readdirSync-recursive-error-leak.test.ts
The fixture replaces the symlink-loop directory with an unreadable zzblocked directory (root privilege drop, chmod 0o000), asserts EACCES error code, restores permissions before cleanup, and the test file's comment is updated to note Windows-specific caveats.

Possibly related PRs

  • oven-sh/bun#30166: Also touches recursive readdir's ELOOP error handling path, fixing cleanup for Buffer entries previously triggered by ELOOP failures.
🚥 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 clearly matches the main change: skipping symlink cycles in recursive readdir instead of failing with ELOOP.
Description check ✅ Passed The description covers what changed and how it was verified, matching the template's required content even with different headings.

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

@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: 1

🤖 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 `@test/js/node/fs/fs.test.ts`:
- Around line 1525-1569: The recursive symlink-cycle regression test in the fs
recursive walker coverage should also exercise the async fs.opendir path, since
the current test only covers readdirSync, promises.readdir, and opendirSync.
Extend the existing test case by adding an async fs.promises.opendir/
fs.opendir-based walk using the same root and expected dirent names, so the
recursive walker behavior is verified across all affected entry points.
🪄 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: 44adf3ff-a6d8-4d34-b85d-085e1e32b19e

📥 Commits

Reviewing files that changed from the base of the PR and between 9f29e55 and c8ec67d.

📒 Files selected for processing (4)
  • src/runtime/node/node_fs.rs
  • test/js/node/fs/fs.test.ts
  • test/js/node/fs/readdirSync-recursive-error-leak-fixture.js
  • test/js/node/fs/readdirSync-recursive-error-leak.test.ts

Comment thread test/js/node/fs/fs.test.ts Outdated

@claude claude 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.

LGTM — minimal, well-justified errno-set addition with thorough test coverage; the one finding is a comment-wording nit.

Extended reasoning...

Overview

The runtime change is two tokens: | E::ELOOP added to the tolerated-errno match arm in both readdir_with_entries_recursive_async and readdir_with_entries_recursive_sync. The walker uses openat(O_DIRECTORY | O_RDONLY) as its "is this traversable" probe and already skips ENOENT/ENOTDIR/EPERM; a symlink cycle yields ELOOP from the same probe and should be treated identically, since a real directory can never produce ELOOP. This brings all five recursive listing entry points (readdirSync paths/dirents, promises.readdir paths/dirents, recursive opendirSync) in line with Node, which the PR verifies against v26.3.0.

The test changes are larger (~130 lines) but mechanical in nature: a new comprehensive test exercising all five variants over a fixture with a two-link cycle, a self-cycle, a dangling symlink, and a resolvable symlink-to-directory (proving traversal still works); plus migration of three existing error-path tests that previously relied on self-referential symlinks producing ELOOP — exactly the behavior this PR removes. Those tests now trigger the same error path via an unreadable directory (EACCES), with a setuid(65534) drop when running as root since root bypasses DAC. Each migrated test still asserts its original invariant (buffer-entry cleanup, multi-subtask promise settlement, Dirent.path leak bound), and each is wrapped in try/finally to restore permissions before tempDir disposal.

Security risks

None. This widens a skip set on a per-entry directory-open probe inside recursive readdir — no new inputs are trusted, no paths are followed that weren't before, and EACCES still propagates. The setuid calls are test-only, in spawned child processes, gated on already being root.

Level of scrutiny

Low-to-moderate. The native change is a one-token addition to an existing pattern with a clear correctness argument (real directories cannot yield ELOOP from openat(O_DIRECTORY)) and demonstrated Node parity. The test migrations warrant a closer read because they change how three regression tests provoke failure, but I traced each: the dropPrivileges guard is a no-op when not root (so non-root CI just sees EACCES on the 0o000 dir directly), and when root it drops to 65534 after makeUnreadableDir chmods the tempDir to 0755 so the unprivileged child can still traverse to it. tempDir creates directly under os.tmpdir(), so there's no restrictive intermediate ancestor on Linux; on macOS tests don't run as root so the drop never fires. Cleanup restores modes in finally before using disposal runs rmSync.

Other factors

The bug hunter found one nit: the rewritten comment describes the EPERM slot as "unreadable", but unreadable directories yield EACCES (which is deliberately not skipped, and which the PR's own migrated tests depend on propagating). It's a comment-accuracy issue only — the code and tests are internally consistent — so it doesn't block approval. The PR also removes the stale "This is different than what Node does" comment, which is appropriate given the change explicitly aligns with Node. The new test includes a non-vacuous sanity check (readdirSync(join(root, 'lpA')) must throw ELOOP) proving the fixture actually contains a cycle. Full test-suite pass reported for fs.test.ts, the leak test, and dir.test.ts/glob.test.ts; cargo check clean on all three tier-1 targets.

Comment thread src/runtime/node/node_fs.rs
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

All three pieces of feedback are addressed in 0c42e77.

On the duplicate flag (#31418)

Real overlap, and #31418 is the larger change. I re-checked its extra claim against node v26.3.0 and it still holds, so that work is still needed and is not in this PR:

recursive flavor node v26.3.0 bun, with this PR
readdirSync descends into symlinked dirs descends
readdirSync + withFileTypes descends descends
promises.readdir descends descends
promises.readdir + withFileTypes does not descend descends
opendirSync / promises.opendir does not descend descends

This PR is only the ELOOP half of #31418. Where the two differ in practice:

Happy either way: close this if #31418 gets rebased, or land this and #31418's remaining delta shrinks to the no_symlink_descent flag. Also worth noting the table's last row, opendir does not descend in node either, which neither PR covers yet.

Review nits

  • @coderabbitai: added promises.opendir(recursive) to the new test. While doing that I pulled the "symlink to a real directory is still descended into" assertion out into its own test, because opendir and promises.readdir + withFileTypes do not descend in node (see the table above). Asserting a full listing with a resolvable symlink in the cycle fixture would have baked in a divergence unrelated to ELOOP. The cycle fixture now matches node v26.3.0 entry for entry across all six flavors.
  • @claude: correct, "unreadable" is EACCES, which is deliberately not in the skip set and which the migrated tests depend on propagating. Reworded both comments to name the exclusion explicitly, since that is the thing a future reader would get wrong.

CI

The four red lanes (including 13 x64-asan) all fail on the same unrelated test, test/js/bun/cookie/cookie-map.test.ts:

- "__Host-id=; Path=/; Expires=Fri, 1 Jan 1970 00:00:00 -0000; Secure; SameSite=Lax"
+ "__Host-id=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Lax"

That reproduces on this branch's base commit, and this PR touches nothing outside node_fs.rs and the fs tests. No fs failures in any lane.

@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Tip

For best results, initiate chat on the files or code changes.

@robobun, thanks for the thorough writeup and for addressing the review nits in 0c42e77. A few notes:

  • The promises.opendir(recursive) addition and splitting out the "symlink to a real directory" assertion into its own test makes sense — keeping the cycle fixture aligned with Node's actual per-flavor descent behavior avoids conflating unrelated divergences with the ELOOP fix itself.
  • Agreed on the EACCES wording — making the exclusion explicit in the comments will save future readers from assuming it's also skipped.
  • Your overlap analysis against #31418 is clear and the CI failures on cookie-map.test.ts (date format mismatch: 1 Jan 1970 ... -0000 vs 01 Jan 1970 ... GMT) reproducing on the base commit confirms they're unrelated to this change.

This all looks good to me. I'll take another pass once the build is green.


✏️ Learnings added
Learnt from: robobun
URL: https://github.com/oven-sh/bun/pull/33432

Timestamp: 2026-07-06T11:15:41.866Z
Learning: In `src/runtime/node/node_fs.rs`, the recursive readdir walker (`readdir_with_entries_recursive_async` and `readdir_with_entries_recursive_sync`) skips non-fatal entry errors `ENOENT`, `ENOTDIR`, `EPERM`, and (as of PR `#33432`) `ELOOP` when `openat` fails on a subdirectory. `EACCES` is deliberately excluded from this skip set — it must propagate as an error, and tests in `test/js/node/fs/fs.test.ts` and `test/js/node/fs/readdirSync-recursive-error-leak-fixture.js` rely on unreadable directories (`chmod 0o000`) producing `EACCES` to exercise these error paths.

Learnt from: robobun
URL: https://github.com/oven-sh/bun/pull/33432

Timestamp: 2026-07-06T11:15:41.866Z
Learning: Bun PR `#33432` (fixing `ELOOP` handling in recursive `node:fs` readdir/opendir) overlaps with PR `#31418`, which additionally introduces a `no_symlink_descent` flag so `promises.readdir({recursive:true, withFileTypes:true})` and `opendirSync`/`promises.opendir` recursive walks match Node's behavior of not descending into symlinked directories (verified against Node v26.3.0). PR `#33432` covers only the `ELOOP` errno skip; the symlink-descent-flag work remains scoped to `#31418`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

robobun added 2 commits July 6, 2026 11:20
…with ELOOP

The recursive readdir walker opens every symlink and directory entry with
openat(O_DIRECTORY), and tolerates ENOENT, ENOTDIR and EPERM from that open
by skipping the entry. ELOOP was not in that set, so a single symlink cycle
anywhere in the tree aborted the whole listing and returned nothing.

Node's recursive walkers ignore per-entry stat failures, so add ELOOP to the
skipped set in both the sync and the async walker. This also covers
fs.promises.readdir and recursive fs.opendir, which both go through readdir.

The two existing regression tests and the Dirent.path leak fixture used a
self-referential symlink to force a failing subdirectory open. They now use
an unreadable directory (EACCES), dropping to an unprivileged uid first
because root bypasses the permission check.
…ergence

fs.promises.opendir(recursive) shares the walker but was not exercised. Move the
symlink-to-a-real-directory guard into its own test: node does not descend into
symlinked directories for promises.readdir+withFileTypes or for opendir, so the
cycle fixture would otherwise have asserted a divergence unrelated to ELOOP.

Also name the excluded errno in the walker comment: an unreadable directory is
EACCES, which propagates, not EPERM, which is skipped.
@robobun
robobun force-pushed the farm/4da0443c/readdir-recursive-eloop branch from 0c42e77 to b4c14f7 Compare July 6, 2026 11:27
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto `48ff9eb` (cookie: update remaining cookie-map Expires assertions to IMF-fixdate, #33425). That was the only commit between this branch's old base and main, and it fixes the cookie-map.test.ts failure that was painting every lane red here. The diff is unchanged: same four files, same two commits.

For the record, the old base 9f29e5512 was itself red on main (build 68787) with the same failure, so none of it was ever this PR.

@robobun

robobun commented Jul 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status on b4c14f72 (build 68879), now finished: 283 passed, 1 failed, 2 expired. Zero test failures on any lane.

The one red job is :darwin: 26 aarch64 - test-bun, and it never ran a test:

Error: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'.
Refusing to continue with a partial download
🚨 Error: The command exited with status 1

darwin-aarch64-build-bun itself passed, so the artifact exists and a retry of that one job should pick it up.

That lane is sharded, and the sibling shard passed, which is the one that happens to carry this PR's tests. So macOS coverage is real:

--- [3/2369] test/js/node/fs/fs.test.ts
 374 pass, 11 skip, 0 fail, 5622 expect() calls

--- [4/2369] test/js/node/fs/readdirSync-recursive-error-leak.test.ts
 1 pass, 0 fail

The 2 expired jobs are darwin 14 lanes that sat runnable for over an hour without picking up an agent, so that is agent capacity, not this branch.

I am deliberately not pushing an empty retrigger commit. The diff is already proven green per-file on linux (including x64-asan), windows and macOS, and with the queues starved enough to expire jobs, a fresh 286-job build would likely hit the same timeout. One click of Retry on the darwin job is the cheaper path.

Summary of where this stands: the diff is two tokens of runtime change (| E::ELOOP in each of the two recursive walkers) plus tests. The open question is not correctness but scope, see the comparison with #31418 above.

Jarred-Sumner pushed a commit that referenced this pull request Aug 22, 2026
…opened (#40000)

### Problem
- `fs.readdirSync(root, { recursive: true })` throws `ENOENT: no such
file or directory, scandir '<root>'` when another process removes a
subdirectory during the call. `root` still exists. `fs.promises.readdir`
rejects too.
- The walk skips a subdirectory whose open fails with ENOENT, ENOTDIR or
EPERM. A removal that lands after the open surfaces as ENOENT from
`getdents64` instead. Both walkers in `src/runtime/node/node_fs.rs`
returned that error.

### Fix
- One predicate, `readdir_skips_subdir`, now covers the open and the
read of a subdirectory in both walkers. A tolerated read error ends that
subdirectory. Root errors and other errnos are returned as before.
- Matches node for this race. libc's `readdir(3)` reports this ENOENT as
the end of the directory, so node lists the subdirectory as empty and
continues. bun reads with the raw syscall and saw the errno.
- The open arm is unchanged. Only timing decides which syscall sees the
removal, so the read arm uses the same set.
- Verified: the new test in `test/js/node/fs/fs.test.ts`. Its four walk
variants fail on bun 1.4.0. Other suites: see the notes.

### Background
- Each directory below the root is opened with `openat(root_fd,
relative)` and read with `getdents64`, in one loop (sync) or in one
subtask per directory (async). Any subtask error rejects the promise.
- On Linux, `getdents64` on a directory removed after its open fails
with ENOENT (`IS_DEADDIR` in fs/readdir.c). glibc, and the FreeBSD arm
of bun's iterator, turn this into end of directory. The Linux arm does
not.
- The test preloads a shim on libc's `syscall()`, bun's route to
`getdents64`. It really removes two marked directories during the walk,
and the test checks that both are gone. glibc only.

<details><summary>Notes</summary>

- Origin: found while #39710 was verified. Recursive rm has the same gap
on its own walk and is handled in that thread. The original observation:
a root with 128 subdirectories, a second process unlinking their files
and removing them, and `readdirSync(root, { recursive: true })` in a
loop in the main process. It fired about once in a few hundred
iterations.
- Repro without the race: the shim from the test, run against bun 1.4.0.
Sync: `ENOENT scandir 'root-sync'`. Async: `ENOENT getdents64
'root-async/vanish-mid-read'`.
- Raw syscall against libc, same machine (kernel 6.17, glibc 2.41),
directory opened and then removed: `getdents64` returns -1 with ENOENT
on overlayfs and on tmpfs. glibc's `readdir(3)` on the same state
returns NULL with errno 0, that is, end of directory. The kernel side is
generic: `vfs_rmdir` sets `S_DEAD` on the inode, and `iterate_dir`
returns ENOENT for it before any filesystem code runs. So the errno does
not depend on the filesystem, only on whether the reader uses libc or
the raw syscall. A probe through Python or any other libc reader shows
an empty listing for this reason, not an error.
- Node, observed: `fs.opendirSync(dir)`, then `rmdirSync(dir)`, then
`dir.readSync()` returns `null` (end of directory) on node v26.3.0. bun
returns `ENOENT scandir` there before and after this change. That
`opendir` path and a root removed after its open are the same libc
difference, on a different consumer of the iterator. This change leaves
them alone: it would mean a change to the Linux arm of the iterator,
which every directory walker in bun shares (readdir, opendir, cp, rm,
glob, the shell builtins). The rm side of that is in flight in #39710.
That alignment is a separate change if wanted.
- Node with the subdirectory removed before node opens it (a shim on
`scandir(3)`): `readdirSync` and `fs.promises.readdir` both throw
`ENOENT scandir 'root/vanish-before-read'`. bun skips the subdirectory
there, and did so before this change. This change does not widen that
difference, it only makes the read step behave like the open step.
- The shim removes one directory before its first read (the read fails
at once) and one after its first read handed out an entry (the
end-of-directory read fails). The `withFileTypes` variants cover the
`dirent_path_prev` release on the new `break` path. They pass under the
ASAN debug build.
- Why the message named the root: `NodeFS::readdir` rebuilds every error
of the sync form as `scandir` on the root argument. That is unchanged
here and is what #38017 changes.
- Unchanged: EACCES, EINVAL, ELOOP, EIO and every other errno still fail
the call, from the open and from the read. The root cases in the new
test pass before and after this change on purpose. They pin that only
subdirectories are skipped.
- Open PRs on the same lines, all independent of this one: #38017 (name
the failing subdirectory), #37988 (syscall name of the async forms),
#33432 (add ELOOP to the skipped set, which becomes a one-line change to
the predicate). The test asserts `code` and `path` only, so #37988 and
#38017 do not change its expectations.
- The walkers are shared by all platforms, so the predicate applies on
macOS and Windows too. What those kernels report for a removed open
directory was not checked here. The test runs on Linux glibc only.
- Suites run with the debug build: all of `test/js/node/fs/fs.test.ts`,
`readdirSync-recursive-error-leak.test.ts`, `dir.test.ts`,
`fs-path-length.test.ts`, node's `test-fs-readdir*.js`, regression tests
17793, 24007, 28159, 29585. `test/regression/issue/10139.test.ts` (a
`bun build` of a 128 MB asset with a 5 s budget) times out in this
container with the debug build. It does not use recursive readdir.

</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/node/fs/fs.test.ts

<!-- robobun:evidence:end -->
Jarred-Sumner pushed a commit that referenced this pull request Aug 27, 2026
…0668)

### Problem
- `fs.promises.readdir(dir, { recursive: true })` and the callback form
never settle on a tree with two directory symlink loops and one entry
that fails to open (`ln -s . loop1; ln -s . loop2; ln -s bad bad`).
Every pool thread spins and the caller cannot catch anything.
`readdirSync` rejects the same tree with `ELOOP` in milliseconds. Both
async forms share `AsyncReaddirRecursiveTask`
(`node_fs_binding.rs:204`).
- Cause: a failing subtask records its error in `pending_err` and
releases its reference (`node_fs.rs:2404`), but every other subtask
keeps calling `enqueue` (`node_fs.rs:2294`), which schedules one new
subtask per directory found. Nothing reads `pending_err` until
`subtask_count` reaches zero, so the promise waits for the whole
frontier. The kernel follows 40 symlinks before `ELOOP`, so two loops
make that frontier 2^41 directories.

### Fix
- `AsyncReaddirRecursiveTask` gets a `has_error` flag, set right after
the first error is recorded. `enqueue` then schedules nothing, and a
subtask that starts after the flag is set releases its reference without
opening its directory. The scan settles once the few subtasks already in
flight return.
- The rejection is the same error as before (the first one recorded). On
success nothing changes: the flag is never set, so the only new cost is
one relaxed atomic load per directory.
- The `subtask_count` decrements are now `AcqRel`, so the last subtask
sees the other subtasks' `pending_err` and queued results. The cp task
already uses this ordering.
- Verified: `test/js/node/fs/fs.test.ts` (`readdir({recursive: true})
settles with the first error while symlink loops are still being
walked`, promise and callback form; on the stock binary the test times
out with the child still spinning, the fixed one settles in 0.6 s under
ASAN). Also ran all of `fs.test.ts`,
`readdirSync-recursive-error-leak.test.ts`, `dir.test.ts`,
`fs-path-length.test.ts` and node's `test-fs-readdir*.js`.

### Background
- The async recursive readdir is one `Job` whose off-thread part is
shared by pool subtasks. The root directory runs in `run`. Each
directory or symlink entry becomes a `ReaddirSubtask` on the work pool.
`subtask_count` is a refcount: it starts at 1 for the root, `enqueue`
adds one, and every subtask subtracts one when it is done. The subtask
that brings it to zero calls `finish_concurrently`, which joins the
results or keeps the error and hands the job back to the JS thread.
- `readdir_skips_subdir` lists the errors that skip one entry (`ENOENT`,
`ENOTDIR`, `EPERM`). Any other error, `ELOOP` included, fails the whole
listing, as in the sync walker.
- This change does not bound a tree that has only the two loops and no
entry that fails early. On a stock kernel the first `ELOOP` appears at
depth 41, so that tree is a 2^41 directory walk for `readdirSync`, and
for node's sync and async walkers too (node v26 did not finish it here
either). Only cycle detection (the dev/ino of the ancestors) would bound
it. That is a semantics change, and it interacts with #33432 and #31418,
which make `ELOOP` a skipped error. It is not part of this PR.

<details><summary>Notes</summary>

Repro on the released build (Linux 6.17, overlayfs):

```
mkdir -p /tmp/t/a/b && cd /tmp/t && ln -s bad bad && ln -s . s1 && ln -s . s2
timeout -s KILL 20 bun -e 'require("fs").promises.readdir(".",{recursive:true}).then(a=>console.log("ok",a.length),e=>console.log("rej",e.code))'
# never prints, rc=137. Same with fs.readdir(".", {recursive:true}, cb).
bun -e 'try{require("fs").readdirSync(".",{recursive:true})}catch(e){console.log(e.code)}'
# ELOOP in 9 ms
```

With this change both async forms print `rej ELOOP` in about 10 ms (0.5
s for the debug build, most of it startup).

The bare two-loop tree without `bad`: `readdirSync` did not finish in 30
s on this machine. The fuzz report that saw it finish in 11 s ran on a
box where the kernel returns `ELOOP` after 8 to 11 components, so the
sync walker got its first error early there. The async walker was the
only one that did not stop at that error.

`USE_SYSTEM_BUN=1 bun test` on the new test: the test hits the 5 s
runner timeout and the runner kills the spinning child ("killed 1
dangling process"). `bun bd test` with this change: pass in 583 ms.
</details>

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · platform-specific test(s) that do not
run on this machine, deferring to CI, which covers all platforms:
test/js/node/fs/fs.test.ts

<!-- robobun:evidence:end -->
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-06, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant