mirror of
https://github.com/stablyai/orca.git
synced 2026-09-26 16:02:43 +00:00
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:
@@ -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)) {
|
||||
|
||||
Reference in New Issue
Block a user