From dcc0daf9ba1222e90e50793021799408335d3ade Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Wed, 28 Oct 2026 12:51:51 -0700 Subject: [PATCH] fix(watchers): stop stat admission without listeners (#34870) Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../filesystem-watcher-local-events.test.ts | 66 +++++++++++-------- .../filesystem-watcher-local-events.ts | 2 +- 2 files changed, 40 insertions(+), 28 deletions(-) diff --git a/src/main/ipc/filesystem/watchers/filesystem-watcher-local-events.test.ts b/src/main/ipc/filesystem/watchers/filesystem-watcher-local-events.test.ts index eb8da3a8bac..5152ac3f314 100644 --- a/src/main/ipc/filesystem/watchers/filesystem-watcher-local-events.test.ts +++ b/src/main/ipc/filesystem/watchers/filesystem-watcher-local-events.test.ts @@ -285,34 +285,41 @@ describe('local filesystem watcher flush serialization', () => { ) }) - it('starts no further stats when a full inflight batch is cancelled', async () => { - const eventCount = 5_000 - const pendingStats = deferred<{ isDirectory: () => boolean }>() - statMock.mockReturnValue(pendingStats.promise) - const root = await createLocalWatcher('/repo', '/repo') - root.listeners.set(1, sender as never) + it.each(['cancelled', 'unwatched'] as const)( + 'starts no further stats when a full inflight batch is %s', + async (endState) => { + const eventCount = 5_000 + const pendingStats = deferred<{ isDirectory: () => boolean }>() + statMock.mockReturnValue(pendingStats.promise) + const root = await createLocalWatcher('/repo', '/repo') + root.listeners.set(1, sender as never) - watcherCallback?.( - null, - Array.from({ length: eventCount }, (_, index) => ({ - type: 'update' as const, - path: `/repo/file-${index}.ts` - })) - ) - vi.advanceTimersByTime(WATCH_BATCH_INTERVAL_MS) - await flushMicrotasks() - expect(statMock).toHaveBeenCalledTimes(8) + watcherCallback?.( + null, + Array.from({ length: eventCount }, (_, index) => ({ + type: 'update' as const, + path: `/repo/file-${index}.ts` + })) + ) + vi.advanceTimersByTime(WATCH_BATCH_INTERVAL_MS) + await flushMicrotasks() + expect(statMock).toHaveBeenCalledTimes(8) - cancelLocalBatchFlush(root) - pendingStats.resolve({ isDirectory: () => false }) - for (let i = 0; i < eventCount * 4 && root.batch.flushInFlight; i++) { - await Promise.resolve() + if (endState === 'cancelled') { + cancelLocalBatchFlush(root) + } else { + root.listeners.clear() + } + pendingStats.resolve({ isDirectory: () => false }) + for (let i = 0; i < eventCount * 4 && root.batch.flushInFlight; i++) { + await Promise.resolve() + } + + expect(root.batch.flushInFlight).toBe(false) + expect(statMock).toHaveBeenCalledTimes(8) + expect(sender.send).not.toHaveBeenCalled() } - - expect(root.batch.flushInFlight).toBe(false) - expect(statMock).toHaveBeenCalledTimes(8) - expect(sender.send).not.toHaveBeenCalled() - }) + ) it('leaves an open flush window to the armed timer instead of draining early', async () => { const firstStat = deferred<{ isDirectory: () => boolean }>() @@ -357,13 +364,18 @@ describe('local filesystem watcher flush serialization', () => { const listener = { ...sender, id: 7, removeListener: vi.fn(), on: vi.fn() } try { await subscribeLocalWatcher('/repo', listener as never) - watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) + watcherCallback?.(null, [{ type: 'create', path: '/repo/directory' }]) unsubscribeLocalWatcher('/repo', listener.id) await subscribeLocalWatcher('/repo', listener as never) - watcherCallback?.(null, [{ type: 'delete', path: '/repo/file.ts' }]) + watcherCallback?.(null, [{ type: 'create', path: '/repo/directory' }]) await vi.waitFor(() => expect(sender.send).toHaveBeenCalledTimes(1), { timeout: WATCH_BATCH_INTERVAL_MS * 20 }) + expect(subscribeMock).toHaveBeenCalledTimes(1) + expect(sender.send).toHaveBeenCalledWith('fs:changed', { + worktreePath: '/repo', + events: [{ kind: 'create', absolutePath: '/repo/directory', isDirectory: true }] + }) } finally { for (const teardown of watcherLifecycleState.pendingTeardowns.values()) { clearTimeout(teardown) diff --git a/src/main/ipc/filesystem/watchers/filesystem-watcher-local-events.ts b/src/main/ipc/filesystem/watchers/filesystem-watcher-local-events.ts index 889240a6112..bd6e17823b1 100644 --- a/src/main/ipc/filesystem/watchers/filesystem-watcher-local-events.ts +++ b/src/main/ipc/filesystem/watchers/filesystem-watcher-local-events.ts @@ -131,7 +131,7 @@ async function flushBatch(root: WatchedRoot): Promise { async (evt) => { // Deleted paths cannot be statted; let the renderer infer from dirCache. const isDirectory = - root.batch.cancelled || evt.type === 'delete' + root.batch.cancelled || root.listeners.size === 0 || evt.type === 'delete' ? undefined : await tryStatIsDirectory(evt.path)