Skip to content

Clear JSC CodeCache on --hot reload and balance ref_strings refcount - #35588

Closed
robobun wants to merge 5 commits into
mainfrom
farm/6f18b28d/hot-reload-codecache-leak
Closed

Clear JSC CodeCache on --hot reload and balance ref_strings refcount#35588
robobun wants to merge 5 commits into
mainfrom
farm/6f18b28d/hot-reload-codecache-leak

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Fixes #11083
Fixes #14734

Re-applies #30553 (closed unmerged when the Rust rewrite moved the files) against the current tree, with the refcount invariant pushed into the shared helper so it also covers FileSystemRouter.

Reproduction

import { spawn } from "bun";
import { writeFileSync } from "node:fs";

let i = 0;
function next() {
  i++;
  writeFileSync("hello.mjs", `var x = ${i};
Bun.gc(true);
const s = require("bun:jsc").heapStats();
console.error(JSON.stringify({ i: ${i}, umpcb: s.objectTypeCounts.UnlinkedModuleProgramCodeBlock || 0 }));
`);
}
next();
await using runner = spawn({
  cmd: [process.execPath, "--hot", "run", "hello.mjs"],
  stdio: ["ignore", "ignore", "pipe"],
});
for await (const chunk of runner.stderr) {
  if (new TextDecoder().decode(chunk).includes("}")) next();
}

heapStats().objectTypeCounts.UnlinkedModuleProgramCodeBlock climbs exactly +1 per reload with no upper bound; RSS grows linearly.

Cause

Two leaks on the --hot reload path whenever the edited file's content changes. They are not independent: the refcount fix only drains once the SourceProvider is released, which needs the CodeCache entry gone.

ref_strings leaks its initial ref

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 ref_strings map is weak (entries self-remove via free_ref_string -> RefString::destroy -> clear_ref_string when the impl refcount hits zero), but with that leaked +1 the ExternalStringImpl refcount could never reach zero, so the duped source bytes and the map slot survived forever even after the owning SourceProvider was collected.

FileSystemRouter had the same bug: claim() took an unconditional +1 on whatever ref_counted_string returned, so a fresh origin/base_dir/asset_prefix leaked +1 after finalize() released its single hold.

JSC CodeCache accumulates stale entries

JSC::CodeCache is keyed by source text (SourceCodeKey = hash + length + flags). Every reload of an edited file produces a different transpiled string, so getUnlinkedModuleProgramCodeBlock misses and inserts a fresh entry holding a Strong<UnlinkedModuleProgramCodeBlock>, whose key keeps the old Zig::SourceProvider (and its source StringImpl) alive via RefPtr.

The cache's prune() only runs pruneSlowCase() when ~10s have elapsed, or the working set grew by ~16 MB of source text, or it hit 2000 entries. A tight edit-save loop hits none of these, so one entry is added per reload and none are evicted. The cap is in source bytes, not code-block overhead, so 16 MB of pinned source can hold substantially more in UnlinkedModuleProgramCodeBlock + SourceProvider objects.

Fix

  • ref_counted_string() now always returns exactly +1 (takes an extra ref on cache hit). Callers consume it uniformly: ref_counted_resolved_source sets source_code_needs_deref = true, and FileSystemRouter::claim drops its extra ref.
  • GlobalObject::reload() calls vm.codeCache()->clear() alongside moduleLoader()->clearAll() / requireMap()->clear(). Every module already goes through fetch + transpile after clearAll(), so the added JSC re-parse is a fraction of the existing per-reload work; it trades that for not pinning up to ~2000 stale code blocks between prunes.

Verification

refStringsCount() is exposed via bun:internal-for-testing so the test can observe the native-heap side (ref_strings entries are Box<[u8]> + ExternalStringImpl, invisible to heapStats()).

test/regression/issue/11083.test.ts does 50 reloads with unique content each time and asserts both the max live UnlinkedModuleProgramCodeBlock count and the ref_strings count stay under 10. With the fix both sit at 1-2; reverting either half drives its counter to ~50.

build maxCodeBlocks maxRefStrings result
both fixes 2 1 pass
CodeCache clear only 2 45 fail (refs)
neither (system bun) 51 n/a fail (throws)

test/cli/hot/hot.test.ts (12 tests) and test/js/bun/util/filesystem_router.test.ts (29 tests, including the UAF guard) remain green.


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/hot.test.ts

GlobalObject::reload() cleared the module loader and requireMap but not
the VM's CodeCache. CodeCache is keyed by source text, so every reload
of an edited file inserted a new UnlinkedModuleProgramCodeBlock entry
holding a Strong<> to the old SourceProvider (and its source string).
Its prune only fires after ~10s elapsed or ~16 MB accumulated; a tight
edit-save loop hits neither, so entries piled up unbounded (heapStats
shows UnlinkedModuleProgramCodeBlock climbing exactly +1 per reload).

Separately, ref_counted_resolved_source() returned with
source_code_needs_deref=false so the +1 from create_external() on a
fresh RefString was never balanced. The ref_strings map is designed to
be weak (entries self-remove via free_ref_string -> RefString::destroy
-> clear_ref_string when the impl refcount hits zero), but with that
leaked +1 the duped source bytes and map slot survived forever even
after the owning SourceProvider was collected.

reload() now calls vm.codeCache()->clear(), and
ref_counted_resolved_source now hands off exactly +1 (taking an extra
ref on cache hit) with needs_deref=true so SourceProvider::create
consumes it.

Fixes #11083
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 83aa21bb-d272-43f6-9d8d-879ebec4846c

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and 3404977.

📒 Files selected for processing (8)
  • src/js/internal-for-testing.ts
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/virtual_machine_exports.rs
  • src/runtime/api/filesystem_router.rs
  • src/runtime/dispatch_js2native.rs
  • test/cli/hot/hot.test.ts
  • test/regression/issue/11083.test.ts

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

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:17 AM PT - Jul 25th, 2026

@robobun, your commit 3404977 is building: #80919

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Memory Leak running Typescript server on file reload #15857 - Reports memory climbing ~2-20MB per file save when a TS server reloads files, directly matching the CodeCache and ref_strings leak patterns fixed here
  2. Duplicate Output Issue After Repeated Saves with bun --hot #13511 - Duplicate console output after repeated --hot saves, consistent with stale module code blocks accumulating in the CodeCache
  3. Running --inspect and --hot together causes the debugger to slow down after each hot reload #12659 - Debugger slows down after each hot reload with loaded file count doubling (972 → 1944 → 2916), matching stale entries accumulating in CodeCache and ref_strings

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

Fixes #15857
Fixes #13511
Fixes #12659

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

I didn't find any bugs, but this touches refcount balance across the Rust↔C++ FFI boundary (ref_strings / ExternalStringImpl / SourceProvider::create's needsDeref gate) plus a VM-wide CodeCache::clear() on every hot reload — worth a human pass on the ownership reasoning.

What was reviewed:

  • Traced the impl refcount on both fresh-insert and cache-hit paths through adopt_wtf_impl (no inc) → toWTFString(ZeroCopy) (+1) → needsDeref deref → ~SourceProvider; both now net to zero when the last provider dies.
  • Checked the other ref_counted_string caller (filesystem_router.rs) — it uses the wrapper that discards was_new, so its behavior is unchanged.
  • Confirmed both ref_counted_resolved_source call sites use ::<false>, so the ADD_DOUBLE_REF branch's interaction with the new needsDeref=true is dead.
  • Test buffers stderr to newline framing before JSON.parse, kills the child, and asserts a heapStats-derived count with a wide margin (2 observed vs <10 threshold vs ~50 unfixed).
Extended reasoning...

Overview

Two changes on the --hot reload path: (1) GlobalObject::reload() now calls vm.codeCache()->clear() after clearing the module registry, and (2) ref_counted_resolved_source() now sets source_code_needs_deref = true and takes an extra ref on cache hits so both fresh and hit paths hand exactly +1 to the C++ consumer. A new regression test drives 50 hot reloads with unique source each time and asserts UnlinkedModuleProgramCodeBlock count stays bounded.

Security risks

None identified. This is a leak fix on a dev-only (--hot) path; no untrusted-input parsing, no auth/crypto.

Level of scrutiny

High. Per the repo's review guidance, native memory safety — specifically "reference counts provably balanced on every terminal path" — is the most-blocked category. The refcount flow here spans three layers (create_external's +1 leaked into impl_, adopt_wtf_impl which does not inc, and SourceProvider::create's toWTFString(ZeroCopy) which does inc followed by the needsDeref-gated deref). My trace says the new balance is correct on both was_new branches, but a wrong-by-one here is a UAF once a SourceProvider is collected while another holder still has the impl, and that only shows under GC pressure. The CodeCache::clear() is straightforward but is a VM-wide behavioral change on every reload.

Other factors

  • The PR re-applies #30553, which was closed unmerged during the Rust rewrite rather than approved-then-ported.
  • The unused ADD_DOUBLE_REF=true path now interacts differently with needsDeref=true (previously all three refs leaked; now one is balanced) — dead code today, but a human may want it audited or removed.
  • Test quality is good: newline-framed stderr parsing, await using on the subprocess, per-platform timeout branch on isDebug, and a threshold (<10) well separated from both the fixed value (2) and the unfixed value (~50). PR description shows USE_SYSTEM_BUN=1 fails and bun bd passes, plus hot.test.ts and filesystem_router.test.ts remaining green.

The original test only asserted on heapStats().UnlinkedModuleProgramCodeBlock,
which is the JSC-cell side of the leak and goes to ~2 with the CodeCache
clear alone. The ref_strings leak is native-heap (Box<[u8]> + ExternalStringImpl
+ map slot), invisible to heapStats(), so reverting the refcount balance fix
in VirtualMachine.rs would have silently passed.

Expose VirtualMachine::ref_strings.count() via bun:internal-for-testing and
assert it stays <10. Verified: with only the CodeCache clear, maxRefStrings
climbs to ~45 and the test fails; with both fixes it sits at 1.

Also surface child crash lines instead of silently skipping non-JSON stderr,
and drop the stale TODO in hot.test.ts that referenced this leak.
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/jsc/virtual_machine_exports.rs Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread test/regression/issue/11083.test.ts Outdated
Comment thread src/jsc/VirtualMachine.rs Outdated
ref_counted_string previously returned +1 on a fresh insert and +0 on a
cache hit, which no caller can consume correctly without also knowing
was_new. Move the cache-hit ref into the helper so it always hands back
exactly +1. This lets ref_counted_resolved_source drop its was_new
special-case, and fixes the identical leak in FileSystemRouter where
claim() took an unconditional +1 on top of the fresh-insert +1 and
finalize() only released one.

Also: trim explanatory comments per review, drop the defensive typeof
guard in the test (a missing binding now fails loudly via the crash
regex + toBeGreaterThan(0) instead of silently passing).
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated

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

Thanks — all three points from the earlier pass are addressed: the +1-always invariant now lives in ref_counted_string itself (so claim correctly drops its extra ref), the test calls refStringsCount() unconditionally with a > 0 floor, and the comments are down to the durable one-liners. I traced the refcount at both call sites (fresh vs. cache-hit, including MatchedRoute::init's independent +1) and it balances. Deferring to a maintainer for the contract change on ref_counted_string and the per-reload codeCache()->clear() tradeoff rather than bot-approving a refcount change on the module-load path.

What was reviewed:

  • Confirmed ref_counted_string has exactly two callers (ref_counted_resolved_source, filesystem_router::claim); both consume the new always-+1 contract correctly.
  • adopt_wtf_impl adopts (no inc), so source_code_needs_deref = true is the single balancing deref — no double-free on cache hit.
  • MatchedRoute::init still takes its own refs on origin/base_dir/asset_prefix, so dropping claim's ref_() doesn't under-count when a route outlives its router.
Extended reasoning...

Overview

Re-review after the author addressed prior feedback. The PR fixes two coupled leaks on the --hot reload path: (1) ref_counted_string returned +1 on fresh insert but +0 on cache hit, and callers didn't distinguish, leaking the create_external +1; (2) JSC's CodeCache accumulates one UnlinkedModuleProgramCodeBlock per unique source and its prune heuristic never fires under a tight edit loop. The fix pushes the +1-always invariant into ref_counted_string (so both callers — ref_counted_resolved_source and FileSystemRouter::claim — consume it uniformly) and adds vm.codeCache()->clear() to GlobalObject::reload(). A new refStringsCount() internal-for-testing binding lets the regression test observe the native-heap map size independently of heapStats().

Prior feedback resolution

All three earlier inline comments are resolved: (a) the sibling filesystem_router.rs leak is fixed at the shared-helper layer per REVIEW.md's whole-class rule; (b) the typeof refStringsCount === "function" guard is gone and expect(maxRefStrings).toBeGreaterThan(0) guards against silent rot; (c) the multi-line bug-history comments are squeezed to single-line invariant/SAFETY notes and comment-cop's re-fires are on the pre-squeeze commit (all threads resolved).

Security risks

None identified. This is a leak fix; no user-controlled input reaches new parsing or allocation paths, and the new binding is gated behind bun:internal-for-testing.

Level of scrutiny

High. Changing the return contract of a refcount helper on the module-source interning path is exactly the class where an off-by-one is a UAF or a permanent leak. I verified there are only two call sites of ref_counted_string in the tree (both updated), that adopt_wtf_impl adopts without incrementing (so source_code_needs_deref = true is the single balancing deref, not a double-free on cache hit), and that MatchedRoute::init still takes its own +1 on the router's BackRefs so a matched route outliving its router stays sound after claim dropped its ref_(). The codeCache()->clear() addition is placed after moduleLoader->clearAll() / requireMap()->clear() so every module re-fetches anyway; the re-parse cost rationale in the PR description holds.

Other factors

Deferring rather than approving because (1) refcount-contract changes on the module-load path are the canonical "maintainer should look" category in this repo, and (2) clearing the entire JSC CodeCache on every reload is a deliberate perf/memory tradeoff (vs. e.g. a targeted delete or letting prune run) that a maintainer should ratify. Test coverage is solid: the regression test drives 50 unique-content reloads and asserts both counters stay bounded, with the PR's own table showing each half of the fix independently fails when reverted.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status

The diff is green. test/regression/issue/11083.test.ts passes on every lane it ran; hot.test.ts and filesystem_router.test.ts remain green.

Seven failures on build #80919, all tagged flaky by the CI helper and unrelated to this change:

test lane relation
test/js/node/test/parallel/test-fastutf8stream-reopen.js ubuntu-25.04 aarch64 none (fast-utf8-stream reopen race; also failed on #80326 x64)
test/cli/install/migration/complex-workspace.test.ts debian-13 x64-asan none (bun install workspace migration)
test/js/node/test/parallel/test-http-server-connections-checking-leak.js debian-13 x64 none (http server FinalizationRegistry)
test/cli/run/no-orphans.test.ts darwin-26 aarch64 none (orphan process tracking)
test/cli/run/require-cache.test.ts darwin-26 aarch64 none (non---hot import() path; ref_counted_resolved_source only runs under is_watcher_enabled(), codeCache()->clear() only runs in reload(); 65 MB vs 64 MB threshold)
test/js/node/net/node-net.test.ts darwin-26 aarch64 none (EADDRINUSE port race)
test/regression/issue/20144/20144.test.ts darwin-26 aarch64 none

This PR only touches GlobalObject::reload(), the ref_counted_string refcount path (whose only callers are the --hot module loader and FileSystemRouter), and a test-only binding. None of those paths are reachable from the tests above.

Ready for a maintainer pass on the refcount contract change and the codeCache()->clear() tradeoff.

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

@robobun robobun closed this Aug 1, 2026
robobun added a commit that referenced this pull request Aug 4, 2026
…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>
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