Skip to main content

ssh_cli/
signals.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Operating system signal handling for **one-shot** graceful shutdown.
3//!
4//! # Shutdown model (Rules Rust — graceful shutdown, CLI one-shot)
5//!
6//! ssh-cli is **not** a long-lived daemon. Shutdown is **minimal but cooperative**:
7//!
8//! 1. **Detect** — SIGINT (Ctrl+C / `ctrlc`) and SIGTERM (`signal-hook` on Unix).
9//! 2. **Signal** — set shared [`AtomicBool`] flags (`Release` stores).
10//! 3. **Await** — long loops (exec / SCP / tunnel / VPS) poll [`should_stop`] and
11//!    return; `main` flushes stdio, shuts down the Tokio runtime, then exits
12//!    **130** (SIGINT) or **143** (SIGTERM). Broken pipe → **141** (see `errors`).
13//!
14//! ## What we deliberately do *not* do
15//!
16//! - No `CancellationToken` / `TaskTracker` tree (overkill for single-session CLI).
17//! - No SIGHUP hot-reload, SIGUSR ops, readiness probes, or `sd_notify`.
18//! - No `std::process::exit` inside signal handlers (async-signal-unsafe).
19//! - SIGPIPE: Rust std ignores the signal so writers get `BrokenPipe` / exit 141.
20//!
21//! ## Double signal
22//!
23//! A second SIGINT/SIGTERM sets [`is_force_exit`]. Tunnel aborts outstanding
24//! forwards immediately; other paths already return on the first signal.
25//!
26//! ## Interior mutability (Rules Rust)
27//!
28//! | Primitive | Role | Ordering |
29//! |-----------|------|----------|
30//! | `static AtomicBool` (×3) | cancel / term / force flags | store `Release`, load `Acquire` |
31//! | `static AtomicU8` | cooperative hit counter | `Relaxed` (count only; flags publish) |
32//! | `std::sync::Once` | register handlers once | n/a |
33//!
34//! No `RefCell`, `Mutex`, `OnceLock<Arc<_>>`, or `Arc` wrapper around the flags:
35//! process-global atomics are the smallest correct primitive (handlers capture
36//! `'static` references; readers poll without cloning).
37
38use anyhow::Result;
39use std::sync::atomic::{AtomicBool, AtomicU8, Ordering};
40use std::sync::Once;
41
42/// Ensures [`register_handler`] runs at most once (`ctrlc` rejects a second set).
43static REGISTER_ONCE: Once = Once::new();
44
45/// Global cancellation flag (SIGINT / Ctrl+C).
46///
47/// Writers (signal handlers) use `Release`; readers use `Acquire` so a stop
48/// observed in a poll loop happens-after the handler store.
49static CANCEL_FLAG: AtomicBool = AtomicBool::new(false);
50
51/// Global SIGTERM flag (Unix supervisors / `kill` without `-9`).
52///
53/// Same `Release`/`Acquire` pair as [`CANCEL_FLAG`]. Kept separate so exit
54/// code can prefer **143** over **130**.
55static FLAG_SIGTERM: AtomicBool = AtomicBool::new(false);
56
57/// Set when a second cooperative signal arrives while already cancelling.
58///
59/// `Release` store / `Acquire` load — tunnel aborts forwards after observing it.
60static FORCE_EXIT: AtomicBool = AtomicBool::new(false);
61
62/// Counts cooperative signals (SIGINT + SIGTERM) for double-signal escalation.
63///
64/// Ordering: `Relaxed` — this counter only decides local force-exit escalation;
65/// it does **not** publish dependent data. The actual stop state is published via
66/// `Release` stores on the bool flags above (paired with `Acquire` loads).
67static SIGNAL_HITS: AtomicU8 = AtomicU8::new(0);
68
69/// Registers SIGINT (and Unix SIGTERM) handlers that set cancellation flags.
70///
71/// Must be called once **before** any long-running operation and, on the binary
72/// path, **before** the Tokio multi-thread runtime is built (G-UNSAFE-13 /
73/// signal-hook first-hook race). Additional calls are safe and silently ignored
74/// (`Once` / idempotent).
75///
76/// # Errors
77///
78/// Returns an error if the first registration fails (e.g. `ctrlc` cannot
79/// install a handler). Failures are **not** ignored.
80pub fn register_handler() -> Result<()> {
81    let mut register_result: Result<()> = Ok(());
82    REGISTER_ONCE.call_once(|| {
83        register_result = register_handler_inner();
84    });
85    register_result
86}
87
88fn register_handler_inner() -> Result<()> {
89    // SIGINT only — do **not** enable ctrlc `termination` (would also catch
90    // SIGTERM, collide with signal-hook, and collapse exit 143 → 130).
91    // Closures capture `'static` references to process atomics (no Arc).
92    ctrlc::set_handler(|| {
93        note_cooperative_signal(&CANCEL_FLAG, &FORCE_EXIT, "SIGINT");
94    })?;
95
96    tracing::debug!("Ctrl+C (SIGINT) handler registered successfully");
97
98    #[cfg(unix)]
99    {
100        // One SIGTERM path only: atomics inside async-signal-safe callback.
101        // Distinguishes 143 (term flag) from 130 (cancel flag) and escalates
102        // force-exit on a second hit (any cooperative signal via SIGNAL_HITS).
103        // `flag::register` only sets one AtomicBool — insufficient for double-hit.
104        // No tracing/alloc/mutex here — signal-hook low-level runs in async-signal context.
105        // SAFETY:
106        // 1. Contract: `signal_hook::low_level::register` requires the action to be
107        //    async-signal-safe (POSIX): no alloc, no mutex, no panic, no non-safe OS calls.
108        // 2. Callback body: only AtomicBool/AtomicU8 stores and local integer ops
109        //    (async-signal-safe subset); cannot panic.
110        // 3. SIGTERM is not in signal-hook FORBIDDEN set (SIGKILL/SIGSTOP/SIGILL/…).
111        // 4. Binary `main` registers before Tokio multi_thread workers (G-UNSAFE-13);
112        //    library `run()` re-entry is idempotent via REGISTER_ONCE.
113        // 5. See docs.rs signal_hook::low_level::register # Safety.
114        unsafe {
115            signal_hook::low_level::register(signal_hook::consts::SIGTERM, || {
116                // Publish term before counting so a concurrent poll sees stop even
117                // if the second-hit branch races with another handler.
118                FLAG_SIGTERM.store(true, Ordering::Release);
119                let prev = record_signal_hit();
120                if prev >= 1 {
121                    FORCE_EXIT.store(true, Ordering::Release);
122                }
123            })?;
124        }
125        tracing::debug!("SIGTERM handler registered");
126    }
127
128    #[cfg(not(unix))]
129    {
130        // Windows: no native SIGTERM. ctrlc covers Ctrl+C.
131        // Ctrl+Break / console close are not required for this agent one-shot CLI.
132    }
133
134    Ok(())
135}
136
137/// Atomically bumps [`SIGNAL_HITS`]; returns the previous count.
138///
139/// `Relaxed` is enough: only relative ordering of hits matters for force-exit;
140/// visibility of stop state uses `Release`/`Acquire` on the bool flags.
141#[inline]
142fn record_signal_hit() -> u8 {
143    SIGNAL_HITS.fetch_add(1, Ordering::Relaxed)
144}
145
146/// Records a cooperative SIGINT with double-signal force escalation.
147///
148/// Safe to call from the `ctrlc` dedicated thread (may log). Not for use inside
149/// async-signal-safe SIGTERM callbacks.
150fn note_cooperative_signal(cancel: &AtomicBool, force: &AtomicBool, kind: &str) {
151    let prev = record_signal_hit();
152    if prev == 0 {
153        cancel.store(true, Ordering::Release);
154        // tracing in SIGINT handler: ctrlc runs the handler on a dedicated
155        // thread (not the async-signal context), so logging is safe here.
156        tracing::debug!(signal = kind, "cancellation signal received");
157    } else {
158        force.store(true, Ordering::Release);
159        cancel.store(true, Ordering::Release);
160        tracing::debug!(signal = kind, "force-exit signal (second hit)");
161    }
162}
163
164/// Returns `true` if the user pressed Ctrl+C (SIGINT).
165///
166/// Prefer [`should_stop`] when either SIGINT or SIGTERM should abort work.
167///
168/// # Examples
169///
170/// ```
171/// use ssh_cli::signals::is_cancelled;
172///
173/// // Before a signal is delivered, returns false
174/// assert!(!is_cancelled());
175/// ```
176#[must_use]
177pub fn is_cancelled() -> bool {
178    CANCEL_FLAG.load(Ordering::Acquire)
179}
180
181/// Returns `true` if the process received SIGTERM.
182#[must_use]
183pub fn is_terminated() -> bool {
184    FLAG_SIGTERM.load(Ordering::Acquire)
185}
186
187/// Returns `true` when any cooperative stop signal was observed (SIGINT or SIGTERM).
188///
189/// Use this in hot loops instead of duplicating `is_cancelled() || is_terminated()`.
190///
191/// # Examples
192///
193/// ```
194/// use ssh_cli::signals::should_stop;
195///
196/// // Fresh process / test thread without a delivered signal.
197/// assert!(!should_stop());
198/// ```
199#[must_use]
200pub fn should_stop() -> bool {
201    is_cancelled() || is_terminated()
202}
203
204/// Returns `true` after a second SIGINT/SIGTERM while shutdown is already in progress.
205///
206/// Tunnel uses this to abort outstanding forwards without waiting on I/O.
207#[must_use]
208pub fn is_force_exit() -> bool {
209    FORCE_EXIT.load(Ordering::Acquire)
210}
211
212/// Shared cancellation flag (SIGINT) for tests and advanced integration.
213///
214/// Prefer [`should_stop`] / [`is_cancelled`] in product paths. Writing the flag
215/// is intended for tests and cooperative library callers.
216#[must_use]
217pub fn cancellation_flag() -> &'static AtomicBool {
218    &CANCEL_FLAG
219}
220
221/// Resets cooperative signal flags (G6 — test isolation only).
222///
223/// Call from tests that write the global flags; pair with `#[serial]` so
224/// parallel tests do not observe a foreign cancel.
225#[cfg(test)]
226pub fn reset_flags_for_tests() {
227    CANCEL_FLAG.store(false, Ordering::Release);
228    FLAG_SIGTERM.store(false, Ordering::Release);
229    FORCE_EXIT.store(false, Ordering::Release);
230    SIGNAL_HITS.store(0, Ordering::Relaxed);
231}
232
233/// Shared SIGTERM flag for tests and advanced integration.
234#[must_use]
235pub fn sigterm_flag() -> &'static AtomicBool {
236    &FLAG_SIGTERM
237}
238
239/// Shared force-exit flag (second cooperative signal).
240#[must_use]
241pub fn force_exit_flag() -> &'static AtomicBool {
242    &FORCE_EXIT
243}
244
245/// Preferred process exit code after cooperative signal handling.
246///
247/// Order: SIGTERM (143) > SIGINT (130) > `None` (caller decides).
248#[must_use]
249pub fn signal_exit_code() -> Option<i32> {
250    if is_terminated() {
251        Some(crate::errors::exit_codes::EX_SIGTERM)
252    } else if is_cancelled() {
253        Some(crate::errors::exit_codes::EX_SIGINT)
254    } else {
255        None
256    }
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use serial_test::serial;
263
264    fn reset_flags_for_test() {
265        cancellation_flag().store(false, Ordering::Release);
266        sigterm_flag().store(false, Ordering::Release);
267        force_exit_flag().store(false, Ordering::Release);
268        SIGNAL_HITS.store(0, Ordering::Relaxed);
269    }
270
271    #[test]
272    #[serial]
273    fn is_cancelled_false_before_signal() {
274        reset_flags_for_test();
275        assert!(!is_cancelled());
276    }
277
278    #[test]
279    #[serial]
280    fn cancellation_flag_is_stable_static() {
281        let flag_a = cancellation_flag();
282        let flag_b = cancellation_flag();
283        assert!(std::ptr::eq(flag_a, flag_b));
284    }
285
286    #[test]
287    #[serial]
288    fn flag_can_be_set_and_read() {
289        let flag = cancellation_flag();
290        let previous_value = flag.load(Ordering::Acquire);
291        flag.store(previous_value, Ordering::Release);
292        assert_eq!(flag.load(Ordering::Acquire), previous_value);
293    }
294
295    #[test]
296    #[serial]
297    fn is_terminated_false_by_default() {
298        reset_flags_for_test();
299        assert!(!is_terminated());
300    }
301
302    #[test]
303    #[serial]
304    fn sigterm_flag_is_stable_static() {
305        let a = sigterm_flag();
306        let b = sigterm_flag();
307        assert!(std::ptr::eq(a, b));
308    }
309
310    #[test]
311    #[serial]
312    fn is_terminated_true_after_set() {
313        let flag = sigterm_flag();
314        flag.store(true, Ordering::Release);
315        assert!(is_terminated());
316        flag.store(false, Ordering::Release);
317    }
318
319    #[test]
320    #[serial]
321    fn is_cancelled_false_after_reset() {
322        let flag = cancellation_flag();
323        flag.store(true, Ordering::Release);
324        assert!(is_cancelled());
325        flag.store(false, Ordering::Release);
326        assert!(!is_cancelled());
327    }
328
329    #[test]
330    #[serial]
331    fn should_stop_true_when_either_flag_set() {
332        reset_flags_for_test();
333        assert!(!should_stop());
334        cancellation_flag().store(true, Ordering::Release);
335        assert!(should_stop());
336        cancellation_flag().store(false, Ordering::Release);
337        sigterm_flag().store(true, Ordering::Release);
338        assert!(should_stop());
339        sigterm_flag().store(false, Ordering::Release);
340        assert!(!should_stop());
341    }
342
343    #[test]
344    #[serial]
345    fn note_cooperative_signal_sets_force_on_second_hit() {
346        reset_flags_for_test();
347        let cancel = cancellation_flag();
348        let force = force_exit_flag();
349        note_cooperative_signal(cancel, force, "SIGINT");
350        assert!(is_cancelled());
351        assert!(!is_force_exit());
352        note_cooperative_signal(cancel, force, "SIGINT");
353        assert!(is_force_exit());
354        reset_flags_for_test();
355    }
356
357    #[test]
358    #[serial]
359    fn signal_exit_code_prefers_sigterm() {
360        reset_flags_for_test();
361        assert_eq!(signal_exit_code(), None);
362        cancellation_flag().store(true, Ordering::Release);
363        assert_eq!(
364            signal_exit_code(),
365            Some(crate::errors::exit_codes::EX_SIGINT)
366        );
367        sigterm_flag().store(true, Ordering::Release);
368        assert_eq!(
369            signal_exit_code(),
370            Some(crate::errors::exit_codes::EX_SIGTERM)
371        );
372        reset_flags_for_test();
373    }
374
375    #[test]
376    #[serial]
377    fn register_handler_is_idempotent() {
378        let _ = register_handler();
379        assert!(register_handler().is_ok());
380    }
381
382    #[test]
383    #[serial]
384    fn record_signal_hit_escalates_on_second() {
385        reset_flags_for_test();
386        assert_eq!(record_signal_hit(), 0);
387        assert_eq!(record_signal_hit(), 1);
388        reset_flags_for_test();
389    }
390}