diff --git a/src/main/ipc/filesystem-watcher-event-batch.test.ts b/src/main/ipc/filesystem-watcher-event-batch.test.ts new file mode 100644 index 000000000..ff166b7b8 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-event-batch.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import type { Event as WatcherEvent } from '@parcel/watcher' +import { + MAX_BATCHED_WATCHER_EVENTS, + queueWatcherEvents, + type WatcherEventBatchState +} from './filesystem-watcher-event-batch' + +describe('queueWatcherEvents', () => { + function events(count: number): WatcherEvent[] { + return Array.from( + { length: count }, + (_, index): WatcherEvent => ({ type: 'update', path: `/repo/file-${index}.ts` }) + ) + } + + it('keeps precise events while the batch remains under the overflow limit', () => { + const batch: WatcherEventBatchState = { events: [], overflowed: false } + + queueWatcherEvents(batch, events(2)) + + expect(batch.overflowed).toBe(false) + expect(batch.events.map((event) => event.path)).toEqual(['/repo/file-0.ts', '/repo/file-1.ts']) + }) + + it('marks overflow without retaining oversized event lists', () => { + const batch: WatcherEventBatchState = { events: events(2), overflowed: false } + + queueWatcherEvents(batch, events(MAX_BATCHED_WATCHER_EVENTS)) + queueWatcherEvents(batch, events(1)) + + expect(batch.overflowed).toBe(true) + expect(batch.events).toHaveLength(0) + }) +}) diff --git a/src/main/ipc/filesystem-watcher-event-batch.ts b/src/main/ipc/filesystem-watcher-event-batch.ts index 338a89506..761cf3524 100644 --- a/src/main/ipc/filesystem-watcher-event-batch.ts +++ b/src/main/ipc/filesystem-watcher-event-batch.ts @@ -1,5 +1,12 @@ import type { Event as WatcherEvent } from '@parcel/watcher' +export const MAX_BATCHED_WATCHER_EVENTS = 5_000 + +export type WatcherEventBatchState = { + events: WatcherEvent[] + overflowed: boolean +} + export function appendWatcherEvents( batchEvents: WatcherEvent[], incomingEvents: WatcherEvent[] @@ -10,3 +17,22 @@ export function appendWatcherEvents( batchEvents.push(event) } } + +export function queueWatcherEvents( + batch: WatcherEventBatchState, + incomingEvents: WatcherEvent[] +): void { + if (batch.overflowed) { + return + } + + if (batch.events.length + incomingEvents.length > MAX_BATCHED_WATCHER_EVENTS) { + // Why: once precision is too expensive, keeping every path only burns + // memory before flush sends the same conservative overflow refresh. + batch.events = [] + batch.overflowed = true + return + } + + appendWatcherEvents(batch.events, incomingEvents) +} diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 9a2d379fe..c6536338b 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -143,7 +143,7 @@ describe('createWslWatcher', () => { await root.subscription.unsubscribe() }) - it('accepts a large WSL poll event batch without overflowing V8 arguments', async () => { + it('marks a large WSL poll event batch for overflow without retaining every event', async () => { const scheduleBatchFlush = vi.fn() const initialEntries = Array.from({ length: 200_000 }, (_, index) => dirent(`file-${index}.ts`)) @@ -153,7 +153,8 @@ describe('createWslWatcher', () => { await vi.advanceTimersByTimeAsync(2_000) expect(scheduleBatchFlush).toHaveBeenCalledOnce() - expect(root.batch.events).toHaveLength(200_000) + expect(root.batch.events).toHaveLength(0) + expect(root.batch.overflowed).toBe(true) await root.subscription.unsubscribe() }) }) diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index 889ed9d70..0955207d0 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -14,7 +14,7 @@ import { readdir } from 'fs/promises' import * as path from 'path' import type { WebContents } from 'electron' import type { Event as WatcherEvent } from '@parcel/watcher' -import { appendWatcherEvents } from './filesystem-watcher-event-batch' +import { queueWatcherEvents } from './filesystem-watcher-event-batch' export type WatcherSubscription = { unsubscribe(): Promise @@ -22,6 +22,7 @@ export type WatcherSubscription = { type DebouncedBatch = { events: WatcherEvent[] + overflowed: boolean timer: ReturnType | null firstEventAt: number } @@ -134,7 +135,7 @@ export async function createWslWatcher( const root: WatchedRoot = { subscription: null!, listeners: new Map(), - batch: { events: [], timer: null, firstEventAt: 0 } + batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 } } // Take initial snapshot @@ -156,7 +157,7 @@ export async function createWslWatcher( prevSnapshot = nextSnapshot if (events.length > 0) { - appendWatcherEvents(root.batch.events, events) + queueWatcherEvents(root.batch, events) deps.scheduleBatchFlush(rootKey, root) } } catch { diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index 6d77a383f..2a3b27d97 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -12,7 +12,7 @@ import { isWslPath } from '../wsl' import { createWslWatcher } from './filesystem-watcher-wsl' import type { WatchedRoot } from './filesystem-watcher-wsl' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' -import { appendWatcherEvents } from './filesystem-watcher-event-batch' +import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch' // ── Ignore patterns ────────────────────────────────────────────────── // Why: high-churn directories are suppressed at the native watcher level @@ -36,7 +36,6 @@ 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 @@ -174,15 +173,17 @@ function emitOverflowPayload(rootKey: string, root: WatchedRoot): void { } async function flushBatch(rootKey: string, root: WatchedRoot): Promise { + const overflowed = root.batch.overflowed const rawEvents = root.batch.events.splice(0) + root.batch.overflowed = false root.batch.timer = null root.batch.firstEventAt = 0 - if (rawEvents.length === 0 || root.listeners.size === 0) { + if ((rawEvents.length === 0 && !overflowed) || root.listeners.size === 0) { return } - if (rawEvents.length > MAX_BATCHED_CHANGE_EVENTS) { + if (overflowed || rawEvents.length > MAX_BATCHED_WATCHER_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) @@ -252,7 +253,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise