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 crate::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    pub fn new() -> Self {
19        let (tx, rx) = mpsc::unbounded_channel();
20        spawn_signal_tasks(tx);
21        Self { rx }
22    }
23
24    pub async fn next_msg(&mut self) -> Option<Msg> {
25        self.rx.recv().await.map(Msg::RuntimeSignal)
26    }
27}
28
29impl Default for RuntimeLifecycle {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35fn spawn_signal_tasks(tx: mpsc::UnboundedSender<RuntimeSignal>) {
36    let ctrl_c_tx = tx.clone();
37    tokio::spawn(async move {
38        // Loop, not one-shot: a second Ctrl+C during a stalled shutdown must
39        // still be delivered (the old task exited after the first signal, so a
40        // wedged MCP-drain window made Ctrl+C appear dead). `ctrl_c()` re-arms on
41        // each await; stop only if registration fails or the receiver is gone.
42        loop {
43            if tokio::signal::ctrl_c().await.is_err() {
44                break;
45            }
46            if ctrl_c_tx.send(RuntimeSignal::Interrupt).is_err() {
47                break; // app shutting down; nobody left to receive.
48            }
49        }
50    });
51
52    spawn_unix_signal_tasks(tx);
53}
54
55#[cfg(unix)]
56fn spawn_unix_signal_tasks(tx: mpsc::UnboundedSender<RuntimeSignal>) {
57    use tokio::signal::unix::{SignalKind, signal};
58
59    let terminate_tx = tx.clone();
60    tokio::spawn(async move {
61        if let Ok(mut sigterm) = signal(SignalKind::terminate()) {
62            while sigterm.recv().await.is_some() {
63                if terminate_tx.send(RuntimeSignal::Terminate).is_err() {
64                    break;
65                }
66            }
67        }
68    });
69
70    tokio::spawn(async move {
71        if let Ok(mut sighup) = signal(SignalKind::hangup()) {
72            while sighup.recv().await.is_some() {
73                if tx.send(RuntimeSignal::Hangup).is_err() {
74                    break;
75                }
76            }
77        }
78    });
79}
80
81#[cfg(not(unix))]
82fn spawn_unix_signal_tasks(_tx: mpsc::UnboundedSender<RuntimeSignal>) {}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87
88    #[tokio::test]
89    async fn lifecycle_wraps_signal_as_reducer_msg() {
90        let (tx, rx) = mpsc::unbounded_channel();
91        let mut lifecycle = RuntimeLifecycle { rx };
92        tx.send(RuntimeSignal::Terminate).expect("send signal");
93
94        let msg = lifecycle.next_msg().await.expect("signal msg");
95        assert!(matches!(msg, Msg::RuntimeSignal(RuntimeSignal::Terminate)));
96    }
97}