From a4deeb3745e00c158fb2da3c6dc003879a9eeb2e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 30 May 2026 03:30:01 -0700 Subject: [PATCH] Bound runtime file-watch event batches --- .../methods/file-watch-event-batcher.test.ts | 60 ++++++++++--------- .../rpc/methods/file-watch-event-batcher.ts | 28 +++++++-- 2 files changed, 57 insertions(+), 31 deletions(-) diff --git a/src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts b/src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts index ee46aa8f66c..75af9a11bd8 100644 --- a/src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts +++ b/src/main/runtime/rpc/methods/file-watch-event-batcher.test.ts @@ -1,38 +1,44 @@ -import { tmpdir } from 'os' -import * as path from 'path' import { describe, expect, it, vi } from 'vitest' import type { FsChangeEvent } from '../../../../shared/types' import { createFileWatchEventBatcher } from './file-watch-event-batcher' -const LARGE_EVENT_COUNT = 150_000 - -function buildFileWatchEvents(count: number): FsChangeEvent[] { - const events: FsChangeEvent[] = [] - const root = path.join(tmpdir(), 'orca-file-watch-batch') - for (let index = 0; index < count; index += 1) { - events.push({ - kind: 'update', - absolutePath: path.join(root, `file-${index}.txt`) - }) - } - return events -} - describe('createFileWatchEventBatcher', () => { - it('accepts large watcher event bursts', () => { + it('keeps precise watcher events while the batch is under the overflow limit', () => { const emit = vi.fn() - const batcher = createFileWatchEventBatcher(path.join(tmpdir(), 'worktree'), emit) + const batcher = createFileWatchEventBatcher('worktree-1', emit) + const events: FsChangeEvent[] = [ + { kind: 'update', absolutePath: '/repo/file-a.ts' }, + { kind: 'delete', absolutePath: '/repo/file-b.ts' } + ] - batcher.push(buildFileWatchEvents(LARGE_EVENT_COUNT)) + batcher.push(events) batcher.flush() - const payload = emit.mock.calls[0]?.[0] as - | { type: string; worktree: string; events: FsChangeEvent[] } - | undefined - expect(payload?.type).toBe('changed') - expect(payload?.events).toHaveLength(LARGE_EVENT_COUNT) - expect(payload?.events.at(-1)?.absolutePath).toBe( - path.join(tmpdir(), 'orca-file-watch-batch', `file-${LARGE_EVENT_COUNT - 1}.txt`) - ) + expect(emit).toHaveBeenCalledWith({ + type: 'changed', + worktree: 'worktree-1', + events + }) + }) + + it('collapses very large watcher bursts without spreading them onto the stack', () => { + vi.useFakeTimers() + try { + const emit = vi.fn() + const batcher = createFileWatchEventBatcher('worktree-1', emit) + const event: FsChangeEvent = { kind: 'update', absolutePath: '/repo/file.ts' } + const burst = Array.from({ length: 200_000 }, () => event) + + expect(() => batcher.push(burst)).not.toThrow() + batcher.flush() + + expect(emit).toHaveBeenCalledWith({ + type: 'changed', + worktree: 'worktree-1', + events: [{ kind: 'overflow', absolutePath: '/repo/file.ts' }] + }) + } finally { + vi.useRealTimers() + } }) }) diff --git a/src/main/runtime/rpc/methods/file-watch-event-batcher.ts b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts index 642d8e10741..de96b7b6855 100644 --- a/src/main/runtime/rpc/methods/file-watch-event-batcher.ts +++ b/src/main/runtime/rpc/methods/file-watch-event-batcher.ts @@ -2,6 +2,7 @@ import type { FsChangeEvent } from '../../../../shared/types' const FILE_WATCH_FLUSH_MS = 150 const FILE_WATCH_MAX_WAIT_MS = 500 +const MAX_FILE_WATCH_BATCH_EVENTS = 5_000 export function createFileWatchEventBatcher( worktree: string, @@ -12,6 +13,7 @@ export function createFileWatchEventBatcher( dispose: () => void } { let events: FsChangeEvent[] = [] + let overflowed = false let timer: ReturnType | null = null let firstEventAt = 0 @@ -26,6 +28,7 @@ export function createFileWatchEventBatcher( const flush = (): void => { clearTimer() const nextEvents = events.splice(0) + overflowed = false firstEventAt = 0 if (nextEvents.length === 0) { return @@ -38,10 +41,26 @@ export function createFileWatchEventBatcher( if (nextEvents.length === 0) { return } - // Why: file watchers can report huge bursts; spreading them into push can - // exceed JavaScript's argument limit before the batch has a chance to flush. - for (const event of nextEvents) { - events.push(event) + if (!overflowed) { + const incomingOverflow = nextEvents.find((event) => event.kind === 'overflow') + if (incomingOverflow) { + events = [incomingOverflow] + overflowed = true + } else if (events.length + nextEvents.length > MAX_FILE_WATCH_BATCH_EVENTS) { + // Why: once precision is too expensive to retain and stream, one + // overflow asks clients to refresh safely without a huge payload. + events = [ + { + kind: 'overflow', + absolutePath: nextEvents[0]?.absolutePath ?? events[0]?.absolutePath ?? '' + } + ] + overflowed = true + } else { + for (const event of nextEvents) { + events.push(event) + } + } } const now = Date.now() if (firstEventAt === 0) { @@ -63,6 +82,7 @@ export function createFileWatchEventBatcher( dispose(): void { clearTimer() events = [] + overflowed = false firstEventAt = 0 } }