[Bug]: nativeWatcher emits late invalid hooks followed by empty MultiCompiler rebuilds
Summary
With Rspack 2.2.4, experiments.nativeWatcher: true and watchOptions.aggregateTimeout: 0, we observe a concrete file's invalid hook after watchRun for a compilation whose modifiedFiles already contains that file. MultiCompiler subsequently runs another compilation with empty modifiedFiles and removedFiles.
Source inspection suggests a possible cross-callback ordering race: the native watcher has independent aggregate and undelayed delivery paths, without a barrier ensuring that a batch's undelayed notifications reach JavaScript before its aggregate callback starts compilation. We have not isolated this proposed cause in a standalone reproduction; the measured behavior and the source-based hypothesis are distinguished below.
Environment and observed behavior
- Linux; Node.js 24.19.0; Rspack 2.2.4; Rsbuild 2.2.6.
- MultiCompiler with a web compiler using the native watcher and a node compiler using Watchpack.
- Both compilers use
aggregateTimeout: 0; the ignored pattern is/[\\/](?:\.git|node_modules)[\\/]/. - No Rspack core patch. The application has a local Router plugin patch, so the application measurement alone is not a minimal core reproduction.
In a warmed development session, we made three isolated component edits, waiting for each update before making the next edit. For each edit, the first web watchRun included the edited file, represented here as src/component.tsx, and two watched contexts. A concrete-file invalid followed, and an additional web compilation ran with both file sets empty. There was one node compilation per edit.
All times below are milliseconds. The first web watchRun is time zero separately for each edit.
| Edit | Concrete-file invalid after first watchRun |
Extra web compilation's modified/removed files | Extra web watchRun → afterDone |
|---|---|---|---|
| 1 | 0.859 | [] / [] |
810.807 |
| 2 | 5.023 | [] / [] |
669.000 |
| 3 | 0.588 | [] / [] |
667.941 |
The controlled edit sequence contained no second source write during each update. The capture records JavaScript compiler hooks, not native batch identifiers; it therefore establishes the callback chronology and redundant compilation, but does not distinguish multiple OS notifications for one write. The code path below permits this ordering, but the hook trace alone cannot identify the native batch responsible.
Reproduction status
The affected workload is a large downstream development graph using Rsbuild 2.2.6 and rsbuild-plugin-react-router 0.7.1 with a local HDR-notification patch. The configuration change was limited to enabling the web compiler's native watcher; the node compiler retained Watchpack. The three source writes were isolated by ten-second quiet windows. With Watchpack on both compilers, the preceding three-edit control had one web and one node compilation per edit; the native-web arm had two web and one node compilation per edit. These were sequential runs in different server processes, so this is not a randomized end-to-end speed comparison.
A separate plain-JavaScript MultiCompiler fixture did not reproduce the native-client symptom in either of these controls (three serial writes per case, no artificial hook delay):
- File dependencies only: native client's file invalidation preceded watchRun on all three edits, one native-client compilation per edit, zero empty runs.
- The same fixture with two parent context dependencies on the native client and one on the Watchpack node compiler: the same negative result on all three native-client edits.
Thus this report currently provides repeatable downstream traces and a source-supported hypothesis, not a minimized failing core reproduction. Context dependencies alone are insufficient to reproduce it. We have not tested the open PRs below at runtime.
To capture the ordering in an affected application, enable only the web environment's nativeWatcher, preserve aggregateTimeout=0, and record invalid, watchRun, and afterDone for both compilers. Log modifiedFiles and removedFiles at watchRun, edit an existing watched component once, and keep recording after the update through a quiet interval. In our traces the file-specific invalid occurs after a watchRun that already lists that file, followed by a web watchRun with both sets empty. No manual invalidate call is made by the edit driver.
Source analysis at v2.2.4
The executor records incoming files, enqueues aggregate execution, and then separately enqueues undelayed execution. See executor.rs, lines 136–158. Undelayed and aggregate execution run in separate Tokio tasks. The aggregate task waits for the configured timeout, but not for completion of the batch's undelayed delivery.
The binding uses separate nonblocking N-API threadsafe functions for aggregate delivery and undelayed delivery. Enqueuing either function does not acknowledge execution of its JavaScript callback.
In JavaScript, the aggregate callback starts the rebuild and the separate change callback invokes
callbackUndelayed.Watchingforwards the latter toonInvalid. It clears the reported-invalidation flag beforewatchRun, allowing that delayed notification to report another invalidation.MultiCompiler's
nodeInvalidchanges a running node torunning-outdated. On completion, it queues that node again. Its watch runner invokeswatching.invalidate(), even if the original compilation already consumed the file set.
Expected behavior and possible fix
A batch's file invalidations should reach the JavaScript consumer before its aggregate callback can start the corresponding compilation. A genuinely newer edit during compilation, including another edit to the same path, must still invalidate that compilation.
Addressing the suspected ordering hazard requires an invariant across the native executor and JavaScript delivery boundary: for example, a serialized delivery path, or sequence-aware batch delivery with an acknowledgement/barrier. Merely swapping the two Rust enqueue statements does not establish that invariant across separate tasks and callback queues. Increasing the debounce timeout can reduce the race probability but does not establish ordering. Suppressing every invalidation during compilation, or deduplicating by filename alone, would risk losing real subsequent edits.
Suggested regression tests
- A deterministic native/binding test that makes aggregate execution ready while the same batch's undelayed delivery is delayed. With a zero aggregate timeout, assert the JavaScript file notification precedes aggregate delivery. Exercise the actual JavaScript bridge, since ordering only the Rust handler calls is insufficient.
- A MultiCompiler integration test using plain JavaScript and one controlled file write. Assert the edited file's invalidation precedes its
watchRunand no subsequent empty compilation is scheduled for that same batch. Keep the watcher alive through a bounded quiet period so an extra compile cannot escape the assertion. - A positive control that writes the same file again while compilation is deliberately held open. Assert the second edit is consumed by a subsequent compilation. This prevents a fix from hiding the race by dropping valid invalidations.
Related work and scope
This differs from #14766: the concrete callback here is delivered late, rather than missing because of raw-path conversion or a full callback queue. Queue loss can coexist with the ordering problem, but is not required for it.
The current source diffs of #14843 and #14772 preserve raw events and add aggregate claim/acknowledge/drain generations, while retaining separate aggregate and undelayed executor tasks and JavaScript callback functions. Their generation checks govern aggregate delivery and stale watcher instances; they do not establish an ordering barrier for a current watcher's raw notifications. #14772 additionally lets Watching coalesce in-flight invalidations instead of always marking the MultiCompiler node outdated; this changes where the follow-up compilation is scheduled, but still treats an already-started batch's delayed raw notification as a new invalidation. These are source observations, not runtime results against those PRs.
This report concerns callback order and redundant compilation. It does not attribute browser reloads, stale data, or the separate unexplained rapid-edit validation failure to this race.
Also related, but distinct: #15019 tracks invalidation provenance and draining pending Watchpack events.
Source: web-infra-dev/rspack