Skip to content

resolver: take Entry.mutex when filling Entry.abs_path in load_as_file and siblings - #34411

Open
robobun wants to merge 4 commits into
mainfrom
farm/fcd9ffa1/resolver-abs-path-entry-mutex
Open

resolver: take Entry.mutex when filling Entry.abs_path in load_as_file and siblings#34411
robobun wants to merge 4 commits into
mainfrom
farm/fcd9ffa1/resolver-abs-path-entry-mutex

Conversation

@robobun

@robobun robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

test/js/bun/util/filesystem_router.test.ts went red on Debian 13 aarch64 in build 74037: the reload() while Bun.build() resolves the same directory subprocess segfaulted at address 0x31 after ~24ms.

Cause

Symbolizing the crash trace against the build's own bun-linux-aarch64-profile binary gives the bundle thread at:

memchr::...::rfind                       twoway.rs:372
bun_core::strings::last_index_of         src/bun_core/string/immutable.rs:713
Resolver::load_as_file_or_directory      src/resolver/resolver.rs:5435
Resolver::resolve_without_symlinks       src/resolver/resolver.rs:1934
Resolver::resolve_and_auto_install       src/resolver/resolver.rs:1447
Transpiler::_resolve_entry_point         src/bundler/transpiler.rs:444
BundleV2::enqueue_entry_points_normal    src/bundler/bundle_v2.rs:2973
BundleThread::generate_in_new_thread     src/bundler/BundleThread.rs:276

resolver.rs:5435 is strings::last_index_of(file.path, "node_modules/"), and file.path is what load_as_file returned from query.entry().abs_path.as_bytes(). That field was lazily filled at five sibling sites in the resolver (load_as_file plain path and js-to-ts rewrite, load_extension, load_index_with_extension, handle_esm_resolution), each with the shape

if query.entry().abs_path.is_empty() {
    unsafe { &mut *query.entry }.abs_path = Interned::from_static(...);
}
query.entry().abs_path.as_bytes()

None of them took the per-entry Entry.mutex, while Route::parse (src/router/lib.rs) filled the same field under that mutex; the accompanying SAFETY comments said "resolver mutex held", but load_as_file/load_extension/load_as_file_or_directory are reached from resolve_without_symlinks without ever taking RESOLVER_MUTEX.

Every reload() busts the directory out of both caches and dir_info_cached_miss re-reads it with in_place = None, so fresh Entry objects are appended with abs_path = Interned::EMPTY. The bundler's read_directory then rewrites that fresh DirEntry in place with prev = &mut D.data and reuses those same Entry objects. So the router (JS thread, under Entry.mutex) and the bundler's resolver (bundle thread, no lock) race to fill abs_path on the very same Entry.

Interned is a #[repr(transparent)] &'static [u8], i.e. a two-word (ptr, len). An unsynchronized two-word store/load pair is a data race; under aarch64's weak memory model the reader can observe the torn (old_ptr, new_len) combination that x86-TSO never produces, and last_index_of then indexes into a slice whose pointer and length do not belong together.

This race predates #34271; #34271's intensified test fixture (one priming build, then forty rounds of four concurrent Bun.build() calls against fifty reload()s) is what widened the window enough for CI to hit it.

Fix

Add Entry::abs_path_or_fill(&self, fill: impl FnOnce() -> Interned) -> &'static [u8] in src/resolver/fs.rs next to the existing Entry::kind / Entry::symlink helpers, which already implement the same double-checked-lock lazy-fill for the sibling cache / need_stat fields. Entry.abs_path becomes Cell<Interned> (matching cache: Cell<EntryCache> and need_stat: Cell<bool> on the same struct) so the helper writes through &self.

All five resolver fill sites collapse to query.entry().abs_path_or_fill(|| Interned::from_static(...)), dropping the per-site unsafe { &mut *query.entry } writes and the now-incorrect SAFETY comments. Route::parse does the equivalent single-acquire check+fill inline (its fill body can return None on I/O error, which a closure cannot), and the hot reloader's abs_path read is moved inside the Entry.mutex guard it already holds for set_cache_fd / need_stat. The per-site drift that let these five sites miss the lock #33056 established for every other Entry rewrite is what this centralization removes.

Lock order is unchanged: the helper nests Entry.mutex over the BSSStringList mutex dirname_store.append_slice takes inside the closure, and nothing under that append touches Entry.mutex, matching the entries_mutex -> Entry.mutex order #33056 documented.

Verification

  • bun bd test test/js/bun/util/filesystem_router.test.ts (29/29), resolve.test.ts (43/43), import-meta.test.js (32/32), framework-router.test.ts (35/35), hot.test.ts (12/12) all green.
  • bun run rust:check-all clean on all 10 targets.
  • The existing concurrency test now also asserts every router.routes value is a valid absolute .tsx path after each round, so a torn abs_path on the router side is observable as a wrong filePath rather than only as a subprocess crash.

The torn state requires a weakly-ordered CPU (x86-TSO only surfaces (new_ptr, old_len), which is the empty slice), so the fail-before half of the gate's probe cannot fire on the x64 gate host: 30 consecutive release runs and 15 debug+ASAN runs of the test pass there without this change. The aarch64 crash trace above is the direct evidence for the fail-before side.

…e and siblings

The five lazy abs_path fill sites in the resolver (load_as_file plain path,
load_as_file js-to-ts rewrite, load_extension, load_index_with_extension,
handle_esm_resolution) read/check/write Entry.abs_path without taking the
per-entry mutex. Route::parse fills the same field under that mutex. After a
reload() bust+reread both sides see the same fresh Entry and race to fill it;
abs_path is a two-word (ptr, len) slice, so on weakly-ordered CPUs the bundler
thread can read a torn value and pass it as LoadResult.path. The symbolized
stack from build 74037 (linux aarch64 release-profile) lands exactly at that
read:

  last_index_of                    src/bun_core/string/immutable.rs:713
  Resolver::load_as_file_or_directory  src/resolver/resolver.rs:5435
  Resolver::resolve_without_symlinks
  Resolver::resolve_and_auto_install
  Transpiler::_resolve_entry_point
  BundleV2::enqueue_entry_points_normal
  BundleThread::generate_in_new_thread

Serialize each fill on Entry.mutex (the invariant #33056 established for every
Entry rewrite) and return the locally-held interned slice instead of re-reading
the field. Route::parse's initial abs_path read is also moved under the same
mutex so it cannot observe a torn value either.

The race requires a weakly-ordered CPU to surface the torn (old-ptr, new-len)
combination; x86-TSO makes that ordering unreachable, so the existing
concurrency test passes with and without this change on x64 (30/30 release,
15/15 debug+ASAN). On aarch64 the test's subprocess segfaulted in build 74037.
@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:38 PM PT - Jul 16th, 2026

@robobun, your commit dbbfcff has 4 failures in Build #74159 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34411

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

bun-34411 --bun

@robobun

robobun commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator Author

Status

After review feedback the five per-site lock blocks are centralized into Entry::abs_path_or_fill (src/resolver/fs.rs), matching the existing Entry::kind / Entry::symlink double-checked-lock helpers for the sibling cache / need_stat fields on the same struct. Route::parse takes the lock once, and the hot reloader's abs_path read is inside the guard it already held. Net diff is +42/-94 in src/.

The torn-read requires the (old_ptr, new_len) ordering that x86-TSO does not produce, so the x64 gate host cannot observe the fail-before half of the probe (30/30 release and 15/15 debug+ASAN runs of the test pass there without this change). The aarch64 release-profile stack trace in the PR body is the direct evidence: it symbolizes to last_index_of(file.path, "node_modules/") where file.path is query.entry().abs_path.as_bytes(), i.e. one of the five sites this change locks.

All of filesystem_router.test.ts (29/29), resolve.test.ts (43/43), import-meta.test.js (32/32), framework-router.test.ts (35/35), hot.test.ts (12/12) pass under bun bd, and bun run rust:check-all is clean on all 10 targets.

CI on build 74159 is red on every lane for test/cli/install/bun-create.test.ts, a pre-existing break on main that is being handled separately; none of the failures there touch this diff. Ready for review.

@coderabbitai

coderabbitai Bot commented Jul 16, 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: 34 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: 55bad2f6-ba72-4abc-aec6-194d7ede26a7

📥 Commits

Reviewing files that changed from the base of the PR and between 3da13a6 and 6e4546a.

📒 Files selected for processing (3)
  • src/jsc/hot_reloader.rs
  • src/router/lib.rs
  • test/js/bun/util/filesystem_router.test.ts

Walkthrough

Changes

The resolver now synchronizes Entry.abs_path cache access across resolution paths. Route parsing uses the same lock, and the concurrent reload/build test verifies route paths remain valid.

Absolute-path synchronization

Layer / File(s) Summary
Guard resolver absolute-path caching
src/resolver/resolver.rs
Resolver paths lock entries while reading or populating cached absolute paths.
Synchronize route parsing and regression coverage
src/router/lib.rs, test/js/bun/util/filesystem_router.test.ts
Route parsing uses guarded reads, while the race test validates absolute .tsx route values.

Possibly related PRs

  • oven-sh/bun#34284: Also changes Route::parse and filesystem-router reload testing, but focuses on DirnameStore interning behavior.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly names the mutex change in resolver path filling and matches the core fix.
Description check ✅ Passed The description explains the bug, fix, and verification, though it doesn't use the template's exact headings.
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.

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 `@src/router/lib.rs`:
- Around line 1038-1045: Make the abs_path empty check and cache fill atomic in
the Route::parse flow. Re-read entry.abs_path() after acquiring the fill mutex,
immediately before recomputing or writing the path, and skip the fill if another
resolver already populated it. Do not rely on the earlier entry_abs_path
snapshot outside the critical section.
🪄 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: 647bb6c8-a23d-4750-b97a-2ee1b49e8a22

📥 Commits

Reviewing files that changed from the base of the PR and between 8c170f6 and 3da13a6.

📒 Files selected for processing (3)
  • src/resolver/resolver.rs
  • src/router/lib.rs
  • test/js/bun/util/filesystem_router.test.ts

Comment thread src/router/lib.rs Outdated
@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Intermittent Errors with Bun.build and Bun.FileSystemRouter when importing absolute paths #9517 - Describes intermittent resolver errors (Could not resolve, NotOpenForReading) when Bun.build() is called repeatedly with a FileSystemRouter present — exactly the concurrent FileSystemRouter + Bun.build() race that this PR fixes by taking Entry.mutex when filling abs_path.

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #9517

🤖 Generated with Claude Code

…te::parse

The initial locked read releases Entry.mutex before Route::parse's fill block
re-acquires it; the bundler's resolver may fill abs_path in between. Re-read
under the lock and skip the open + get_fd_path + intern when already populated.
Comment thread test/js/bun/util/filesystem_router.test.ts
Comment thread src/router/lib.rs Outdated
Same torn-(ptr, len) hazard as the router and resolver sites; the guard block
already exists for set_cache_fd/need_stat, so move the abs_path load inside it.

Also normalize pagesDir to forward slashes for the filesystem_router test's
path prefix check, since router.routes values are normalized via
platform_to_posix_buf on Windows.

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

Both earlier findings (Windows path.sep normalization in the test fixture, and the hot_reloader.rs abs_path read outside the guard) are addressed in 6e4546a. I didn't find further issues, but this adds per-entry locking across five resolver hot-path sites, so a human should confirm the lock-ordering claim (Entry.mutex over the dirname_store append lock) and that the added contention is acceptable.

Reviewed: the five abs_path fill sites in resolver.rs — each now returns the locally-held interned.as_bytes() instead of re-reading the field, so no torn read after write. Route::parse re-checks under the lock before recomputing (CodeRabbit's TOCTOU note). The pagesDirPosix fix uses path.sep evaluated in the subprocess, so the backslash is a runtime value, not a template-literal escape.

Extended reasoning...

Overview

Fixes a torn-read data race on Entry.abs_path (a two-word Interned = &'static [u8] fat pointer) that segfaulted on aarch64 CI. Five sibling fill sites in src/resolver/resolver.rs (load_as_file, its js→ts rewrite, load_extension, load_index_with_extension, handle_esm_resolution) now take Entry.mutex around the check-fill-return; Route::parse in src/router/lib.rs reads and re-checks under the same lock; src/jsc/hot_reloader.rs moves its abs_path read inside the existing guard block. The concurrency test in filesystem_router.test.ts gains a per-round assertion that every router.routes value is a valid absolute .tsx path.

Security risks

None. No user-controlled input parsing, auth, or crypto is touched — this is internal cache synchronization.

Level of scrutiny

High. The resolver is on every module-resolution path and is one of Bun's hottest code paths. Adding a mutex acquire/release to five sites there is mechanically simple but has performance and lock-ordering implications a maintainer should sign off on. The PR body asserts Entry.mutex nests over the BSSStringList mutex taken by dirname_store.append_slice and that nothing under that append touches Entry.mutex; that claim looks right from the diff but deserves a human eye given the CLAUDE.md guidance that "a mutex only counts if EVERY accessor takes it" and the #33056 lock-order precedent it cites.

Other factors

  • All three review threads (CodeRabbit's TOCTOU on Route::parse, my Windows startsWith separator mismatch, my hot_reloader.rs sibling read) are resolved in 05e17c8 and 6e4546a respectively; I re-checked each fix against the current diff.
  • The five resolver edits are byte-for-byte parallel (lock → snapshot cached → branch → write + return local), and each returns the locally-held interned slice rather than re-reading the shared field, so the write-then-read-torn window is closed even if another writer interleaves after the guard drops.
  • The test's pagesDirPosix = pagesDir.replaceAll(path.sep, "/") runs inside the spawned fixture, so path.sep is a runtime one-char string on Windows — no template-literal escaping issue.
  • rust:check-all and the four named test files are reported green; the fail-before half is only reproducible on weakly-ordered CPUs, which the PR body documents.

Move the double-checked lock+fill into a single Entry method in fs.rs, next to
the existing Entry::kind/symlink helpers that use the same pattern for the
sibling cache/need_stat fields. Entry.abs_path is now Cell<Interned> (matching
cache and need_stat) so the helper writes through &self.

The five resolver.rs fill sites and Route::parse's fill block now call the
helper (or do the equivalent single-acquire check+fill), dropping the
per-site unsafe raw writes and the redundant second lock in Route::parse.
Scanner.rs uses set_abs_path()/abs_path() for the Cell accessors.

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

I didn't find any bugs, but this is a concurrency/locking change across the resolver, router, hot-reloader, and test scanner, so it's worth a human look.

Checked that Cell<Interned> matches the existing cache/need_stat pattern under the unsafe impl Sync for Entry at fs.rs:1444, and that all remaining Entry.abs_path accessors (Scanner, hot_reloader, router, resolver) now go through the accessor methods.
Verified abs_path_or_fill doesn't nest with Entry::kind()'s mutex acquisition — resolver calls them sequentially.
Confirmed the router's 'fill block preserves the file-open/stat body and only adds the locked re-check; the entry-mutex → filename_store lock order already existed there.
The fill closures in resolver.rs run under entry.mutex and call into dirname_store/abs_buf — I didn't spot a reverse ordering, but someone who knows the resolver's lock hierarchy should confirm.

Extended reasoning...

Overview

This PR fixes a data race on Entry.abs_path in the resolver's shared file-system cache. abs_path is a two-word Interned (ptr + len) that was previously written via unsafe { &mut *entry } under an assumed "resolver mutex held" invariant, but read unlocked from the router (Route::parse) and hot-reloader — allowing torn (ptr, len) reads on weakly-ordered CPUs. The fix converts the field to Cell<Interned>, adds a centralized abs_path_or_fill() helper that does double-checked lazy fill under the per-entry mutex, and rewires 5 resolver sites plus the router, hot-reloader, and test scanner to use it. The existing concurrent reload() + Bun.build() test is extended to assert every route's filePath is a well-formed absolute path (the observable symptom of a torn read).

Security risks

None. This is internal locking discipline; no user-facing surface, parsing, or trust-boundary changes.

Level of scrutiny

High. The resolver's Entry cache sits under every import/require, Bun.build, FileSystemRouter, and --hot. The change alters lock acquisition on that path: the resolver now takes entry.mutex around each abs_path fill (previously it relied on the coarser resolver mutex per the deleted SAFETY comments), and Route::parse now unconditionally takes entry.mutex for the read where it previously had an unlocked fast path. The new fill closures run dirname_store.append_slice / fs_ref().abs_buf while holding entry.mutex — a lock-nesting order that appears consistent with the router's existing entry-mutex → filename_store.append ordering, but a maintainer who knows the resolver's threading model should confirm there's no reverse-order path.

Other factors

  • The Cell<T> + per-entry mutex + unsafe impl Sync pattern is not new here — cache: Cell<EntryCache> and need_stat: Cell<bool> on the same struct already work this way (fs.rs:264-269), and abs_path_or_fill mirrors Entry::kind()/symlink(). So the design is consistent with the file's established discipline.
  • Net -46/+59 across 5 source files, but the resolver.rs delta is almost entirely mechanical (5 identical unsafe-write-then-read blocks collapsed into the shared helper) and removes 5 unsafe { &mut * } sites whose SAFETY comments were arguably incorrect.
  • I grepped for remaining Entry.abs_path accesses: the other abs_path hits in router/lib.rs, dir_info.rs, cron.rs, bake/, bundle_v2.rs are on Route/DirInfo/other structs, not Entry. Scanner.rs still reads/writes without the mutex, but it holds &mut Entry (exclusive) during single-threaded test discovery.
  • Four incremental commits and no PR timeline metadata was available, so I couldn't check for prior review discussion.

Given the criticality of the resolver hot path and the change in lock-acquisition sites, this should get a human sign-off even though the implementation looks correct.

robobun added a commit that referenced this pull request Aug 9, 2026
…e stress test to ASAN builds

The new test plants an EACCES error in the directory-entry cache for a
watched directory (chmod 000 plus a failed resolve, dropping to nobody
via runuser when root) and then lands one directory event on it. Before
the fix the watcher thread fed the error slot to EntriesOption::entries,
which panics (EntriesOption::entries on non-Entries variant) and aborts
the process; deterministic on Linux, verified 5/5 red on the unfixed
build and 5/5 green on the fixed one.

The reload/build stress test also drives the resolver-side lookup races
that #37274 and #34411 fix, which segfault the fixture on weakly
ordered release lanes (ubuntu 25.04 aarch64, build 91033, sigsegv at
0x1E, 41ms in). Gate it to ASAN builds, where the watcher-side
use-after-free it guards against is detectable and those lanes are
x64.
Jarred-Sumner pushed a commit that referenced this pull request Aug 9, 2026
…ion rewrite (#37274)

Fixes #37266
Fixes #23773

## Crash

In [build 90948](https://buildkite.com/bun/bun/builds/90948) (ubuntu
25.04 aarch64), the `reload() while Bun.build() resolves the same
directory` subprocess in `test/js/bun/util/filesystem_router.test.ts`
died 22ms in with

```
panic: Segmentation fault at address 0x25
```

instead of printing `matches 2000 builds-ok true`. The branch did not
touch the resolver or router (its diff against main is empty for those
modules), and the same test passed in the surrounding ~9 runs of the
same code, so this is a low-probability race. It did not reproduce
locally: ~3000 stress runs of the fixture (linux x64 release and
debug+ASAN, windows aarch64 release, with cpu pinning, oversubscription,
and `MIMALLOC_PURGE_DELAY=0` variants) produced 0 failures, nor did ~150
further runs of a `Bun.resolveSync`-from-plugin variant modeled on
#23773. The buildkite artifact for that build has expired from the
network I can reach, so the trace could not be symbolized; the fix below
closes the remaining windows of the already-proven race class rather
than a bisected instruction.

#23773 is the same crash family observed in the wild on linux aarch64
(about 1 in 20 builds of a large project): `Segmentation fault at
address 0x0` with `Entry.symlink`'s cache access (`fs.zig:397`) inlined
into `finalizeResult`, reached from `Bun.resolveSync` inside a bundler
plugin's `onResolve`, which is the JS thread running `finalize_result`'s
unlocked probe concurrently with bundler-thread re-reads.

## Cause

`RealFS::entries_at` and `read_directory` rewrite a cached `DirEntry` in
place when a resolver with a newer generation re-reads the directory:
`data.clear()` plus `*e_ptr = new_entry`, which drops the old `data:
StringHashMap<*mut Entry>` and frees its bucket allocation. #34271 made
the `entries_at` rewrite take `entries_mutex`, and the route-loader
iteration sites already snapshot the map under the same lock. Three
windows remained:

1. **Unlocked map probes.** The resolver lookup sites probed `.data`
after the lock was released: `finalize_result`, `handle_esm_resolution`,
`load_index_with_extension`, `probe_wildcard_extensions`,
`Transpiler::run_env_loader` (all via `get_entries_ref`), and
`load_as_file` / `load_extension` (via the `EntriesOption` slot captured
after `read_directory` returned, with SAFETY comments incorrectly
claiming "entries_mutex held"). A concurrent stale-generation rewrite
frees the buckets mid-probe; a probe walking freed memory can load a
garbage `*mut Entry` and fault at a small absolute address, which
matches `0x25`. This is the same mechanism #34271's ASAN trace proved
fires in this test, just on the lookup side instead of the iteration
side, and `load_as_file` is the hottest instance (every relative-file
import).

2. **`need_stat` published before `cache`.** `Entry::kind`/`symlink`
cleared `need_stat` first and wrote the resolved `cache` after, with
plain `Cell` ops. The fast path reads `need_stat` without the per-entry
mutex, so on a weakly ordered CPU a reader could observe `need_stat ==
false` while the `cache` write was not yet visible, or mid-store: a torn
read of the 16-byte `Interned` symlink faults inside `as_bytes`. This
window needs entries whose kind is not known from `getdents` (symlinks,
or filesystems returning `DT_UNKNOWN`), which is also consistent with
firing on CI but not on local ext4/tmpfs/NTFS.

3. **Recycled entries overwrote a live cache.** When a re-read's
`getdents` result disagreed with the cached kind, `add_entry_with_store`
rewrote `cache` (kind plus symlink) in place under the per-entry mutex.
A lock-free reader that had already observed `need_stat == false` reads
`cache` without that mutex, and no store ordering can publish anything
to a reader that loaded the stale flag, so the overwrite could tear a
populated `Interned` under it. On a `DT_UNKNOWN` filesystem this fired
on every re-read of every entry.

## Fix

- `DirInfo::get_entry(generation, query)`: generation-checked lookup of
one basename in a single `entries_mutex` critical section. The returned
`EntryLookup` wraps a pointer into the process-lifetime `EntryStore`, so
it stays valid after unlock; only the map walk needed the lock. The
`get_entries_ref` probe sites now go through it (or through an explicit
guard plus `get_entries_ref_locked` where the listing fd is captured in
the same critical section). The now-unused unlocked `get_entries_ref` is
removed.
- `EntriesOption::lookup(query)` does the same for the
`read_directory`-based sites (`load_as_file`, `load_extension`),
returning the lookup and the listing fd from one critical section;
`dir_and_fd()` covers the watch-registration read. `load_extension`
takes the slot handle instead of a bare `&DirEntry`.
- `run_env_loader` copies the listing's basenames out under
`entries_mutex` and lets the dotenv loader probe the copy, instead of
probing the live map between `.env` file reads.
- `Entry.need_stat` is an `AtomicBool`: Acquire fast-path load, Release
store after the `cache` write, so a reader that skips the mutex is
guaranteed to see the completed `cache` write.
- The recycle path only publishes `need_stat = true` and no longer
rewrites `cache`; the next `kind()`/`symlink()` re-stats under the
per-entry mutex, and a reader that raced the flag sees the old cache,
stale but untorn (`set_cache_kind` had no other callers and is removed).
- **Enforcement**: `DirEntry::get` / `get_comptime_query` /
`has_comptime_query` debug-assert that the calling thread holds
`entries_mutex`, so the next unlocked probe fails deterministically in
debug builds instead of surfacing as a rare segfault. The callers that
probed without the lock are converted: the dotenv loader always takes a
`DirEntryKeys` basename snapshot copied out under the lock (the
`DirEntryProbe` impl for the live `DirEntry` is removed so the type
system forces the copy, covering `run_env_loader`,
`PackageManager::init`, and the lockfile printer), PackageManager's
lockfile-presence probe and `bun pm view`'s package-name probe take the
uncontended guard, and the hot reloader's changed-file probe runs under
the lock.
- API cleanup from the same audit: `DirInfo::get_entries` (raw-pointer
return documented as "callers prove exclusivity locally", which is the
claim the deleted `run_env_loader` deref made and this PR proved false)
is removed along with the now-uncalled `RealFS::entries_at`;
`get_entries_ref_locked` becomes `pub` for the one-critical-section
snapshot pattern; `get_entries_const` documents which fields may be read
unlocked.

Known residual same-value races are left alone and are not crash
vectors: the recycle path's `existing.dir = self.dir` always rewrites
identical bytes (the re-read passes the old `DirEntry`'s interned dir
through), and `DirEntry.fd` is a word-sized field read where still
unlocked. The two remaining different-value `set_cache_symlink` writers
(the `getFdPath` path in `finalize_result` and `dir_info_uncached`)
still race lock-free `cache` readers in principle; closing those needs
reader-side locking or an atomic cache and is left for a follow-up. The
lazy `abs_path` fill has its own torn-read window with a symbolized
trace from an earlier occurrence of this same test crash (address
`0x31`, also ~24ms, Debian 13 aarch64); #34411 already covers that and
is complementary to this PR. `src/jsc/hot_reloader.rs` has a similar
unlocked probe on the watcher thread, left for a separate change.

## Why there is no new test

The fixture that crashed is already the regression test for this race
class, and the windows here are sub-microsecond interleavings: ~3000
runs of it against an unfixed build (including 55 debug+ASAN runs, the
configuration that catches the freed-bucket read as a
heap-use-after-free) produced 0 failures, as did 155 release and 35
debug+ASAN runs of a dedicated `Bun.resolveSync`-from-`onResolve`
fixture modeled on #23773's trace, so no honest test can fail without
the fix. The fix is verified by analysis plus the class precedent:
#34271's ASAN trace demonstrates the identical free-while-probing
mechanism on the iteration sites this PR's lookup sites share.

## Verification

- `bun bd test test/js/bun/util/filesystem_router.test.ts`: 33 pass
(including the 40-round race test)
- `bun bd test test/js/bun/resolve/resolve.test.ts`: 73 pass (wildcard
exports extension probing)
- `bun bd test test/bundler/esbuild/packagejson.test.ts`: 84 pass
(exports map resolution)
- `bun bd test test/bundler/bundler_edgecase.test.ts`: 134 pass
(extension probing, js-to-ts rewrites)
- `bun bd test test/bundler/bun-build-api.test.ts`: 52 pass
- `bun bd test test/cli/run/env.test.ts`: 96 pass (`.env` discovery
through the copied-keys probe)
- `bun bd test test/cli/hot/hot.test.ts`: 12 pass,
`test/cli/watch/watch.test.ts`: 8 pass
- Stress on the fixed debug+ASAN build: 25 long + 150 short fixture
runs, 0 failures
- `cargo clippy -p bun_resolver -p bun_bundler` clean
springmin pushed a commit to springmin/bun that referenced this pull request Aug 10, 2026
…ion rewrite (oven-sh#37274)

Fixes oven-sh#37266
Fixes oven-sh#23773

## Crash

In [build 90948](https://buildkite.com/bun/bun/builds/90948) (ubuntu
25.04 aarch64), the `reload() while Bun.build() resolves the same
directory` subprocess in `test/js/bun/util/filesystem_router.test.ts`
died 22ms in with

```
panic: Segmentation fault at address 0x25
```

instead of printing `matches 2000 builds-ok true`. The branch did not
touch the resolver or router (its diff against main is empty for those
modules), and the same test passed in the surrounding ~9 runs of the
same code, so this is a low-probability race. It did not reproduce
locally: ~3000 stress runs of the fixture (linux x64 release and
debug+ASAN, windows aarch64 release, with cpu pinning, oversubscription,
and `MIMALLOC_PURGE_DELAY=0` variants) produced 0 failures, nor did ~150
further runs of a `Bun.resolveSync`-from-plugin variant modeled on
oven-sh#23773. The buildkite artifact for that build has expired from the
network I can reach, so the trace could not be symbolized; the fix below
closes the remaining windows of the already-proven race class rather
than a bisected instruction.

oven-sh#23773 is the same crash family observed in the wild on linux aarch64
(about 1 in 20 builds of a large project): `Segmentation fault at
address 0x0` with `Entry.symlink`'s cache access (`fs.zig:397`) inlined
into `finalizeResult`, reached from `Bun.resolveSync` inside a bundler
plugin's `onResolve`, which is the JS thread running `finalize_result`'s
unlocked probe concurrently with bundler-thread re-reads.

## Cause

`RealFS::entries_at` and `read_directory` rewrite a cached `DirEntry` in
place when a resolver with a newer generation re-reads the directory:
`data.clear()` plus `*e_ptr = new_entry`, which drops the old `data:
StringHashMap<*mut Entry>` and frees its bucket allocation. oven-sh#34271 made
the `entries_at` rewrite take `entries_mutex`, and the route-loader
iteration sites already snapshot the map under the same lock. Three
windows remained:

1. **Unlocked map probes.** The resolver lookup sites probed `.data`
after the lock was released: `finalize_result`, `handle_esm_resolution`,
`load_index_with_extension`, `probe_wildcard_extensions`,
`Transpiler::run_env_loader` (all via `get_entries_ref`), and
`load_as_file` / `load_extension` (via the `EntriesOption` slot captured
after `read_directory` returned, with SAFETY comments incorrectly
claiming "entries_mutex held"). A concurrent stale-generation rewrite
frees the buckets mid-probe; a probe walking freed memory can load a
garbage `*mut Entry` and fault at a small absolute address, which
matches `0x25`. This is the same mechanism oven-sh#34271's ASAN trace proved
fires in this test, just on the lookup side instead of the iteration
side, and `load_as_file` is the hottest instance (every relative-file
import).

2. **`need_stat` published before `cache`.** `Entry::kind`/`symlink`
cleared `need_stat` first and wrote the resolved `cache` after, with
plain `Cell` ops. The fast path reads `need_stat` without the per-entry
mutex, so on a weakly ordered CPU a reader could observe `need_stat ==
false` while the `cache` write was not yet visible, or mid-store: a torn
read of the 16-byte `Interned` symlink faults inside `as_bytes`. This
window needs entries whose kind is not known from `getdents` (symlinks,
or filesystems returning `DT_UNKNOWN`), which is also consistent with
firing on CI but not on local ext4/tmpfs/NTFS.

3. **Recycled entries overwrote a live cache.** When a re-read's
`getdents` result disagreed with the cached kind, `add_entry_with_store`
rewrote `cache` (kind plus symlink) in place under the per-entry mutex.
A lock-free reader that had already observed `need_stat == false` reads
`cache` without that mutex, and no store ordering can publish anything
to a reader that loaded the stale flag, so the overwrite could tear a
populated `Interned` under it. On a `DT_UNKNOWN` filesystem this fired
on every re-read of every entry.

## Fix

- `DirInfo::get_entry(generation, query)`: generation-checked lookup of
one basename in a single `entries_mutex` critical section. The returned
`EntryLookup` wraps a pointer into the process-lifetime `EntryStore`, so
it stays valid after unlock; only the map walk needed the lock. The
`get_entries_ref` probe sites now go through it (or through an explicit
guard plus `get_entries_ref_locked` where the listing fd is captured in
the same critical section). The now-unused unlocked `get_entries_ref` is
removed.
- `EntriesOption::lookup(query)` does the same for the
`read_directory`-based sites (`load_as_file`, `load_extension`),
returning the lookup and the listing fd from one critical section;
`dir_and_fd()` covers the watch-registration read. `load_extension`
takes the slot handle instead of a bare `&DirEntry`.
- `run_env_loader` copies the listing's basenames out under
`entries_mutex` and lets the dotenv loader probe the copy, instead of
probing the live map between `.env` file reads.
- `Entry.need_stat` is an `AtomicBool`: Acquire fast-path load, Release
store after the `cache` write, so a reader that skips the mutex is
guaranteed to see the completed `cache` write.
- The recycle path only publishes `need_stat = true` and no longer
rewrites `cache`; the next `kind()`/`symlink()` re-stats under the
per-entry mutex, and a reader that raced the flag sees the old cache,
stale but untorn (`set_cache_kind` had no other callers and is removed).
- **Enforcement**: `DirEntry::get` / `get_comptime_query` /
`has_comptime_query` debug-assert that the calling thread holds
`entries_mutex`, so the next unlocked probe fails deterministically in
debug builds instead of surfacing as a rare segfault. The callers that
probed without the lock are converted: the dotenv loader always takes a
`DirEntryKeys` basename snapshot copied out under the lock (the
`DirEntryProbe` impl for the live `DirEntry` is removed so the type
system forces the copy, covering `run_env_loader`,
`PackageManager::init`, and the lockfile printer), PackageManager's
lockfile-presence probe and `bun pm view`'s package-name probe take the
uncontended guard, and the hot reloader's changed-file probe runs under
the lock.
- API cleanup from the same audit: `DirInfo::get_entries` (raw-pointer
return documented as "callers prove exclusivity locally", which is the
claim the deleted `run_env_loader` deref made and this PR proved false)
is removed along with the now-uncalled `RealFS::entries_at`;
`get_entries_ref_locked` becomes `pub` for the one-critical-section
snapshot pattern; `get_entries_const` documents which fields may be read
unlocked.

Known residual same-value races are left alone and are not crash
vectors: the recycle path's `existing.dir = self.dir` always rewrites
identical bytes (the re-read passes the old `DirEntry`'s interned dir
through), and `DirEntry.fd` is a word-sized field read where still
unlocked. The two remaining different-value `set_cache_symlink` writers
(the `getFdPath` path in `finalize_result` and `dir_info_uncached`)
still race lock-free `cache` readers in principle; closing those needs
reader-side locking or an atomic cache and is left for a follow-up. The
lazy `abs_path` fill has its own torn-read window with a symbolized
trace from an earlier occurrence of this same test crash (address
`0x31`, also ~24ms, Debian 13 aarch64); oven-sh#34411 already covers that and
is complementary to this PR. `src/jsc/hot_reloader.rs` has a similar
unlocked probe on the watcher thread, left for a separate change.

## Why there is no new test

The fixture that crashed is already the regression test for this race
class, and the windows here are sub-microsecond interleavings: ~3000
runs of it against an unfixed build (including 55 debug+ASAN runs, the
configuration that catches the freed-bucket read as a
heap-use-after-free) produced 0 failures, as did 155 release and 35
debug+ASAN runs of a dedicated `Bun.resolveSync`-from-`onResolve`
fixture modeled on oven-sh#23773's trace, so no honest test can fail without
the fix. The fix is verified by analysis plus the class precedent:
oven-sh#34271's ASAN trace demonstrates the identical free-while-probing
mechanism on the iteration sites this PR's lookup sites share.

## Verification

- `bun bd test test/js/bun/util/filesystem_router.test.ts`: 33 pass
(including the 40-round race test)
- `bun bd test test/js/bun/resolve/resolve.test.ts`: 73 pass (wildcard
exports extension probing)
- `bun bd test test/bundler/esbuild/packagejson.test.ts`: 84 pass
(exports map resolution)
- `bun bd test test/bundler/bundler_edgecase.test.ts`: 134 pass
(extension probing, js-to-ts rewrites)
- `bun bd test test/bundler/bun-build-api.test.ts`: 52 pass
- `bun bd test test/cli/run/env.test.ts`: 96 pass (`.env` discovery
through the copied-keys probe)
- `bun bd test test/cli/hot/hot.test.ts`: 12 pass,
`test/cli/watch/watch.test.ts`: 8 pass
- Stress on the fixed debug+ASAN build: 25 long + 150 short fixture
runs, 0 failures
- `cargo clippy -p bun_resolver -p bun_bundler` clean
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