Conversation
Compiling CSS nesting away multiplies selectors by the product of the
selector list lengths along the nesting chain, which is exponential in
the nesting depth. ~1KB of CSS (26 nested two-selector ::part() lists)
made bun build allocate over 4GB: the minifier clones the entire nested
subtree once per downleveled selector, and the printer substitutes & with
:is(<parent list>) recursively.
Cap the expansion at 65536 combinations per rule and report a proper
minify error instead of exhausting memory. Wire minify errors through
StyleSheet::minify (previously panic!("TODO: Handle")) and drop a
double-deref in the CSS error -> JS error bridge that freed the error
message string while still referenced.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (5)
WalkthroughThis PR adds detection and rejection of CSS nesting compilations that would exceed a selector expansion limit (65536 combinations), preventing memory exhaustion during minification of deeply nested styles with incompatible selector syntax. ChangesCSS Nesting Selector Expansion Limit
Unrelated: The small change in 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Comment |
|
Updated 2:13 AM PT - May 23rd, 2026
❌ @autofix-ci[bot], your commit 7fc411d has 3 failures in
🧪 To try this PR locally: bunx bun-pr 31278That installs a local version of the PR into your bun-31278 --bun |
|
This PR may be a duplicate of:
🤖 Generated with Claude Code |
|
Closing as a duplicate of #31277, which was opened a few minutes earlier with the same fix for the same fuzzing campaign (same 65536 expansion cap, same minify-error plumbing replacing the |
| if options | ||
| .targets | ||
| .should_compile_same(compat::Feature::Nesting) | ||
| && let Some(loc) = css_rules::exceeds_nesting_expansion_limit(&self.rules, false, 1) | ||
| { | ||
| return Err(self.minify_error_with_location(MinifyError { | ||
| kind: MinifyErrorKind::nesting_expansion_limit_exceeded, | ||
| loc, | ||
| })); | ||
| } |
There was a problem hiding this comment.
🟣 Heads-up (pre-existing, not introduced here): the path-2 check is gated on MinifyOptions.targets, but the print-time &-substitution it guards uses PrinterOptions.targets, and transpiler.rs::build_css_output (the bun build --no-bundle file.css path) calls minify(&MinifyOptions::default()) — no targets, so the check is skipped — and then to_css with Targets::for_bundler_target(...), which does compile nesting away. The exponential expansion is therefore still unguarded on that path. The primary bun build / ParseTask.rs path is correctly protected; consider either having transpiler.rs pass for_bundler_target() to minify() the way ParseTask.rs:1260 does, or running the cheap O(rules) exceeds_nesting_expansion_limit check unconditionally.
Extended reasoning...
The new path-2 guard at css_parser.rs:2682-2691 runs only when options.targets.should_compile_same(compat::Feature::Nesting) is true, where options is the MinifyOptions passed to StyleSheet::minify. Its purpose is to bound the print-time &-substitution explosion in serialize_nesting, but the printer decides whether to compile nesting based on PrinterOptions.targets, which callers supply independently. So the guard is sound only under the implicit contract "minify targets == print targets", and at least one in-tree caller violates that contract.
Concrete code path. src/bundler/transpiler.rs:3240 calls sheet.minify(alloc, &MinifyOptions::default(), &extra). With MinifyOptions::default(), targets.browsers = None and include/exclude are empty. In targets.rs:152-156, is_compatible(Nesting) returns true when browsers is None, so should_compile_same(Nesting) evaluates to false || (true && !true) = false and the path-2 check is skipped. The path-1 check inside minify_style_arm does not fire either, because should_compile_selectors() (targets.rs:148-150) is also false when browsers is None, so incompatible stays empty. Immediately after, transpiler.rs:3249-3252 calls sheet.to_css(...) with PrinterOptions { targets: Targets::for_bundler_target(self.options.target), ... }. For target = Browser that returns browser_default() (targets.rs:109) — chrome 87 / safari 14 / etc. — and the printer does compile nesting away, triggering the exponential &-substitution that this PR set out to bound.
Reachability. build_css_output is reached via build_command.rs:553 when ctx.bundler_options.transform_only is set, i.e. bun build --no-bundle file.css (and the legacy/v1 transpiler entry points that go through build_with_resolve_result_eager). The PR's bun build test uses --outdir without --no-bundle, so it exercises the BundleV2 / ParseTask.rs path, which correctly passes for_bundler_target() to minify() at ParseTask.rs:1257-1263 and is protected.
Step-by-step proof. Take the fuzzer's 26-level ::part(header), ::part(body) input and run bun build --no-bundle part-nesting.css:
transpiler.rs:3240callsminifywith default targets →should_compile_same(Nesting) = false→ path-2 check atcss_parser.rs:2682not entered.- Inside
minify_style_arm,context.targets.should_compile_selectors()isfalse(no browsers) →incompatibleis empty → path-1 check atrules/mod.rs:835not entered. Minify completes with the nested tree intact. transpiler.rs:3252callsto_csswithfor_bundler_target(Browser)→ printer's targets lack native nesting →serialize_nestingsubstitutes&with the parent selector list at every level → output doubles per level → 2^26 combinations → multi-GB output / OOM, exactly the behavior this PR fixes for the defaultbun buildpath.
Why this is pre-existing, not a regression. The OOM through transpiler.rs::build_css_output exists on main today; this PR does not touch transpiler.rs and does not change its behavior. The new guard is correctly gated on the only targets minify() has access to, and the primary fuzzer-hit path (ParseTask.rs) is now protected. This is an incomplete-fix observation about a secondary entry point, not a defect introduced by the PR.
Suggested follow-up. Either (a) change transpiler.rs:3240 to pass MinifyOptions { targets: Targets::for_bundler_target(self.options.target), .. } so it matches what it later passes to to_css (mirroring ParseTask.rs:1260), or (b) drop the should_compile_same(Nesting) gate and run exceeds_nesting_expansion_limit unconditionally — it is O(rules), short-circuits on rules.v.is_empty(), and is effectively free for stylesheets without deep multi-selector nesting, so the guard no longer depends on callers keeping minify and print targets in sync.
What does this PR do?
Fixes unbounded memory growth (4 GB+ RSS from ~1 KB of CSS) when compiling CSS nesting away for older browser targets, found by CSS fuzzing (signature
oom:css:mi_page_free_list_extend|mi_page_extend_free…).The fuzzer's minimized input is 26 nested levels of
The published repro calls
minifyTest(input, "")with no targets and does not blow up (nesting is preserved, ~1 KB output). The same input exhausts memory as soon as CSS nesting is compiled away, which is what the real entry points do:or
minifyTest(input, "", { chrome: 87 << 16 })/{ chrome: 100 << 16 }. Upstream lightningcss 1.32.0 produces the same exponential output for the same input and targets, so there is no upstream fix to port.Cause
Compiling nesting away multiplies selectors: every selector of a nested rule ends up combined with every selector of every ancestor rule, so the expansion is the product of the selector list lengths along the nesting chain — 2^26 combinations for this input. That product is realized through two independent paths:
minify_style_arm): when the targets lack:is()support (the default browser targets include chrome 87), every nested rule's&-selectors are "incompatible", so the rule is split into one rule per selector and its entire nested subtree is deep-cloned per selector. Nested rules are minified bottom-up, so the subtree doubles at every level — 2^26 rule clones in the arena before anything is printed. This is what the fuzzer's OOM actually is.&substitution (serialize_nesting): with targets that have:is()but not nesting (chrome 88–119), rules are not split, but the printer substitutes&with:is(<parent list>), and the parent's serialization contains its own already-expanded&— the printed text doubles per level.Fix
Add
MAX_NESTING_EXPANSION = 65536(combinations per style rule) and report a CSS minify error instead of exhausting memory:minify_style_armchecksincompatible selectors × nested subtree sizebefore cloning the subtree (bounds path 1 regardless of which selector feature made the list incompatible).StyleSheet::minifywalks the remaining rule tree and rejects any nested rule whose chain product exceeds the limit when nesting will be compiled at print time (bounds path 2 before the printer does the work).Real-world stylesheets stay orders of magnitude below the limit (the entire expansion of one source rule would already be several MB of output at the threshold); only runaway expansions hit it. Behavior is unchanged for stylesheets with no targets, targets that support nesting natively, single-selector nesting of any depth, and every existing CSS test.
Supporting changes required to surface the error:
StyleSheet::minifypreviously hitpanic!("TODO: Handle")for any minify error; it now returns the error with the offending rule's filename/line/column, sobun buildprints a proper diagnostic:css_jsc/error_jsc.rs::to_error_instancederef'd the message string once too often (bun_string_jsc::to_error_instancealready consumes the reference), freeing the JS error's message while still referenced. This was latent because no CSS minify error could previously reach JS; with the new error path it corrupted memory (libpas "Alloc bit not set" /spansOverlapassertions on debug builds).Related: #31276 caps the printer-side
&-substitution count for a different fuzz finding (&:is(.bar, &.baz)nesting — single-selector lists with multiple&per selector, which the list-length product here intentionally does not flag), and #31270 fixes duplicate re-serialization across vendor-prefix passes. The mechanisms are independent: neither of those bounds the minify-time subtree cloning that OOMs this input underbun build's default targets, and this change does not bound theirs — the fixes are complementary and touch adjacent code.Verification
New
test/js/bun/css/nesting-expansion-limit.test.ts:::part()input errors with the new message for chrome 87 (split path) and chrome 100 (:is()path) instead of expanding,bun buildon the 26-level fuzzer input with default targets exits with the error in ~2 s (spawned with a kill switch so a regression fails instead of OOMing the runner),Without the fix the first three tests fail (no error is raised; the build is killed by the watchdog); with it all seven pass.
Existing suites on the debug build:
test/js/bun/css/css / color / css-modules / small-list-grow / nested-function-backtracking / doesnt_crash (2091 pass),test/bundler/css/(166 pass),test/bundler/esbuild/css.test.ts+ CSS regression tests (56 pass). The only failure is the pre-existingfuzz ansi256debug-ASAN timeout (color integer math, unrelated).cargo clippy -p bun_cssis clean.