fix(watch): do not close an unassigned loop on startup watcher errors (#3121)

onWatcherError can fire while waitUntilReady is still pending, before
startWatchFileLoop returns. Guard loop.close() so shutdown stays
idempotent without throwing on undefined.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gergo Magyar 2026-08-31 23:45:17 +00:00
parent bcabd82310
commit 7e3ec5f34e
2 changed files with 44 additions and 6 deletions

View file

@ -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 {

View file

@ -13,6 +13,9 @@ type MockWatcher = EventEmitter & {
let pendingClose: Promise<void> | 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();
});
});