Skip to main content

mkit_cli/
signal.rs

1//! Signal handling — SIGINT / SIGTERM set a graceful-shutdown flag
2//! that long-running operations (`push` / `pull` / `clone` / `log`)
3//! poll at natural checkpoints, so a `Ctrl-C` aborts cleanly with
4//! `exit::TEMPFAIL` (75) rather than leaving a half-finished transfer.
5//!
6//! ## SIGPIPE is intentionally not registered here
7//!
8//! Rust's runtime sets `SIGPIPE` to `SIG_IGN` at process start since
9//! 1.65, which means `write(2)` on a closed pipe returns `EPIPE`
10//! instead of terminating the process. The CLI uses
11//! `let _ = writeln!(stdout, …)` everywhere, so the `EPIPE` propagates
12//! as a silently-dropped `io::Error` and the program exits at its
13//! next natural completion point — exactly the pipeline-friendly
14//! behaviour `docs/CLI.md` advertises.
15//!
16//! Registering a signal-hook handler over the runtime's `SIG_IGN`
17//! would replace a clean kernel-level ignore with a userspace handler
18//! that does an atomic store and returns — observationally
19//! equivalent but strictly worse (extra wakeups, a window where a
20//! different thread might briefly observe a flipped flag we never
21//! consume). The integration test in `tests/sigpipe.rs` is the
22//! regression guard: it pipes `mkit cat <large-blob>` through
23//! `head -1` and asserts the left-hand exit code is `0`. If anyone
24//! ever opts mkit out of Rust's default with `#[unix_sigpipe]`, that
25//! test goes red.
26//!
27//! ## Implementation
28//!
29//! `signal-hook`'s `flag` module installs the handlers via
30//! `sigaction(2)` and exposes a fully safe API (atomic-bool stores
31//! are async-signal-safe; the `unsafe` lives inside the crate). The
32//! CLI stays under its crate-level `#![deny(unsafe_code)]`.
33
34use std::sync::atomic::{AtomicBool, Ordering};
35use std::sync::{Arc, OnceLock};
36
37/// Shared shutdown flag. Lazily initialised so tests that exercise the
38/// flag without going through [`install`] still observe a coherent
39/// value. The `Arc` is required because `signal_hook::flag::register`
40/// takes an owned `Arc<AtomicBool>` — it does not accept a `&'static`.
41static SHUTDOWN: OnceLock<Arc<AtomicBool>> = OnceLock::new();
42
43fn shutdown_flag() -> &'static Arc<AtomicBool> {
44    SHUTDOWN.get_or_init(|| Arc::new(AtomicBool::new(false)))
45}
46
47/// Install SIGINT/SIGTERM handlers that flip the shared shutdown flag.
48/// Idempotent on the `signal-hook` side: re-registering the same
49/// signal layers another handler on top, but the cost is a few bytes
50/// and the observable behaviour is unchanged, so callers can invoke
51/// this more than once without harm.
52///
53/// On non-Unix targets this is a no-op (Windows signal semantics
54/// differ; the CLI does not currently ship on Windows).
55pub fn install() {
56    #[cfg(unix)]
57    {
58        use signal_hook::consts::{SIGINT, SIGTERM};
59
60        let flag = Arc::clone(shutdown_flag());
61        // Errors here are effectively unreachable (they only fail if
62        // the OS refuses to install a handler, e.g. for SIGKILL).
63        // Silently fall back to the default disposition in that case
64        // — the user sees the same behaviour they would have seen
65        // before this change.
66        let _ = signal_hook::flag::register(SIGINT, Arc::clone(&flag));
67        let _ = signal_hook::flag::register(SIGTERM, flag);
68    }
69}
70
71/// Returns `true` once a shutdown was requested via signal. Long-
72/// running poll loops should call this at natural checkpoints and
73/// return `exit::TEMPFAIL` when it flips.
74#[must_use]
75pub fn is_shutdown() -> bool {
76    SHUTDOWN.get().is_some_and(|f| f.load(Ordering::Relaxed))
77}
78
79/// Alias kept for historical callers. Prefer [`is_shutdown`].
80#[must_use]
81pub fn interrupted() -> bool {
82    is_shutdown()
83}
84
85/// Test hook — flips the shutdown flag so unit tests can verify that
86/// long-running callers do honour it once it flips.
87#[doc(hidden)]
88pub fn set_interrupted_for_tests(v: bool) {
89    shutdown_flag().store(v, Ordering::Relaxed);
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95
96    #[test]
97    fn flag_round_trips() {
98        set_interrupted_for_tests(true);
99        assert!(interrupted());
100        set_interrupted_for_tests(false);
101        assert!(!interrupted());
102    }
103
104    /// `install()` must not pre-flip the flag — long-running callers
105    /// poll `is_shutdown()` and would otherwise abort immediately.
106    #[test]
107    fn install_then_is_shutdown_returns_false() {
108        set_interrupted_for_tests(false);
109        install();
110        assert!(!is_shutdown());
111        assert!(!interrupted(), "alias must agree with is_shutdown");
112    }
113
114    /// Double-install must not panic. Tests share a process, so any
115    /// other test that calls `install()` first must leave this one
116    /// in a working state.
117    #[test]
118    fn install_is_idempotent() {
119        install();
120        install();
121        set_interrupted_for_tests(false);
122    }
123}