Skip to content

css: bound selector expansion when compiling nesting for older targets - #31278

Closed
robobun wants to merge 2 commits into
mainfrom
farm/6882e4f7/css-nesting-expansion-oom
Closed

robobun wants to merge 2 commits into
mainfrom
farm/6882e4f7/css-nesting-expansion-oom

Conversation

@robobun

@robobun robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator

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

.foo::part(header), .foo::part(body) {

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:

# default --target=browser lowers nesting (safari14/chrome87/edge88/firefox78 defaults)
bun build part-nesting.css --outdir out    # n=20 already writes a 0.4 GB file; n=26 grows past 4 GB

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:

  1. Minify-time splitting (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.
  2. Print-time & 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_arm checks incompatible selectors × nested subtree size before cloning the subtree (bounds path 1 regardless of which selector feature made the list incompatible).
  • After minification, StyleSheet::minify walks 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::minify previously hit panic!("TODO: Handle") for any minify error; it now returns the error with the offending rule's filename/line/column, so bun build prints a proper diagnostic:
    error: Compiling CSS nesting for the configured browser targets would expand this rule into more than 65536 selector combinations. Reduce the number of selectors in nested selector lists, or target browsers that support CSS nesting.
        at part-nesting.css:11:1
    
  • css_jsc/error_jsc.rs::to_error_instance deref'd the message string once too often (bun_string_jsc::to_error_instance already 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" / spansOverlap assertions 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 under bun 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:

  • the nested ::part() input errors with the new message for chrome 87 (split path) and chrome 100 (:is() path) instead of expanding,
  • bun build on 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),
  • shallow multi-selector nesting still expands fully for old targets, deep single-selector nesting still compiles, and the original no-targets repro plus modern-target builds are unchanged.

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-existing fuzz ansi256 debug-ASAN timeout (color integer math, unrelated). cargo clippy -p bun_css is clean.

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

coderabbitai Bot commented May 23, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0f566249-79ae-47ef-9552-a2ee534decdc

📥 Commits

Reviewing files that changed from the base of the PR and between f161e03 and 7fc411d.

📒 Files selected for processing (5)
  • src/css/css_parser.rs
  • src/css/error.rs
  • src/css/rules/mod.rs
  • src/css_jsc/error_jsc.rs
  • test/js/bun/css/nesting-expansion-limit.test.ts

Walkthrough

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

Changes

CSS Nesting Selector Expansion Limit

Layer / File(s) Summary
Nesting expansion limit error type
src/css/error.rs
MinifyErrorKind::nesting_expansion_limit_exceeded variant is added with Display formatting that includes MAX_NESTING_EXPANSION in the error message.
Expansion limit detection infrastructure
src/css/rules/mod.rs
MAX_NESTING_EXPANSION constant is defined as 65536. count_rules_capped counts rules up to a cap with early exit. exceeds_nesting_expansion_limit recursively walks rule lists and detects selector product oversizing. minify_style_arm adds early abort when incompatible.len() * subtree_rules exceeds the limit, recording the error and preventing further processing.
Minification flow integration
src/css/css_parser.rs
minify extracts rich MinifyError from MinifyContext.err via new minify_error_with_location helper instead of placeholder panic. Pre-print guard checks exceeds_nesting_expansion_limit and returns error with location. minify_error_with_location maps source index to filename and constructs ErrorLocation in the returned MinifyErrorKind.
Regression test suite
test/js/bun/css/nesting-expansion-limit.test.ts
Helper nestedPartLists(depth) generates deeply nested ::part() multi-selector CSS. Helper buildCSS(name, css) spawns bun build with timeout and SIGKILL. Tests validate that minification rejects expanded selectors for old targets, allows shallow and single-selector deep nesting, and accepts deep nesting when CSS nesting or no targets are configured.

Unrelated:

The small change in src/css_jsc/error_jsc.rs removes an explicit dereference call to str after creating a JS error instance and clarifies that bun_string_jsc::to_error_instance internally consumes the reference.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: bounding selector expansion during CSS nesting compilation for older browser targets.
Description check ✅ Passed The description comprehensively covers both required sections: what the PR does (fixes unbounded memory growth with specific root cause and solution) and how it was verified (new test suite with specific test cases and results from existing suites).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

@robobun

robobun commented May 23, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:13 AM PT - May 23rd, 2026

❌ @autofix-ci[bot], your commit 7fc411d has 3 failures in Build #57279 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31278

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

bun-31278 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. css: bound selector-list expansion when compiling nesting for older targets #31277 - Both PRs bound selector-list expansion in minify_style_arm with a 65536 cap, add the same MinifyErrorKind variant, and fix the same double-deref bug in error_jsc.rs

🤖 Generated with Claude Code

@robobun

robobun commented May 23, 2026

Copy link
Copy Markdown
Collaborator Author

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 panic!("TODO: Handle"), same error_jsc.rs double-deref fix) and additionally errors before the exponential subtree is cloned. The ::part() input from this report is covered by the tests there; the one case this branch additionally bounds (selector-compatibility splits when the targets support nesting natively) is noted on #31277 so it can be folded in.

@robobun robobun closed this May 23, 2026
Comment thread src/css/css_parser.rs
Comment on lines +2682 to 2691
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,
}));
}

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.

🟣 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:

  1. transpiler.rs:3240 calls minify with default targets → should_compile_same(Nesting) = false → path-2 check at css_parser.rs:2682 not entered.
  2. Inside minify_style_arm, context.targets.should_compile_selectors() is false (no browsers) → incompatible is empty → path-1 check at rules/mod.rs:835 not entered. Minify completes with the nested tree intact.
  3. transpiler.rs:3252 calls to_css with for_bundler_target(Browser) → printer's targets lack native nesting → serialize_nesting substitutes & 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 default bun build path.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant