Skip to content

bundler: stop re-interning module paths into FilenameStore on every Bun.build() - #31647

Merged
Jarred-Sumner merged 6 commits into
mainfrom
farm/bcdb198f/fix-bun-build-filename-store-leak
Jun 2, 2026
Merged

Jarred-Sumner merged 6 commits into
mainfrom
farm/bcdb198f/fix-bun-build-filename-store-leak

Conversation

@robobun

@robobun robobun commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator

Repro

Repeated in-process Bun.build() calls panic with an index-out-of-bounds and the process dies with SIGTRAP (exit 133) after ~2000 builds:

const entry = /* a 60-module project */;
const build = async () => {
  const r = await Bun.build({ entrypoints: [entry], minify: true, sourcemap: "external" });
  for (const o of r.outputs) await o.arrayBuffer();
};
for (let i = 1; i <= 5000; i++) await build();
panic: index out of bounds: the len is 4095 but the index is 4095

RSS stays flat (~560 MB) the whole time — this is not a general heap leak, it's a fixed-capacity pointer array being overrun.

Cause

<bun_alloc::OverflowGroup<bun_alloc::OverflowListBlock<&[u8], 64>>>::tail    src/bun_alloc/lib.rs
<bun_alloc::BSSStringList<8192, 65>>::append
<bun_resolver::fs::FilenameStore>::append_slice                              src/resolver/lib.rs
<bun_paths::fs::Path as ...::PathResolverExt>::dupe_alloc
<bun_paths::fs::Path as ...::PathResolverExt>::dupe_alloc_fix_pretty
bun_bundler::bundle_v2::...::generic_path_with_pretty_initialized            src/bundler/bundle_v2.rs

Path::dupeAlloc interns every module path's text/pretty into the process-lifetime FilenameStore (a BSSStringList) so the returned Path borrows 'static data. The store is append-only and never freed. When its inline buffer (8192 slots) fills, entries spill into an OverflowGroup whose block-pointer array is fixed-size; eventually the array is overrun and the bounds check panics.

The Rust port of dupeAlloc had dropped two things the Zig original (src/resolver/fs.zig) does, so each build re-appended every path:

  1. The isSliceInBuffer short-circuit. Zig checks FilenameStore.exists(text) or DirnameStore.exists(text) — if a slice already points into a process-lifetime store, it's already 'static, so Zig returns the path unchanged instead of appending a duplicate. The port always appended (a PORT NOTE flagged it: "TYPE_ONLY shim … this always interns").
  2. Arena-routing the disjoint case. Zig's dupeAlloc takes an allocator and, when text and pretty are disjoint, allocates one combined text\0pretty\0 buffer from the per-build arena (not the permanent store). pretty here is a freshly-relativized display path (../../tmp/.../m0.js) recomputed every build and never a byte-subslice of the absolute text, so it always hit this branch — and the port interned it into FilenameStore, leaking one copy per Bun.build() call.

Fix

Restore both behaviors in Path::dupe_alloc / dupe_alloc_fix_pretty, porting the four Zig branches faithfully:

  • Add the exists() short-circuit (checking both FilenameStore and DirnameStore, matching Zig) — already-interned paths are returned unchanged.
  • Thread BundleV2::arena() through dupe_alloc/dupe_alloc_fix_pretty and allocate the disjoint text/pretty buffer (and the Windows posix-normalized pretty) from it, not the store.

The arena is reset per build, and every path that escapes to JS is copied into an owned buffer (OutputFile::init → BuildArtifact.path: Box<[u8]>) before the arena is torn down, so arena-backing the transient display path is safe.

Verification

  • The 5000-build repro now completes cleanly (exit 0) with flat RSS across the whole run; it crashed at ~build 2000 before.
  • New regression test in test/bundler/bun-build-api.test.ts (500 modules × 400 in-process builds): the pre-fix binary panics with the exact reported message at ~build 270; the fixed binary prints OK 400 and exits 0.
  • test/bundler/bun-build-api.test.ts (46 pass), bundler_edgecase (103 pass), bundler_naming + bundler_splitting (21 pass), bundler_bun (10 pass) all green — including the existing sourcemap-leak test, confirming path/display output is unchanged.

Relationship to #31504

#31504 fixes the allocator (src/bun_alloc/lib.rs): an off-by-one in OverflowGroup::tail and a 32×-too-small overflow block size, which together let the store hold ~8.4M distinct names without panicking — the right fix for the bun --bun / Bun.resolveSync case that genuinely interns that many distinct filenames.

This PR fixes the resolver layer for the Bun.build() case, where the same ~60 paths are re-interned every build. That's an unbounded-growth leak that #31504's larger ceiling would only delay (≈64k builds), not fix. The two are complementary and touch disjoint files.

…un.build()

Repeated in-process `Bun.build()` calls panicked with
`index out of bounds: the len is 4095 but the index is 4095` (SIGTRAP,
exit 133) after ~2000 builds. `Path::dupeAlloc` interns every module
path into the process-lifetime `FilenameStore`; the Rust port had
dropped two things the Zig original does, so each build re-appended
every path and the append-only store grew without bound until its
overflow-block pointer array (fixed at 4095 blocks) went out of bounds.

The Zig `dupeAlloc`:
- Short-circuits via `isSliceInBuffer`: when `text`/`pretty` already
  point into a process-lifetime store (`FilenameStore` or
  `DirnameStore`), the slices are already `'static`, so it returns the
  path unchanged instead of appending a duplicate.
- Takes an allocator and routes the disjoint `text`/`pretty` case — a
  freshly-relativized display path recomputed every build — into the
  per-build bundle arena, not the permanent store.

Restore both in `Path::dupe_alloc`/`dupe_alloc_fix_pretty`, porting the
four Zig branches faithfully and threading `BundleV2::arena()` through
the call sites. The arena is reset per build and every path that
escapes to JS is copied into an owned buffer first, so arena-backing the
transient display path is safe.

Verified: the 5000-build repro now completes with flat RSS; regression
test in bun-build-api.test.ts crashes the pre-fix binary at ~build 270
and passes after.
@coderabbitai

coderabbitai Bot commented Jun 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 62b03e0a-130b-43df-88a0-8ec02bd78143

📥 Commits

Reviewing files that changed from the base of the PR and between 4663109 and af729f6.

📒 Files selected for processing (6)
  • src/bundler/LinkerContext.rs
  • src/bundler/linker_context/postProcessCSSChunk.rs
  • src/bundler/linker_context/postProcessHTMLChunk.rs
  • src/bundler/linker_context/postProcessJSChunk.rs
  • src/resolver/lib.rs
  • test/bundler/bun-build-api.test.ts

Walkthrough

Path interning now accepts an explicit per-build arena allocator. Resolver trait signatures and Path::dupe_alloc/dupe_alloc_fix_pretty were updated to use the arena and avoid permanently appending transient pretty/text buffers to the process FilenameStore. Bundler callsites were updated to pass their arenas, and a regression test ensures repeated Bun.build() calls no longer panic.

Changes

Filename store arena allocation fix

Layer / File(s) Summary
Path duplication trait and namespace helper
src/resolver/lib.rs
PathResolverExt now requires &bun_alloc::MimallocArena for dupe_alloc and dupe_alloc_fix_pretty. Adds dupe_namespace helper to special-case file/empty namespace as static.
Arena-aware path interning implementation
src/resolver/lib.rs
Path::dupe_alloc rewritten to short-circuit when slices already live in process store, handle aliased/sub-slice pretty, and allocate combined text\0pretty\0 in the provided arena for disjoint cases, returning a static-lifetime Path view over arena memory.
dupe_alloc_fix_pretty platform adjustments
src/resolver/lib.rs
Non-Windows delegates to dupe_alloc(alloc). Windows arm allocates mutable pretty in the arena, converts to POSIX in-place, and assigns new.pretty from arena memory (removes prior Vec + FilenameStore append).
Bundler callers and path initialization
src/bundler/bundle_v2.rs, src/bundler/LinkerContext.rs, src/bundler/linker_context/*
Bundler threads explicit arenas: generic_path_with_pretty_initialized accepts bump and passes it into dupe_alloc_fix_pretty(bump); bundle path interning calls use self.arena(); LinkerContext and post-process functions pass worker/arena into generate_isolated_hash.
Repeated build regression test
test/bundler/bun-build-api.test.ts
Adds test that generates 500-module cyclic graph and runs 400 Bun.build() invocations in one process (minify + external sourcemaps), consuming outputs to exercise interning and asserting no panic and successful completion.

Suggested reviewers

  • RiskyMH
  • dylan-conway
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: stopping re-interning of module paths into FilenameStore during repeated Bun.build() calls.
Description check ✅ Passed The description covers the required sections: provides a detailed repro, explains the root cause, describes the fix, and includes comprehensive verification steps.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@github-actions github-actions Bot added the claude label Jun 1, 2026
@robobun

robobun commented Jun 1, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:52 AM PT - Jun 1st, 2026

❌ @robobun, your commit af729f6 has 2 failures in Build #59531 (All Failures):

  • test/js/node/http/node-http-backpressure-max.test.ts - code 1 on 🍎 14 x64
  • ❌ CPU instruction violation on Windows x64 — 1 check(s) failed
  • The baseline build contains instructions not available on Nehalem (SSE4.2, no AVX/AVX2/AVX512).

    • Static instruction scan

    Static scan violations

    strpbrk  [CLDEMOTE]  (6 insns)
    

    If these are runtime-dispatched behind a CPUID gate: add each symbol to scripts/verify-baseline-static/allowlist-x64-windows.txt with a comment pointing at the gate.

    If there's no gate: this is a real bug — a -march leaked into a subbuild.


🧪   To try this PR locally:

bunx bun-pr 31647

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

bun-31647 --bun

@robobun

robobun commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced the SIGTRAP crash (index out of bounds: the len is 4095 but the index is 4095) with the reporter's 5000-build script — crashes at ~build 2000.

Root cause: the Rust port of Path::dupeAlloc dropped Zig's isSliceInBuffer dedup short-circuit and its per-build-arena routing for the disjoint text/pretty case, so every Bun.build() re-interned all module paths into the append-only process-lifetime FilenameStore until its fixed 4095-block overflow array overran.

Fix: restore both behaviors (faithful port of Zig's four dupeAlloc branches), threading BundleV2::arena() through.

Verified:

  • 5000-build repro completes (exit 0), RSS flat across the run.
  • Regression test crashes the pre-fix binary at ~build 270, passes after (gate fail-before/pass-after confirmed).
  • Existing bundler suites green (build-api, edgecase, naming, splitting, bun, sourcemap-leak).

Waiting on CI.

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

🤖 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/bundler/bundle_v2.rs`:
- Line 2471: The code calls path.dupe_alloc(self.arena()).expect("oom") inside
the path_as_static construction, which panics on OOM; replace the expect("oom")
with the repo OOM helper (e.g., call .unwrap_or_oom() on the Result returned by
dupe_alloc or pass the Result into bun_core::handle_oom) so allocation failures
are converted to the controlled OOM path; update both occurrences that use
path.dupe_alloc(self.arena()).expect("oom") (including the call that feeds
path_as_static) to use .unwrap_or_oom() or handle_oom() consistently.

In `@src/resolver/lib.rs`:
- Around line 569-578: Add a debug assertion before each early return that calls
self.into_static() to verify the namespace is also interned/static: check that
self.namespace is empty or "file" or that is_interned(self.namespace) is true,
so callers that produce interned text but a transient namespace will be caught
in debug builds; place this debug_assert immediately above the branches that
return Ok(unsafe { (*self).into_static() }) (the branches that currently check
core::ptr::eq(self.text.as_ptr(), self.pretty.as_ptr()) && self.text.len() ==
self.pretty.len() and the other similar early-return branches) so the invariant
is enforced without changing release behavior.
🪄 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: c68345a7-81cf-428f-b715-9b95689b0fba

📥 Commits

Reviewing files that changed from the base of the PR and between 5ac120c and 4663109.

📒 Files selected for processing (3)
  • src/bundler/bundle_v2.rs
  • src/resolver/lib.rs
  • test/bundler/bun-build-api.test.ts

Comment thread src/bundler/bundle_v2.rs
Comment thread src/resolver/lib.rs
Comment thread test/bundler/bun-build-api.test.ts Outdated
…nchanged

The dupe_alloc short-circuit widens text/pretty/namespace to 'static via
into_static(). The caller proves text/pretty are interned; add a
debug_assert that namespace is also static (empty/"file"/store-interned)
so a transient namespace would be caught in debug builds. Deduplicates
the three early-return sites behind one checked closure.
Comment thread src/resolver/lib.rs
bun:test's default per-test timeout is 5s, so an explicit timeout is
required for this hundreds-of-builds test (as with the sibling leak
tests); drop it from 300s to 180s to match the CI runner's own per-test
ceiling. Extract BUILDS to a const and note the ~550-module recursion
cliff the module count deliberately stays under. No behavior change.
@robobun

robobun commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

Update — review feedback addressed

  • debug_assert on namespace (suggested by review): added before every into_static() early return, verifying the namespace is static/interned (the invariant the caller relies on). Deduplicated behind one closure. Confirmed it doesn't fire across build-api / plugin / plugin-chain / naming / splitting suites.
  • .expect("oom"): kept — pre-existing, and the dominant local convention in bundle_v2.rs (38 uses vs 0 for unwrap_or_oom/handle_oom).
  • Test timeout: bun:test's default is 5s and this runs hundreds of real bundles (~155s on debug/ASAN), so an explicit timeout is required (like the sibling leak tests). Lowered 300s→180s to match the CI runner's own per-test ceiling.

Also worth noting: while tuning the test I confirmed that going much higher than ~550 modules/build hits a separate, pre-existing stack overflow in the recursive tree-shaker (LinkerContext::mark_file_live_for_tree_shaking) — unrelated to this fix. The test deliberately stays under that.

All three review threads resolved. Gate fail-before (old binary panics, 3/3 runs) and pass-after (completes) both reconfirmed on the debug build. CI running.

Comment thread src/resolver/lib.rs
Comment thread src/resolver/lib.rs
Comment thread src/resolver/lib.rs
@robobun

robobun commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI update

The only red lane was windows-x64-baseline-verify-baseline — a whole-binary Intel-SDE emulation check for non-baseline CPU instructions (AVX etc.), on a 10-min timeout. It's unrelated to this diff:

  • This change is pure Rust path-interning logic (pointer-range checks + small slice copies) — no SIMD/intrinsics that could introduce an AVX instruction.
  • The matching windows-2019-x64-baseline-test-bun lane (same baseline binary, full test suite including this PR's regression test) passed, as did the debian-13-x64-asan lane. A genuine illegal-instruction would fail the baseline test lane too — it didn't.

Pushed one ci: retrigger to re-roll the flaky SDE lane. All 73 other lanes were green.

@robobun

robobun commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI: the failing lane is unrelated to this diff (concrete evidence)

Pulled the windows-x64-baseline-verify-baseline job log. The failure is the static instruction scan flagging one symbol:

VIOLATIONS (would SIGILL on x86-64 Nehalem):
  strpbrk  [CLDEMOTE]  (6 insns)

strpbrk is a C runtime function and CLDEMOTE is a cache-line-demote instruction — neither comes from this PR. The diff is pure Rust path-interning (copy_from_slice + pointer-range checks) compiled with -Ctarget-cpu=nehalem; it cannot emit CLDEMOTE in a libc strpbrk. Corroborating:

  • The same static scan on linux-x64-baseline and linux-x64-musl-baseline passed on this PR (identical nehalem ISA over the same Rust code).
  • The SIMD baseline test phase on Windows passed (28/28).
  • The lane passes on other open PRs, consistent with a CRT/link artifact that isn't a function of this source change.

This looks like a pre-existing Windows-CRT strpbrk/CLDEMOTE issue surfacing in the static scan, not something this PR introduced. The diff itself is green on every lane that exercises it (incl. the x64 ASAN lane and both Windows test lanes).

I've used my one CI re-roll, so I'm not pushing further retriggers. Flagging for a maintainer: the change is ready and the red lane needs either an allowlist entry for strpbrk [CLDEMOTE] or a CRT/flags fix that's out of scope here.

@robobun

robobun commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI follow-up — sharper read on the strpbrk/CLDEMOTE failure

Correction to my earlier note: I compared against main builds. CLDEMOTE appears 0 times in main's windows-x64-baseline binary but 6 times in this branch's — attributed to strpbrk at a fixed address (0x01431c121c), stable across both of my builds.

strpbrk is the MSVC C runtime routine (Windows msvcrt), not Bun/Rust source — this PR is pure Rust path-interning compiled at -Ctarget-cpu=nehalem and touches no C build flags, so it can't author CLDEMOTE into the CRT strpbrk. The delta is therefore in the linked CRT codegen on the Windows baseline agent, not this diff. Supporting evidence:

  • Same static scan on linux-x64-baseline + linux-x64-musl-baseline passed here (identical nehalem ISA over the same Rust code).
  • Windows SIMD baseline test phase passed (28/28); the only failure is the static scan's 1 CRT symbol.
  • Every lane that exercises this diff is green (incl. x64 ASAN and both Windows test lanes).

I can't fully explain why the CRT strpbrk object differs on this branch vs main without the Windows agent's toolchain state, but it isn't something this source change controls. I've used my one CI re-roll, so I'm not pushing further retriggers. Handing off: a maintainer with Windows-CI access can confirm whether the agent's MSVC CRT changed (strpbrk gaining CLDEMOTE) and either allowlist strpbrk [CLDEMOTE] in allowlist-x64-windows.txt or pin the CRT — both out of scope for this path-interning fix.

…st-processing

Making generic_path_with_pretty_initialized honor its arena (prior commit)
turned LinkerContext::path_with_pretty_initialized into a cross-thread
allocation hazard: it passed self.arena() — the bundle-thread-owned
MimallocArena — but generate_isolated_hash runs on worker threads (via
generate_chunk -> post_process_{js,css,html}_chunk). MimallocArena asserts
single-thread ownership, so a non-aliased path reaching the arena branch
from a worker would panic (debug) or corrupt the heap (release).

Thread worker.arena() through generate_isolated_hash into
path_with_pretty_initialized, matching how generateCodeForFileInChunkJS
already handles the same 'pretty not computed' edge case. The bundle-thread
scan/enqueue callers in bundle_v2.rs keep self.arena() (correct thread).

Verified: bun-build-api (incl. the 500x400 regression test), splitting,
html and css bundler suites all pass.
@robobun

robobun commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

Update — fixed a real cross-thread allocation bug the review caught

Commit af729f6: making generic_path_with_pretty_initialized honor its arena (the core fix) had turned LinkerContext::path_with_pretty_initialized into a cross-thread-allocation hazard — it fed self.arena() (the bundle-thread-owned MimallocArena) into generate_isolated_hash, which runs on worker threads during chunk post-processing. MimallocArena asserts single-thread ownership (debug panic / release heap corruption). It hadn't surfaced because that call site only passes aliased paths (which skip the arena branch), but it was a footgun.

Fix: thread the worker-local arena (worker.arena()) through generate_isolated_hash → path_with_pretty_initialized, from the three post_process_{js,css,html}_chunk callers — matching how generateCodeForFileInChunkJS already handles the same edge case. Bundle-thread scan/enqueue callers keep self.arena().

Verified: bun-build-api (incl. the 500×400 regression test), bundler_splitting, bundler_html, and the CSS suites all pass on the debug build (these exercise the multi-chunk worker-thread path).

All four review threads addressed and resolved. This is a substantive code change (not a retrigger), so CI re-runs legitimately.

@robobun

robobun commented Jun 1, 2026

Copy link
Copy Markdown
Collaborator Author

CI status — both red lanes are unrelated flake; diff is green

1. darwin-14-x64-test-bun (new): test/js/node/http/node-http-backpressure-max.test.ts — "should handle backpressure with the maximum allowed bytes" timed out after 60000ms. This is a node:http backpressure test, unrelated to this PR's resolver/bundler path-interning change. It's a flaky isolated timeout, not a deterministic failure: it passed on the other 15 test lanes of this same build — including debian-13-x64-asan, all other darwin lanes (aarch64, darwin-26), and every Linux/Windows test lane. A real regression would fail across lanes.

2. windows-x64-baseline-verify-baseline: the same pre-existing static-scan hit on the MSVC CRT strpbrk ([CLDEMOTE], 6 insns), covered in the comments above — the identical Linux-x64-baseline + musl-baseline scans over the same Rust code pass, and this PR is -Ctarget-cpu=nehalem Rust that can't author CLDEMOTE into a libc routine.

Every lane that exercises this diff is green: the full bundler/resolver test coverage, the ASAN lane, and the 500×400 in-process-build regression test. CodeRabbit's latest pass reported no actionable comments, and all review threads are resolved.

I've already used my one CI re-roll, so I'm not pushing further — handing off. A maintainer can re-run the flaky node-http-backpressure-max darwin lane and decide on the strpbrk [CLDEMOTE] allowlist/CRT question (out of scope for this fix).

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