fix(watcher): treat FSEvents rescan-required errors as overflow so folder watches keep running (#31439)

@parcel/watcher reports FSEvents MustScanSubDirs ("... File system must be
re-scanned.") as a non-fatal per-batch error while the native stream keeps
running. Orca forwarded it as watch-error, which retired the watched root and
silently stopped external-edit delivery for the rest of the session.

Route rescan-required errors to a new delivery-queue overflow() so consumers
resync and the subscription stays live. A root deleted in the same batch is
still reported as watch-error. Other errors keep the existing path.

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
This commit is contained in:
Jun
2026-10-26 08:23:11 -07:00
committed by GitHub
co-authored by Neil
parent 88fc2eb4fc
commit 5985bbf913
4 changed files with 69 additions and 17 deletions
@@ -11,8 +11,15 @@ import type {
const DIRECTORY_STAT_CONCURRENCY = 8
const directoryStatSlots = new PrioritySemaphore(DIRECTORY_STAT_CONCURRENCY)
// FSEvents MustScanSubDirs: events were dropped but the native stream keeps running.
export function isParcelWatcherRescanRequiredError(error: Error): boolean {
return error.message.endsWith('File system must be re-scanned.')
}
export type WatcherProcessEventDeliveryQueue = {
enqueue(events: readonly ParcelWatcherEvent[]): void
/** Replace pending paths with one overflow; the subscription stays live. */
overflow(): void
flush(): Promise<void>
close(): void
}
@@ -116,21 +123,30 @@ export function createWatcherProcessEventDeliveryQueue(
}
}
const overflow = (): void => {
if (!active) {
return
}
pendingEvents = []
pendingOverflow = true
void drain()
}
return {
enqueue(events): void {
if (!active || events.length === 0 || pendingOverflow) {
return
}
if (events.length > eventLimit || pendingEvents.length + events.length > eventLimit) {
pendingEvents = []
pendingOverflow = true
} else {
for (const event of events) {
pendingEvents.push(event)
}
overflow()
return
}
for (const event of events) {
pendingEvents.push(event)
}
void drain()
},
overflow,
flush(): Promise<void> {
if (!active || !draining) {
return Promise.resolve()
@@ -1,6 +1,9 @@
import type * as ParcelWatcher from '@parcel/watcher'
import { loadParcelWatcher } from './parcel-watcher-module-loader'
import { createWatcherProcessEventDeliveryQueue } from './parcel-watcher-event-delivery'
import {
createWatcherProcessEventDeliveryQueue,
isParcelWatcherRescanRequiredError
} from './parcel-watcher-event-delivery'
import { WatcherProcessFailure } from './parcel-watcher-process-failure'
import type {
WatcherProcessCallback,
@@ -88,6 +91,10 @@ export async function subscribeWithInProcessWatcher(
return
}
if (err) {
if (isParcelWatcherRescanRequiredError(err)) {
eventDelivery.overflow()
return
}
callback(err, [])
return
}
@@ -600,14 +600,8 @@ describe('parcel watcher process canary', () => {
callback?.(null, [{ type: 'update', path: '/repo/after-overflow.txt' }])
await vi.advanceTimersByTimeAsync(0)
expect(sendMock).toHaveBeenCalledWith(
{
op: 'watch-error',
id: 1,
message: 'Events were dropped by the FSEvents client. File system must be re-scanned.'
},
expect.any(Function)
)
expect(sendMock.mock.calls.some(([message]) => message.op === 'watch-error')).toBe(false)
expect(sendMock).toHaveBeenCalledWith({ op: 'overflow', id: 1 }, expect.any(Function))
expect(sendMock).toHaveBeenCalledWith(
{
op: 'events',
@@ -618,6 +612,36 @@ describe('parcel watcher process canary', () => {
)
})
it('still invalidates a root deleted in the same batch as an FSEvents overflow', async () => {
let callback:
| ((err: Error | null, events: { type: string; path: string }[]) => void)
| undefined
subscribeMock
.mockResolvedValueOnce({ unsubscribe: vi.fn() })
.mockImplementationOnce(async (_dir, nextCallback) => {
callback = nextCallback
return { unsubscribe: vi.fn() }
})
const sendMock = vi.fn()
process.send = sendMock
await import('./parcel-watcher-process-entry')
await vi.advanceTimersByTimeAsync(0)
process.emit('message', { op: 'subscribe', id: 1, dir: '/repo', opts: {} }, undefined)
await vi.advanceTimersByTimeAsync(0)
sendMock.mockClear()
callback?.(new Error('Too many events. File system must be re-scanned.'), [
{ type: 'delete', path: '/repo/nested' },
{ type: 'delete', path: '/repo' }
])
await vi.advanceTimersByTimeAsync(0)
expect(sendMock.mock.calls.map(([message]) => message)).toEqual([
{ op: 'overflow', id: 1 },
{ op: 'watch-error', id: 1, message: 'File watcher root was deleted' }
])
})
it.each(['/repo', '/repo/Café'])(
'invalidates only the deleted root %s after delivering its batch',
async (dir) => {
@@ -6,6 +6,7 @@ import { detectShallowWatchDelivery } from '../../shallow-watch-delivery-probe'
import { startWatcherDeliveryCanary } from '../../parcel-watcher-delivery-canary'
import {
createWatcherProcessEventDeliveryQueue,
isParcelWatcherRescanRequiredError,
type WatcherProcessEventDeliveryQueue
} from './parcel-watcher-event-delivery'
import type {
@@ -124,12 +125,16 @@ function main(): void {
if (rootDeleted) {
return
}
if (err) {
if (err && !isParcelWatcherRescanRequiredError(err)) {
send({ op: 'watch-error', id, message: errorMessage(err) })
return
}
rootDeleted = events.some((event) => event.type === 'delete' && event.path === dir)
eventDelivery.enqueue(events)
if (err) {
eventDelivery.overflow()
} else {
eventDelivery.enqueue(events)
}
if (rootDeleted) {
// Native backends stop deleted roots without reporting an error.
void eventDelivery