Skip to content

resolver: resolve the entry point by stat instead of reading its directory - #32392

Closed
robobun wants to merge 1 commit into
mainfrom
farm/c9d344b6/entry-point-stat-resolve
Closed

robobun wants to merge 1 commit into
mainfrom
farm/c9d344b6/entry-point-stat-resolve

Conversation

@robobun

@robobun robobun commented Jun 16, 2026 •

Copy link
Copy Markdown
Collaborator

What

Running bun <script> resolves the entry point by reading its parent directory's entire listing to (1) confirm the file exists and (2) locate the enclosing package.json/tsconfig.json. That work is O(entries in the directory), so startup scales with the number of siblings.

Fixes #32389: a script under a large immutable tree such as /nix/store starts 10-16x slower than the same script in a small directory. The same directory read also makes bun fail on a directory that is searchable but not listable (mode 0711), even though the file itself is readable and Node runs it fine.

Repro

$ echo 'console.log("test")' > pkgs.js
$ time bun ./pkgs.js                         # small dir
real    0m0.039s
$ nix store add-file ./pkgs.js
/nix/store/ffy4qxjb9cd8sjl44736mibyxw99bmzx-pkgs.js
$ time bun /nix/store/ffy4qxjb9cd8sjl44736mibyxw99bmzx-pkgs.js
real    0m0.626s                             # ~16x slower, same file

Synthetic confirmation: startup grows linearly with the sibling count (60k entries ≈ 3x a 1-entry dir on a release build), independent of script content.

Cause

Entry-point resolution goes VirtualMachine::_resolve → resolve_and_auto_install → load_as_file (read_directory on the parent) → finalize_result → dir_info_cached_miss (builds the parent DirInfo). Both steps only need a handful of named lookups (the entry file, package.json, tsconfig.json, jsconfig.json, node_modules), but the esbuild-derived cache reads and interns the whole directory to answer them.

Fix

The entry point's specifier is a canonicalized absolute path (from get_fd_path), so its exact basename is the correct on-disk name. When resolving it:

  • load_as_file stats dir/base once and returns if it is a regular file (anything else falls through to the normal directory read).
  • dir_info_uncached resolves the four config markers by direct stat instead of a map lookup.

A new DirEntry.complete flag marks an entry created without a readdir; read_directory_with_iterator and entries_at re-read it to a full listing before any consumer iterates it (shell completions, routers, the glob walker, the test scanner), so the cache's completeness invariant is preserved. The fast path is gated to the main entry via Resolver.entry_point_hint, so ordinary relative/package imports and the bundler are unchanged, and case-insensitive filesystems are unaffected (the canonical entry path already carries the on-disk case).

Verification

test/js/bun/resolve/bun-main-entry-point.test.ts runs an entry point under a 0711 (searchable, non-listable) directory. As root the directory read is bypassed by the kernel, so the child is dropped to nobody via setpriv (Linux-only, skipped otherwise):

  • before: error: Module not found (the resolver tries to readdir the directory)
  • after: the script runs

A listable-directory control under the same privilege drop isolates the assertion from setup failures. With the fix, a 60k-entry directory starts in the same time as a 1-entry directory.

Existing resolver suites (resolve, resolve-ts, require, resolve-error, tsconfig/long-path overflow) pass, and entry points whose directory carries a tsconfig.json (path aliases) or package.json (#imports) still pick up that config via the stat path.


no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/resolve/bun-main-entry-point.test.ts

@robobun

robobun commented Jun 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 PM PT - Sep 7th, 2026

❌ @robobun, your commit 75f1b95 has 3 failures in Build #112308 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32392

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

bun-32392 --bun

@coderabbitai

coderabbitai Bot commented Jun 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds an entry_point_hint fast path to Bun's resolver that avoids readdir on the parent directory when resolving the process entry point. A new complete: bool field on DirEntry tracks incomplete directory listings; Resolver gains an entry_point_hint: Option<&'static [u8]> path hint; dir_info_cached_miss skips opening the leaf directory when the hint is active; dir_info_uncached uses a new marker_kind! macro to stat config markers directly when the listing is incomplete; load_as_file short-circuits to a single stat; and VirtualMachine::_resolve activates the hint for absolute entry-point specifiers.

Changes

Entry-point directory enumeration optimization

Layer / File(s) Summary
DirEntry.complete flag and cache reuse contract
src/resolver/fs.rs, src/resolver/dir_info.rs
DirEntry gains a complete: bool field defaulted to true. entries_at re-reads the directory when the entry is stale or incomplete. read_directory_with_iterator bypasses the cache for incomplete entries. DirInfo::get_entries_const is rerouted through get_entries_ref(0) so incomplete entries are upgraded before iteration.
entry_point_hint field and lazy-leaf dir skipping
src/resolver/resolver.rs
Resolver gets an entry_point_hint: Option<&'static [u8]> field initialized to None in for_worker and init1. dir_info_cached_miss derives a lazy_leaf mode based on the hint being active and the leaf queue position; when active, the leaf directory's open and readdir calls are skipped and the resulting DirEntry is marked incomplete.
marker_kind! macro and config-marker stat fallback
src/resolver/resolver.rs
dir_info_uncached introduces entries_complete and a marker_kind! macro that reads from the cached listing when complete or performs a direct kind/stat when incomplete. Applied to node_modules detection, package.json existence and parsing (preserving existing branching and logging), and tsconfig.json/jsconfig.json detection.
load_as_file fast path and VirtualMachine._resolve hint activation
src/resolver/resolver.rs, src/jsc/VirtualMachine.rs
load_as_file adds an early return that performs a single kind lookup when entry_point_hint is set, returning a LoadResult immediately for regular files without opening the parent directory. VirtualMachine::_resolve sets entry_point_hint when source == MAIN_FILE_NAME with an absolute specifier and uses a scopeguard to reliably clear it on all exit paths.
Tests for non-listable parent directory
test/js/bun/resolve/bun-main-entry-point.test.ts
Adds imports for isLinux and chmodSync. Introduces runAsNobody privilege-dropping helper and new tests verifying entry-point resolution succeeds with non-listable parent directories (mode 0711), a listable control (mode 0755), and symlinked entry points that resolve relative imports from the real target directory.

Suggested reviewers

  • alii
  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: using stat instead of directory reading for entry point resolution, which directly addresses the core performance optimization.
Linked Issues check ✅ Passed The PR fully addresses issue #32389 by eliminating the O(directory_size) directory read for entry point resolution, enabling 10-16x faster startup on large directories and supporting 0711 non-listable directories like Node.js does.
Out of Scope Changes check ✅ Passed All changes are directly scoped to resolving the entry point efficiently: VirtualMachine entry-point guard, DirEntry.complete flag, lazy leaf handling, load_as_file fast path, and marker resolution via stat—no extraneous modifications detected.
Description check ✅ Passed The PR description covers the required purpose and verification details, even though it uses different section headings than the template.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/resolver/resolver.rs (1)

6179-6187: ⚠️ Potential issue | 🟠 Major

Fix bin-folder discovery to use the same direct-stat path when entries are incomplete.

The marker_kind! macro correctly handles incomplete entries by falling back to direct stat (line 6137–6144). However, the bin-folder discovery at line 6187 requires entries!().has_comptime_query(b"node_modules"), which will always fail when the lazy leaf's entry map is incomplete — even though marker_kind! on line 6179 succeeded via direct stat and set HasNodeModules true.

When entries_complete is false (incomplete lazy leaf), node_modules can be proven to exist by direct stat but the subsequent has_comptime_query check skips the .bin discovery anyway, losing the capability that the first check proved.

Replace the check at line 6187 with the same condition-and-fallback logic marker_kind! uses, or extract the direct-stat path into a shared helper.

🤖 Prompt for 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.

In `@src/resolver/resolver.rs` around lines 6179 - 6187, The bin-folder discovery
check uses a condition that fails for incomplete lazy leaf entries, even when
marker_kind! on the preceding check successfully proved node_modules exists via
direct stat. Replace the has_comptime_query check in the bin-folder discovery
block with the same condition-and-fallback logic that marker_kind! uses (the
direct stat path for incomplete entries), or extract that fallback logic into a
shared helper function to avoid the duplication and ensure consistent behavior
when entries are incomplete.
🤖 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/resolver/resolver.rs`:
- Around line 5756-5779: The RealFS::kind() call in the entry_point_hint fast
path is passed self.store_fd which allows it to acquire and cache a file
descriptor, but the returned LoadResult hardcodes file_fd: FD::INVALID instead
of propagating cache.fd like the normal resolution path does. Replace the
hardcoded FD::INVALID in the LoadResult with cache.fd to properly propagate the
file descriptor that RealFS::kind() may have opened and cached when
store_fd=true.

---

Outside diff comments:
In `@src/resolver/resolver.rs`:
- Around line 6179-6187: The bin-folder discovery check uses a condition that
fails for incomplete lazy leaf entries, even when marker_kind! on the preceding
check successfully proved node_modules exists via direct stat. Replace the
has_comptime_query check in the bin-folder discovery block with the same
condition-and-fallback logic that marker_kind! uses (the direct stat path for
incomplete entries), or extract that fallback logic into a shared helper
function to avoid the duplication and ensure consistent behavior when entries
are incomplete.
🪄 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: b51b1b9f-82ba-405d-8a19-bac661a6dcd4

📥 Commits

Reviewing files that changed from the base of the PR and between 78f0fff and 401dc47.

📒 Files selected for processing (5)
  • src/jsc/VirtualMachine.rs
  • src/resolver/dir_info.rs
  • src/resolver/fs.rs
  • src/resolver/resolver.rs
  • test/js/bun/resolve/bun-main-entry-point.test.ts

Comment thread src/resolver/resolver.rs Resolved

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/resolver/resolver.rs (1)

627-633: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Scope entry_point_hint to the actual entry path.

The hint is a boolean, so while the VM guard is active, any nested load_as_file() call can take this stat-only path. Directory entry points can re-enter here via package.json main/browser/index resolution, where path is package metadata rather than the canonical CLI specifier; on case-insensitive filesystems this can return the metadata spelling with diff_case: None and bypass the normal directory-entry lookup for a non-entry file. Store the hinted file/dir path and require path == hint_path for the file shortcut, and the matching parent directory for the lazy-leaf shortcut.

Also applies to: 4435-4436, 5761-5767

🤖 Prompt for 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.

In `@src/resolver/resolver.rs` around lines 627 - 633, Replace the boolean
`entry_point_hint` field with a field that stores the actual canonicalized entry
path (or None if not set). At the anchor location (resolver.rs 627-633), change
the field to store the hinted file/directory path instead of just a boolean. At
the sibling location (resolver.rs 4435-4436), update the `load_as_file`
stat-only shortcut to only apply when the current `path` parameter exactly
matches the stored hint_path. At the other sibling location (resolver.rs
5761-5767), update the lazy-leaf shortcut logic to additionally verify that the
current path's parent directory matches the hint's parent directory before
applying the optimization.
🤖 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.

Outside diff comments:
In `@src/resolver/resolver.rs`:
- Around line 627-633: Replace the boolean `entry_point_hint` field with a field
that stores the actual canonicalized entry path (or None if not set). At the
anchor location (resolver.rs 627-633), change the field to store the hinted
file/directory path instead of just a boolean. At the sibling location
(resolver.rs 4435-4436), update the `load_as_file` stat-only shortcut to only
apply when the current `path` parameter exactly matches the stored hint_path. At
the other sibling location (resolver.rs 5761-5767), update the lazy-leaf
shortcut logic to additionally verify that the current path's parent directory
matches the hint's parent directory before applying the optimization.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 52922a2b-7921-4469-bc0f-b37225d3ef14

📥 Commits

Reviewing files that changed from the base of the PR and between 401dc47 and 92a53bb.

📒 Files selected for processing (1)
  • src/resolver/resolver.rs

@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the entry-point hint scoping in f57a249.

The hint is now the canonicalized entry path (was a boolean). The load_as_file shortcut fires only when the path being resolved equals that exact path, and the dir_info_cached_miss lazy leaf only when the leaf is the entry's own parent directory. Nested resolutions during the entry resolve (a directory entry's index/main, a browser-field remap) now stay on the normal directory-listing path, so on-disk case is preserved on case-insensitive filesystems. The "canonical path, so its basename is the correct on-disk name" justification only holds for that exact path, which this enforces.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/resolver/resolver.rs (1)

5777-5802: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don’t let symlinked entry points take the stat-only shortcut.

A symlink to a regular file can satisfy this EntryKind::File check and return before the normal directory-entry path records symlink metadata. Later symlink finalization depends on finding the basename in the directory entries around Line 1746, but the lazy leaf stores an incomplete empty listing, so a symlinked main can resolve relative imports from the symlink directory instead of the real target. Fall through for symlinks, or populate/return the realpath state here and add a symlinked-entry regression test.

🤖 Prompt for 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.

In `@src/resolver/resolver.rs` around lines 5777 - 5802, The current code takes a
fast path optimization when finding the entry-point file, but this shortcut
incorrectly handles symlinks. When the entry kind is determined to be a File, a
symlink to a file will also match this check, causing the code to return early
without recording proper symlink metadata. Later symlink finalization depends on
finding the basename in directory entries but gets an incomplete listing. Add an
additional check after verifying that cache.kind equals EntryKind::File to
ensure the entry is not a symlink. If it is a symlink, skip the dec_ret! early
return and fall through to the normal directory-entry path that properly handles
symlink resolution and metadata.
🤖 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.

Outside diff comments:
In `@src/resolver/resolver.rs`:
- Around line 5777-5802: The current code takes a fast path optimization when
finding the entry-point file, but this shortcut incorrectly handles symlinks.
When the entry kind is determined to be a File, a symlink to a file will also
match this check, causing the code to return early without recording proper
symlink metadata. Later symlink finalization depends on finding the basename in
directory entries but gets an incomplete listing. Add an additional check after
verifying that cache.kind equals EntryKind::File to ensure the entry is not a
symlink. If it is a symlink, skip the dec_ret! early return and fall through to
the normal directory-entry path that properly handles symlink resolution and
metadata.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1552e2c7-1900-40c5-bc34-89676c4e9e95

📥 Commits

Reviewing files that changed from the base of the PR and between 92a53bb and f57a249.

📒 Files selected for processing (2)
  • src/jsc/VirtualMachine.rs
  • src/resolver/resolver.rs

Comment thread src/resolver/resolver.rs Resolved

@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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
test/js/bun/resolve/bun-main-entry-point.test.ts (1)

33-40: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Pin the dropped-privilege child to an accessible cwd.

setpriv drops to uid/gid 65534, but the child still inherits the test runner’s cwd. If the checkout/workdir is not traversable by nobody, Bun can fail before exercising the entry script. Set cwd: "/tmp" alongside HOME: "/tmp" so this stays hermetic.

As per coding guidelines, tests must be hermetic and subprocess tests should isolate setup from host-specific state.

🧪 Proposed fix
   await using proc = Bun.spawn({
     cmd: ["setpriv", `--reuid=${NOBODY}`, `--regid=${NOBODY}`, "--clear-groups", bunExe(), scriptPath],
     // HOME points at a world-writable dir so `nobody` can reach any cache/config.
     env: { ...bunEnv, HOME: "/tmp" },
+    cwd: "/tmp",
     stdout: "pipe",
     stderr: "pipe",
   });
🤖 Prompt for 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.

In `@test/js/bun/resolve/bun-main-entry-point.test.ts` around lines 33 - 40, The
runAsNobody function spawns a child process with dropped privileges but doesn't
set the cwd for the child process, causing it to inherit the test runner's
current working directory which may not be traversable by the nobody user. Add
cwd: "/tmp" to the options object passed to Bun.spawn() alongside the existing
HOME: "/tmp" environment variable to ensure the child process runs from a
directory accessible to the unprivileged user and remains hermetic.

Source: Coding guidelines

🤖 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/bun/resolve/bun-main-entry-point.test.ts`:
- Around line 82-88: The multi-line comment block describing the symlink test
behavior (starting at line 82 in bun-main-entry-point.test.ts) describes the old
behavior where the stat fast path sees through the symlink. Update this comment
to reflect the new intended behavior where symlinked entry points fall through
to the normal directory-listing path instead. The test code itself is correct;
only the documentation comment needs to be updated to accurately describe what
the test validates.

---

Outside diff comments:
In `@test/js/bun/resolve/bun-main-entry-point.test.ts`:
- Around line 33-40: The runAsNobody function spawns a child process with
dropped privileges but doesn't set the cwd for the child process, causing it to
inherit the test runner's current working directory which may not be traversable
by the nobody user. Add cwd: "/tmp" to the options object passed to Bun.spawn()
alongside the existing HOME: "/tmp" environment variable to ensure the child
process runs from a directory accessible to the unprivileged user and remains
hermetic.
🪄 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: ea734922-ea78-47ee-bf38-cbbc4a4b48a0

📥 Commits

Reviewing files that changed from the base of the PR and between f57a249 and 5b568ce.

📒 Files selected for processing (2)
  • src/resolver/resolver.rs
  • test/js/bun/resolve/bun-main-entry-point.test.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: 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/bun/resolve/bun-main-entry-point.test.ts`:
- Around line 36-39: The test currently uses the shared global /tmp directory
for both the cwd and HOME environment variable when spawning the nobody child
process, which creates a dependency on host-local state and violates test
hermeticity. Instead of hardcoding "/tmp", create a disposable temporary
directory that is world-accessible (with appropriate permissions so the nobody
user can traverse and access it) before the test runs, use this directory path
for both the cwd property and the HOME environment variable in the env object,
and ensure the temporary directory is cleaned up after the test completes or in
a cleanup handler to leave no artifacts behind.
🪄 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: 2eeeb687-dd9f-4215-a869-1ece0ebf7e11

📥 Commits

Reviewing files that changed from the base of the PR and between 5b568ce and 8979044.

📒 Files selected for processing (1)
  • test/js/bun/resolve/bun-main-entry-point.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.

No bugs found and all prior feedback is addressed, but this threads a new DirEntry.complete invariant and a lifetime-erased entry_point_hint through the core resolver hot path — worth a human pass on the cache-completeness contract and the get_entries_const re-read behavior change.

Extended reasoning...

Overview

This PR changes how Bun resolves the process entry point so that bun <script> no longer enumerates the script's parent directory. It touches four production files: src/jsc/VirtualMachine.rs (sets/clears a new entry_point_hint around _resolve via a scopeguard over a raw *mut Self), src/resolver/resolver.rs (~250 lines: new entry_point_hint field, lazy_leaf gate in dir_info_cached_miss, a marker_kind! macro in dir_info_uncached, and a stat-only fast path in load_as_file), src/resolver/fs.rs (new DirEntry.complete flag plus re-read of incomplete entries in entries_at and read_directory_with_iterator), and src/resolver/dir_info.rs (get_entries_const rerouted through get_entries_ref(0) so incomplete entries are upgraded before iteration). Three new tests cover a 0711 non-listable directory under a setpriv-dropped nobody child, a listable control, and a symlinked entry point.

Security risks

None that stand out. The fast path is gated on source == MAIN_FILE_NAME with an exact byte-equality match against a canonicalized absolute path, and only short-circuits when kind == File with no symlink involved; everything else falls through to the existing path. No auth, crypto, or permission surfaces are touched.

Level of scrutiny

High. The resolver is the production-critical path that runs on every bun <script> startup, and this change introduces a new cross-cutting invariant: DirEntry.complete == false means the cached listing is a lie, and every consumer that iterates data must upgrade it first. The PR routes the known iteration entry points (entries_at, read_directory_with_iterator, get_entries_const) through the upgrade, but a human should confirm no other consumer reads DirEntry.data directly. Separately, get_entries_const previously did a pure index lookup and now (via entries_at) can issue a filesystem re-read — a behavior change worth a glance at its callers. The entry_point_hint uses unsafe { bun_ptr::detach_lifetime(...) } to erase a borrow to 'static, cleared by a scopeguard holding a raw *mut Self; the SAFETY comments are convincing, but unsafe lifetime erasure in a long-lived struct field is exactly the kind of thing that benefits from a second pair of eyes.

Other factors

The author has addressed every prior review comment (CodeRabbit's fd-propagation and hermetic-tempdir notes; my three nits on _stderr, reserve(64) placement, and .concurrent), and the bug-hunting system found nothing on the latest revision. The PR description is thorough, the code is well-commented, and the scoping fix in f57a249 (hint as exact path rather than a flag) is the right shape. The two CI failures in robobun's report are pre-existing build-rust linker warnings and musl build-cpp infra failures, unrelated to this diff. This looks correct to me, but the combination of a new cache invariant, a semantic change to a "const" accessor, and unsafe lifetime erasure across ~300 lines of resolver internals puts it outside what I'd approve without a human reviewer.

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

No issues found on this pass, but this reworks the resolver's directory-cache completeness contract (new DirEntry.complete flag, lazy-leaf skip, marker_kind! stat fallback) plus an unsafe lifetime-erased hint in VirtualMachine::_resolve, and Build #62801 is still showing failures — worth a maintainer look before merge.

Extended reasoning...

Overview

This PR optimizes bun <script> startup by resolving the process entry point via a single stat instead of readdir-ing its parent directory. It touches five Rust files in the resolver/VM (src/resolver/{resolver,fs,lib,dir_info}.rs, src/jsc/VirtualMachine.rs) and adds four tests to test/js/bun/resolve/bun-main-entry-point.test.ts. The core mechanism is a new DirEntry.complete: bool flag marking cache entries created without a full readdir, an entry_point_hint: Option<&'static [u8]> on Resolver that gates a load_as_file short-circuit and a dir_info_cached_miss lazy-leaf skip, and a marker_kind! macro in dir_info_uncached that resolves package.json/tsconfig.json/jsconfig.json/node_modules by direct stat when the listing is incomplete. Three live-read paths (entries_at in both fs.rs and lib.rs, read_directory_with_iterator) and get_entries_const were updated to upgrade an incomplete entry to a full read before any consumer iterates it.

Security risks

None identified. The change is a read-path optimization; it does not touch auth, crypto, network, or permissions. The new setpriv-as-nobody test helper drops privileges (does not escalate) and is gated to Linux+root CI lanes. The unsafe { detach_lifetime(...) } in VirtualMachine::_resolve is a soundness concern rather than a security one — the scopeguard clears the hint on every exit path, and the comment documents why the borrow cannot outlive _resolve.

Level of scrutiny

High. The module resolver is on the hot path of every bun invocation and every import/require, and this PR changes its directory-cache completeness invariant: any consumer that iterates a DirEntry's map without going through one of the upgraded re-read paths would now silently miss files when the entry-point's own directory is involved. The PR appears to cover the known iteration sites (and the new "incomplete-cache upgrade" test exercises one), but verifying that coverage is complete — including the bundler, glob walker, router, shell completions, and test scanner mentioned in the description — is exactly the kind of cross-cutting check a maintainer familiar with the resolver should do. The care_about_bin_folder interaction, case-insensitive-FS handling, and the raw-pointer scopeguard in _resolve each add a dimension that benefits from human review.

Other factors

  • CI: robobun reports failures in Build #62801 on commit 7bcc1b1; the only commit since (d1f00de) adds failure-path stderr surfacing to two tests and would not fix a substantive failure. The red build should be understood before merge.
  • Review history: All seven inline threads (CodeRabbit's fd-propagation, comment-accuracy, hermetic-tempdir notes; my four test-hygiene nits) are resolved, and the author has been responsive and precise in each fix. The entry_point_hint was already tightened from a boolean to an exact-path match (f57a249) to keep nested resolutions on the normal path.
  • Tests: Good coverage of the new behavior (0711 non-listable dir, listable control, symlinked entry, incomplete-cache upgrade), though the two privilege-drop tests only run on Linux-as-root lanes.
  • Not simple/mechanical: ~300 lines of resolver logic change with a new cache invariant and unsafe lifetime handling — outside the bar for bot auto-approval.

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

All prior review feedback is addressed and the bug hunter found nothing, but this threads a new DirEntry.complete invariant through the resolver's directory cache and adds an unsafe lifetime-erased hint in VirtualMachine::_resolve — worth a maintainer's eyes on the cache-completeness contract and the scopeguard before merge.

Extended reasoning...

Overview

This PR changes how bun <script> resolves its entry point: instead of readdir-ing the script's parent directory (O(siblings)), it stats the single canonicalized path and probes the four config markers (package.json, tsconfig.json, jsconfig.json, node_modules) directly. Touches src/resolver/resolver.rs (~250 lines: entry_point_hint field, lazy_leaf gating in dir_info_cached_miss, marker_kind! macro in dir_info_uncached, fast path in load_as_file), src/resolver/{fs.rs,lib.rs,dir_info.rs} (new DirEntry.complete flag and re-read-on-incomplete in entries_at / read_directory_with_iterator / get_entries_const), src/jsc/VirtualMachine.rs (sets/clears the hint around the entry resolve), and adds four tests.

Security risks

None identified. The change narrows when a directory is enumerated; it does not relax any permission check, parse untrusted input, or expose data. The new unsafe in VirtualMachine::_resolve (detach_lifetime on normalized_specifier + a raw *mut Self captured by a scopeguard) is scoped to the duration of _resolve and the guard clears the hint on every exit, so the erased borrow cannot outlive its backing — but that argument is exactly what a maintainer should verify.

Level of scrutiny

High. The module resolver is on the hot path of every process start, and this introduces a new cache invariant: a DirEntry may now be stored with complete = false and an empty data map, and every consumer that iterates or does name lookups must upgrade it first. The PR routes get_entries_const through entries_at, guards the .bin-folder probe on entries_complete, and re-reads in read_directory_with_iterator / entries_at — but confirming there is no other read path that bypasses these (bundler, glob walker, test scanner, hot-reload watcher) is the kind of whole-program check a maintainer is better placed to do. The fast path is also gated on subtle conditions (!care_about_bin_folder, exact path equality with the canonicalized hint, regular-file-and-not-symlink) whose interaction with case-insensitive filesystems and bun run is reasoned about in comments but not mechanically enforced.

Other factors

All seven prior review threads (mine and CodeRabbit's) are resolved; the author was responsive to each. The bug-hunting system found nothing on the current revision. Test coverage is good for the new behaviour (0711 directory under nobody, listable control, symlinked entry, incomplete-cache upgrade via require("../pkg")), and the PR description states the existing resolver suites pass. The one CI failure (autobahn.test.ts on macOS) is unrelated to this change. Given the scope — resolver internals, a new cache-completeness contract, and new unsafe — this should get a human sign-off rather than a bot approval.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Remove comments. Remove unsafe.

@robobun

robobun commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator Author

Done in a2224e9.

  • Removed the unsafe. entry_point_hint is now an owned Option<Box<[u8]>> instead of a lifetime-erased &'static [u8], so there is no more detach_lifetime. The resolve loop in VirtualMachine::_resolve now break Err(..)s on its error paths instead of early-returning, so all paths converge after the loop where the hint is cleared, dropping the raw-pointer scopeguard. The only unsafe left in the diff is the existing &mut *rfs reborrow idiom that load_as_file/dir_info_uncached already use.
  • Removed the comments added across the resolver and VM changes.

Tests still pass (7/7 in bun-main-entry-point.test.ts).

@robobun
robobun force-pushed the farm/c9d344b6/entry-point-stat-resolve branch from a2224e9 to 3aa8374 Compare July 11, 2026 22:31
@robobun

robobun commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on main (3aa8374). One conflict, in VirtualMachine::_resolve: main moved the function to the typed crate::CrateError instead of bun_core::err!. Resolved by keeping the typed error and this PR's loop structure, so the error arms are now break Err(crate::CrateError::ModuleNotFound) / break Err(e.into()) and the entry_point_hint is still cleared at the single point after the loop. Tests pass (7/7 in bun-main-entry-point.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.

No bugs found, but this touches the resolver's directory-cache invariants (new DirEntry.complete state, incomplete-entry upgrade paths, entry-point fast path in load_as_file/dir_info_cached_miss) and reroutes get_entries_const through the re-reading entries_at — worth a human look given how many consumers depend on the cache's completeness contract.

Checked: the entry_point_hint set/clear in _resolve — every loop exit now converges on the post-loop = None, including the retry-after-cache-bust continue. The lazy_leaf gate requires !care_about_bin_folder, so the && entries_complete guards on the bin-folder scan are defensive rather than reachable in the same resolve. The load_as_file fast path falls through on symlinks and non-regular files, and passes store_fd=false so no fd is acquired to leak.

Extended reasoning...

Overview

The PR adds a stat-based fast path for resolving the process entry point, avoiding an O(n) readdir of its parent directory. It touches five resolver files: a new entry_point_hint: Option<Box<[u8]>> on Resolver, a complete: bool flag on DirEntry, a lazy_leaf branch in dir_info_cached_miss that skips the directory open/iterate for the entry's parent, a marker_kind! macro in dir_info_uncached that stats config markers directly when the listing is incomplete, an early-return in load_as_file, and control-flow restructuring in VirtualMachine::_resolve (early returns → break Err so the hint is always cleared). get_entries_const is rerouted through get_entries_ref(0) so incomplete entries are upgraded before any consumer iterates them. Four new tests cover the 0711 directory case (via setpriv/nobody), a listable-directory control, symlinked entry points, and the incomplete-cache upgrade path.

Security risks

None identified. The change is a read-path optimization gated to the main entry point's canonicalized absolute path; it does not touch auth, crypto, network, or user-controlled parsing. The setpriv privilege drop is test-only.

Level of scrutiny

High. The module resolver is on the critical path of every bun <file> invocation, and this change introduces a new cache state (complete = false) with an implicit contract that every consumer that iterates a DirEntry must go through a path that upgrades it first. The PR handles the known consumers (entries_at, read_directory_with_iterator, get_entries_const), but validating that the upgrade is exhaustive across shell completions, routers, glob walker, test scanner, bundler, --hot, and workers is exactly the kind of cross-cutting invariant a maintainer familiar with the resolver's clients should confirm. Rerouting get_entries_const through entries_at also means a formerly pure lookup can now perform I/O, which is a behavior change worth a maintainer's eye.

Other factors

All prior inline review threads (fd propagation, hint scoping, unsafe lifetime erasure, test hygiene) are resolved. The final revision replaced the lifetime-erased &'static [u8] hint with an owned Box<[u8]> and dropped the scopeguard, which removes the last new unsafe beyond the pre-existing &mut *rfs reborrow idiom. The bug-hunting pass found nothing. The change is well-scoped and well-tested for its stated goal, but its blast radius (resolver cache semantics) puts it outside what I'd approve without a human reviewer.

@christopher-buss

christopher-buss commented Sep 4, 2026 •

Copy link
Copy Markdown

Windows confirmation of the diagnosis, plus one measurement that I think shows the fix is scoped too narrowly.

Short version: the cost is not specific to the entry point's own parent directory. A large directory anywhere above the script costs exactly the same, and this PR's fast path is gated to the entry point (entry_point_hint), so it would fix the /nix/store repro in #32389 and leave the general case unchanged.

I have not built this branch — this is black-box measurement on 1.4.0 read against the PR description, so treat the conclusion as a prediction to falsify, not a review finding. The falsifying test is cheap and I've named it at the end.

Environment

  • bun 1.4.0 (1.4.0+34cbb9a40), pre-fix
  • Windows 11 Pro 10.0.26100, NTFS
  • Medians of 25–30 spawns via .NET Process.Start, stdio redirected, two warmups, cases interleaved round-robin so drift and AV scanning hit every case equally. All probes exited 0.
  • Script is identical everywhere: process.exit(0)

The measurement that matters

One directory holding 50,000 files. I vary only how far above the script it sits:

script location big dir is... bun
big/exit.js its own parent 21.4 ms
big/c1/exit.js grandparent 20.9 ms
big/c1/c2/exit.js 2 levels up 20.8 ms
big/c1/c2/c3/c4/exit.js 4 levels up 20.8 ms

Same shape with every directory small, as a control:

script location bun
small/exit.js 11.4 ms
small/c1/exit.js 11.5 ms
small/c1/c2/exit.js 11.5 ms

The penalty is ~+9.5 ms and flat. Being the entry point's own parent is worth nothing extra — row 1 and row 4 are within noise of each other.

Only row 1 looks like what this PR fast-paths. Rows 2–4 cost the same and appear to go through the ancestor DirInfo chain (dir_info_cached_miss walking up), which the entry_point_hint gate excludes by design.

No project boundary stops the walk

I expected package.json or node_modules in the script's own directory to terminate the upward search. It does not:

script's own directory contains bun
nothing 20.8 ms
package.json 21.1 ms
package.json + node_modules/ 21.3 ms
(control: no big ancestor) 11.8 ms

So a normal, well-formed project nested anywhere under a large directory pays the full cost.

Why I noticed

%TEMP% on this machine holds 263,063 entries — and the script was in %TEMP%/bt/, i.e. the grandparent case, not the nix case:

cwd bun node deno
clean dir 11.9 ms 27.0 ms 59.0 ms
%TEMP%/bt (263k in grandparent) 100.1 ms 26.8 ms 59.5 ms

node and deno are flat; only bun moves, 8.4×. Scaling is roughly linear in sibling count, ~0.2 µs/entry:

ancestor entries bun node
0 11.9 ms —
5,000 12.6 ms —
50,000 21.8 ms 25.9 ms
263,063 ~100 ms 26.8 ms

Path depth on its own is free (a six-level-deep path under small parents measures 11.6 ms vs 11.4 ms at the top), and a different volume changes nothing. It is sibling count in some ancestor, full stop.

This inverted my benchmark result completely: bun looked ~6× slower than node at running a file, when in a clean directory on the same machine it is 2.2× faster (11.4 vs 25.0 ms), with a free TypeScript path (11.5 ms .ts vs 11.4 ms .js, against node's +15 ms for type stripping). Anyone benchmarking bun from /tmp, a CI scratch dir, or a cache directory is measuring the enumeration.

The cheap falsifying test

Your synthetic check ("60k entries ≈ 3× a 1-entry dir") puts the script inside the 60k directory. Re-run it with the script one level down:

big/                 <- 60k entries
big/c1/script.js

If the patched build still shows the full 3× there, the fast path is not covering the case that actually bites, and the fix likely needs to extend to the ancestor DirInfo walk — resolving the four markers (package.json, tsconfig.json, jsconfig.json, node_modules) by stat at every level, not only at the entry.

If it comes out flat, then I've misread the gating and this is just corroboration — say so and I'll withdraw the concern.

Happy to run the patched build against the 263k-entry directory on Windows if someone points me at an artifact.

…ctory

Resolving the process entry point (`bun <script>`) read the whole parent
directory to find the one file, so startup scaled with the sibling count
(slow under /nix/store) and failed outright when the directory was
searchable but not listable (mode 0711).

Hint the resolver with the entry point's canonicalized path while that
single specifier resolves. load_as_file then stats the one file, and
dir_info_cached_miss skips enumerating its parent, storing that directory's
listing as incomplete (DirEntry.complete == false). dir_info_uncached
probes the config markers (package.json, tsconfig.json, jsconfig.json,
node_modules) by direct stat when the listing is incomplete. The live read
paths (read_directory_with_iterator, entries_at_locked) re-read an
incomplete listing before any lookup, so a later import of that same
directory still finds its files. Bin-folder discovery in
dir_info_uncached is guarded on a complete listing.

The hint is an owned Box<[u8]> cleared at the single point after the
resolve loop, with the loop's error paths converging via break.

Fixes #32389
@robobun
robobun force-pushed the farm/c9d344b6/entry-point-stat-resolve branch from 3aa8374 to 75f1b95 Compare September 8, 2026 00:24
Comment thread src/resolver/fs.rs
Comment on lines +388 to +389
/// `false` marks a listing created without a full `readdir` (entry-point
/// fast path); consumers that iterate must re-read it first.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/resolver/resolver.rs
Comment on lines +4631 to +4632
// A permission-denied ancestor (or the lazy entry-point leaf)
// has no fd to enumerate; its entry set stays empty.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/resolver/resolver.rs
Comment on lines +4634 to +4637
// Pre-size `data` so the per-entry inserts below skip the
// 1→2→4→…→N hashbrown rehash cascade from an empty table. 64
// covers a typical node_modules package dir; larger dirs
// still rehash from there (cheap relative to starting at 0).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment thread src/resolver/resolver.rs
Comment on lines +6417 to +6419
// NOTE: store the raw `NonNull` field (not the
// `&'static` accessor result) so mut-provenance flows
// through to `enqueue_dependency_to_resolve`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun

robobun commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on main again (75f1b95), squashed into one commit. Main moved a lot under this PR, so a few resolutions are worth noting:

  • Locking refactor. Main renamed the live directory-cache read path to entries_at_locked and added get_entry for locked single lookups, with get_entries_const now explicitly unlocked and scalar-only. I no longer reroute get_entries_const through a re-reading accessor (that would take entries_mutex inside an accessor callers may already hold). The incomplete-listing re-read lives only in the two locked chokepoints, read_directory_with_iterator and entries_at_locked, where main already does the stale-generation re-read.
  • Dead fs_full bodies. Main deleted the unused RealFS method bodies in fs.rs. Took the deletion; only the DirEntry.complete field remains there, since that struct is the canonical type.
  • Opaque permission-denied ancestors. Main now treats an EPERM/EACCES ancestor as opaque and empty (open_dir = INVALID), but keeps the requested directory itself fatal. The lazy entry-point leaf also yields open_dir = INVALID, so the two share main's if open_dir.is_valid() iteration gate; the leaf additionally sets complete = false. These are complementary: main covers ancestors, this PR covers the entry point's own directory (the /nix/store and 0711 case).
  • LoadResult lost diff_case/dir_info; the fast-path constructor matches the new shape.

Verified: 7/7 in bun-main-entry-point.test.ts, and the full test/js/bun/resolve/ suite (393 pass; the two "load the same file 2000 times" debug-build timeouts are pre-existing). No new unsafe.

Comment thread src/resolver/lib.rs
// SAFETY: BSSMap-owned slot; uniquely held under `entries_mutex`.
if let EntriesOption::Entries(existing) = unsafe { &mut *result_ptr } {
if existing.generation < generation {
if existing.generation < generation || !existing.complete {

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 !existing.complete gate here makes entries_at_locked re-read the entry-point directory during the very resolve this PR is optimising: finalize_result unconditionally calls dir.get_entry(self.generation, name.filename) (resolver.rs:1588), which routes to entries_at_locked on the just-created complete=false slot and performs a full open+readdir — so a 60k-entry /nix/store still costs O(entries) at startup, contrary to the PR's stated fix for #32389. Fix: the incomplete-entry upgrade must not fire on the symlink probe inside the same entry-point resolve — e.g. skip the re-read while entry_point_hint names a child of dir, or resolve the entry's symlink via the stat result already obtained in load_as_file instead of get_entry.

Extended reasoning...

Trace on the base's stated failure case (bun /nix/store/…-pkgs.js): _resolve sets entry_point_hint; resolve_and_auto_install → load_as_file takes the new stat fast-path (resolver.rs:5791) and returns without touching read_directory. Control reaches finalize_result (resolver.rs:1404) → read_dir_info(name.dir) misses the DirInfo cache → dir_info_cached_miss sees lazy_leaf (resolver.rs:4415), stores a DirEntry{complete:false} in rfs.entries (4627-4698) and builds the DirInfo via marker_kind! stats. Back in finalize_result, line 1588 calls dir.get_entry(self.generation, name.filename) → DirInfo::get_entry → entries_at_locked(index, generation) (dir_info.rs:266). At lib.rs:1611 existing.generation == self.generation but !existing.complete, so it opens /nix/store with open_dir_for_iteration and runs self.readdir(...) over the whole listing — the exact O(entries) work the PR claims to remove. The 0711 test still passes because the open fails with EACCES and get_entry returns None, so it never observes that the readdir was attempted; there is no test…

Verification: normal — the fast path this PR adds is immediately undone within the same resolve, so the O(entries) readdir the PR exists to remove (issue #32389, the /nix/store case) still runs. Trace on bun /nix/store/…-pkgs.js: 1. load_as_file takes the new stat short-circuit (src/resolver/resolver.rs:5791-5811) and returns without touching rfs.entries. 2. resolve_and_auto_install then calls…

@robobun

robobun commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #48148 covers this case together with the other startup listings (the cwd, every ancestor, the entry point's directory, and the .env lookup) in one mechanism.

@robobun robobun closed this Nov 8, 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.

Slow startup when running Bun scripts from deep immutable paths (/nix/store) vs local filesystem

3 participants