Skip to content

perf: defer link-stage-output drop to rayon workers after output is produced#9733

Merged
graphite-app[bot] merged 1 commit into
mainfrom
perf/deferred-graph-drop
Jun 14, 2026
Merged

perf: defer link-stage-output drop to rayon workers after output is produced#9733
graphite-app[bot] merged 1 commit into
mainfrom
perf/deferred-graph-drop

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Jun 13, 2026

Copy link
Copy Markdown
Member

Summary

Once the bundle output exists, the hot thread still spends ~15ms (on a 20k-module build) inside free() tearing down the link stage output (module_table, metas, stmt_infos, ...). The rayon workers are idle at that point, so this PR ships that drop to one of them (utils/defer_drop.rs):

  • symbol_db is mem::take'n out for the cache merge exactly as main consumes it (inline, unchanged on both incremental and non-incremental paths).
  • The remaining link_stage_output is spawn_drop'd right after generate() returns instead of being freed at bundle_up scope end.

Only this one object is deferred, governed by one rule (documented in the module): only values main itself kept alive through the overlapped region are eligible. main held link_stage_output through the render_error/generateBundle hooks and the write tail anyway — here it is freed concurrently during them, so no memory window ever extends (peak RSS measured flat).

Deferred drops cannot pile up — this is enforced, not assumed: every pending drop is counted, and defer_drop::drain() blocks until the count is zero at every entry point that starts rayon work (BundleFactory::build_bundle plus the three HMR partial-scan entries). In steady state it is a single uncontended lock check.

Numbers

apps/10000 (20,014 modules), full build incl. minify + sourcemap, binary A/B vs main, order-balanced ABAB pairs, first run per binary discarded, 1.3x-median scan filter:

pair base build_ms this PR delta
1 203.1 194.7 −8.4
2 199.4 195.6 −3.8
3 209.0 194.0 −15.1
4 193.4 183.4 −9.9
5 200.3 184.4 −15.9
6 205.2 190.7 −14.5
7 193.6 185.1 −8.5
8 215.7 188.5 −27.2

Median pair delta −12.2 ms (~−6% of build), 8/8 pairs negative. Output is byte-identical (main.js / main.js.map sha256 match the unmodified baseline). Peak RSS flat (/usr/bin/time -l, interleaved runs).

What was deliberately NOT deferred

Adversarial review (4 rounds) shrank this change to what's provably safe:

  • ast_table (per-module AST arenas): main frees these before chunk instantiation/minify allocate; deferring them would overlap the full arena graph with allocation-heavy phases and risk a peak-RSS spike. Kept inline.
  • non-incremental symbol_db: main frees it inline before the output hooks; deferring it would extend its lifetime across unbounded plugin hooks. Kept inline (this costs ~5ms of the potential win and is the right trade).

Notes

  • The free still happens — on otherwise-idle workers, overlapping the hook/write tail. Real work is moved off the critical path, not eliminated; in-process memory is released a few ms later within the same build span.
  • The pending counter is process-global on purpose: the dropped value is exclusively owned, so the worst cross-instance effect is a bounded (~15ms) wait or background CPU, never correctness.

🤖 Generated with Claude Code


Note

Medium Risk
Touches build lifecycle and the shared rayon pool with global synchronization; correctness relies on documented invariants (one deferred object per build, drain at entry), though output is intended to stay byte-identical.

Overview
Moves teardown of heavy link stage output (module_table, metas, stmt infos, etc.) off the main thread after GenerateStage::generate() returns, so ~15ms of free() can overlap plugin hooks and the write tail on idle rayon workers.

Adds utils/defer_drop: spawn_drop enqueues exclusive drops on rayon::spawn, with a process-global pending counter and drain() so deferred work cannot pile up across builds. symbol_db is still taken with mem::take and merged into the cache inline before deferral (unchanged lifecycle for cache integrity).

defer_drop::drain() runs at every rayon-work entry: BundleFactory::build_bundle and the three HMR/lazy-compile paths in impl_bundler_hmr.rs that skip build_bundle.

Reviewed by Cursor Bugbot for commit d9f9569. Bugbot is set up for automated code reviews on this repo. Configure here.

@netlify

netlify Bot commented Jun 13, 2026

Copy link
Copy Markdown

Deploy Preview for rolldown-rs ready!

Name Link
🔨 Latest commit 7bcf23d
🔍 Latest deploy log https://app.netlify.com/projects/rolldown-rs/deploys/6a2e264949864d0008f34887
😎 Deploy Preview https://deploy-preview-9733--rolldown-rs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@Brooooooklyn Brooooooklyn force-pushed the perf/deferred-graph-drop branch from d1bc703 to d9f9569 Compare June 13, 2026 01:56
@codspeed-hq

codspeed-hq Bot commented Jun 13, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 4 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing perf/deferred-graph-drop (7bcf23d) with main (0f0e287)2

Open in CodSpeed

Footnotes

  1. 10 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (7bcf23d) during the generation of this report, so 0f0e287 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

hyf0 commented Jun 13, 2026

Copy link
Copy Markdown
Member

I think this optimization is useful. My concern is more about how we keep it solid going forward:

  • It feels a bit hard-coded to this specific lifecycle point.
  • How should future code know when deferred drop is allowed?
  • Do we need a dedicated benchmark/guardrail for the workload where this helps?

@graphite-app

graphite-app Bot commented Jun 14, 2026

Copy link
Copy Markdown
Contributor

Merge activity

…roduced (#9733)

## Summary

Once the bundle output exists, the hot thread still spends ~15ms (on a 20k-module build) inside `free()` tearing down the link stage output (`module_table`, `metas`, `stmt_infos`, ...). The rayon workers are idle at that point, so this PR ships that drop to one of them (`utils/defer_drop.rs`):

- `symbol_db` is `mem::take`'n out for the cache merge **exactly as main consumes it** (inline, unchanged on both incremental and non-incremental paths).
- The remaining `link_stage_output` is `spawn_drop`'d right after `generate()` returns instead of being freed at `bundle_up` scope end.

Only this one object is deferred, governed by one rule (documented in the module): **only values main itself kept alive through the overlapped region are eligible.** main held `link_stage_output` through the `render_error`/`generateBundle` hooks and the write tail anyway — here it is freed concurrently *during* them, so no memory window ever extends (peak RSS measured flat).

Deferred drops cannot pile up — this is enforced, not assumed: every pending drop is counted, and `defer_drop::drain()` blocks until the count is zero at every entry point that starts rayon work (`BundleFactory::build_bundle` plus the three HMR partial-scan entries). In steady state it is a single uncontended lock check.

## Numbers

apps/10000 (20,014 modules), full build incl. minify + sourcemap, binary A/B vs main, order-balanced ABAB pairs, first run per binary discarded, 1.3x-median scan filter:

| pair | base build_ms | this PR | delta |
|---|---|---|---|
| 1 | 203.1 | 194.7 | −8.4 |
| 2 | 199.4 | 195.6 | −3.8 |
| 3 | 209.0 | 194.0 | −15.1 |
| 4 | 193.4 | 183.4 | −9.9 |
| 5 | 200.3 | 184.4 | −15.9 |
| 6 | 205.2 | 190.7 | −14.5 |
| 7 | 193.6 | 185.1 | −8.5 |
| 8 | 215.7 | 188.5 | −27.2 |

**Median pair delta −12.2 ms (~−6% of build), 8/8 pairs negative.** Output is byte-identical (`main.js` / `main.js.map` sha256 match the unmodified baseline). Peak RSS flat (`/usr/bin/time -l`, interleaved runs).

## What was deliberately NOT deferred

Adversarial review (4 rounds) shrank this change to what's provably safe:

- **`ast_table` (per-module AST arenas):** main frees these *before* chunk instantiation/minify allocate; deferring them would overlap the full arena graph with allocation-heavy phases and risk a peak-RSS spike. Kept inline.
- **non-incremental `symbol_db`:** main frees it inline *before* the output hooks; deferring it would extend its lifetime across unbounded plugin hooks. Kept inline (this costs ~5ms of the potential win and is the right trade).

## Notes

- The free still happens — on otherwise-idle workers, overlapping the hook/write tail. Real work is moved off the critical path, not eliminated; in-process memory is released a few ms later within the same build span.
- The pending counter is process-global on purpose: the dropped value is exclusively owned, so the worst cross-instance effect is a bounded (~15ms) wait or background CPU, never correctness.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Touches build lifecycle and the shared rayon pool with global synchronization; correctness relies on documented invariants (one deferred object per build, drain at entry), though output is intended to stay byte-identical.
>
> **Overview**
> Moves teardown of heavy **link stage output** (`module_table`, metas, stmt infos, etc.) off the main thread after `GenerateStage::generate()` returns, so ~15ms of `free()` can overlap plugin hooks and the write tail on idle rayon workers.
>
> Adds **`utils/defer_drop`**: `spawn_drop` enqueues exclusive drops on `rayon::spawn`, with a process-global pending counter and **`drain()`** so deferred work cannot pile up across builds. **`symbol_db`** is still taken with `mem::take` and merged into the cache inline before deferral (unchanged lifecycle for cache integrity).
>
> **`defer_drop::drain()`** runs at every rayon-work entry: **`BundleFactory::build_bundle`** and the three HMR/lazy-compile paths in **`impl_bundler_hmr.rs`** that skip `build_bundle`.
>
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d9f9569. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->
@graphite-app graphite-app Bot force-pushed the perf/deferred-graph-drop branch from d9f9569 to 7bcf23d Compare June 14, 2026 03:55
@graphite-app graphite-app Bot merged commit 7bcf23d into main Jun 14, 2026
34 checks passed
@graphite-app graphite-app Bot deleted the perf/deferred-graph-drop branch June 14, 2026 04:00
This was referenced Jun 18, 2026
shulaoda added a commit that referenced this pull request Jun 18, 2026
## [1.1.2] - 2026-06-18

### 📝 Notable tsconfig behavior changes

These ship via the `oxc_resolver` 11.21.3 bump (#9841) and affect `resolve.tsconfigPaths` (Vite 8 resolves through oxc-resolver):

- **Honor explicit non-TS extensions in `include`** (oxc-project/oxc-resolver#1213). `compilerOptions.paths` now resolve for importers whose extension is explicitly listed in a tsconfig's `include` (e.g. `src/**/*.vue`, `src/**/*.svelte`). Previously oxc-resolver filtered importers by extension before evaluating the `include` globs, so a `.vue`/`.svelte` file listed in `include` never matched its project and its `paths` were skipped. This unblocks the default create-vite Vue + TS layout (a solution-style root plus a referenced `tsconfig.app.json` that declares `paths` and `include: ["src/**/*.ts", "src/**/*.vue"]`). Matches vue-tsc and svelte-check, which register these extensions via TypeScript's `extraFileExtensions`.
- **No fallback to the outermost tsconfig in auto-discovery** (oxc-project/oxc-resolver#1220). Auto-discovery no longer attaches the topmost ancestor `tsconfig.json` to a file that no project actually owns (via `files` / `include` / project references). Previously such a file inherited the outermost ancestor's `paths` / `baseUrl`, leaking aliases into files that project does not own. oxc-resolver now returns no config in that case, matching tsserver / typescript-go, which route such files to an inferred project with no aliases.

### 🚀 Features

- add option named for invalid return type errors for more places (#9846) by @shulaoda
- add option names for invalid return type errors (#9821) by @sapphi-red
- transform: infer decorator strictNullChecks from tsconfig (#9590) by @kylecannon
- expose React Compiler options for rolldown and Vite users (#9801) by @Boshen
- tracing: gate chrome-json trace layer behind `chrome-tracing` feature (#9773) by @hyf0
- dev: align test-dev-server with Vite dev server (#9668) by @h-a-n-a

### 🐛 Bug Fixes

- plugin_timings: point doc link to existing checks reference page (#9837) by @hyf0
- generator: correct contradictory panic message in cjs cross-chunk symbol lookup (#9836) by @hyf0
- esm: preserve with clause on export * from external (#9796) by @hyf0
- Make external_import_binding_merger deterministic (#9755) by @naruaway
- surface invalid `manualCodeSplitting` group `test` regex as an error (#9792) by @shulaoda
- avoid panic on `output.file` without a file name (#9789) by @shulaoda
- avoid O(N^2) rendering of high-volume diagnostics (#9748) (#9749) by @IWANABETHATGUY
- avoid panic on JSON numbers outside f64 range (#9788) by @shulaoda
- deps: bump mimalloc-safe to 0.1.63 to fix worker_threads segfault (#9785) by @shulaoda
- cache ESM evaluation errors (#9784) by @sapphi-red
- wrap node require helper in pure IIFE (#9783) by @kb019
- lazy-barrel: load locally-used imports on a re-exported record (#9757) by @shulaoda
- avoid dangling wrapped-ESM init call across chunks (#9502) (#9717) by @IWANABETHATGUY
- dev: detect same-second rewrites in CI poll watcher (#9736) by @h-a-n-a
- dev: force rebuild after HMR errors (#9686) by @h-a-n-a
- dev: print build errors on browser refresh after a failed build (#9652) by @h-a-n-a

### 🚜 Refactor

- single-source the chunk $N symbol-naming algorithm (#9831) by @Dunqing
- simplify common_dir helper (#9857) by @IWANABETHATGUY
- drop commondir crate in favor of in-house helper (#9849) by @Boshen
- binding: extract helpers from normalize_binding_options (#9842) by @Boshen
- move rolldown_filter_analyzer to tasks and scope oxc cfg feature (#9839) by @Boshen
- options: merge manualCodeSplitting into codeSplitting object form (#9805) by @IWANABETHATGUY
- options: support codeSplitting object form in CodeSplittingMode (#9804) by @IWANABETHATGUY
- diagnostic: reuse ByteLocator for per-source line lookup (#9762) by @IWANABETHATGUY
- remove redundant Arc around tracing spans (#9778) by @camc314
- remove unnecessary `Arc` around sourcemap sender (#9777) by @camc314
- rolldown_plugin_vite_wasm_fallback: remove the plugin (#9775) by @sapphi-red
- binding: remove infer-able `napi(ts_type)` (#9737) by @sapphi-red
- remove preprocessor span dedup (#9734) by @hyf0
- identify AST nodes by NodeId instead of Span/Address (#9609) by @IWANABETHATGUY

### 📚 Documentation

- tsconfig: align auto-discovery docs with oxc-resolver behavior (#9845) by @shulaoda
- relocate meta/design to internal-docs, split design from implementation (#9826) by @h-a-n-a
- meta: add options normalization design doc (#9818) by @IWANABETHATGUY
- document why the napi tracing feature is enabled (#9766) by @Boshen
- dev: move test-dev-server test guidance into the testing docs (#9809) by @h-a-n-a

### ⚡ Performance

- drop unused regex unicode property tables from the binding (#9848) by @Boshen
- drop urlencoding crate in favor of percent-encoding (#9851) by @Boshen
- drop owo-colors supports-colors feature in vite reporter (#9824) by @Boshen
- skip enum member value extraction for non-TypeScript modules (#9840) by @shulaoda
- rolldown: use unstable sort for itertools sorted_by at unique-key sites (#9827) by @Boshen
- cheaper deterministic ordering in external import binding merger (#9810) by @IWANABETHATGUY
- disable idna's ICU backend by pinning idna_adapter to 1.0.0 (-129 KB) (#9811) by @Boshen
- size: use unstable sort where stability is unneeded (#9803) by @Boshen
- remove num-format dependency from vite reporter (#9795) by @Boshen
- reduce js callback error size (#9776) by @Boshen
- rolldown_error: remove Debug supertrait from BuildEvent (#9798) by @Boshen
- reduce plugin hook order code size (#9761) by @Boshen
- deps: disable `infer` default features to reduce binary size (#9765) by @Boshen
- reduce pluginable monomorphization size (#9771) by @Boshen
- avoid rebuilding replace plugin values (#9764) by @Boshen
- defer link-stage-output drop to rayon workers after output is produced (#9733) by @Brooooooklyn
- tree-shaking: hoist already-included guard to call sites in inclusion DFS (#9738) by @Brooooooklyn
- renamer: dedup before allocating the owned name in add_symbol_in_root_scope (#9740) by @Brooooooklyn

### 🧪 Testing

- allocs: track allocation counts for rolldown_sourcemap (#9835) by @hyf0
- bench: add CodSpeed micro-benchmarks for rolldown_sourcemap (#9834) by @hyf0
- add cjs named export mutation test (#9823) by @sapphi-red
- dev: restore shared-page reliability conventions in AGENTS.md (#9786) by @h-a-n-a
- dev: add `AGENTS.md` test guidance for agents (#9763) by @h-a-n-a
- dev: split out initial-build-error into its own playground (#9772) by @h-a-n-a
- dev: align e2e suite with Vite and parallelize playgrounds (#9759) by @h-a-n-a
- remove unnecessary module namespace object JSON serializations in tests (#9725) by @sapphi-red
- use `assert.deepStrictEqual` instead of `assert.deepEqual` by using `assert/strict` instead of `assert` (#9724) by @sapphi-red
- hmr: add test case for #5301 (#5302) by @sapphi-red
- dev: add tests for dev-engine principles (#9720) by @h-a-n-a
- dev: align dev-engine test harness with Vite (#9684) by @h-a-n-a

### ⚙️ Miscellaneous Tasks

- deps: update napi to 3.9.3 (#9862) by @shulaoda
- deps: update oxc to 0.137.0 (#9856) by @Boshen
- re-enable default lld linker on x86_64-unknown-linux-gnu (#9855) by @Boshen
- deps: bump vite-plus to 0.2.1 (#9850) by @Boshen
- skills: translate _config.json when encoding rolldown REPL links (#9847) by @IWANABETHATGUY
- deps: update oxc_resolver and oxc_resolver_napi to 11.21.3 (#9841) by @Boshen
- pin vite-plus (vp) CLI to 0.1.24 in setup-vp (#9830) by @Boshen
- add crate/package-level CODEOWNERS (#9819) by @IWANABETHATGUY
- drop unused derive_more display feature from rolldown_plugin (#9820) by @Boshen
- remove auto-assign PR workflow (#9807) by @IWANABETHATGUY
- deps: update rollup submodule for tests to v4.62.0 (#9780) by @rolldown-guard[bot]
- deps: update esbuild for tests to 0.28.1 (#9779) by @rolldown-guard[bot]
- deps: update test262 submodule for tests (#9781) by @rolldown-guard[bot]
- deps: update oxc to 0.136.0 (#9770) by @Boshen
- add pull request template (#9756) by @sapphi-red
- clarify `rolldown_plugin_vite_*` is compatible for the same minor (#9774) by @sapphi-red
- deps: update github actions (#9745) by @renovate[bot]
- deps: update rust crates (#9747) by @renovate[bot]
- deps: update napi to v3.9.2 (#9744) by @renovate[bot]
- deps: update npm packages (#9746) by @renovate[bot]
- deps: update @napi-rs/cli and emnapi deps (#9741) by @Brooooooklyn
- generator: fix `vp fmt` on Windows (#9727) by @sapphi-red
- ban importing from `assert` and recommend `assert/strict` (#9726) by @sapphi-red

### ❤️ New Contributors

* @naruaway made their first contribution in [#9755](#9755)
* @kb019 made their first contribution in [#9783](#9783)

Co-authored-by: shulaoda <165626830+shulaoda@users.noreply.github.com>
leegeunhyeok added a commit to rollipop-dev/rolldown that referenced this pull request Jun 18, 2026
## [1.0.16] - 2026-06-18

### 🚀 Features

- add option named for invalid return type errors for more places
(rolldown#9846) by `@shulaoda`
- add option names for invalid return type errors (rolldown#9821) by
`@sapphi-red`
- transform: infer decorator strictNullChecks from tsconfig (rolldown#9590) by
`@kylecannon`
- expose React Compiler options for rolldown and Vite users (rolldown#9801) by
`@Boshen`
- tracing: gate chrome-json trace layer behind `chrome-tracing` feature
(rolldown#9773) by `@hyf0`
- dev: align test-dev-server with Vite dev server (rolldown#9668) by `@h-a-n-a`

### 🐛 Bug Fixes

- plugin_timings: point doc link to existing checks reference page
(rolldown#9837) by `@hyf0`
- generator: correct contradictory panic message in cjs cross-chunk
symbol lookup (rolldown#9836) by `@hyf0`
- esm: preserve with clause on export * from external (rolldown#9796) by `@hyf0`
- Make external_import_binding_merger deterministic (rolldown#9755) by
`@naruaway`
- surface invalid `manualCodeSplitting` group `test` regex as an error
(rolldown#9792) by `@shulaoda`
- avoid panic on `output.file` without a file name (rolldown#9789) by
`@shulaoda`
- avoid O(N^2) rendering of high-volume diagnostics (rolldown#9748) (rolldown#9749) by
`@IWANABETHATGUY`
- avoid panic on JSON numbers outside f64 range (rolldown#9788) by `@shulaoda`
- deps: bump mimalloc-safe to 0.1.63 to fix worker_threads segfault
(rolldown#9785) by `@shulaoda`
- cache ESM evaluation errors (rolldown#9784) by `@sapphi-red`
- wrap node require helper in pure IIFE (rolldown#9783) by `@kb019`
- lazy-barrel: load locally-used imports on a re-exported record (rolldown#9757)
by `@shulaoda`
- avoid dangling wrapped-ESM init call across chunks (rolldown#9502) (rolldown#9717) by
`@IWANABETHATGUY`
- dev: detect same-second rewrites in CI poll watcher (rolldown#9736) by
`@h-a-n-a`
- dev: force rebuild after HMR errors (rolldown#9686) by `@h-a-n-a`
- dev: print build errors on browser refresh after a failed build
(rolldown#9652) by `@h-a-n-a`

### 🚜 Refactor

- single-source the chunk $N symbol-naming algorithm (rolldown#9831) by
`@Dunqing`
- simplify common_dir helper (rolldown#9857) by `@IWANABETHATGUY`
- drop commondir crate in favor of in-house helper (rolldown#9849) by `@Boshen`
- binding: extract helpers from normalize_binding_options (rolldown#9842) by
`@Boshen`
- move rolldown_filter_analyzer to tasks and scope oxc cfg feature
(rolldown#9839) by `@Boshen`
- options: merge manualCodeSplitting into codeSplitting object form
(rolldown#9805) by `@IWANABETHATGUY`
- options: support codeSplitting object form in CodeSplittingMode
(rolldown#9804) by `@IWANABETHATGUY`
- diagnostic: reuse ByteLocator for per-source line lookup (rolldown#9762) by
`@IWANABETHATGUY`
- remove redundant Arc around tracing spans (rolldown#9778) by `@camc314`
- remove unnecessary `Arc` around sourcemap sender (rolldown#9777) by `@camc314`
- rolldown_plugin_vite_wasm_fallback: remove the plugin (rolldown#9775) by
`@sapphi-red`
- binding: remove infer-able `napi(ts_type)` (rolldown#9737) by `@sapphi-red`
- remove preprocessor span dedup (rolldown#9734) by `@hyf0`
- identify AST nodes by NodeId instead of Span/Address (rolldown#9609) by
`@IWANABETHATGUY`

### 📚 Documentation

- tsconfig: align auto-discovery docs with oxc-resolver behavior (rolldown#9845)
by `@shulaoda`
- relocate meta/design to internal-docs, split design from
implementation (rolldown#9826) by `@h-a-n-a`
- meta: add options normalization design doc (rolldown#9818) by
`@IWANABETHATGUY`
- document why the napi tracing feature is enabled (rolldown#9766) by `@Boshen`
- dev: move test-dev-server test guidance into the testing docs (rolldown#9809)
by `@h-a-n-a`

### ⚡ Performance

- drop unused regex unicode property tables from the binding (rolldown#9848) by
`@Boshen`
- drop urlencoding crate in favor of percent-encoding (rolldown#9851) by
`@Boshen`
- drop owo-colors supports-colors feature in vite reporter (rolldown#9824) by
`@Boshen`
- skip enum member value extraction for non-TypeScript modules (rolldown#9840)
by `@shulaoda`
- rolldown: use unstable sort for itertools sorted_by at unique-key
sites (rolldown#9827) by `@Boshen`
- cheaper deterministic ordering in external import binding merger
(rolldown#9810) by `@IWANABETHATGUY`
- disable idna's ICU backend by pinning idna_adapter to 1.0.0 (-129 KB)
(rolldown#9811) by `@Boshen`
- size: use unstable sort where stability is unneeded (rolldown#9803) by
`@Boshen`
- remove num-format dependency from vite reporter (rolldown#9795) by `@Boshen`
- reduce js callback error size (rolldown#9776) by `@Boshen`
- rolldown_error: remove Debug supertrait from BuildEvent (rolldown#9798) by
`@Boshen`
- reduce plugin hook order code size (rolldown#9761) by `@Boshen`
- deps: disable `infer` default features to reduce binary size (rolldown#9765)
by `@Boshen`
- reduce pluginable monomorphization size (rolldown#9771) by `@Boshen`
- avoid rebuilding replace plugin values (rolldown#9764) by `@Boshen`
- defer link-stage-output drop to rayon workers after output is produced
(rolldown#9733) by `@Brooooooklyn`
- tree-shaking: hoist already-included guard to call sites in inclusion
DFS (rolldown#9738) by `@Brooooooklyn`
- renamer: dedup before allocating the owned name in
add_symbol_in_root_scope (rolldown#9740) by `@Brooooooklyn`

### 🧪 Testing

- allocs: track allocation counts for rolldown_sourcemap (rolldown#9835) by
`@hyf0`
- bench: add CodSpeed micro-benchmarks for rolldown_sourcemap (rolldown#9834) by
`@hyf0`
- add cjs named export mutation test (rolldown#9823) by `@sapphi-red`
- dev: restore shared-page reliability conventions in AGENTS.md (rolldown#9786)
by `@h-a-n-a`
- dev: add `AGENTS.md` test guidance for agents (rolldown#9763) by `@h-a-n-a`
- dev: split out initial-build-error into its own playground (rolldown#9772) by
`@h-a-n-a`
- dev: align e2e suite with Vite and parallelize playgrounds (rolldown#9759) by
`@h-a-n-a`
- remove unnecessary module namespace object JSON serializations in
tests (rolldown#9725) by `@sapphi-red`
- use `assert.deepStrictEqual` instead of `assert.deepEqual` by using
`assert/strict` instead of `assert` (rolldown#9724) by `@sapphi-red`
- hmr: add test case for rolldown#5301 (rolldown#5302) by `@sapphi-red`
- dev: add tests for dev-engine principles (rolldown#9720) by `@h-a-n-a`
- dev: align dev-engine test harness with Vite (rolldown#9684) by `@h-a-n-a`

### ⚙️ Miscellaneous Tasks

- add rollipop-integration skill by `@leegeunhyeok`
- update esbuild snap diff metrics by `@leegeunhyeok`
- sync upstream rolldown v1.1.2 by `@leegeunhyeok`
- deps: update napi to 3.9.3 (rolldown#9862) by `@shulaoda`
- deps: update oxc to 0.137.0 (rolldown#9856) by `@Boshen`
- re-enable default lld linker on x86_64-unknown-linux-gnu (rolldown#9855) by
`@Boshen`
- deps: bump vite-plus to 0.2.1 (rolldown#9850) by `@Boshen`
- skills: translate _config.json when encoding rolldown REPL links
(rolldown#9847) by `@IWANABETHATGUY`
- deps: update oxc_resolver and oxc_resolver_napi to 11.21.3 (rolldown#9841) by
`@Boshen`
- pin vite-plus (vp) CLI to 0.1.24 in setup-vp (rolldown#9830) by `@Boshen`
- add crate/package-level CODEOWNERS (rolldown#9819) by `@IWANABETHATGUY`
- drop unused derive_more display feature from rolldown_plugin (rolldown#9820)
by `@Boshen`
- remove auto-assign PR workflow (rolldown#9807) by `@IWANABETHATGUY`
- deps: update rollup submodule for tests to v4.62.0 (rolldown#9780) by
`@rolldown-guard[bot]`
- deps: update esbuild for tests to 0.28.1 (rolldown#9779) by
`@rolldown-guard[bot]`
- deps: update test262 submodule for tests (rolldown#9781) by
`@rolldown-guard[bot]`
- deps: update oxc to 0.136.0 (rolldown#9770) by `@Boshen`
- add pull request template (rolldown#9756) by `@sapphi-red`
- clarify `rolldown_plugin_vite_*` is compatible for the same minor
(rolldown#9774) by `@sapphi-red`
- deps: update github actions (rolldown#9745) by `@renovate[bot]`
- deps: update rust crates (rolldown#9747) by `@renovate[bot]`
- deps: update napi to v3.9.2 (rolldown#9744) by `@renovate[bot]`
- deps: update npm packages (rolldown#9746) by `@renovate[bot]`
- deps: update @napi-rs/cli and emnapi deps (rolldown#9741) by `@Brooooooklyn`
- generator: fix `vp fmt` on Windows (rolldown#9727) by `@sapphi-red`
- ban importing from `assert` and recommend `assert/strict` (rolldown#9726) by
`@sapphi-red`

Co-authored-by: leegeunhyeok <26512984+leegeunhyeok@users.noreply.github.com>
graphite-app Bot pushed a commit that referenced this pull request Jun 24, 2026
Related to #9884 and #9733

## TL;DR

`@rolldown/browser` ≥ 1.1.2 throws `RuntimeError: Atomics.wait cannot be called in this context` when bundling **on the browser main thread**, as soon as a *second* build runs in the same session. 1.1.1 was fine. The cause is the `defer_drop` optimization added in 1.1.2 (`crates/rolldown/src/utils/defer_drop.rs`): `BundleFactory::build_bundle` synchronously calls `defer_drop::drain()`, which does `Condvar::wait` — i.e. it **parks the calling thread** — to wait for the *previous* build's deferred drop to finish. On wasm that lowers to `memory.atomic.wait`, which is illegal on the browser main thread. Fix: on wasm, drop inline (don't defer, don't drain).

## Symptom

In a cross-origin-isolated page, calling `rolldown()` / `generate()` **on the page main thread** (not in a Worker):

```
RuntimeError: Atomics.wait cannot be called in this context
    at wasm-function[...] (rolldown-binding.wasm32-wasi.wasm)
    ...
```

- First build of the session succeeds; the **second and later** builds throw.
- The rolldown REPL hits this every time, because it runs **two builds per "Bundle"**: it first compiles `rolldown.config.ts` (build #1), then bundles the entry (build #2).
- Running the exact same code **inside a Web Worker** does not throw (parking is legal off the main thread).

## Background: why the main thread cannot park

When one thread must wait for another (e.g. "wait until the background free is done"), it **parks** — sleeps until notified. The primitive is a futex; in wasm it is the `memory.atomic.wait` instruction (JS: `Atomics.wait`). Browsers **forbid `memory.atomic.wait` on the main thread** (a sleeping main thread would freeze the event loop / UI), so the engine throws `Atomics.wait cannot be called in this context`. Worker threads may park freely. `std::sync::{Mutex, Condvar}` on `wasm32-wasip1-threads` lower to this instruction, so any `Condvar::wait` reached on the browser main thread crashes.

## Root cause

`defer_drop` (new in 1.1.2) moves the ~15 ms free of the link-stage output off the hot thread onto a rayon worker:

- `bundle.rs` — after `generate()`: `defer_drop::spawn_drop(link_stage_output)` → `PENDING += 1; rayon::spawn(move || drop(value))`.
- `bundle_factory.rs` — `BundleFactory::build_bundle` (a **synchronous** function that runs on whatever thread called rolldown): `defer_drop::drain()`, which `Condvar::wait`s while `PENDING > 0`, to guarantee a build never overlaps the previous build's frees.

On native this is fine (the caller is a tokio/rayon worker that may block). On the browser **main thread** it is not: `build_bundle` of the **second** build parks the main thread waiting for the **first** build's still-running background drop.

```mermaid
sequenceDiagram
    autonumber
    participant M as Browser main thread
    participant W as rayon worker (web worker)

    Note over M: Build #1 — e.g. compile rolldown.config.ts
    M->>M: generate() returns
    M->>W: spawn_drop(link_stage_output) — PENDING = 1
    activate W
    Note over W: freeing ~15 ms in the background

    Note over M: Build #2 — bundle the entry (same synchronous turn, no yield)
    M->>M: BundleFactory::build_bundle()
    M->>M: drain() sees PENDING > 0
    M->>M: Condvar::wait()  ➜  wasm memory.atomic.wait
    Note over M: blocking the browser main thread is forbidden
    M-->>M: ❌ throw "Atomics.wait cannot be called in this context"
    deactivate W
```

Between build #1's `spawn_drop` and build #2's `drain()` the main thread never yields, so the background worker has not retired the drop yet → `PENDING` is still `> 0` → `drain()` parks → crash. (`getModuleInfo`/plugin hooks are unrelated — they run on the bundling worker; the crash reproduces with no plugins at all, purely from doing two builds.)

## Why 1.1.1 was fine, and why it is not a dependency regression

- **1.1.1** has no `defer_drop`: it frees the link-stage output inline on the hot thread. No background drop, no `drain`, no parking on the caller → the main thread never blocks.
- Dependency bumps were ruled out by diffing the exact versions used by 1.1.1 vs 1.1.2: `napi` `3.9.1→3.9.3` leaves `tokio_runtime.rs` byte-identical (only a TSFN monomorphization refactor + a stream rewrite); `@tybys/wasm-util`/emnapi `1.11.0→1.11.1` only changes JS (`ensureBufferFor`), the wasm-side thread/wait C code is byte-identical; `@napi-rs/cli` `3.7.1→3.7.2` emits a byte-identical wasi loader/worker template. The only new code on the "synchronous-`Condvar::wait`-on-the-calling-thread" path is `defer_drop.rs` itself.

## The fix

On wasm, drop inline (the deferral's ~15 ms saving is irrelevant for interactive browser builds, and there is no spare blockable thread the main thread is allowed to wait on):

```diff
+#[cfg(not(target_family = "wasm"))]
 use std::sync::{Condvar, Mutex, PoisonError};

+#[cfg(not(target_family = "wasm"))]
 static PENDING: Mutex<usize> = Mutex::new(0);
+#[cfg(not(target_family = "wasm"))]
 static PENDING_IS_ZERO: Condvar = Condvar::new();

+#[cfg(not(target_family = "wasm"))]
 struct PendingGuard;
+#[cfg(not(target_family = "wasm"))]
 impl Drop for PendingGuard { /* unchanged */ }

 pub fn spawn_drop<T: Send + 'static>(value: T) {
-  *PENDING.lock().unwrap_or_else(PoisonError::into_inner) += 1;
-  rayon::spawn(move || {
-    let _guard = PendingGuard;
-    drop(value);
-  });
+  // On wasm the thread that later calls `drain()` may be the browser main
+  // thread, where the matching `Condvar::wait` lowers to `memory.atomic.wait`
+  // and is illegal. Drop inline so there is never a cross-build wait.
+  #[cfg(target_family = "wasm")]
+  drop(value);
+  #[cfg(not(target_family = "wasm"))]
+  {
+    *PENDING.lock().unwrap_or_else(PoisonError::into_inner) += 1;
+    rayon::spawn(move || {
+      let _guard = PendingGuard;
+      drop(value);
+    });
+  }
 }

 pub fn drain() {
-  let mut pending = PENDING.lock().unwrap_or_else(PoisonError::into_inner);
-  while *pending > 0 {
-    pending = PENDING_IS_ZERO.wait(pending).unwrap_or_else(PoisonError::into_inner);
-  }
+  // wasm drops inline in `spawn_drop`, so nothing is ever pending; a
+  // `Condvar::wait` here would crash on the browser main thread.
+  #[cfg(not(target_family = "wasm"))]
+  {
+    let mut pending = PENDING.lock().unwrap_or_else(PoisonError::into_inner);
+    while *pending > 0 {
+      pending = PENDING_IS_ZERO.wait(pending).unwrap_or_else(PoisonError::into_inner);
+    }
+  }
 }
```

### Trade-off / alternative

This disables the deferral on **all** wasm, including wasi-threads runs inside a Worker where parking would be legal. That is the simplest correct option and costs only the ~15 ms inline-free on wasm. If the optimization is worth keeping for the worker case, the alternative is to keep `spawn_drop` deferring on wasm but make `drain()` **not block on the browser main thread** — either skip the wait there (the pending drop simply finishes in the background; the count stays bounded by "one per build") or detect the main thread (e.g. via emnapi's `_emnapi_is_main_browser_thread`). The inline approach avoids that runtime/FFI coupling.

## Reproduction & verification

In a cross-origin-isolated (COOP/COEP) page, run two builds on the main thread:

```js
const core = await import('.../@rolldown/browser/dist/index.browser.mjs')
const binding = await import('.../@rolldown/browser/dist/rolldown-binding.wasi-browser.js')
binding.__volume.fromJSON({ '/index.ts': 'export const foo = 42' })
for (let i = 0; i < 2; i++) {
  const b = await core.rolldown({ input: ['/index.ts'], cwd: '/' })
  await b.generate({ format: 'esm' })
}
```

- **Before:** the 2nd `generate()` throws `Atomics.wait cannot be called in this context`.
- **After:** both builds succeed.

The same code inside a Web Worker already succeeds with or without this change (parking is legal off the main thread), so the patch only affects the browser-main-thread path. (Builds run in a Worker are unaffected; native is unchanged.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants