Skip to content

bundler: keep deep CSS, export star, composes and chunk graphs from overflowing the linker's stack - #38966

Open
robobun wants to merge 1 commit into
mainfrom
farm/d74fff89/linker-walk-stack-depth
Open

bundler: keep deep CSS, export star, composes and chunk graphs from overflowing the linker's stack#38966
robobun wants to merge 1 commit into
mainfrom
farm/d74fff89/linker-walk-stack-depth

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun.build() on a chain of CSS files where each one @imports the next dies with a native stack overflow once the chain is a few hundred files long (debug+ASAN: AddressSanitizer: stack-overflow in find_imported_files_in_css_order::Visitor::visit, src/bundler/linker_context/findImportedFilesInCSSOrder.rs). Release builds die between 4000 and 8000 files. Bun 1.3.14 built the same project.
  • The bundle thread is spawned with Rust's default 2 MiB stack (src/bundler/BundleThread.rs:151; the Zig version had 16 MiB) and several linker walks still recurse once per graph edge. bundler: convert per-edge-recursive JS-import-graph walks to explicit-stack DFS #34554 converted the JS import graph walks; these were left recursive, and each one reproduces on the current debug build (chain length at which it overflowed the 2 MiB thread in parentheses):
    • CSS @import order, Visitor::visit in findImportedFilesInCSSOrder.rs (between 300 and 400 files)
    • export * resolution, ExportStarContext::add_exports in scanImportsAndExports.rs (between 1050 and 1100 files); DependencyWrapper::has_dynamic_exports_due_to_export_star walks the same graph
    • chunk hashing, LinkerContext::append_isolated_hashes_for_imported_chunks (about 1560 chunks, with splitting and a chain of import()s)
    • CSS modules composes: the property conflict check in scanImportsAndExports.rs and the exports object generation in generateCodeForLazyExport.rs (between 600 and 900 classes)
    • StaticRouteVisitor (bake production builds) recurses per import of the route's JS import graph
  • Found while testing the composes walk: generateCodeForLazyExport.rs marked a class as visited only after walking the classes it composes, so a composes cycle that does not pass through the class being exported (.x { composes: b } .b { composes: c } .c { composes: b }) recursed forever. That one crashes release builds on a three line file.
  • bundler: give the bundle thread a 16 MiB stack #38862 gave the thread a 16 MiB stack and was closed: that only moves the threshold. This PR follows the direction given there.

Fix

  • export * (both walks), chunk hashing, both composes walks and StaticRouteVisitor become explicit-stack DFS, the pattern bundler: convert per-edge-recursive JS-import-graph walks to explicit-stack DFS #34554 used: a frame per node, successors pushed in discovery order and the pushed tail reversed (or a per-frame cursor advanced one edge at a time), so nodes are visited, hashed, appended and diagnosed in exactly the order the recursion produced. Stack usage is now constant per walk; the depth lives in a Vec.
    • mark_dynamic_exports_due_to_export_star: the recursive form returned true through every frame on the way up and each frame marked its file, so on finding a dynamic file the whole stack is marked and drained.
    • add_exports: the frame stack is also the import path the recursive form kept in source_index_stack, used for the cycle check and the shadowing check.
    • append_isolated_hashes_for_imported_chunks: Enter / Asset / Leave frames keep the hash input byte for byte identical (imported chunks, then what the output pieces reference, then the chunk's own hash), so output hashes do not change. chunks is only read, so the parameter becomes &[Chunk] and the two per-call collect()s go away.
    • generate_code_for_lazy_export: a class is now marked on the way in. For every input that terminated before, the appended names are the same in the same order (a class could only be re-entered while still in progress through a cycle avoiding the root, and those inputs never terminated); for those cycles the walk now terminates.
    • StaticRouteVisitor: result carries a finished file's answer to the file below it; true finishes and caches every file on the stack, as the recursive early returns did.
  • The CSS @import order walk stays recursive but calls StackCheck::is_safe_to_recurse() before following each @import (or cross-file composes) edge. When it fails, the walk is abandoned and Maximum call stack size exceeded while following this "@import" chain is logged on the importing file at the import; link() already fails the build when the log has errors after compute_chunks (src/bundler/LinkerContext.rs:799), so Bun.build() reports a BuildMessage and bun build exits 1 with a code frame. This walk threads arena-backed condition lists through the recursion (the bitwise_copy / ManuallyDrop invariants in that file), so the explicit-stack rewrite is not worth its risk for a limit that is thousands of files deep in release builds; the check is the fallback bundler: give the bundle thread a 16 MiB stack #38862's review asked for where a rewrite is impractical.
    • The check works on this thread because BundleThread::thread_main calls configure_named_thread, which initializes the per-thread stack bounds (bun_core::output, StackCheck::configure_thread); the CLI's main thread is configured in bun_bin. The check leaves 128 KiB (256 KiB on Windows) of headroom, which is far more than one visit frame plus what it calls.
    • The dev server uses the same function (finish_from_bake_dev_server); there the error lands in dev.log, which the dev server prints, and the bundle finishes with an empty order for that stylesheet instead of crashing.
  • Verified with test/bundler/bun-build-api.test.ts (eight new tests; each runs Bun.build() in a child process so an overflow shows up as a signal). On the debug build without the src changes, six of them fail with SIGSEGV (the 1000-file @import chain, the 1000-class composes chain, the composes cycle, both 1500-file export * chains, the 2200-chunk chain) and the two order guards (50-file @import chain, composes conflict through a chain) pass; with the changes all eight pass. The @import test accepts either outcome because where the check trips depends on the build: the debug build reports the error at file 287, release builds bundle all 1000.
    • The export * tests run the bundled output (esm tail / cjs tail), the composes chain test checks the generated class list order, the conflict test checks which file is reported as the first definition (postorder), and the cycle test checks the generated class lists.
    • Also green on this build: test/bundler/esbuild/css.test.ts, css/css-modules.test.ts, esbuild/importstar.test.ts, esbuild/importstar_ts.test.ts, esbuild/default.test.ts, esbuild/splitting.test.ts, bundler_splitting.test.ts, bundler_naming.test.ts, bundler_html.test.ts, html-import-manifest.test.ts, bundler_compile_splitting.test.ts, bundler_edgecase.test.ts (includes the bundler: convert per-edge-recursive JS-import-graph walks to explicit-stack DFS #34554 deep chain tests), and the rest of bun-build-api.test.ts.
    • StaticRouteVisitor has no new test: it only runs in bake production builds, and test/bake/dev/production.test.ts already covers both answers (a page importing a "use client" component gets a script tag, a page without one does not). I also built a page whose client component sits three server components deep with this branch and checked the script tag is emitted, and the all-server variant stays static; the bake production tests take about 10s each on a debug build, so I did not add that fixture to the file.
  • Not changed: add_exports still scans the current path once per edge for the cycle check, as it did before, so the deep export * tests take several seconds on a debug build (the new tests have a 120s timeout; all eight take well under a second each on a release build). Also not changed: match_import_with_export recurses once per ambiguous export * alternative and loops forever on an ambiguous cycle; that is a separate bug and has been reported separately.

Background

  • Bundle thread: Bun.build() does not bundle on the JS thread; the work is queued to a single long-lived thread started in src/bundler/BundleThread.rs, and the linker (everything under src/bundler/linker_context/) runs there. std::thread::Builder without stack_size gives it 2 MiB. bun build from the CLI runs the same code on the main thread (8 MiB on Linux), which is why the CLI needs about four times the chain length to fail.
  • StackCheck (src/bun_core/util.rs): captures the current thread's stack end (WTF's StackBounds, set up per thread by configure_thread) and is_safe_to_recurse() compares it with the current stack pointer; it is how the parser, printer and the JSON/TOML/YAML parsers turn deep input into an error instead of a crash. On a thread that never configured it, it always reports safe.
  • Explicit-stack DFS: replacing recursion with a Vec of frames. To keep depth-first postorder (visit everything a node depends on, then the node), a node's Enter pushes its successors followed by its own Leave, then reverses that slice so the first successor pops first and Leave pops last; walks that must interleave side effects with the descent instead keep a cursor in the frame and handle one edge per loop iteration.
  • CSS import order: CSS files are emitted in depth-first postorder of the @import graph (the deepest import first), which is what the order walk computes before the later passes dedupe repeated files. This is the walk that now reports an error when the chain is deeper than the stack allows.
  • export * resolution: for every file, the linker merges the named exports of each export * from target (and transitively theirs) into the file's resolved exports, skipping names shadowed by a real export of any file on the path and recording names supplied by two different targets as ambiguous. Files that export * from something not statically analyzable (CommonJS, or an unresolved external) are marked EsmWithDynamicFallback and re-export at runtime instead; that marking is the first of the two export * walks.
  • Isolated hash: each chunk's content hash must change when any chunk it imports changes, so the final hash of a chunk mixes in the isolated hashes of everything reachable through cross_chunk_imports and through the chunk/asset references in its output pieces, in a fixed order. With splitting, every import() target is its own chunk, so a chain of import()s is a chain of chunks.
  • composes (CSS modules): a class's exported value is its own generated name preceded by the names of every class it composes, transitively, across files. The linker walks that graph twice: once to warn when two files composed together set the same property, and once to generate the exports object. Both are walks over classes, so a long chain of classes is enough to make them deep.

…verflowing the linker's stack

The bundle thread has a 2 MiB stack and several linker walks still
recursed once per graph edge, so a long enough chain of files crashed
Bun.build() with a native stack overflow.

Convert the export star walks, the chunk hash walk, both CSS modules
composes walks and the static route visitor to explicit-stack DFS that
visits in the same order as before. The CSS @import order walk keeps
recursing but checks the remaining stack before following an import and
fails the build with an error instead of overflowing.

Marking a composed class as visited before walking it also stops the
exports object generation from recursing forever on a composes cycle
that does not pass through the class being exported.
@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: 24 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: 3f5b2032-af53-485a-869e-7a2c3dad8e11

📥 Commits

Reviewing files that changed from the base of the PR and between c1ae5ca and 53bccfd.

📒 Files selected for processing (7)
  • src/bundler/LinkerContext.rs
  • src/bundler/linker_context/StaticRouteVisitor.rs
  • src/bundler/linker_context/findImportedFilesInCSSOrder.rs
  • src/bundler/linker_context/generateChunksInParallel.rs
  • src/bundler/linker_context/generateCodeForLazyExport.rs
  • src/bundler/linker_context/scanImportsAndExports.rs
  • test/bundler/bun-build-api.test.ts

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

@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 rewrites six separate linker graph walks (chunk hashing, both export * walks, both composes walks, StaticRouteVisitor) and each rewrite must preserve the exact visitation order for hash stability and diagnostic correctness, a human look would still be worthwhile.

What was reviewed:

  • Traced each explicit-stack conversion against its recursive original for order preservation — the push-then-reverse and per-frame-cursor patterns reproduce the same postorder in every case I stepped through.
  • Checked that add_exports's new stack is the same set of files the old source_index_stack[0..stack_end_pos] covered for both the shadowing and cycle checks.
  • Verified the visit_name mark-on-entry change only affects inputs that previously never terminated (composes cycles avoiding the root); DAG and diamond cases produce the same name order.
  • Confirmed mark_dynamic_exports_due_to_export_star drains and marks exactly the current path on finding a dynamic file, matching the recursive return true propagation.
Extended reasoning...

Overview

This PR converts six recursive graph traversals in the bundler's linker into explicit-stack DFS to prevent native stack overflows on deep import/export/composes/chunk chains, and adds a StackCheck guard to the one walk (find_imported_files_in_css_order) that stays recursive. It touches LinkerContext.rs, five files under linker_context/, and adds eight subprocess-based regression tests to bun-build-api.test.ts. It also fixes an unbounded-recursion bug in the composes-exports walk by marking classes as visited on entry rather than exit.

Security risks

None identified. This is internal bundler control-flow refactoring; no new user-controlled input parsing, no auth/crypto/permissions surface. The new user-facing error message is a fixed-format diagnostic.

Level of scrutiny

High. The linker is production-critical: chunk hashes feed output filenames (a silent order change would invalidate every existing hash-based test and break CDN caching), and the export * shadowing/ambiguity semantics determine whether bundles are correct. Each of the six rewrites is an independent algorithm transformation with its own order-preservation invariant, and the composes fix is a deliberate behavior change. This is exactly the kind of PR where a maintainer who owns the bundler should confirm the approach — particularly the choice to leave the CSS @import walk recursive behind StackCheck rather than converting it.

Other factors

The PR description is unusually thorough and the test coverage is strong (each converted walk has a deep-chain test that crashed the debug build before and passes after; two order-guard tests pin the observable ordering; the composes-cycle test pins the newly-terminating case). I stepped through each conversion by hand and found the transformations sound, and the automated bug-hunting pass found nothing. Deferring solely on size and criticality, not on any specific concern.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on the current debug build with Bun.build() in a child process: a 1000-file CSS @import chain, a 1500-file export * chain, a 2200-chunk import() chain with splitting, a 1000-class composes chain and the 3-class composes cycle all exit with SIGSEGV before this change (the chains overflow the 2 MiB bundle thread in find_imported_files_in_css_order, ExportStarContext::add_exports, append_isolated_hashes_for_imported_chunks and the two composes walks respectively; the cycle recurses forever). With this change the @import chain fails with a BuildMessage on a debug build (and bundles on release builds) and everything else bundles; the new tests in test/bundler/bun-build-api.test.ts cover each case, and the related bundler suites listed in the description pass.

The ambiguous export * cycle that makes match_import_with_export recurse forever is a separate bug and is tracked separately; this PR does not touch that function.

@robobun

robobun commented Aug 15, 2026

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

@robobun, your commit 53bccfd is building: #97759

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