Skip to content

css: bound raw-token expansion when compiling nesting for older targets - #32453

Open
robobun wants to merge 8 commits into
mainfrom
farm/d32cfd68/css-token-expansion-cap
Open

css: bound raw-token expansion when compiling nesting for older targets#32453
robobun wants to merge 8 commits into
mainfrom
farm/d32cfd68/css-token-expansion-cap

Conversation

@robobun

@robobun robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator

Fixes a fuzzer-found OOM in the CSS minifier.

Fuzzer signature: oom:css:_RINvXNtC15bun_collections7vec_extINtNtC5alloc3vec3VecNtNtNtC7bun_css10properties6custom12TokenOrValueEINtB3_6Ve, i.e. <Vec<TokenOrValue> as VecExt>::deep_clone_with, on 1.4.0-canary.1+55f6c899f.

Repro

// BUN_FEATURE_FLAG_INTERNAL_FOR_TESTING=1
const c = require("bun:internal-for-testing").cssInternals;
const payload = "x ".repeat(3000);
const css =
  "x::part(a), y::part(b) {\n".repeat(12) +
  ".inner { color: f(" + payload + "var(--x)) }";
c._test(css, "", { chrome: 80 << 16 }); // before: OOMs past ~1.5 GB; after: throws a catchable error

6.3 KB of CSS; chrome 80 is the default bundler target. On the unfixed build, D=10 allocates ~360 MB, D=12 passes ~1.5 GB, D=14 passes 6 GB; none hit the existing 65,536-selector cap (which only fires at D=16). The same shape reproduces with --foo:, z-index:, or any property whose value the property-specific parser can't read; nothing color-specific. Reaches the same path through bun build --minify.

Cause

When the targets require compiling CSS nesting away, each nested rule is duplicated once per enclosing selector combination (selector_expansion_multiplier in MinifyContext), and every copy carries its own clone of the rule's declarations. A property value that the property-specific parser couldn't read is stored as Property::Unparsed/Property::Custom holding a raw TokenList (Vec<TokenOrValue>), which is deep-cloned in full for every copy.

MAX_SELECTOR_EXPANSION (src/css/rules/mod.rs) bounds the number of copies at 65,536 but not their size, so a multi-thousand-token value under the cap still clones into gigabytes of TokenOrValue. In the repro, sizeof(TokenOrValue) is 72, so 4,096 copies of a 6,001-token value is 4,096 x 6,001 x 72 ≈ 1.7 GB of allocations; the failed allocation is exactly 6,001 x 72 = 432,072 bytes.

Fix

Add MAX_TOKEN_EXPANSION = 1 << 20 (~1M tokens, on the order of 100 MB of in-memory TokenOrValue) next to MAX_SELECTOR_EXPANSION. StyleRule::charge_selector_expansion (src/css/rules/style.rs) already charges multiplier x selectors against the selector cap; it now also charges multiplier x selectors x token_weight against the new cap, where token_weight is the recursive TokenOrValue count across the rule's unparsed/custom declarations (new TokenList::token_weight in src/css/properties/custom.rs and DeclarationBlock::token_weight in src/css/declaration.rs). Past the cap, minify fails with a new MinifyErrorKind::token_expansion_limit_exceeded (src/css/error.rs) instead of allocating. Rules with only parsed properties have token_weight == 0 and pay nothing; the walk is O(tokens) per charged rule, bounded by input size.

Related

Complements #31913 (bounds the split-clone payload, but only fires when a selector is actually target-incompatible; ::part() is compatible with chrome 80 so nothing splits here) and #31916 (bounds selector-chain bytes, but the selectors here are tiny; the amplification is the declaration value). Different crash signatures; each covers a distinct amplification vector under the same expansion multiplier.

Verification

  • The synthetic repro above: D=8..20 now errors in ~10 ms at flat RSS.
  • The fuzzer's 18,313-byte minimized input (embedded gzip+base64 in the report) now throws the token-limit error through _test and prefixTest instead of OOMing; minifyTest with no targets still completes (nothing expands).
  • Output unchanged: depth-2 expansion with a small unparsed value is byte-identical to the unfixed build (pinned as an inline snapshot); depth-6 with a 3,000-token value (under the cap) still compiles.
  • New tests in test/js/bun/css/nested-selector-list-expansion.test.ts (the existing home of the selector-expansion cap tests) fail on the unfixed build (5 failures) and pass with the fix (33/33).
  • test/js/bun/css/css.test.ts (1093) and test/bundler/css/ (167) pass; test/js/bun/css/ passes except the pre-existing debug-build fuzz-test timeouts (css-fuzz.test.ts, color.test.ts fuzz ansi256), identical on the unfixed build in the same container.

When compiling CSS nesting away for browser targets that don't support
it, each nested rule is duplicated once per enclosing selector
combination, and every copy carries its own clone of the rule's
declarations. The existing MAX_SELECTOR_EXPANSION cap bounds the number
of such copies, but not the size of each copy: an unparsed property
value (any value the property-specific parser couldn't read, stored as
a raw TokenList) can hold thousands of TokenOrValue nodes, so a handful
of two-selector nesting levels over a few-thousand-token value stays
well under the 65,536-selector cap while deep-cloning gigabytes of
TokenOrValue.

Fuzzer signature:
  oom:css:_RINvXNtC15bun_collections7vec_extINtNtC5alloc3vec3Vec
  NtNtNtC7bun_css10properties6custom12TokenOrValueE...
i.e. <Vec<TokenOrValue> as VecExt>::deep_clone_with.

Add MAX_TOKEN_EXPANSION (1,048,576 tokens, ~100 MB of in-memory
TokenOrValue) and charge multiplier * selectors * token_weight in
charge_selector_expansion, where token_weight is the recursive
TokenOrValue count across the rule's unparsed/custom declarations. Past
the cap, minify fails with a new token_expansion_limit_exceeded error
instead of allocating.
@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:08 AM PT - Jun 17th, 2026

@robobun, your commit 956d7927efb500af5e6c129f80b64d6fa49dabf4 passed in Build #63106! 🎉


🧪   To try this PR locally:

bunx bun-pr 32453

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

bun-32453 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. css: bound nested-prelude substitution output across the stylesheet #32451 - Also bounds CSS nesting expansion output to prevent fuzzer-found OOM when compiling for older targets, using a printer-side byte counter rather than a token-count cap

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

A new token-expansion budget is added to the CSS minifier to prevent OOM/hangs when compiling deeply nested CSS rules with large unparsed property values. MAX_TOKEN_EXPANSION caps cumulative TokenOrValue node cloning; token_expansion_total tracks it in MinifyContext; recursive token_weight() methods compute per-rule costs; charge_selector_expansion and unknown rule minification enforce the limit.

Changes

CSS Token Expansion Budget

Layer / File(s) Summary
Budget constant, context field, and error variant
src/css/rules/mod.rs, src/css/error.rs
Adds MAX_TOKEN_EXPANSION = 1 << 20 constant and token_expansion_total: usize field to MinifyContext, alongside a new MinifyErrorKind::token_expansion_limit_exceeded variant and its Display message referencing the constant.
Recursive token_weight() computation
src/css/properties/custom.rs, src/css/declaration.rs
TokenList::token_weight() recursively counts TokenOrValue nodes including nested Function args, var()/env() fallbacks, color alpha, and light-dark() halves. DeclarationBlock::token_weight() sums weights across Unparsed and Custom properties in both declaration lists.
Budget enforcement and context initialization
src/css/rules/mod.rs, src/css/rules/style.rs, src/css/css_parser.rs
Adds charge_token_expansion() helper that saturating-accumulates token weights. Extends charge_selector_expansion to compute selector copy fan-out and charge declarations.token_weight() against the budget. Adds CssRule::Unknown minify arm to charge unknown rule tokens when nested under selector expansion. StyleSheet::minify initializes token_expansion_total to 0.
Regression tests
test/js/bun/css/nested-selector-list-expansion.test.ts
Adds TOKEN_LIMIT_ERROR constant and _test entrypoint import, a nestedWithLargeUnparsedValue helper that builds deeply nested ::part() selectors with large unparsed payloads, and a comprehensive test suite verifying the token limit is enforced across minify/prefix/_test entrypoints, passes under-limit inputs, skips the cap for modern/no-target configurations, enforces through @starting-style, counts tokens from unknown at-rules and env() indices, verifies flat selector-split charging behavior, and fails bun build --minify without hanging.

Possibly related PRs

  • oven-sh/bun#31277: Introduced the original selector-expansion budget in StyleRule::charge_selector_expansion, MinifyContext, and error.rs — the exact same code paths this PR extends with the token-expansion budget.
  • oven-sh/bun#31482: Both PRs modify the CSS minifier's nested-rule and budgeting path, refactoring StyleRule and CssRuleList::minify to handle selector expansion and recursion through nested at-rules, so this token-expansion charge is layered onto the same minification framework.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: adding a bound on raw-token expansion when compiling CSS nesting for older browser targets.
Description check ✅ Passed The description comprehensively covers the fuzzer-found OOM issue, root cause, solution with code changes, and verification steps, exceeding template requirements.
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.

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/bun/css/nested-selector-list-expansion.test.ts`:
- Around line 386-389: The test assertion in the
nested-selector-list-expansion.test.ts file is checking for the wrong error
constant in the expect assertion for stderr validation. Replace the reference
from LIMIT_ERROR to TOKEN_LIMIT_ERROR in the stringContaining check to ensure
the CLI regression test specifically validates the token-expansion failure path
rather than a generic selector-limit message. This ensures consistency with the
specificity used in other minify/prefix test assertions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 09a10d3e-8278-47d6-a472-276c6c9298a4

📥 Commits

Reviewing files that changed from the base of the PR and between 0c537fe and 243fd46.

📒 Files selected for processing (7)
  • src/css/css_parser.rs
  • src/css/declaration.rs
  • src/css/error.rs
  • src/css/properties/custom.rs
  • src/css/rules/mod.rs
  • src/css/rules/style.rs
  • test/js/bun/css/nested-selector-list-expansion.test.ts

Comment thread test/js/bun/css/nested-selector-list-expansion.test.ts
@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate of #32451: that PR bounds the serialized selector-prelude bytes on the printer side (trigger: & > & chains x many sibling leaves x fat root selector; signature hang:css:...Write::write_str). This PR bounds the declaration-value token count on the minify side (trigger: multi-selector nesting x a large unparsed property value; signature oom:css:...Vec<TokenOrValue>::deep_clone_with).

Neither catches the other's reproduction: #32451 only meters preludes, and the selectors in this PR's repro are tiny; this PR only charges Unparsed/Custom property tokens, and #32451's repro uses parsed color: rgb(...) values with token_weight == 0. They are complementary caps under the same nesting-expansion multiplier, alongside #31913 (split-clone payload) and #31916 (selector-chain bytes).

Comment thread src/css/rules/style.rs Outdated
Comment thread src/css/properties/custom.rs
robobun and others added 2 commits June 17, 2026 05:35
…n cap

Two gaps in MAX_TOKEN_EXPANSION found in review, both reproducing the
same Vec<TokenOrValue>/Vec<i32> deep_clone OOM under the selector cap:

- CssRule::Unknown nested in a style rule stores its prelude and block
  as raw TokenLists and is deep-cloned by the same per-selector split,
  but charge_selector_expansion only weighed the carrying style rule's
  own declarations (which are empty when the payload is in the at-rule
  body). Charge prelude + block against the cap in CssRuleList::minify.

- TokenList::token_weight's Env arm only counted the fallback TokenList,
  not the unbounded Vec<i32> indices list that every deep_clone also
  reallocates. Count each index as one unit (conservative).

Factor the charge-and-check into MinifyContext::charge_token_expansion
so both call sites share it.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/css/rules/style.rs (1)

392-410: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Charge token expansion when selector splitting clones declarations.

Line 392 skips the whole budget when selector_expansion_multiplier == 1, but a top-level incompatible selector list is still split later and dc::decl_block_static(...) deep-clones the declaration block for each split rule at Lines 878-885. A large unparsed/custom value can still be cloned once per selector without hitting MAX_TOKEN_EXPANSION.

Possible fix
-        if context.selector_expansion_multiplier > 1 {
-            let copies = context
-                .selector_expansion_multiplier
-                .saturating_mul(self.selectors.v.len().max(1));
+        let selector_count = self.selectors.v.len().max(1);
+        let selectors_incompatible = self.selectors.v.len() > 1
+            && context.targets.should_compile_selectors()
+            && !self.is_compatible(context.targets);
+        let splits_selectors = selectors_incompatible
+            && !(context.targets.is_compatible(css::Feature::IsSelector)
+                && !self.selectors.any_has_pseudo_element()
+                && self.selectors.specifities_all_equal());
+        let copies = context
+            .selector_expansion_multiplier
+            .saturating_mul(selector_count);
+
+        if context.selector_expansion_multiplier > 1 {
             context.selector_expansion_total =
                 context.selector_expansion_total.saturating_add(copies);
             if context.selector_expansion_total > super::MAX_SELECTOR_EXPANSION {
                 context.err = Some(crate::error::MinifyError {
                     kind: crate::error::MinifyErrorKind::selector_expansion_limit_exceeded,
@@
                 });
                 return Err(MinifyErr::minify_err);
             }
-            // Same expansion multiplies this rule's unparsed/custom property
-            // token lists. A large raw value under the selector cap still
-            // deep-clones into gigabytes of `TokenOrValue`, so budget the
-            // token payload separately.
-            if context.charge_token_expansion(copies, self.declarations.token_weight()) {
-                context.err = Some(crate::error::MinifyError {
-                    kind: crate::error::MinifyErrorKind::token_expansion_limit_exceeded,
-                    loc: self.loc,
-                });
-                return Err(MinifyErr::minify_err);
-            }
+        }
+
+        // Same fan-out multiplies this rule's unparsed/custom property token
+        // lists. Include top-level selector splitting too: it deep-clones the
+        // declarations even when the nesting multiplier is still 1.
+        if (context.selector_expansion_multiplier > 1 || splits_selectors)
+            && context.charge_token_expansion(copies, self.declarations.token_weight())
+        {
+            context.err = Some(crate::error::MinifyError {
+                kind: crate::error::MinifyErrorKind::token_expansion_limit_exceeded,
+                loc: self.loc,
+            });
+            return Err(MinifyErr::minify_err);
         }

Also applies to: 878-885

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/css/rules/style.rs` around lines 392 - 410, The token expansion budget
check at line 392 is only performed when selector_expansion_multiplier is
greater than 1, but the declarations get cloned for each selector even when the
multiplier equals 1 (as shown by the dc::decl_block_static calls at lines
878-885). This allows large unparsed or custom property values to bypass the
MAX_TOKEN_EXPANSION limit. Move the token expansion charging logic (the call to
context.charge_token_expansion with copies and self.declarations.token_weight())
outside of the condition that checks if selector_expansion_multiplier > 1, so
that token weight is always budgeted when selector splitting will occur.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/css/rules/style.rs`:
- Around line 392-410: The token expansion budget check at line 392 is only
performed when selector_expansion_multiplier is greater than 1, but the
declarations get cloned for each selector even when the multiplier equals 1 (as
shown by the dc::decl_block_static calls at lines 878-885). This allows large
unparsed or custom property values to bypass the MAX_TOKEN_EXPANSION limit. Move
the token expansion charging logic (the call to context.charge_token_expansion
with copies and self.declarations.token_weight()) outside of the condition that
checks if selector_expansion_multiplier > 1, so that token weight is always
budgeted when selector splitting will occur.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a5d3e408-c841-47bb-a2fc-80ea80277d38

📥 Commits

Reviewing files that changed from the base of the PR and between 683ab5e and 906877e.

📒 Files selected for processing (4)
  • src/css/properties/custom.rs
  • src/css/rules/mod.rs
  • src/css/rules/style.rs
  • test/js/bun/css/nested-selector-list-expansion.test.ts

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Re the coderabbit outside-diff note on charge_selector_expansion (top-level split with multiplier == 1): that's the flat split-clone vector (K selectors x W tokens from K+W input, quadratic), which is exactly what #31913 bounds by weighing each split's clone against MAX_SELECTOR_SPLIT_CLONE_WEIGHT in the while incompatible.len() > 0 loop before cloning. This PR targets the nesting-multiplier vector (2^D x W, exponential) that the fuzzer reported; #31913 is already listed under Related as the complementary cap for the split path. Duplicating the splits_selectors predicate here would conflict with both #31913 and the copy already in minify_nested_rules.

Comment thread src/css/rules/style.rs Outdated
let weight = self.declarations.token_weight();
if weight > 0 {
context.token_expansion_total = context
.token_expansion_total

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 CssRule::Unknown (above), self.declarations.token_weight() also misses two more TokenList carriers in the same sty.rules.deep_clone() subtree that the Unknown-specific fix wouldn't cover: (1) @container style(--foo: x x …)StyleQuery::Feature(Box<Property::Custom>) is deep-cloned via ContainerRule::deep_clone but the Container arm of CssRuleList::minify only recurses into cont.rules and never charges the condition (likewise MediaFeatureValue::Env fallbacks in @media/@container size queries); (2) nested rules' selectorsPseudoElement::CustomFunction { arguments: TokenList } / PseudoClass::CustomFunction (e.g. .inner::-foo(x x …)), cloned via self.selectors.deep_clone(). Both reproduce the same depth-12 ~0.9 GB OOM with token_expansion_total == 0. Rather than patching each carrier, a recursive StyleRule::token_weight() (declarations + selector CustomFunction args + nested at-rule preludes/blocks/conditions) charged once at the split level would close all of these — including the Unknown gap — together.

Extended reasoning...

What this adds to the comment above

The 🔴 comment above establishes that charge_selector_expansion only weighs self.declarations.token_weight() and so misses TokenLists carried elsewhere in the cloned subtree, citing CssRule::Unknown as the example. Both fixes that comment proposes are scoped to CssRule::Unknown specifically (charge prelude + block in the _ => {} arm of CssRuleList::minify, or walk self.rules for Unknown children). This comment names two further carriers that an author implementing either of those fixes literally would still leave open — same multiplier, same deep_clone sink, same crash class, but different containers in different code paths — so the fuzzer would surface them next under the same signature.

Carrier 1: @container style() conditions

@container is in allowed_in_style_rule() (css_parser.rs:1041), so it nests inside style rules. @container style(--foo: x x x …) parses via ContainerCondition::parse (with ALLOW_STYLE, container.rs:322) → parse_style_queryStyleQuery::parse_feature (container.rs:147–156) → Property::parse on PropertyId::Custom(--foo)CustomProperty::parseTokenList::parse, producing a StyleQuery::Feature(Box<Property::Custom>) whose value is an arbitrarily large TokenList. ContainerRule::deep_clone (container.rs:364–374) deep-clones conditionStyleQuery::deep_clone (container.rs:180–194) → dc::propertyCustomProperty::deep_cloneTokenList::deep_clone. The Container arm of CssRuleList::minify (rules/mod.rs) only does cont.rules.minify(...) and never charges the condition; every enclosing style rule has empty declarations (only a nested @container), so token_expansion_total stays at 0. The same applies to MediaFeatureValue::Env(EnvironmentVariable) fallbacks reachable via @media (width: env(--x, <large fallback>)) and @container (width: env(--x, …)) size queries — QueryFeature::deep_clone deep-clones the fallback TokenList and nothing charges it.

This is not covered by the Unknown fix: @container has its own CssRuleList::minify arm (Container(cont)), not the catch-all _ => {}, and "walk self.rules for Unknown children" doesn't match a Container child.

Carrier 2: selector CustomFunction arguments

PseudoElement::CustomFunction { arguments: TokenList } and PseudoClass::CustomFunction { arguments: TokenList } (parser.rs:1040–1045, 3150–3155) are parsed via TokenList::parse_raw (parser.rs:1312–1317, 1406–1410), so .inner::-foo(x x …) produces a multi-thousand-entry Vec<TokenOrValue> on the inner rule's selector. The partition path's sty.rules.deep_clone(context.arena) reaches StyleRule::deep_cloneself.selectors.deep_clone() (style.rs) → per-component deep_clone()PseudoElement::deep_clone() = self.clone() (parser.rs:3177–3179) → TokenList::clone()Vec<TokenOrValue>::clone(), allocating a fresh vec per clone. The leaf rule's declarations (color: red) parse as a typed Property::Color, so self.declarations.token_weight() == 0 and token_expansion_total again stays at 0.

This is not covered by either Unknown fix or any "walk self.rules for at-rule TokenLists" fix: the carrier lives in the selector tree (StyleRule.selectors), not in StyleRule.rules.

Step-by-step proof (concrete numbers, both carriers)

Same skeleton as the PR's repro — only the payload location changes:

const payload = "x ".repeat(3000);   // ~6,000 raw tokens

// Carrier 1: @container style() condition
const css1 = "x::part(a), y::part(b) {\n".repeat(12)
           + "@container style(--foo: " + payload + ") { .inner { color: red } }";

// Carrier 2: selector CustomFunction
const css2 = "x::part(a), y::part(b) {\n".repeat(12)
           + ".inner::-foo(" + payload + ") { color: red }";

cssInternals._test(css1 /* or css2 */, "", { chrome: 80 << 16 });
  • Selector cap. At depth 12 with two-selector lists, selector_expansion_total ≈ 8K–12K, far under 65,536.
  • Token cap. Every style rule on the path has declarations.token_weight() == 0 (empty blocks for the 12 wrapper levels; parsed color: red for .inner), so token_expansion_total stays at 0.
  • Split-clone. Levels 2..12 carry implicit Component::Nesting → incompatible with chrome 80; ::part() is a pseudo-element so no :is() collapse. minify_style_arm partitions both selectors at every level, executing sty.rules.deep_clone(context.arena) per partition, doubling the leaf subtree at each level → ~2^11 ≈ 2,048 clones bottom-up.
  • Allocation. 2,048 × ~6,000 tokens × 72 B/TokenOrValue0.88 GB of Vec<TokenOrValue> allocations before either cap fires — same magnitude and crash class this PR targets. (Carrier 2 goes through Vec::clone() rather than deep_clone_with(), but the allocation is identical.)

Suggested fix

Rather than patching three more arms individually (the catch-all for Unknown, the Container/Media arms for conditions, and a separate selector walk), replace the per-rule declaration-only weight with a recursive StyleRule::token_weight() that sums self.declarations.token_weight() + Σ arguments.token_weight() over each Component::PseudoElement(CustomFunction{..}) / Component::NonTsPseudoClass(CustomFunction{..}) in self.selectors + a walk over self.rules collecting TokenList weights from Unknown.{prelude,block}, Container.condition, Media.query env fallbacks, and recursing into nested style rules. Charged once at the outermost split level (where copies is computed), this closes all four carriers under the new cap's documented contract ("raw TokenOrValue nodes that compiling nested rules … may clone") in one place.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified all three carriers (@container style(--foo: ...), ::-foo(...), :-foo(...)) reproduce at D=12. These, along with @media env fallbacks and selector-list arguments, are exactly what #31913's clone_weight module walks: it recursively weighs Container.condition via style_query -> property -> token_list, selector Component::PseudoElement(CustomFunction) / PseudoClass(CustomFunction) arguments, media/supports conditions, and the full nested rule_list, charging the total against MAX_SELECTOR_SPLIT_CLONE_WEIGHT in the while incompatible.len() > 0 loop before the clone. Reimplementing that walk here at the charge_selector_expansion layer would duplicate ~200 lines of #31913 and conflict on merge.

This PR's scope is the nesting-multiplier vector the fuzzer reported (Vec<TokenOrValue>::deep_clone_with under declarations + the immediately adjacent Unknown and env() carriers surfaced in this review round); #31913 is listed under Related as the complementary cap that bounds every split-clone payload carrier. Leaving this open for a maintainer to weigh in on scope; happy to fold a comprehensive walk in if preferred over landing #31913.

Comment thread src/css/rules/style.rs
charge_selector_expansion previously only charged tokens when the
enclosing nesting multiplier was > 1, so a flat top-level rule whose
N > 1 incompatible selectors minify_style_arm partitions into N cloned
declaration blocks was never charged. Gate on copies > 1 instead (where
copies = multiplier * selectors): a flat single-selector rule still pays
nothing, and a flat N-selector rule charges N * W as it should.
Comment thread src/css/declaration.rs
The previous wording claimed other property kinds are fixed-size, which
is not true for list-typed parsed values (font-family, background-image,
etc.). Those carry no raw TokenOrValue nodes so MAX_TOKEN_EXPANSION is
not the right budget for them; state that accurately rather than
claiming they are bounded.
Comment thread src/css/rules/style.rs
The copies > 1 gate added in 29405e1 charged every flat top-level
rule with N > 1 selectors, including when minify_style_arm never
partitions it (no targets configured, or all selectors compatible).
Those cases clone nothing, so charging N * W is a false positive on
input that compiled fine before this PR.

Gate additionally on should_compile_selectors(): the cheapest
necessary condition for any partition. With no selector compilation
configured the partition never runs and nothing is charged. Pin the
no-targets flat case so it stays that way.
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