Skip to content

node:fs glob: read directories ahead in the async walk, less work per entry - #42963

Open
robobun wants to merge 5 commits into
mainfrom
robobun/2aa9c675/fs-glob-walker
Open

robobun wants to merge 5 commits into
mainfrom
robobun/2aa9c675/fs-glob-walker

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

Fix

  • Glob.#prefetch() starts the readdir of the next 8 queued directories, and the root readdir starts with the root lstat. The walk awaits the same promises from the readdir cache, so the walk order and the results do not change.
  • The walk memoizes cache keys, joins the directory prefix once per directory (joinPrefix), and skips resolve() without an exclude array. That is the first commit. The read-ahead is the second.
  • Tree walk: fs.promises.glob 56 ms to 31 ms, fs.globSync 17.1 ms to 15.2 ms.
  • Verified: test/js/node/fs/glob.test.ts (new: async equals sync on a wide tree, the read-ahead is on and bounded, no join or resolve per entry). Also test-fs-glob.mjs (448 pass). Self-reviewed together with node:fs glob: compile plain patterns without minimatch #42874.

Background

Notes

Numbers. Release builds of the same commit with and without this change, Linux x64, bench/glob/node-fs-glob.mjs from #42874, median over fresh processes (7 for the tree, 21 for the directory), ms. The box was under load (load average 55), so compare rows, not absolute values. first, second: that call in the process. later: median of 100 more calls.

**/*.rs over src/ of this repo (1,525 results):

globSync first second later promises.glob first second later
bun 1.3.14 (1,588 results, other semantics) 11.6 9.4 7.0 13.3 10.5 8.8
main 35.2 22.0 17.1 52.1 50.3 56.5
this PR 29.5 19.7 15.2 45.4 28.8 31.3
node v26.3.0 49.8 33.2 25.3 234 171 103
node v27 nightly (native glob) 6.1 3.8 3.6 10.7 4.5 3.9

"*.txt" in a directory of 20 files:

globSync first second later promises.glob first second later
main 5.81 0.26 0.086 7.64 0.66 0.138
this PR 5.73 0.30 0.067 7.11 0.38 0.095

The first call does not change here. That is the subject of #42874.

Why the read-ahead is safe. It only puts the readdir promise in the cache earlier. The matching code is untouched, so the walk order and the results are the same. It reads a queued directory only when the parent's readdir said it is a directory and a pattern for it lists it (readsDirectory). Each visited directory looks at the last 8 queue items, so a visit starts at most 8 reads, and only for items that no earlier visit started. On the test tree (61 directories, at most one subdirectory each) exactly 8 reads are in flight at the peak, and the test expects 8. A consumer that stops early leaves at most those reads unused. Cache.readdir maps a readdir error to []. The promise can still reject when fs.promises.readdir is patched and its result does not sort, so Cache.prefetchReaddir() attaches a rejection handler to the promise that nothing awaits. The walk gets the same rejection from its own readdir(path), as before.

Why joinPrefix is equivalent. readdir never returns ., .. or a name with a separator, so join(dir, name) has nothing to normalize in name. join(dir, sep) + name gives the same string for every shape dir takes here (., a, ../, .., /, C:\, a UNC root), checked against path.posix and path.win32. The literal-segment branch (children = [stat], where the name can be ..) still calls join.

Why the shared cache keys are equivalent. cacheKey(index) depends only on the glob strings and the index, and every child() of a pattern has the same glob strings.

Tests. Two of the new tests fail without the change: the in-flight readdir count is 1 on main, and main calls path.join and path.resolve at least once per entry for 200 files. The other tests pin behavior that must not change. Beyond the suite, a differential run of the old and the new module on 16,500 random cases (random trees with dotfiles and symlinks, exclude function and array, withFileTypes, followSymlinks, other cwd, sync and async) gave the same results in the same order.

#42870 (a results fix from upstream Node) removes three lines in each of the two walk loops, about ten lines below the joinPrefix hunks. The changes do not overlap.


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

The walk memoizes Pattern.cacheKey() (every child() of a pattern shares the
keys), joins the directory prefix once per directory and not twice per entry,
and does not call path.resolve() per result or per queued path when there is
no exclude array. The results and their order do not change.
fs.promises.glob and fs.glob wait one thread pool round trip per directory,
one after the other. The walk now starts the readdir of the next 8 queued
directories before it visits them, and reads the root while it stats it. The
walk awaits the same promises from the readdir cache, so the walk order and
the results do not change. A rejection of a readdir that nothing awaits is
marked handled. The consumer still gets it from the walk's own readdir.
@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 16eb16a1-6694-4725-ad11-5a144f40c788

📥 Commits

Reviewing files that changed from the base of the PR and between a8e4e90 and bf5a2b2.

📒 Files selected for processing (2)
  • src/js/internal/fs/glob.ts
  • test/js/node/fs/glob.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The glob implementation now uses prefixed directory entries, shared cache keys, guarded exclusions, and asynchronous directory prefetching. New tests cover path operations, ordering, errors, concurrency limits, and early iterator termination.

Changes

Glob traversal optimization

Layer / File(s) Summary
Path handling and cache updates
src/js/internal/fs/glob.ts
Adds separator-aware entry prefixes, shared pattern cache keys, cached directory entries, and conditional exclusion checks for sync and async traversal.
Asynchronous directory prefetch
src/js/internal/fs/glob.ts
Adds directory-read detection, queue prefetch state, and a maximum of eight concurrent background directory reads.
Traversal behavior tests
test/js/node/fs/glob.test.ts
Tests path-call reduction, ordering, readdir error propagation, bounded concurrency, and clean early termination.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 5f355

No merge-blocking behavior issue is confirmed in the optimized glob traversal.

🚥 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 summarizes the main changes: asynchronous directory read-ahead and reduced per-entry work in node:fs glob traversal.
Description check ✅ Passed The description is comprehensive and explains the problem, implementation, performance results, verification steps, and behavioral guarantees. It does not use the template headings exactly, but it pro…

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

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:29 PM PT - Sep 16th, 2026

❌ @robobun, your commit 5f3557b has 1 failures in Build #116708 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42963

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

bun-42963 --bun

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

How I reproduced it: bench/glob/node-fs-glob.mjs from #42874. It times the first, the second and 100 later calls of fs.globSync and fs.promises.glob in fresh processes, for **/*.rs over src/ of this repo and for "*.txt" in a directory of 20 files. Binaries: Bun 1.3.14, a release build of main, a release build of this branch, Node v26.3.0, and a Node v27 nightly. The tables are in the Notes block of the PR body.

Where the time went: --cpu-prof on a warm globSync loop (path.join was 23% of it), and the gap between the sync and the async walk of the same tree (one thread pool round trip per directory).

CI: every job passes except one. test/js/bun/spawn/spawn.test.ts fails on the x64 ASAN lane in "an idle reader stopped at the highwater mark does not keep the process alive". The child prints a LeakSanitizer warning to stderr (ptrace appears to be blocked), and the test expects an empty stderr. The same failure is in 28 of the last 40 failed builds of other branches, and this PR does not touch that code. All review threads are resolved. The PR is ready for a maintainer.

PR: #42963

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline nit, I also checked: joinPrefix(dir) + name against join(dir, name) for every path/fullpath shape the walk produces (., .., ../, /, C:\, normalized relative paths — the .. literal-segment branch still goes through join), the rejection path of a prefetched readdir (the cached inner promise is adopted by the async readdir() wrapper, so prefetchReaddir's $then leaves nothing unhandled and the walk's own readdir(path) still rejects), and the #addSubpattern/ResultSet.add skips (only taken when #isExcluded is the default, and the exclude-callback branch recomputes the same resolve(root, path)). None of these change results or order; the prefetch only ever adds cache entries earlier.

Extended reasoning...

Findings-present run: one inline nit on the maxInFlight <= 16 bound survived verification. This note records what else was traced and ruled out from reading src/js/internal/fs/glob.ts against the diff: the joinPrefix substitution (readdir names contain no ./../separator and path is always normalized because parent = join(path, "..") and entry paths are built from normalized prefixes), the unhandled-rejection question for prefetched readdirs (the async readdir() wrapper adopts the cached inner promise, so both the inner and the outer promise have handlers; the walk's later readdir(path) produces a fresh outer promise that rejects to the consumer, which the new sort-failure tests exercise for both the root and a directory inside the prefetch window — d3 is among the last 8 queue items on the sorted fixture), the memoized cacheKey (depends only on the shared glob strings and a non-negative index; "" past the end is ??=-stable), and the exclude fast-path (ResultSet.#isExcluded is only replaced via setup() for array excludes, and the callback branch now resolves the path itself). Prefetch reads directories the walk may later skip as excluded or cyclic, but that is an extra readdir mapped to [] on error, not a behavior change. No debug build was available in this checkout, so the tests were not executed here.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

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

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

The latest push tightens the read-ahead test to the exact window, and this run found no bugs in the walker changes; since this is an async read-ahead in the node:fs glob walk with a correctness argument that lives in the PR text rather than in the code, a human look at that argument would still be worthwhile.

What was reviewed: the joinPrefix equivalence for every dir shape the walk produces (., relative, ..-prefixed, POSIX and drive/UNC roots), the shared #cacheKeys array across child() clones (keyed only on glob strings and index), the excludeNothing short-circuits including the relocated resolve() in the exclude-callback branch, and the #prefetch() window: it only inserts the same readdir promise into the cache earlier and skips entries whose parent Dirent is not a directory or whose patterns take the single-literal stat path. Also checked that prefetchReaddir attaches a rejection handler while the later awaited readdir(path) still surfaces the rejection, which the two new subprocess tests pin. One candidate was ruled out: the test comment explaining why maxInFlight is exactly 8 misdescribes the fixture (the root has 20 subdirectories), but the asserted value is correct because a popped item's readdir settles before its child is pushed and prefetched.

Extended reasoning...

Overview

The diff touches two files: src/js/internal/fs/glob.ts (the built-in node:fs glob walker ported from Node, ~110 lines changed) and test/js/node/fs/glob.test.ts (~147 lines added). The runtime change has four parts: memoized Pattern.cacheKey shared across child() clones, a joinPrefix(dir) + name replacement for per-entry path.join in both the sync and async walk loops, excludeNothing short-circuits in ResultSet.add and #addSubpattern, and a bounded (kPrefetchWindow = 8) read-ahead of readdir in the async glob() generator via Cache.prefetchReaddir and Cache.cachedDirent. The commit after the prior review (aaf77e04) converted the tests to describe.each and changed the in-flight assertion from <= 16 to exactly 8, which addresses the earlier inline nit.

Security risks

None specific to this change. The code handles filesystem paths from readdir results and user-supplied cwd/patterns, but the edits do not introduce new path parsing or trust boundaries: joinPrefix is only applied to names returned by readdir (never ./../separator-containing), and the literal-segment branch, where user-supplied .. can appear, still goes through join. The exclude callback path still resolves against #root before calling statSync. The prefetch only starts readdir on paths the walk would already read.

Level of scrutiny

Medium. The per-entry string work is mechanical and the equivalence argument for joinPrefix holds for the shapes the walk produces (join(dir, sep) normalizes to a trailing separator on both path.posix and path.win32, and drive/UNC roots stay intact). The read-ahead is the part that deserves a human eye: it relies on Cache.readdir being idempotent per path and on the walker awaiting the same cached promise, so result ordering cannot change. I traced readsDirectory against the firstPattern fast path in #iterateSubpatterns and they agree, and cachedDirent correctly returns undefined for pending stat promises and for null (stat failure) so ?.isDirectory() skips those. The rejection-handler design ($then(undefined, nullOnReject) on the prefetch while the walk awaits its own readdir(path)) is exercised by the two "fails to sort" subprocess tests, which would produce an unhandled-rejection exit without the handler.

Other factors

The new tests cover the async-equals-sync property across seven pattern shapes (including a literal-segment pattern and a missing-directory pattern), withFileTypes, early consumer break, and the exact read-ahead window. The vendored upstream test-fs-glob.mjs suite is unchanged and the PR claims it passes; that cannot be confirmed from this checkout without a build. The one ruled-out candidate is a misleading test comment about the fixture shape, which does not affect the assertion's correctness. No CODEOWNERS entry covers these files. Because this is a non-trivial change to a hot compat path with an order-sensitivity argument that a human should confirm they agree with, deferring rather than approving is the appropriate outcome.

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

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
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.

2 participants