Skip to main content

mj_controller/
termination.rs

1//! Process-wide graceful termination coordination.
2//!
3//! The first termination signal cancels all subscribers so callers can unwind
4//! through their normal cleanup paths. A second signal exits immediately. On
5//! Unix the immediate exit status follows the conventional `128 + signal`
6//! convention (SIGINT 130, SIGHUP 129, SIGTERM 143); Windows uses 1.
7
8use std::sync::Arc;
9#[cfg(unix)]
10use std::sync::atomic::AtomicBool;
11use std::sync::atomic::{AtomicU8, AtomicUsize, Ordering};
12#[cfg(unix)]
13use std::time::Duration;
14
15use tokio_util::sync::CancellationToken;
16
17#[cfg(windows)]
18use tokio::signal::windows::{CtrlBreak, CtrlC};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21enum SignalAction {
22    Graceful,
23    Force,
24}
25
26static SUPPRESSED_INTERRUPTS: AtomicUsize = AtomicUsize::new(0);
27
28#[cfg(unix)]
29const SIGNAL_POLL_INTERVAL: Duration = Duration::from_millis(10);
30
31/// Keeps a foreground child process's Ctrl-C from also terminating Hel.
32///
33/// The child remains in the terminal's foreground process group and receives
34/// the signal normally; only Mjolnir's process-wide graceful shutdown is
35/// suspended until the guard is dropped.
36pub struct SuppressInterruptGuard;
37
38pub fn suppress_interrupts() -> SuppressInterruptGuard {
39    SUPPRESSED_INTERRUPTS.fetch_add(1, Ordering::AcqRel);
40    SuppressInterruptGuard
41}
42
43impl Drop for SuppressInterruptGuard {
44    fn drop(&mut self) {
45        SUPPRESSED_INTERRUPTS.fetch_sub(1, Ordering::AcqRel);
46    }
47}
48
49/// Pure, testable signal transition. The side effect for `Force` belongs to
50/// the listener task, never to this state machine.
51fn next_signal_action(signals_seen: &AtomicU8) -> SignalAction {
52    match signals_seen.fetch_add(1, Ordering::AcqRel) {
53        0 => SignalAction::Graceful,
54        _ => SignalAction::Force,
55    }
56}
57
58#[derive(Clone, Debug)]
59pub struct Coordinator {
60    token: CancellationToken,
61    signals_seen: Arc<AtomicU8>,
62}
63
64impl Coordinator {
65    pub fn install() -> Self {
66        let coordinator = Self {
67            token: CancellationToken::new(),
68            signals_seen: Arc::new(AtomicU8::new(0)),
69        };
70        #[cfg(unix)]
71        install_unix_signals(&coordinator);
72        #[cfg(windows)]
73        {
74            // Register both handlers before returning from `install`. Signals
75            // arriving before the spawned task is first polled are then held
76            // by the initialized streams instead of bypassing coordination.
77            let (ctrl_c, ctrl_break) = install_windows_signals();
78            let listener = coordinator.clone();
79            tokio::spawn(async move { listener.listen(ctrl_c, ctrl_break).await });
80        }
81        coordinator
82    }
83
84    pub fn token(&self) -> CancellationToken {
85        self.token.clone()
86    }
87
88    fn received_signal(&self, signal: i32) {
89        #[cfg(unix)]
90        if signal == libc::SIGINT && SUPPRESSED_INTERRUPTS.load(Ordering::Acquire) > 0 {
91            return;
92        }
93        #[cfg(windows)]
94        if signal == 0 && SUPPRESSED_INTERRUPTS.load(Ordering::Acquire) > 0 {
95            return;
96        }
97        // SIGHUP means the controlling terminal is gone. A graceful cancel
98        // cannot work then: crossterm 0.29 busy-loops inside event::read on
99        // the dead tty's EOF, so the UI thread never observes the token and
100        // the process survives as a headless CPU spinner. Exit immediately;
101        // detached workers are unaffected and child proxies exit on EOF.
102        #[cfg(unix)]
103        if signal == libc::SIGHUP {
104            std::process::exit(exit_code(signal));
105        }
106        match next_signal_action(&self.signals_seen) {
107            SignalAction::Graceful => self.token.cancel(),
108            SignalAction::Force => std::process::exit(exit_code(signal)),
109        }
110    }
111
112    #[cfg(windows)]
113    async fn listen(self, mut ctrl_c: CtrlC, mut ctrl_break: CtrlBreak) {
114        loop {
115            tokio::select! {
116                _ = ctrl_c.recv() => self.received_signal(0),
117                _ = ctrl_break.recv() => self.received_signal(1),
118            }
119        }
120    }
121}
122
123#[cfg(unix)]
124fn install_unix_signals(coordinator: &Coordinator) {
125    let requested = Arc::new(AtomicBool::new(false));
126    for signal in [libc::SIGINT, libc::SIGTERM] {
127        // Registration order matters: the first handler exits only when a
128        // previous signal armed the flag; the second one arms it.
129        signal_hook::flag::register_conditional_shutdown(
130            signal,
131            exit_code(signal),
132            requested.clone(),
133        )
134        .expect("install forced termination signal handler");
135        let requested = requested.clone();
136        // SAFETY: the handler only reads and writes lock-free atomics, which
137        // are async-signal-safe. Polling outside the handler avoids relying on
138        // a self-pipe write to wake the graceful-shutdown listener.
139        unsafe {
140            signal_hook::low_level::register(signal, move || {
141                if signal != libc::SIGINT || SUPPRESSED_INTERRUPTS.load(Ordering::Acquire) == 0 {
142                    requested.store(true, Ordering::SeqCst);
143                }
144            })
145        }
146        .expect("install graceful termination signal handler");
147    }
148
149    signal_hook::flag::register_conditional_shutdown(
150        libc::SIGHUP,
151        exit_code(libc::SIGHUP),
152        Arc::new(AtomicBool::new(true)),
153    )
154    .expect("install hangup signal handler");
155
156    let listener = coordinator.clone();
157    std::thread::Builder::new()
158        .name("hel-termination".to_string())
159        .spawn(move || {
160            while !requested.load(Ordering::Acquire) {
161                std::thread::sleep(SIGNAL_POLL_INTERVAL);
162            }
163            listener.received_signal(0);
164        })
165        .expect("spawn termination signal listener");
166}
167
168#[cfg(windows)]
169fn install_windows_signals() -> (CtrlC, CtrlBreak) {
170    use tokio::signal::windows::{ctrl_break, ctrl_c};
171
172    (
173        ctrl_c().expect("install Ctrl-C listener"),
174        ctrl_break().expect("install Ctrl-Break listener"),
175    )
176}
177
178#[cfg(unix)]
179const fn exit_code(signal: i32) -> i32 {
180    128 + signal
181}
182
183#[cfg(not(unix))]
184const fn exit_code(_signal: i32) -> i32 {
185    1
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    static INTERRUPT_SUPPRESSION_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
193
194    #[test]
195    fn first_then_repeated_signal_transitions_to_force() {
196        let signals_seen = AtomicU8::new(0);
197        assert_eq!(next_signal_action(&signals_seen), SignalAction::Graceful);
198        assert_eq!(next_signal_action(&signals_seen), SignalAction::Force);
199    }
200
201    #[test]
202    fn interrupt_suppression_is_scoped() {
203        let _lock = INTERRUPT_SUPPRESSION_TEST_LOCK.lock().unwrap();
204        assert_eq!(SUPPRESSED_INTERRUPTS.load(Ordering::Acquire), 0);
205        {
206            let _guard = suppress_interrupts();
207            assert_eq!(SUPPRESSED_INTERRUPTS.load(Ordering::Acquire), 1);
208        }
209        assert_eq!(SUPPRESSED_INTERRUPTS.load(Ordering::Acquire), 0);
210    }
211
212    #[cfg(unix)]
213    #[test]
214    fn suppressed_interrupt_does_not_advance_shutdown() {
215        let _lock = INTERRUPT_SUPPRESSION_TEST_LOCK.lock().unwrap();
216        let coordinator = Coordinator {
217            token: CancellationToken::new(),
218            signals_seen: Arc::new(AtomicU8::new(0)),
219        };
220
221        let guard = suppress_interrupts();
222        coordinator.received_signal(libc::SIGINT);
223        assert!(!coordinator.token().is_cancelled());
224        assert_eq!(coordinator.signals_seen.load(Ordering::Acquire), 0);
225
226        drop(guard);
227        coordinator.received_signal(libc::SIGINT);
228        assert!(coordinator.token().is_cancelled());
229        assert_eq!(coordinator.signals_seen.load(Ordering::Acquire), 1);
230    }
231
232    #[tokio::test]
233    async fn coordinator_cancellation_fans_out_to_late_subscribers() {
234        let lock = INTERRUPT_SUPPRESSION_TEST_LOCK.lock().unwrap();
235        let coordinator = Coordinator {
236            token: CancellationToken::new(),
237            signals_seen: Arc::new(AtomicU8::new(0)),
238        };
239        let early = coordinator.token().child_token();
240        coordinator.received_signal(0);
241        drop(lock);
242        let late = coordinator.token().child_token();
243        early.cancelled().await;
244        late.cancelled().await;
245    }
246
247    #[cfg(windows)]
248    #[tokio::test]
249    async fn windows_signal_streams_register_synchronously() {
250        let (_ctrl_c, _ctrl_break) = install_windows_signals();
251    }
252}