Skip to main content

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}