Skip to content

fix: stop swallowing interrupts forever when an agent won't exit - #249

Merged
quickbeard merged 1 commit into
mainfrom
launcher-interrupt-escalation
Aug 6, 2026
Merged

fix: stop swallowing interrupts forever when an agent won't exit#249
quickbeard merged 1 commit into
mainfrom
launcher-interrupt-escalation

Conversation

@quickbeard

@quickbeard quickbeard commented Aug 6, 2026

Copy link
Copy Markdown
Owner

What users report

CoDev Code can't be quit on Windows: double ctrl+c leaves the terminal hung and frozen.

From the user's screenshot: the launch banner (Starting CoDev Code...), then the TUI's exit epilogue (Continue codev -s ses_…) — and no shell prompt after it. The agent had already run its shutdown path and printed its parting line; what never came back was the terminal.

What this PR fixes

runAgent registered no-op SIGINT/SIGTERM handlers for the entire lifetime of the child:

const swallow = () => {};
process.on("SIGINT", swallow);
process.on("SIGTERM", swallow);

The intent is right — the terminal already delivers the interrupt to the child, so the hub shouldn't tear itself down first and yank the console out from under an agent that is still cleaning up. But it never escalates. If the child stops responding, the hub cannot be interrupted at all: the promise never settles, and no number of ctrl+c presses reaches it. The only way out is closing the terminal window, which is exactly what "hanging and frozen" describes.

This is demonstrable, not theoretical — the two new tests hang for the full 30s timeout against main and pass with the change.

Windows is where a wedged child is most likely. A hub-launched agent runs through four nested cmd.exe batch shims:

PowerShell → codev.cmd (our shim) → codevhub.cmd (npm) → node (hub)
           → cmd.exe (shell:true) → codev.cmd (npm) → codev.exe

A batch host that catches a console break stops at Terminate batch job (Y/N)? and waits for input rather than exiting — and with the agent's alternate screen just torn down, that prompt is easy to miss entirely.

The change

Swallow the first interrupt as before; a second one stops waiting, tells the user the agent may still be shutting down, logs it, and resolves 130.

The child is deliberately not killed. On Windows child.kill() is TerminateProcess, which would take out the cmd.exe wrapper and orphan the real agent still attached to the console — strictly worse than letting it finish. Giving the shell back is the goal.

The escalation does not fire during a normal session: interactive agents hold the terminal in raw mode, where ctrl+c is delivered as input rather than as a signal, so the counter only moves once the terminal is generating real interrupts.

Tests

Three new tests in tests/lib/run.test.ts, driven through process.emit against a fake child that never exits, so they behave identically on Windows:

  • a single interrupt is still swallowed and the child's own exit code still wins
  • a second interrupt resolves 130 and prints the notice
  • the signal listeners are removed when it stops waiting (no leak across launches)

Full suite green via the pre-commit hook: biome check, tsc --noEmit, 1387 passed / 2 skipped, pnpm build.

What this does not do

The root cause of the freeze is not confirmed. I could not reproduce it — the same chain on macOS unwinds cleanly in 0.74s (node codevhubcodev, both gone), so whatever wedges is Windows-only, and I have no Windows host to bisect on.

What this PR guarantees is that the failure is survivable: the user gets their terminal back instead of having to close the window. It does not explain why the child stops exiting in the first place.

To close that out, a Windows reporter can run codev, quit with double ctrl+c, and while it is hung check which processes remain:

Get-CimInstance Win32_Process -Filter "Name='cmd.exe' OR Name='node.exe' OR Name='codev.exe'" |
  Select-Object ProcessId, ParentProcessId, CommandLine

If cmd.exe layers survive after codev.exe is gone, it is the batch-shim break prompt and the fix belongs in the shim chain (a call-based shim, or resolving past npm's .cmd so shell: true isn't needed). If codev.exe itself is still alive, the hang is in the agent's own shutdown and belongs in codev-code.

Two related observations in codev-code, neither changed here:

  • cli/cmd/tui.ts bounds the worker shutdown RPC at 5s before process.exit(0), so a slow shutdown holds the console for up to five seconds after the epilogue is already on screen — the exact window in which a user concludes it is hung and starts mashing ctrl+c.
  • Tui.run calls win32FlushInputBuffer() before that shutdown and before win32InstallCtrlCGuard's restore, so ctrl+c presses during those seconds stay buffered and land on the shell afterwards.

🤖 Generated with Claude Code

runAgent registered no-op SIGINT/SIGTERM handlers for the whole lifetime
of the child so the terminal's interrupt would reach the agent without
tearing the hub down first. The handlers were never escalated, so if the
child stopped responding the hub could not be interrupted at all: the
promise never settled and no number of ctrl+c presses could reach it.
The user's only way out was closing the terminal window.

Windows is where this bites. A hub-launched agent runs through four
nested cmd.exe batch shims (our .cmd shim, npm's codevhub.cmd, the
shell:true wrapper, npm's codev.cmd), and a batch host that catches a
console break stops at "Terminate batch job (Y/N)?" rather than exiting.

Swallow the first interrupt as before, then stop: a second one abandons
the wait, tells the user the agent may still be shutting down, and
resolves 130. The child is deliberately not killed — on Windows that
would TerminateProcess the cmd.exe wrapper and orphan the real agent
still attached to the console.

The escalation does not fire during a normal session: interactive agents
hold the terminal in raw mode, where ctrl+c arrives as input rather than
as a signal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard force-pushed the launcher-interrupt-escalation branch from dbb9d84 to e4db661 Compare August 6, 2026 04:07
@quickbeard
quickbeard merged commit 36c4dd9 into main Aug 6, 2026
2 of 4 checks passed
@quickbeard
quickbeard deleted the launcher-interrupt-escalation branch August 6, 2026 04:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant