fix(cli): force-exit headless runs so a lingering handle can't wedge kimi -p - #1233
Conversation
…kimi -p Print mode (`kimi -p`) never calls process.exit(); it relies on the Node event loop draining once the run completes. A stray ref'd handle left over from the run — a lingering socket, an un-cleared timer, or a child whose pipes stay open — keeps the loop alive, so a finished run hangs until an external timeout kills it instead of exiting. - Arm an unref'd force-exit fallback after a headless run completes. A healthy run drains and exits before it fires (behaviour unchanged); it only force-exits a run whose loop is already wedged. - Bound prompt cleanup with a timeout so a wedged shutdown step (a SessionEnd hook, MCP shutdown, or a connection blackholed by a restrictive firewall) can't keep a completed run alive forever. Add unit tests for the force-exit guard and for cleanup staying bounded when harness.close() hangs.
🦋 Changeset detectedLatest commit: 269b9d5 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
commit: |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2e41108a8f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…eMainCommand pure handleMainCommand is a reusable, exported, unit-tested handler — scheduling a deferred process.exit inside it could terminate a test runner or an embedding host once the spied exit was restored. Make it pure: it now returns a MainCommandOutcome, and the process entrypoint (the program action, which already owns the error-path process.exit) arms the unref'd force-exit fallback only for a completed headless run.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 8672e4fc6c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…ing output The unref'd force-exit fallback fired after a fixed grace even when the event loop was alive only because buffered stdout/stderr had not finished flushing to a slow or piped consumer (the prompt writers ignore write() backpressure). That could truncate the assistant output or resume hint of an otherwise-successful `kimi -p` run — a regression versus the previous natural-drain exit. finalizeHeadlessRun now flushes stdio first (bounded by HEADLESS_STDIO_DRAIN_TIMEOUT_MS) and only then arms the force-exit. Draining first also means a leaked handle is the only thing that can still hold the loop, so the backstop stays precise and bounded.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 28ce5a82f1
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The cleanup timeout guard caught every rejection, so a cleanup step that failed fast (e.g. restoring a resumed session's permission, or harness.close hitting a persistence error) was silently swallowed: runPrompt resolved as success and the session could be left in `auto` with no error surfaced. A timeout should bound how long we wait, not change the outcome. raceWithTimeout now propagates a rejection that settles before the timeout, and only swallows the abandoned promise's late rejection once the timeout has won the race.
Problem
kimi -p(headless/print mode) can hang after the run completes: the agent finishes, prints its answer and the resume hint, then the process never exits and is eventually killed by an external timeout (e.g. a benchmark/sandbox wall-clock limit).Root cause
Print mode never calls
process.exit()— it relies on the Node event loop draining once the run is done. If a stray ref'd handle survives shutdown — a lingering socket (e.g. a connection blackholed by a restrictive firewall, or an HTTP/2 session kept alive by PING), an un-cleared timer, or a child whose pipes stay open — the loop never empties and the completed run hangs.The full headless path (turn →
run_in_backgroundtask → telemetry →harness.close()) was reproduced end to end; on a healthy network it exits in ~1–2s, so the lifecycle/background-cleanup code itself is fine — the hang needs a lingering handle, and-phas no fallback to force exit.Fix
scheduleHeadlessForceExit). A healthy run drains and exits before it fires, so behaviour is unchanged; it only force-exits a run whose loop is already wedged. The exit code is read lazily, so goal turns that setprocess.exitCodeare preserved.Tests
clearTimeout.runPrompttest: completes even whenharness.close()never resolves.test/cli/update/preflight.test.ts(unrelated to this change — they fail identically onmain).