Serialize filesystem watcher batch flush operations (#17602)

* Serialize filesystem watcher batch flush operations

- Prevent dropped events during rapid concurrent file changes
- Queue and drain follow-up batches to preserve event ordering
- Cancel pending batch work when watchers are torn down

* Prevent queued batch drain while debounce timer is armed

An armed timer means the debounce window is still open. Drain only after
the window closes to avoid splitting related filesystem events across
separate payloads.

* Remove redundant batch timer cleanup

Rely on cancelLocalBatchFlush to handle the batch timer
teardown, eliminating duplicate logic in the watcher
cleanup path.
This commit is contained in:
Jinjing
2026-08-31 18:28:26 -07:00
committed by GitHub
parent 2222e54754
commit 50938b2dbd
10 changed files with 308 additions and 54 deletions
@@ -0,0 +1,37 @@
import type { Event as WatcherEvent } from '@parcel/watcher'
import type { WatchedRoot } from './filesystem-watcher-wsl'
export type DebouncedBatch = {
events: WatcherEvent[]
overflowed: boolean
timer: ReturnType<typeof setTimeout> | null
firstEventAt: number
flushInFlight: boolean
flushQueued: boolean
cancelled: boolean
}
export function createDebouncedBatch(): DebouncedBatch {
return {
events: [],
overflowed: false,
timer: null,
firstEventAt: 0,
flushInFlight: false,
flushQueued: false,
cancelled: false
}
}
/** Cancel pending and queued flush work when a root is torn down. */
export function cancelLocalBatchFlush(root: WatchedRoot): void {
root.batch.cancelled = true
root.batch.flushQueued = false
if (root.batch.timer) {
clearTimeout(root.batch.timer)
root.batch.timer = null
}
root.batch.events = []
root.batch.overflowed = false
root.batch.firstEventAt = 0
}
@@ -7,6 +7,7 @@ import {
UNWATCHABLE_ROOT_CACHE_MAX,
watcherLifecycleState
} from './filesystem-watcher-lifecycle-state'
import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control'
export function rememberUnwatchableRoot(rootKey: string): void {
const { unwatchableRoots } = watcherLifecycleState
@@ -203,9 +204,7 @@ function cleanupLocalWatchersForSender(senderId: number): void {
clearTimeout(pending)
watcherLifecycleState.pendingTeardowns.delete(key)
}
if (watchedRoot.batch.timer) {
clearTimeout(watchedRoot.batch.timer)
}
cancelLocalBatchFlush(watchedRoot)
trackDetachedLocalUnsubscribe(key, watchedRoot)
watcherLifecycleState.watchedRoots.delete(key)
}
@@ -0,0 +1,171 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Event as WatcherEvent } from '@parcel/watcher'
import type { FsChangedPayload } from '../../shared/filesystem-entry-types'
import { WATCH_BATCH_TRAILING_MS } from '../../shared/filesystem-watch-batch-window'
const { statMock, subscribeMock } = vi.hoisted(() => ({
statMock: vi.fn(),
subscribeMock: vi.fn()
}))
vi.mock('fs/promises', () => ({ stat: statMock }))
vi.mock('./parcel-watcher-process', () => ({ subscribeViaWatcherProcess: subscribeMock }))
import { createLocalWatcher } from './filesystem-watcher-local-events'
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
const promise = new Promise<T>((nextResolve) => {
resolve = nextResolve
})
return { promise, resolve }
}
async function flushMicrotasks(): Promise<void> {
for (let i = 0; i < 6; i++) {
await Promise.resolve()
}
}
type Sender = { isDestroyed: () => boolean; send: ReturnType<typeof vi.fn> }
describe('local filesystem watcher flush serialization', () => {
let watcherCallback: ((error: Error | null, events: WatcherEvent[]) => void) | undefined
let sender: Sender
beforeEach(() => {
vi.useFakeTimers()
statMock.mockReset()
subscribeMock.mockReset()
watcherCallback = undefined
sender = { isDestroyed: () => false, send: vi.fn() }
subscribeMock.mockImplementation(async (_root: string, callback: typeof watcherCallback) => {
watcherCallback = callback
return { unsubscribe: vi.fn() }
})
})
it('serializes an inflight flush and drains one follow-up without overlap', async () => {
const firstStat = deferred<{ isDirectory: () => boolean }>()
const secondStat = deferred<{ isDirectory: () => boolean }>()
statMock.mockReturnValueOnce(firstStat.promise).mockReturnValueOnce(secondStat.promise)
const root = await createLocalWatcher('/repo', '/repo')
root.listeners.set(1, sender as never)
const firstPath = '/repo/first.ts'
const secondPath = '/repo/second.ts'
watcherCallback?.(null, [{ type: 'update', path: firstPath }])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
expect(statMock).toHaveBeenCalledTimes(1)
watcherCallback?.(null, [{ type: 'update', path: secondPath }])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
expect(statMock).toHaveBeenCalledTimes(1)
expect(sender.send).not.toHaveBeenCalled()
firstStat.resolve({ isDirectory: () => true })
await flushMicrotasks()
expect(statMock).toHaveBeenCalledTimes(2)
expect(sender.send).toHaveBeenCalledTimes(1)
secondStat.resolve({ isDirectory: () => false })
await flushMicrotasks()
expect(sender.send).toHaveBeenCalledTimes(2)
expect((sender.send.mock.calls[1][1] as FsChangedPayload).events).toEqual([
{ kind: 'update', absolutePath: secondPath, isDirectory: false }
])
})
it('coalesces a queued storm while preserving delete-before-create ordering', async () => {
const firstStat = deferred<{ isDirectory: () => boolean }>()
const createStat = deferred<{ isDirectory: () => boolean }>()
statMock.mockReturnValueOnce(firstStat.promise).mockReturnValueOnce(createStat.promise)
const root = await createLocalWatcher('/repo', '/repo')
root.listeners.set(1, sender as never)
const firstPath = '/repo/first.ts'
const transientPath = '/repo/transient.ts'
const replacedPath = '/repo/replaced.ts'
watcherCallback?.(null, [{ type: 'update', path: firstPath }])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
watcherCallback?.(null, [
{ type: 'create', path: transientPath },
{ type: 'delete', path: transientPath },
{ type: 'delete', path: replacedPath },
{ type: 'create', path: replacedPath }
])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
expect(statMock).toHaveBeenCalledTimes(1)
firstStat.resolve({ isDirectory: () => true })
await flushMicrotasks()
expect(statMock).toHaveBeenCalledTimes(2)
createStat.resolve({ isDirectory: () => true })
await flushMicrotasks()
expect((sender.send.mock.calls[1][1] as FsChangedPayload).events).toEqual([
{ kind: 'delete', absolutePath: replacedPath },
{ kind: 'create', absolutePath: replacedPath, isDirectory: true }
])
})
it('drops queued events when the last listener is removed', async () => {
const firstStat = deferred<{ isDirectory: () => boolean }>()
const root = await createLocalWatcher('/repo', '/repo')
root.listeners.set(1, sender as never)
statMock.mockReturnValueOnce(firstStat.promise)
watcherCallback?.(null, [{ type: 'update', path: '/repo/first.ts' }])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
watcherCallback?.(null, [{ type: 'update', path: '/repo/queued.ts' }])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
root.listeners.clear()
firstStat.resolve({ isDirectory: () => true })
await flushMicrotasks()
expect(statMock).toHaveBeenCalledTimes(1)
expect(sender.send).not.toHaveBeenCalled()
})
it('leaves an open debounce window to the armed timer instead of draining early', async () => {
const firstStat = deferred<{ isDirectory: () => boolean }>()
const secondStat = deferred<{ isDirectory: () => boolean }>()
statMock.mockReturnValueOnce(firstStat.promise).mockReturnValueOnce(secondStat.promise)
const root = await createLocalWatcher('/repo', '/repo')
root.listeners.set(1, sender as never)
const transientPath = '/repo/transient.ts'
const otherPath = '/repo/other.ts'
watcherCallback?.(null, [{ type: 'update', path: '/repo/first.ts' }])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
// Queue an event mid-flush, then settle the flush before its debounce window closes.
watcherCallback?.(null, [{ type: 'create', path: transientPath }])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS - 50)
firstStat.resolve({ isDirectory: () => true })
await flushMicrotasks()
expect(sender.send).toHaveBeenCalledTimes(1)
expect(statMock).toHaveBeenCalledTimes(1)
// The still-open window coalesces the create away instead of emitting a transient one.
watcherCallback?.(null, [
{ type: 'delete', path: transientPath },
{ type: 'update', path: otherPath }
])
vi.advanceTimersByTime(WATCH_BATCH_TRAILING_MS)
await flushMicrotasks()
secondStat.resolve({ isDirectory: () => false })
await flushMicrotasks()
expect(sender.send).toHaveBeenCalledTimes(2)
expect((sender.send.mock.calls[1][1] as FsChangedPayload).events).toEqual([
{ kind: 'update', absolutePath: otherPath, isDirectory: false }
])
})
})
+83 -32
View File
@@ -16,6 +16,7 @@ import {
retainLocalWatcherPhysicalFailure,
trackDetachedLocalUnsubscribe
} from './filesystem-watcher-listener-lifecycle'
import { createDebouncedBatch } from './filesystem-watcher-batch-control'
// ── Event coalescing ─────────────────────────────────────────────────
// Why: keep the last event per path in a flush window; delete→create emits both (delete cleans the subtree, create refreshes the parent), create→delete is dropped (§4.4).
@@ -94,50 +95,92 @@ function emitOverflowPayload(root: WatchedRoot): void {
}
async function flushBatch(root: WatchedRoot): Promise<void> {
if (root.batch.cancelled) {
return
}
if (root.batch.flushInFlight) {
root.batch.flushQueued = true
return
}
root.batch.flushInFlight = true
if (root.batch.timer) {
clearTimeout(root.batch.timer)
root.batch.timer = null
}
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 && !overflowed) || root.listeners.size === 0) {
return
}
try {
if ((rawEvents.length === 0 && !overflowed) || root.listeners.size === 0) {
return
}
if (overflowed || rawEvents.length > MAX_BATCHED_WATCHER_EVENTS) {
// Why: deletion storms can be too large to coalesce/stat per path; one overflow asks the renderer for the same conservative refresh.
emitOverflowPayload(root)
return
}
const coalesced = coalesceEvents(rawEvents)
const events: FsChangeEvent[] = await Promise.all(
coalesced.map(async (evt) => {
// Why: a deleted path can't be stat'd; leave isDirectory undefined and let the renderer infer from dirCache.
const isDirectory = evt.type === 'delete' ? undefined : await tryStatIsDirectory(evt.path)
return {
kind: evt.type,
absolutePath: evt.path,
isDirectory
if (overflowed || rawEvents.length > MAX_BATCHED_WATCHER_EVENTS) {
// Why: deletion storms can be too large to coalesce/stat per path; one overflow asks the renderer for the same conservative refresh.
if (!root.batch.cancelled) {
emitOverflowPayload(root)
}
})
)
return
}
const payload: FsChangedPayload = {
worktreePath: root.rootPath,
events
}
const coalesced = coalesceEvents(rawEvents)
for (const [, wc] of root.listeners) {
if (!wc.isDestroyed()) {
wc.send('fs:changed', payload)
const events: FsChangeEvent[] = await Promise.all(
coalesced.map(async (evt) => {
// Why: a deleted path can't be stat'd; leave isDirectory undefined and let the renderer infer from dirCache.
const isDirectory = evt.type === 'delete' ? undefined : await tryStatIsDirectory(evt.path)
return {
kind: evt.type,
absolutePath: evt.path,
isDirectory
}
})
)
if (root.batch.cancelled || root.listeners.size === 0) {
return
}
const payload: FsChangedPayload = {
worktreePath: root.rootPath,
events
}
for (const [, wc] of root.listeners) {
if (!wc.isDestroyed()) {
wc.send('fs:changed', payload)
}
}
} finally {
root.batch.flushInFlight = false
if (root.batch.flushQueued) {
root.batch.flushQueued = false
if (
!root.batch.cancelled &&
// Why: an armed timer still owns its debounce window; draining here would split related events across payloads.
!root.batch.timer &&
(root.batch.events.length > 0 || root.batch.overflowed)
) {
// Drain the queued batch only after the current payload has settled,
// preserving watcher event ordering without dropping a storm tail.
void flushBatch(root)
}
}
}
}
export function scheduleLocalBatchFlush(root: WatchedRoot): void {
if (root.batch.cancelled) {
return
}
if (root.batch.flushInFlight) {
root.batch.flushQueued = true
}
const now = Date.now()
if (root.batch.firstEventAt === 0) {
@@ -148,6 +191,7 @@ export function scheduleLocalBatchFlush(root: WatchedRoot): void {
if (now - root.batch.firstEventAt >= WATCH_BATCH_MAX_WAIT_MS) {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
root.batch.timer = null
}
void flushBatch(root)
return
@@ -157,7 +201,11 @@ export function scheduleLocalBatchFlush(root: WatchedRoot): void {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
root.batch.timer = setTimeout(() => void flushBatch(root), WATCH_BATCH_TRAILING_MS)
// Why: clear the handle as it fires so `batch.timer` means "a debounce window is still open", which gates the queued drain.
root.batch.timer = setTimeout(() => {
root.batch.timer = null
void flushBatch(root)
}, WATCH_BATCH_TRAILING_MS)
}
// ── Watcher creation ─────────────────────────────────────────────────
@@ -170,7 +218,7 @@ export async function createLocalWatcher(
const root: WatchedRoot = {
subscription: null!,
listeners: new Map(),
batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 },
batch: createDebouncedBatch(),
rootPath
}
@@ -211,6 +259,9 @@ export async function createLocalWatcher(
return
}
if (root.batch.cancelled) {
return
}
queueWatcherEvents(root.batch, events)
scheduleLocalBatchFlush(root)
},
@@ -16,6 +16,7 @@ import {
retainLocalWatcherPhysicalFailure,
trackDetachedLocalUnsubscribe
} from './filesystem-watcher-listener-lifecycle'
import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control'
export async function installLocalWatcher(
rootKey: string,
@@ -78,9 +79,7 @@ export async function installLocalWatcher(
Array.from(cancelToken.listeners.entries()).filter(([, listener]) => !listener.isDestroyed())
)
if (cancelToken.cancelled || liveListeners.size === 0) {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
cancelLocalBatchFlush(root)
void trackDetachedLocalUnsubscribe(rootKey, root)
return 'cancelled'
}
@@ -14,6 +14,7 @@ import {
trackDetachedLocalUnsubscribe
} from './filesystem-watcher-listener-lifecycle'
import { subscribeLocalWatcher } from './filesystem-watcher-local-subscription'
import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control'
export async function closeLocalWatcherForWorktreePath(
worktreePath: string,
@@ -100,9 +101,7 @@ export async function closeLocalWatcherForWorktreePath(
if (!root) {
return
}
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
cancelLocalBatchFlush(root)
watcherLifecycleState.watchedRoots.delete(rootKey)
// Why: the in-process Parcel fallback has no unsubscribe timeout of its own, so an unbounded await
// here would hang delete forever and hold the removal gate. The promise stays tracked in
@@ -14,6 +14,7 @@ import {
takeLocalCapacityRetryListeners,
trackDetachedLocalUnsubscribe
} from './filesystem-watcher-listener-lifecycle'
import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control'
import { scheduleLocalCapacityRetry } from './filesystem-watcher-local-capacity'
import { installLocalWatcher } from './filesystem-watcher-local-install'
@@ -199,7 +200,6 @@ export function unsubscribeLocalWatcher(worktreePath: string, senderId: number):
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
// Why: duplicate unwatch calls for a root would leak overwritten grace timers; keep just one.
if (watcherLifecycleState.pendingTeardowns.has(rootKey)) {
return
@@ -213,6 +213,7 @@ export function unsubscribeLocalWatcher(worktreePath: string, senderId: number):
return
}
void trackDetachedLocalUnsubscribe(rootKey, currentRoot)
cancelLocalBatchFlush(currentRoot)
watcherLifecycleState.watchedRoots.delete(rootKey)
}, WATCHER_TEARDOWN_GRACE_MS)
+2 -3
View File
@@ -1,6 +1,7 @@
import { disposeWatcherProcess } from './parcel-watcher-process'
import { watcherLifecycleState } from './filesystem-watcher-lifecycle-state'
import { trackDetachedLocalUnsubscribe } from './filesystem-watcher-listener-lifecycle'
import { cancelLocalBatchFlush } from './filesystem-watcher-batch-control'
/** Tear down all watchers on app shutdown. */
export async function closeAllWatchers(): Promise<void> {
@@ -57,9 +58,7 @@ export async function closeAllWatchers(): Promise<void> {
}
for (const [rootKey, root] of watcherLifecycleState.watchedRoots) {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
cancelLocalBatchFlush(root)
await trackDetachedLocalUnsubscribe(rootKey, root).catch(() => undefined)
}
watcherLifecycleState.watchedRoots.clear()
+5 -8
View File
@@ -13,18 +13,12 @@ import { queueWatcherEvents } from './filesystem-watcher-event-batch'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { createWslWatcherProcessExit, createWslWatcherStartup } from './wsl-watcher-process-exit'
import { reserveWatcherChild, WatcherChildCapacityError } from './parcel-watcher-child-registry'
import { createDebouncedBatch, type DebouncedBatch } from './filesystem-watcher-batch-control'
export type WatcherSubscription = {
unsubscribe(): Promise<void>
}
type DebouncedBatch = {
events: WatcherEvent[]
overflowed: boolean
timer: ReturnType<typeof setTimeout> | null
firstEventAt: number
}
export type WatchedRoot = {
subscription: WatcherSubscription
listeners: Map<number, WebContents>
@@ -171,7 +165,7 @@ export async function createWslWatcher(
const root: WatchedRoot = {
subscription: null!,
listeners: new Map(),
batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 },
batch: createDebouncedBatch(),
rootPath: worktreePath
}
@@ -199,6 +193,9 @@ export async function createWslWatcher(
}
function ingestFrame(frame: string): void {
if (root.batch.cancelled) {
return
}
const nextSnapshot = parseSnapshotFrame(frame, distro)
if (!prevSnapshot) {
prevSnapshot = nextSnapshot
+2 -1
View File
@@ -51,6 +51,7 @@ import {
import { stat } from 'node:fs/promises'
import { subscribe as subscribeParcelWatcher } from '@parcel/watcher'
import { createWslWatcher } from './filesystem-watcher-wsl'
import { createDebouncedBatch } from './filesystem-watcher-batch-control'
import {
MAX_PHYSICAL_WATCHER_CHILDREN,
reserveWatcherChild,
@@ -135,7 +136,7 @@ describe('registerFilesystemWatcherHandlers', () => {
return {
subscription: { unsubscribe: vi.fn(async () => release()) },
listeners: new Map(),
batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 },
batch: createDebouncedBatch(),
rootPath: worktreePath
}
})