bundler: keep deep CSS, export star, composes and chunk graphs from overflowing the linker's stack - #38966
bundler: keep deep CSS, export star, composes and chunk graphs from overflowing the linker's stack#38966robobun wants to merge 1 commit into
Conversation
…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.
|
Warning Review limit reached
Next review available in: 24 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (7)
Comment |
There was a problem hiding this comment.
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 oldsource_index_stack[0..stack_end_pos]covered for both the shadowing and cycle checks. - Verified the
visit_namemark-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_stardrains and marks exactly the current path on finding a dynamic file, matching the recursivereturn truepropagation.
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.
|
Status: ready for review. Reproduced on the current debug build with The ambiguous |
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-overflowinfind_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.@importorder,Visitor::visitin findImportedFilesInCSSOrder.rs (between 300 and 400 files)export *resolution,ExportStarContext::add_exportsin scanImportsAndExports.rs (between 1050 and 1100 files);DependencyWrapper::has_dynamic_exports_due_to_export_starwalks the same graphLinkerContext::append_isolated_hashes_for_imported_chunks(about 1560 chunks, withsplittingand a chain ofimport()s)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 graphcomposescycle 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.Fix
export *(both walks), chunk hashing, bothcomposeswalks andStaticRouteVisitorbecome 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 aVec.mark_dynamic_exports_due_to_export_star: the recursive form returnedtruethrough 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 insource_index_stack, used for the cycle check and the shadowing check.append_isolated_hashes_for_imported_chunks:Enter/Asset/Leaveframes 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.chunksis only read, so the parameter becomes&[Chunk]and the two per-callcollect()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:resultcarries a finished file's answer to the file below it;truefinishes and caches every file on the stack, as the recursive early returns did.@importorder walk stays recursive but callsStackCheck::is_safe_to_recurse()before following each@import(or cross-filecomposes) edge. When it fails, the walk is abandoned andMaximum call stack size exceeded while following this "@import" chainis logged on the importing file at the import;link()already fails the build when the log has errors aftercompute_chunks(src/bundler/LinkerContext.rs:799), soBun.build()reports aBuildMessageandbun buildexits 1 with a code frame. This walk threads arena-backed condition lists through the recursion (thebitwise_copy/ManuallyDropinvariants 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.BundleThread::thread_maincallsconfigure_named_thread, which initializes the per-thread stack bounds (bun_core::output,StackCheck::configure_thread); the CLI's main thread is configured inbun_bin. The check leaves 128 KiB (256 KiB on Windows) of headroom, which is far more than onevisitframe plus what it calls.finish_from_bake_dev_server); there the error lands indev.log, which the dev server prints, and the bundle finishes with an empty order for that stylesheet instead of crashing.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 withSIGSEGV(the 1000-file@importchain, the 1000-class composes chain, the composes cycle, both 1500-fileexport *chains, the 2200-chunk chain) and the two order guards (50-file@importchain, composes conflict through a chain) pass; with the changes all eight pass. The@importtest 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.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.StaticRouteVisitorhas 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.add_exportsstill scans the current path once per edge for the cycle check, as it did before, so the deepexport *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_exportrecurses once per ambiguousexport *alternative and loops forever on an ambiguous cycle; that is a separate bug and has been reported separately.Background
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::Builderwithoutstack_sizegives it 2 MiB.bun buildfrom 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'sStackBounds, set up per thread byconfigure_thread) andis_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.Vecof frames. To keep depth-first postorder (visit everything a node depends on, then the node), a node'sEnterpushes its successors followed by its ownLeave, then reverses that slice so the first successor pops first andLeavepops last; walks that must interleave side effects with the descent instead keep a cursor in the frame and handle one edge per loop iteration.@importgraph (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 eachexport * fromtarget (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 thatexport *from something not statically analyzable (CommonJS, or an unresolved external) are markedEsmWithDynamicFallbackand re-export at runtime instead; that marking is the first of the twoexport *walks.cross_chunk_importsand through the chunk/asset references in its output pieces, in a fixed order. Withsplitting, everyimport()target is its own chunk, so a chain ofimport()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.