fix: cancel watcher batches after terminal errors

This commit is contained in:
Orca Worker
2026-09-12 00:19:18 -07:00
parent 2dce9f9ed7
commit 8e4f7015ca
2 changed files with 32 additions and 7 deletions
@@ -91,7 +91,7 @@ describe('local filesystem watcher flush serialization', () => {
expect(root.batch.timer).toBeNull()
})
it('can schedule a late event after an error clears the pending timer', async () => {
it('discards queued and late events after a terminal watcher error', async () => {
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const root = await createLocalWatcher('/repo', '/repo')
@@ -102,7 +102,35 @@ describe('local filesystem watcher flush serialization', () => {
watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
expect(sender.send).toHaveBeenCalledTimes(2)
expect(sender.send).toHaveBeenCalledTimes(1)
expect(root.batch.cancelled).toBe(true)
expect(root.batch.events).toEqual([])
expect(root.batch.timer).toBeNull()
} finally {
errorLog.mockRestore()
}
})
it('suppresses an inflight batch and its queued drain after a terminal watcher error', async () => {
const errorLog = vi.spyOn(console, 'error').mockImplementation(() => {})
const pendingStat = deferred<{ isDirectory: () => boolean }>()
statMock.mockReturnValueOnce(pendingStat.promise)
try {
const root = await createLocalWatcher('/repo', '/repo')
root.listeners.set(1, sender as never)
watcherCallback?.(null, [{ type: 'update', path: '/repo/first.ts' }])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
expect(statMock).toHaveBeenCalledTimes(1)
watcherCallback?.(null, [{ type: 'update', path: '/repo/queued.ts' }])
watcherCallback?.(new Error('watcher interrupted'), [])
pendingStat.resolve({ isDirectory: () => false })
vi.advanceTimersByTime(WATCH_BATCH_MAX_WAIT_MS)
await flushMicrotasks()
expect(sender.send).toHaveBeenCalledTimes(1)
expect(statMock).toHaveBeenCalledTimes(1)
expect(root.batch.events).toEqual([])
expect(root.batch.timer).toBeNull()
} finally {
errorLog.mockRestore()
}
@@ -16,7 +16,7 @@ import {
retainLocalWatcherPhysicalFailure,
trackDetachedLocalUnsubscribe
} from './filesystem-watcher-listener-lifecycle'
import { createDebouncedBatch } from './filesystem-watcher-batch-control'
import { cancelLocalBatchFlush, createDebouncedBatch } from './filesystem-watcher-batch-control'
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
// Why: matches the watcher subprocess budget in parcel-watcher-event-delivery.ts.
@@ -258,10 +258,7 @@ export async function createLocalWatcher(
console.error(`[filesystem-watcher] error for ${rootKey}:`, err)
emitOverflowPayload(root)
// Why: after an error the native subscription may be invalid (deleted root); tear down the dead watcher so it doesn't dangle (§7.3).
if (root.batch.timer) {
clearTimeout(root.batch.timer)
root.batch.timer = null
}
cancelLocalBatchFlush(root)
// Why: error callback can fire before subscribe() assigns root.subscription; guard against null so cleanup doesn't crash.
if (root.subscription) {
retainLocalWatcherPhysicalFailure(rootKey, err)