From aef2dc52819071dd0ff0fc4b8aafcabd1fca0eb9 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 20 May 2026 03:14:11 -0400 Subject: [PATCH] Fix filesystem watcher large event batches (#2413) Co-authored-by: Orca --- .../ipc/filesystem-watcher-event-batch.ts | 12 +++ .../filesystem-watcher-large-batch.test.ts | 77 +++++++++++++++++++ src/main/ipc/filesystem-watcher-wsl.test.ts | 14 ++++ src/main/ipc/filesystem-watcher-wsl.ts | 3 +- src/main/ipc/filesystem-watcher.ts | 3 +- 5 files changed, 107 insertions(+), 2 deletions(-) create mode 100644 src/main/ipc/filesystem-watcher-event-batch.ts create mode 100644 src/main/ipc/filesystem-watcher-large-batch.test.ts diff --git a/src/main/ipc/filesystem-watcher-event-batch.ts b/src/main/ipc/filesystem-watcher-event-batch.ts new file mode 100644 index 000000000..338a89506 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-event-batch.ts @@ -0,0 +1,12 @@ +import type { Event as WatcherEvent } from '@parcel/watcher' + +export function appendWatcherEvents( + batchEvents: WatcherEvent[], + incomingEvents: WatcherEvent[] +): void { + // Why: worktree deletion can deliver enough events that `push(...events)` + // exceeds V8's argument limit and crashes the main process. + for (const event of incomingEvents) { + batchEvents.push(event) + } +} diff --git a/src/main/ipc/filesystem-watcher-large-batch.test.ts b/src/main/ipc/filesystem-watcher-large-batch.test.ts new file mode 100644 index 000000000..dae7078f3 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-large-batch.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handleMock } = vi.hoisted(() => ({ + handleMock: vi.fn() +})) + +vi.mock('electron', () => ({ + ipcMain: { + handle: handleMock + } +})) + +vi.mock('fs/promises', () => ({ + stat: vi.fn() +})) + +vi.mock('@parcel/watcher', () => ({ + subscribe: vi.fn() +})) + +vi.mock('./filesystem-watcher-wsl', () => ({ + createWslWatcher: vi.fn() +})) + +vi.mock('../providers/ssh-filesystem-dispatch', () => ({ + getSshFilesystemProvider: vi.fn() +})) + +import { closeAllWatchers, registerFilesystemWatcherHandlers } from './filesystem-watcher' +import { stat } from 'fs/promises' +import { subscribe as subscribeParcelWatcher } from '@parcel/watcher' +import type { Event as WatcherEvent } from '@parcel/watcher' + +type HandlerMap = Record Promise | unknown> + +describe('local filesystem watcher large batches', () => { + const handlers: HandlerMap = {} + + beforeEach(async () => { + vi.useRealTimers() + handleMock.mockReset() + vi.mocked(stat).mockReset() + vi.mocked(subscribeParcelWatcher).mockReset() + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + handleMock.mockImplementation((channel, handler) => { + handlers[channel] = handler + }) + registerFilesystemWatcherHandlers() + await closeAllWatchers() + }) + + it('accepts a large local watcher event batch without overflowing V8 arguments', 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 + }) + + await handlers['fs:watchWorktree']( + { sender: { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 } }, + { worktreePath: '/tmp/repo' } + ) + + const events = Array.from( + { length: 200_000 }, + (_, index): WatcherEvent => ({ type: 'delete', path: `/tmp/repo/file-${index}` }) + ) + + expect(() => watcherCallback?.(null, events)).not.toThrow() + await closeAllWatchers() + vi.useRealTimers() + }) +}) diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index 6ac1beff9..9a2d379fe 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -142,4 +142,18 @@ describe('createWslWatcher', () => { expect(readPaths).not.toContain(path.join(rootPath, 'package.json')) await root.subscription.unsubscribe() }) + + it('accepts a large WSL poll event batch without overflowing V8 arguments', async () => { + const scheduleBatchFlush = vi.fn() + const initialEntries = Array.from({ length: 200_000 }, (_, index) => dirent(`file-${index}.ts`)) + + readdirMock.mockResolvedValueOnce(initialEntries).mockResolvedValueOnce([]) + + const root = await createWslWatcher(rootKey, rootPath, deps(scheduleBatchFlush)) + await vi.advanceTimersByTimeAsync(2_000) + + expect(scheduleBatchFlush).toHaveBeenCalledOnce() + expect(root.batch.events).toHaveLength(200_000) + await root.subscription.unsubscribe() + }) }) diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index 4aa0481f9..889ed9d70 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -14,6 +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' export type WatcherSubscription = { unsubscribe(): Promise @@ -155,7 +156,7 @@ export async function createWslWatcher( prevSnapshot = nextSnapshot if (events.length > 0) { - root.batch.events.push(...events) + appendWatcherEvents(root.batch.events, events) deps.scheduleBatchFlush(rootKey, root) } } catch { diff --git a/src/main/ipc/filesystem-watcher.ts b/src/main/ipc/filesystem-watcher.ts index afaeb9a8d..7d33757f5 100644 --- a/src/main/ipc/filesystem-watcher.ts +++ b/src/main/ipc/filesystem-watcher.ts @@ -12,6 +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' // ── Ignore patterns ────────────────────────────────────────────────── // Why: high-churn directories are suppressed at the native watcher level @@ -285,7 +286,7 @@ async function createWatcher(rootKey: string, rootPath: string): Promise