mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
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.
This commit is contained in:
parent
74cee9bf07
commit
ca09e31cb7
4 changed files with 51 additions and 2 deletions
5
.changeset/fix-headless-exit-code.md
Normal file
5
.changeset/fix-headless-exit-code.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"@moonshot-ai/kimi-code": patch
|
||||
---
|
||||
|
||||
Fix `kimi -p` runs exiting with code 0 when a turn fails.
|
||||
|
|
@ -44,7 +44,11 @@ import { createKimiCodeHostIdentity } from './version';
|
|||
*
|
||||
* Used to bound shutdown so a wedged cleanup step can't keep a completed
|
||||
* headless run alive, without silently swallowing a cleanup that fails fast. The
|
||||
* timer is unref'd so it never keeps the loop alive on its own.
|
||||
* timer stays ref'd so a cleanup step that suspends on an unref'd handle (e.g.
|
||||
* telemetry's retry backoff when the network is blocked) can't drain the event
|
||||
* loop and exit 0 before the rejection propagates — the timer keeps the loop
|
||||
* alive until it fires, then gives the rejection a chance to surface. A wedged
|
||||
* cleanup is still bounded by `timeoutMs`, so this can't hang the run forever.
|
||||
*/
|
||||
async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promise<void> {
|
||||
let timedOut = false;
|
||||
|
|
@ -61,7 +65,6 @@ async function raceWithTimeout(promise: Promise<void>, timeoutMs: number): Promi
|
|||
timedOut = true;
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
});
|
||||
try {
|
||||
await Promise.race([guarded, timedOutSignal]);
|
||||
|
|
|
|||
|
|
@ -172,6 +172,16 @@ export function main(): void {
|
|||
}
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
// Set the failure exit code synchronously, before any `await`. The
|
||||
// terminal `process.exit(1)` below is our intended exit, but it sits
|
||||
// behind `await logStartupFailure(...)`; by the time we reach that
|
||||
// await, the failed run's `finally` cleanup has already torn down its
|
||||
// ref'd handles (sockets, timers, background tasks). If the event loop
|
||||
// drains during the await, Node exits on its own with the DEFAULT code
|
||||
// 0 and `process.exit(1)` never runs — headless (`kimi -p`) failures
|
||||
// would then exit 0 nondeterministically. Setting `process.exitCode`
|
||||
// up front makes that drain-exit report failure too.
|
||||
process.exitCode = 1;
|
||||
const operation = opts.prompt !== undefined ? 'run prompt' : 'start shell';
|
||||
await logStartupFailure(operation, error);
|
||||
process.stderr.write(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ const mocks = vi.hoisted(() => {
|
|||
})),
|
||||
initializeCliTelemetry: vi.fn(),
|
||||
handleUpgrade: vi.fn(),
|
||||
flushDiagnosticLogs: vi.fn(),
|
||||
finalizeHeadlessRun: vi.fn(),
|
||||
log: {
|
||||
info: vi.fn(),
|
||||
|
|
@ -80,6 +81,7 @@ vi.mock('@moonshot-ai/kimi-code-sdk', async () => {
|
|||
mocks.createKimiHarness(...args);
|
||||
return mocks.harness;
|
||||
},
|
||||
flushDiagnosticLogs: mocks.flushDiagnosticLogs,
|
||||
KimiHarness: MockKimiHarness,
|
||||
log: mocks.log,
|
||||
};
|
||||
|
|
@ -215,6 +217,7 @@ describe('main entry command handling', () => {
|
|||
mocks.harness.close.mockResolvedValue(undefined);
|
||||
mocks.shutdownTelemetry.mockResolvedValue(undefined);
|
||||
mocks.handleUpgrade.mockResolvedValue(0);
|
||||
mocks.flushDiagnosticLogs.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('runs update preflight before starting the shell', async () => {
|
||||
|
|
@ -301,6 +304,34 @@ describe('main entry command handling', () => {
|
|||
expect(typeof forceExitArgs[2]).toBe('function');
|
||||
});
|
||||
|
||||
it('sets the failure exit code before awaiting startup failure logging', async () => {
|
||||
const originalExitCode = process.exitCode;
|
||||
const opts: CLIOptions = { ...defaultOpts(), prompt: 'explain the repo' };
|
||||
mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'print' });
|
||||
mocks.runUpdatePreflight.mockResolvedValue('continue');
|
||||
mocks.runPrompt.mockRejectedValue(new Error('provider failed'));
|
||||
mocks.flushDiagnosticLogs.mockImplementation(() => new Promise(() => {}));
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((code?: string | number | null) => {
|
||||
throw new ExitCalled(Number(code ?? 0));
|
||||
});
|
||||
|
||||
try {
|
||||
main();
|
||||
const programArgs = mocks.createProgram.mock.calls[0] as unknown as unknown[];
|
||||
const mainAction = programArgs[1] as (opts: CLIOptions) => void;
|
||||
mainAction(opts);
|
||||
|
||||
await waitForAssertion(() => {
|
||||
expect(mocks.flushDiagnosticLogs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(exitSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
exitSpy.mockRestore();
|
||||
process.exitCode = originalExitCode;
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps shell mode update preflight interactive by default', async () => {
|
||||
const opts = defaultOpts();
|
||||
mocks.validateOptions.mockReturnValue({ options: opts, uiMode: 'shell' });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue