mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user