Skip to content

fs: reuse DirEntry in bustEntriesCache instead of orphaning it - #29919

Closed
robobun wants to merge 1 commit into
mainfrom
farm/cb4c44e1/fix-bustentriescache-leak
Closed

fs: reuse DirEntry in bustEntriesCache instead of orphaning it#29919
robobun wants to merge 1 commit into
mainfrom
farm/cb4c44e1/fix-bustentriescache-leak

Conversation

@robobun

@robobun robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator

What

RealFS::bust_entries_cache() leaked a heap DirEntry (plus its EntryMap) per call and forced append-only growth of EntryStore/FilenameStore. Triggered on every HMR/watch file change and every FileSystemRouter.reload().

Repro

const router = new Bun.FileSystemRouter({ dir, style: "nextjs", fileExtensions: [".tsx"] });
for (let i = 0; i < 500; i++) router.reload();

4 subdirs x 20 files grow RSS by tens of MB over 500 reloads and keep growing without bound.

Cause

BSSMap::remove() only drops the hash to index mapping. The backing slot, and the EntriesOption::Entries(&mut DirEntry) it holds, stays behind, orphaned. On the next get_or_put() the key is gone, so at_index() returns None, in_place = None, and the read paths allocate a fresh DirEntry and consume a fresh BSSMap slot. The old DirEntry (with its EntryMap) is never freed, and because prev_map is None, every file in the directory gets a new Entry appended to EntryStore and (for long names) new strings appended to FilenameStore.

Fix

Do not remove the key. Flag the existing DirEntry as stale and let the next locked directory read take the in_place path that already exists for the generation-bump case: it reuses the DirEntry allocation, passes the old EntryMap as prev_map so Entry structs and filename strings are reused, and writes back to the same BSSMap slot. Err / NotFound entries (no DirEntry) still fall back to remove().

stale is honored only by the rebuild paths that run under entries_mutex:

  • RealFS::read_directory_with_iterator (locks internally)
  • Resolver::dir_info_cached_miss (already held the lock)
  • Resolver::dir_info_for_resolution (now takes the lock like its sibling; its in-place rewrite previously ran unlocked)

It is deliberately ignored by RealFS::entries_at, the accessor behind DirInfo::get_entries*. Those readers run without entries_mutex (and on other threads), so rebuilding there would free the map out from under them. They keep seeing their DirInfo's listing until the next locked read, which is exactly what they saw before this change, when the bust orphaned the slot they kept reading.

Because the orphaned slot was previously never written again (accidental RCU), callers that captured a *DirEntry and read its EntryMap across calls that can now trigger an in-place rewrite need explicit synchronization. The route-load and bust walks already snapshot the entry pointers under entries_mutex since #33056; the remaining one was the watcher thread:

  • hot_reloader::on_file_update: hold entries_mutex around the dir_ent.entries reads, and bust after releasing the lock since bust_entries_cache takes it internally.
  • entries_at: carry over the previous DirEntry.fd through a generation refresh so DirInfo::get_file_descriptor keeps working.

Verification

RSS growth over 400 reload()s (4 dirs x ~100 entries)
before ~65+ MB, eventually aborts when the BSSMap overflow list fills
after ~17 MB

test/js/bun/util/filesystem_router.test.ts passes, including the new leak regression test and the reload() while Bun.build() resolves the same directory race test from #33056. That test was load-bearing here: an earlier revision of this branch also honored stale in entries_at, and the test caught it as an AddressSanitizer heap-buffer-overflow (RouteLoader::load iterating an EntryMap that a bundler-thread finalize_result -> get_entries_ref -> entries_at rebuild had just freed, without the lock). With entries_at left read-only for stale, that run is 40/40 locally under ASAN.

Rebase notes


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

@coderabbitai

coderabbitai Bot commented Apr 29, 2026

Copy link
Copy Markdown
Contributor

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

Directory-entry caching gains a stale flag so listings can be invalidated without evicting allocations; cache invalidation, reuse, and rebuild logic now honor stale. Router reload deep-copies extensions and asset_prefix_path into the reload arena. A regression test verifies reload does not leak directory-entry caches.

Changes

Directory-entry stale invalidation + Router reload memory fix

Layer / File(s) Summary
Data Shape
src/fs.zig
Added stale: bool = false to FileSystem.DirEntry.
Cache Invalidation
src/fs.zig
RealFS.bustEntriesCache marks cached .entries variants as entry.entries.stale = true instead of evicting the cache slot; non-.entries cases still remove the cache mapping.
Cache Reuse / Refresh
src/fs.zig
RealFS.entriesAt and readDirectoryWithIterator treat .entries cache hits as misses when entries.stale is true or generation is older, forcing an in-place rebuild; the newly built DirEntry preserves entries.fd from the previous cached entry.
Resolver Integration
src/resolver/resolver.zig
dirInfoForResolution and dirInfoCachedMaybeLog now require !cached_entry.entries.stale in addition to generation checks before reusing cached directory listings; stale entries trigger in-place update paths.
Hot Reloader Ordering
src/bun.js/hot_reloader.zig
Watcher thread reads dir_ent.entries under rfs.entries_mutex and moves ctx.bustDirCache(...) to run after releasing the lock to avoid races with in-place updates.
Router Reload Memory Handling
src/bun.js/api/filesystem_router.zig
FileSystemRouter.reload allocates a fresh []string in the reload arena and deep-copies each extension string’s bytes; asset_prefix_path is duplicated into the reload arena rather than reusing the old arena pointer.
Tests
test/js/bun/util/filesystem_router.test.ts
Added regression test reload() should not leak directory entry caches that spawns a subprocess, runs repeated FileSystemRouter.reload() with forced GC, and asserts route count, RSS delta, and empty stderr.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main filesystem cache fix.
Description check ✅ Passed The description is detailed and includes the change rationale and verification, though it does not use the template's exact section headings.

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

@robobun

robobun commented Apr 29, 2026

Copy link
Copy Markdown
Collaborator Author

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. Memory Leak running Typescript server on file reload #15857 - Memory leak on file reload: RSS grows ~2-20MB per save, matching the orphaned DirEntry pattern fixed here
  2. bun --hot always leaks memory #11083 - bun --hot always leaks memory: RSS grows monotonically on each reload, consistent with bustEntriesCache leaking DirEntry slots
  3. Memory Not Freed After Running bun --hot Command #14734 - Memory not freed after bun --hot: same pattern of growing memory per file save under hot reload
  4. Intermittent Errors with Bun.build and Bun.FileSystemRouter when importing absolute paths #9517 - Intermittent "Unexpected reading file" errors with Bun.FileSystemRouter + Bun.build(): stale/corrupted directory entry state could cause these intermittent file-reading failures

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

Fixes #15857
Fixes #11083
Fixes #14734
Fixes #9517

🤖 Generated with Claude Code

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The fix looks correct and well-reasoned to me, but since it changes cache-invalidation semantics in the resolver/fs directory cache (shared by HMR, watch, bundler, and install), I'd like a human to confirm the stale-flag approach has no edge cases I'm missing.

Extended reasoning...

Overview

This PR fixes a memory leak in RealFS.bustEntriesCache() by replacing entries.remove(path) with a stale flag on the existing DirEntry, so the next read takes the pre-existing in_place re-scan path (reusing the DirEntry heap allocation, EntryMap, and Entry/FilenameStore slots) instead of orphaning them and allocating fresh ones. It threads the stale check through the three cache-hit sites (fs.zig:entriesAt, fs.zig:readDirectoryWithIterator, and two spots in resolver.zig). It also fixes a real use-after-free in FileSystemRouter.reload() where extension strings were shallow-duped from an arena that gets freed in the same call. A new memory-growth regression test is added.

Security risks

None. This is internal cache lifecycle management with no auth/crypto/input-handling implications.

Level of scrutiny

High. bustEntriesCache is called via Resolver.bustDirCache, which is on the hot path for HMR, --watch, --hot, bun install, and FileSystemRouter.reload(). The change is semantically equivalent to the existing generation-bump path (the in_place reuse logic already exists and is exercised), and I verified that all three re-scan sites overwrite the DirEntry via ptr.* = new_entry (from DirEntry.init()), which resets stale back to false. The .err/NotFound fallback to remove() is preserved. Still, this is core resolver/fs caching shared across every build/resolve operation on every platform, so a subtle regression here would have broad impact.

Other factors

  • The UAF fix in filesystem_router.zig (deep-copying extension strings into the new arena) is straightforward and correct.
  • The new test follows the pattern of the adjacent MatchedRoute.params does not leak test (subprocess + RSS delta), with a generous 32 MB threshold vs. the ~1 MB observed post-fix.
  • Existing test reload() works with new dirs/files covers the functional correctness of the stale flag (directory is actually re-read from disk).
  • Only a nit was flagged (explicit test timeout, which follows local file precedent).
  • No CODEOWNERS cover these files.

Comment thread test/js/bun/util/filesystem_router.test.ts
@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

Heads-up: the filesystem_router.zig drive-by here (deep-copy extensions on reload()) is superseded by #30070, which also fixes asset_prefix_path (same defect — not copied at all) and adds a dedicated regression test. When rebasing this PR to resolve the current conflicts, that hunk can be dropped.

@robobun
robobun force-pushed the farm/cb4c44e1/fix-bustentriescache-leak branch from bcd3e71 to ba8181e Compare May 1, 2026 19:41
@robobun

robobun commented May 1, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Latest: bd484038, rebased onto main f6f15233 and squashed to a single commit.

Fix (.rs only)

  • src/resolver/fs.rs: DirEntry.stale: bool
  • src/resolver/lib.rs: bust_entries_cache sets stale instead of removing; read_directory_with_iterator honors it; entries_at ignores it (DirInfo readers keep their view) and carries the previous fd through a generation refresh
  • src/resolver/resolver.rs: both in_place sites check !stale
  • src/jsc/hot_reloader.rs: entries_mutex held around the watcher's dir_ent.entries reads; bust after release; updated the stale SAFETY note at the kqueue lookup

What converged into main since this PR was opened

Verification (local, debug+ASAN build on this sha)

result
test/js/bun/util/filesystem_router.test.ts 30/30 pass
reload() should not leak directory entry caches (this PR) with fix: 0-1 MB RSS growth over 400 reloads; without fix: ~101 MB
#33056 reload() while Bun.build() race test 20/20 on a tight loop

The ~20 MB residual previously reported was ASAN quarantine retaining freed per-reload buffers; the test now sets quarantine_size_mb=0 in the child's ASAN_OPTIONS, so the RSS delta reflects retained allocations on every lane and the 32 MB threshold has wide headroom.

Open review question

@dylan-conway's stale vs generation question is answered in #29919 (comment). Short form: Resolver.generation has a single writer (the bundler's per-round counter); the busting callers (FileSystemRouter.reload, the --hot watcher, the module-not-found retry) never advance a generation, Generation is a u16 shared with the DevServer round counter, and Err slots carry no generation at all. stale is the non-destructive spelling of what the old remove() meant, consumed only by the locked directory-rebuild paths. Happy to reshape if a different spelling is preferred.

CI

Builds 73540 and 73564 are red only on test/bundler/transpiler/transpiler.test.js ("rejects yield/await/this/super in enum initializer"), a main-wide js_parser-area breakage from the commits this rebase sits on (being fixed separately). Nothing in this diff: no filesystem_router mentions in any failure annotation, and the ASAN lane ran this PR's test green.

No unresolved review threads.

@robobun
robobun force-pushed the farm/cb4c44e1/fix-bustentriescache-leak branch from ba8181e to 72b3853 Compare May 2, 2026 00:12

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

Caution

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

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

2972-2978: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Guard the union tag before reading cached_entry.entries.

This stale-aware branch is missing the cached_entry.* == .entries check that Line 2266 already has. If a prior lookup cached .err, Line 2973 will read the wrong tagged-union field and trip a safety check instead of falling back to the in-place refresh path.

Suggested fix
             if (rfs.entries.atIndex(cached_dir_entry_result.index)) |cached_entry| {
-                if (!cached_entry.entries.stale and cached_entry.entries.generation >= r.generation) {
-                    dir_entries_option = cached_entry;
-                    needs_iter = false;
-                } else {
-                    in_place = cached_entry.entries;
+                if (cached_entry.* == .entries) {
+                    if (!cached_entry.entries.stale and cached_entry.entries.generation >= r.generation) {
+                        dir_entries_option = cached_entry;
+                        needs_iter = false;
+                    } else {
+                        in_place = cached_entry.entries;
+                    }
                 }
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/resolver/resolver.zig` around lines 2972 - 2978, The code reads
cached_entry.entries without checking the union tag, which can panic if
cached_entry contains .err; update the branch around
rfs.entries.atIndex(cached_dir_entry_result.index) to first test the tag
(cached_entry.* == .entries) before accessing cached_entry.entries: when the tag
is .entries and not stale and generation >= r.generation set dir_entries_option
and needs_iter=false; when the tag is .entries but stale set in_place =
cached_entry.entries; otherwise (tag != .entries) treat it as a cache
miss/fallback and proceed with the in-place refresh path (do not read
cached_entry.entries).
src/fs.zig (1)

621-643: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the cached directory FD when refreshing a stale entry.

Now that Line 621 routes invalidations through entriesAt(), this helper also needs the same FD lifecycle as readDirectoryWithIterator(). Today it always calls readdir(false, ...) and always closes handle, so refreshing a stale DirEntry that already had fd set will overwrite it with .invalid at Line 643 and leak the old descriptor. That breaks callers that rely on entries.fd after invalidation and turns repeated busts into FD churn.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fs.zig` around lines 621 - 643, When refreshing a stale DirEntry you must
preserve any previously-set directory FD instead of overwriting and leaking it:
if existing.entries.fd is valid, avoid closing or discarding that descriptor
when calling bun.openDirForIteration() / readdir(), and after readdir returns
ensure the new_entry retains the original FD (assign new_entry.fd =
existing.entries.fd when readdir did not produce a new FD) before you clear/free
existing.entries.data and overwrite existing.entries.*; only close the handle if
you opened a fresh descriptor for this refresh and did not reuse the
existing.entries.fd.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/api/filesystem_router.zig`:
- Around line 265-268: The new Router is being initialized with
.asset_prefix_path pointing at this.router.config.asset_prefix_path which lives
in the old arena that is freed right after reload; update the Router.init call
to deep-copy the asset prefix by duplicating
this.router.config.asset_prefix_path via the allocator (similar to how .dir is
created with allocator.dupe) so Router.config.asset_prefix_path owns its own
memory and won't dangle when the old arena is freed; ensure you use
allocator.dupe(u8, this.router.config.asset_prefix_path) (or equivalent) and
handle errors consistently with the existing catch/unreachable pattern.

In `@src/fs.zig`:
- Around line 783-801: bustEntriesCache currently mutates rfs.entries (setting
entry.entries.stale and calling rfs.entries.remove) without taking
entries_mutex, racing with readDirectoryWithIterator and dirInfoCachedMaybeLog
which expect the map to be mutex-protected; fix by either acquiring
entries_mutex at the start of bustEntriesCache and releasing it after the
mutation, or require/assert that the caller holds entries_mutex (e.g., add an
assertion using entries_mutex.isHeld() or similar) and document that contract;
ensure the lock covers both the lookup (rfs.entries.get) and the subsequent
mutation/removal to prevent races on entry.entries.stale and
rfs.entries.remove().

In `@test/js/bun/util/filesystem_router.test.ts`:
- Around line 613-628: The test currently asserts stderr is exactly empty
(expect(stderr).toBe("")) but debug/ASAN builds can emit a known warning from
the spawned process; update the assertion in the test that spawns proc (using
Bun.spawn with bunExe() and bunEnv) to either remove the strict stderr equality
check or filter out the ASAN warning string (e.g., lines containing "WARNING:
ASAN interferes with JSC signal handlers" or similar) from stderr before
asserting the remainder is empty so unrelated ASAN noise doesn't fail the
leak/regression assertions for proc, stdout, stderr, and exitCode.

---

Outside diff comments:
In `@src/fs.zig`:
- Around line 621-643: When refreshing a stale DirEntry you must preserve any
previously-set directory FD instead of overwriting and leaking it: if
existing.entries.fd is valid, avoid closing or discarding that descriptor when
calling bun.openDirForIteration() / readdir(), and after readdir returns ensure
the new_entry retains the original FD (assign new_entry.fd = existing.entries.fd
when readdir did not produce a new FD) before you clear/free
existing.entries.data and overwrite existing.entries.*; only close the handle if
you opened a fresh descriptor for this refresh and did not reuse the
existing.entries.fd.

In `@src/resolver/resolver.zig`:
- Around line 2972-2978: The code reads cached_entry.entries without checking
the union tag, which can panic if cached_entry contains .err; update the branch
around rfs.entries.atIndex(cached_dir_entry_result.index) to first test the tag
(cached_entry.* == .entries) before accessing cached_entry.entries: when the tag
is .entries and not stale and generation >= r.generation set dir_entries_option
and needs_iter=false; when the tag is .entries but stale set in_place =
cached_entry.entries; otherwise (tag != .entries) treat it as a cache
miss/fallback and proceed with the in-place refresh path (do not read
cached_entry.entries).
🪄 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: 282efbb6-841f-47da-8d06-959d3431a34e

📥 Commits

Reviewing files that changed from the base of the PR and between 1b82e1d and 72b3853.

📒 Files selected for processing (4)
  • src/bun.js/api/filesystem_router.zig
  • src/fs.zig
  • src/resolver/resolver.zig
  • test/js/bun/util/filesystem_router.test.ts

Comment thread src/bun.js/api/filesystem_router.zig Outdated
Comment thread src/resolver/fs.zig Outdated
Comment thread test/js/bun/util/filesystem_router.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.

Caution

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

⚠️ Outside diff range comments (2)
src/fs.zig (2)

621-644: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Refresh-to-error still drops the reused DirEntry.

Both stale refresh branches clear the old EntryMap and then route failures through readDirectoryError(). If that replaces the cache slot with .err/NotFound, the heap *DirEntry is no longer referenced, so delete/recreate or permission-flap loops can still grow EntryStore/FilenameStore again on the next successful read.

Also applies to: 1095-1139

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fs.zig` around lines 621 - 644, The bug is that
existing.entries.data.clearAndFree(...) is called before calling
readDirectoryError() and before the new DirEntry is successfully assigned, which
can drop the only heap reference to the old DirEntry when readDirectoryError()
replaces the cache with an error, allowing resource-growth on subsequent reads;
fix by deferring clearing/freeing of existing.entries.data until after new_entry
is successfully obtained and assigned (i.e., remove the pre-error clearAndFree
calls and only call existing.entries.data.clearAndFree(bun.default_allocator)
after existing.entries.* = new_entry), and apply the same change to the other
stale-refresh branch (lines ~1095-1139) so that the old DirEntry remains
referenced if readDirectoryError() winds up writing an error into the cache.

618-644: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Lock entriesAt() while it rewrites cached entries.

bustEntriesCache() now serializes .stale writes with entries_mutex, but entriesAt() still clears existing.entries.data and overwrites existing.entries.* without taking that mutex. That leaves the stale-refresh path open to lost invalidations and concurrent cache mutation. If callers are supposed to hold the lock already, please assert/document that contract here instead of leaving it implicit.

🔒 Suggested fix
 pub fn entriesAt(this: *RealFS, index: allocators.IndexType, generation: bun.Generation) ?*EntriesOption {
+    this.entries_mutex.lock();
+    defer this.entries_mutex.unlock();
+
     var existing = this.entries.atIndex(index) orelse return null;
     if (existing.* == .entries) {
         if (existing.entries.stale or existing.entries.generation < generation) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fs.zig` around lines 618 - 644, entriesAt() updates cached entries
(clearing existing.entries.data and writing existing.entries.*) without holding
entries_mutex, which races with bustEntriesCache() that serializes .stale
writes; fix by taking the same entries_mutex around the stale-refresh section in
entriesAt() (or assert the lock is already held) so cache mutation is
serialized: acquire entries_mutex before calling bun.openDirForIteration/readdir
and before clearAndFree/assignment, release after assigning new_entry, and/or
add an explicit debug assert in entriesAt() if the locking contract is that
callers must hold entries_mutex (referencing entriesAt, bustEntriesCache,
entries_mutex, existing.entries.data and existing.entries.*).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/fs.zig`:
- Around line 621-644: The bug is that existing.entries.data.clearAndFree(...)
is called before calling readDirectoryError() and before the new DirEntry is
successfully assigned, which can drop the only heap reference to the old
DirEntry when readDirectoryError() replaces the cache with an error, allowing
resource-growth on subsequent reads; fix by deferring clearing/freeing of
existing.entries.data until after new_entry is successfully obtained and
assigned (i.e., remove the pre-error clearAndFree calls and only call
existing.entries.data.clearAndFree(bun.default_allocator) after
existing.entries.* = new_entry), and apply the same change to the other
stale-refresh branch (lines ~1095-1139) so that the old DirEntry remains
referenced if readDirectoryError() winds up writing an error into the cache.
- Around line 618-644: entriesAt() updates cached entries (clearing
existing.entries.data and writing existing.entries.*) without holding
entries_mutex, which races with bustEntriesCache() that serializes .stale
writes; fix by taking the same entries_mutex around the stale-refresh section in
entriesAt() (or assert the lock is already held) so cache mutation is
serialized: acquire entries_mutex before calling bun.openDirForIteration/readdir
and before clearAndFree/assignment, release after assigning new_entry, and/or
add an explicit debug assert in entriesAt() if the locking contract is that
callers must hold entries_mutex (referencing entriesAt, bustEntriesCache,
entries_mutex, existing.entries.data and existing.entries.*).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a4a7b9b2-b800-452d-a1cd-069f268a9cad

📥 Commits

Reviewing files that changed from the base of the PR and between 72b3853 and 81a1dc8.

📒 Files selected for processing (2)
  • src/bun.js/api/filesystem_router.zig
  • src/fs.zig

@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/fs.zig (1)

621-648: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Don't free the cached directory path on a failed in-place refresh.

These two refresh paths now pass existing.entries.dir / existing.dir back into readdir() for reuse, but readdir() still does errdefer dir.deinit(...). If the rescan fails, that cleanup frees the borrowed path, and the subsequent readDirectoryError(dir, err) runs with a dangling slice while the cached DirEntry still points at freed memory. Since bustEntriesCache() now invalidates by setting stale, transient watch/HMR read failures will hit this path regularly.

🛠️ Suggested fix
         fn readdir(
             fs: *RealFS,
             store_fd: bool,
             prev_map: ?*DirEntry.EntryMap,
             _dir: string,
             generation: bun.Generation,
             handle: std.fs.Dir,
             comptime Iterator: type,
             iterator: Iterator,
         ) !DirEntry {
             _ = fs;

             var iter = bun.iterateDir(.fromStdDir(handle));
             var dir = DirEntry.init(_dir, generation);
             const allocator = bun.default_allocator;
-            errdefer dir.deinit(allocator);
+            errdefer {
+                dir.data.deinit(allocator);
+                if (prev_map == null) allocator.free(dir.dir);
+            }

             if (store_fd) {
                 FileSystem.setMaxFd(handle.fd);
                 dir.fd = .fromStdDir(handle);
             }

As per coding guidelines, "In Zig code, be careful with allocators and use defer for cleanup."

Also applies to: 1098-1151

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/fs.zig` around lines 621 - 648, The in-place refresh path calls
readdir(...) with an owned dir slice borrowed from existing.entries.dir but
still lets readdir() errdefer dir.deinit(...) free that slice on error, leaving
existing.entries pointing at freed memory; update the error paths so you do not
free the cached path when readdir fails: when calling readdir from the refresh
branches (the block using existing.entries.dir / existing.dir and function
readdir), remove or conditionally skip the cleanup that deallocates the borrowed
dir slice on error (i.e., avoid calling existing.entries.data.clearAndFree or
dir.deinit/free of the cached path before calling readDirectoryError), and
instead ensure readDirectoryError receives a valid borrowed slice or clone the
path into a temporary owned buffer before calling readdir so cleanup is safe;
apply the same fix to the other refresh region (around lines 1098–1151)
referencing the same symbols.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/fs.zig`:
- Around line 621-648: The in-place refresh path calls readdir(...) with an
owned dir slice borrowed from existing.entries.dir but still lets readdir()
errdefer dir.deinit(...) free that slice on error, leaving existing.entries
pointing at freed memory; update the error paths so you do not free the cached
path when readdir fails: when calling readdir from the refresh branches (the
block using existing.entries.dir / existing.dir and function readdir), remove or
conditionally skip the cleanup that deallocates the borrowed dir slice on error
(i.e., avoid calling existing.entries.data.clearAndFree or dir.deinit/free of
the cached path before calling readDirectoryError), and instead ensure
readDirectoryError receives a valid borrowed slice or clone the path into a
temporary owned buffer before calling readdir so cleanup is safe; apply the same
fix to the other refresh region (around lines 1098–1151) referencing the same
symbols.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 16da5bf4-cbc9-496e-9ff0-117a43b83081

📥 Commits

Reviewing files that changed from the base of the PR and between 81a1dc8 and cb3f88a.

📒 Files selected for processing (2)
  • src/fs.zig
  • src/resolver/resolver.zig

Comment thread src/resolver/fs.zig Outdated
Comment thread src/resolver/resolver.zig Outdated
Comment thread src/resolver/fs.zig Outdated
Comment thread src/bun.js/hot_reloader.zig Outdated
Jarred-Sumner pushed a commit that referenced this pull request May 3, 2026
…chedMaybeLog (#30170)

## Problem

`EntriesOption` is a tagged union:
```zig
pub const EntriesOption = union(Tag) {
    entries: *DirEntry,
    err: DirEntry.Err,  // { original_err: anyerror, canonical_error: anyerror }
};
```

When `readDirectory()` fails with a non-ENOENT error (EACCES, EMFILE,
…), `readDirectoryError` stores `.err` in `rfs.entries` (fs.zig:1002).
If the directory later becomes openable and `dirInfoCachedMaybeLog`
processes it as queue slot [0] (the target path itself, which is never
pre-checked against `rfs.entries` during queue construction), it
reached:

```zig
if (rfs.entries.atIndex(cached_dir_entry_result.index)) |cached_entry| {
    if (cached_entry.entries.generation >= r.generation) {   // no tag check
```

Reading `.entries` while `.err` is active reinterprets the two
`anyerror` values as a `*DirEntry` pointer and dereferences it. In debug
this is a safety panic; in release it's a segfault at e.g.
`0x1D401D401F8`.

## Repro

```js
chmodSync(bad, 0o000);
try { Bun.resolveSync('./bad/index.js', root); } catch {}  // caches .err for 'bad'
chmodSync(bad, 0o755);
Bun.resolveSync('./bad', root);  // dirInfoCached(bad): open OK, cache has .err -> crash
```

Before:
```
panic(main thread): access of union field 'entries' while field 'err' is active
resolver.zig:2973:33 in dirInfoCachedMaybeLog
```

## Fix

Add the same `if (cached_entry.* == .entries)` guard that
`dirInfoForResolution` already has (resolver.zig:2265). When the cached
entry is `.err`, fall through with `needs_iter = true` so the directory
is re-iterated now that it can be opened.

## Verification

Added a test to `test/js/bun/resolve/resolve.test.ts` that reproduces
the exact sequence. Runs as the current user when non-root, or drops to
`nobody` via `runuser` when root (since root bypasses DAC). Skipped on
Windows.

- Without fix: panics with `access of union field 'entries' while field
'err' is active`
- With fix: resolves to `bad/index.js`

---

Note: #29919 independently adds this same guard as part of a larger
memory-leak fix; this PR is the minimal targeted crash fix with a
dedicated regression test.

---------

Co-authored-by: robobun <robobun@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
@robobun
robobun force-pushed the farm/cb4c44e1/fix-bustentriescache-leak branch from c05672a to dd26f56 Compare May 3, 2026 07:17
Comment thread src/runtime/api/filesystem_router.zig Outdated
Comment thread src/router.zig Outdated
Comment thread src/router/router.zig Outdated
@Jarred-Sumner
Jarred-Sumner force-pushed the farm/cb4c44e1/fix-bustentriescache-leak branch from 7328c03 to 87be2a7 Compare May 4, 2026 10:28

@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 new issues found and all prior review threads are resolved — but this swaps an RCU-via-leak for explicit synchronization across the watcher/JS/bundle threads on the shared rfs.entries cache, and took several rounds of race-condition fixes to settle (with two related items deferred to follow-ups), so it's worth a human pass on the concurrency reasoning before merge.

Extended reasoning...

Overview

This PR fixes a memory leak in RealFS.bustEntriesCache() where each cache bust orphaned a heap *DirEntry (plus its EntryMap) and forced append-only growth of EntryStore/FilenameStore. The fix adds a stale: bool flag to DirEntry and has bustEntriesCache mark the existing entry stale (under entries_mutex) instead of removing the key, so the next read takes the pre-existing in_place rebuild path and reuses all allocations. Touches: src/resolver/fs.zig (core cache + entriesAt fd-preservation), src/resolver/resolver.zig (two !stale guards), src/jsc/hot_reloader.zig (mutex around affected-files reads + bust reordering), src/router/router.zig and src/runtime/api/filesystem_router.zig (snapshot subdirs before recursing; deep-copy extensions/asset_prefix_path on reload), plus a new RSS-leak regression test.

Security risks

None. No auth/crypto/permissions/parsing of untrusted input is touched. The risk surface here is memory-safety (UAF / torn reads on a shared hashmap), not security.

Level of scrutiny

High. The leak being removed was — as the PR's own review threads document — the de-facto RCU that made several unlocked reads of *DirEntry sound. Removing it required compensating synchronization that was iterated on across three separate race findings during review (within-event reader/writer race → reorder bust; cross-event race → lock the affected-files loop; same-thread reentrancy in RouteLoader.load/bustDirCacheRecursive → snapshot subdirs before recursing). rfs.entries is a process-global singleton shared across the JS-thread resolver, the watcher thread, and BundleThread, so the blast radius of a mistake here is HMR/--hot/--watch crashes under load. Two related issues were explicitly deferred to follow-ups (the pre-existing unlocked entriesAt writer-writer race, and the FrameworkRouter.scanInner snapshot). That's reasonable scoping, but it means a human should confirm the boundary drawn is acceptable.

Other factors

All seven of my prior inline review threads are resolved with specific commits and rationale; coderabbit's three findings are also resolved. CI build #50578 passes on 71 jobs including all ASAN shards; the only failures are pre-existing fleet-wide flakes unrelated to this change. The new regression test is solid (subprocess RSS-delta assertion with a generous 32 MB bound vs. ~72 MB pre-fix). The bug-hunting system found no new issues on the current revision. I'm deferring rather than approving solely because the concurrency reasoning is subtle enough — and was wrong in non-obvious ways multiple times during this review cycle — that a human sign-off on the final synchronization story is warranted.

@robobun
robobun force-pushed the farm/cb4c44e1/fix-bustentriescache-leak branch from 87be2a7 to 081f6c2 Compare May 4, 2026 12:57

@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 my concerns from earlier rounds have been addressed and I have nothing further to flag, but given how subtle the in-place-reuse concurrency invariants turned out to be (three separate race fixes during review), this one warrants a human pass before merge.

Extended reasoning...

Overview

This PR changes RealFS.bustEntriesCache() from removing the BSSMap key (orphaning the backing *DirEntry slot) to marking the existing DirEntry as .stale so the next read takes the existing in_place re-scan path and reuses the allocation. It touches six files: the core fs cache (src/resolver/fs.zig), the resolver's stale/generation checks (src/resolver/resolver.zig), the hot-reloader watcher thread (src/jsc/hot_reloader.zig), the route loader (src/router/router.zig), the JS FileSystemRouter API (src/runtime/api/filesystem_router.zig), and adds a leak regression test.

Security risks

None identified. The change is internal to the resolver's directory-entry cache and HMR watcher; no auth, crypto, network, or untrusted-input parsing is involved. The risk surface is memory-safety/concurrency (UAF, torn reads), not security.

Level of scrutiny

High. The previous orphaning behavior was effectively poor-man's RCU: leaked *DirEntry slots were never written again, which made several unlocked reads of entries.data (hot_reloader watcher thread, RouteLoader.load recursion, bustDirCacheRecursive) accidentally sound. Switching to in-place reuse removes that implicit guarantee, and the PR had to re-establish it explicitly via entries_mutex locking in the watcher and snapshot-before-recurse in the route loaders. Three distinct races were found and fixed during review (within-event, cross-event, and same-thread reentrancy), and two related issues were explicitly deferred to follow-ups (entriesAt unlocked writer-writer, FrameworkRouter.scanInner). Code that needed three rounds of concurrency fixes to converge is not something I should approve without a human look.

Other factors

  • All review threads (mine and coderabbit's) are resolved; the current revision has no outstanding bug findings.
  • CI passes on 71 lanes including all ASAN shards; the 3 failures are documented pre-existing fleet-wide flakes unrelated to this change.
  • The filesystem_router.zig deep-copy of extensions/asset_prefix_path fixes a real pre-existing UAF that the new test surfaced under ASAN.
  • The resolver and fs cache are on the hot path for every module resolution in Bun; a regression here would be high-impact.

@robobun
robobun force-pushed the farm/cb4c44e1/fix-bustentriescache-leak branch from 081f6c2 to 9eecfda Compare May 4, 2026 20:08
@robobun
robobun force-pushed the farm/cb4c44e1/fix-bustentriescache-leak branch from 843746d to ffb9993 Compare May 25, 2026 06:18
@robobun
robobun force-pushed the farm/cb4c44e1/fix-bustentriescache-leak branch from ffb9993 to f3f83c3 Compare June 6, 2026 15:39

@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 new issues found on this revision, but given the change swaps the resolver's de-facto RCU (orphan-on-bust) for in-place DirEntry rewrites under entries_mutex — with several rounds of cross-thread race fixes already applied — this warrants a human pass over the locking/snapshot invariants before merge.

Extended reasoning...

Overview

This PR changes RealFS::bust_entries_cache from removing the BSSMap key (orphaning the heap DirEntry and its EntryMap) to flagging the existing DirEntry as stale so the next read takes the pre-existing in_place re-scan path. Touches src/resolver/{fs,lib,resolver}.rs (the stale flag, entries_at fd carry-over, both in_place guard sites), src/jsc/hot_reloader.rs (watcher-thread entries_mutex hold + bust reordering), and src/router/lib.rs + src/runtime/api/filesystem_router.rs (snapshot subdir *Entry pointers before recursing). Adds an RSS-leak regression test.

Security risks

None in the auth/crypto sense. The risk surface is memory-safety: the old orphaning behavior was an accidental RCU that made several unlocked *DirEntry reads sound; reusing the slot in place required adding explicit synchronization at three caller sites (hot_reloader affected-files loop, RouteLoader::load, bust_dir_cache_recursive). The PR went through three rounds of race-condition fixes (within-event, cross-event, and same-thread iterator-invalidation via recursive read_dir_info) and explicitly defers three pre-existing issues (entries_at writer-writer locking, FrameworkRouter.scanInner snapshot, fd close at other in_place sites).

Level of scrutiny

High. This is production-critical resolver/HMR code with unsafe raw-pointer dereferences, non-recursive mutex ordering constraints, and cross-thread invariants whose correctness depends on which caller holds entries_mutex vs. dir_cache state at each step. The fix is well-reasoned and well-documented in-source, but the class of bug it's been iterating on (captured *DirEntry rewritten under the reader) is exactly the kind that merits a maintainer's eyes on the final locking topology and the deferred follow-up list.

Other factors

All prior inline review threads are resolved; CI was green on a previous full run and the latest push is just a rebase + rustfmt. The new test exercises the single-threaded reload() path but not the --hot watcher-thread races the synchronization changes target. The author has flagged the PR as ready for human review.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

Ran into the same FileSystemRouter.reload() leak from a fuzz soak and ended up with a complementary fix on farm/b630cb56/fix-fsrouter-reload-leak that brings the residual down from ~8 MB/500 reloads to flat:

  • reload() bumps resolver.generation instead of calling bust_dir_cache at all, and RouteLoader::load uses get_entries_ref(generation) so the existing in-place re-read path fires. That avoids the dir_cache BSSMap orphaning too (the residual noted in this PR's description), and drops the recursive bust walk.
  • Route::parse interns public_path / the Windows normalised abs_path / the uppercase match_name through a small dedup table in front of DirnameStore, so each distinct path is appended once for the process instead of once per reload.
  • basename is widened directly from entry.base() (EntryStore is process-lifetime) instead of being re-appended to DirnameStore.

With all three, __sanitizer_get_current_allocated_bytes is byte-identical before and after every reload cycle and RSS is flat over 5000 reloads with 50 routes (was ~125 MB). The test in that branch disables ASAN's quarantine in the child so the RSS delta is meaningful under bun bd too.

This PR's bust_entries_cache change still helps the HMR / dev-server callers that my branch doesn't touch, so the two aren't mutually exclusive; happy to fold the dedup + generation pieces into this PR if that's easier than landing both.

@dylan-conway

Copy link
Copy Markdown
Member

why does this add stale. doesn't generation already cover this? having two seems incorrect

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

They end up covering different callers, and the callers that bust are the ones whose generation never moves.

Resolver.generation has exactly one writer: BundleThread.rs stamps each per-bundle transpiler with the DevServer's round counter (transpiler.resolver.generation = generation). For those resolvers the generation check already does what you describe: the next round sees entries.generation < self.generation and takes the in_place re-read.

The callers of bust_dir_cache never advance a generation:

  • FileSystemRouter.reload() runs on the VM's long-lived transpiler.resolver, which is initialized to 0 and never bumped (no generation writes in filesystem_router.rs, router/lib.rs, or hot_reloader.rs).
  • The --hot watcher thread busts the changed directory on that same resolver.
  • The module-not-found retry in jsc_hooks.rs busts the parent dir so a require() of a file created at runtime can be found.

For all of those, entries.generation >= self.generation is always 0 >= 0, so generation never invalidates anything and the bust is their only invalidation signal. That is why the old code removed the key. The removal is also the leak: BSSMap.remove only drops the hash to index mapping, the backing slot and the heap DirEntry are never freed or reused, and the next read allocates a fresh slot plus N new Entry/FilenameStore slots.

I looked for a way to express the bust through generation and did not find a sound one:

  • The watcher thread cannot bump every live resolver's generation (they belong to the JS and bundler threads and are read unsynchronized), and that would invalidate every directory rather than the one that changed.
  • Writing an "older than everyone" value into the entry does not exist: readers sit at 0 and the type is unsigned, so the only option is reserving a sentinel (0 = invalid, real generations start at 1), which is the same extra bit hidden inside the generation field, plus wraparound interaction with the DevServer's wrapping_add counter.
  • The negative-cache slots (EntriesOption::Err, e.g. ENOENT for a directory created later) carry no generation at all and are returned from cache unconditionally, so bust_entries_cache still has to remove those either way; only the Entries arm changes.

So stale is the non-destructive spelling of what remove() already meant ("this listing is known wrong"), limited to the variant that owns reusable allocations. It is cleared when the in_place path rewrites the slot (DirEntry::init sets stale: false), and the read sites combine both conditions (e.stale || e.generation < generation).

If you would rather not add a field, I can encode the same thing as a reserved generation value instead; it seemed like the same bit with more edge cases, so I went with the explicit flag. Happy to change it to whichever shape you prefer.

@robobun
robobun force-pushed the farm/cb4c44e1/fix-bustentriescache-leak branch from f3f83c3 to 6d5fe1b Compare June 27, 2026 23:52
@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

One clarification on top of the comment above, since the linked branch (farm/b630cb56/fix-fsrouter-reload-leak) proposes having reload() bump resolver.generation instead of busting the route tree: that does work for the reload() caller, and I am fine adopting it here or letting it land separately. The tradeoff is scope: a generation bump re-validates every directory the VM resolver touches afterwards, while the recursive bust only re-reads the route tree.

It does not replace this change, though. The unbounded version of the leak is the watcher-thread bust_dir_cache in --hot and dev-server sessions (one orphaned DirEntry plus N Entry/FilenameStore slots per file event, forever), and that caller cannot be expressed as a generation bump: it invalidates one specific directory, from another thread, on resolvers it does not own. So bust_entries_cache itself has to stop orphaning the slot, which is the stale bit. With that in place, whether reload() busts or bumps a generation becomes a perf choice rather than a leak fix, and the interning/dedup pieces in that branch are additive on top.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

Another data point on the generation question, since I looked at exactly that option while chasing the same reload() leak. Branch, not a PR: main...farm/d69732fc/fsrouter-reload-leak

I do not think this needs a new stale bit or a resolver.generation bump. read_directory_with_iterator already has the in-place branch (in_place + the prev map) that reuses the DirEntry slot and every Entry whose name still exists; it is just unreachable on demand, because the only thing that can take it is a generation comparison that nothing drives. The branch above exposes it as RealFS::refresh_directory(dir) -> bool (a force_refresh flag on the existing read path) and has reload() call that per directory, falling back to the old bust_dir_cache only when the directory is no longer readable, so deletion and error semantics are unchanged. No new state on DirEntry, no counter.

Two concrete problems with driving this off resolver.generation, for whoever is weighing that variant:

  • Generation is a u16. A per-reload wrapping_add(1) wraps after 65536 calls, and from then on every cached DirEntry (stamped with a large generation) compares as fresh against every smaller requested generation, so reload() silently stops observing filesystem changes. The report that prompted this ran 335k reloads before the OOM abort.
  • DirEntry.generation lives in the process-shared entries map and already has a writer with a different meaning: BundleThread.rs:270 stamps per-bundle resolvers with the DevServer's round counter (DevServer.rs:4306), which lands in the same DirEntry.generation fields. In a dev server process, a directory read during round N is stamped N and then compares as fresh against any reload() generation <= N, so the router serves a stale listing. The two counters advance independently but write the same field.

The refresh shape also made the two residual growths this PR's description calls out go away:

  • dir_cache.remove() is unnecessary for a refresh: DirInfo only holds the index of the entries slot, and the slot is rewritten in place, so a cached DirInfo observes the new listing. Removing it is what costs a DirInfo slot per directory per reload.
  • Route::parse re-interns public_path / match_name / basename into the never-freed DirnameStore on every parse, which is unbounded across reloads even once the entry caches are reused. Unchanged routes now reuse the previous Routes' interned names (those have to stay immortal anyway, live MatchedRoutes borrow them), and basename never needed a copy: the Entry it points into is already process-lifetime.

With those, post-GC RSS over 1600 reload()s of an unchanged 40-route tree is flat under the ASAN debug build, from ~35 KB per reload before. The regression test on the branch asserts the steady-state slope of the last 200-reload window rather than total growth, so it stays meaningful under ASAN, where the quarantine otherwise makes RSS grow even without a leak.

Not opening a second PR for the same leak. Happy for whatever lands here to take any or all of that branch.

@robobun

robobun commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator Author

@dylan-conway putting the three variants in one place so this is decidable without reading each comment above.

Why generation alone does not cover the busts. Resolver.generation has a single writer: BundleThread.rs stamps each per-bundle transpiler with the DevServer's round counter. The callers of bust_dir_cache (FileSystemRouter.reload(), the --hot watcher thread, the module-not-found retry in jsc_hooks.rs) all run on the VM's long-lived resolver, which is initialized to 0 and never advanced, so for them entries.generation >= self.generation is always 0 >= 0 and the bust is the only invalidation they have. Two more constraints on reusing the field for invalidation:

  • Generation is a u16 (bun_core/util.rs). A per-reload bump wraps after 65536 reloads, after which every cached entry compares as fresh against smaller requested generations and reload() silently stops observing changes.
  • The same DirEntry.generation is already written with a different meaning by the DevServer's round counter, so a reload counter and a bundle-round counter would advance independently through one u16.
  • The negative-cache slots (EntriesOption::Err, e.g. ENOENT for a directory created later) carry no generation at all and are returned from cache unconditionally; they can only be invalidated by remove, which bust_entries_cache still does for them.

What stale actually encodes. The watcher-thread callers cannot re-read the directory themselves: they are off the JS thread, and JS-thread consumers iterate DirEntry.data without a lock, so a cross-thread rewrite would race them. An off-thread buster can only leave a marker for the next JS-thread reader. The old marker was remove(), which orphans the BSSMap slot, the heap DirEntry, and its Entry/FilenameStore slots (the unbounded leak in --hot). stale is the same marker without the orphaning; the next reader takes the existing in-place re-read path and clears it.

reload() is the one caller that does not need a deferred marker, because it runs on the JS thread and can drive the in-place re-read synchronously. That is what the two sibling branches do: farm/d69732fc/fsrouter-reload-leak exposes the existing in-place path as refresh_directory(dir) and calls it per route directory, and farm/b630cb56/fix-fsrouter-reload-leak bumps a generation instead (with the u16 caveats above). Either shape also drops the recursive bust walk, and their route-name interning fixes are additive. I kept reload() on the bust path here so there is one invalidation mechanism rather than two, but I am happy to fold the refresh_directory shape into this PR if you prefer it; it does not remove the need for the marker on the watcher path.

So: generation answers "is this listing as new as my pass requires" for the bundler, and the bust answers "the OS says this listing changed" for callers that cannot re-read it themselves. The second cannot be expressed with the first without reserving sentinel values inside a wrapping u16 that another subsystem also writes. If you want, I can rename the field (needs_refresh?) or move it out of DirEntry into the map slot, but some per-entry invalidation bit has to exist for the off-thread busts.

Comment thread src/jsc/hot_reloader.rs
@robobun

robobun commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (d816daf4). Two things worth flagging from this rebase beyond conflict resolution:

#33056 superseded the synchronization half of this PR. The snapshot-under-entries_mutex iteration it added to RouteLoader::load and bust_dir_cache_recursive covers what this branch previously did there (deferred subdirectory recursion), so those changes are dropped and this PR no longer touches src/router/lib.rs or the bust walk. What remains is the leak fix itself (DirEntry.stale + bust semantics), the watcher-thread guard in hot_reloader.rs, and the test.

The new reload() while Bun.build() resolves the same directory test from #33056 caught a real bug in the previous revision of this branch. With my diff as it was, that test failed 3/20 runs locally (main: 20/20), one of them as an ASAN heap-buffer-overflow: RouteLoader::load iterating a DirEntry.data map that the bundler thread had just freed through finalize_result -> DirInfo::get_entries_ref -> RealFS::entries_at. The cause was me making entries_at honor stale: that accessor serves DirInfo readers that run without entries_mutex on other threads, and on main it effectively never rewrites (nothing advances those callers' generation), so honoring stale there turned cross-thread reads into unlocked in-place rewrites.

Fixed in 3e380ca0e7:

  • entries_at ignores stale and is back to its generation-only behavior; those readers keep seeing their DirInfo's listing until the next locked directory read, exactly as they kept reading the orphaned listing before this change.
  • stale is honored only by the rebuild paths that hold entries_mutex: read_directory_with_iterator, dir_info_cached_miss, and dir_info_for_resolution, which now takes the lock like its sibling (its in-place rewrite previously ran unlocked; with stale that gap went from cold to hot, and it is also reachable on main via generation bumps).

With that, the #33056 race test is 40/40 locally under ASAN with this diff, and the leak regression test still passes.

@dylan-conway this is also a sharper version of the answer to your question: the stale bit is only consumed by the three locked directory-rebuild paths, never by the generation-checked accessors, so the two fields stay orthogonal (generation = which pass last validated this listing; stale = the OS invalidated it, refresh on the next locked read).

Comment thread src/resolver/lib.rs Outdated
@robobun
robobun force-pushed the farm/cb4c44e1/fix-bustentriescache-leak branch from 3e380ca to 80bb9b5 Compare July 1, 2026 10:44
robobun added a commit that referenced this pull request Jul 15, 2026
Replace the RSS-threshold test with a direct assertion on the DirnameStore
append count exposed through bun:internal-for-testing (same pattern as
sslCtxLiveCount). The RSS proxy was Linux-only, coupled to an unrelated open
DirEntry leak (#29919), and carried a provably-unfailable AllocError check.

Also drop the exists() fast path from intern_route_path: every call site
passes bytes from a thread-local buffer, the filename store, or Entry inline
storage, so the pointer-range check against DirnameStore's backing buffer is
never true.
@robobun
robobun force-pushed the farm/cb4c44e1/fix-bustentriescache-leak branch from 80bb9b5 to f7f2d92 Compare July 16, 2026 01:20
Comment thread test/js/bun/util/filesystem_router.test.ts Outdated
@robobun
robobun force-pushed the farm/cb4c44e1/fix-bustentriescache-leak branch from f7f2d92 to bd48403 Compare July 16, 2026 01:43

@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 new issues this pass — the quarantine_size_mb=0 fix in bd48403 addresses the last nit and the measured 0-1 MB delta gives the 32 MB threshold plenty of headroom. That said, this reworks cross-thread invalidation of the process-global resolver entry cache (unsafe raw-pointer writes under entries_mutex, watcher-thread vs JS-thread), and the stale-vs-generation design question raised earlier was answered in detail but hasn't been explicitly signed off by a maintainer — so I'm leaving this for human review rather than approving.

What was reviewed:

  • bust_entries_cache now flags stale under entries_mutex instead of remove(); Err/not-found slots still fall back to remove.
  • The three locked rebuild sites (read_directory_with_iterator, dir_info_cached_miss, dir_info_for_resolution) honor stale; entries_at deliberately does not — confirmed against the #33056 race test rationale.
  • hot_reloader::on_file_update: bust moved after the 'locked block so entries_mutex isn't taken recursively; tag re-check guards the slot rewrite race.
  • Leak test: subprocess-isolated, ASAN quarantine disabled in child, ~100× signal margin between fixed/unfixed.
Extended reasoning...

Overview

The PR changes RealFS::bust_entries_cache to mark the existing DirEntry as stale (a new bool field) instead of calling BSSMap::remove(), which orphaned the heap DirEntry + EntryMap and forced append-only growth of EntryStore/FilenameStore on every HMR file event and FileSystemRouter.reload(). The three directory-rebuild paths that already run under entries_mutex now honor stale alongside the existing generation check and take the pre-existing in_place re-read path; entries_at deliberately does not (unlocked cross-thread readers). hot_reloader::on_file_update gains an entries_mutex guard around its DirEntry.data reads and moves the bust_dir_cache call after the lock is released. A new RSS-delta regression test covers the reload() leak.

Security risks

None identified. No user-controlled input parsing, auth, or network surface is touched — this is internal resolver-cache lifecycle. The relevant risk class here is memory safety (UAF / data race), not security.

Level of scrutiny

High. This is unsafe Rust operating on raw *mut DirEntry / *mut EntriesOption pointers into a process-global BSSMap, shared between the watcher thread, the JS thread, and bundler threads. An earlier revision of this very branch introduced an ASAN heap-buffer-overflow (caught by the #33056 race test) by honoring stale in entries_at. The PR description and timeline document a subtle set of locking invariants (which callers hold entries_mutex, which don't, why entries_at must stay read-only for stale, why the bust must move after the 'locked block). This is exactly the category CLAUDE.md flags as most-blocked: cross-thread lifetime, in-place rewrites of shared state, and "accidental RCU" that the fix intentionally removes. It warrants a maintainer's eyes, not bot approval.

Other factors

  • A maintainer explicitly questioned the design ("why does this add stale. doesn't generation already cover this?"). The author gave a thorough, well-reasoned answer (generation is a u16 with one writer in BundleThread, the bust callers' generation never advances, negative-cache slots carry no generation, watcher-thread callers can't safely bump other threads' generations), but there's been no explicit maintainer acknowledgment that the two-field shape is accepted. That's a design decision a human should sign off on.
  • Two sibling branches proposed alternative shapes (refresh_directory / generation-bump for reload()) that the author declined to fold in for scope reasons; a maintainer may want to weigh those trade-offs.
  • All four of my prior inline findings (pre-existing fd-leak note, two stale SAFETY-comment contradictions, ASAN-threshold headroom) are resolved; the bug-hunting system found nothing new this run.
  • Test coverage is good: the new leak test has ~100× margin (0-1 MB fixed vs ~100 MB unfixed with quarantine disabled), and the pre-existing reload() while Bun.build() race test from #33056 is load-bearing for the entries_at-stays-read-only invariant (40/40 under ASAN per the author).

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #36675, which combines this with the other --hot reload leak fixes and adds a memory regression test.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants