Skip to content

Commit f30781b

Browse files
authored
fix(kimi-code): exit 1 when a headless (-p) turn fails (#1483)
Headless (`kimi -p`) failures could exit with code 0 when the event loop drained during the shutdown cleanup (e.g. telemetry's unref'd retry backoff when the network is blocked), because the rejection never reached the process.exit(1) call. Set the failure exit code before any await in both the run-prompt catch and the main catch, and keep the cleanup timeout ref'd so the loop stays alive long enough for the rejection to propagate.
1 parent 9b76e5b commit f30781b

4 files changed

Lines changed: 51 additions & 2 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
Fix `kimi -p` runs exiting with code 0 when a turn fails.

apps/kimi-code/src/cli/run-prompt.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ import { createKimiCodeHostIdentity } from './version';
4444
*
4545
* Used to bound shutdown so a wedged cleanup step can't keep a completed
4646
* headless run alive, without silently swallowing a cleanup that fails fast. The
47-
* timer is unref'd so it never keeps the loop alive on its own.
47+
* timer stays ref'd so a cleanup step that suspends on an unref'd handle (e.g.
48+
* telemetry's retry backoff when the network is blocked) can't drain the event
49+
* loop and exit 0 before the rejection propagates — the timer keeps the loop
50+
* alive until it fires, then gives the rejection a chance to surface. A wedged
51+
* cleanup is still bounded by `timeoutMs`, so this can't hang the run forever.
4852
*/
4953
async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promise<void> {
5054
let timedOut = false;
@@ -61,7 +65,6 @@ async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promi
6165
timedOut = true;
6266
resolve();
6367
}, timeoutMs);
64-
timer.unref?.();
6568
});
6669
try {
6770
await Promise.race([guarded, timedOutSignal]);

apps/kimi-code/src/main.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,16 @@ export function main(): void {
172172
}
173173
})
174174
.catch(async (error: unknown) => {
175+
// Set the failure exit code synchronously, before any `await`. The
176+
// terminal `process.exit(1)` below is our intended exit, but it sits
177+
// behind `await logStartupFailure(...)`; by the time we reach that
178+
// await, the failed run's `finally` cleanup has already torn down its
179+
// ref'd handles (sockets, timers, background tasks). If the event loop
180+
// drains during the await, Node exits on its own with the DEFAULT code
181+
// 0 and `process.exit(1)` never runs — headless (`kimi -p`) failures
182+
// would then exit 0 nondeterministically. Setting `process.exitCode`
183+
// up front makes that drain-exit report failure too.
184+
process.exitCode = 1;
175185
const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell';
176186
await logStartupFailure(operation, error);
177187
process.stderr.write(

apps/kimi-code/test/cli/main.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => {
3232
})),
3333
initializeCliTelemetry: vi.fn(),
3434
handleUpgrade: vi.fn(),
35+
flushDiagnosticLogs: vi.fn(),
3536
finalizeHeadlessRun: vi.fn(),
3637
log: {
3738
info: vi.fn(),
@@ -80,6 +81,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async () => {
8081
mocks.createKimiHarness(...args);
8182
return mocks.harness;
8283
},
84+
flushDiagnosticLogs: mocks.flushDiagnosticLogs,
8385
KimiHarness: MockKimiHarness,
8486
log: mocks.log,
8587
};
@@ -215,6 +217,7 @@ describe('main entry command handling', () => {
215217
mocks.harness.close.mockResolvedValue(undefined);
216218
mocks.shutdownTelemetry.mockResolvedValue(undefined);
217219
mocks.handleUpgrade.mockResolvedValue(0);
220+
mocks.flushDiagnosticLogs.mockResolvedValue(undefined);
218221
});
219222

220223
it('runs update preflight before starting the shell', async () => {
@@ -301,6 +304,34 @@ describe('main entry command handling', () => {
301304
expect(typeof forceExitArgs[2]).toBe('function');
302305
});
303306

307+
it('sets the failure exit code before awaiting startup failure logging', async () => {
308+
const originalExitCode = process.exitCode;
309+
const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' };
310+
mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' });
311+
mocks.runUpdatePreflight.mockResolvedValue('continue');
312+
mocks.runPrompt.mockRejectedValue(new Error('provider failed'));
313+
mocks.flushDiagnosticLogs.mockImplementation(() => new Promise(() => {}));
314+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
315+
throw new ExitCalled(Number(code ?? 0));
316+
});
317+
318+
try {
319+
main();
320+
const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[];
321+
const mainAction = programArgs[1] as (opts: CLIOptions) => void;
322+
mainAction(opts);
323+
324+
await waitForAssertion(() => {
325+
expect(mocks.flushDiagnosticLogs).toHaveBeenCalledTimes(1);
326+
});
327+
expect(process.exitCode).toBe(1);
328+
expect(exitSpy).not.toHaveBeenCalled();
329+
} finally {
330+
exitSpy.mockRestore();
331+
process.exitCode = originalExitCode;
332+
}
333+
});
334+
304335
it('keeps shell mode update preflight interactive by default', async () => {
305336
const opts = defaultOpts();
306337
mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' });

0 commit comments

Comments
 (0)