harmont_cli/plugin/signal.rs
1//! Bridges OS signals to the orchestrator's `CancellationToken`.
2//!
3//! Today's hm process: a single tokio runtime serving one CLI command.
4//! Ctrl-C should: (1) flip the token so plugins drain quickly; (2)
5//! exit with code 130 (sigint).
6
7// Pedantic-bucket nags accepted at module scope:
8// - `print_stderr`: this module's whole purpose is signalling the user
9// on the TTY when they Ctrl-C. The output sink is not running at this
10// point (or is being torn down); stderr is the correct channel.
11// - `exit`: force-exit on second Ctrl-C is the documented UX, matching
12// the legacy executor. The user has explicitly asked us to die.
13#![allow(clippy::print_stderr, clippy::exit)]
14
15use std::sync::Arc;
16use std::sync::atomic::{AtomicBool, Ordering};
17
18use crate::orchestrator::cancel::CancellationToken;
19
20/// Spawn a tokio task that listens for SIGINT (Ctrl-C) and flips
21/// the token. Returns a handle; aborting the handle is sufficient
22/// cleanup since the runtime tears down on process exit.
23///
24/// On second Ctrl-C, the task force-exits with code 130 — same UX
25/// as the legacy executor.
26#[must_use = "drop the JoinHandle to leak the listener; bind to a `_` to tie its lifetime to the caller scope"]
27pub fn install_ctrlc(token: CancellationToken) -> tokio::task::JoinHandle<()> {
28 tokio::spawn(async move {
29 let armed = Arc::new(AtomicBool::new(false));
30 loop {
31 match tokio::signal::ctrl_c().await {
32 Ok(()) => {
33 if armed.swap(true, Ordering::SeqCst) {
34 eprintln!("\nforce-exit on second Ctrl-C");
35 std::process::exit(130);
36 }
37 eprintln!("\ncancelling… (Ctrl-C again to force)");
38 token.cancel();
39 }
40 Err(_) => return,
41 }
42 }
43 })
44}