Skip to content

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

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

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). This one is gone on main: bundler: one bundle-wide name per cross-chunk binding (no more export {x as y} / import {y as z} between chunks) #40518 replaced that walk with final_chunk_hashes, which does not recurse.
    • 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), 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.
    • has_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. It still returns that result: since bundler: lift module.exports = require() after side effects in unwrapped packages to export * #41188 the caller reads it.
    • 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.
    • Rebased onto main bbdc5a519e. The chunk hashing conversion is no longer in this PR (see Problem). add_exports keeps the default rule main added for lifted CommonJS files (bundler: keep the default export of a lifted module.exports = require() #41298): it reads reexports_default from the frame stack.
    • 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 a debug build of main bbdc5a519e without the src changes, five of them fail (the 1000-file @import chain, the 1000-class composes chain, the composes cycle, both 1500-file export * chains). The 2200-chunk chain passes there since bundler: one bundle-wide name per cross-chunk binding (no more export {x as y} / import {y as z} between chunks) #40518, and so do the two order guards (50-file @import chain, composes conflict through a chain). With the changes all eight pass. bundler_cjs2esm.test.ts and bundler_cjs.test.ts (the lifted CommonJS cases) give the same results as on main. 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.
  • 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.

@coderabbitai

coderabbitai Bot commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 6 billable files and costs up to $1.50.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 13 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Essentials
  • Run ID: d49b0d54-e48a-4a08-90c8-3553196604f2
📥 Commits

Reviewing files that changed from the base of the PR and between bbdc5a5 and 1fc4fc9.

📒 Files selected for processing (6)
  • 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
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

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 6:41 PM PT - Oct 6th, 2026

❌ @robobun, your commit 1fc4fc9 has 2 failures in Build #123669 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38966

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

bun-38966 --bun

@robobun

robobun commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

#40169 touches the same two walks (find_imported_files_in_css_order::visit and ExportStarContext::add_exports) with a smaller change: a StackCheck that fails the build with Maximum call stack size exceeded, an O(1) on-path check for the export star cycle test, and the pool stack size for the Bundler thread. The iterative rewrite here goes further; both cannot merge as is, so one of them needs a rebase on the other.

@robobun

robobun commented Aug 23, 2026

Copy link
Copy Markdown
Collaborator Author

Update: #40169 no longer touches the linker walks. It keeps the Bundler thread at DEFAULT_THREAD_STACK_SIZE and leaves the explicit-stack rewrite to this PR.

One thing worth folding in here. The cycle check in the new add_exports is still a scan of the path:

if !self
    .stack
    .iter()
    .any(|frame| frame.source_index == other_source_index)

Every file in an export * chain starts its own walk, and every level scans the path, so a chain of N files costs O(N^3). On stock 1.4.0 a 6000-file chain spends about 12 s there (bun build on the CLI). A Vec<bool> indexed by source index, set when a frame is pushed and cleared when it is popped, makes the check O(1) and took that run to about 2 s:

// in ExportStarContext
on_stack: Vec<bool>,   // vec![false; named_exports.len()] at construction

// on push
self.on_stack[idx as usize] = true;
// on pop (both pop sites)
self.on_stack[frame.source_index as usize] = false;
// cycle check
if !self.on_stack[other_source_index as usize] { push }

The shadowing loop over self.stack can stay as is: it only runs for files that have real named exports.

…ing 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, 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.

The chunk hash walk is not part of this change any more: #40518 replaced
it with final_chunk_hashes, which does not recurse. The test for a deep
chunk chain stays.
@robobun
robobun force-pushed the farm/d74fff89/linker-walk-stack-depth branch from 53bccfd to 1fc4fc9 Compare October 7, 2026 01:05
Comment on lines +20 to +21
/// A file whose imports are being checked, and how many of its import
/// records have been looked at so far.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +69 to +75
///
/// Explicit-stack DFS (was recursive, one call per import). `result` is
/// the answer the most recently finished file (or cache hit) gave to the
/// file below it on the stack: `true` finishes that file as well, so it
/// propagates down through every file on the stack, caching `true` for
/// each, the way the recursive form's early returns did; `false` lets the
/// file go on to its next import.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +129 to +131
/// Starts checking `source_index`'s imports unless the answer is already
/// known: cached by an earlier walk, or `false` for a file that is already
/// being checked further down the stack (a cycle).

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +75 to +78
///
/// The traversal recurses once per `@import`, so an import chain deeper than
/// the thread's stack allows is reported as an error on the bundler log and
/// yields an empty order.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +105 to +107
/// The `@import` that could not be followed because `visit` recurses
/// once per `@import` and the thread's stack was nearly exhausted.
/// Once set, the walk unwinds without visiting anything else.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +124 to +126
/// Visits the file `record` (an `@import` or `composes` in `importer`)
/// resolved to. Returns false if the walk has been abandoned, in which
/// case the caller returns as well.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +202 to +203
// return above intentionally skips it, and the `too_deep` returns
// below don't need it because the whole walk is discarded.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +394 to +395
// Split-borrow (see `LinkerContext::log_disjoint`). `link()` fails the
// build once `compute_chunks` returns.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +99 to +100
/// A class whose `composes` declarations are being walked, and
/// how far along them the walk is.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +107 to +108
/// Append the class's own name once its `composes` are done.
/// False for the root class, whose name the caller appends.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +145 to +152
/// Starts walking a composed class's own `composes` unless it has
/// been reached already. Its name is appended when that walk
/// completes, after the names it composes.
///
/// The class is marked as reached on the way in. The recursive
/// form marked it on the way out, which appended the same names
/// in the same order whenever it terminated, but recursed forever
/// on a `composes` cycle that did not pass through the root class.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +219 to +227
/// Appends the name of every class that `css_ref` (a class in
/// the file `idx`, already marked as reached) transitively
/// composes, each after the names it composes itself.
///
/// Explicit-stack DFS (was recursive, one `visit_composes` and
/// `visit_name` call per composed class). Each iteration handles
/// one name of one `composes` declaration of the class on top of
/// the stack, so the names, the `from global` strings and the
/// diagnostics come out in the order the recursion produced them.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +306 to +309
// E.g.: `composes: foo from global`
//
// In this example `foo` is global and won't be rewritten to a locally scoped
// name, so we can just add it as a string.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1460 to +1462
/// One file on the path of an explicit-stack walk over the `export *` graph:
/// the file and how many of its `export_star_import_records` have been
/// followed so far.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1468 to +1469
/// What the recursive form of `has_dynamic_exports_due_to_export_star`
/// returned immediately upon entering a file, if anything.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1480 to +1491
/// Marks `source_index` as `EsmWithDynamicFallback` if it transitively
/// `export *`s from a file whose exports are not statically analyzable
/// (CommonJS, or an unresolved external `export *`), along with every file
/// on the `export *` path to that file. Returns whether `source_index` has
/// dynamic exports.
///
/// Explicit-stack DFS (was per-edge recursive, one call per `export *`).
/// In the recursive form, finding such a file returned `true` through
/// every frame on the way back up, and each of those frames marked its
/// file: the frames on the stack at that moment are exactly the files to
/// mark. A file whose export stars are exhausted without finding one
/// returned `false`, which is popping it.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1615 to +1622
/// Merge the re-exports reachable through `source_index`'s `export *`
/// statements into `resolved_exports[target_id]`.
///
/// Explicit-stack DFS (was per-edge recursive, one call per `export *`).
/// `stack` is the chain of files being followed from `source_index`, which
/// the recursive form kept as a separate list: a file already on it is a
/// cycle and is not entered again, and a real export in any file on it
/// shadows the re-exports found below.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1744 to +1745
// Search further through this file's export stars, unless doing so
// would loop: avoid infinite loops due to cycles in the export star graph

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1869 to +1870
/// A class (`Ref` local to the CSS file `IndexInt`) on the explicit
/// stack of `Visitor::visit`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1975 to +1977
/// Every frame names a class of a file that has a CSS AST: the root
/// comes from `validate_css_import_composes`, and `Enter` only
/// pushes classes it looked up in the composed file's AST.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1984 to +1993
/// Records the properties of `root` and of everything it
/// (transitively) composes, warning when two files contribute the
/// same property.
///
/// Explicit-stack DFS (was recursive, one call per composed class).
/// `Enter` pushes the composed classes in source order followed by
/// its own `Leave`, then reverses that tail so they pop in source
/// order, each one's subtree completing before the next pops, and
/// `Leave` last: the recursion's postorder, which is what decides
/// which style rule a conflicting property is first seen in.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +2027 to +2028
// Read-only deref: may be the same
// allocation as `ast`, so bind shared.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +2047 to +2049
// Otherwise it is composed from the global scope.
//
// See comment above for why we are skipping checking this for now.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun robobun changed the title bundler: keep deep CSS, export star, composes and chunk graphs from overflowing the linker's stack bundler: keep deep CSS, export star and composes graphs from overflowing the linker's stack Oct 7, 2026

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

Beyond the inline findings, I traced the four explicit-stack rewrites against the recursive forms they replace and did not find a behavior change: has_dynamic_exports_due_to_export_star drains exactly the frames the old true return path marked, add_exports computes reexports_default and the shadowing check over the same path the old source_index_stack held, the __css_validation Enter/Leave reversal reproduces the old postorder, and the composes emitter in generateCodeForLazyExport.rs only diverges on inputs that previously never terminated (the visited-on-entry change). StaticRouteVisitor's result propagation also matches the old early returns, including caching true for every file on the stack.

Extended reasoning...

The change converts five recursive graph walks in src/bundler/linker_context/ to explicit-stack DFS and adds a StackCheck guard plus a new error message to the CSS @ import order walk, with eight new subprocess tests in test/bundler/bun-build-api.test.ts; it touches no auth, crypto or injection surface. The bug hunt was cut off at its findings cap rather than running dry, and several posted findings concern test coverage and the dev-server path, so approval is not appropriate; this note only records which equivalence checks on the core walks were done and came up clean.

Findings marked 🟡 are optional suggestions and need no follow-up push.

/// propagates down through every file on the stack, caching `true` for
/// each, the way the recursive form's early returns did; `false` lets the
/// file go on to its next import.
fn has_transitive_use_client_impl(

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.

🔴 Maintainers get a rewritten has_transitive_use_client_impl with no test covering a deep or multi-level route import graph, so a regression in the new stack/result propagation would go unnoticed. The function at StaticRouteVisitor.rs:76 replaces recursion with a result-driven stack loop, but the only coverage is the one-level cases in test/bake/dev/production.test.ts and the author's manual check. Fix: add a bake production fixture where the "use client" component sits several server imports deep (and an all-server sibling that stays static) so the true propagation through multiple frames and the false exhaustion path are both asserted.

Why this was flagged

StaticRouteVisitor.rs:76-130 replaces the per-import recursion with an explicit stack and a result flag that pops and caches frames; StaticRouteVisitor.rs:132 adds enter(). The PR text states no new test was added for this walk and that the deep case was checked by hand. The repository review rules say every behavioral change ships an automated test and that manual verification does not count. The existing coverage the PR cites (a page importing a "use client" component directly, and a page without one) never pushes more than one frame, so the loop body at StaticRouteVisitor.rs:88-93 (popping with result == true and caching true for every frame on the stack) and the enter() cycle/cache paths for nested files are unexercised. On the base branch the recursive form was equally untested at depth, but this PR is the one changing it; a wrong propagation here would mark a route static and drop its client script tag in bake production builds without any test failing.

Verification: The new multi-frame result propagation at src/bundler/linker_context/StaticRouteVisitor.rs:89-130 only runs when a bake production route reaches a "use client" file through intermediate server modules. The only test change is test/bundler/bun-build-api.test.ts, which never reaches it; test/bake/dev/production.test.ts all import the "use client" file directly, so that path is unexercised.

Comment on lines +387 to +408
if let Some(TooDeep {
importer,
range,
kind,
}) = visitor.too_deep
{
let importer = &this.parse_graph().input_files.items_source()[importer.get() as usize];
// Split-borrow (see `LinkerContext::log_disjoint`). `link()` fails the
// build once `compute_chunks` returns.
this.log_disjoint().add_range_error_fmt(
Some(importer),
range,
format_args!(
"Maximum call stack size exceeded while following this \"{}\" chain",
if kind == ImportKind::Composes {
"composes"
} else {
"@import"
}
),
);
return Vec::new();

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.

🟡 (optional) Dev server users whose stylesheet trips the new depth check get a silently empty stylesheet served as a successful build, with no error overlay. The too-deep error at findImportedFilesInCSSOrder.rs:396 goes to the transpiler log and the function returns an empty order; the dev-server caller at bundle_v2.rs:6117 builds a CSS chunk from it and keeps the entry in css_entry_points, so the file is recorded as built. Fix: surface the abandoned walk to the caller (e.g. return a Result or a flag) so the dev-server path fails the file the way scan_css_imports errors do at bundle_v2.rs:5921-5934, via handle_parse_task_failure plus swap_remove from css_entry_points, while Bun.build()/bun build keep failing through the linker log.

Why this was flagged

A dev-server project (bake / bun ./index.html dev) has a CSS @ import chain deep enough that StackCheck::is_safe_to_recurse() returns false in visit_import. find_imported_files_in_css_order then logs Maximum call stack size exceeded while following this "@ import" chain on this.log_disjoint() (findImportedFilesInCSSOrder.rs:396) and returns Vec::new(). The dev-server caller finish_from_bake_dev_server at bundle_v2.rs:6117-6133 pushes a Content::Css chunk with an empty imports_in_chunk_in_order and the entry stays in start.css_entry_points, so the incremental graph records the file as successfully bundled. The only trace is DevServer.rs:3289-3295, which prints errors in dev.log to stderr once and attaches nothing to the file, so the browser shows no error overlay. The base branch crashes the process here instead, but the sibling path for CSS import errors at bundle_v2.rs:5906-5934 routes through handle_parse_task_failure and removes the entry from css_entry_points, and this new error does not use it.

Verification: src/bundler/linker_context/findImportedFilesInCSSOrder.rs:388-408 log via this.log_disjoint().add_range_error_fmt(...) and return Vec::new(). The dev-server caller src/bundler/bundle_v2.rs:6117-6134 takes the empty order, builds a Content::Css chunk from it, and leaves the entry in start.css_entry_points. No insert_failure is made, so no error overlay.

Comment on lines +1140 to +1165
"a chain of 1000 CSS @imports either bundles or fails with a build error",
async () => {
// `find_imported_files_in_css_order` still recurses per @import, but it
// checks the remaining stack first. How deep it gets before giving up
// depends on the build (a debug build crashed between 300 and 400 files,
// a release build gets past 1000), so both outcomes are valid here; what
// is not is the child dying.
const length = 1000;
using dir = tempDir("build-api-css-import-chain-deep", cssImportChain(length));
const result = await buildDeepGraphInChild(String(dir), { entrypoints: ["c0.css"] });
if (result.success) {
expect(result.logs).toEqual([]);
expect(result.outputs).toBe(1);
expect(cssRuleOrder(result.entryText!)).toEqual(Array.from({ length }, (_, i) => length - 1 - i));
} else {
expect(result.outputs).toBe(0);
expect(result.logs).toEqual([
{
message: 'Maximum call stack size exceeded while following this "@import" chain',
file: expect.stringMatching(/^c\d+\.css$/),
lineText: expect.stringContaining("@import"),
notes: [],
},
]);
}
},

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.

🟡 (optional) Maintainers get a regression test for the new @ import stack check that cannot fail on a release build, before or after this change. The test at test/bundler/bun-build-api.test.ts:1140 accepts either outcome, and with 1000 files a release build (which the PR says only overflowed at 4000-8000 files) bundles everything on both the base and the fixed binary, so the TooDeep error path, its message and the empty-order return are never exercised there. Fix: make the error branch deterministic, for example a chain long enough to trip the check on release too or an internal knob that lowers the threshold, and assert that branch unconditionally; the same debug-only sizing applies to the 1000-class composes chain and both 1500-file export star chains. [also at: test/bundler/bun-build-api.test.ts:1150 - nit: the one test for the new Maximum call stack size exceeded while following this "@ import" chain error only asserts that message when the build happens to fail, so on release lanes the error path is never checked.]

Why this was flagged

The test "a chain of 1000 CSS @ imports either bundles or fails with a build error" at test/bundler/bun-build-api.test.ts:1140-1166 branches on result.success. The PR text states a release build of the unfixed code dies only between 4000 and 8000 files, and the fixed release build bundles all 1000, so on release both the base and the fixed binary take the success branch and the test passes either way; USE_SYSTEM_BUN=1 (CLAUDE.md line 108) therefore passes against the base. The new code at src/bundler/linker_context/findImportedFilesInCSSOrder.rs:133-140 (too_deep set) and :388-408 (add_range_error_fmt, return Vec::new()) only runs when the debug lane trips at file 287, so the release lane never checks the message, the reported file, or that the empty order does not break compute_chunks. The same sizing-to-debug applies to the composes chain (1000 classes, line 1168) and the export star chains (1500 files, lines 1266 and 1281).

Verification: On a release build, a 1000-file @ import chain finishes without tripping StackCheck. test/bundler/bun-build-api.test.ts:1150-1164 branches on result.success, so whichever branch the binary takes, the test passes; on a release build both the base and the fixed binary take the success branch and never exercise the new TooDeep path in src/bundler/linker_context/findImportedFilesInCSSOrder.rs.

Comment on lines +1140 to +1166
"a chain of 1000 CSS @imports either bundles or fails with a build error",
async () => {
// `find_imported_files_in_css_order` still recurses per @import, but it
// checks the remaining stack first. How deep it gets before giving up
// depends on the build (a debug build crashed between 300 and 400 files,
// a release build gets past 1000), so both outcomes are valid here; what
// is not is the child dying.
const length = 1000;
using dir = tempDir("build-api-css-import-chain-deep", cssImportChain(length));
const result = await buildDeepGraphInChild(String(dir), { entrypoints: ["c0.css"] });
if (result.success) {
expect(result.logs).toEqual([]);
expect(result.outputs).toBe(1);
expect(cssRuleOrder(result.entryText!)).toEqual(Array.from({ length }, (_, i) => length - 1 - i));
} else {
expect(result.outputs).toBe(0);
expect(result.logs).toEqual([
{
message: 'Maximum call stack size exceeded while following this "@import" chain',
file: expect.stringMatching(/^c\d+\.css$/),
lineText: expect.stringContaining("@import"),
notes: [],
},
]);
}
},
deepGraphTimeout,

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.

🟡 nit (optional): maintainers get regression coverage for only one of the three edges the new stack guard protects, so the other two can regress unnoticed. findImportedFilesInCSSOrder.rs:134 guards plain @ import, conditional @ import (line 235, the nested_conditions fork) and cross-file composes (line 319), and the "composes" wording at line 401 is never produced by any test. Fix: add sibling fixtures alongside the 1000-file @ import chain, one chain of @ import "./next.css" screen; and one chain of .c { composes: c from "./next.module.css" }, each asserting the exact message for its kind, so every guarded edge and both message variants are exercised.

Why this was flagged

The guard in findImportedFilesInCSSOrder.rs:134-141 is reached from three call sites: the conditional @ import branch at findImportedFilesInCSSOrder.rs:235 (which also runs the nested_conditions ManuallyDrop path and the drop(nested_import_records) at line 244 before the early return at line 246), the plain @ import branch at line 251, and the composes branch at line 319. The message at line 400-406 picks "composes" or "@ import" from kind. The only new fixture that can reach the guard is cssImportChain at test/bundler/bun-build-api.test.ts:1109, which emits unconditional @ import "./cN.css"; lines, so only the line 251 call site and the "@ import" wording are ever executed by the suite. A regression in the composes branch (for example dropping the !self.visit_import(...) return at line 319-327, which would resume the walk after too_deep is set) or in the conditional branch would still pass every test here. REVIEW.md asks that every sibling entry point receiving the same fix be covered; the base branch has no coverage either, but the base also has no guard to regress.

Verification: nit. Triggering condition: whenever the conditional-@ import fork or the cross-file composes edge of the new stack guard regresses, no test in this PR can catch it. The only new fixture that can reach the guard is cssImportChain (test/bundler/bun-build-api.test.ts:1116-1122), which emits bare @ import "./c${i+1}.css"; — plain branch only.

Comment on lines +1308 to +1313
test.concurrent(
"bundles a chain of 2200 chunks that import each other",
async () => {
// With splitting, every import() target becomes its own chunk, and each
// chunk's hash covers the chunks it imports. That walk
// (`append_isolated_hashes_for_imported_chunks`) used to recurse per

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.

🟡 (optional) Maintainers get a 2200-file splitting build in the suite that passes on the base branch and names a function that does not exist. The comment at test/bundler/bun-build-api.test.ts:1313 cites append_isolated_hashes_for_imported_chunks, but no such symbol is in the tree and this diff does not touch chunk hashing (LinkerContext.rs:2660 already computes hashes with an iterative closure). The PR text's claim that chunk hashing became an explicit-stack DFS is not in the diff. Fix: either drop this test and the chunk-hashing claim, or include the chunk-hashing change it is meant to guard and name the real function; a test that cannot fail on base only adds a slow debug/ASAN build. [also at: test/bundler/bun-build-api.test.ts:1334 - Maintainers get a 2200-file splitting build test that guards nothing this PR changes and may pass on the base branch, costing 5-20s per debug run.; test/bundler/bun-build-api.test.ts:1335 - nit: maintainers get a 2200-file splitting build in every test run that guards nothing this PR changes. The comment at test/bundler/bun-build-api.test.ts:1313 names append_isolated_hashes_for_imported_chunks, which does not exist on the base or after this diff; chunk hashing is already a bitset fixpoint in final_chunk_hashes (src/bundler/LinkerContext.rs:2654).]

Why this was flagged

The test "bundles a chain of 2200 chunks that import each other" at test/bundler/bun-build-api.test.ts:1308 writes 2200 modules with import() chains and builds with splitting: true. Its comment at line 1313 says the walk append_isolated_hashes_for_imported_chunks used to recurse per imported chunk; a repo-wide grep finds no such symbol, and the only cross-chunk hash code is the non-recursive reachability closure in src/bundler/LinkerContext.rs:2660-2750, which this diff leaves untouched. So the base branch passes this test unchanged, meaning it certifies nothing in this PR while adding a 2200-file splitting build with a 120_000 ms timeout to every debug/ASAN run. The PR description's bullets about append_isolated_hashes_for_imported_chunks, Enter/Asset/Leave frames and &[Chunk] describe code absent from the diff, so the description disagrees with the code.

Verification: Every suite run executes this 2200-file splitting build. append_isolated_hashes_for_imported_chunks appears only in the test comment at test/bundler/bun-build-api.test.ts:1313; no such function exists. Chunk hashing in LinkerContext::final_chunk_hashes (src/bundler/LinkerContext.rs:2654) is already a non-recursive bitset fixpoint; the base commit bbdc5a5 contains the identical function.

// chains build in well under a second on a release build but take 5-20s on
// a debug build with ASAN (more when the tests run concurrently), hence the
// timeout.
const deepGraphTimeout = 120_000;

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.

🟡 nit (optional): Eight new tests each carry a 120 s per-test timeout (deepGraphTimeout, test/bundler/bun-build-api.test.ts:1055), which test/CLAUDE.md forbids ("Do not set a timeout on tests") and REVIEW.md says not to raise to make slow tests pass. The file already has three such outliers, so this is precedent, not a blocker. Fix: keep the chains at the minimum length that still overflows the 2 MiB stack on the unfixed build and drop the explicit timeout where the default suffices, or state in the test why each outlier needs it.

Why this was flagged

Each test.concurrent added between test/bundler/bun-build-api.test.ts:1113 and :1334 passes deepGraphTimeout (120_000) as its third argument. test/CLAUDE.md states tests must not set timeouts and REVIEW.md says to shrink the workload rather than raise the timeout. On a debug+ASAN runner the author reports 5-20s per test, so the suite's wall time for this file grows by minutes; on the base branch the file has only three timeout-bearing tests (lines 2383, 2445, 2514). No functional defect; this is a convention slip the repository's own rules call out.

Verification: nit. Triggering condition: whenever this test file runs. test/bundler/bun-build-api.test.ts:1055 declares const deepGraphTimeout = 120_000; and it is passed as the third argument to all eight new test.concurrent calls. test/CLAUDE.md:118-120 reads "Do not set a timeout on tests. Bun already has timeouts." The base branch has five timeout-bearing tests, not three. No functional defect.

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