Skip to content

fix(watch): avoid deadlock when closing from event callback#9475

Open
DaZuiZui wants to merge 1 commit into
rolldown:mainfrom
DaZuiZui:fix/issue-9462-watch-close
Open

fix(watch): avoid deadlock when closing from event callback#9475
DaZuiZui wants to merge 1 commit into
rolldown:mainfrom
DaZuiZui:fix/issue-9462-watch-close

Conversation

@DaZuiZui

Copy link
Copy Markdown
Contributor

Root Cause

watcher.close() deadlocked when it was awaited from inside a watcher event callback.

The watch coordinator emits events by awaiting the JS listener. In the reported reproduction, the listener receives BUNDLE_END and then awaits watcher.close(). The old close() implementation sent WatcherMsg::Close and immediately waited for the coordinator future to finish.

That creates a re-entrant wait cycle:

  1. the Rust coordinator is waiting for the current JS event callback to return;
  2. the JS callback is waiting for watcher.close() to resolve;
  3. watcher.close() is waiting for the same coordinator to finish;
  4. the coordinator cannot process the queued close message until the callback returns.

So the close request was queued correctly, but the coordinator never got a chance to observe it.

Fix

This PR separates "request close" from "wait until closed" for this re-entrant path.

  • Add a non-blocking request_close() method in the Rust watcher and expose it to JS as requestClose().
  • Track whether WatcherEmitter is currently dispatching an event callback.
  • When watcher.close() is called during event dispatch, stop JS workers and call requestClose() without awaiting the coordinator from inside the callback.
  • Keep the existing waiting behavior for normal watcher.close() calls outside event callbacks.
  • Keep async runtime shutdown tied to the watcher completion path.
  • Add a regression test for awaiting watcher.close() inside BUNDLE_END.
  • Document the watch-mode lifecycle edge case.

Fixes #9462.

Tests

  • PATH="$HOME/.cargo/bin:$PATH" just build-rolldown
  • PATH="$HOME/.cargo/bin:$PATH" cargo test -p rolldown_watcher --lib
  • PATH="$HOME/.cargo/bin:$PATH" RUST_BACKTRACE=1 ROLLDOWN_TEST=1 pnpm --filter rolldown-tests exec vitest run watch/watch.test.ts --reporter verbose --hideSkippedTests
  • Manual physical watcher test with await watcher.close() inside a BUNDLE_END callback
  • PATH="$HOME/.cargo/bin:$PATH" just lint-repo
  • PATH="$HOME/.cargo/bin:$PATH" just roll reached lint-publint; Rust tests, Node tests, watch/dev-watch tests, Rollup compatibility tests, formatting, and base lint passed. The final local failure was @rolldown/browser publint because packages/browser/dist/* was unavailable; rebuilding it was blocked by the local Rust toolchain not being able to install wasm32-wasip1-threads std for Rust 1.95.0.

@codspeed-hq

codspeed-hq Bot commented May 20, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 4 untouched benchmarks
⏩ 10 skipped benchmarks1


Comparing DaZuiZui:fix/issue-9462-watch-close (3cdb9ab) with main (381a058)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 (b88de34) during the generation of this report, so 381a058 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@hyf0 hyf0 self-assigned this May 20, 2026
hyf0 added a commit that referenced this pull request Jun 23, 2026
## Summary

- add an out-of-band close notification so the coordinator remains
responsive while awaiting consumer event callbacks
- stop waiting for an in-flight `event`, `change`, or `restart` callback
when that callback closes its own watcher, then run the normal complete
close sequence
- preserve sequential async listener behavior and the contract that
`await watcher.close()` settles only after hooks, bundler cleanup, and
the `close` event finish
- document the re-entrant close lifecycle and add an end-to-end
regression test

## Root cause

The coordinator awaited the JavaScript event callback, while a callback
awaiting `watcher.close()` waited for that same coordinator to finish:

```text
coordinator -> JS callback -> watcher.close() -> coordinator
```

The queued `WatcherMsg::Close` could not be processed until the callback
returned, so neither side could make progress.

## Design

`Watcher::close()` now both queues the normal state-machine message and
wakes a dedicated close notification. Consumer callback dispatch waits
on the callback and that notification together. If close wins, only the
Rust-side wait for the callback is dropped; the JavaScript promise
continues running while the coordinator executes the existing close
hooks, bundler cleanup, and close event. The original close promise then
resolves and the callback resumes.

This differs from the request-only approach in #9475: a re-entrant
`close()` does not resolve early before cleanup has completed.

Fixes #9462.

## Tests

- `just build-rolldown`
- `just build-rolldown-test-dev-server`
- `RUST_BACKTRACE=1 ROLLDOWN_TEST=1 pnpm --filter rolldown-tests
test:watcher` (36 passed)
- `cargo test -p rolldown_watcher --lib` (17 passed)
- `cargo clippy -p rolldown_watcher --all-targets -- --deny warnings`
- `just lint-repo`
- `vp check`
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.

[Bug]: watcher.close() deadlocks when called inside its event callback

2 participants