node:fs: stop the async recursive readdir walk at the first error - #40668
Conversation
fs.readdir and fs.promises.readdir with recursive: true run one pool subtask per directory. A failing subtask recorded its error and released its reference, but every other subtask kept scheduling subtasks for the directories it found, and the promise settled only when the whole frontier had drained. A tree with two directory symlink loops has a 2^41 directory frontier before the kernel reports ELOOP, so the promise never settled and every pool thread spun. Set a flag with the first error. enqueue then schedules nothing, and a subtask that starts after the flag is set releases its reference without opening its directory. The subtask_count decrements are AcqRel so the last subtask sees the other subtasks' pending_err and queued results.
|
Warning Review limit reached
On-demand reviews are free for the next 24 days. After that, they cost $0.25 per reviewed file. Or wait 14 minutes for your next included review. View limit detailsLimit details: You’ve used all 5 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
Comment |
|
Status: reproduced on the released build (both With this change both async forms reject with CI (build 106996): 179 of 181 jobs pass. The new test passes on every lane. The two red tests, |
There was a problem hiding this comment.
LGTM — focused fix with a proper hang-guard test.
Checked the refcount balance: enqueue's new early return skips both the fetch_add and the schedule, and every path through perform_work (early-exit, error, success→write_results) reaches on_subtask_done() exactly once. Traced the happens-before for the Relaxed has_error flag — whichever thread's fetch_sub(AcqRel) sees 1 synchronizes with the erroring thread's release, so finish_concurrently observes pending_err regardless of which thread finishes last. The success path is untouched (flag never set). Test mirrors the neighboring one at line ~1820 exactly (subprocess timeout + signalCode: null assertion), so the Bun.spawn timeout is a hang guard, not a time-based wait.
Extended reasoning...
Overview
The PR fixes a hang in fs.readdir/fs.promises.readdir with {recursive: true} when the tree contains symlink loops plus an entry that errors. The async walker (AsyncReaddirRecursiveTask in src/runtime/node/node_fs.rs) previously recorded the first error in pending_err but kept fanning out subtasks until the entire frontier drained — with two .-loops that's ~2^41 directories before the kernel returns ELOOP. The fix adds a has_error: AtomicBool that enqueue() checks before scheduling and perform_work() checks at entry, so once any subtask errors the frontier stops growing and the scan settles as soon as in-flight subtasks return. The three subtask_count.fetch_sub sites are factored into on_subtask_done() and the ordering is upgraded from Relaxed to AcqRel. A new test in test/js/node/fs/fs.test.ts builds the pathological tree, spawns both promise and callback forms, and asserts rejected ELOOP / exit 0 / signalCode: null.
Security risks
None. This is an internal work-scheduling short-circuit in the recursive readdir implementation; no user input parsing, no auth, no crypto, no new externally-reachable surface. The change only makes an existing error path settle sooner. The Relaxed ordering on has_error is safe because it guards no data — it's a monotonic hint; a stale false just means one more subtask runs, and the actual pending_err is published via the AcqRel refcount decrement (verified the release-sequence chain: whichever thread hits fetch_sub == 1 acquires against every prior subtask's release, so it observes pending_err and queued results regardless of interleaving).
Level of scrutiny
Medium — concurrent native code with atomics warrants a careful read, which I gave it. The change is small (~30 net lines of Rust), purely additive on the error path (success path never sets the flag, so behavior is unchanged there), and the AcqRel upgrade is strictly safer than the previous Relaxed. Refcount balance holds on every path: enqueue's early return skips both the fetch_add and the schedule (nothing to pair); the three on_subtask_done() call sites in perform_work are mutually exclusive (early-exit returns, error branch and write_results are opposite match arms). The PR description cites the cp task as prior art for the same ordering.
Other factors
The test is placed in the existing module file per convention, uses tempDir/bunExe/bunEnv, covers both sibling entry points (promise + callback), and follows the exact hang-guard pattern of the immediately preceding test in the same file (subprocess timeout: 10_000 with a signalCode: null assertion — this is a Bun.spawn kill deadline, not a bun:test timeout, so it doesn't violate the "no test timeouts" rule and satisfies REVIEW.md's "hang-guard tests assert the process exited on its own"). The PR author reports USE_SYSTEM_BUN=1 times out and bun bd test passes in 583 ms. No CODEOWNERS entry covers either changed file. No outstanding review objections in the timeline. Exit reason was dry_streak.
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.readdirSyncrejects the same tree withELOOPin milliseconds. Both async forms shareAsyncReaddirRecursiveTask(node_fs_binding.rs:204).pending_errand releases its reference (node_fs.rs:2404), but every other subtask keeps callingenqueue(node_fs.rs:2294), which schedules one new subtask per directory found. Nothing readspending_erruntilsubtask_countreaches zero, so the promise waits for the whole frontier. The kernel follows 40 symlinks beforeELOOP, so two loops make that frontier 2^41 directories.Fix
AsyncReaddirRecursiveTaskgets ahas_errorflag, set right after the first error is recorded.enqueuethen 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.subtask_countdecrements are nowAcqRel, so the last subtask sees the other subtasks'pending_errand queued results. The cp task already uses this ordering.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 offs.test.ts,readdirSync-recursive-error-leak.test.ts,dir.test.ts,fs-path-length.test.tsand node'stest-fs-readdir*.js.Background
Jobwhose off-thread part is shared by pool subtasks. The root directory runs inrun. Each directory or symlink entry becomes aReaddirSubtaskon the work pool.subtask_countis a refcount: it starts at 1 for the root,enqueueadds one, and every subtask subtracts one when it is done. The subtask that brings it to zero callsfinish_concurrently, which joins the results or keeps the error and hands the job back to the JS thread.readdir_skips_subdirlists the errors that skip one entry (ENOENT,ENOTDIR,EPERM). Any other error,ELOOPincluded, fails the whole listing, as in the sync walker.ELOOPappears at depth 41, so that tree is a 2^41 directory walk forreaddirSync, 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 node:fs: skip symlink cycles in recursive readdir instead of failing with ELOOP #33432 and node:fs: match Node's recursive readdir symlink and cycle handling #31418, which makeELOOPa skipped error. It is not part of this PR.Notes
Repro on the released build (Linux 6.17, overlayfs):
With this change both async forms print
rej ELOOPin about 10 ms (0.5 s for the debug build, most of it startup).The bare two-loop tree without
bad:readdirSyncdid 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 returnsELOOPafter 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 teston the new test: the test hits the 5 s runner timeout and the runner kills the spinning child ("killed 1 dangling process").bun bd testwith this change: pass in 583 ms.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