You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
File watching: a native watcher leaks when the last listener goes away during an ignore refresh #9460
If the last listener of a watched root releases while refreshIgnores is waiting on backend.subscribe, the new subscription is stored on a state that is already disposed. Nothing unsubscribes it, not even manager.close(), so a native (parcel or inotify) watcher leaks for the life of the process. An earlier dispose can also log a false "ignore refresh re-attach failed" and start a recovery interval on the dead state.
Expected: a subscription that resolves after its watcher is disposed is closed at once.
Steps to reproduce
Watch a workspace.
Change .gitignore so a folder is no longer ignored (git-watcher calls refreshWatcherIgnores).
Close the last watch on that workspace while the refresh re-attaches.
See the native watcher still alive.
Logs, screenshots, or recordings
Found by model checking every order of events, then reproduced as a failing test against the real code. A fix with that regression test is ready.
Implementation notes
Files
packages/workspace-fs/src/watch.tsattachNativeSubscription and refreshIgnores. tryRecover already checks for this case.
Approach
In attachNativeSubscription, if the controller aborted while subscribe was pending, unsubscribe and throw. In the refreshIgnores catch, return false when aborted. This covers all three callers.
The report holds up when the code is read. FsWatcherManager.refreshIgnores drops the old native subscription and then calls attachNativeSubscription(state) again. Neither function checks, after backend.subscribe resolves, whether the state was disposed while the call was pending. If the last listener releases, or manager.close() runs, inside that window, disposeWatcherState has already removed the state from watchers, aborted it and set state.subscription = null. The new subscription is then written onto that dead state, and nothing will unsubscribe it again. The parcel/FSEvents/inotify watcher leaks until the process exits. If the dispose happens earlier in the re-attach (for example while the ignore providers or normalizePath are running), the attach throws an abort error. refreshIgnores then logs a false "ignore refresh re-attach failed — entering recovery" and starts a recovery interval on the disposed state.
Affected code
packages/workspace-fs/src/watch.ts:632-668: attachNativeSubscription. The only abort check is at :652, before the await this.backend.subscribe(...) at :668. The resolved subscription is assigned to state.subscription without checking again.
packages/workspace-fs/src/watch.ts:835-898: refreshIgnores. The ownership check at :868-873 runs before the re-attach. After await this.attachNativeSubscription(state) at :875 returns, nothing checks it again. The catch at :876-894 treats an abort the same as a real failure: it calls console.error and setInterval(tryRecover).
packages/workspace-fs/src/watch.ts:375-390 (release) and :405-413 (close): both call disposeWatcherState (:415-432), which only unsubscribes the subscription that exists at that moment. That is null during the swap gap.
For comparison, tryRecover (:1171-1179) already handles this race: it unsubscribes the orphaned subscription when this.watchers.get(state.absolutePath) !== state. In createWatcher, the .then at :352-355 also disposes a state created after an abort. refreshIgnores is the only caller with no guard.
There is a check-then-act gap across the await at watch.ts:668. refreshIgnores checks ownership at :868, then waits for backend.subscribe. A concurrent release()/close() disposes the state and finds subscription === null. The pending subscribe then resolves and assigns a live subscription to the disposed state. No later code path can reach it, because the state is no longer in this.watchers.
The secondary symptom is smaller. A recovery interval on the dead state is cleared on the first tryRecover tick (:1141-1146), so it does not leak. The error log is still wrong, though.
Reproduction sketch (bun:test)
FsWatcherManagerOptions.backend can be injected (watch.ts:264, :327), so this can be tested without real timing:
Write a fake NativeWatchBackend. Its subscribe returns a deferred promise you control, and each subscription it returns counts its unsubscribe calls.
Create new FsWatcherManager({ backend: fake, listGitIgnoredDirs: async () => ignored }) with ignored = ["buildout"] on a temp root. Then const release = await manager.subscribe({ absolutePath: root }, () => {}). Resolve the first subscribe right away.
Set ignored = [] and call const p = manager.refreshIgnores(root). Wait until the fake records the second subscribe call and leave it pending.
await release() (or await manager.close()), then resolve the pending subscribe and await p.
Assert that the second subscription was unsubscribed and that p resolved to false. On current main, the first assertion should fail.
A variant: call release() before step 3's providers resolve (make listGitIgnoredDirs deferred too). Assert that console.error was not called with the "re-attach failed" message.
watch-gitignored-prune.test.ts:239 (FsWatcherManager.refreshIgnores) is the natural home for this test. It must not wait on real timers, per the repo test rules.
Fix direction
The approach in the issue fits the existing code:
In attachNativeSubscription, after await this.backend.subscribe(...), check state.controller.signal.aborted. If it is set, await unsubscribeQuietly(subscription) and throw the abort reason. Only assign state.subscription if the check passes. This covers all three callers (createWatcher, refreshIgnores, tryRecover). It makes the after-the-fact cleanup in tryRecover (:1174-1179) a fallback, though keeping that cleanup is harmless.
In the refreshIgnores catch, return false without logging or starting recovery when state.controller.signal.aborted is set, or when this.watchers.get(state.absolutePath) !== state.
Note: this is based only on reading the code. I did not run anything.
Activity
github-actions commentedon Nov 7, 2026
Summary
The report holds up when the code is read.
FsWatcherManager.refreshIgnoresdrops the old native subscription and then callsattachNativeSubscription(state)again. Neither function checks, afterbackend.subscriberesolves, whether the state was disposed while the call was pending. If the last listener releases, ormanager.close()runs, inside that window,disposeWatcherStatehas already removed the state fromwatchers, aborted it and setstate.subscription = null. The new subscription is then written onto that dead state, and nothing will unsubscribe it again. The parcel/FSEvents/inotify watcher leaks until the process exits. If the dispose happens earlier in the re-attach (for example while the ignore providers ornormalizePathare running), the attach throws an abort error.refreshIgnoresthen logs a false "ignore refresh re-attach failed — entering recovery" and starts a recovery interval on the disposed state.Affected code
packages/workspace-fs/src/watch.ts:632-668:attachNativeSubscription. The only abort check is at:652, before theawait this.backend.subscribe(...)at:668. The resolved subscription is assigned tostate.subscriptionwithout checking again.packages/workspace-fs/src/watch.ts:835-898:refreshIgnores. The ownership check at:868-873runs before the re-attach. Afterawait this.attachNativeSubscription(state)at:875returns, nothing checks it again. The catch at:876-894treats an abort the same as a real failure: it callsconsole.errorandsetInterval(tryRecover).packages/workspace-fs/src/watch.ts:375-390(release) and:405-413(close): both calldisposeWatcherState(:415-432), which only unsubscribes the subscription that exists at that moment. That isnullduring the swap gap.tryRecover(:1171-1179) already handles this race: it unsubscribes the orphaned subscription whenthis.watchers.get(state.absolutePath) !== state. IncreateWatcher, the.thenat:352-355also disposes a state created after an abort.refreshIgnoresis the only caller with no guard.packages/host-service/src/events/git-watcher.ts:455-460→packages/host-service/src/runtime/filesystem/filesystem.ts:101-102.Suspected root cause
There is a check-then-act gap across the
awaitatwatch.ts:668.refreshIgnoreschecks ownership at:868, then waits forbackend.subscribe. A concurrentrelease()/close()disposes the state and findssubscription === null. The pending subscribe then resolves and assigns a live subscription to the disposed state. No later code path can reach it, because the state is no longer inthis.watchers.The secondary symptom is smaller. A recovery interval on the dead state is cleared on the first
tryRecovertick (:1141-1146), so it does not leak. The error log is still wrong, though.Reproduction sketch (
bun:test)FsWatcherManagerOptions.backendcan be injected (watch.ts:264,:327), so this can be tested without real timing:NativeWatchBackend. Itssubscribereturns a deferred promise you control, and each subscription it returns counts itsunsubscribecalls.new FsWatcherManager({ backend: fake, listGitIgnoredDirs: async () => ignored })withignored = ["buildout"]on a temp root. Thenconst release = await manager.subscribe({ absolutePath: root }, () => {}). Resolve the first subscribe right away.ignored = []and callconst p = manager.refreshIgnores(root). Wait until the fake records the secondsubscribecall and leave it pending.await release()(orawait manager.close()), then resolve the pending subscribe andawait p.presolved tofalse. On currentmain, the first assertion should fail.A variant: call
release()before step 3's providers resolve (makelistGitIgnoredDirsdeferred too). Assert thatconsole.errorwas not called with the "re-attach failed" message.watch-gitignored-prune.test.ts:239(FsWatcherManager.refreshIgnores) is the natural home for this test. It must not wait on real timers, per the repo test rules.Fix direction
The approach in the issue fits the existing code:
attachNativeSubscription, afterawait this.backend.subscribe(...), checkstate.controller.signal.aborted. If it is set,await unsubscribeQuietly(subscription)and throw the abort reason. Only assignstate.subscriptionif the check passes. This covers all three callers (createWatcher,refreshIgnores,tryRecover). It makes the after-the-fact cleanup intryRecover(:1174-1179) a fallback, though keeping that cleanup is harmless.refreshIgnorescatch, returnfalsewithout logging or starting recovery whenstate.controller.signal.abortedis set, or whenthis.watchers.get(state.absolutePath) !== state.Note: this is based only on reading the code. I did not run anything.