From 8e4f7015ca7befd9818f4ab17cc587e56b9501e8 Mon Sep 17 00:00:00 2001 From: Orca Worker Date: Sat, 12 Sep 2026 00:19:18 -0700 Subject: [PATCH] fix: cancel watcher batches after terminal errors --- .../filesystem-watcher-local-events.test.ts | 32 +++++++++++++++++-- .../ipc/filesystem-watcher-local-events.ts | 7 ++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/main/ipc/filesystem-watcher-local-events.test.ts b/src/main/ipc/filesystem-watcher-local-events.test.ts index 4212703d5dc..be289de2145 100644 --- a/src/main/ipc/filesystem-watcher-local-events.test.ts +++ b/src/main/ipc/filesystem-watcher-local-events.test.ts @@ -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() } diff --git a/src/main/ipc/filesystem-watcher-local-events.ts b/src/main/ipc/filesystem-watcher-local-events.ts index 604e38458d8..71aa00a2906 100644 --- a/src/main/ipc/filesystem-watcher-local-events.ts +++ b/src/main/ipc/filesystem-watcher-local-events.ts @@ -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)