fix: defer_drop crashes the browser main thread#9942
Merged
graphite-app[bot] merged 1 commit intoJun 24, 2026
Conversation
defer_drop crashes the browser main thread
✅ Deploy Preview for rolldown-rs canceled.
|
Merging this PR will not alter performance
Comparing Footnotes
|
Brooooooklyn
approved these changes
Jun 24, 2026
Contributor
Merge activity
|
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.)
2813bb4 to
987b95b
Compare
Merged
shulaoda
added a commit
that referenced
this pull request
Jun 24, 2026
## [1.1.3] - 2026-06-24 ### 🐛 Bug Fixes - `defer_drop` crashes the browser main thread (#9942) by @shulaoda - camel-case: correct camel case for nested values (#9933) by @kb019 - cli: display --help options in camelCase (#9941) by @IWANABETHATGUY - preserve used re-exports under preserveModules (#9122) (#9934) by @IWANABETHATGUY - watch: make close reentrant in event callbacks (#9904) by @hyf0 - git for windows treats symlink files as regular files (#9915) by @AliceLanniste - dev: cancel pending full reload on build error (#9903) by @h-a-n-a - chunking: pass plugin meta to codeSplitting groups name function (#9267) by @Kyujenius - dev: serve assets emitted during HMR/lazy compile (vite#22596) (#9815) by @h-a-n-a - release: dry-run step no longer publishes binding packages (#9866) by @Boshen ### 🚜 Refactor - rolldown_common: model ModuleId as a classified Path/Virtual/Bare enum (#9927) by @Boshen - remove unused LegacyModuleIdx (#9872) by @shulaoda - remove unused StmtInfos::get_namespace_stmt_info (#9870) by @shulaoda - remove unused Module::as_external_mut (#9871) by @shulaoda - remove unused EcmaAst::is_body_empty (#9869) by @shulaoda - drop dead is_css_module handling in resolve_dependencies (#9867) by @shulaoda - drop redundant with_commonjs on cjs source type (#9868) by @shulaoda ### 📚 Documentation - clarify on drafting PRs (#9952) by @h-a-n-a - update contribution guidelines (#9944) by @fubhy - note Rust crates don't follow semver in AGENTS.md (#9905) by @IWANABETHATGUY - add feedback form (#9159) by @TheAlexLichter ### ⚡ Performance - utils: avoid allocation in default_sanitize_file_name for clean names (#9928) by @Boshen - binding: box once-per-build futures before spawn_future (#9864) by @Boshen - utils: avoid wasted allocation in legitimize_identifier_name (#9926) by @Boshen - rolldown: fuse the canonical-name dedup and insert in the renamer (#9900) by @Boshen - rolldown: probe the name map once in ConflictResolver::resolve (#9899) by @Boshen - cut two heap allocations from wrapped ESM init finalize (#9901) by @Boshen - rolldown_plugin_vite_reporter: hoist invariant out_dir prefix out of reporter loop (#9873) by @shulaoda - drop throwaway Vec in wrapped esm init stmt (#9878) by @shulaoda - borrow owner_filename in build-import-analysis AddDeps (#9874) by @shulaoda ### 🧪 Testing - cover preserveModules named export via namespace re-export (#6010) (#9937) by @IWANABETHATGUY ### ⚙️ Miscellaneous Tasks - deps: update napi to v3.9.4 (#9954) by @shulaoda - reduce noise from CODEOWNERS for trival changes (#9953) by @h-a-n-a - deps: update mimalloc-safe to 0.1.64 (#9950) by @shulaoda - deps: update rollup submodule for tests to v4.62.2 (#9931) by @rolldown-guard[bot] - deps: test mimalloc-safe upstream-mimalloc switch in CI (#9930) by @shulaoda - rolldown_plugin_vite_build_import_analysis: remove unused v2 code path (#9917) by @shulaoda - rolldown_plugin_vite_manifest: remove unused is_enable_v2 code path (#9916) by @shulaoda - rolldown_plugin_vite_asset_import_meta_url: remove unexposed native vite plugin (#9896) by @shulaoda - rolldown_plugin_vite_asset: remove unexposed native vite plugin (#9895) by @shulaoda - rolldown_plugin_vite_css_post: remove unexposed native vite plugin (#9894) by @shulaoda - rolldown_plugin_vite_css: remove unexposed native vite plugin (#9893) by @shulaoda - rolldown_plugin_vite_html_inline_proxy: remove unexposed native vite plugin (#9892) by @shulaoda - rolldown_plugin_vite_html: remove unexposed native vite plugin (#9891) by @shulaoda - deps: update github actions (#9909) by @renovate[bot] - deps: update rust crate oxc_sourcemap to v8.0.2 (#9910) by @renovate[bot] - deps: update npm packages (#9912) by @renovate[bot] - deps: update github actions to v7 (#9913) by @renovate[bot] - deps: update rolldown-plugin-dts to ^0.26.0 (#9897) by @renovate[bot] - remove rolldown_filter_analyzer crate (#9865) by @Boshen ### ❤️ New Contributors * @fubhy made their first contribution in [#9944](#9944) Co-authored-by: shulaoda <165626830+shulaoda@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related to #9884 and #9733
TL;DR
@rolldown/browser≥ 1.1.2 throwsRuntimeError: Atomics.wait cannot be called in this contextwhen 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 thedefer_dropoptimization added in 1.1.2 (crates/rolldown/src/utils/defer_drop.rs):BundleFactory::build_bundlesynchronously callsdefer_drop::drain(), which doesCondvar::wait— i.e. it parks the calling thread — to wait for the previous build's deferred drop to finish. On wasm that lowers tomemory.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):rolldown.config.ts(build Configure Renovate #1), then bundles the entry (build chore: add contribution guide #2).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.waitinstruction (JS:Atomics.wait). Browsers forbidmemory.atomic.waiton the main thread (a sleeping main thread would freeze the event loop / UI), so the engine throwsAtomics.wait cannot be called in this context. Worker threads may park freely.std::sync::{Mutex, Condvar}onwasm32-wasip1-threadslower to this instruction, so anyCondvar::waitreached 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— aftergenerate():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(), whichCondvar::waits whilePENDING > 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_bundleof the second build parks the main thread waiting for the first build's still-running background drop.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 WBetween build #1's
spawn_dropand build #2'sdrain()the main thread never yields, so the background worker has not retired the drop yet →PENDINGis 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
defer_drop: it frees the link-stage output inline on the hot thread. No background drop, nodrain, no parking on the caller → the main thread never blocks.napi3.9.1→3.9.3leavestokio_runtime.rsbyte-identical (only a TSFN monomorphization refactor + a stream rewrite);@tybys/wasm-util/emnapi1.11.0→1.11.1only changes JS (ensureBufferFor), the wasm-side thread/wait C code is byte-identical;@napi-rs/cli3.7.1→3.7.2emits a byte-identical wasi loader/worker template. The only new code on the "synchronous-Condvar::wait-on-the-calling-thread" path isdefer_drop.rsitself.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):
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_dropdeferring on wasm but makedrain()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:
generate()throwsAtomics.wait cannot be called in this context.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.)