Skip to content

fix(plain): honour Ctrl+C during probes and kill the final command - #12

Open
bsg62 wants to merge 1 commit into
mainfrom
fix/plain-shutdown
Open

fix(plain): honour Ctrl+C during probes and kill the final command#12
bsg62 wants to merge 1 commit into
mainfrom
fix/plain-shutdown

Conversation

@bsg62

@bsg62 bsg62 commented Jul 28, 2026

Copy link
Copy Markdown
Member

Fixes three related shutdown bugs in plain (non-TUI) mode. Each was found by reading the code and confirmed by a regression test that fails against a faithful revert of that individual fix.

1. Ctrl+C was silently swallowed during health probes

The probe loop had no signal arm, and tokio::signal::ctrl_c() was rebuilt at every select! — a fresh future only observes signals delivered while it is being polled. Worse, because tokio installs a SIGINT handler the default terminate action is gone, so a missed signal left the process uninterruptible rather than killing it.

With a readiness endpoint that accepts but never answers (timeout: 10), the future was armed for roughly one second in eleven. Six consecutive SIGINTs over 12 seconds were all ignored.

A dedicated task now owns one long-lived ctrl_c() future and publishes to a watch channel that every wait point selects on, including the probe round itself.

2. Ctrl+C orphaned the final command

The handler stopped the servers and called std::process::exit. The final command was not a process group and kill_on_drop was skipped on the tee path — and process::exit runs no destructors regardless. Verified: servers died, the runner exited, and the final command kept running. For the intended use case (command: "npm test") that leaks a whole test runner and its children.

The final command is now always spawned as a process group and killed explicitly. spawn, spawn_captured and spawn_inner collapse into a single spawn_group(command, tee_output) — plain mode still tees to the terminal, the TUI still captures only. This also removes three stale #[allow(dead_code)] items.

3. The failure error named a healthy server

Every round re-probed all servers, including ones already reporting ready, incrementing their counters. The first server to exhaust its attempts aborted the run — whichever was first in the list, not whichever was broken. Against the old code the new test produces:

An error occurred: Could not connect to server Ready Server after 8 attempts

Ready Server was serving HTTP 200 the whole time; Never Ready was the actual problem. Ready servers are now skipped.

Notes

  • Ctrl+C still exits 0; a failure to stop servers still exits 1, with its message now routed through anyhow instead of a bare eprintln + exit(1).
  • Plain mode no longer re-detects a server that dies after reporting ready. That is the tradeoff which makes the error attribution correct, and it matches what the TUI already does.

Testing

76 tests pass (52 unit + 24 integration), clean across back-to-back runs with no leaked processes. cargo clippy --all-targets -- -D warnings and cargo fmt --check are clean.

New test Failure against old behavior
honours_ctrl_c_while_a_probe_is_in_flight runner ignored Ctrl+C delivered during an in-flight probe
stops_final_command_descendants_on_ctrl_c runner did not exit promptly after Ctrl+C
blames_the_server_that_never_became_ready blames Ready Server instead of Never Ready

🤖 Generated with Claude Code

Plain mode built a fresh `tokio::signal::ctrl_c()` future at every
`select!`, so a SIGINT delivered outside those windows was dropped — and
because tokio installs a handler, the default terminate action was gone
too, leaving the process uninterruptible rather than killed. With a slow
readiness probe the future was armed for roughly one second in eleven,
so Ctrl+C usually did nothing. A dedicated task now owns a single
long-lived future and publishes to a watch channel that every wait point
selects on, including the probe round itself.

On shutdown the runner called `std::process::exit`, which left the final
command and its descendants running. The final command is now always
spawned as a process group and killed explicitly. `spawn`,
`spawn_captured` and `spawn_inner` collapse into one `spawn_group`;
plain mode still tees output to the terminal, the TUI still captures
only.

The probe loop also re-probed servers that had already reported ready,
so a healthy server exhausted its attempts first and was named in the
error while a different, slower server was the one holding up the run.
Ready servers are now skipped. As a consequence plain mode no longer
re-detects a server that dies after reporting ready, matching the TUI.

Each regression test was checked against a faithful revert of its
individual fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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