fix(watchers): stop stat admission without listeners (#34870)

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 12:51:51 -07:00
committed by GitHub
co-authored by OrcaWin
parent 278b02b27a
commit dcc0daf9ba
2 changed files with 40 additions and 28 deletions
@@ -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)
@@ -131,7 +131,7 @@ async function flushBatch(root: WatchedRoot): Promise<void> {
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)