Skip to content

bundler: stop an export star ambiguity that loops back into a re-export chain from overflowing the stack - #38984

Open
robobun wants to merge 1 commit into
mainfrom
farm/cfa7b073/linker-ambiguous-export-star-cycle
Open

bundler: stop an export star ambiguity that loops back into a re-export chain from overflowing the stack#38984
robobun wants to merge 1 commit into
mainfrom
farm/cfa7b073/linker-ambiguous-export-star-cycle

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • bun build segfaults (panic(main thread): Segmentation fault; a debug build reports AddressSanitizer: stack-overflow with LinkerContext::match_import_with_export calling itself) on a barrel whose export * reaches a module that re-exports the same name from the barrel:

    // entry.js
    import { x } from "./barrel.js";
    // barrel.js
    export * from "./a.js"; export * from "./b.js";
    // a.js
    export const x = 1;
    // b.js
    export { x } from "./barrel.js";

    Two barrels whose second export * each lead to a re-export from the other barrel crash the same way.

  • Cause: when a name reaches a file through two export * statements, match_import_with_export (src/bundler/LinkerContext.rs) traces each alternative with a recursive call to itself. The cycle check at the top of its loop only scanned the trackers pushed by the current call (cycle_detector[cycle_detector_top..]), so the recursive call tracing b's re-export did not see that this same import was already being traced by its caller, followed it into barrel again, met the same alternative, and recursed until the stack was gone. esbuild's matchImportWithExport scans its whole cycleDetector; the frame-local slice dates back to the Zig version of this function.

  • The same recursion also grows by one frame per barrel on an acyclic chain of such alternatives, so a long enough chain overflowed the 2 MiB stack of the thread Bun.build() links on (an unfixed debug build died at a little over 400 barrels, the released binary somewhere between 4000 and 5000).

Fix

  • Scan the whole cycle_detector. Every call still truncates it back to its entry length when it returns (esbuild's save/restore of the detector around the recursive call), so the entries a call sees are exactly the chains currently being traced on the stack; alternatives traced one after another do not see each other's entries.
  • Why this is the right result: an alternative whose chain reaches a tracker that is still being traced is a cycle in the re-export graph, and with the old code every such input recursed forever (each recursive call restarted the same chain with an empty window), so the only inputs whose outcome changes are ones that crashed before. The alternative now resolves to Cycle, and the existing comparison of the alternatives reports Ambiguous import "x" has multiple matching exports, which is what esbuild (0.21.5) prints for both graphs above. (A spec-following module loader would ignore the circular alternative and resolve x to a.x; esbuild chose to report the ambiguity instead, and this keeps Bun in line with it.)
  • Before each recursive call, check the remaining stack with StackCheck (as the parser and printer do) and fail the build with Maximum call stack size exceeded while resolving import "x" instead of overflowing. A new MatchImportKind::StackOverflow carries this out of the recursion: each call in between stops tracing and passes it up, and the outermost call reports it at the import being matched, the way Cycle is reported, so the build fails with that one error.
  • Tests, all in test/bundler/bundler_edgecase.test.ts:
    • ExportStarAmbiguityCycleIntoBarrel and ExportStarAmbiguityCycleAcrossBarrels: the two graphs above, expecting the ambiguity error. Before the fix the bun build child is killed (SIGSEGV with the released 1.4.0 canary, ASAN stack-overflow with an unfixed debug build).
    • ExportStarAmbiguityAlternativesShareReExport: two alternatives that pass through the same re-export one after another must not be taken for a cycle. Passes before and after; it pins the truncate-on-return behavior the fix relies on.
    • ExportStarAmbiguityDeepReExportChain: a 600-barrel acyclic chain built with Bun.build() in a child process. Unfixed debug build: the child dies. Fixed debug build: the new error. Release builds, where 600 barrels fit on the stack, bundle it; the test accepts either outcome and requires the child to exit normally. Its fail-before is therefore debug-only; the released binary bundles a chain of this length. It takes about 9 to 11 s here under debug+ASAN, almost all of it parsing the 1200 files, hence the explicit timeout (same as the neighbouring deep-chain tests).
  • Also ran test/bundler/esbuild/importstar.test.ts, the cycle/ambiguity/export-star tests of test/bundler/esbuild/default.test.ts, and the whole of bundler_edgecase.test.ts against the fixed build: all pass.
  • bundler: eliminate quadratic blow-ups on deep re-export chains #31667 rewrites the same cycle check (switching it to a hash set past a threshold) and does not change which entries are scanned, so it neither fixes nor is fixed by this; whichever lands second gets a small conflict in this loop. bundler: keep deep CSS, export star, composes and chunk graphs from overflowing the linker's stack #38966 converts the linker's other recursive walks and leaves this function alone.

Background

  • An import tracker is a (file, import ref) pair identifying one import {x} or export {x} from statement; advance_import_tracker follows it one step to the export it names in the target file, and match_import_with_export loops on that until it reaches a real symbol (or a CommonJS file, a missing export, etc.).
  • resolved_exports[file][name] is computed before imports are matched. When a name reaches a file through several export * statements that point at different symbols, the first is stored as the export and the rest are attached to it as potentially_ambiguous_export_star_refs. While matching an import, every alternative is traced to its final symbol and the import is ambiguous unless they all agree (export * from "./c" next to export { x } from "./c" is the legitimate case this exists for, covered by the existing ReExportStarNameCollisionNotAmbiguousImport test). Tracing an alternative that is itself a re-export is where the recursion comes from.
  • cycle_detector is a Vec of the trackers on the chains currently being followed. Each call notes its length on entry, pushes the trackers it visits, and truncates back to the noted length when it returns.
  • StackCheck compares the stack pointer with the stack bounds recorded when the thread started and reports whether more than 128 KiB (256 KiB on Windows) remain. bun build links on the main thread; Bun.build() links on a dedicated thread with the default 2 MiB stack.
Measurements (chain of N barrels, x resolving through all of them)

Released 1.4.0 canary (unfixed), Bun.build() on the bundle thread: N = 4000 bundles, N = 5000 segfaults. bun build on the main thread bundles N = 20000.

Unfixed debug+ASAN build, Bun.build(): N = 400 bundles, N = 425 dies.

Fixed debug+ASAN build, Bun.build(): N = 375 bundles, N = 400 fails with Maximum call stack size exceeded while resolving import "x" (the guard keeps 128 KiB in reserve, about 25 frames of this function in a debug build).

esbuild 0.21.5 on the first graph in the description:

✘ [ERROR] Ambiguous import "x" has multiple matching exports   b.js:1:9
✘ [ERROR] Ambiguous import "x" has multiple matching exports   entry.js:1:9

Bun stops after the first file with an import error, so it reports the b.js one.

match_import_with_export traces every "export * from" alternative of a
name with a recursive call, but the cycle check only looked at the
trackers pushed by the current call. An alternative whose re-export
chain led back into a chain that an outer call was still tracing was
therefore traced again from scratch, recursing until the stack
overflowed. Scan the whole cycle detector (as esbuild does), so such an
alternative resolves to a cycle and the import is reported as ambiguous.

Tracing alternatives still recurses once per nested alternative, so
check the remaining stack before each recursive call and fail the build
with an error instead of overflowing on a very long acyclic chain.
@coderabbitai

coderabbitai Bot commented Aug 15, 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: 10 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: 4d82be37-08ca-40a4-9c8f-2f3f7f7b858f

📥 Commits

Reviewing files that changed from the base of the PR and between 732491c and 612b800.

📒 Files selected for processing (2)
  • src/bundler/LinkerContext.rs
  • test/bundler/bundler_edgecase.test.ts

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

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: reproduced and fixed.

  • Reproduced with the released 1.4.0 canary (eabb96d): bun build entry.js on the 4-file graph in the description segfaults; an unfixed debug build reports an ASAN stack-overflow in match_import_with_export.
  • With this branch both cycle graphs fail with Ambiguous import "x" has multiple matching exports (esbuild's result for the same input), and a 600-barrel acyclic chain fails with Maximum call stack size exceeded while resolving import "x" on a debug build instead of killing the process.
  • Tests: test/bundler/bundler_edgecase.test.ts (ExportStarAmbiguity*), failing before the fix as described in the PR body, passing after.

@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 reviewed this PR and didn't find any bugs. Because it changes cycle-detection semantics in the bundler linker's import-matching loop, a human look would still be worthwhile to confirm the argument that scanning the whole cycle_detector cannot alter the outcome for any input that previously terminated.

What was reviewed:

  • The one-line cycle_detector[cycle_detector_top..]cycle_detector change: verified the top-level caller clears the vec first (so top-level behavior is unchanged) and each recursive call truncates on return (so sequential alternatives don't see each other's entries — pinned by the new AlternativesShareReExport test).
  • The new MatchImportKind::StackOverflow variant: checked both callers of match_import_with_export (the recursive site and the top-level match at ~3891) handle it, and that the break 'loop_ paths reach the truncate before the early return.
  • Confirmed StackCheck::init().is_safe_to_recurse() is the same pattern the parser/printer use.
Extended reasoning...

Overview

This PR fixes a stack overflow in LinkerContext::match_import_with_export (src/bundler/LinkerContext.rs) when an export * ambiguity chain loops back into a re-export that is already being traced. Two changes:

  1. The cycle check at the top of the loop now scans all of self.cycle_detector instead of self.cycle_detector[cycle_detector_top..], so a recursive call tracing an alternative sees the trackers its callers have already pushed. This matches esbuild's matchImportWithExport.
  2. A bun_core::StackCheck guard before each recursive call converts a would-be overflow on very deep acyclic chains into a build error via a new MatchImportKind::StackOverflow variant, propagated up through the recursion and reported the same way as Cycle.

Four tests are added to test/bundler/bundler_edgecase.test.ts: two crash reproductions that now emit esbuild's ambiguity error, one negative test pinning that sequential alternatives sharing a re-export are not mistaken for a cycle, and a 600-barrel deep-chain test in a child process that accepts either a successful bundle (release) or the new stack-size error (debug+ASAN).

Security risks

None. This is bundler linker control flow with no auth, crypto, filesystem, or network surface. The only user-facing effect is that inputs which previously segfaulted now fail the build with an error message.

Level of scrutiny

Medium-high. The linker is a core code path and the cycle-detection change is a semantic change (which entries are compared), not a mechanical refactor. The PR argues convincingly — and I verified on the two example graphs — that any input where the new full-vec scan fires would have recursed forever under the old windowed scan, so no previously-working build changes outcome. That argument leans on the determinism of advance_import_tracker and on each frame's truncate-on-return, both of which I checked in the surrounding code. It also matches the esbuild reference. Still, this is the kind of subtle graph-traversal invariant a maintainer familiar with the linker should sign off on.

Other factors

  • The exhaustive match result.kind at the sole top-level call site handles the new variant; no other consumers of MatchImportKind exist.
  • Both new break 'loop_ exits reach cycle_detector.truncate(cycle_detector_top) before the early StackOverflow return, so the detector stays balanced across recursion.
  • self.cycle_detector.clear() before each top-level call means cycle_detector_top == 0 there, so the change is a no-op at the outermost frame and only affects recursive calls.
  • Tests follow harness conventions (tempDir, bunEnv, await using, concurrent subprocess draining, signalCode: null assertion) and sit next to the existing DeepImportChain/DeepImportDiamondDAG tests with the same 120s timeout.
  • The PR notes overlap with #31667 and #38966; whichever lands second takes a small conflict here.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Aug 15th, 2026

@robobun, your commit 612b800 is building: #97878

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.

1 participant