Skip to content

Fix bun --hot per-reload memory retention (DirEntry reuse, ref_strings balance) - #36675

Open
robobun wants to merge 13 commits into
mainfrom
claude/f0581dc1/hot-reload-leak-regression
Open

Fix bun --hot per-reload memory retention (DirEntry reuse, ref_strings balance)#36675
robobun wants to merge 13 commits into
mainfrom
claude/f0581dc1/hot-reload-leak-regression

Conversation

@robobun

@robobun robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes #11083
Fixes #14734
Closes #36251, Closes #35588, Closes #29919, Closes #33197

Reproduction

// driver.mjs: rewrites hello.mjs on each [done], the child logs RSS
import { spawn } from "bun";
import { writeFileSync } from "node:fs";
let i = 0;
const next = () => writeFileSync("hello.mjs",
  `// ${Buffer.alloc(800, "x")}\nBun.gc(true);\nif(${++i}%2000===0)console.log('RSS[%d]=%d MB',${i},(process.memoryUsage().rss/1e6)|0);\nconsole.error('[done]');`);
next();
await using r = spawn({ cmd: [process.execPath, "--hot", "hello.mjs"], stdio: ["ignore","inherit","pipe"] });
for await (const c of r.stderr) if (Buffer.from(c).includes("[done]")) next();

20,000 reloads on a release build, before: RSS 27 MB -> 179 MB (~7.6 KB/reload). After: RSS 27 MB -> 29 MB (flat).

Cause

Two independent per-reload leaks stack. A per-size-bucket allocation histogram (wrapped global allocator, see BUN_TRACK_ALLOC below) attributed the growth on a 40-file project directory over 5000 reloads:

size bucket before after source
2-4 KB 4983 allocs, 16.0 MB 4 allocs (flat) DirEntry.data.reserve(64) hashbrown table
32-64 KB 994 allocs, 32.6 MB 3 allocs (flat) EntryStore BSSList overflow blocks
33-64 B +3/reload flat Box<RefString> + transpiled bytes

Resolver DirEntry orphaning (the ~5x canary-vs-1.3.14 regression)

bust_entries_cache() only dropped the hash->index BSSMap mapping, orphaning the boxed DirEntry, its .data StringHashMap bucket array, its open .fd, and every Entry slot it referenced in the append-only EntryStore slab. The next dir_info_cached_miss() allocated everything fresh in a new slot.

The mechanism existed in Zig 1.3.14 too (BSSMap.remove there also only drops the mapping). What turned it into a ~5x regression is that the Rust port adds new_entry.data.reserve(64) so every orphaned DirEntry leaks a fixed 3216-byte hashbrown table regardless of actual directory size; Zig started from zero capacity and leaked only what the directory needed. On top of that, each orphaned DirEntry's names are re-appended into EntryStore, so the leak scales with the project directory's file count.

The backtrace for the per-reload 3216-byte allocation, captured with gdb on a stripped profile build:

hashbrown::RawTable<(StringHashMapKey, *mut Entry)>::reserve_rehash
  <- Resolver::dir_info_cached_miss
  <- Resolver::dir_info_cached_maybe_log
  <- resolve_without_symlinks / finalize_result / transpile_file

Fix. bust_entries_cache() now marks the DirEntry .stale instead of removing the mapping. The four refresh sites (dir_info_cached_miss, dir_info_for_resolution, read_directory_with_iterator, entries_at_locked) see the existing slot via at_index, rewrite it in place, carry any stored .fd forward, and pass prev_map so Entry slots are reused. A freshly-opened directory fd that was not adopted into DirEntry.fd is released.

The .fd cannot simply be closed at bust time: its raw value is also copied into queued ParseTask.contents_or_fd, the dev server's DirectoryWatchStore, and sometimes the watcher's own watchlist, so a close there would race those readers on another thread. Carrying it forward into the refreshed entry is safe because the same owners still hold the same handle.

Because the slot is now rewritten in place instead of orphaned, the hot reloader's directory-event path takes entries_mutex while reading the captured DirEntry (the previous code relied on the orphaned slot never being written again).

ref_strings refcount imbalance

ref_counted_string() returned +1 on a fresh insert (from String::create_external()) but +0 on a cache hit. ref_counted_resolved_source() set source_code_needs_deref = false, so the fresh-insert +1 was never balanced: the ExternalStringImpl refcount could never reach zero, and the duped source bytes + map slot survived forever even after the owning SourceProvider was collected. FileSystemRouter::claim() had the same shape for origin / base_dir / asset_prefix.

Fix. ref_counted_string() now always returns exactly +1 (takes an extra ref on cache hit). ref_counted_resolved_source sets source_code_needs_deref = true, and FileSystemRouter::claim drops its extra ref.

With the balance fixed, ref_strings drains as JSC's CodeCache evicts (it pins one SourceProvider per unique source via the SourceCodeKey). The CodeCache's own prune policy (2000 entries / 16 MB of source / 10 s) bounds that accumulation, so clearing the cache on each reload is not needed and would cost unchanged modules their cache hit: at 3000 reloads of a tiny source, UnlinkedModuleProgramCodeBlock and ref_strings both cap at 2000 and hold there, RSS 32 -> 40 MB.

Watcher entrypoint fd

Watcher::add_file's already-watched branch on Linux overwrote the stored fd with the caller's freshly-opened one and never closed the one it replaced. It now closes previous, gated on fstat(previous).st_nlink > 0 so releasing the last reference to an unlinked inode cannot deliver a deferred IN_DELETE_SELF on the item's still-registered watch (which DEV:hot-9 otherwise turns into a spurious reload; see #33197 for the 0/20-vs-20/20 verification of that gate).

Verification

New in test/cli/hot/hot.test.ts:

  • does not leak file descriptors on each reload (from resolver, watcher: stop leaking file descriptors on every --hot reload #36251): 20 reloads of a dependency under bun --hot, asserts the set of /proc/<pid>/fd entries pointing into the project is identical before and after (allowing a +/-1 transient per target).
  • does not leak native memory on each reload: 60/200 reloads in a 40-file directory, asserts at each sample that ref_strings count never exceeds live UnlinkedModuleProgramCodeBlock count by more than a small constant (i.e. it drains as CodeCache evicts).

hotReloadDiagnostics() is added to bun:internal-for-testing so the test can observe refStrings, sourceMappings, watchlistLen, and hotReloadCounter directly.

build 20k-reload RSS refStrings vs UMPCB fd delta / 20 reloads
before 27 -> 179 MB refStrings grows unbounded +60
after 27 -> 29 MB refStrings = UMPCB - 1, both cap at 2000 0

test/cli/hot/hot.test.ts (14), test/bake/dev/hot.test.ts (11, including DEV:hot-9), and test/js/bun/util/filesystem_router.test.ts (33) all pass on a debug ASAN build. bun run rust:check-all passes on all 10 targets.

Not fixed here

  • LSAN still reports one 28-byte direct leak per reload (Watcher::append_file_assume_capacity::<true> path to_vec()). watchlistLen stays constant, so the allocation is being dropped somewhere without the Cow being freed; it is tiny and separate from the entrypoint-fd path this PR touches.
  • An entrypoint that is atomically renamed over on each save (vim/emacs rename(tmp, target)) still leaks one fd per save on Linux because of the st_nlink gate; fixing it needs the watch re-registered on the new inode first.
  • On macOS/FreeBSD the already-watched branch still drops the incoming fd; closing it needs a proof the caller owned it exclusively.

BUN_TRACK_ALLOC

The per-size-bucket live-allocation histogram that located the DirEntry leak is kept behind cfg(bun_track_alloc) (build with BUN_TRACK_ALLOC=1). It wraps the Rust global allocator with two atomic adds per alloc/free; hotReloadDiagnostics().allocHistogram reads it out. It surfaces reachable-but-growing native memory that LSAN (unreachable-only) cannot see.


no test proof · iteration 6 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/hot/hot.test.ts

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:02 AM PT - Aug 5th, 2026

@robobun, your commit d9bf5846e4e09d73b5467340ede6438c6289717b passed in Build #89104! 🎉


🧪   To try this PR locally:

bunx bun-pr 36675

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

bun-36675 --bun

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Memory Leak running Typescript server on file reload #15857 - Reports memory growing ~2-20 MB per file reload, only released on restart — directly matches the DirEntry orphaning and CodeCache accumulation leaks fixed here
  2. Running --inspect and --hot together causes the debugger to slow down after each hot reload #12659 - Inspector accumulates duplicate script resources after each hot reload (972 → 1944 → 2916...) — CodeCache clearing removes retained Strong<> refs to old SourceProviders visible to the debugger

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

Fixes #15857
Fixes #12659

🤖 Generated with Claude Code

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

Not adding Fixes #15857 / Fixes #12659 per the bot suggestion:

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Hot reload now exposes allocation diagnostics, uses owned reference strings, refreshes stale resolver entries in place, manages directory descriptors safely, and adds regression tests for descriptor and native-memory growth.

Changes

Hot reload leak handling

Layer / File(s) Summary
Allocation tracking and diagnostics
Cargo.toml, scripts/build/config.ts, scripts/build/rust.ts, src/bun_bin/*, src/jsc/virtual_machine_exports.rs, src/runtime/dispatch_js2native.rs, src/js/internal-for-testing.ts
The build can enable bun_track_alloc. The tracked allocator records 32 allocation buckets. Testing bindings expose hot-reload and allocation counters.
Reload cache and reference ownership
src/jsc/RefString.rs, src/jsc/VirtualMachine.rs, src/runtime/api/filesystem_router.rs, src/jsc/AsyncModule.rs, src/runtime/jsc_hooks.rs, src/jsc/hot_reloader.rs
Reference strings now use OwnedRefString ownership. Resolved-source callers use the updated ownership contract. Hot-reloader directory entries are read under entries_mutex.
Stale entries and descriptor lifecycle
src/resolver/fs.rs, src/resolver/dir_info.rs, src/resolver/lib.rs, src/resolver/resolver.rs, src/watcher/Watcher.rs
Resolver invalidation marks entries stale and refreshes them without losing valid descriptors. Unadopted handles are closed. Watcher replacement conditionally closes replaced descriptors.
Hot-reload regression coverage
test/cli/hot/hot.test.ts
Linux tests check project descriptor counts across dependency reloads. Native-memory tests check RSS, cache counts, and diagnostic bounds.

Possibly related PRs

  • oven-sh/bun#36251: This change includes resolver, watcher, hot-reloader, and regression-test updates for hot-reload file-descriptor leaks.
  • oven-sh/bun#36874: Both changes modify stale DirEntry refresh behavior.
  • oven-sh/bun#36878: Both changes modify resolver directory-handle lifecycle and cache handling.

Suggested reviewers: jarred-sumner, cirospaciari

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR addresses the resolver, watcher, ref_strings, and memory-leak objectives, but it does not implement the explicit CodeCache-clear requirement in #35588. Clear JSC CodeCache during --hot reload as required by #35588, or update that issue to accept the bounded-pruning design instead.
✅ Passed checks (3 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The allocation diagnostics, ownership changes, resolver updates, watcher fixes, and regression tests support the linked hot-reload leak objectives.
Title check ✅ Passed The title clearly identifies the primary fix: per-reload memory retention in bun --hot.
Description check ✅ Passed The description explains the causes, fixes, verification results, and known limitations in sufficient detail.

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: 4

Caution

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

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

1587-1640: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Honor DirEntry::stale in entries_at_locked. Hot-reload invalidation marks the cached entry stale without advancing Resolver::generation, and durable DirInfoRef values can still reach that indexed slot. Add || existing.stale to the refresh condition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/resolver/lib.rs` around lines 1587 - 1640, The refresh condition in
Resolver::entries_at_locked currently checks only whether existing.generation is
older than generation. Also refresh the cached DirEntry when existing.stale is
true by including that flag in the condition, while preserving the existing
directory reopening and replacement flow.
🤖 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 `@scripts/build/rust.ts`:
- Around line 494-496: Update the Config type and resolveConfig() to add and
populate a flat trackAlloc field from BUN_TRACK_ALLOC once during configuration
resolution. In the Rust flag construction shown, replace the direct
process.env.BUN_TRACK_ALLOC check with cfg.trackAlloc so subsequent builds use
the resolved configuration rather than mutable process state.

In `@src/bun_bin/track_alloc.rs`:
- Around line 39-54: Update alloc, alloc_zeroed, and realloc in the allocator
implementation to adjust allocation counters only after their underlying calls
return a non-null pointer; preserve the old realloc accounting when it fails.
Add failure-path tests covering null results and verifying histogram bytes and
allocation counts remain correct for all three methods.

In `@src/watcher/Watcher.rs`:
- Around line 879-889: Update the descriptor replacement logic around fds[index]
in Watcher to close every valid previous descriptor when fstat fails or reports
st_nlink == 0, while retaining the existing behavior that avoids closing
descriptors for linked inodes unless their watch is removed first. Ensure
cleanup removes the associated watch before closing where necessary, preserving
the no-spurious-reload behavior.

In `@test/cli/hot/hot.test.ts`:
- Around line 983-1011: Make the ref-string diagnostic assertion in the hot
reload test fail when the diagnostic hook is unavailable: require maxRefStrings
to be nonnegative and below the limit, removing the maxRefStrings < 0 fallback.
Preserve the existing failure-context fields and ensure the invariant explicitly
validates that hotReloadDiagnostics() resolved under the configured test
environment.

---

Outside diff comments:
In `@src/resolver/lib.rs`:
- Around line 1587-1640: The refresh condition in Resolver::entries_at_locked
currently checks only whether existing.generation is older than generation. Also
refresh the cached DirEntry when existing.stale is true by including that flag
in the condition, while preserving the existing directory reopening and
replacement flow.
🪄 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: e1126d54-7038-40a3-b068-e613c9982ddb

📥 Commits

Reviewing files that changed from the base of the PR and between a7838c5 and 25eee8f.

📒 Files selected for processing (16)
  • Cargo.toml
  • scripts/build/rust.ts
  • src/bun_bin/lib.rs
  • src/bun_bin/track_alloc.rs
  • src/js/internal-for-testing.ts
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/hot_reloader.rs
  • src/jsc/virtual_machine_exports.rs
  • src/resolver/fs.rs
  • src/resolver/lib.rs
  • src/resolver/resolver.rs
  • src/runtime/api/filesystem_router.rs
  • src/runtime/dispatch_js2native.rs
  • src/watcher/Watcher.rs
  • test/cli/hot/hot.test.ts

Comment thread scripts/build/rust.ts
Comment thread src/bun_bin/track_alloc.rs Outdated
Comment thread src/watcher/Watcher.rs
Comment thread test/cli/hot/hot.test.ts Outdated
@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. resolver: dedupe DirnameStore interns across bust+reread and Route::parse #34284 - Fixes DirnameStore unbounded growth across bust+reread and Route::parse cycles, overlapping with this PR's DirEntry orphaning fix in bust_entries_cache (leak Fix ?? operator  #1)
  2. router: dedupe DirnameStore interns so FileSystemRouter.reload() does not exhaust the store #34276 - Fixes DirnameStore exhaustion from FileSystemRouter.reload() on repeated --hot cycles, addressing the same per-reload memory growth class as this PR's resolver leak

🤖 Generated with Claude Code

Comment thread src/bun_bin/track_alloc.rs
Comment thread src/bun_bin/track_alloc.rs
Comment thread src/jsc/hot_reloader.rs
Comment thread src/jsc/virtual_machine_exports.rs
Comment thread src/resolver/fs.rs
Comment thread src/resolver/lib.rs Outdated
Comment thread src/resolver/lib.rs
Comment thread src/resolver/resolver.rs Outdated
Comment thread src/resolver/resolver.rs Outdated
Comment thread src/watcher/Watcher.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No bugs found, but this PR reworks memory/fd ownership across the resolver, watcher, hot reloader, and VM ref-string interning — including a semantic change to ref_counted_string (now always +1) and in-place DirEntry slot rewriting under entries_mutex. Worth a human pass on the refcount balance and fd carry-forward paths.

Checked: both ref_counted_string callers (ref_counted_resolved_source via source_code_needs_deref = true, FileSystemRouter::claim) updated for the new +1 contract.
Checked: bust_entries_cache now marks stale instead of removing; all four refresh sites (read_directory, entries_at_locked, dir_info_for_resolution, dir_info_cached_miss) honor .stale and carry prev_fd forward.
Checked: the new entries_mutex lock in hot_reloader.rsbust_dir_cache is called before the locked block, so no re-entry on the non-recursive mutex.
Checked: Watcher::add_file fd-close is Linux-only (ATOMIC_FILE_WATCHER) and the st_nlink > 0 gate's known atomic-rename leak is documented in the PR body.

Extended reasoning...

Overview

This PR fixes three stacked per-reload memory leaks in bun --hot (~7.6 KB/reload → flat), touching 17 files across the resolver (src/resolver/{fs,lib,resolver}.rs), hot reloader (src/jsc/hot_reloader.rs), watcher (src/watcher/Watcher.rs), VM ref-string interning (src/jsc/VirtualMachine.rs), FileSystemRouter, and ZigGlobalObject.cpp. It also adds a cfg(bun_track_alloc) global-allocator histogram, a hotReloadDiagnostics() internal-for-testing hook, and two regression tests (fd-count and native-memory) in test/cli/hot/hot.test.ts.

Security risks

None identified. No untrusted-input parsing, no auth/crypto. The changes are internal lifecycle/ownership plumbing.

Level of scrutiny

High. Every substantive change here is in the most-blocked review category (native memory safety): a refcount-contract change on ref_counted_string (fresh insert and cache hit now both return +1, with source_code_needs_deref flipped to true and FileSystemRouter::claim dropping its explicit extra ref); fd-ownership rework where bust_entries_cache marks DirEntry.stale instead of orphaning, and four refresh sites carry an existing .fd forward while a scopeguard closes any freshly-opened-but-unadopted directory handle; a new cross-thread invariant where the hot reloader now takes entries_mutex around DirEntry reads because slots can be rewritten in place by a JS-thread resolve; and a Linux-only Watcher::add_file close of the replaced fd gated on st_nlink > 0 (with a documented residual leak on atomic-rename saves). Each of these is exactly the shape REVIEW.md flags as needing careful human audit — refcount balance on every terminal path, fd released exactly once with a named owner, and mutex non-re-entry.

Other factors

The PR description is unusually thorough (allocation-histogram evidence, gdb backtrace, before/after tables, explicit "Not fixed here" section), the CodeRabbit findings were all addressed in the follow-up commit, and the two new tests target the specific leak signatures (fd delta via /proc/<pid>/fd, UnlinkedModuleProgramCodeBlock count and ref_strings count via the diagnostic hook). The RSS bound was dropped from the test as too noisy across CI lanes, which is reasonable. That said, the interaction surface — resolver × watcher × hot-reloader thread, plus a public-ish refcount API contract change — is large enough that a maintainer familiar with the resolver's BSSMap/EntryStore lifetime and the dev-server's DirectoryWatchStore fd sharing should confirm the carry-forward reasoning and the open_dir_adopted scopeguard placement in dir_info_cached_miss.

@robobun

robobun commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 7029a35 (#86979): test/cli/hot/hot.test.ts passes on every lane.

The only [new] failure is test/cli/install/bun-upgrade.test.ts on Windows 11 aarch64 ("Canary builds are not available for this platform yet"), which is external release infrastructure and untouched by this diff; reported for main-break triage. The rest are [flaky] (passed alone or on retry).

Ready for review.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

We don't want to clear the codeCache. The code cache is important for hot reloading speed.

@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Dropped the codeCache()->clear() in 3571b01. You're right that unchanged modules should hit it on the next reload.

It turns out the clear isn't needed for the leak fix anyway: CodeCache's own prune policy (2000 entries / 16 MB / 10 s) bounds the accumulation, and with the ref_strings refcount balanced, ref_strings drains in lockstep as the cache evicts. Verified on a release build:

20,000-reload RSS at 3,000 reloads (tiny source)
before this PR 27 -> 179 MB UMPCB ~3000, refStrings ~3000
with clear (previous revision) 26 -> 29 MB UMPCB ~2, refStrings ~1
without clear (this revision) 27 -> 29 MB UMPCB caps at 2000, refStrings caps at 2000, RSS 32 -> 40 MB

The test now asserts refStrings <= UMPCB + 5 at each sample point instead of both < 10.

@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/resolver/lib.rs (2)

1283-1299: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Do not cache a descriptor that the close guard will close.

When store_fd is true and need_to_close_files() is true, readdir stores handle in entries.fd, while should_close_handle remains true. If the previous descriptor is invalid, this block does not clear entries.fd. The cache then stores handle, and _close_guard closes it on return. Later users receive a valid-looking but closed descriptor.

Clear entries.fd before caching when ownership is not transferred, or disarm the guard only after the cache takes ownership.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/resolver/lib.rs` around lines 1283 - 1299, Update the descriptor
ownership handling around the `store_fd` assignment and `in_place` block so
`entries.fd` never retains `handle` while `should_close_handle` remains true.
When ownership is not transferred and no valid previous descriptor is restored,
clear the cached descriptor or disarm the close guard only after caching takes
ownership; preserve reuse of a valid `prev_fd`.

1204-1207: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift

Retain the cached entry when refresh fails.

When a stale refresh fails, both refresh paths call read_directory_error after selecting an existing DirEntry. That operation replaces the cached EntriesOption::Entries value or marks it not found. The stored reference does not own or drop the leaked Box<DirEntry>, and its cached descriptor remains open. A failed reload can therefore leak one directory entry and descriptor per attempt.

Retain the existing slot and return a temporary error, or add lock-safe cleanup that explicitly releases the descriptor before changing the cached variant.

Also applies to: 1602-1613, 1630-1634

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/resolver/lib.rs` around lines 1204 - 1207, Update the stale-entry refresh
flows around the EntriesOption::Entries selection and the read_directory_error
calls to preserve the existing cached entry when reloading fails. Avoid
replacing the cached EntriesOption or marking it not found while the selected
DirEntry remains referenced; instead return a temporary error with the slot
intact, or perform lock-safe cleanup that explicitly releases the descriptor
before changing the cached variant. Apply the same handling to both refresh
paths identified near the related read_directory_error logic.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/resolver/lib.rs`:
- Around line 1283-1299: Update the descriptor ownership handling around the
`store_fd` assignment and `in_place` block so `entries.fd` never retains
`handle` while `should_close_handle` remains true. When ownership is not
transferred and no valid previous descriptor is restored, clear the cached
descriptor or disarm the close guard only after caching takes ownership;
preserve reuse of a valid `prev_fd`.
- Around line 1204-1207: Update the stale-entry refresh flows around the
EntriesOption::Entries selection and the read_directory_error calls to preserve
the existing cached entry when reloading fails. Avoid replacing the cached
EntriesOption or marking it not found while the selected DirEntry remains
referenced; instead return a temporary error with the slot intact, or perform
lock-safe cleanup that explicitly releases the descriptor before changing the
cached variant. Apply the same handling to both refresh paths identified near
the related read_directory_error logic.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3470746d-fc70-4589-8ab6-ee94cf436566

📥 Commits

Reviewing files that changed from the base of the PR and between 25eee8f and 3571b01.

📒 Files selected for processing (5)
  • scripts/build/config.ts
  • scripts/build/rust.ts
  • src/bun_bin/track_alloc.rs
  • src/resolver/lib.rs
  • test/cli/hot/hot.test.ts

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun rebase

…odeCache clear, ref_strings balance)

Fixes #11083
Fixes #14734

Supersedes and combines #36251 and #35588.

Three independent per-reload leaks under --hot:

Resolver DirEntry orphaning (the ~5x canary-vs-1.3.14 regression):
bust_entries_cache() only dropped the hash->index BSSMap mapping, orphaning the
boxed DirEntry, its .data StringHashMap, its open fd, and all Entry slots it
referenced in the append-only EntryStore slab. The next dir_info_cached_miss()
allocated everything fresh. The Rust port added reserve(64) on the fresh map so
every orphaned DirEntry leaked a fixed 3.2 KB hashbrown table regardless of
actual directory size (Zig 1.3.14 started from zero capacity and leaked only
what it touched).
Fix: bust_entries_cache() now marks the DirEntry .stale instead of removing the
mapping; the refresh sites (dir_info_cached_miss, dir_info_for_resolution,
read_directory_with_iterator, entries_at_locked) see the existing slot and
rewrite it in place, carrying the fd forward and passing prev_map so Entry slots
are reused. The unadopted freshly-opened dir fd is released.

JSC CodeCache accumulation:
CodeCache is keyed by source text, so every edited reload inserted a fresh
UnlinkedModuleProgramCodeBlock holding a Strong<> to the old SourceProvider and
source string; its prune only fires after ~10s / ~16 MB / 2000 entries, which a
fast edit loop does not hit.
Fix: GlobalObject::reload() clears it alongside the module registry.

ref_strings refcount imbalance:
ref_counted_string() returned +1 on insert, +0 on hit;
ref_counted_resolved_source() set source_code_needs_deref=false, so the
fresh-insert +1 was never balanced and the ExternalStringImpl + duped source
bytes could never reach zero even after the SourceProvider was collected.
FileSystemRouter's claim() had the same shape for origin/base_dir/asset_prefix.
Fix: ref_counted_string() now always returns exactly +1 (extra ref on hit);
ref_counted_resolved_source() sets source_code_needs_deref=true, and
FileSystemRouter::claim drops its extra ref.

Watcher::add_file's already-watched branch on Linux closes the descriptor it
just replaced (gated on st_nlink>0 so releasing an unlinked inode cannot deliver
a deferred IN_DELETE_SELF on the item's still-registered watch).

Adds hotReloadDiagnostics() to bun:internal-for-testing (refStrings,
sourceMappings, watchlistLen, hotReloadCounter) for the regression test.
Adds an optional per-size-bucket live-byte histogram wrapper around the Rust
global allocator (BUN_TRACK_ALLOC=1 at build time) for future investigations.

Co-authored-by: robobun <robobun@users.noreply.github.com>
Comment thread src/jsc/RefString.rs
Comment thread src/jsc/RefString.rs
Comment thread src/jsc/RefString.rs
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/resolver/resolver.rs
Comment thread src/resolver/resolver.rs
Comment thread src/resolver/resolver.rs
Comment thread src/runtime/api/filesystem_router.rs
Comment thread src/runtime/api/filesystem_router.rs
Comment thread src/runtime/api/filesystem_router.rs
Comment thread src/runtime/api/filesystem_router.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

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

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

4535-4540: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Propagate directory iteration errors.

Line 4539 converts an iterate_dir(open_dir) error into a successful partial scan. The code then caches new_entry, so later resolutions can treat incomplete directory contents as complete until invalidation. Return the iterator error instead of caching partial results.

As per coding guidelines, “Never swallow failures or signal success after failure; propagate I/O, syscall, cleanup, and requested-operation errors explicitly.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/resolver/resolver.rs` around lines 4535 - 4540, Update the directory
iteration loop around dir_iterator.next() to propagate Err values instead of
breaking and caching partial results. Preserve the existing handling for
Ok(Some(v)) and Ok(None), and return the iterator error through the enclosing
resolver operation.

Source: Coding guidelines


4224-4228: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Reopen or rewind the descriptor before generation-only refreshes.

When entries.generation < self.generation and entries.stale is false, queue_top.fd can point at a descriptor whose POSIX directory offset is already at EOF. bun_sys::iterate_dir resets only iterator state, not the descriptor offset. The refresh can therefore cache an empty or partial map. Open a fresh descriptor or reset the offset before enumeration.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/resolver/resolver.rs` around lines 4224 - 4228, The generation-only
refresh path must reset the directory descriptor before enumeration when reusing
a non-stale entry. Update the logic around entries.generation, entries.stale,
and slot.fd to reopen the descriptor or rewind its POSIX offset so
bun_sys::iterate_dir starts from the beginning and produces a complete map.
🤖 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/runtime/api/filesystem_router.rs`:
- Around line 100-109: Make base_dir a non-optional OwnedRefString in both
FileSystemRouter and MatchedRoute, preserving the constructor and
MatchedRoute::init assignments without wrapping the value in Some. Update all
base_dir reads to remove unwrap calls, including the sites near the constructor
and route matching logic, while leaving genuinely optional origin and
asset_prefix unchanged.

---

Outside diff comments:
In `@src/resolver/resolver.rs`:
- Around line 4535-4540: Update the directory iteration loop around
dir_iterator.next() to propagate Err values instead of breaking and caching
partial results. Preserve the existing handling for Ok(Some(v)) and Ok(None),
and return the iterator error through the enclosing resolver operation.
- Around line 4224-4228: The generation-only refresh path must reset the
directory descriptor before enumeration when reusing a non-stale entry. Update
the logic around entries.generation, entries.stale, and slot.fd to reopen the
descriptor or rewind its POSIX offset so bun_sys::iterate_dir starts from the
beginning and produces a complete map.
🪄 Autofix

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: 78565092-be07-49f2-ae56-933e5d865e9b

📥 Commits

Reviewing files that changed from the base of the PR and between 85e82c0 and c695426.

📒 Files selected for processing (6)
  • src/jsc/AsyncModule.rs
  • src/jsc/RefString.rs
  • src/jsc/VirtualMachine.rs
  • src/resolver/resolver.rs
  • src/runtime/api/filesystem_router.rs
  • src/runtime/jsc_hooks.rs

Comment thread src/runtime/api/filesystem_router.rs
…fd only for cache hits

base_dir is assigned unconditionally in both FileSystemRouter's
constructor and MatchedRoute::init, so the Option (and its unwraps) only
existed for the old take()-based finalizers. The queue-seed gate now
also skips generation-outdated entries: their stored descriptor offset
is at EOF like a stale entry's, so re-enumerating through it would cache
an empty listing; the re-scan opens a fresh handle instead and the
stored fd still carries forward into the refreshed entry.
@robobun

robobun commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

feb261e addresses the review feedback on c695426:

  • base_dir is now a plain OwnedRefString in both FileSystemRouter and MatchedRoute (it was only Option to support the old take()-based finalizers); the unwraps and the unreachable top_level_dir fallback in get_script_src are gone.
  • The queue-seed fd gate now also skips generation-outdated entries, not just stale ones. The stored descriptor's offset is at EOF from the previous enumeration and getdents64 resumes at the offset, so re-listing through it would cache an empty DirEntry. This was the same mechanism the !entries.stale gate already guarded against; the generation arm was pre-existing behavior (reachable only with store_fd after a bundler generation bump) and the fix is the same one-line guard, so it belongs here. The stored fd still carries forward into the refreshed entry via prev_fd.

Not changing the Err(_) => break in the directory iteration loops: that break-on-error shape predates this PR (same on main, and the while let Ok(Some(_)) loops in the other two readdir sites share it). Propagating mid-stream getdents errors through resolution is a behavior change across three sites with its own failure-path design, separate from this fix.

filesystem_router.test.ts (33) and hot.test.ts (14) pass; rust:check-all 10/10.

Comment thread src/resolver/resolver.rs
Comment thread src/resolver/lib.rs
The carry-forward gate in read_directory_with_iterator skipped the close
when the caller passed the handle in (had_handle), but ownership of
maybe_handle transfers to this function (Scanner passes
Dir::into_raw()), so overwriting entries.fd with prev_fd orphaned it.
Close whatever entries.fd holds unless _close_guard owns it. Not
reachable today (Scanner runs before anything can mark an entry stale),
so this corrects the ownership contract rather than a live leak.
Comment thread src/resolver/lib.rs

@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 d9bf584. The three concerns raised on earlier revisions — the entries_mutexWatcher.mutex AB-BA ordering in load_as_file, the dead open_dirs defer in dir_info_cached_miss, and the !had_handle fd-ownership gap in read_directory_with_iterator — are each addressed. Deferring to a human reviewer given the scope: cross-thread in-place mutation of resolver DirEntry slots under entries_mutex, the RefString refcount-balance change (now source_code_needs_deref = true), and fd carry-forward across three refresh sites.

What was reviewed:

  • Lock ordering: the four new entries_mutex acquire sites drop before any path that reaches Watcher.mutex; bust_dir_cache_recursive snapshots entry pointers under the lock and releases before recursing.
  • OwnedRefString balance: ref_counted_string returns exactly +1 on both arms; into_raw() transfers to C++ matched by source_code_needs_deref; FileSystemRouter/MatchedRoute release via field drop.
  • fd lifecycle: prev_fd carry-forward closes the displaced handle unless _close_guard owns it; open_dir_owned.take() in dir_info_cached_miss transfers only the freshly-opened handle.
Extended reasoning...

Overview

Twenty files across the resolver (lib.rs, resolver.rs, fs.rs, dir_info.rs), JSC bindings (RefString.rs, VirtualMachine.rs, hot_reloader.rs, AsyncModule.rs, jsc_hooks.rs), FileSystemRouter, the watcher, plus a new opt-in bun_track_alloc global-allocator wrapper and two regression tests in hot.test.ts. The core changes are (1) bust_entries_cache marks DirEntry slots .stale for in-place refresh instead of orphaning them, with fd carry-forward at four refresh sites; (2) ref_counted_string now returns an RAII OwnedRefString that is always +1, and ResolvedSource sets source_code_needs_deref = true so the external-string finalizer can drain the map; (3) Watcher::add_file closes the fd it displaces on Linux (gated on st_nlink > 0).

Security risks

None identified. No user-controlled input reaches new parsing or allocation-sizing logic; the track_alloc histogram is behind a build-time cfg and the diagnostic hook is gated on bun:internal-for-testing.

Level of scrutiny

High. This is memory-safety-critical native code: manual refcount balance across an FFI boundary, raw-fd ownership transfer, and in-place mutation of a process-global cache slot that other threads hold raw pointers into. The PR itself introduced (and then fixed after review) an AB-BA deadlock and an fd-ownership contract gap, which is exactly the class of bug that benefits from a second set of eyes on the final shape. The get_entries_refget_entries_ref_locked migration also changes the aliasing contract for every DirEntry.data reader.

Other factors

The PR has iterated substantially (six+ revisions) in response to review; all prior findings are resolved and CI on the previous head was green with no [new] failures. Test coverage is good (fd-count assertion via /proc/<pid>/fd, refStrings-vs-UnlinkedModuleProgramCodeBlock invariant). One comment-cop bot note on lib.rs:1291 remains open but is cosmetic. Given the breadth of the resolver/watcher interaction and the refcount-semantics change visible to C++ (source_code_needs_deref flip), a maintainer sign-off is appropriate.

@robobun

robobun commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #37050 fixes the EBADF/EISDIR reload race in this same area and touches the Watcher::add_file already-watched branch differently (never replacing a valid stored fd, instead of closing the replaced one), so that hunk will conflict. The resolver-side DirEntry/fd carry-forward here is untouched and still needed.

Jarred-Sumner pushed a commit that referenced this pull request Aug 6, 2026
…#37050)

## What

Fixes `test/cli/hot/watch-many-dirs.test.ts` ("handles 129 directories
being updated simultaneously"), which has been failing on main and
across PR CI runs on Linux with:

```
error: EISDIR reading "/tmp/.../hot-many-dirs_.../dir-0072/index.js"
error: EBADF reading "/tmp/.../hot-many-dirs_.../dir-0009/index.js"
```

followed by the 30s test timeout (the failed reload never prints the
expected output). Seen red on main builds 86133/86209/86347 and in many
PR runs, e.g. [build 89567](https://buildkite.com/bun/bun/builds/89567).
The race is timing-sensitive: it reproduced once in 80 local runs of an
unmodified release build (with the exact EBADF + EISDIR + timeout
signature), while loaded CI machines hit it regularly.

## Cause

`bun --hot` re-transpiled a changed module by reading through a file
descriptor snapshotted from the watcher's watchlist
(`ImportWatcher::snapshot_fd_and_package_json`). The snapshot copies the
fd number under the watcher mutex, but the read in
`transpiler.rs:read_file_with_allocator` happens after the mutex is
released.

Concurrently, the watcher thread's `flush_evictions` closes stored
descriptors under that same mutex. On Linux, a write to a file inside a
watched directory produces both a file event and a directory event; the
directory-event arm in `hot_reloader.rs` evicts the watched file's entry
(to handle atomic saves that replace the inode). With 129 directories
the inotify events span multiple batches, so a reload triggered by batch
N snapshots a descriptor that batch N+1's eviction then closes:

- fd closed before the read: `EBADF reading "<path>"`
- fd number recycled by one of the resolver's many `openat(O_DIRECTORY)`
calls: `EISDIR reading "<path>"`

The mutex ordering added previously (`flush_evictions` before `enqueue`,
snapshot under the mutex) only closed the same-event window; nothing can
serialize a later batch's eviction against a read that happens after the
snapshot returns. Reading through the stored fd was also wrong after
atomic saves (pre-rename inode, stale contents), which is why the
entrypoint already had a workaround skipping it.

## Fix

Reloads now always open the file by path, and the watchlist keeps sole
ownership of its stored descriptor:

- `snapshot_fd_and_package_json` becomes `snapshot_package_json`; the
stored fd is never handed out for reads. This removes the race
structurally and makes the entrypoint's open-by-path workaround
universal, so it is deleted.
- `Watcher::add_file` no longer replaces a valid stored fd on the
already-watched branch (it only upgrades fd-less entries inserted by
`add_file_by_path_slow`, e.g. the `--hot` entrypoint, so the
directory-event recovery path keeps working). The old overwrite dropped
the replaced descriptor without closing it, leaking one fd on the
entrypoint per reload.
- `add_file` now returns `FdOwnership` saying whether it adopted the
caller's descriptor; the transpiler handoff sites, `AsyncModule`, the
bundler's plugin watch path, and `add_file_by_path_slow` close the
descriptor when the watcher did not take it. The bundler's
`watcher_data` path intentionally ignores the outcome and keeps today's
behavior, since its descriptor can be borrowed from the resolver's entry
cache.

The fd-per-reload behavior is locked in by a new deterministic test in
`watch-many-dirs.test.ts` that counts `/proc/<pid>/fd` entries for the
entrypoint and an edited dependency across 15 reloads. On an unfixed
build the entrypoint gains exactly one fd per reload (+15); with the fix
both counts are stable. (The remaining per-reload directory-handle
growth comes from the resolver's `DirEntry` cache and is addressed
separately in #36675.)

## Verification

- New test fails on an unfixed build (`entryDelta: 15`) and passes with
the fix.
- The race fix is structural rather than statistical: after this change
the transpiler only ever reads descriptors it opened itself, so the
closed-by-eviction read cannot occur. Empirically, "handles 129
directories" failed 1/80 runs on an unfixed release build and passed
40/40 (plus 12/12 debug ASAN) runs on a fixed one.
- `test/cli/hot/hot.test.ts` (12), `test/cli/hot/watch.test.ts` (2),
`test/cli/watch/` (7), `test/bake/dev/hot.test.ts` (11, including
DEV:hot-9), `test/js/bun/util/filesystem_router.test.ts` (33),
`test/cli/test/test-changed.test.ts` (20) all pass on a debug ASAN
build.
- `bun run rust:check-all`: 10/10 target combos clean.

The race predates any recent change (the fd-reuse pattern and evicting
close shipped with #30412 and were inherited from the original design);
CI frequency rose recently with timing shifts. The open #36675 fixes the
adjacent resolver-side leaks and includes a different change to the same
`add_file` branch; this PR supersedes that hunk by never replacing the
stored fd at all.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/cli/hot/watch-many-dirs.test.ts

<!-- robobun:evidence:end -->
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.

bun --hot always leaks memory Memory Not Freed After Running bun --hot Command

2 participants