REPL Ctrl-C cancels the running command cooperatively instead of killing the process - #443
Conversation
…penSUSE#441) `request_review --watch` listened on `tokio::signal::ctrl_c()` directly — which armed tokio's process-wide SIGINT handler as a side effect, so after one watch a Ctrl-C anywhere else in the session was silently swallowed instead of doing anything. `regenerate`'s wait carried a should-stop predicate wired to a spinner flag nothing ever set on a signal, making its "Ctrl-C bails out promptly" comment dead code. Both now observe the session's `CancellationToken`: `sleep_or_interrupt` selects the token (in the inter-poll sleep AND the 429 backoff, which could previously block a cancel for up to the 60s retry clamp), and `regenerate`'s predicate reads the live token alongside the spinner flag. The user-visible outcomes are unchanged — a stopped watch still reports "stopped watching; the request is still posted" and returns success. This also gives the seam its first reach into the waits from MCP: a `job_cancel` now interrupts a running `request_review --watch` or `regenerate` wait at the next poll boundary, which the signal-only implementation could never do. The previously-untested Interrupted branch gains tests on both arms, cancelled mid-wait rather than pre-cancelled, so a closure that captures the token's state at construction instead of reading it live goes red.
… killing the process (openSUSE#441) A Ctrl-C during a dispatched command hit the default SIGINT disposition and killed mtui outright — reedline holds raw mode only inside `read_line`, so mid-dispatch the terminal is cooked and the key is a real signal. The flows' "unlock always" sections never ran, and the stranded `/var/lock/mtui.lock` carried a dead pid, so it read as foreign to every later run: only `unlock --force` or the 24h stale reap could clear it. A forwarder task (one persistent SIGINT stream, armed when the REPL starts) feeds presses into a small channel. The dispatch site drains stale presses, installs a fresh `CancellationToken` — the token is one-shot, and without the per-line install a cancelled token would fail every later command at the driver pre-flight, including `quit`'s teardown — and runs the dispatch under a biased select that keeps polling the command after a press: the first press cancels the token (the flows unwind at their own checkpoints and release their locks) and says so; a second press force-quits with exit 130 and a warning naming `unlock --force`. The completion branch wins a race with a press, so a command that already finished can never be force-quit for it; escalation propagates out of the loop so the editor drops first and the session's history survives a force-quit. Ctrl-D's teardown goes through the same shape minus the cancel: a press during a wedged teardown never cancels it (the teardown is the cleanup) but still counts toward force-quit — previously a hung teardown left the operator with no exit at all once the handler was armed. The startup seeding window (`-a`/`--sut` before the first prompt) still has the old behavior; the docs say so rather than claiming otherwise.
The FAQ gains "What does Ctrl-C do?": the two-press protocol, and the honest per-command latency — checkpointed flows (`update`, `prepare --installed-only`, non-transactional `downgrade`, multi-template fan-outs) stop with `error: cancelled` and their locks released; `install`/`uninstall`/`run`/`reboot` and transactional downgrades finish their current host operation first and report normally; the folded waits stop and report success in their own words. The startup seeding window and the force-quit cost are stated as they are. AGENTS.md's cancellation paragraph records the second producer (REPL SIGINT next to MCP `job_cancel`), scopes the `CommandError::Cancelled` rule to flows that stop *as failures*, and replaces the "no headless caller can ever fire ctrl_c" claim with what is actually true — a shared stdio terminal fires every registered listener.
b3878cc to
b7e5e9c
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #443 +/- ##
==========================================
- Coverage 96.32% 96.28% -0.04%
==========================================
Files 193 193
Lines 42260 42907 +647
==========================================
+ Hits 40705 41315 +610
- Misses 1555 1592 +37 ☔ View full report in Codecov by Harness. |
mimi1vx
left a comment
There was a problem hiding this comment.
Reviewed against a local worktree of the PR head (b3878cc1).
Gates run locally, all green: cargo clippy --workspace --all-targets -- -D warnings, RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --all-features --document-private-items, cargo test -p mtui-cli -p mtui-core (770 tests).
The core design is right: cooperative-first, biased select, fresh token per dispatch, dispatch future never dropped on the first press. I verified the parts that are easy to get wrong and they hold (details at the bottom). Three things need fixing before merge — one behavioural, two that record load-bearing constraints the code currently leaves undocumented or contradicted.
Critical (must fix)
1. The teardown protection only covers Ctrl-D, not a typed quit/exit — and three documents claim otherwise
step_teardown (fresh token, escalate-only, no cancel arm) is reachable only from Signal::CtrlD (crates/mtui-cli/src/repl.rs:287). A typed quit, exit, or exit reboot is Signal::Success(line) (repl.rs:208) and goes through step_interruptible, which does token.cancel() on the first press and, on the second, emits the mid-command warning (repl.rs:265):
forcing exit mid-command; operation locks may remain on the update's hosts — release them withunlock --forcefrom a new session
In the exact scenario the escalate arm was added for — a blackholed refhost parking release_pool_claims, which has no timeout of its own — an operator who typed quit instead of pressing Ctrl-D force-quits and is told to run unlock --force, with no mention of unlock --pool. The RRID-based pool claim is genuinely stranded on the fleet and the message points nowhere near it.
The cancel itself is inert today (Quit::call never consults the seam, and neither release_pool_claims nor HostsGroup::close gate on it), so this is wrong operator guidance plus a latent contract hole rather than an immediate strand — but it is a hole: the moment anything on the close path gains a checkpoint, a typed quit cancels its own cleanup, which is precisely #441.
Three documents assert the fixed behaviour:
CHANGELOG.md:218— "During the Ctrl-D/quitteardown a press cannot cancel anything"docs/src/faq.md:208— "Ctrl-C during the Ctrl-D/quitteardown behaves the same way but cancels nothing"AGENTS.md:233says "Ctrl-D teardown", which is accurate — so the contract file and the user-facing docs now disagree with each other.
Suggested fix, matching the existing is_shell_line / is_edit_line precedent already in this loop: resolve the line's first token through the registry and route a Quit hit to step_teardown; or give step_interruptible a cancel_on_press: bool (see Warning 3, which wants the same seam). Please add a test that a typed quit never observes a cancelled token — a_press_during_the_teardown_does_not_cancel_it mirrored onto step_interruptible with SlowQuitCmd is a two-line variant, and it should be observed red first.
2. main.rs — the reason this cannot become fn main() -> ExitCode is undocumented, and it is a deadlock rather than a style preference
The obvious later "modernise the binary" cleanup is:
fn main() -> anyhow::Result<ExitCode> { …; Ok(ending.into()) }which runs destructors and lets the drop(repl) dance be deleted. It would hang the force-quit. Returning from main drops runtime, and Runtime::drop blocks until in-flight spawn_blocking tasks complete. There is one live on exactly this path: Prompter::stdin() (crates/mtui-hosts/src/prompter.rs:73-90), installed at the composition root (crates/mtui-cli/src/main.rs:78), performs a blocking io::stdin().read_line() on the blocking pool to back the SSH command-timeout question. If the operator double-presses Ctrl-C while keep waiting? [Y/n] is outstanding — the single most likely moment to force-quit — that read never returns, so Runtime::drop never returns. std::process::exit(130) is what escapes it.
The comment at main.rs:95-100 justifies only drop(repl) (the history flush) and says nothing about why process::exit rather than ExitCode. By this repo's own rule — a load-bearing shape must not be left explained by a comment about something else — this constraint needs recording, or the next cleanup pass silently reintroduces a hang in the escape hatch. One sentence is enough:
process::exit, notmain() -> ExitCode: returning would drop the runtime, which blocks on in-flightspawn_blocking— including the stdin prompter that is very likely outstanding when someone force-quits.
3. A second exit-code vocabulary, and the documented exit-code contract left stale
mtui_core::ExitStatus (crates/mtui-core/src/entrypoint.rs:42) already exists, is exported, and its module doc carries a ## Exit-code contract section presenting itself as the authority (Ok=0, Failure=1, Usage=2, with From<ExitStatus> for i32). It is consumed by seed_session (ControlFlow<ExitStatus>) and by mtui-mcp/src/main.rs:28.
After this PR main speaks two exit vocabularies nineteen lines apart:
std::process::exit(code.into()); // main.rs:85 — ExitStatus
std::process::exit(code); // main.rs:104 — ReplExit / EXIT_INTERRUPTEDand entrypoint.rs's contract doc never learns that 130 exists. A reader of the file that claims to define mtui's exit codes concludes mtui only ever exits 0/1/2 — which packaging, wrapper scripts, and CI will act on.
Preferred fix: add ExitStatus::Interrupted => 130 and map ReplExit::ForceQuit onto it. 130 is a process-level convention, not a REPL-internal detail, and it belongs in the one place downstream will look (ExitStatus's own doc line — "the process exit status a single non-interactive command run yields" — needs widening if you take this route). Minimum acceptable: extend the ## Exit-code contract section to name 130 and cross-reference ReplExit.
Warnings (should fix)
-
[
repl.rs:264,repl.rs:299] The escalation warning iswarn!, soset_log_level errorsilences the one message that names the stranded locks. The PR notes the first-press notice is suppressible and calls that a conscious choice — agreed, that one is informational. The escalation line is a different class of event: it is the only record that the process is abandoning locks, emitted at the moment an operator most needs it.tracing::error!survives the level operators actually set. Same for the mid-teardown variant. -
[
repl.rs:433-535]step_interruptibleandstep_teardownare ~90% duplicated. Drain, fresh token,tokio::pin!, biased select,is_nonefall-through, escalate-on-second — identical; the only differences aretoken.cancel()and the warning text. One function with acancel_on_press: boolwould also make Critical 1 a one-line call-site change instead of a third copy. Two independent copies of a subtle select protocol is the shape that drifts. -
[
repl.rs:253-271,repl.rs:287-307,main.rs:94-105] The twoRepl::runescalate arms and the drop-then-exit ordering have no coverage. TTY-boundness is a fair reason for the signal source, but the wiring here — which warning pairs with which arm, anddrop(repl)precedingprocess::exit— is what the changelog makes a user-visible promise about ("without discarding the session's command history").FileBackedHistory's sync-on-drop makes that ordering genuinely load-bearing and nothing enforces it. Extractingfn on_escalate(kind) -> ReplExitwould at least pin the arm→message pairing.
Suggestions
-
[
main.rs:101]drop(repl)drops far more than "the editor".Replis the sole owner ofArc<Mutex<Session>>, so this synchronously tears down the entire session graph — every SSHTarget, the reqwest client, the openQA transport, the template registry — beforeprocess::exit, on the one path whose whole job is get out now. I checked: nothing in that chain blocks today (noDroponSession/Target;SpinnerGuard::drop/TtySpinner::droponlyhandle.abort()plus std-mutex writes). But the blast radius is wider than the comment claims. What you actually want is "run exactly one destructor, then exit", which neitherExitCodenorprocess::exitexpresses — worth naming, and worth narrowing (Repl::into_history(), or dropping onlyline_editor) so this path cannot grow a blocking destructor by accident. -
[
repl.rs:68,repl.rs:90]i32is the wrong domain for an exit code.WEXITSTATUSis 8 bits, soprocess::exittruncates mod 256;EXIT_INTERRUPTED: i32/exit_code() -> Option<i32>admits values that silently change meaning. 130 is fine andExitStatushas the same shape, so consistency partly excuses it — but if Critical 3 lands asExitStatus::Interrupted, make the domainu8(orstd::process::ExitCoderight at the boundary, which is that type's one genuinely good use here even though it cannot be returned frommain). -
MCP semantics of "cancel is success." Folding both waits onto the seam means a
job_cancelon arequest_review --watchorregeneratejob now ends with the command returningOk, i.e.job_status→done, notcancelled. AGENTS.md was amended to carve this out and that is defensible for the REPL, but a client that just calledjob_canceland is told the job succeeded is a surprising surface. Worth a sentence beside thejob_cancelbullet indocs/src/mcp.md:293. -
[
request_review.rs:403] The watch banner still reads"watching for reactions (up to {}s, Ctrl-C to stop)". Over MCP there is no Ctrl-C, andjob_cancelnow works — cheap to say "Ctrl-C / job_cancel". -
[
repl.rs:345-388] A non-TTY REPL now swallows SIGINT while idle. With the handler armed from the first prompt, a piped session (no raw mode, so Ctrl-C at the "prompt" is a real SIGINT) queues the press and then discards it at the next drain — the process becomes un-interruptible by Ctrl-C between commands. Narrow, and arguably out of scope, but it is a behaviour change the FAQ's "Ctrl-C at the prompt is unchanged" does not cover.
Verified sound
For the record, the parts I specifically tried to break and could not:
- The
biased;ordering and its 32-round race pin — I traced the poll interleaving ina_press_racing_completion_never_force_quits; the presses really are queued in the same poll window as the completion, so the test would be a coin flip withoutbiased. - Unconditional fresh-token-per-dispatch against
Command::run's pre-flightcheck_cancelled:runcallssession.activate(...)on both the single-template and fan-out paths, which re-pushes the fresh token onto the activeHostsGroup, soupdate_flow's fivetargets.cancel_requested()checkpoints observe the right token. The stale-token residual on non-active groups is genuinely inert —Quit,close, andrelease_pool_claimsnever read the seam, andrun_paralleldeliberately doesn't. editandshellare intercepted before dispatch, so the "idle gap" drain rationale is accurate and neither can be cancelled mid-editor. tokio'ssigactionhandler is reset onexec, so child editors/shells keep default SIGINT; theshellbridge holds local raw mode, so Ctrl-C there is a byte to the remote PTY, not a signal.- Drain loop terminates on both
EmptyandDisconnected;mpsc::Receiver::recvis cancel-safe in the select; thepress.is_none()fall-through is unreachable-but-correct. drop(repl)beforeprocess::exitis genuinely required (reedline syncsFileBackedHistoryon drop) andruntimecorrectly outlives it.- No secrets reach the display or the log on any new path.
…openSUSE#441) Addresses mimi1vx's review round on openSUSE#443. The teardown protection reached `Signal::CtrlD` only, so a typed `quit` or `exit` took the ordinary path: the first Ctrl-C cancelled the cleanup's own token, and the second told the operator to run `unlock --force` while saying nothing about the pool claim they had just stranded. Three documents already claimed otherwise. The two near-identical dispatch loops are now one function parameterised by `OnPress`, and a line whose *command position* resolves to `quit` is routed to the no-cancel variant — so aliases come along for free while `help quit` stays an ordinary line. The force-quit records move from `warn!` to `error!`: they are the only trace that mtui is walking away from locks it holds, and they have to survive the `set_log_level error` an operator may be running under. Their arm→message pairing is extracted into `on_escalate` so a test can pin which remedy goes with which exit. `ExitStatus` gains `Interrupted` (130) and `ReplExit` maps onto it, so the binary speaks one exit vocabulary and the module documenting the exit-code contract knows that 130 exists. The pre-exit drop is narrowed to the line editor alone — the force-quit path runs exactly one destructor (reedline's history sync) instead of tearing down the whole session graph — and the reason `process::exit` cannot become `main() -> ExitCode` is now recorded at the site: returning drops the runtime, which blocks on in-flight `spawn_blocking`, including the stdin prompter most likely to be outstanding just then. Docs: the FAQ and CHANGELOG now say "the session teardown (Ctrl-D or a typed `quit`/`exit`)" truthfully, the FAQ notes that a piped session is not interruptible between commands, `mcp.md` explains why a cancelled watch job ends `done` rather than `cancelled`, and the watch banner names `job_cancel` beside Ctrl-C.
|
All addressed in
Four new mutation proofs cover the routing, the token-position match, the pairing, and the |
Closes #441.
The bug
reedline holds raw mode only inside
read_line, so a Ctrl-C at the prompt is a key event — but during a dispatched command the terminal is cooked, the key is a real SIGINT, and the default disposition kills mtui outright. The flows' "unlock always" sections never run, and unlike #405's in-process strand, the leftover/var/lock/mtui.lockcarries a dead pid: it reads as foreign to every later run, and onlyunlock --forceor the 24h stale reap clears it.quit's teardown (pool claims, host close) is skipped the same way.A second, related defect fixed along the way:
request_review --watchlistened ontokio::signal::ctrl_c()directly, which arms tokio's process-permanent SIGINT handler as a side effect — so after one watch, a Ctrl-C during any later command was silently swallowed instead of doing anything. Ctrl-C behavior was non-deterministic across a session.The fix
First press cancels, second press force-quits — driving the cooperative seam that already exists (the same one MCP
job_canceluses):edit/idle gap must not cancel the next command — and with the two-slot queue, a leftover press would otherwise consume the cooperative slot so a genuine press force-quits), installs a freshCancellationTokenper dispatch (the token is one-shot; without the per-line install, a cancelled token would fail every later command at the driver pre-flight — includingquit's teardown, re-stranding exactly these locks), and runs the dispatch under a biased select that never drops the command future: a press cancels the token and reports it; the flows unwind at their own checkpoints and release their locks; a second press escalates.maindrops the editor first — the session's shell history survives a force-quit — then exits 130 with a warning namingunlock --force/--poolfrom a new session.sleep_or_interruptselects the session token (in the inter-poll sleep and the 429 backoff, which could block a cancel up to the 60s retry clamp), andregenerate's should-stop predicate — previously wired to a spinner flag nothing ever set, making its Ctrl-C claim dead code — reads the live token. Bonus this buys: MCPjob_cancelnow interrupts a running watch/regenerate wait at the poll boundary, which the signal-only implementation never could.Honesty about what the first press does per command
Cancellation is cooperative and the flows differ (documented in the new FAQ entry):
update,prepare --installed-only, non-transactionaldowngrade, and multi-template fan-outs stop at their checkpoints witherror: cancelledand locks released (update's last checkpoint sits before the point of no return; a press after dispatch is deliberately inert).install/uninstall/run/rebootand transactional downgrades have no mid-op checkpoint by design — they finish the current host operation, release locks normally, and report their result. The folded waits stop and report success in their own words.Known remaining window, stated rather than papered over: the startup seeding load (
-a/--sutbefore the first prompt — the slowest phase in the program) still has the old kill-with-strand behavior; arming the handler there without a consumer would make Ctrl-C a silent no-op for a minute, which is worse. Routing seeding through the same drain/token protocol is the follow-up.Tests
Twenty-one mutation proofs across two rounds, each observed red with the test demonstrably running. The adversarial round (three independent reviewers over the uncommitted tree) reshaped the change: the biased select, the teardown escalate arm, the history-preserving exit path, and five surviving mutants killed — including a drain
while→ifmutant whose real-world outcome (a stale press consuming the cooperative slot) would have been strictly worse than the original bug. Two proofs are documented as bounded rather than absolute: the biased-select pin is statistical (32 independent races per run; the unbiased mutant failed both ways when measured), and the one-line Ctrl-D arm wiring is unreachable from tests (TTY-bound) — narrowed structurally by the type change instead, and said so. No test raises a real signal; the interrupt source is channel-injected, and the thintokio::signalforwarder plusprocess::exit(130)stay untested by design, per the repo's established pattern. Stress: 40 consecutive suite runs (~1280 select races), zero flakes.Residuals (documented)
release_pool_claimshas no timeout of its own (pre-existing;CLOSE_TIMEOUTwraps only the host close) — the teardown escalate arm is the operator's exit meanwhile.tracing::warn!— suppressible byset_log_level error, matching thewarn_on_unlock_failuresprecedent; a conscious choice, noted.activaterefreshes the active group every dispatch).Process
Three scouts (REPL/signal internals incl. vendored reedline/crossterm/tokio semantics, the per-command cancel-coverage map, the signal-test feasibility study), implementation, then three independent read-only adversarial reviews (signal semantics, test vacuity, contracts) over the uncommitted tree — thirteen findings, all fixed before the first commit existed.