fix: null the cleared batch timer on last-listener unsubscribe

`Timeout.refresh()` is a no-op on a handle already passed to
`clearTimeout`. `unsubscribeLocalWatcher` cleared `root.batch.timer`
without nulling it, so a re-subscribe inside the teardown grace window
reused the root with a dead handle and `scheduleLocalBatchFlush` never
re-armed — fs change events for that root stopped reaching the renderer.

- Null `root.batch.timer` after clearing it in the unsubscribe path.
- Add a real-timer regression test covering unsubscribe + re-subscribe
  within the grace window; it fails on the previous PR head.
This commit is contained in:
Neil
2026-09-12 18:04:07 -07:00
parent 8e4f7015ca
commit 3193aec627
2 changed files with 29 additions and 0 deletions
@@ -16,6 +16,11 @@ vi.mock('./parcel-watcher-process', () => ({ subscribeViaWatcherProcess: subscri
import { createLocalWatcher } from './filesystem-watcher-local-events'
import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control'
import {
subscribeLocalWatcher,
unsubscribeLocalWatcher
} from './filesystem-watcher-local-subscription'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
@@ -326,4 +331,26 @@ describe('local filesystem watcher flush serialization', () => {
{ kind: 'update', absolutePath: otherPath, isDirectory: false }
])
})
it('re-arms the debounce window after a re-subscribe inside the teardown grace period', async () => {
// Why real timers: fake-timers' refresh() revives a cleared handle, but Node's is a no-op — the bug only shows on real Timeouts.
vi.useRealTimers()
statMock.mockResolvedValue({ isDirectory: () => true })
const listener = { ...sender, id: 7, once: vi.fn() }
try {
await subscribeLocalWatcher('/repo', listener as never)
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
unsubscribeLocalWatcher('/repo', listener.id)
await subscribeLocalWatcher('/repo', listener as never)
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
await new Promise((resolve) => setTimeout(resolve, WATCH_BATCH_TRAILING_MS + 50))
expect(sender.send).toHaveBeenCalledTimes(1)
} finally {
for (const teardown of watcherLifecycleState.pendingTeardowns.values()) {
clearTimeout(teardown)
}
watcherLifecycleState.pendingTeardowns.clear()
watcherLifecycleState.watchedRoots.clear()
}
})
})
@@ -199,6 +199,8 @@ export function unsubscribeLocalWatcher(worktreePath: string, senderId: number):
if (root.listeners.size === 0) {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
// Why: a cleared handle can't be refresh()ed; null it so a grace-window re-subscribe arms a fresh window.
root.batch.timer = null
}
// Why: duplicate unwatch calls for a root would leak overwritten grace timers; keep just one.
if (watcherLifecycleState.pendingTeardowns.has(rootKey)) {