Fix filesystem watcher deletion bursts

Fixes #2409 by avoiding stack-overflowing watcher event accumulation and using overflow refreshes for oversized watcher batches.
This commit is contained in:
Neil 2026-05-20 00:55:35 -07:00 committed by GitHub
parent cf4bde846d
commit c386df6638
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 51 additions and 9 deletions

View File

@ -74,4 +74,34 @@ describe('local filesystem watcher large batches', () => {
await closeAllWatchers()
vi.useRealTimers()
})
it('emits one overflow event for oversized native watcher batches', async () => {
vi.useFakeTimers()
vi.mocked(stat).mockResolvedValue({ isDirectory: () => true } as never)
let watcherCallback: ((err: Error | null, events: WatcherEvent[]) => void) | undefined
vi.mocked(subscribeParcelWatcher).mockImplementation(async (_root, callback) => {
watcherCallback = callback as typeof watcherCallback
return { unsubscribe: vi.fn() } as never
})
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
await handlers['fs:watchWorktree']({ sender }, { worktreePath: '/tmp/repo' })
watcherCallback?.(
null,
Array.from(
{ length: 5_001 },
(_, index): WatcherEvent => ({ type: 'update', path: `/tmp/repo/file-${index}.txt` })
)
)
await vi.advanceTimersByTimeAsync(150)
expect(stat).toHaveBeenCalledTimes(1)
expect(sender.send).toHaveBeenCalledWith('fs:changed', {
worktreePath: '/tmp/repo',
events: [{ kind: 'overflow', absolutePath: '/tmp/repo' }]
})
await closeAllWatchers()
vi.useRealTimers()
})
})

View File

@ -36,6 +36,7 @@ const WATCHER_IGNORE_DIRS: string[] = [
const DEBOUNCE_TRAILING_MS = 150
const DEBOUNCE_MAX_WAIT_MS = 500
const MAX_BATCHED_CHANGE_EVENTS = 5_000
// ── Per-root watcher state ───────────────────────────────────────────
// WatchedRoot and WatcherSubscription are defined in filesystem-watcher-wsl.ts
@ -160,6 +161,18 @@ async function tryStatIsDirectory(filePath: string): Promise<boolean | undefined
// ── Flush and emit ───────────────────────────────────────────────────
function emitOverflowPayload(rootKey: string, root: WatchedRoot): void {
const payload: FsChangedPayload = {
worktreePath: rootKey,
events: [{ kind: 'overflow', absolutePath: rootKey }]
}
for (const [, wc] of root.listeners) {
if (!wc.isDestroyed()) {
wc.send('fs:changed', payload)
}
}
}
async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
const rawEvents = root.batch.events.splice(0)
root.batch.timer = null
@ -169,6 +182,13 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
return
}
if (rawEvents.length > MAX_BATCHED_CHANGE_EVENTS) {
// Why: deletion storms can be valid but too large to coalesce/stat/send
// per path. One overflow asks the renderer for the same conservative refresh.
emitOverflowPayload(rootKey, root)
return
}
const coalesced = coalesceEvents(rawEvents)
// Build the payload with isDirectory info
@ -258,15 +278,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise<Watched
// as overflow so the renderer conservatively refreshes all visible
// tree state rather than trusting possibly-invalid caches (§7.2, §7.3).
console.error(`[filesystem-watcher] error for ${rootKey}:`, err)
const overflowPayload: FsChangedPayload = {
worktreePath: rootKey,
events: [{ kind: 'overflow', absolutePath: rootKey }]
}
for (const [, wc] of root.listeners) {
if (!wc.isDestroyed()) {
wc.send('fs:changed', overflowPayload)
}
}
emitOverflowPayload(rootKey, root)
// Why: after a watcher error the native subscription may be invalid
// (e.g. watched root was deleted). Tear down the dead watcher so we
// don't leave a dangling subscription for a root that no longer