diff --git a/src/main/runtime/file-watcher-host.test.ts b/src/main/runtime/file-watcher-host.test.ts index 4aef254312f..77f6e3efa06 100644 --- a/src/main/runtime/file-watcher-host.test.ts +++ b/src/main/runtime/file-watcher-host.test.ts @@ -134,12 +134,12 @@ describe('watchFileExplorerInWorker', () => { worker.emit('message', { type: 'ready' }) const dispose = await promise - dispose() + await dispose() expect(worker.postedMessages).toContainEqual({ type: 'unsubscribe' }) expect(worker.terminated).toBe(true) // Idempotent: a second dispose does nothing further. - dispose() + await dispose() expect( worker.postedMessages.filter((m) => (m as { type?: string }).type === 'unsubscribe') ).toHaveLength(1) @@ -151,7 +151,7 @@ describe('watchFileExplorerInWorker', () => { const worker = lastWorker() worker.emit('message', { type: 'ready' }) const dispose = await promise - dispose() + await dispose() onEvents.mockClear() worker.emit('message', { diff --git a/src/main/runtime/file-watcher-host.ts b/src/main/runtime/file-watcher-host.ts index 55da4aa7728..90bd9ec8598 100644 --- a/src/main/runtime/file-watcher-host.ts +++ b/src/main/runtime/file-watcher-host.ts @@ -36,7 +36,7 @@ function getFileWatcherWorkerPath(): string { export function watchFileExplorerInWorker( rootPath: string, callback: (events: FsChangeEvent[]) => void -): Promise<() => void> { +): Promise<() => Promise> { return new Promise((resolve, reject) => { const worker = new Worker(getFileWatcherWorkerPath(), { workerData: { rootPath, ignore: RUNTIME_FILE_WATCH_IGNORE } @@ -45,7 +45,10 @@ export function watchFileExplorerInWorker( let ready = false let disposed = false - const dispose = (): void => { + // Why: returns a promise that resolves once the worker is actually down, so + // the shutdown drain (awaitRuntimeFileWatcherUnsubscribes) doesn't finish + // while the native watcher thread is still alive. + const dispose = async (): Promise => { if (disposed) { return } @@ -57,7 +60,10 @@ export function watchFileExplorerInWorker( } catch { // Worker already gone — terminate below covers it. } - void worker.terminate() + await worker.terminate().then( + () => undefined, + () => undefined + ) } worker.on('message', (message: FileWatcherWorkerMessage) => { diff --git a/src/main/runtime/file-watcher-worker.ts b/src/main/runtime/file-watcher-worker.ts index 2dd05d655ae..3325b43c377 100644 --- a/src/main/runtime/file-watcher-worker.ts +++ b/src/main/runtime/file-watcher-worker.ts @@ -36,6 +36,20 @@ if (!parentPort) { const port = parentPort +/** Report a watcher failure to the host and ask the renderer to refresh from + * scratch (the overflow event), so a mid-stream error never leaves the + * explorer silently stale. */ +function reportWatchError(err: unknown): void { + port.postMessage({ + type: 'error', + message: err instanceof Error ? err.message : String(err) + } satisfies FileWatcherWorkerMessage) + port.postMessage({ + type: 'events', + events: [{ kind: 'overflow', absolutePath: data.rootPath }] + } satisfies FileWatcherWorkerMessage) +} + /** Run an async mapper over items with a bounded number in flight at once, so a * large batch can't occupy every libuv threadpool thread in this worker. */ async function mapWithConcurrency( @@ -71,15 +85,7 @@ async function main(): Promise { data.rootPath, (err, events) => { if (err) { - // Surface the error and ask the renderer to refresh from scratch. - port.postMessage({ - type: 'error', - message: err.message - } satisfies FileWatcherWorkerMessage) - port.postMessage({ - type: 'events', - events: [{ kind: 'overflow', absolutePath: data.rootPath }] - } satisfies FileWatcherWorkerMessage) + reportWatchError(err) return } // Why: large watcher batches usually mean a generated directory or branch @@ -103,9 +109,13 @@ async function main(): Promise { } return { kind: event.type, absolutePath: event.path, isDirectory } } - ).then((mapped) => { - port.postMessage({ type: 'events', events: mapped } satisfies FileWatcherWorkerMessage) - }) + ) + .then((mapped) => { + port.postMessage({ type: 'events', events: mapped } satisfies FileWatcherWorkerMessage) + }) + // Why: without this, a throwing postMessage / stat becomes an unhandled + // rejection that crashes the worker silently. Surface it instead. + .catch((err: unknown) => reportWatchError(err)) }, { ignore: data.ignore } ) diff --git a/src/main/runtime/orca-runtime-files.ts b/src/main/runtime/orca-runtime-files.ts index e3e08a01552..62deef6b63c 100644 --- a/src/main/runtime/orca-runtime-files.ts +++ b/src/main/runtime/orca-runtime-files.ts @@ -298,7 +298,7 @@ export class RuntimeFileCommands { // recursive crawl can't starve the main/`serve` process (issue #5308). const dispose = await watchFileExplorerInWorker(rootPath, callback) return () => { - trackRuntimeFileWatcherUnsubscribe(rootPath, async () => dispose()) + trackRuntimeFileWatcherUnsubscribe(rootPath, dispose) } }