diff --git a/src/main/ipc/filesystem/watcher-process/parcel-watcher-event-delivery.ts b/src/main/ipc/filesystem/watcher-process/parcel-watcher-event-delivery.ts index d718e08ad3f..9803eaa774f 100644 --- a/src/main/ipc/filesystem/watcher-process/parcel-watcher-event-delivery.ts +++ b/src/main/ipc/filesystem/watcher-process/parcel-watcher-event-delivery.ts @@ -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 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 { if (!active || !draining) { return Promise.resolve() diff --git a/src/main/ipc/filesystem/watcher-process/parcel-watcher-in-process-fallback.ts b/src/main/ipc/filesystem/watcher-process/parcel-watcher-in-process-fallback.ts index 2235a7b4621..f3b4b7c90bf 100644 --- a/src/main/ipc/filesystem/watcher-process/parcel-watcher-in-process-fallback.ts +++ b/src/main/ipc/filesystem/watcher-process/parcel-watcher-in-process-fallback.ts @@ -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 } diff --git a/src/main/ipc/filesystem/watcher-process/parcel-watcher-process-entry.test.ts b/src/main/ipc/filesystem/watcher-process/parcel-watcher-process-entry.test.ts index b78b0774347..bf13961463d 100644 --- a/src/main/ipc/filesystem/watcher-process/parcel-watcher-process-entry.test.ts +++ b/src/main/ipc/filesystem/watcher-process/parcel-watcher-process-entry.test.ts @@ -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) => { diff --git a/src/main/ipc/filesystem/watcher-process/parcel-watcher-process-entry.ts b/src/main/ipc/filesystem/watcher-process/parcel-watcher-process-entry.ts index b7051ccdabb..094071468a7 100644 --- a/src/main/ipc/filesystem/watcher-process/parcel-watcher-process-entry.ts +++ b/src/main/ipc/filesystem/watcher-process/parcel-watcher-process-entry.ts @@ -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