fix(watcher): serialize watch and close on the native watcher#14757
fix(watcher): serialize watch and close on the native watcher#14757stormslowly wants to merge 3 commits into
Conversation
📦 Binary Size-limit
❌ Size increased by 4.00KB from 66.51MB to 66.51MB (⬆️0.01%) |
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
Pull request overview
This PR addresses a macOS native watcher crash caused by a race between an in-flight watch task mutating FSEvents state and a concurrent close that frees that state. It does so by tracking the spawned watch task and coordinating task cancellation/teardown before dropping the underlying watcher.
Changes:
- Add
rspack_napi::runtime::spawn_handleto return aJoinHandlefor spawned tasks. - Track the native watcher’s in-flight watch task and abort it before starting a new watch cycle.
- Make
close()abort and await the in-flight watch task before closing the underlyingFsWatcher.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| crates/rspack_napi/src/runtime.rs | Adds spawn_handle() so callers can retain and manage spawned task JoinHandles. |
| crates/rspack_binding_api/src/native_watcher.rs | Stores the watch task handle and coordinates cancellation during re-watch and close to avoid macOS FSEvents teardown races. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
`NativeWatcher::watch` spawns a detached task that drives the FSEvents backend's `append_path` (mutating the watcher's CFMutableArray), while `NativeWatcher::close` drops the watcher (`CFRelease` of that array). Nothing kept the two apart, so the close-old-then-watch-new restart flow could free the CFArray while a watch task was still appending to it — a use-after-free that aborts the process on macOS. Guard every access to the watcher with an async mutex: a watch task acquires it before touching the watcher and holds it for the whole `watch()`, and `close` aborts the in-flight task and then acquires the same mutex before tearing the watcher down. A future can only be cancelled at an `.await`, never mid-FFI, so an aborted task always releases the guard with the watcher in a consistent state, and holding the mutex in `close` proves no task is inside `append_path`. Adds `rspack_napi::runtime::spawn_handle` to retain the JoinHandle needed to abort.
Drives `binding.NativeWatcher` directly: several overlapping watch tasks on one watcher followed by an immediate close, repeated. Reproduces the use-after-free (SIGSEGV) on every run without the fix.
c4db59c to
b082901
Compare
There was a problem hiding this comment.
why need spawn_handle here and what does spawn_handle mean here?
| * This function is unsafe because it uses `&mut self` to call the watcher asynchronously. | ||
| * It's important to ensure that the watcher is not used in any other places before this function is finished. | ||
| * You must ensure that the watcher not call watch, close or pause in the same time, otherwise it may lead to undefined behavior. | ||
| * `watch` and `close` are serialized internally, but calling `pause` while this function is |
There was a problem hiding this comment.
so calling pause while watching still can cause panic | abort issue?
| * This function is unsafe because it uses `&mut self` to call the watcher asynchronously. | ||
| * It's important to ensure that the watcher is not used in any other places before this function is finished. | ||
| * You must ensure that the watcher not call watch, close or pause in the same time, otherwise it may lead to undefined behavior. | ||
| * `watch` and `close` are serialized internally, but calling `pause` while this function is |
There was a problem hiding this comment.
this api's semantic is not same as js version
Why
On macOS, tearing down and restarting the native watcher (
experiments.nativeWatcher) intermittently aborts the process:Symbolicated crash stack (release-debug build), on a tokio worker thread:
A use-after-free between two entry points that were never kept apart:
watch()spawns a detached task and returns to JS immediately. The task registers paths with FSEvents viaappend_path, which mutates the watcher'sCFMutableArray(self.paths).close()drops the watcher —Drop→CFRelease(self.paths).NativeWatchFileSystemmemoizes oneNativeWatcherper compiler and clears#inneronly afterclose()'s promise resolves. The# Safetynote oncloseasserted callers must not overlap the two, but nothing enforced it.So
append_pathwrites into the CFArray whileclosefrees it. The freed slot gets reused by anos_logobject, and the nextCFArrayAppendValuedispatchesaddObject:to a non-array → NSException → SIGABRT.Where the window is.
DiskWatcher::watchcalls into notify only for patterns it isn't already watching, and on macOSWatcherRootAnalyzercollapses every dependency into a single recursive common root that is stable across rebuilds.append_paththerefore runs on a watcher's firstwatch()— one recursive FSEvents subscription over the whole project root, which is far from instantaneous — while steady-state rebuilds dedupe to a no-op. The race is aclose()(or a secondwatch(), which would also see an empty pattern set) landing inside that first registration.Instrumenting a real consumer confirms it is reachable: Rstest rebuilds its dev server whenever a config file changes, and its watch-mode restart test edits the config while startup is still in progress.
close()arrives 3 ms after the firstwatch()on the same memoizedNativeWatcher, and the process aborts inappend_path.This is distinct from the
analyzer/root.rspath-tree assert — it still reproduces with that fix applied.What
Serialize the two entry points that reach the disk watcher with an async mutex:
watcher.watch();closeaborts the in-flight watch task, then acquires the same mutex before tearing the watcher down.watchandcloseare the only paths that reachDiskWatcher(FsWatcher::watch→wait_for_event→disk_watcher.watch(), andFsWatcher::close→disk_watcher.close()).pauseandtrigger_eventtake&selfand only touch the executor / trigger, never the FSEvents state, so they stay outside the lock — the# Safetynote is updated to say exactly that.Cancellation-safe by construction: a future can only be dropped at an
.await, never mid-FFI, so an aborted task always releases the guard with the watcher in a consistent state. Holding the mutex incloseis proof that no task is insideappend_path. It cannot deadlock either —abort()always precedes the lock wait, so the current holder is guaranteed to reach an await and release.Also adds
rspack_napi::runtime::spawn_handle, which returns theJoinHandleneeded to abort (the existingspawndrops it).Test
tests/rspack-test/NativeWatcherLifecycle.test.jsdrivesbinding.NativeWatcherdirectly: overlapping watch tasks on a fresh watcher followed by an immediate close, repeated. The file set is large enough that the work precedingappend_path(path-manager update, mtime baselines, path-tree rebuild) keeps the registration in flight long enough for the close to land on it. It runs in thenativeWatcherproject (retry: 0,maxConcurrency: 1) so a native abort cannot be masked by a retry.Verification
macOS arm64, binding built from this branch:
NativeWatcherLifecycleNativeWatcher.part1NativeWatcher.part2NativeWatcher.part3part1/2/3matter because the watch task now holds the mutex across the wholewatch(), including the long event-await — they confirm real watch cycles are not stalled by it.