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
13use tokio_util::sync::CancellationToken;
14
15#[cfg(windows)]
16use tokio::signal::windows::{CtrlBreak, CtrlC};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19enum SignalAction {
20    Graceful,
21    Force,
22}
23
24static SUPPRESSED_INTERRUPTS: AtomicUsize = AtomicUsize::new(0);
25
26/// Keeps a foreground child process's Ctrl-C from also terminating Hel.
27///
28/// The child remains in the terminal's foreground process group and receives
29/// the signal normally; only Mjolnir's process-wide graceful shutdown is
30/// suspended until the guard is dropped.
31pub struct SuppressInterruptGuard;
32
33pub fn suppress_interrupts() -> SuppressInterruptGuard {
34    SUPPRESSED_INTERRUPTS.fetch_add(1, Ordering::AcqRel);
35    SuppressInterruptGuard
36}
37
38impl Drop for SuppressInterruptGuard {
39    fn drop(&mut self) {
40        SUPPRESSED_INTERRUPTS.fetch_sub(1, Ordering::AcqRel);
41    }
42}
43
44/// Pure, testable signal transition. The side effect for `Force` belongs to
45/// the listener task, never to this state machine.
46fn next_signal_action(signals_seen: &AtomicU8) -> SignalAction {
47    match signals_seen.fetch_add(1, Ordering::AcqRel) {
48        0 => SignalAction::Graceful,
49        _ => SignalAction::Force,
50    }
51}
52
53#[derive(Clone, Debug)]
54pub struct Coordinator {
55    token: CancellationToken,
56    signals_seen: Arc<AtomicU8>,
57}
58
59impl Coordinator {
60    pub fn install() -> Self {
61        let coordinator = Self {
62            token: CancellationToken::new(),
63            signals_seen: Arc::new(AtomicU8::new(0)),
64        };
65        #[cfg(unix)]
66        install_unix_signals(&coordinator);
67        #[cfg(windows)]
68        {
69            // Register both handlers before returning from `install`. Signals
70            // arriving before the spawned task is first polled are then held
71            // by the initialized streams instead of bypassing coordination.
72            let (ctrl_c, ctrl_break) = install_windows_signals();
73            let listener = coordinator.clone();
74            tokio::spawn(async move { listener.listen(ctrl_c, ctrl_break).await });
75        }
76        coordinator
77    }
78
79    pub fn token(&self) -> CancellationToken {
80        self.token.clone()
81    }
82
83    fn received_signal(&self, signal: i32) {
84        #[cfg(unix)]
85        if signal == libc::SIGINT && SUPPRESSED_INTERRUPTS.load(Ordering::Acquire) > 0 {
86            return;
87        }
88        #[cfg(windows)]
89        if signal == 0 && SUPPRESSED_INTERRUPTS.load(Ordering::Acquire) > 0 {
90            return;
91        }
92        // SIGHUP means the controlling terminal is gone. A graceful cancel
93        // cannot work then: crossterm 0.29 busy-loops inside event::read on
94        // the dead tty's EOF, so the UI thread never observes the token and
95        // the process survives as a headless CPU spinner. Exit immediately;
96        // detached workers are unaffected and child proxies exit on EOF.
97        #[cfg(unix)]
98        if signal == libc::SIGHUP {
99            std::process::exit(exit_code(signal));
100        }
101        match next_signal_action(&self.signals_seen) {
102            SignalAction::Graceful => self.token.cancel(),
103            SignalAction::Force => std::process::exit(exit_code(signal)),
104        }
105    }
106
107    #[cfg(windows)]
108    async fn listen(self, mut ctrl_c: CtrlC, mut ctrl_break: CtrlBreak) {
109        loop {
110            tokio::select! {
111                _ = ctrl_c.recv() => self.received_signal(0),
112                _ = ctrl_break.recv() => self.received_signal(1),
113            }
114        }
115    }
116}
117
118#[cfg(unix)]
119fn install_unix_signals(coordinator: &Coordinator) {
120    use std::io::Read;
121    use std::os::fd::AsRawFd;
122
123    // Self-pipe that wakes the listener thread. `std::io::pipe` creates both
124    // ends close-on-exec, so spawned programs do not inherit them. The write
125    // end is non-blocking so a signal handler never stalls on a full pipe; a
126    // full pipe already holds a wake-up the listener has not read yet.
127    let (mut wake_reader, wake_writer) =
128        std::io::pipe().expect("create termination signal wake pipe");
129    // SAFETY: F_GETFL and F_SETFL on a descriptor this function owns.
130    let nonblocking = unsafe {
131        let fd = wake_writer.as_raw_fd();
132        let flags = libc::fcntl(fd, libc::F_GETFL);
133        flags != -1 && libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) != -1
134    };
135    assert!(
136        nonblocking,
137        "make the termination signal wake pipe non-blocking: {}",
138        std::io::Error::last_os_error()
139    );
140    // The registered handlers own the write end for the rest of the process,
141    // so the listener never sees end-of-file.
142    let wake_writer = Arc::new(wake_writer);
143
144    let requested = Arc::new(AtomicBool::new(false));
145    for signal in [libc::SIGINT, libc::SIGTERM] {
146        // Registration order matters: the first handler exits only when a
147        // previous signal armed the flag; the second one arms it. A signal
148        // that arrives after the flag is armed exits before any pipe write.
149        signal_hook::flag::register_conditional_shutdown(
150            signal,
151            exit_code(signal),
152            requested.clone(),
153        )
154        .expect("install forced termination signal handler");
155        let requested = requested.clone();
156        let wake_writer = wake_writer.clone();
157        // SAFETY: the handler only touches lock-free atomics and calls
158        // `write(2)`, all async-signal-safe, and signal-hook preserves errno.
159        // The suppression decision is made here, when the signal is
160        // delivered. The flag is stored before the write, so the listener
161        // sees it once the byte arrives. The write result is ignored: EAGAIN
162        // means the pipe is full, so the listener has an unread wake-up.
163        unsafe {
164            signal_hook::low_level::register(signal, move || {
165                if signal != libc::SIGINT || SUPPRESSED_INTERRUPTS.load(Ordering::Acquire) == 0 {
166                    requested.store(true, Ordering::SeqCst);
167                    libc::write(wake_writer.as_raw_fd(), b"x".as_ptr().cast(), 1);
168                }
169            })
170        }
171        .expect("install graceful termination signal handler");
172    }
173
174    signal_hook::flag::register_conditional_shutdown(
175        libc::SIGHUP,
176        exit_code(libc::SIGHUP),
177        Arc::new(AtomicBool::new(true)),
178    )
179    .expect("install hangup signal handler");
180
181    let listener = coordinator.clone();
182    std::thread::Builder::new()
183        .name("hel-termination".to_string())
184        .spawn(move || {
185            // The flag decides, not the byte. A child forked from this process
186            // runs these handlers until it execs, and its write to the
187            // inherited pipe wakes this thread without setting this process's
188            // flag. Checking the flag before the first read also covers a
189            // signal that arrived before this thread started.
190            let mut wake = [0_u8; 64];
191            while !requested.load(Ordering::Acquire) {
192                match wake_reader.read(&mut wake) {
193                    Ok(0) => {
194                        tracing::error!(
195                            "termination wake pipe closed; a signal can no longer start graceful shutdown"
196                        );
197                        return;
198                    }
199                    Ok(_) => {}
200                    Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
201                    Err(error) => {
202                        tracing::error!(
203                            %error,
204                            "termination wake pipe failed; a signal can no longer start graceful shutdown"
205                        );
206                        return;
207                    }
208                }
209            }
210            listener.received_signal(0);
211        })
212        .expect("spawn termination signal listener");
213}
214
215#[cfg(windows)]
216fn install_windows_signals() -> (CtrlC, CtrlBreak) {
217    use tokio::signal::windows::{ctrl_break, ctrl_c};
218
219    (
220        ctrl_c().expect("install Ctrl-C listener"),
221        ctrl_break().expect("install Ctrl-Break listener"),
222    )
223}
224
225#[cfg(unix)]
226const fn exit_code(signal: i32) -> i32 {
227    128 + signal
228}
229
230#[cfg(not(unix))]
231const fn exit_code(_signal: i32) -> i32 {
232    1
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    static INTERRUPT_SUPPRESSION_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
240
241    #[test]
242    fn first_then_repeated_signal_transitions_to_force() {
243        let signals_seen = AtomicU8::new(0);
244        assert_eq!(next_signal_action(&signals_seen), SignalAction::Graceful);
245        assert_eq!(next_signal_action(&signals_seen), SignalAction::Force);
246    }
247
248    #[cfg(unix)]
249    #[test]
250    fn suppressed_interrupt_does_not_advance_shutdown() {
251        let _lock = INTERRUPT_SUPPRESSION_TEST_LOCK.lock().unwrap();
252        let coordinator = Coordinator {
253            token: CancellationToken::new(),
254            signals_seen: Arc::new(AtomicU8::new(0)),
255        };
256
257        let guard = suppress_interrupts();
258        coordinator.received_signal(libc::SIGINT);
259        assert!(!coordinator.token().is_cancelled());
260        assert_eq!(coordinator.signals_seen.load(Ordering::Acquire), 0);
261
262        drop(guard);
263        coordinator.received_signal(libc::SIGINT);
264        assert!(coordinator.token().is_cancelled());
265        assert_eq!(coordinator.signals_seen.load(Ordering::Acquire), 1);
266    }
267
268    #[tokio::test]
269    async fn coordinator_cancellation_fans_out_to_late_subscribers() {
270        let lock = INTERRUPT_SUPPRESSION_TEST_LOCK.lock().unwrap();
271        let coordinator = Coordinator {
272            token: CancellationToken::new(),
273            signals_seen: Arc::new(AtomicU8::new(0)),
274        };
275        let early = coordinator.token().child_token();
276        coordinator.received_signal(0);
277        drop(lock);
278        let late = coordinator.token().child_token();
279        early.cancelled().await;
280        late.cancelled().await;
281    }
282}