Skip to main content

mermaid_cli/app/
lifecycle.rs

1//! Process lifecycle signal handling.
2//!
3//! Crossterm raw mode turns a typed Ctrl+C into a key event, but OS
4//! signals can still arrive from `kill`, terminal close, or a process
5//! manager. This module converts those signals into reducer messages
6//! so shutdown follows the same path as `/quit`.
7
8use tokio::sync::mpsc;
9
10use mermaid_domain::{Msg, RuntimeSignal};
11
12/// Small signal stream consumed by the app main loops.
13pub struct RuntimeLifecycle {
14    rx: mpsc::UnboundedReceiver<RuntimeSignal>,
15}
16
17impl RuntimeLifecycle {
18    #[must_use]
19    pub fn new() -> Self {
20        let (tx, rx) = mpsc::unbounded_channel();
21        spawn_signal_tasks(tx);
22        Self { rx }
23    }
24
25    pub async fn next_msg(&mut self) -> Option<Msg> {
26        self.rx.recv().await.map(Msg::RuntimeSignal)
27    }
28}
29
30impl Default for RuntimeLifecycle {
31    fn default() -> Self {
32        Self::new()
33    }
34}
35
36fn spawn_signal_tasks(tx: mpsc::UnboundedSender<RuntimeSignal>) {
37    let ctrl_c_tx = tx.clone();
38    tokio::spawn(async move {
39        // Loop, not one-shot: a second Ctrl+C during a stalled shutdown must
40        // still be delivered (the old task exited after the first signal, so a
41        // wedged MCP-drain window made Ctrl+C appear dead). `ctrl_c()` re-arms on
42        // each await; stop only if registration fails or the receiver is gone.
43        loop {
44            if tokio::signal::ctrl_c().await.is_err() {
45                break;
46            }
47            if ctrl_c_tx.send(RuntimeSignal::Interrupt).is_err() {
48                break; // app shutting down; nobody left to receive.
49            }
50        }
51    });
52
53    spawn_unix_signal_tasks(tx);
54}
55
56#[cfg(unix)]
57fn spawn_unix_signal_tasks(tx: mpsc::UnboundedSender<RuntimeSignal>) {
58    use tokio::signal::unix::{SignalKind, signal};
59
60    let terminate_tx = tx.clone();
61    tokio::spawn(async move {
62        if let Ok(mut sigterm) = signal(SignalKind::terminate()) {
63            while sigterm.recv().await.is_some() {
64                if terminate_tx.send(RuntimeSignal::Terminate).is_err() {
65                    break;
66                }
67            }
68        }
69    });
70
71    tokio::spawn(async move {
72        if let Ok(mut sighup) = signal(SignalKind::hangup()) {
73            while sighup.recv().await.is_some() {
74                if tx.send(RuntimeSignal::Hangup).is_err() {
75                    break;
76                }
77            }
78        }
79    });
80}
81
82#[cfg(not(unix))]
83fn spawn_unix_signal_tasks(_tx: mpsc::UnboundedSender<RuntimeSignal>) {}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88
89    #[tokio::test]
90    async fn lifecycle_wraps_signal_as_reducer_msg() {
91        let (tx, rx) = mpsc::unbounded_channel();
92        let mut lifecycle = RuntimeLifecycle { rx };
93        tx.send(RuntimeSignal::Terminate).expect("send signal");
94
95        let msg = lifecycle.next_msg().await.expect("signal msg");
96        assert!(matches!(msg, Msg::RuntimeSignal(RuntimeSignal::Terminate)));
97    }
98}