fix: make this.emitFile chunk path synchronous to avoid deadlock#9031
fix: make this.emitFile chunk path synchronous to avoid deadlock#9031shulaoda merged 7 commits intorolldown:mainfrom
this.emitFile chunk path synchronous to avoid deadlock#9031Conversation
`PluginContext.emitFile({ type: 'chunk' })` is exposed to JS as a sync
napi binding. It used to `block_on` a future that sent `AddEntryModule`
over a bounded `mpsc::channel(1024)`, then await capacity. Once the
channel filled, the send future could only be unblocked by the module
loader draining it — but draining each message dispatched plugin hooks
back to the JS thread via TSFN, which was pinned inside `block_on`.
Classic producer ⇄ consumer deadlock.
Under parallelism the cycle could trigger well below 1024 emits
(~400 in local testing), because in-flight tasks waiting on JS hooks
keep the channel saturated.
Fix the root cause by making the entire emit_chunk path synchronous:
- `FileEmitter::emit_chunk` is now sync; its `tx` is a
`std::sync::Mutex` (critical section: clone the `Option<Sender>`,
drop the lock, send — all lock-free).
- `BindingPluginContext::emit_chunk` no longer enters the runtime.
- `PluginContext::emit_chunk` and `NativePluginContextImpl::emit_chunk`
are de-async'd to match.
- `PluginDriver.tx` is unified on `std::sync::Mutex` for consistency
with `FileEmitter.tx`.
Switch the module loader's message channel to `unbounded_channel()` as
defense in depth, so any future bounded queue on this path cannot
re-introduce the same cycle.
Add two regression fixtures:
- `emit-chunk-many-from-transform`: 2000 emits in a tight loop from a
single `transform` hook.
- `emit-chunk-many-parallel-inputs`: 1500 inputs each emitting one
chunk from their own `transform`, exercising the
parallelism-dependent variant.
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
Pull request overview
Fixes a deterministic/non-deterministic deadlock when JS plugins synchronously call this.emitFile({ type: 'chunk' }) at scale by removing async/blocking behavior from the emit-chunk pipeline and eliminating bounded-channel backpressure that could park the JS thread.
Changes:
- Make
emit_chunkfully synchronous end-to-end (plugin context → file emitter → module-loader message send) and removeblock_onfrom the NAPI binding. - Switch module-loader messaging to
tokio::sync::mpsc::unbounded_channel()and update all producers/consumers to useUnboundedSender. - Add regression fixtures that previously deadlocked (tight loop emits; many parallel transforms each emitting once).
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/rolldown/tests/fixtures/plugin/context/emit-chunk-many-parallel-inputs/_config.ts | Adds parallel-input regression fixture that emits many chunks across transforms. |
| packages/rolldown/tests/fixtures/plugin/context/emit-chunk-many-from-transform/_config.ts | Adds tight-loop regression fixture emitting 2000 chunks from one transform. |
| packages/rolldown/tests/fixtures/plugin/context/emit-chunk-many-from-transform/main.js | Trigger module for the tight-loop regression fixture. |
| crates/rolldown_common/src/file_emitter.rs | Converts emit_chunk and sender installation to sync + UnboundedSender. |
| crates/rolldown_binding/src/options/plugin/binding_plugin_context.rs | Removes block_on from sync NAPI emit_chunk binding; marks SYNC-SAFE. |
| crates/rolldown_plugin/src/plugin_context/plugin_context.rs | Changes PluginContext::emit_chunk API from async to sync. |
| crates/rolldown_plugin/src/plugin_context/native_plugin_context.rs | Updates native context to use UnboundedSender and sync emit_chunk. |
| crates/rolldown_plugin/src/plugin_driver/mod.rs | Switches plugin driver tx storage to std::sync::Mutex + UnboundedSender. |
| crates/rolldown_plugin/src/plugin_driver/plugin_driver_factory.rs | Constructs plugin driver with std::sync::Mutex<Option<UnboundedSender>>. |
| crates/rolldown/src/module_loader/module_loader.rs | Changes loader channel to unbounded and documents deadlock rationale. |
| crates/rolldown/src/module_loader/task_context.rs | Updates shared task context tx to UnboundedSender. |
| crates/rolldown/src/module_loader/module_task.rs | Updates message sends to non-async UnboundedSender::send. |
| crates/rolldown/src/module_loader/external_module_task.rs | Updates message sends to non-async UnboundedSender::send. |
| crates/rolldown/src/module_loader/runtime_module_task.rs | Updates error/result sends to UnboundedSender::send. |
| crates/rolldown/src/stages/scan_stage.rs | Removes awaits for setting context sender on plugin driver/file emitter. |
| crates/rolldown/tests/rolldown/issues/4895/mod.rs | Updates test plugin to use sync emit_chunk (no .await). |
| crates/rolldown/tests/rolldown/issues/5011/mod.rs | Updates test plugin to use sync emit_chunk (no .await). |
| crates/rolldown/tests/rolldown/issues/7833/mod.rs | Updates test plugin to use sync emit_chunk (no .await). |
| crates/rolldown/tests/rolldown/optimization/chunk_merging/allow_extension_exports/mod.rs | Updates test plugin to use sync emit_chunk (no .await). |
| crates/rolldown/tests/rolldown/optimization/chunk_merging/allow_extension_merge_same_exports/mod.rs | Updates test plugin to use sync emit_chunk (no .await). |
@rolldown/browser
@rolldown/debug
@rolldown/pluginutils
rolldown
@rolldown/binding-android-arm64
@rolldown/binding-darwin-arm64
@rolldown/binding-darwin-x64
@rolldown/binding-freebsd-x64
@rolldown/binding-linux-arm-gnueabihf
@rolldown/binding-linux-arm64-gnu
@rolldown/binding-linux-arm64-musl
@rolldown/binding-linux-ppc64-gnu
@rolldown/binding-linux-s390x-gnu
@rolldown/binding-linux-x64-gnu
@rolldown/binding-linux-x64-musl
@rolldown/binding-openharmony-arm64
@rolldown/binding-wasm32-wasi
@rolldown/binding-win32-arm64-msvc
@rolldown/binding-win32-x64-msvc
commit: |
|
This has fixed the issue I reported here: vitejs/vite#21957 Thank you @lazarv |
|
@shulaoda, would you mind having a look when you are free? |
|
Thanks for the contribution, great fix! LGTM! I pushed a few small tweaks on top. |
this.emitFile chunk path synchronous to avoid deadlock
… data (#9176) ## Summary - Replace `tokio::sync::Mutex` with `std::sync::Mutex` for two places that only guard plain data and never hold the lock across an `.await` point: - `PluginDriver.tx` / `NativePluginContextImpl.tx` — `Arc<Mutex<Option<UnboundedSender<ModuleLoaderMsg>>>>` - `BundleCoordinator.watcher` — `Mutex<DynFsWatcher>` - De-async `PluginDriver::set_context_load_modules_tx` as a consequence, and drop the redundant `.await` at its two call sites in `ScanStage`. ## Why Per tokio's own guidance, the async mutex should be reserved for protecting IO resources where the lock genuinely needs to be held across `.await`. For plain data the blocking `std::sync::Mutex` is both cheaper and less prone to latent deadlocks when used through sync NAPI bindings. This continues the migration started in #9031 (which collapsed the `FileEmitter.tx` path to sync for the `emit_chunk` deadlock fix). Scope of this change: - `PluginDriver.tx` is only ever acquired to **clone an `Option<UnboundedSender>`** and to swap a new sender in at scan boundaries. The existing code in `NativePluginContextImpl::load` already drops the guard before awaiting `sender.send(...)`; the only reason it used `tokio::sync::Mutex` was incidental. Switching to `std::sync::Mutex` makes that invariant syntactically enforced (no `.await` possible while the guard is live) and lets `set_context_load_modules_tx` shed its unnecessary `async` marker. - `BundleCoordinator.watcher` is used exclusively inside `update_watch_paths`, where the guard is held only over synchronous `paths_mut.add(...)` / `paths_mut.commit()` calls. No `.await` exists in that critical section, so async mutex is pure overhead here. No behavioral change for plugin authors or bundler consumers, the JS APIs and message flows are unchanged. ## Refs - Closes part of #9114 (tokio → std mutex migration) - Related: #9031 (sync-NAPI deadlock fix for `emit_chunk`; established the `std::sync::Mutex` pattern for the tx slot on `FileEmitter`)
## [1.0.0-rc.18] - 2026-04-29 ### 💥 BREAKING CHANGES - optimization: default unspecified inlineConst.mode to smart (#9248) by @IWANABETHATGUY ### 🐛 Bug Fixes - rolldown_plugin_vite_import_glob: return error instead of panicking when virtual module uses a relative glob (#9241) by @shulaoda - binding: treat empty inlineConst object as omitted (#9247) by @IWANABETHATGUY - rolldown: keep enum declaration for optional-chain access (#9229) by @Dunqing - link_stage: restore inline let-else in exports-kind filter (#9237) by @IWANABETHATGUY - dev/lazy: avoid module reinitialization in lazy compilation patches (#9179) by @h-a-n-a - dev: visit identifier references for runtime rewrites in HMR finalizer (#9191) by @h-a-n-a - chunk-optimizer: pick dominator for runtime placement to avoid cycles (#9164) by @IWANABETHATGUY - make `this.emitFile` chunk path synchronous to avoid deadlock (#9031) by @lazarv - use sentinel id for `browser: false` ignored modules (#9192) by @shulaoda - prevent chunk optimizer from creating import cycles (#9228) by @IWANABETHATGUY ### 🚜 Refactor - replace tokio::sync::Mutex with std::sync::Mutex for non-IO data (#9176) by @shulaoda - rolldown_plugin_vite_import_glob: do not rewrite import path for absolute base (#9195) by @shulaoda - runtime_helper: wrap DependedRuntimeHelperMap in a struct (#9215) by @IWANABETHATGUY - drop redundant clear() in determine_safely_merge_cjs_ns (#9206) by @IWANABETHATGUY - clean up generate_lazy_export (#9208) by @IWANABETHATGUY - bitset: return bool from set_bit to fuse guard-and-set (#9207) by @IWANABETHATGUY - link_stage: simplify exports-kind filter and clarify safety comments (#9205) by @IWANABETHATGUY ### 📚 Documentation - determine_module_exports_kind (#9252) by @IWANABETHATGUY - fix dead link to esbuild ESM/CJS interop tests (#9230) by @Copilot - remove CSS bundling references (#9234) by @shulaoda - correct IncrementalFullBuild row in BundleMode table (#9214) by @IWANABETHATGUY - design: add bundler data lifecycle design doc (#9212) by @hyf0 - remove minifier alpha status notices (#9202) by @sapphi-red ### ⚙️ Miscellaneous Tasks - upgrade oxc to 0.128.0 (#9260) by @shulaoda - deps: bump rolldown-ariadne to 0.6.0 (#9254) by @IWANABETHATGUY - deps: update github actions (#9259) by @renovate[bot] - deps: update github actions (#9258) by @renovate[bot] - remove renovate overrides (#9257) by @Boshen - use ubuntu-latest for security workflow (#9256) by @Boshen - notify Discord around release publish (#9251) by @Boshen - add release environment to npm publish workflow (#9250) by @Boshen - justfile: drop the `--` separator before forwarded args in `vp run` (#9246) by @shulaoda - deps: update test262 submodule for tests (#9243) by @sapphi-red - add more tracing instrumentations (#9220) by @sapphi-red - rolldown_plugin_vite_import_glob: remove outdated sourcemap doc comment (#9213) by @shulaoda - update security workflow (#9201) by @Boshen ### ❤️ New Contributors * @lazarv made their first contribution in [#9031](#9031) Co-authored-by: shulaoda <165626830+shulaoda@users.noreply.github.com>
Closes the
emit_chunkitem from the sync-NAPI deadlock audit in #7311.Symptom
Plugins that call
this.emitFile({ type: 'chunk', ... })from atransform(or any hook, really) hang rolldown indefinitely once enough emits accumulate. The hang is deterministic for tight emit loops (~1025 emits from a single hook) andnon-deterministic under parallelism — it can trigger with as few as ~400 emits spread across concurrent transforms. There is no error, no log, no stack trace from rolldown — the build just stops making progress. The main JS thread ends up
parked inside
_pthread_cond_waitundernapi_call_function → ... → block_on, and every tokio worker is parked at the samepthread_cond_wait.This was reproducible against
rolldown@1.0.0-rc.13on a real RSC plugin (@lazarv/react-server) building a Mantine-sized app (~3300"use client"modules, each emitted as an entry chunk), and thenminimised to 30 lines of plugin code.
Root cause
PluginContext.emitFile({ type: 'chunk' })is a sync napi binding. Until this PR it callednapi::bindgen_prelude::block_on(...)on the JS thread to drive anasync fn emit_chunkwhose two await points were:self.tx.lock().awaiton atokio::sync::Mutex<Option<Sender<ModuleLoaderMsg>>>send(AddEntryModule(...)).awaiton a boundedmpsc::channel(1024)shared with the module loaderThe bounded channel is the trap. Once it fills, the send future can only be unblocked by the module loader draining it. But draining each
AddEntryModulemessage dispatches plugin hooks (resolveId,load, furthertransforms) back tothe JS thread via TSFN — and the JS thread is pinned inside
block_onservicing the currentemit_chunk. The only consumer that can free capacity is waiting on the only thread that is blocked producing. Classic producer ⇄ consumer deadlockthrough TSFN.
A couple of subtleties worth calling out, because they explain why the bug was easy to miss:
while the channel stays saturated, so the producer hits
tx.send().awaitand hangs at a much smaller emit count. In local testing against unpatchedrc.13:buildStart)emitFilescale fine. A control experiment — 12 000 virtual inputs each going through atransformhook that does not callemitFile— completes cleanly. So the bottleneck is not transform dispatch, TSFNthroughput,
fetch_modules, or the loader loop. It is specificallyemit_chunk'sblock_on+ bounded-channel interaction, exactly as audit: Review sync NAPI bindings for deadlock safety #7311 predicted when it flaggedemit_chunkas MEDIUM risk.setImmediate/process.nextTick/Promise.resolveyields let libuv service TSFN callbacks, but at the point the producer is stuck inblock_onthe consumer cannot hand the JS threadanything to do — the loader is either blocked on the current transform's TSFN response, or has no work ready that does not depend on the blocked thread. The deadlock is structural, not a scheduling race.
"use client"file in a normal RSC project emits exactly one chunk from its owntransform. 3300 client components is a reasonable size for an app. No plugin is doing anythingpathological; the rolldown primitive simply does not survive its documented usage at scale.
Fix
Collapse the entire
emit_chunkpath to synchronous code and dropblock_onfrom the napi binding. There is no reason any of it was async:FileEmitter::emit_chunkis now a syncfn. Itstxis astd::sync::Mutex<Option<UnboundedSender<ModuleLoaderMsg>>>. The critical section isOption::clone()— theSenderis cheaply cloneable — then the lock is dropped beforesend.The install/clear path runs only at scan boundaries and is never contended with build traffic; the per-emit path's contention is bounded to nanoseconds (lock-free CAS on the clone).
BindingPluginContext::emit_chunkno longer enters the tokio runtime. It is now a plain sync binding with the same shape as the adjacentemit_file, marked// SYNC-SAFEper the convention introduced by fix(dev): makeregister_modulesasync #7289 / audit: Review sync NAPI bindings for deadlock safety #7311.PluginContext::emit_chunkandNativePluginContextImpl::emit_chunkare de-async'd to match.PluginDriver.txis unified onstd::sync::Mutexfor consistency withFileEmitter.tx— theloadhook holds it only to clone the sender and drop the lock before awaiting module-load completion.unbounded_channel().UnboundedSender::sendis synchronous and infallible, so even if a future refactor reintroduces a sync wait on this path, there is no.awaitthat can park the JS thread. All
tx.send(...).awaitcall sites inmodule_task.rs,external_module_task.rs,runtime_module_task.rs, andnative_plugin_context.rs::loadare updated accordingly.JS API impact
None.
this.emitFile({ type: 'chunk' })remains synchronous and returnsstringdirectly, matching Rollup'sPluginContext.emitFilecontract. No plugin needs to change.Tests
Two new deterministic regression fixtures under
packages/rolldown/tests/fixtures/plugin/context/:emit-chunk-many-from-transform— a singletransformhook emits 2000 chunks in a tight loop. Exercises the "tight emit loop" path. Deadlocks onmain, passes in ~470 ms with this fix.emit-chunk-many-parallel-inputs— 1500 virtual inputs, each with its owntransformemitting exactly one chunk. Exercises the realistic "large plugin at scale" path. Deadlocks onmain, passes in ~600 ms with this fix.Both are marked
sequential: trueand live alongside the existingplugin/context/emit-filefixtures. Fullfixture.test.tsrun is green (99/99).Relation to #7311
#7311 audited sync NAPI bindings after the dev-engine deadlock fixed in #7289. It explicitly flagged
emit_chunkas a MEDIUM-risk sync binding with deadlock potential, listed two possible resolutions (Option A: make async; Option B: addSYNC-SAFEcomment with justification), and left the task unchecked when the issue was closed as completed. This PR resolves that task by taking a third path: make the entire underlying implementation synchronous so the binding can staysync without
block_on. TheSYNC-SAFEcomment is added per convention. This avoids the breaking-change implications of makingthis.emitFilereturn aPromise.