Skip to content

css: bound the payload cloned when splitting target-incompatible selectors - #31913

Open
robobun wants to merge 8 commits into
mainfrom
farm/7c1c01a3/css-split-clone-budget
Open

css: bound the payload cloned when splitting target-incompatible selectors#31913
robobun wants to merge 8 commits into
mainfrom
farm/7c1c01a3/css-split-clone-budget

Conversation

@robobun

@robobun robobun commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes a CSS minifier hang/OOM family found by fuzzing (signature: hang:css:...PropertyIdTag::name, an 11.6 KB input that hung or ate 6+ GB on the canary it was found on), and a related debug-build panic.

Root cause

When a style rule's selector list is incompatible with the configured browser targets (and cannot be collapsed into :is()), minify_style_arm (src/css/rules/mod.rs) splits it: each split-off selector gets a deep clone of the rule's declarations and its entire nested-rule subtree:

rules: sty.rules.deep_clone(context.arena),

Minify recurses bottom-up, so at each nesting level the subtree already contains the clones split off at deeper levels. With two-selector lists the cloned payload doubles per level: exponential time and memory in nesting depth. Caught live under gdb on the fuzzer input: the stack is CssRuleList::deep_clone recursing into TokenList clones from minify_style_arm.

The existing caps don't bound this. MAX_SELECTOR_EXPANSION (65,536) counts selectors, not bytes; each cloned rule can carry arbitrarily large token lists. On current main (all caps intact, release build):

// 449 KB in -> 1,083,588,618 bytes out, 2.1 GB RSS, no error
const pad = "a".repeat(32 * 1024);
const css = `.a:placeholder-shown .x, .b:-webkit-autofill .y { --p: ${pad};\n`.repeat(14)
  + "color: red" + "}".repeat(14);
require("bun:internal-for-testing").cssInternals._test(css, "", { safari: (13<<16)|(2<<8) });

The same shape reaches the same path through bun build --minify (default browser targets compile nesting away), so the bundler amplifies identically.

Fix

Charge a weight estimate of each split's clones against a per-stylesheet budget, MAX_SELECTOR_SPLIT_CLONE_WEIGHT = 64 MB, before cloning. The weight walk counts every structure whose count or size scales with user input: rules, declarations, raw token lists with their text (including dimension units and dashed idents), custom-property and var()/env()/function/pseudo names, selector text (classes, ids, element, attribute and namespace names, attribute values, wrapped ::cue()/:local()/view-transition selectors), and the names, preludes, and conditions of every at-rule that can nest inside a style rule (@media, @supports, @container, @layer, @scope, unknown at-rules). Past the budget, minification reports a catchable error ("Splitting nested CSS rules with selectors unsupported by the configured browser targets duplicates too much CSS."), mirroring the existing MAX_SELECTOR_EXPANSION and MAX_PREFIX_EXPANSION_BYTES (#31642) bounds. The weight walk only runs for rules that actually split, so stylesheets that never split pay nothing, and its cost is proportional to what it charges.

Also fixes a bug in the same input family: the printer's indent counter was a u8 incremented by 2 per nesting level, so pretty-printing a valid stylesheet with 128+ nested levels overflowed it; that is a panic ("attempt to add with overflow") in debug builds. Widened to u32.

Not changed: the >-combinator token floods from the fuzzer input parse and minify in linear time already (consecutive combinators collapse during selector parsing; raw token lists in unknown declarations round-trip linearly); tests now pin that down.

Verification

  • The 11.6 KB minimized fuzzer input terminates quickly on all passes (minify, nesting compile, prefix), reporting bounded-expansion errors; embedded (gzip+base64) as a regression test.
  • The 449 KB repro above now errors in ~60 ms instead of producing a 1 GB output.
  • Output below the budget is byte-for-byte unchanged: asserted for the split path at depth 12 (8,595,441 identical bytes pre/post fix), plus css.test.ts (1093), nested-selector-list-expansion.test.ts, nested-selector-expansion.test.ts, nested-vendor-prefix-duplication.test.ts, doesnt_crash.test.ts, test/bundler/css/ (164), test/bundler/esbuild/css.test.ts (53) all pass.
  • New tests in test/js/bun/css/nested-selector-list-expansion.test.ts, including a 19-case test.each matrix moving the payload into every chargeable text carrier. On an unfixed build: the clone-budget tests fail (33 MB / 69 MB outputs are returned instead of the error, bun build writes the file), and the indent test fails on unfixed debug builds with the overflow panic.

(css-fuzz.test.ts debug-build timeouts are pre-existing on main and the file is skipped on CI.)

…ctors

Splitting a selector list that is incompatible with the browser targets
deep-clones the rule's declarations and entire nested-rule subtree once
per split-off selector. Under CSS nesting the subtree at each level
already contains the clones made at deeper levels, so the cloned payload
compounds exponentially with depth. The existing selector-count cap
bounds how many rules this produces but not their size, so a few hundred
KB of input could clone and print gigabytes while staying under it.

Charge a weight estimate (rules, declarations, selector components, raw
tokens and their text) against a 64 MB budget before each split and
report a minify error past it, mirroring the selector-count cap.

Also widen the printer's indent counter from u8 to u32: pretty-printing
a valid stylesheet with 128+ nested levels overflowed it, a panic in
debug builds.
@coderabbitai

coderabbitai Bot commented Jun 5, 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: 156744e0-3511-4105-8701-5979dfac49df

📥 Commits

Reviewing files that changed from the base of the PR and between 791f75b and 3bd202f.

📒 Files selected for processing (1)
  • test/js/bun/css/nested-selector-list-expansion.test.ts

Walkthrough

The PR implements a clone-weight budget to prevent CSS minifier pathological expansion when splitting incompatible nested selectors. It adds weight estimation for cloned AST payloads, accumulates weights during minification, enforces an upper limit, and widens the printer indent type to support deeper nesting without overflow.

Changes

Clone-weight budget mechanism

Layer / File(s) Summary
Type contracts, limits, and error reporting
src/css/printer.rs, src/css/error.rs, src/css/rules/mod.rs
Printer::indent_amt widens from u8 to u32 to prevent overflow in deeply nested rules. New error variant MinifyErrorKind::selector_split_clone_limit_exceeded with display formatting and a public clone-weight limit constant MAX_SELECTOR_SPLIT_CLONE_WEIGHT are defined.
Clone weight computation module
src/css/rules/mod.rs
The internal clone_weight module walks CSS rule variants (style, media, supports, container, layer, etc.), including nested structures, selectors, declarations, and tokenized payloads, estimating cumulative weight using saturating arithmetic.
MinifyContext extension and minification integration
src/css/rules/mod.rs, src/css/css_parser.rs
MinifyContext gains a split_clone_weight_total field to track accumulated clone weight. In minify_style_arm, weight is computed per cloned declaration/rule subtree, accumulated into the context, and an error is emitted when the budget is exceeded. The field is initialized to zero in StyleSheet::minify.
Clone-weight limit and basic enforcement regressions
test/js/bun/css/nested-selector-list-expansion.test.ts
Tests validate clone-weight enforcement on deeply nested payloads, vendor-prefix fuzzer-shaped selectors, parameterized budget charging across CSS constructs, deterministic output just below the limit, full expansion for shallow incompatible splits, and subprocess integration with exit code and error message verification.
Robustness and stress regression tests
test/js/bun/css/nested-selector-list-expansion.test.ts
Tests validate process signal safety on deep @starting-style spanning, pretty-print robustness with 128+ nested rule levels, termination on gzip-embedded fuzzer inputs across multiple passes, and linear-time minification behavior under large > combinator token floods.

Possibly related PRs

  • oven-sh/bun#31277: Both PRs extend CSS minifier safety limits via new MinifyContext fields and MinifyErrorKind variants; this PR adds clone-weight budgeting while the related PR addresses selector-expansion counting.
  • oven-sh/bun#31920: Both PRs modify minify_style_arm logic around incompatible-selector clone creation; this PR adds clone-weight accounting while the related PR refactors merge and deferred minification to avoid hangs.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and concisely describes the main change: bounding cloned payload when splitting target-incompatible CSS selectors.
Description check ✅ Passed The description comprehensively addresses both required template sections: 'What does this PR do?' details the root cause, fix, and implementation; 'How did you verify your code works?' provides extensive verification evidence including test cases, output comparisons, and regressions.
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.

@github-actions github-actions Bot added the claude label Jun 5, 2026
@robobun

robobun commented Jun 5, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 10:03 PM PT - Jun 16th, 2026

@robobun, your commit 3bd202f has 6 failures in Build #63070 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 31913

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

bun-31913 --bun

Comment thread src/css/rules/mod.rs
@robobun

robobun commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author

Heads up: #31916 fixes the OOM half of this fuzz family (signature oom:css:…write_all…, the byte-unbounded selector expansion and the printer's & substitution path, which this PR's clone budget does not reach since no splits or clones happen there). The two changes are complementary but touch adjacent lines in error.rs, rules/mod.rs, and css_parser.rs, so whichever lands second needs a small rebase.

The weight walk only counted plain ident-like token text, so a dimension
token's unit, a dashed ident, or a var()/env()/function name carrying the
same input-sized text was charged the flat token constant and bypassed
the budget. Count every token variant that stores its text inline.

url() stores only an import-record index; its text is not reachable
during minify, so like parsed leaf values it stays on the selector-count
bound (noted in the module doc).
Comment thread src/css/rules/mod.rs
Custom-property names (both the Custom and Unknown spellings) and
selector component text (classes, ids, element and attribute names,
attribute values, namespace urls, ::part names, unknown pseudo-class and
pseudo-element names, :lang lists) carry input-sized borrowed text that
is re-emitted per split clone, but were charged only the flat constants.
Moving a large payload from a property value into the property name or a
nested rule's selector bypassed the budget. Charge their text lengths.

CSS-modules local references print a symbol-table name rather than
inline text and keep the flat charge.
Comment thread src/css/rules/mod.rs
…ctors

Sweep the remaining inline-text carriers reachable inside a style rule's
subtree: view-transition part names, ::cue()/::cue-region() and
:local()/:global() wrapped selectors, unknown at-rule names, and the
names, preludes, and conditions of every at-rule that can nest inside a
style rule (@media queries, @supports conditions, @container names and
conditions including style() queries, @layer names, @scope selector
lists). Each of these amplified to 33+ MB from ~30 KB of input while
charging only the flat constants.

@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: 2

🤖 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 336-349: The test spawns a Bun process into variable proc with
stdout set to "pipe" but never consumes proc.stdout, risking a deadlock; update
the await that collects outputs to concurrently drain stdout as well as stderr
and exit by replacing the Promise.all([...]) that currently only awaits
proc.stderr.text() and proc.exited with one that also awaits proc.stdout.text()
(e.g., Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited()])),
ensuring you still check proc.signalCode and use the captured stdout/stderr as
needed.
- Around line 401-409: The test currently masks all unexpected stderr by using
stderr.includes("panic") ? stderr : "" which hides other failures; update the
expectation to assert a concrete stderr contract instead: replace the
conditional stderr expression (the use of stderr.includes("panic")) so the
expected object checks for the exact stderr value you expect (e.g., an empty
string) and keep the indentedLength and exitCode checks (refer to
indentedLength, parseInt(stdout, 10), exitCode, and stderr.includes in the
existing snippet) so the test fails on any unexpected stderr output rather than
only on "panic".
🪄 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: 8e8dfc79-605a-43d2-85ff-e11cd2289cd7

📥 Commits

Reviewing files that changed from the base of the PR and between a7839df and 78cdda2.

📒 Files selected for processing (5)
  • src/css/css_parser.rs
  • src/css/error.rs
  • src/css/printer.rs
  • src/css/rules/mod.rs
  • test/js/bun/css/nested-selector-list-expansion.test.ts

Comment thread test/js/bun/css/nested-selector-list-expansion.test.ts
Comment thread test/js/bun/css/nested-selector-list-expansion.test.ts
Comment thread src/css/rules/mod.rs
A namespaced attribute selector ([ns|attr]) stores its prefix and url on
the boxed attribute struct, separate from the type-selector namespace
components that were already charged; a large prefix bypassed the
budget. Charge both fields.

Also drain stdout in the bun build clone-limit test.
Comment thread test/js/bun/css/nested-selector-list-expansion.test.ts
Same pattern as the clone-limit test fixed in the previous commit.

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

All four rounds of clone-weight coverage gaps I flagged are now addressed and the bug hunter found nothing new, but ~400 lines of new heuristic weight-estimation logic in the production CSS minifier path (plus the noted overlap with #31916) is worth a human pass before merging.

Extended reasoning...

Overview

This PR adds a per-stylesheet clone-weight budget (MAX_SELECTOR_SPLIT_CLONE_WEIGHT = 64 MB) to bound the exponential payload duplication that occurs when minify_style_arm splits target-incompatible selector lists under deep CSS nesting. It introduces a ~330-line clone_weight module in src/css/rules/mod.rs that walks rules, declarations, selector components, pseudo-classes/elements, at-rule preludes, and raw token lists to estimate cloned-AST weight; threads a split_clone_weight_total accumulator through MinifyContext; adds a new MinifyErrorKind variant; widens Printer::indent_amt from u8 to u32 to fix a debug-build overflow panic at 128+ nesting levels; and adds ~190 lines of regression tests including a 19-case test.each matrix and an embedded fuzzer input.

Security risks

None introduced. The change is purely defensive — it converts an adversarial-input OOM/hang (449 KB → 1 GB output, 2.1 GB RSS) into a catchable error. Output below the budget is asserted byte-for-byte unchanged. No new parsing, no new external input handling, no auth/crypto/permissions surface.

Level of scrutiny

This warrants human review. The clone_weight module is a heuristic that must enumerate every AST variant carrying input-sized borrowed text; missing one reopens the OOM via that vector. Over four review rounds I found and the author confirmed/fixed gaps for Token::Dimension units, DashedIdent/var()/env() names, custom-property names, selector component text (class/id/type/attribute), view-transition part names, ::cue()/:local()/:global() wrapped selectors, unknown-at-rule names, nestable at-rule preludes, and finally attribute-selector namespace prefixes — the round-3 "full sweep" claim was followed by a round-4 miss. The author has since re-walked struct fields, the test matrix covers each fixed vector, and the bug hunter found nothing this pass, so I believe coverage has converged. But the iteration history itself is the signal that a human should sanity-check the final variant coverage rather than rely solely on bot review.

Other factors

  • The minifier runs on every bun build with CSS, so correctness of the "output unchanged below budget" guarantee matters; it is asserted at depth 12 (8,595,441 bytes) plus the existing 1300+ CSS tests pass per the description.
  • The u8u32 indent widening and error-variant addition are mechanical and low-risk on their own.
  • robobun noted PR #31916 touches adjacent lines in error.rs, rules/mod.rs, and css_parser.rs; whichever lands second needs a rebase, which a human should coordinate.
  • No CODEOWNERS entry for src/css.
  • All inline comments (mine and CodeRabbit's) are resolved; the one declined CodeRabbit suggestion (strict stderr === "") was declined with a sound rationale matching established repo conventions.

@robobun

robobun commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator Author

Status summary for reviewers:

  • The fix and tests are complete and locally green: 52 tests in test/js/bun/css/nested-selector-list-expansion.test.ts pass on the fixed build; 22 of them fail on an unfixed build (the budget tests return 33 MB to 1 GB outputs instead of erroring, and the indent test panics on unfixed debug builds).
  • Four rounds of bot review findings (uncharged text carriers in the weight walk) were each verified to amplify on an unfixed build, fixed, and covered by the 19-case test.each matrix. All review threads are resolved.
  • CI: the only failing test across this PR's five builds (60942 through 61005) is test/cli/install/bunx.test.ts, a network-dependent installer test this diff does not touch, failing on varying lanes (Windows, Linux, ASAN) each run. Main also has failing build lanes in the same window (a7034f2). The CSS suites pass on every lane that ran them.
  • Heads up for whoever merges: css: budget selector expansion and nesting substitution by bytes #31916 (the printer-side OOM half of this fuzz family) touches adjacent lines in error.rs, rules/mod.rs, and css_parser.rs; whichever lands second needs a small rebase.

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another fuzzer report landed with the same root cause, signature oom:css:_RNvMs3_NtC7bun_css7printerNtB5_7Printer11write_bytes|_RNvMs5_NtNtC7bun_css10properties6customNtB5_9TokenList6to (Printer::write_bytes under TokenList::to_css) on 1.4.0-canary.1+55f6c899f.

The 16,483-byte minimized input nests 12 levels of o::part(header), .foo::part(body) { … } around a ~10.8 KB unparsed color: C olor-mix(… token list (81 unclosed parens swallow the rest of the file). On current main with {chrome: 80<<16} targets, _test allocates ~2.4 GB and prefixTest ~280 MB before the output buffer OOMs. Under 65,536 selectors throughout, so the existing caps never fire.

Verified against this branch (patch applied onto main, debug+ASAN): _test and prefixTest both terminate with selector_split_clone_limit_exceeded after ~197 MB peak RSS; minifyTest is unaffected. The synthetic shape reduces to:

// 5.4 KB in -> 42 MB out, 3.1 GB RSS on main; errors under this branch
const c = require("bun:internal-for-testing").cssInternals;
let inner = ".x { color: f(" + "a ".repeat(2500) + ") }";
for (let i = 0; i < 14; i++) inner = "a::part(h), .b::part(i) { " + inner + " }";
c._test(inner, "", {chrome: 80<<16});
Fuzzer repro (gzip+base64)
const c = require("bun:internal-for-testing").cssInternals;
const i = Buffer.from(Bun.gunzipSync(Buffer.from("H4sIAAAAAAACA+2b3W7TMBTHdwvSXoArSwipRfNIm35sqdjFPiqNj4ltDITEjZs4qZljB8dt2lWT9ga8ApfwFCAeZc/APTjJythGt3VfwHriC1fn/I9/qe3YjmIvoAVI/10a7Xp76RKuM/S6EmexxrHuc4pZSAJ6UjJeedOT1Z329z7BMwVpnPTjBmGRjJlmUkxYFU+de8C9vMDntIcDJZPL3bIvRToOK6rd9jHft4/w1Fw6Xem81JXMpdgnIeP9W1tl0+cUBlRQxVycVYpZUFBfJ0R5MUJIOk5ElC60KfGoKs6gWV+mNpcKTVXqaUmvX0SDu6O15KTlfHGHqu8fvny5hcrUjAbZyOEgIVVI+DBbFT4TTPdjqnGLS3fbDC0m3EF46Il6DVOA88OXbudOLOWgJZWh4YR5uu3Mw3WBC1moFPV2T6nX0lylUqtXKlbdrlvz1WqpVqqeHWWNJck6BYkj6mqsiJn9HVSl2C5X0CNkI9LREoEMZCA7lF3Ng3f7JGn1mQkpv3BCW9vMzCeyh9NFr4NKjUNnKHdGeeLMiPN3kS496j0odLiKnkqOuH8trjMrNNR1Sq5tMbO/9/mKix13LTuiJ2vaM+9cbIdi4r3rxNkqStDGsOMPs27IRD6SuJJL5aAllOY4ZL0CE4iG7aSFuBQBVWi63aEzL9a3mi+erayl97q4vvW8vrHVXH7JemvGvvZs5dXy1koQb1rNxY0dvbzRbK6vsuTp0uaTzpvXJe72V2urTe0tCesxevgI8ID/K/ipW/X3309y43/dg84PeMADftLw45mhqQA/ufiQqIAJaADAAx7wgAc84P9x/ETXqtNl5oMC9aBvAR7wgAc84AEPeMADHvCABzzgAX8RPGChiwH+hvDIGApWz8+viJnzX/nPID1calsPUMV6UMxltm15NEBDYxHt/trTGJKy+XqHmeBMUGw2TfrMHOmNKTdbnw2+1SHCnO9NzwJij3LSD7Q2xwIHv8Wrg/g/Rh9Tyy5VPpcJThSJDkNzdOOM4OP6UTDicGIOJ7ttxj2qzYGhgfFnlaqotzuyuHtZcdPn3u051B3silUsaBto3nj3W3bZpFwUuUd8WVbwmCMF7+d3GUemZnFko9laxbTVHJotzZcr1arJa/V6qVw8ncZJq2DaFVVrszVkzxcbPwGA/CP/Y0AAAA==", "base64"))).toString("latin1");
c._test(i, "", {chrome: 80<<16});      // ~2.4 GB RSS on main
c.prefixTest(i, "", {chrome: 80<<16}); // ~280 MB RSS on main

No new PR; this one already covers it.

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

One more signature from the same fuzz family on 1.4.0-canary.1+55f6c899f, recording for dedup:

oom:build:_RINvXNtC15bun_collections7vec_extINtNtC5alloc3vec3VecNtNtNtC7bun_css10properties6custom12TokenOrValueEINtB3_6

(<Vec<bun_css::properties::custom::TokenOrValue> as bun_collections::vec_ext::VecExt>::…, i.e. allocation while growing the cloned token vecs rather than the printer buffer.)

Same shape as the 03:37 comment above: 24,477-byte input with the o::part(header), .foo::part(body) split levels wrapping a color: C olor-mix( unparsed token list that swallows the rest of the file through 78 unclosed parens. On current main, Bun.build on the raw input peaks at ~6.8 GB RSS and emits 311 MB of CSS while staying at 6,144 selectors (well under 65,536). The split path is the only amplification; the token-list portion alone (input bytes 5301..end) minifies in 37 MB.

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another fuzzer hit with the same root cause, signature oom:build:_RINvXNtC15bun_collections7vec_extINtNtC5alloc3vec3VecNtNtNtC7bun_css10properties6custom12TokenOrValueEINtB3_6 (a Vec<TokenOrValue> growth during deep_clone, i.e. the minify-side clone path rather than the printer path from the previous comment) on 1.4.0-canary.1+55f6c899f.

The 49,765-byte minimized input is the same shape as above: 12 unclosed o::part(header), .foo::part(body) { levels (with a few single-selector .foo { levels interleaved) around an unparsed color: C olor-mix( declaration whose 85 nested unclosed parens and braces swallow ~34 KB of border-right-color: #b32323; lines as raw tokens. On current main with target: "browser", Bun.build allocates >8 GB RSS and emits a 505 MB stylesheet from the 49 KB input; under a 2 GB vmem cap it aborts in Vec<TokenOrValue> allocation. A 26 KB hand-reduced equivalent reproduces at 12 levels (2.3 GB RSS / 107 MB out):

// 26 KB in -> 107 MB out, 2.3 GB RSS on main
const tail = ".x { color: xyz(" + "border-right-color: #b32323;\n".repeat(900);
const css = "a::part(p), b::part(q) {\n".repeat(12) + tail;
await Bun.build({ entrypoints: ["./e.css"], files: { "./e.css": css }, target: "browser", throw: false });

::part() forces the split (no :is() wrap possible), and the ~5,400-token Unparsed value is charged by this branch's clone_weight::property() -> token_list(), so the selector_split_clone_limit_exceeded guard applies here too. Noting the signature for dedup.

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another fuzzer report landed for this family, signature oom:build:void JSC::ConservativeRoots::genericAddSpan<JSC::CompositeMarkHook>(void*, void*, JSC::CompositeMarkHook&)|JSC on 1.4.0-canary.1+55f6c899f. Verified this PR fixes it: the build reports the duplicates too much CSS minify error and peak RSS drops from 4.6 GB to ~510 MB (debug+ASAN).

The new input's shape is a dozen levels of ::part() two-selector nesting with a large unparsed color: declaration at the leaf (a known property with an invalid value that falls through to Property::Unparsed with an ~11 KB token list, the unclosed olor-mix(...) swallowing ~80 repetitions of itself). On main the 16 KB input emits a 222 MB output (26,496 rules, each carrying that token list) without tripping any count cap. The existing Property::Unparsed(u) => token_list(&u.value) arm in clone_weight::property already covers it.

Repro:

bun -e 'await Bun.build({entrypoints:["./e.css"], files:{"./e.css": Buffer.from(Bun.gunzipSync(Buffer.from("H4sIAAAAAAACA+2b227bNhiAe7sBeYEBAwgMBeyhTCUfGxnrRQ4GsrVGkzYbBvSGliiZC0UKFGXZMQL0DfYKvWzv+gYF+ii7313v219W3BxWtw7idDn84gWF/8CPh18kDZMPycOTqa/NaQGmS0vzVc9hIJi/HxmdqYCKmEV8Yd8LYP+3JEVqaWrHkn+2sZDOV97K0quI0Xp7Uyj56FpV+MM3hCU6FVZo9bVJp8d65ZsXCgNfuz8QMlhKDe5ck0l9aQZFPFJYG/ILhrVWxaRruPUHZ3TvXuJnf+G01EVoqIXPachiIcdXq5lDLbOYL7HElQUNI664ET6ddg3sIXhoc2aClBCiPS9hxlYGnAXcVO+R1VAXMp8ry02h6etgXCWT7+fbsv9KFvM7tnr/99u3N9CyEJPJdP7wiNImZnKWbatQKGHHKbe0L7W/DxMMuHuEzjTJqAMFeB9C7WffpVpP4BcH0GguAjvwHOIQNxkdfsHEfdBotNqNhtOut521ZtNtuc2veznnMpm2j6UJ9y01DJY3jzQ5rdca5D6pE5ZZTdDsXGb/vHi9kGVzToEb37AVy4mhm2dSdB9ME+VDc97fF/CV6xEtNiQecTvHylgfzNOkUyEt941Dflp7VOhsh3MnP6X+tPGZSnGgLtPk0pYYmAmWXOx5dxhzItnyEeyHxQGnLPgrS6drm+KdWeDPsmEsVDmT+Fpq45ENUuQ0FqOKUITHg7xPpFYRN2RlkPF7T3b2uk8ebfWKuq7v7D1u7+51N5+JUQ/kvUdbv2/ubUXpU6e7vntgN3e73Z1tkf+28fTX7M8/XOmPt1vbXRtsKOcX8vN9xCMe8dcF/zybuK4Dz+FV6Id/37y4SSPx7iVGIuIRj3jEIx7xiEf8MX4dOx/xiEc84q8e/j12PuIRj3jEIx7xiEf85ePh7w/s/1uGhzHH/kc84hGP+CuE//F2tP7EHb7iXBlGG+KvDZ6AoOKMwvJJBFwSKl8jOMv9qu7cJQ3nbrU0q9edgEdkJqySw0+HK2NWi4SiQkmhOIXTm6GAS54pl3AGG/D9jCm48VlcIKMBl2wcWQt3ySYn/M2R/2e9z1jrITeh1DnNDUuOXUt052Y6n7Wf10fMk2x2745buAYzKSPB8OBwbmE/TAtbWfis7Mzu6EyxEdEAkGXE/dSv1yCVRol/SjfNKoHwtJLjso5pAuFAkzpZbTUgwB6QVXet1mg2IW+1226t+mWaZP0KBCNptlZbpL5W7XwEQuIof+k+AAA=", "base64"))).toString("latin1")}, target:"browser", minify:false, sourcemap:"external", throw:false})'

Or as a synthetic distilled to the essentials (15 levels of ::part() split with a 10 KB custom property, 10 KB in, 334 MB out, 1.1 GB peak RSS on release main):

let css = "";
for (let i = 0; i < 15; i++) css += "a::part(x), b::part(y) {\n";
css += "--big: " + "x".repeat(10000) + ";\n" + "}\n".repeat(15);
await Bun.build({ entrypoints: ["./e.css"], files: { "./e.css": css }, target: "browser", throw: false });

No new PR opened; this one already covers it.

A known property name with a value that fails typed parsing (e.g.
box-shadow with an arbitrary ident) falls back to UnparsedProperty,
whose token list is charged via the Property::Unparsed arm of
clone_weight::property. Another fuzzer input (41 KB, 13 nested
two-selector rules around a ~40 KB unparsed box-shadow value;
signature oom:build:...Vec<TokenOrValue>...) reaches the same
clone path through this arm and peaks at 6.3 GB on main; under the
budget it reports the expected error.
@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another fuzzer report with the same root cause, signature oom:build:_RINvXNtC15bun_collections7vec_extINtNtC5alloc3vec3VecNtNtNtC7bun_css10properties6custom12TokenOrValueEINtB3_6 (Vec<TokenOrValue> growth under vec_ext) on 1.4.0-canary.1+55f6c899f.

The 41,169-byte minimized input nests 13 levels of [foo="bar"], .bar { (all unclosed) around a ~40 KB unparsed box-shadow: 0px 0px :only-childhadow … token list. On current main, Bun.build with the default browser targets peaks at 6.3 GB RSS over ~7 s; .a, .b nesting alone reproduces cleanly (2^depth copies of the inner token list).

Verified against this branch (merged onto current main, debug+ASAN): Bun.build reports selector_split_clone_limit_exceeded after ~1.2 s at ~617 MB peak RSS; minifyTest, _test, and prefixTest with {chrome: 80<<16} all throw the same error. All 52 existing tests in nested-selector-list-expansion.test.ts pass after the merge.

The payload reaches the budget through the Property::Unparsed arm of clone_weight::property (known property name + value that fails typed parsing), which the existing test.each matrix didn't exercise directly (every carrier was --p: custom or selector-based). Pushed 791f75b adding ["unparsed-property-value", "box-shadow: PAD;"] to the matrix; it returns a 34.6 MB output on an unfixed build and throws under this branch.

Repro:

bun -e 'await Bun.build({entrypoints:["./e.css"], files:{"./e.css": Buffer.from(Bun.gunzipSync(Buffer.from("H4sIAAAAAAACA+3d307CMBiH4XMS76GBEzB0mUs8GfHIyzAedGsHjWPFbshw4V68Fm9MB3KA0Sgx/kPfJluX7nt+Tb9dwILMOdEsYiHDWS3WVxS1N2/0qCO2I3G1LCdKu7bsZdVFm3DWTZTvXg5F0M6i+cG1RKVXmUqNvLGlTWxuq+XXb/tWo2JX5EuZTmyuN286z3oIgUAgEAgEAvnPZELHIH+a0DMIBPKJhFZBIJBdcthn4HNDIBAIBAKBQA6TBJU7d9rQKwgEAoFAIBAIBAKBQCAQCAQCgUAgEAgEAoFAIBAIBAKBQCAQCAQCgUAgEAgEAoFAIBAIBAKBQCAQCAQCgUAgEAgEAoFAIBAIBAKBQCAQCAQCgUAgEAgEAoFAIBAIBAKBQCAQCAQCgUAgEAjkl5NaeKNHHbEdrwXsVzVVdT9KrxfDTf3gCQSZc02i0ljbUiW50Vdj7+aFjuXU3UpvZkZVthhLr7RV+SYt9u2P0uXC28rsEfx+3odCjjo7MXK8nr9hC1NU/Si8v5vVw16WhcNeGGYPg9UmRDSi8qoobWVdIfXcq/VDLFKVp/1IHIv+SSmkOA2n5WAgVo/COKl50aAAAA==", "base64"))).toString("latin1")}, target:"browser", minify:false, sourcemap:"external", throw:false})'

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another fuzzer finding lands here. Signature oom:build:_RNvC7___rustc12___rust_alloc|_RINvXNtC15bun_collections7vec_extINtNtC5alloc3vec3VecNtNtNtC7bun_css10propertie... on 1.4.0-canary.1+55f6c899f; still reproduces on main.

Repro (the blob posted to the fuzz channel had one flipped byte; corrected here, CRC-verified against the gzip footer):

await Bun.build({
  entrypoints: ["./e.css"],
  files: {
    "./e.css": Buffer.from(
      Bun.gunzipSync(
        Buffer.from(
          "H4sIAAAAAAAC/+2azW4bNxDHe20Av0BPBIoAkmE6u1p9xCskh9gWoDY1bCdOUSAXand2xYRLLriUV7JgwG/QV+ixfYoAfZQ8Q+4JVyv5K1Ecx1KDKrNzoDHzH/7oIUXywMfkMdr/zuaHXt65h2WmLssEzwzNzEgA5QmL4WPJ7fpbwyV2bmv3sAZo1+z9fwhLVcYNVxL3+mUJIgFDGmuV322kkZLFLqzBBP1rsX//wh/NnW2hp9Kx4gHQiCVcjFb37PpCYQwSNA/opCj2OgGRyZkOM0KI8v2UaVPpAwtBVzfIZqQKXwDSgC4iPRWOqmR8b76Wfez5srwL1bs/37xZQWXhJuPJzuETqXTCxKzpyohLbkYZGNoTKnhttxab7hM6i6TDtu3Afx+pYPBjptS4p7Sl0ZyHpu83gHq1OnGImw5PP6NzH9brzVa97rS8lrPVaLhNt3FzlnMryeSfZFkKgaGa2cPMJ9PhPSAeYQOjCMpWU7aYBbR6kqJ8dqMoP5pD7zW3v3M1pMVlxCdu+yKYqJN5kWzipOUV8RiuRqedzm43P+RXwueXnokXJ2qZkqUdMm/P/llwt7e9Y8xZyQaG9i7MT4Cy8NUgm5xuEtqzhT9rjhMuy50kUEJpn2yToqUJH1a4JJD08x4RSsagyVp/ABv7B0ed/ae7e8VYnxwc/dY6POrsPOfDPevfe7r7YudoN86eOZ0nhydm57DTOejy/NftZ78M/vjdFcGo2+x2TLgtnUdk/QHiEY94xCMe8d8Kv75g/Bir/83xPpchGNCWxQxgyZeFhyQ1IxqAEBmuPcQjHvGIRzziEb/K+JTpDLrSfNUwsLqIRzziEY/4ReJfDsau07HfKU7DkvBMVnDdIR7xiEc84hGPeMQjHvGI/37wWOXvD0+so+IMo/JLuQn65Z8xeXv2t+fcJ3XnfrWUeZ4TQkxmzio5PX+amrBazCXlUnAJVPl+xAXQDAQExuJ7AyZ5UjxklzQEwUaxMUpeetqaMD3N/2T2NbU6Bh0JldNcs/QitUS3b0i+rp8HY75gmaFBn4sQjAE9LkuqITyd29lPk87WZk/4r7xYJtMny+1LdZsKNI/7FlVO2c89r2atfE2eBldik6YScl9JMSrHlqW2njT1yGazbmfoIdl0t2r1RsO2zVbLrVXbn6UJ1qvY2SSN5maTeFvV9gchPg7utj8AAA==",
          "base64",
        ),
      ),
    ).toString("latin1"),
  },
  target: "browser",
  minify: true,
  sourcemap: "external",
  throw: false,
});

16,310 bytes in, 214 MB out, 3.4 GB RSS on main (release). The shape is ~12 levels of nested o::part(header), .foo::part(body) (pseudo-element, so splits instead of :is()-wrapping) around a .vminfoo rule whose color: value is an ~11 KB unparsed token list (unclosed paren swallows the rest of the input). Each split deep-clones that subtree.

Built this branch merged onto main: the clone-weight budget catches it on every pass (minifyTest/prefixTest/_test with chrome80/safari10/etc., and Bun.build with target: "browser"), reporting the "duplicates too much CSS" error instead. Peak RSS on the Bun.build path drops to ~170 MB on a debug+ASAN build.

A minimal synthetic that hits the same path, in case it's useful for the test matrix:

const pad = Buffer.alloc(10000, "x").toString();
let css = "";
for (let i = 0; i < 14; i++) css += "o::part(h), .foo::part(b) {\n";
css += ".inner { color: unk(" + pad + "); }\n";
for (let i = 0; i < 14; i++) css += "}\n";
// main: 83 MB output; this branch: CLONE_LIMIT_ERROR

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

No new issues found — all four rounds of weight-walk gaps I flagged earlier are resolved and covered by the test matrix. Deferring to a human for sign-off given the size of the new clone_weight AST walk, the documented flat-charge exclusions, and the pending rebase interaction with #31916.

Extended reasoning...

Overview

This PR adds a ~400-line clone_weight module in src/css/rules/mod.rs that walks the CSS AST to estimate the byte-weight of rule subtrees cloned during the incompatible-selector split in minify_style_arm, charging it against a new 64 MB per-stylesheet budget (MAX_SELECTOR_SPLIT_CLONE_WEIGHT). It also adds a new MinifyErrorKind variant, a split_clone_weight_total field on MinifyContext, widens Printer::indent_amt from u8 to u32 to fix a debug-build overflow panic at 128+ nesting levels, and adds ~200 lines of regression tests including a 19-case parameterized matrix and an embedded fuzzer input.

Security risks

The change is itself a DoS hardening: it bounds an exponential clone-amplification path where ~450 KB of adversarial CSS could produce 1+ GB of output and multi-GB RSS through bun build --minify. The fix is purely additive (a pre-clone budget check that returns a catchable error) and uses saturating arithmetic throughout, so no new overflow or injection surface is introduced. The u8u32 widening is a strict relaxation. No auth, crypto, or filesystem paths are touched.

Level of scrutiny

This warrants human review. The clone_weight walk has to enumerate every AST variant that carries input-sized borrowed text — and over four prior review rounds I found bypasses (dimension units, dashed idents, custom-property names, selector text, view-transition/cue/local wrapped selectors, nested at-rule payloads, attribute namespace prefixes), each of which restored the multi-GB amplification. All were fixed and are now in the test matrix, and the author re-audited every reachable struct field after round 4. But the walk's correctness depends on completeness, the 64 MB threshold and the documented flat-charge exclusions (url() import-record text, CSS-modules locals, parsed leaf values) are design choices, and the change sits in the core minifier hot path.

Other factors

  • Test coverage is strong: 52 tests in the file, 22 of which fail on an unfixed build per the author's verification; byte-for-byte output stability below the budget is asserted.
  • All prior review threads (mine and CodeRabbit's) are resolved.
  • robobun has since logged four more fuzzer signatures (today, 2026-06-17) that this branch already fixes without further changes; the latest commit just adds a Property::Unparsed case to the test matrix.
  • There is a known small rebase needed against #31916 (touches adjacent lines in error.rs / rules/mod.rs / css_parser.rs); whoever merges should be aware.
  • CI failures noted in the status summary are an unrelated network-dependent bunx.test.ts flake; CSS suites pass on every lane that ran them.

…yloads

Another fuzz input for the same bug class: ~12 levels of two-selector
::part()-bearing nested rules whose deepest declaration value is an
unclosed function token swallowing several KB of trailing text. For a
chrome 80 target every nested level splits (no :is() support), cloning
the payload-bearing subtree at each level; before the clone-weight
budget this produced ~155 MB of output and ~2.3 GB of RSS (the lab()
@supports fallback is cloned along with the subtree and accounts for
roughly half of that).
@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed 3bd202f with another fuzzer reproduction for the same bug class (signature oom:css:...write_all...|_RNvMs3_NtC7bun_css7prin on 1.4.0-canary.1+55f6c899f).

The 16.5 KB input is ~12 levels of two-selector ::part()-bearing nested rules whose deepest declaration value is an unclosed olor-mix( function token that swallows several KB of trailing text, including a border-right-color: lab(...). For a chrome 80 target every nested level has & (incompatible since chrome 80 lacks :is()), so each level splits into per-selector clones of the payload-bearing subtree.

On main this produces ~155 MB of output and ~2.3 GB of RSS (the @supports (color: lab(...)) fallback staged by the lab() declaration is deep-cloned along with the subtree and accounts for roughly half of that). With the clone-weight budget in this PR both _test and prefixTest report selector_split_clone_limit_exceeded instead, at ~230 MB RSS under debug+ASAN.

The new test OOMs the process on an unfixed build (same shape as the existing minimized-fuzzer test in this file).

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another fuzzer OOM in this class surfaced today, signature oom:build:_RNvC7___rustc12___rust_alloc|_RINvXNtC15bun_collections7vec_extINtNtC5alloc3vec3VecNtNtNtC7bun_css10propertie (i.e. Vec<bun_css::properties::custom::TokenOrValue> allocation failing) on 1.4.0-canary.1+55f6c899f.

The 15 KB minimized input is twelve o::part(header), .foo::part(body) { nesting levels wrapped around a color: declaration whose unclosed olor-mix( function token swallows the rest of the file (~10 KB) into a single unparsed token list. ~6k selector combinations (under the 65,536 cap), each deep-cloning that token list: 4.2 GB peak RSS / 208 MB output in release.

Verified this branch catches it: the build fails with the split-clone limit error at ~628 MB RSS (debug) instead of OOMing.

I had a narrower variant that charges declaration bytes at charge_selector_expansion time (so it bails during the top-down recursion before any subtree is cloned, ~350 MB RSS on the same input) on farm/33c876bf/css-nesting-declaration-byte-limit, but this PR's post-minify walk covers selectors and at-rule conditions too, so deferring to this one.

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another fuzzer signature in the same family, filed today:

oom:build:bmalloc_heap_config_specialized_local_allocator_try_allocate_small_segregated_slow|bmalloc_allocate_impl_casua

Minimized trigger is ~12 levels of o::part(header), .foo::part(body) { over an inner rule whose unparsed color: value swallowed ~70 lines of input via an unclosed function (a few thousand TokenOrValue entries). The selector count stays at ~27k (under MAX_SELECTOR_EXPANSION) while the cloned token vectors reach 3+ GB RSS on release:

const c = require("bun:internal-for-testing").cssInternals;
const blob = Buffer.alloc(8192 * 2, "x ").toString();
const src = "a::part(x), .b::part(y) {\n".repeat(12)
  + ".leaf { color: f(" + blob + ") }";
c.minifyTest(src, "", { chrome: 80 << 16 });  // ~3.2 GB RSS

This PR's clone_weight::token_list walk (recursing into Function.arguments) already covers it; verified the mechanism by reading the diff. I pushed a narrower token-count-only variant to farm/71941bcc/css-nesting-expansion-token-cap in case a smaller change is preferred, but this PR supersedes it.

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another fuzzer signature covered by this fix: oom:css:clock_gettime@@GLIBC_2.17|_mi_arenas_collect(bool, bool, mi_tld_s*)|mi_page_queue_find_free_ex on 1.4.0-canary.1+55f6c899f.

The 15.5 KB minimized input is ~10 levels of two-selector ::part() nesting over a declaration whose unparsed value is ~11 KB of nested fn(... { ... tokens (79 levels of function args interleaved with curly blocks). With Safari 13.2 targets it allocates ~2.4 GB and produces 141 MB of output; ulimit -v turns that into the OOM stack above.

I independently landed on the same approach on farm/94b11f9f/css-nesting-clone-byte-limit (a lighter node-count budget rather than byte weight) before finding this PR; the test cases there cover the nested-function-token shape if useful. Not opening a separate PR since this one's weight walk already recurses into Function/Var/Env token lists and so catches the same input.

robobun added a commit that referenced this pull request Jun 17, 2026
When the configured browser targets don't support :is(), minify_style_arm
splits a multi-selector rule into one cloned rule per incompatible
selector, each carrying a deep_clone of the nested subtree. Minify runs
bottom-up, so nesting such rules roughly doubles the cloned subtree at
every level. The existing MAX_SELECTOR_EXPANSION (65536) bounds how many
rules this produces at print time but not how many CssRule structs sit in
the heap while minify is running, so a ~9.5 KB fuzzer input that charges
only 61436 selectors still drove peak memory past ~260 MB.

Add MAX_PARTITION_CLONE_RULES (16384) and charge each split's subtree rule
count against it before cloning. Reuses the existing
selector_expansion_limit_exceeded error kind since the remedy is the same.

(Superseded by #31913, which weights clones by bytes instead of rule
count; kept for comparison.)
@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another fuzzer signature in this family on 1.4.0-canary.1+55f6c899f:

oom:css:_RNvXs1_NtNtC8bun_core4util2ioINtNtC5alloc3vec3VechENtB5_5Write9write_allC11bun_css_jsc|_RINvNtNtC7bun_css10css_

The 9.5 KB minimized input is ~10 levels of .foo:placeholder-shown .bar, .foo:-webkit-autofill .baz { (partitioned for Safari 13.2, which lacks :is()), wrapping ~40 single-selector nested rules with a few more two-selector levels at the bottom. Its selector_expansion_total charges 61,436 (under the 65,536 cap), but the partition clones drive RSS past ~260 MB in release.

Verified this PR's split-clone weight budget catches it: _test and prefixTest with { safari: (13<<16)|(2<<8) } both report selector_split_clone_limit_exceeded. The embedded fuzzer blob and a reduced synthetic shape are on branch farm/fde0c1a3/css-minify-arena-cap (which uses a simpler rule-count cap; superseded by the byte-weight approach here).

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another fuzz signature this PR covers: oom:css:_RNvMs4_NtC5alloc7raw_vecNtB5_11RawVecInner11finish_growC11bun_css_jsc (on 1.4.0-canary.1+55f6c899f).

The minimized 16 KB input is ~14 levels of a::part(x), b::part(y) { ... } around a leaf rule whose color: value is a ~10 KB unparsed token list. 2^14 = 16384 copies (under the 65536 selector cap) × ~1000 tokens × ~96 B each is ~1.5 GB of cloned Vec<TokenOrValue>, 2.4 GB peak RSS and 153 MB of output on a release build with no error. A smaller 3.4 KB synthetic input with the same shape OOMs under a 4 GB ulimit:

const { cssInternals } = require("bun:internal-for-testing");
let css = ".leaf { color: " + Array.from({length: 1000}, (_, i) => "a" + (i % 10)).join(" ") + "; }";
for (let i = 0; i < 14; i++) css = "a::part(x), b::part(y) { " + css + " }";
cssInternals._test(css, "", { safari: (13 << 16) | (2 << 8) });

This PR's clone_weight::decl_block walk (token-list count + text) catches it. I had put together a narrower variant that only charges the token-list weight in charge_selector_expansion at farm/4cafcfc5/css-declaration-expansion-cap before finding this PR; that branch also has a small spawned-subprocess regression test for this specific shape in nested-selector-list-expansion.test.ts that may be worth folding in.

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Also fixes a newer fuzzer OOM with signature oom:build:_RNvC7___rustc12___rust_alloc|_RINvXNtC15bun_collections7vec_extINtNtC5alloc3vec3VecNtNtNtC7bun_css10propertie (Bun 1.4.0-canary.1+55f6c899f). The 82 KB minimized input is a different reduction of the same ::part()-nested + large unclosed-function TokenList shape already covered by the test added in 3bd202f; verified this branch handles it cleanly (Splitting nested CSS rules ... duplicates too much CSS, ~635 MB RSS under ASAN vs. >16 GB and abort on main).

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Another fuzzer report with the same root cause, signature hang:css:_RNvNtC7bun_css10css_parser15dtoa_short_impl|_RNvMNtNtC7bun_css6values6numberNtB2_12CSSNumberFns6to_css|_RNvNtN on 1.4.0-canary.1+55f6c899f (the printer spinning inside CSSNumberFns::to_css while serializing the expanded output).

Repro (50 KB input -> 825 MB bun build --minify output on main, ~145 MB via the internals test path from the fuzzer input):

let css = '';
for (let i = 0; i < 14; i++) css += 'o::part(a), .f::part(b) {\n';
css += '.leaf { border-right-color: lab(40% 56.6 39); --big: ' + 'x'.repeat(50000) + '; }';
// bun build this.css --minify  -> 825 MB

14 levels of two-selector ::part() nesting = 8,192 leaf copies (well under the 65,536 selector cap) x a ~50 KB single-ident custom property value = ~400 MB; the lab() color fallback adds an @supports rule per copy, doubling it. This PR's clone_weight::token_text_len covers the single-long-ident case (which #32453's token-count approach misses, since one 50 KB ident is one token).

I pushed a simpler alternative on farm/238b8c68/css-nesting-bytes-cap that bounds it at print time instead: accumulates the bytes emitted under each outermost with_context call into Printer::nesting_compiled_bytes and errors at 64 MB (matching MAX_PREFIX_EXPANSION_BYTES), checked per-rule so it bails as soon as the threshold is crossed. ~40 lines across 3 files vs the clone_weight walk here; trades off catching the blowup before the arena allocates the clones. Either approach fixes this signature; leaving the choice to the reviewer.

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