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:
parent
cf4bde846d
commit
c386df6638
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue