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/// Shared SIGTERM flag for tests and advanced integration.
222#[must_use]
223pub fn sigterm_flag() -> &'static AtomicBool {
224    &FLAG_SIGTERM
225}
226
227/// Shared force-exit flag (second cooperative signal).
228#[must_use]
229pub fn force_exit_flag() -> &'static AtomicBool {
230    &FORCE_EXIT
231}
232
233/// Preferred process exit code after cooperative signal handling.
234///
235/// Order: SIGTERM (143) > SIGINT (130) > `None` (caller decides).
236#[must_use]
237pub fn signal_exit_code() -> Option<i32> {
238    if is_terminated() {
239        Some(crate::errors::exit_codes::EX_SIGTERM)
240    } else if is_cancelled() {
241        Some(crate::errors::exit_codes::EX_SIGINT)
242    } else {
243        None
244    }
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250    use serial_test::serial;
251
252    fn reset_flags_for_test() {
253        cancellation_flag().store(false, Ordering::Release);
254        sigterm_flag().store(false, Ordering::Release);
255        force_exit_flag().store(false, Ordering::Release);
256        SIGNAL_HITS.store(0, Ordering::Relaxed);
257    }
258
259    #[test]
260    #[serial]
261    fn is_cancelled_false_before_signal() {
262        reset_flags_for_test();
263        assert!(!is_cancelled());
264    }
265
266    #[test]
267    #[serial]
268    fn cancellation_flag_is_stable_static() {
269        let flag_a = cancellation_flag();
270        let flag_b = cancellation_flag();
271        assert!(std::ptr::eq(flag_a, flag_b));
272    }
273
274    #[test]
275    #[serial]
276    fn flag_can_be_set_and_read() {
277        let flag = cancellation_flag();
278        let previous_value = flag.load(Ordering::Acquire);
279        flag.store(previous_value, Ordering::Release);
280        assert_eq!(flag.load(Ordering::Acquire), previous_value);
281    }
282
283    #[test]
284    #[serial]
285    fn is_terminated_false_by_default() {
286        reset_flags_for_test();
287        assert!(!is_terminated());
288    }
289
290    #[test]
291    #[serial]
292    fn sigterm_flag_is_stable_static() {
293        let a = sigterm_flag();
294        let b = sigterm_flag();
295        assert!(std::ptr::eq(a, b));
296    }
297
298    #[test]
299    #[serial]
300    fn is_terminated_true_after_set() {
301        let flag = sigterm_flag();
302        flag.store(true, Ordering::Release);
303        assert!(is_terminated());
304        flag.store(false, Ordering::Release);
305    }
306
307    #[test]
308    #[serial]
309    fn is_cancelled_false_after_reset() {
310        let flag = cancellation_flag();
311        flag.store(true, Ordering::Release);
312        assert!(is_cancelled());
313        flag.store(false, Ordering::Release);
314        assert!(!is_cancelled());
315    }
316
317    #[test]
318    #[serial]
319    fn should_stop_true_when_either_flag_set() {
320        reset_flags_for_test();
321        assert!(!should_stop());
322        cancellation_flag().store(true, Ordering::Release);
323        assert!(should_stop());
324        cancellation_flag().store(false, Ordering::Release);
325        sigterm_flag().store(true, Ordering::Release);
326        assert!(should_stop());
327        sigterm_flag().store(false, Ordering::Release);
328        assert!(!should_stop());
329    }
330
331    #[test]
332    #[serial]
333    fn note_cooperative_signal_sets_force_on_second_hit() {
334        reset_flags_for_test();
335        let cancel = cancellation_flag();
336        let force = force_exit_flag();
337        note_cooperative_signal(cancel, force, "SIGINT");
338        assert!(is_cancelled());
339        assert!(!is_force_exit());
340        note_cooperative_signal(cancel, force, "SIGINT");
341        assert!(is_force_exit());
342        reset_flags_for_test();
343    }
344
345    #[test]
346    #[serial]
347    fn signal_exit_code_prefers_sigterm() {
348        reset_flags_for_test();
349        assert_eq!(signal_exit_code(), None);
350        cancellation_flag().store(true, Ordering::Release);
351        assert_eq!(
352            signal_exit_code(),
353            Some(crate::errors::exit_codes::EX_SIGINT)
354        );
355        sigterm_flag().store(true, Ordering::Release);
356        assert_eq!(
357            signal_exit_code(),
358            Some(crate::errors::exit_codes::EX_SIGTERM)
359        );
360        reset_flags_for_test();
361    }
362
363    #[test]
364    #[serial]
365    fn register_handler_is_idempotent() {
366        let _ = register_handler();
367        assert!(register_handler().is_ok());
368    }
369
370    #[test]
371    #[serial]
372    fn record_signal_hit_escalates_on_second() {
373        reset_flags_for_test();
374        assert_eq!(record_signal_hit(), 0);
375        assert_eq!(record_signal_hit(), 1);
376        reset_flags_for_test();
377    }
378}