diff --git a/gitnexus/src/cli/watch.ts b/gitnexus/src/cli/watch.ts index b8f1e3880..868e5915d 100644 --- a/gitnexus/src/cli/watch.ts +++ b/gitnexus/src/cli/watch.ts @@ -413,7 +413,7 @@ export async function watchCommandWithRunnerIdentity( process.once('SIGINT', stop); process.once('SIGTERM', stop); try { - let loop: WatchFileLoop; + let loop: WatchFileLoop | undefined; let fatalRefreshError: unknown; let configControlValid = true; let lastSuccessfulRefreshAt: string | undefined; @@ -476,7 +476,7 @@ export async function watchCommandWithRunnerIdentity( if (shouldStopAfterWatchRefreshFailure(error, paths)) { fatalRefreshError = error; cliError(formatFatalWatchRefreshFailure(error, paths)); - void loop.close(); + void loop?.close(); stopWatching(); return; } @@ -493,7 +493,7 @@ export async function watchCommandWithRunnerIdentity( `Watcher failed: ${error instanceof Error ? error.message : String(error)}. ` + 'Watch mode is stopping.', ); - void loop.close(); + void loop?.close(); stopWatching(); }, ); @@ -506,6 +506,7 @@ export async function watchCommandWithRunnerIdentity( } await stopped; + if (loop === undefined) return; await loop.close(); if (fatalRefreshError !== undefined) process.exitCode = 1; } finally { diff --git a/gitnexus/test/unit/watch-loop-shutdown.test.ts b/gitnexus/test/unit/watch-loop-shutdown.test.ts index 66b3b857f..4b832041d 100644 --- a/gitnexus/test/unit/watch-loop-shutdown.test.ts +++ b/gitnexus/test/unit/watch-loop-shutdown.test.ts @@ -13,6 +13,9 @@ type MockWatcher = EventEmitter & { let pendingClose: Promise | undefined; let releaseClose: (() => void) | undefined; +const { delayWatcherClose } = vi.hoisted(() => ({ + delayWatcherClose: { current: false }, +})); let lastWatcher: MockWatcher | undefined; vi.mock('chokidar', () => ({ @@ -23,10 +26,9 @@ vi.mock('chokidar', () => ({ }); emitter.add = vi.fn(); emitter.close = vi.fn(async () => { - await pendingClose; + if (delayWatcherClose.current) await pendingClose; }); lastWatcher = emitter; - queueMicrotask(() => emitter.emit('ready')); return emitter; }, })); @@ -51,13 +53,15 @@ afterEach(async () => { pendingClose = undefined; releaseClose = undefined; lastWatcher = undefined; + delayWatcherClose.current = false; }); describe('watch loop fatal shutdown', () => { it('does not start another refresh while a slow watcher.close is in flight', async () => { const repo = await makeRepo(); + delayWatcherClose.current = true; const refreshCalls: string[][] = []; - const loop: WatchFileLoop = await startWatchFileLoop( + const loopPromise = startWatchFileLoop( repo, 10, async (paths) => { @@ -70,6 +74,11 @@ describe('watch loop fatal shutdown', () => { void loop.close(); }, ); + await vi.waitFor(() => { + expect(lastWatcher).toBeDefined(); + }); + lastWatcher.emit('ready'); + const loop: WatchFileLoop = await loopPromise; loops.push(loop); expect(refreshCalls).toEqual([[]]); @@ -87,4 +96,32 @@ describe('watch loop fatal shutdown', () => { releaseClose?.(); await loop.close(); }); + + it('reports a watcher error during startup even if the caller has not assigned loop yet', async () => { + const repo = await makeRepo(); + let loop: WatchFileLoop | undefined; + const onWatcherError = vi.fn((error: unknown) => { + void loop?.close(); + expect(error).toBeInstanceOf(Error); + }); + + const started = startWatchFileLoop( + repo, + 10, + async () => {}, + () => {}, + onWatcherError, + ).then((assigned) => { + loop = assigned; + return assigned; + }); + await vi.waitFor(() => { + expect(lastWatcher).toBeDefined(); + }); + lastWatcher.emit('error', new Error('startup watcher failed')); + + await expect(started).rejects.toThrow('startup watcher failed'); + expect(onWatcherError).toHaveBeenCalledOnce(); + expect(loop).toBeUndefined(); + }); });