POK-85: Fix Ctrl-C / graceful exit in aikit update#34
Merged
Conversation
…eardown) `aikit update` couldn't be aborted cleanly with Ctrl-C. Root cause was subprocess lifecycle, not signal delivery: `run()` executed each update / install command with `subprocess.run(shell=True, capture_output=True)`, which on interrupt only kills the immediate shell. The real installer tree it spawns (npm/pip/curl and their children) was orphaned and kept running, so aborting an update never truly returned control — most visible under tmux. aikit `run()`: - Launch captured commands in their own session/process group (`start_new_session`, POSIX) with stdin closed. - On KeyboardInterrupt or timeout, tear down the *entire* group (SIGINT/SIGTERM -> SIGKILL) via a new `_terminate_process_tree`, then re-raise so `sk.run_cli` still exits 130. aikit — not the tty — owns the teardown, so behaviour is identical in tmux and a plain shell. - Timeouts now kill the whole tree instead of leaking a runaway process. - Non-POSIX / non-captured runs fall back to single-process teardown. - Exit codes, return shape, and output are unchanged. scriptkit `parallel_map()` (used by aikit agent discovery): - Replace the ThreadPoolExecutor (whose context-manager exit blocks until every in-flight task finishes) with daemon workers + an interruptible queue drain, so Ctrl-C during discovery no longer hangs until the slowest version check / network timeout returns. Signature, completion-order results, progress rendering, and worker-exception propagation are unchanged. Regression tests: session isolation, timeout tree-teardown, and interrupt tree-teardown for aikit `run()`; completion order, exception propagation, and prompt-interrupt for `parallel_map`. Bumps aikit 1.15.4 -> 1.15.5 and scriptkit 1.2.0 -> 1.2.1 (both PATCH: bug fix, no interface change). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: multica-agent <github@multica.ai>
saheljalal
commented
Jul 3, 2026
saheljalal
left a comment
Collaborator
Author
There was a problem hiding this comment.
Review — POK-85 Ctrl-C / graceful exit in aikit update
Verdict: Approve. Root cause is correctly identified (orphaned installer tree, not signal delivery), the fix is defensively coded with sane fallbacks, and the regression tests are meaningful and pass. I checked out the branch, traced the paths, and ran the suite locally — everything below the diff holds up.
What I verified
run()teardown (aikit:1111,_terminate_process_treeataikit:1059): capturingpgid = proc.pidup front (before the group leader can exit) is the right call —os.killpg(pgid, …)still reaches the tree after the leader dies. SIGINT/SIGTERM→SIGKILL escalation with a grace reap in between is correct, and thepgid is Nonefallback (capture=False/ non-POSIX) tears down only the single child, so we can never signal aikit's own group.signalis imported,hasattr(os, "killpg")guards the group path.- Interrupt semantics:
start_new_session=Trueputs the child in its own session, so the tty's Ctrl-C reaches only aikit; aikit forwards SIGINT to the group and re-raises, letting the single termination point insk.run_cli(scriptkit/cli.py:63) exit130. Confirmed that convention exists — no newtry/except KeyboardInterruptin the tool, per repo style. 👍 - Timeout path: after
TimeoutExpired, tearing the tree then a secondcommunicate(timeout=5)to drain is correct; the(TimeoutExpired, ValueError)guard covers the reaped-then-drain case. parallel_maprewrite (scriptkit/progress.py:79): daemon workers + a 0.1s-polled drain gives prompt interrupt; every item yields exactly onecompletedentry so therange(len(items))drain can't deadlock; completion-order and exception propagation preserved.- Tests:
tests/test_aikit_interrupt.pyand the newparallel_mapcases pass locally. The heartbeat-file liveness check (vsos.kill(pid,0)) is a nice touch — a killed-but-unreaped zombie can't masquerade as alive. Full suite green except the pre-existingpluckcharacterization failures, which I confirmed also fail onmain— unrelated to this PR.
Notes (non-blocking)
- Nit / confirm — captured commands now get
stdin=DEVNULL(aikit:1132). Previouslysubprocess.run(capture_output=True)left stdin inherited. This is a behavioral change and, I'd argue, an improvement (a captured child that prompts on stdin was already invisible since stdout is piped, so it would hang; EOF now fails fast and the child can't steal terminal input). I checked everyrun()caller — none pipe input — so this is safe, but it's worth a one-line mention in the PR body since it's a real semantics change beyond the interrupt fix. - Nit —
parallel_mapworker-exception cleanup (scriptkit/progress.py): on the first worker exception_drainraises immediately, leaving the remaining daemon workers running (they're abandoned, not awaited). The oldThreadPoolExecutorblocked at__exit__until in-flight tasks drained. For the discovery use case (read-only version checks, no shared mutation) this is harmless and arguably better, but the daemons keep pulling frompending/pushing to an orphanedcompletedqueue until they finish. Fine as-is; flagging only so it's a conscious trade-off.
Nice, well-scoped fix — correct diagnosis, minimal surface, good coverage.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
aikit updatecouldn't be aborted cleanly with Ctrl-C (reported through tmux, but the defect is environment-independent). Tracing SIGINT handling showed the root cause is subprocess lifecycle, not signal delivery:run()executed each update/install command withsubprocess.run(shell=True, capture_output=True). On interrupt Python'ssubprocess.runonly kills the immediate shell, so the real installer tree it spawned (npm/pip/curl and their children) was orphaned and kept running. aikit itself returned, but control never cleanly came back to the terminal — which reads as "couldn't exit gracefully," and is most visible under tmux where the orphans stay attached to the pane.Fix
aikit
run()(_terminate_process_tree+ rewrite)start_new_session, POSIX) with stdin closed.KeyboardInterruptor timeout, tear down the entire group (SIGINT/SIGTERM→SIGKILL), then re-raise so the single termination point insk.run_clistill exits130. aikit — not the tty — owns the teardown, so behaviour is identical in tmux and a plain shell.scriptkit
parallel_map()(used by aikit agent discovery:aikit,doctor,setup, first run)ThreadPoolExecutorblocked at context-manager exit until every in-flight task finished, so Ctrl-C during discovery hung until the slowest version check's network timeout (up to ~20s) returned. Replaced with daemon workers + an interruptible queue drain, so the interrupt is raised in the main thread immediately. Signature, completion-order results, progress rendering, and worker-exception propagation are unchanged.This follows the repo convention that graceful exit lives in
scriptkit/sk.run_cli— no newtry/except KeyboardInterruptwas added to the tool.Behavioral note — captured stdin (intentional, reviewed safe)
Captured commands (
capture=True, the default and every current caller) are now launched withstdin=subprocess.DEVNULLinstead of inheriting the parent's stdin. This is a deliberate part of the fix: a session-isolated background command has no controlling terminal, so an inherited stdin could block on aread(or raiseSIGTTIN) and hang the run. With stdin closed, a stray read gets EOF immediately instead. The Code Reviewer confirmed no caller pipes input intorun(), so nothing any caller relies on changes — these are non-interactive install/update/version-check commands whose output is already captured.capture=Falseruns are unaffected and still inherit stdin.Tests
New regression tests, all green (
venv/bin/python -m pytest; pre-existing unrelatedpluckfailures aside):tests/test_aikit_interrupt.py— session isolation, timeout tree-teardown, and interrupt tree-teardown (liveness verified via a heartbeat file so a killed-but-unreaped zombie can't masquerade as alive).tests/test_cli_progress_tables.py—parallel_mapcompletion order, worker-exception propagation, and prompt-interrupt (asserts Ctrl-C returns without awaiting 5s workers).Verified end-to-end by driving a real
SIGINTat a process group: before, a signal-ignoring grandchild survived; after, the whole tree is gone and the process exits 130.Versioning
1.15.4→1.15.5(PATCH — bug fix, no interface change)1.2.0→1.2.1(PATCH — bug fix, no interface change)Closes POK-85