Bound runtime file-watch event batches

This commit is contained in:
Neil
2026-05-31 02:29:19 -07:00
parent 549ab29489
commit a4deeb3745
2 changed files with 57 additions and 31 deletions
@@ -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()
}
})
})
@@ -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<typeof setTimeout> | 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
}
}