Skip to content

js_parser: compress symbol link chains when following merged declarations - #42242

Open
robobun wants to merge 5 commits into
mainfrom
robobun/405cb0f0/symbol-link-chain-compression
Open

robobun wants to merge 5 commits into
mainfrom
robobun/405cb0f0/symbol-link-chain-compression

Conversation

@robobun

@robobun robobun commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Transpiling n merged enum E {…} declarations is O(n^2): 720 KB takes 14 s on 1.4.2 (62% of samples in generate_closure_for_type_script_namespace_or_enum). Bundling n top-level var x re-declarations has the same shape: 64k take 6.2 s.
  • declare_symbol (src/js_parser/p.rs:5193) links each merging re-declaration from the old symbol to the new one, so n re-declarations form a chain of length n. p.rs:6919 and to_ast (p.rs:9268) walk that chain from its start once per declaration and never shorten it.

Fix

  • Add bun_ast::symbol::follow_symbols: symbol::Map::follow for the parser's flat table. It returns the chain end and points every symbol on the way at it (path compression). All five chain walks in js_parser now call it.
  • Correct because every reader of link is has_link(), a walk to the end, or one single-hop compare that cannot change (Notes). New links are only set on chain ends.
  • Parser-side fix only. Bun.build of merged enums stays O(n^2) for a separate reason (Notes).
  • Verified: test/js/bun/transpiler/transpiler-redeclared-symbol-chain-hang.test.ts (time ratio against distinctly named declarations: 0.7 to 1.0 with the fix, 9x on debug main, 20x to 32x on 1.4.2). Also test/bundler/esbuild/ts.test.ts and test/bundler/transpiler/transpiler.test.js. Self-reviewed: 5 concerns raised, all about what this description claims, 5 addressed.

Background

  • A file's symbols live in a flat table indexed by Ref::inner_index. When two declarations of one name merge (var x; var x;, enum E {} enum E {}), the scope keeps the newest symbol and the older one gets link = newer. A Ref to an older symbol resolves by following links to the end.
  • esbuild has the same loops, but keeps the existing symbol for enum + enum since 0.14, so its enum chains stay short.
  • The linker already compresses these links through Map::follow.
Notes
  • Self-review also ran 330 inputs through builds with and without the change: output and diagnostics were identical.
  • The five call sites: generate_closure_for_type_script_namespace_or_enum, the top-level-symbol-to-parts pass in to_ast, record_assignment, the relocated top-level var pass, and the duplicate-import check in scan_imports.
  • Release timings, before and after. transformSync of merged enum E{A_i} x 8192 / 16384 / 32768: 202 / 802 / 5223 ms before, 18 / 35 / 82 ms after (the namespace control is 13 / 27 / 58 ms). Bun.build of var x=1; x 16384 / 32768 / 65536: 389 / 1623 / 6215 ms before, 17 / 29 / 68 ms after. The var numbers are for a re-declared name with one reference. The linker still does work per use times per declaring part, as esbuild does.
  • The var case was found while checking the enum report. Transpiling it was already linear. Bundling was not, because only to_ast's bundle-mode pass walks the chain per part. A gdb sample of the release build put the time at p.rs:9268.
  • Still quadratic, not touched here: Bun.build of n merged top-level enums (8192 blocks take 8.3 s and 8.6 GB RSS before and after). compute_ts_enums_map (p.rs:9450) copies the shared member map once per declaration. That has its own follow-up.
  • Link writers after the first compression (visit-time declare_symbol with Kind::Other, the HMR duplicate-namespace link, Map::merge) only set a link on a symbol that has none, or run behind a logged error, so compression cannot strand a path.
  • The one reader that looks at an immediate link and not the chain end is the single-use inliner (substitute_single_use_symbol_in_expr, symbols[ident.ref].link == ref). ref there is a nested let/const, which never gets a link and is never a link target, so the compare cannot change.
  • Walkers left alone: the react-compiler lowering (is_module_level_or_global, a bounds-checked read-only walk over the same parser table), pm_diff_normalize.rs (capped at 64 hops) and LinkerContext (CSS-only symbols).
  • Side effect: on 1.4.2, export var x=1; followed by 200000 var x=2; lines and imported from another file kills Bun.build with SIGSEGV (stack overflow in the recursive Map::merge on the long chain). With this change the chain is already short when the linker runs and the build takes 167 ms. The output for that shape is still wrong (export var x; var x; drops the declarations of x from the bundle). That bug is older than this PR and is tracked separately.

[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/transpiler/transpiler-redeclared-symbol-chain-hang.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/40] gen cpp.rs (cppbind)
[2/40] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[3/40] gen JS modules (bundle-modules)
Preprocess modules (13229ms)
Bundle modules (70ms)
Postprocesss modules (178ms)
Bundle Functions (667ms)
Generate Code (45ms)

[14.20s] Bundled "src/js" for development
  2791 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[3/27] cargo bun_runtime → libbun_runtime.a
[20/27] cxx obj/unified/UnifiedSource-src_runtime_bake-0.cpp.o
FAILED: obj/unified/UnifiedSource-src_runtime_bake-0.cpp.o 
/usr/bin/ccache /usr/lib/llvm-21/bin/clang++ -march=nehalem -O0 -glldb -g3 -gz=zstd -fno-standalone-debug -fsanitize=address -fno-exceptions -fno-c++-static-destructors -fno-rtti -fno-omit-frame-pointer -mno-omit-leaf-frame-pointer -fvisibility=hidden -fvisibility-inlines-hidden -fno
... (truncated)

release without fix: 2 FAILED
bun test v1.4.3-canary.1 (2fa92b585)

test/js/bun/transpiler/transpiler-redeclared-symbol-chain-hang.test.ts:
53 |   expect(out).toStartWith("var E;\n((E) => {})(E ||= {});\n((E) => {})(E ||= {});\n");
54 |   expect(out.indexOf("var E", 1)).toBe(-1);
55 |   expect(out.length).toBe("var E;\n".length + n * "((E) => {})(E ||= {});\n".length);
56 | 
57 |   // ~0.8x with the fix; 10x (debug) to 30x (release) before it.
58 |   expect(ms / baseline.ms).toBeLessThan(3);
                                ^
error: expect(received).toBeLessThan(expected)

Expected: < 3
Received: 19.87074234283156

      at <anonymous> (/workspace/bun/test/js/bun/transpiler/transpiler-redeclared-symbol-chain-hang.test.ts:58:28)
(fail) merged enum declarations transpile in linear time [776.93ms]
75 | 
76 |   expect(out).toContain("var x = 1;\nvar x = 1;\n");
77 |   expect(out).toContain("export {\n  x\n};");
78 | 
79 |   // ~1x with the fix; 10x (debug) to 20x (release) before it.
80 |   expect(ms / baseline.ms).toBeLessThan(4);
                                ^
error: expect(received).toBeLessThan(expected)

Expected: < 4
Received: 22.94364281789948

      at <anonymous> (/workspace/bun/test/js/b
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/transpiler/transpiler-redeclared-symbol-chain-hang.test.ts
bun test v1.4.3 (5f554969b)

test/js/bun/transpiler/transpiler-redeclared-symbol-chain-hang.test.ts:
(pass) merged enum declarations transpile in linear time [4523.92ms]
(pass) top-level var re-declarations bundle in linear time [2745.59ms]

 2 pass
 0 fail
 9 expect() calls
Ran 2 tests across 1 file. [10.22s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 795ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/29] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[2/29] gen cpp.rs (cppbind)
[3/29] gen JS modules (bundle-modules)
Preprocess modules (13190ms)
Bundle modules (75ms)
Postprocesss modules (162ms)
Bundle Functions (727ms)
Generate Code (55ms)

[14.22s] Bundled "src/js" for production
  2594 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[3/21] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/sr
... (truncated)
diff hotspot
src/ast/symbol.rs                                  | 17 +++++
 src/js_parser/p.rs                                 | 38 ++--------
 src/js_parser/scan/scan_imports.rs                 | 11 +--
 ...transpiler-redeclared-symbol-chain-hang.test.ts | 81 ++++++++++++++++++++++
 4 files changed, 107 insertions(+), 40 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/ast/symbol.rs                                             5      3     15
src/js_parser/p.rs                                            4      4     15
src/js_parser/scan/scan_imports.rs                            1      1     15
…nspiler/transpiler-redeclared-symbol-chain-hang.test.ts      3      8     15

self-review · 16 surviving concerns

  • should-fix: Under neighbour CPU load, the wall-clock ratio false-fails on a fixed release build about 2% of the time, and a process.cpuUsage() ratio measured on the same runs never does; the fix is a 6-line change to bench() that I verified still fails on unfixed 1.4.2.
  • should-fix: The wall-clock ratio test will emit flaky-new warnings rather than red builds: about 0.1-0.7% per lane-run on Linux, roughly 3 a day fleet-wide and 1 every 1-2 weeks on main. All three false-fails I reproduced came from bench()'s wall-clock spent < 400 budget, which is cheap to fix.
  • should-fix: The test's only transpile-mode chain exists solely because scope.rs:463-467 still maps enum+enum to ReplaceWithNew (upstream esbuild: mergeKeepExisting). The premise that no rule-independent transpile chain exists is wrong: plain-JS { var x=1; xN } and function f(x, y=[x=1, xN]){ var x; xN } …
  • consider: Don't split, stack or fold this PR. Land it first, because four open PRs (Bun.Transpiler: make treeShaking remove unused declarations and their imports #38352, bundler: tree-shake top-level member assignments with the binding they mutate #41154, bundler: treat property reads on a module-local plain object literal as side-effect free #41152, minify: never give a slot the name of a pinned symbol #41174) each add a private non-compressing copy of the loop it deletes. Bun.Transpiler: make treeShaking remove unused declarations and their imports #38352 re-creates the same O(n^2) on this PR's own var x=1; x n input. None of the four conflict textually, so nothing forces…
  • consider: Kind: performance (a complexity fix; the fuzz-ledger numbers are given and I reproduced them). For Symbol.link walks this PR is the right unit: all 5 parser walkers go through one helper, and the 3 walkers it leaves are defensible. But "n merged same-name declarations go quadratic" has a second, …
  • consider: Kind: performance (complexity fix). Nobody outside the fuzzer hits this. There are zero tracker reports, and real-world same-name redeclaration counts top out at 88 per file where about 7,000 are needed to cost 100 ms. For bun build, the PR's own headline input (merged enums) stays at about 9 s…
  • consider: Kind: performance (complexity fix, maintainer-requested, numbers in PR). The new helper is unenforced. Four in-flight PRs by the same author add five fresh hand-rolled .link.get() walks in src/js_parser that merge cleanly beside follow_symbols. One of them (Bun.Transpiler: make treeShaking remove unused declarations and their imports #38352) re-creates the quadratic on…
  • consider: This is a performance change with real numbers, and it is the right parser-level fix to merge on its own. Its "bundles in linear time" half is true only for redeclarations nothing else references; for every realistic idiom, 94-100% of Bun.build time stays in the linker's users-times-declarers edg…
  • consider: Kind: performance. The read-side concern is real. Two readers of HAS_BEEN_ASSIGNED_TO and one reader of use_count_estimate read a non-root and miscompile today. All three are identical on 1.3.13, 1.4.2 and the PR build, so they belong in a stacked bug-fix PR built on follow_symbols, not in this…
  • consider: Performance fix with a regression test: test/harness.ts has no helper for "A is at most k times B", this PR adds the 7th hand-rolled timing-ratio harness, and css: split target-incompatible selectors out of a rule in linear time #42243 and Join repeated header values in linear time, and throw at the string length limit #42250 add two more; move the helper into harness.ts here, without holding the parser fix for it.
  • consider: For Bun.build of merged top-level enums with members (the PR's own headline input) this PR changes nothing observable. compute_ts_enums_map rebuilds the shared member map once per merged block (n x M), and only the chain root's entry is ever read. Land js_parser: compress symbol link chains when following merged declarations #42242 unchanged, say in the body that the…
  • consider: Under Bun.build, merged enums stay O(n^2) after this PR because of ScopeUses::sees (renamer.rs:1246-1251, from bundler: let a nested binding keep its name unless it would capture a reference #41286). That scan has shipped since 1.4.1, takes 60/60 profile samples after the fix, and costs about 5x the parser walk this PR removes. It is a separate ledger item against the rena…
  • consider: The PR's "no walker left in src/js_parser" claim holds under a multi-line search. But the single-line grep used to justify it, and any per-line lint, cannot see three rustfmt-split .link sites, one of which is the only reader that depends on the immediate link. An enforcing lint is cheap and mu…
  • consider: This is a performance change and the right granularity. Do not grow it into a RootRef newtype: that is not one mechanical PR and would not make the class unrepresentable. The guard this repo uses is the helper plus a source-lint, and four open PRs that hand-roll the same walk show the lint is t…
  • consider: Performance PR with maintainer-requested numbers; its premise holds and it should merge. The probe is confirmed. named_exports[alias].ref_ leaves the parser as the exporting declaration's own non-root ref. top_level_symbols_to_parts is keyed by the chain root, at the exact line this PR rewrit…
  • consider: Kind: performance fix that is also an unclaimed crash fix. The same link chain overflows the recursive symbol::Map::merge on the 2 MiB "Bundler" thread, and Bun.build() dies with a silent SIGSEGV on 1.4.2 and at the PR base. The PR removes that only as a side effect. Its title, body, test header …

42 concerns were raised and did not survive verification.

…ions

declare_symbol links every re-declaration of a name that merges with
the previous one (`enum E {} enum E {}`, top-level `var x; var x;`) from
the old symbol to the new one, so n re-declarations form a chain of
length n. The parser followed that chain from its start once per
declaration without shortening it, in
generate_closure_for_type_script_namespace_or_enum (transpile) and in
to_ast's top-level-symbol-to-parts pass (bundle), so both were O(n^2):
720 KB of merged enum blocks took 14 s to transpile and 64k `var x`
re-declarations took 6 s to bundle.

Add bun_ast::symbol::follow_symbols, the flat-table counterpart of
Map::follow with the same path compression, and use it at every place
the parser walks a link chain.
@coderabbitai

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview 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: Essentials

Run ID: a1af537c-ece2-4393-bd8e-4b23b6a525f5

📥 Commits

Reviewing files that changed from the base of the PR and between 5d378a0 and 85e256c.

📒 Files selected for processing (1)
  • src/ast/symbol.rs

Included review availability: Your plan provides up to 10 included reviews per hour; 0 remain after this review.


Walkthrough

The change adds iterative, path-compressing symbol lookup and replaces manual link traversal in parser and import-resolution paths. Regression tests cover long merged-declaration chains in transpiler and bundler flows.

Changes

Symbol chain resolution

Layer / File(s) Summary
Canonical symbol lookup
src/ast/symbol.rs
Adds follow_symbols, which resolves linked symbols iteratively and compresses traversed paths to the terminal reference.
Parser resolution integration
src/js_parser/p.rs, src/js_parser/scan/scan_imports.rs
Replaces manual symbol-link traversal in assignment tracking, variable relocation, TypeScript lowering, symbol indexing, and import validation.
Long chain regression coverage
test/js/bun/transpiler/transpiler-redeclared-symbol-chain-hang.test.ts
Adds transpiler and bundler tests for 16,384 merged declarations and compares execution time with distinct declarations.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Severity of issue fixed: Medium

Merge Risk: ⚪ Minimal · up to 85e25

The incremental change has no identified merge-blocking risk.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: path compression for symbol-link chains during merged declaration resolution.
Description check ✅ Passed The description explains the problem, fix, scope, background, verification steps, performance results, regression coverage, and known limitations. It uses alternative headings instead of the template …

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

@robobun

robobun commented Sep 10, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on 1.4.2 and on a debug build of main with the ledger script: n merged enum E{A_i} declarations took 202 / 802 / 5223 ms for n = 8192 / 16384 / 32768 (release), against 13 / 27 / 58 ms for the same number of namespace blocks. Bun.build of n top-level var x=1; re-declarations showed the same curve (389 / 1623 / 6215 ms for 16k / 32k / 64k). With this branch both are linear (82 ms and 29 ms at n = 32768).

The regression test (test/js/bun/transpiler/transpiler-redeclared-symbol-chain-hang.test.ts) fails on 1.4.2 (ratios 32x and 25x) and on a debug build of main (9x, and a timeout), and passes here (0.7x to 1.0x, about 3 s per test on a debug build).

CI: 179 of 181 jobs pass and the new test passes on every lane. The two red jobs do not touch this diff:

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Comment thread test/js/bun/transpiler/transpiler-redeclared-symbol-chain-hang.test.ts Outdated
Use empty enum blocks and 16k declarations, take the asserted output
from the timed runs, and sample once when a run is slow (debug builds).
Both tests now finish in about 3 s on a debug build and no longer set a
per-test timeout.
Comment thread src/ast/symbol.rs Outdated
Comment thread src/ast/symbol.rs Outdated
@robobun

robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:31 PM PT - Sep 10th, 2026

❌ @robobun, your commit 010eb4d has 2 failures in Build #114081 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42242

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

bun-42242 --bun

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

LGTM — the per-test timeouts flagged earlier are gone, and I have nothing further.

What was reviewed: follow_symbols is textbook union-find path compression via Cell::replace (returns the old link, so the compression walk advances correctly; no-link input short-circuits both loops). All five replaced call sites preserve semantics — has_link() stays true after compression so the scan_imports guard still holds, and the relocated_top_level_vars empty-ref guard is kept. The ratio-vs-same-work-baseline test design with a spent-time-capped best-of avoids absolute thresholds and matches the perf-test guidance.

Extended reasoning...

Overview

The PR adds follow_symbols in src/ast/symbol.rs — a flat-table analogue of Map::follow that walks a symbol link chain to its root and path-compresses every visited node via Cell::replace. Five hand-rolled link-following loops in src/js_parser/p.rs (record_assignment, relocated top-level vars, enum/namespace closure emission, top-level-symbol-to-parts) and one in src/js_parser/scan/scan_imports.rs are replaced with calls to it. A new test at test/js/bun/transpiler/transpiler-redeclared-symbol-chain-hang.test.ts times 16,384 merged declarations against the same number of distinctly-named ones and asserts a bounded ratio plus the expected output shape.

Security risks

None. This is a pure algorithmic-complexity fix inside the parser's symbol table; no untrusted input handling, no allocation-size arithmetic, no FFI or filesystem surface changes. The interior mutability is via Cell<Ref> on a &[Symbol] slice, which is sound and already the pattern Map::follow uses.

Level of scrutiny

Moderate — parser hot path, but the change is small and mechanical. I verified: (1) the two-loop compression is correct, including the no-link case where root == ref_ and the second loop is skipped; (2) Cell::replace returns the prior value, so the compression walk traverses the original chain rather than re-reading the just-overwritten link; (3) each replaced call site returns the same root as before — in scan_imports the walk now starts from name_ref instead of symbol.link.get(), but has_link() is checked first so the root is identical, and compression leaves has_link() true; (4) the relocated_top_level_vars empty-ref guard is preserved as an if. This matches REVIEW.md's "fix complexity at the root" and "one implementation, in the right place" (it centralizes five copies next to Map::follow).

Other factors

The earlier inline nit (explicit 90 s per-test timeouts) was addressed — both tests now use the default timeout. The test follows harness conventions (tempDir, Buffer.alloc(n, fill) over .repeat), asserts output shape before/alongside the timing ratio, and uses a same-work baseline with a spent-time-capped best-of so debug+ASAN takes one sample while release takes several. The 3×/4× thresholds sit well below the stated 10–50× pre-fix ratio, which is the shape REVIEW.md asks for in perf tests.

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

LGTM — the earlier per-test timeout nit has been addressed, and the rest looks correct.

What was reviewed:

  • follow_symbols: two-phase find-root-then-compress via Cell::replace; preserves has_link() on every intermediate and is a no-op write when ref_ is already the root — same contract as the existing Map::follow.
  • All five replaced call sites resolve to the same root as the old hand-rolled loops; scan_imports.rs still guards on has_link() before following, and only .link is mutated so the borrowed symbol.original_name read afterward is unaffected.
  • Grepped src/js_parser/ for remaining .link.get() / has_link() chain walks — the leftovers in parse_entry.rs are single-hop boolean checks, not walks, so the whole class in the parser is covered.
  • Tests: ratio vs. same-machine distinct-name baseline with best-of sampling, no absolute-time or sleep dependence, tempDir/Buffer.alloc per harness conventions, and output-shape assertions alongside the timing.
Extended reasoning...

Overview

This PR fixes an O(n²) hang in the JS/TS parser when a symbol is redeclared many times. It adds follow_symbols in src/ast/symbol.rs — a flat-table counterpart to the existing Map::follow that walks a symbol's link chain to its root and path-compresses every visited node's Cell<Ref> link to point directly at the root. Five hand-rolled chain-following loops across src/js_parser/p.rs (record_assignment, relocated-top-level-vars, enum/namespace closure generation, top-level-symbol-to-parts) and src/js_parser/scan/scan_imports.rs are replaced with calls to this helper. A new test file benches merged-enum transpile and repeated-var bundle against a same-count distinct-name baseline and asserts the ratio stays under 3–4×.

Security risks

None. This is a purely algorithmic complexity fix inside the parser's symbol-resolution machinery. No user-controlled input reaches new indexing paths that weren't already indexed identically by the old loops; inner_index() values are parser-generated. No FFI, allocation, or trust-boundary changes.

Level of scrutiny

Moderate. The parser is a hot path, but the change is small (17 net new lines of helper, five loop replacements) and mirrors an existing, well-understood pattern already in the same file (Map::follow does the identical two-phase path compression on the linker's nested table). I verified: (1) the compression preserves the has_link() invariant since every intermediate's link is rewritten to root, never to Ref::NONE; (2) when ref_ is already the root the second loop is skipped entirely; (3) Cell::replace returns the prior link so traversal advances correctly; (4) the scan_imports.rs site still holds a shared borrow of symbol across the call, but follow_symbols only takes &[Symbol] and mutates through Cell, so there is no aliasing conflict and original_name is untouched; (5) grepping src/js_parser/ for .link.get() / has_link() shows the only remaining hits are single-hop boolean predicates in parse_entry.rs, not chain walks — the "fix the whole class" requirement is met for the parser.

Other factors

My earlier inline nit (explicit 90 000 ms per-test timeouts violating test/CLAUDE.md) has been addressed — the current test file has no timeout arguments. The tests follow harness conventions (tempDir, Buffer.alloc(n, fill) instead of .repeat, ratio-vs-baseline with best-of sampling capped by wall time so debug builds take one sample) and assert output correctness (single var E;, correct closure count, export shape) alongside the performance ratio, so they aren't purely timing-dependent. The 3×/4× thresholds against a reported 10×–50× pre-fix ratio leave adequate headroom for ASAN/debug jitter. No outstanding third-party CHANGES_REQUESTED reviews are visible in the timeline.

Jarred-Sumner pushed a commit that referenced this pull request Sep 12, 2026
)

### Problem
- Bundling a file that declares one top-level enum n times (`enum E {A}
enum E {B} ...`) costs O(n^2) memory. 8192 blocks (100 KB of source)
reach 8.6 GB RSS in `Bun.build`. With 8192 different names: 70 MB.
- The cause is `compute_ts_enums_map` (`src/js_parser/p.rs:9448`).
`s_enum` pushes one ref per block to `top_level_enums`. Every ref maps
to the one member map that all blocks share, and the function copies
that map once per ref: n maps of n entries. `LinkerGraph::load` then
clones each copy.

### Fix
- `compute_ts_enums_map` skips a ref whose symbol has a link. Only the
newest symbol of a merged enum has no link, so each enum gets one map.
- Correct because both readers of `ts_enums` look up the newest symbol
only. The printer calls `symbols.follow()` first. Linker step 5 uses the
ref of the named export, which is the newest symbol for `export enum E`
and for `export { E }`.
- 8192 blocks now take 1.1 GB. The rest is not specific to enums (see
Notes).
- Verified: `test/bundler/bundler_merged_enum.test.ts` (stock bun: 220
MB, bound 100 MB). Also ran
`test/bundler/esbuild/{ts,dce,importstar_ts,splitting}.test.ts`.

### Background
- `ts_enums` maps an enum symbol to its constant members. The linker and
the printer use it to replace `E.A` in another file with the value.
- Each declaration of a name gets a symbol. For `enum` + `enum`,
`declare_symbol` sets `older.link = newer`, and the scope keeps the
newer symbol. Readers follow the links.
- All blocks of a merged enum share one `TSNamespaceMemberMap`.

<details><summary>Notes</summary>

Release build, linux x64, `Bun.build` of n lines `enum E{M<i>}` plus
`console.log(E)`. Time and RSS after the build:

| n | main | this branch | n lines `var x = x \|\| <i>;` (main and
branch) |
| --- | --- | --- | --- |
| 2048 | 420 ms, 640 MB | 207 ms, 137 MB | |
| 4096 | 1678 ms, 2203 MB | 811 ms, 312 MB | 370 ms, 391 MB |
| 8192 | 9205 ms, 8588 MB | 2332 ms, 1099 MB | 1705 ms, 1461 MB |

- What remains is the part dependency fan-out in the linker. Every block
is a part that declares `E` and uses `E`. Step 5 gives each of the n
parts a dependency on all n parts, and tree shaking visits each of them.
n merged `var x = x || i;` statements cost the same on main and on this
branch (last column). esbuild has the same model. This PR does not
change it.
- #42242 makes the link chain walks in the parser linear for the same
input. It does not touch `compute_ts_enums_map`. The two changes are
independent.
- The new test bundles 256 blocks of 32 members (51 KB). That makes the
map copies large and the part fan-out small. Peak RSS over an empty
process: 220 to 250 MB before and 15 MB after in release, 320 MB before
and 50 MB after in a debug ASAN build. A debug ASAN build without the
fix needs 16 s for this input, so there the test fails on the default
timeout and not on the bound.
- The bundle of the files of
`ts/EnumCrossModuleInliningMergedDeclarations` is byte-identical before
and after, with and without `--minify`.
- A symbol of kind `TsEnum` only gets a link from a newer `enum` of the
same name in the same scope (`Scope::can_merge_symbol_kinds`). A
`namespace` after an `enum` keeps the enum symbol. Symbol hoisting only
links hoisted kinds. So the end of each chain is a top-level enum and is
in `top_level_enums`.
- Self-review: checked every writer of `Symbol::link`, every
`record_export` caller and both readers of `ts_enums`. No input makes a
reader look up a linked symbol. If one did, step 5 would keep the enum
object (larger output) and the printer would still inline the value, so
it fails safe. With the condition inverted (keep only the linked
symbols) `ts/EnumCrossModuleInliningMergedDeclarations` fails its DCE
check, so that test is sensitive to which symbol keeps the map.
</details>

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

---

**[human-review]** gate passed · iteration 0 · 2 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_merged_enum.test.ts
bun test v1.4.3 (4ff9193)

test/bundler/bundler_merged_enum.test.ts:
(pass) bundler > ts/EnumCrossModuleInliningMergedDeclarations [994.67ms]
killed 1 dangling process
(fail) bundler > merged enum blocks do not each get a copy of the shared member table [5006.20ms]
  ^ this test timed out after 5000ms.

 1 pass
 1 fail
 2 expect() calls
Ran 2 tests across 1 file. [9.15s]
error: script "bd" exited with code 1
__F:1:S:0

release without fix: 1 FAILED
bun test v1.4.3-canary.1 (1f89da512)

test/bundler/bundler_merged_enum.test.ts:
(pass) bundler > ts/EnumCrossModuleInliningMergedDeclarations [30.83ms]
113 |       }),
114 |       emptyProcessMaxRSS(),
115 |     ]);
116 |     // Peak RSS over an empty process for this 51 KB input. Before the fix:
117 |     // 220 MB in release, 320 MB in a debug ASAN build. After: 50 MB in debug.
118 |     expect((fixtureMaxRSS - baselineMaxRSS) / 1024 / 1024).toBeLessThan(isASAN || isDebug ? 150 : 100);
                                                                 ^
error: expect(received).toBeLessThan(expected)

Expected: < 100
Received: 222.87890625

      at <anonymous> (/workspace/bun/test/bundler/bundler_merged_enum.test.ts:118:60)
(fail) bundler > merged enum blocks do not each get a copy of the shared member table [276.69ms]

 1 pass
 1 fail
 7 expect() calls
Ran 2 tests across 1 file. [444.00ms]
__F:1:S:0
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_merged_enum.test.ts
bun test v1.4.3 (4ff9193)

test/bundler/bundler_merged_enum.test.ts:
(pass) bundler > ts/EnumCrossModuleInliningMergedDeclarations [911.75ms]
(pass) bundler > merged enum blocks do not each get a copy of the shared member table [1756.98ms]

 2 pass
 0 fail
 7 expect() calls
Ran 2 tests across 1 file. [5.70s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 690ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (8120ms)
Bundle modules (50ms)
Postprocesss modules (166ms)
Bundle Functions (502ms)
Generate Code (44ms)

[8.89s] Bundled "src/js" for production
  2599 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[1/5] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_react_compiler v0.0.0 (/workspace/bun/src/react_compiler)
�[1m�[92m   Compiling�[0m bun_js_parser v0.0.0 (/workspace/bun/src/js_parser)
�[1m�[92m   Compiling�[0m bun_resolver v0.0.0 (/workspace/bun/src/resolver)
�[1m�[92m   Compiling�[0m bun_ini v0.0.0 (/workspace/bun/src/ini)
�[1m�[92m   Compiling�[0m bun_bundler v0.0.0 (/workspace/bun/src/bundler)
�[1m�[92m   Compiling�[0m bun_router v0.0.0 (/workspace/bun/src/router)
�[1m�[92m   Compiling�[0m bun_standalone_graph v0.0.0 (/workspace/bun/src/standalone_graph)
�[1m�[92m   Compiling�[0m bun_transpiler v0.0.0 (/workspace/bun/src/transpiler)
�[1m�[92m   Compiling�[0m bun_bunfig v0.0.0 (/worksp
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/js_parser/p.rs                       |   6 ++
 test/bundler/bundler_merged_enum.test.ts | 120 +++++++++++++++++++++++++++++++
 2 files changed, 126 insertions(+)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                      reads  edits  tests
src/js_parser/p.rs                            8      3     14
test/bundler/bundler_merged_enum.test.ts      1      2     14
```

</details>

<!-- robobun:evidence:end -->

This branch has not been deployed

No deployments
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