Skip to content

bundler: borrow CSS @import conditions from the parsed stylesheet - #46176

Closed
robobun wants to merge 2 commits into
mainfrom
robobun/bcb81c9b/css-import-condition-leak
Closed

robobun wants to merge 2 commits into
mainfrom
robobun/bcb81c9b/css-import-condition-leak

Conversation

@robobun

@robobun robobun commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • On the ASAN CI lane, bun build aborts when an @import condition owns heap memory: Direct leak of 144 byte(s) in 2 object(s) in <TokenList as DeepClone>::deep_clone for @import "./b.css" (min-width: env(foo, 1px));.
  • A small condition with env(), calc() or a dotted layer name leaks 16 to 704 bytes per build. No user reported it.
  • The CSS linker deep-cloned each condition into storage that never runs Drop (findImportedFilesInCSSOrder.rs:187, prepareCssAstsForChunk.rs:542).

Fix

  • CssImportOrder.conditions now lists references to the new conditions field of each parsed ImportRule. The linker clones no condition.
  • The @supports and @media wrappers take a bitwise copy, as the @layer wrapper did. The parsed stylesheet stays the only owner.
  • Verified: test/bundler/esbuild/css.test.ts (10 new) and test/bake/dev/css.test.ts (1) fail on an ASAN build of main and pass here. Also ran test/bundler/css/*.test.ts, bundler_html.test.ts and test/js/bun/css/css.test.ts.
  • Self-reviewed: 22 concerns raised, 20 addressed, 2 in part (see Notes).

Background

  • An import condition is the layer(), supports() and media query of an @import. The bundler wraps inlined rules in them.
  • Each file of a CSS chunk has one CssImportOrder entry. Fix effectively every native-code memory leak in Bun #30875 put its condition list in the linker arena, which runs no destructor.
  • Considered bitwise copies in the existing Vec<ImportConditions>, a registry of clones, and arena-backed value types. Borrowing removes the clones and the Drop that forgets the list.

Downsides

Notes

Reproduce

echo '.b1 { color: green }' > b.css
printf '@import "./b.css" (min-width: env(foo, 1px));\n.c0 { color: blue }\n' > entry.css
ASAN_OPTIONS=detect_leaks=1 bun-debug build ./entry.css --outdir out

On main the report has two allocations of 72 bytes. One is in Visitor::visit, through ImportRule::conditions_with_import_records (findImportedFilesInCSSOrder.rs:187). The other is in wrap_rules_with_conditions, through MediaList::clone_with_import_records (prepareCssAstsForChunk.rs:542). The CI lane keeps 30 stack frames. At that depth the two stacks are equal, and LeakSanitizer prints one entry of 144 bytes.

Cause

  • Visitor::visit made a Vec<ImportConditions> for each conditional import. It cloned the wrapping conditions into a slab in the LinkerGraph arena (deep_clone_conditions, findImportedFilesInCSSOrder.rs:747), then cloned the conditions of the rule.
  • Order entries shared that slab bitwise, so CssImportOrder::drop forgot the list. No code ran Drop for a cloned ImportConditions.
  • wrap_rules_with_conditions cloned supports and media again into wrapper rules. Those rules are in an arena rule list, and CssChunk::drop frees that list with set_len(0).
  • The leaked values are on the global heap: the indices and the fallback of an EnvironmentVariable, the Box of a Calc, and the segments of a LayerName with more than one segment.

Why the borrow is sound

  • A parsed stylesheet stays in the AST table until deinit_without_freeing_arena (src/bundler/bundle_v2.rs:5541). The chunks and their order lists are dropped before that. The dev server drops the chunks in finalize_bundle (src/runtime/bake/dev_server/finalize.rs:215), then calls finalize_bundle_cleanup.
  • The list of references is in the LinkerGraph arena, which lives as long as the AST table.
  • RawSlice and BackRef are Copy and have no destructor. The linker can copy an order entry bitwise, truncate its list, or discard it. Nothing is freed twice and nothing leaks.
  • A reference points at the conditions field of an ImportRule in the rules buffer of a parsed stylesheet. After the parse, the linker and bundle_v2.rs read that buffer through shared references. The only mutable access found is the teardown drop.
  • The chunk stylesheets were already bitwise views of the parsed rules, under the same lifetime rule.

What the diff removes

  • deep_clone_conditions, shallow_clone_records, ImportRule::conditions_with_import_records, and the clone_with_import_records functions of LayerName, SupportsCondition and MediaList.
  • impl Drop for CssImportOrder.
  • ImportRule::conditions_mut, the repr(C) attributes and the layout assertions that made its cast valid, and the second copy of the condition printer in ImportRule::to_css.
  • The wrapping_import_records parameter of Visitor::visit. Nothing wrote to that list, so it was always empty.
  • unsafe tokens in the 8 changed source files: 81 to 75.

Tests

  • css/CSSAtImportConditionsLeakCheck{Env,Calc,External,Nested}: a spawned bun build with leak checking on, and the exact output. On a debug build of main each fails with a LeakSanitizer summary: 704, 2,112, 256 and 744 bytes.
  • The same four fixtures with the API suffix run Bun.build in the test process and check the output. On main under the CI lane settings, that process aborts at exit with 3816 byte(s) leaked in 107 allocation(s).
  • css/CSSAtImportConditionsLeakCheckTwoChunks: three entry points give two CSS chunks that wrap one stylesheet in the same condition. Main leaks 360 bytes.
  • css/CSSAtImportConditionsLeakCheckRSS runs only on builds without ASAN. It calls Bun.build 60 times on a condition with a long env() fallback. The bound is 100 MiB. The released canary (367d939d9) grows 367 MiB. A release build of this branch grows 3.7 to 8.1 MiB over 6 runs.
  • test/bake/dev/css.test.ts, nested conditional css imports across hot reloads: it edits the outer condition and then the leaf, and checks the served CSS each time. On an ASAN build it turns leak checking on for the dev server process. On main that process exits with 2016 byte(s) leaked in 51 allocation(s), and the test fails with DevServer panicked.
  • On a debug build with ASAN, with and without the CI lane settings: css.test.ts 65 pass and 1 skip, dev/css.test.ts 16 pass. On a release build: 66 pass and 16 pass. Also on the debug build: test/bundler/css/*.test.ts (25 pass), bundler_html.test.ts (24), test/js/bun/css/css.test.ts (1,314) and css-loader.test.ts (2).

Measured

Main ea6a2c7b7c and this branch, debug builds with ASAN:

  • LeakSanitizer, 12 single-import inputs: 8 leak on main (16 to 704 bytes per build), none leaks here. Three do not leak on main: (min-width: 100px), supports(display: grid), and layer(a.b) with no conditional import below it. The last input is the url() case in Downsides.
  • Output trees that differ between main and this branch: 0 of 11 (diff -r).
  • MediaList::deep_clone calls in one build (gdb breakpoint hit counts): one conditional import 2 to 0, a nested pair 6 to 0, an unconditional import 0 to 0.
  • __rust_alloc calls in one build of 100 imports: no condition 14,708 to 14,708, (min-width: Npx) 14,808 to 14,808, env(a, Npx) 15,208 to 15,008, calc(Npx + 1em) 16,990 to 15,802.
  • <CssImportOrder as Drop>::drop calls in the build with no condition: 101 to 0. The symbol is gone.

The earlier base fd539ca7c0, with and without this diff. The files of the diff are equal on both bases, apart from one unrelated hunk in css_parser.rs.

  • Release .text: 81,314,645 to 81,312,085 bytes, 2,560 bytes smaller (llvm-size). .rodata, .data and .bss are equal. bloaty is not installed.
  • size_of::<CssImportOrder>(): 104 to 96 bytes. One element of a condition list: 104 bytes (ImportConditions) to 8 bytes (BackRef).
  • Release RSS growth over 300 Bun.build calls with a 2,000-token env() fallback: main 173 to 176 MB, this diff 4 to 6 MB. A control with no condition grows 2 to 9 MB.
  • User instructions for one release bun build of 2,000 unconditional imports, 12 interleaved pairs: main median 127,524,727 (127,141,172 to 128,844,651), this diff median 127,576,112 (127,355,329 to 128,146,248). The medians differ by 0.04%, inside the spread of each build. perf is not installed. A small C program read the counter with perf_event_open.
  • A release build with ASAN under the CI lane settings: css.test.ts 65 pass and 1 skip, dev/css.test.ts 16 pass.
  • cargo check --workspace passed for 9 of the 12 CI targets: linux gnu and musl, android, darwin, and x64 windows. The other 3 did not run.

Self-review

The review raised 22 concerns. 20 are addressed in the diff, the tests or this text. Two are addressed in part:

  • It asked for an external @import in the dev server test. The page of that test loads in a client, and an external import would make the client contact the network. The spawned builds cover external imports.
  • It asked for the rebase cost for other open PRs under Downsides. That is a process cost, so it is in the next section.

Open PRs that touch the same lines

Conflict regions in the files of this PR when each head is merged with this branch (git merge-tree), next to the count for main alone:

The CSS linker cloned each @import condition into arena storage that
runs no destructor, and cloned it again for the @supports and @media
wrapper rules of a chunk. A condition can own heap memory: the fallback
or the indices of env(), a calc() tree, a layer name with more than one
segment. Each clone leaked that memory. LeakSanitizer reports it for
`@import "./b.css" (min-width: env(foo, 1px));`.

CssImportOrder.conditions is now a list of references to the conditions
of each parsed ImportRule. The list is in the LinkerGraph arena and is
Copy, so CssImportOrder needs no Drop impl. The wrapper rules take a
bitwise copy, as the @layer wrapper already did. The parsed stylesheet
stays the only owner and frees the memory once, at bundle teardown.

ImportRule now has a conditions field. The accessor that cast between
ImportRule and ImportConditions, the repr(C) attributes and the layout
assertions are gone. Also removed: deep_clone_conditions,
ImportRule::conditions_with_import_records, the clone_with_import_records
functions of LayerName, SupportsCondition and MediaList, and the
import-records list that Visitor::visit passed down and nothing wrote.

Tests: spawned builds with leak checking on, the same fixtures through
Bun.build, two CSS chunks that share a stylesheet, and an RSS check for
builds without ASAN.
The dev server links the import chain again on every rebuild. The new
case has a conditional import under a conditional import, edits the
condition of the outer file and then the leaf, and asserts the served
CSS at each step.

On an ASAN build the dev server process runs with leak checking on, and
with BUN_DESTRUCT_VM_ON_EXIT so that memory the VM still owns is not
reported. Without the fix it exits with a LeakSanitizer report for the
cloned conditions.
@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Closed: #46021 fixed this leak on main.

How the leak was reproduced, on main at ea6a2c7b7c with a debug build with ASAN:

echo '.b1 { color: green }' > b.css
printf '@import "./b.css" (min-width: env(foo, 1px));\n.c0 { color: blue }\n' > entry.css
ASAN_OPTIONS=detect_leaks=1 bun-debug build ./entry.css --outdir out

LeakSanitizer reported 144 byte(s) leaked in 2 allocation(s) in <TokenList as DeepClone>::deep_clone. On main at 963452075e the same command exits 0 with no report, and the tests of this PR pass without its source change.

PR: #46176 (closed). Fix on main: #46021.

@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:05 AM PT - Oct 20th, 2026

❌ @robobun, your commit 6bf4d6f has 1 failures in Build #129414 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46176

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

bun-46176 --bun

@coderabbitai

coderabbitai Bot commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: cd2079a5-af69-462a-a387-291e52c0560b
📥 Commits

Reviewing files that changed from the base of the PR and between ea6a2c7 and 6bf4d6f.

📒 Files selected for processing: 10
  • src/bundler/Chunk.rs
  • src/bundler/linker_context/findImportedFilesInCSSOrder.rs
  • src/bundler/linker_context/prepareCssAstsForChunk.rs
  • src/css/css_parser.rs
  • src/css/media_query.rs
  • src/css/rules/import.rs
  • src/css/rules/layer.rs
  • src/css/rules/supports.rs
  • test/bake/dev/css.test.ts
  • test/bundler/esbuild/css.test.ts
💤 Files with no reviewable changes: 2
  • src/css/rules/supports.rs
  • src/css/media_query.rs

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Walkthrough

CSS import conditions are consolidated in ImportConditions. CSS import ordering and chunk preparation now use arena-backed condition references instead of cloned condition and import-record vectors. Tests cover conditional imports, rebuilds, CSS chunks, and memory growth.

Changes

CSS import condition handling

Layer / File(s) Summary
Condition contract and parser
src/css/rules/import.rs, src/css/css_parser.rs, src/css/rules/{layer.rs,supports.rs}, src/css/media_query.rs, src/bundler/Chunk.rs
ImportRule stores layer, supports, and media in ImportConditions. Parsing and serialization use this aggregate. The changes also remove import-record cloning helpers and use arena-backed condition slices for CSS import order.
Arena-backed CSS import ordering
src/bundler/linker_context/findImportedFilesInCSSOrder.rs
CSS import traversal builds and compares borrowed condition slices. Import-order entries store RawSlice views, and condition trimming and debug iteration use those slices.
Chunk wrapping and regression coverage
src/bundler/linker_context/prepareCssAstsForChunk.rs, test/bake/dev/css.test.ts, test/bundler/esbuild/css.test.ts
Chunk preparation reads BackRef conditions and shallow-copies condition data into wrappers. Tests cover nested imports, rebuilds, CLI and API output, code splitting, and RSS growth.

Suggested reviewers: jarred-sumner, dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 6bf4d

This change stops CSS @import conditions from leaking memory by reusing the parsed condition data instead of copying it. Output is expected to stay the same, and new tests cover nested conditions, rebuilds, code splitting, and memory growth. No outstanding issues remain, so the change appears ready to merge once normal CI passes.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: borrowing CSS @import conditions from parsed stylesheets.
Description check Passed The description thoroughly explains the problem, fix, rationale, risks, and verification results. It does not use the exact template headings, but it provides all required information and is complete.
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.
✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked the two soundness questions the borrow raises and ruled them out: the BackRef<ImportConditions> lists are only read while the parsed stylesheets are alive (chunks drop inside the build closure before deinit_without_freeing_arena, and the dev server drops chunks in finalize_bundle before finalize_bundle_cleanup), and the bitwise-copied @ media/@ supports wrapper rules go into arena rule slabs that CssChunk::drop empties with set_len(0), so no second Drop runs on the aliased heap.

Extended reasoning...

The change rewires the CSS linker (findImportedFilesInCSSOrder.rs, prepareCssAstsForChunk.rs, Chunk.rs) and the css crate's ImportRule to store non-owning BackRef pointers into parsed stylesheets instead of deep clones in arena slabs, removing a Drop impl and several clone helpers; it touches no security-sensitive surface. It is unsafe-Rust lifetime work with one confirmed inline finding, so a human review is warranted; the ruled-out items above were verified against bundle_v2.rs and dev_server/finalize.rs ordering.

Comment thread src/bundler/linker_context/prepareCssAstsForChunk.rs
@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #46021 fixed this leak on main.

#46021 replaces the deep clones of the @import conditions with bitwise copies (fork_conditions, ImportRule::conditions()). The @supports and @media wrappers take a ptr::read copy. The parsed stylesheet stays the only owner, as in this PR.

I checked main at 963452075e with a debug build with ASAN:

  • @import "./b.css" (min-width: env(foo, 1px)); builds with no LeakSanitizer report. The calc(), env() index, external, nested and dotted layer inputs of this PR also build with no report.
  • The tests of this PR pass on main without its source change. test/bundler/esbuild/css.test.ts: 66 pass, with and without the ASAN lane options. test/bake/dev/css.test.ts: 17 pass.
  • @import "./b.css" (min-width: env(foo, url(sub/g.css))); no longer panics.

What this PR still adds to main is a refactor and tests, not a fix:

  • a borrowed list (RawSlice<BackRef<ImportConditions>>) in place of the aliased Vec<ImportConditions> and the Drop that forgets it
  • a conditions field on ImportRule in place of the layout cast
  • the leak tests above

The branch stays. Reopen this PR if a maintainer wants the refactor or the tests.

@robobun robobun closed this Oct 21, 2026
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