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