Skip to main content

dynomite/core/
signal.rs

1//! UNIX signal table and dispatch.
2//!
3//! A small static table maps each handled signal to a
4//! [`SignalEntry`]. Signal handling itself runs in a tokio task
5//! that consumes a `Signal` stream so the body of every handler
6//! stays on the runtime and never executes in async-signal-unsafe
7//! context.
8//!
9//! # Examples
10//!
11//! ```
12//! use dynomite::core::signal::{default_actions, SignalAction};
13//!
14//! let table = default_actions();
15//! assert!(table.iter().any(|e| matches!(e.action, SignalAction::Shutdown)));
16//! ```
17
18use nix::sys::signal::Signal;
19
20use crate::core::log::{log_level_decrement, log_level_increment};
21use crate::core::types::Status;
22
23/// Action to run when a signal is delivered.
24///
25/// The default mapping is: SIGTTIN/SIGTTOU adjust the global log
26/// verbosity, SIGHUP reopens the log file, SIGINT requests a
27/// graceful shutdown, SIGUSR1 and SIGUSR2 are reserved noop slots,
28/// SIGSEGV records a stack trace, and SIGPIPE is ignored.
29///
30/// # Examples
31///
32/// ```
33/// use dynomite::core::signal::SignalAction;
34/// assert_ne!(SignalAction::Shutdown, SignalAction::Noop);
35/// assert_eq!(SignalAction::Ignore, SignalAction::Ignore);
36/// ```
37#[derive(Debug, Clone, Copy, Eq, PartialEq)]
38pub enum SignalAction {
39    /// Reserved slot used by the in-process action table that
40    /// currently does nothing.
41    Noop,
42    /// Bump the global log verbosity by one.
43    LogLevelUp,
44    /// Drop the global log verbosity by one.
45    LogLevelDown,
46    /// Reopen the active log file (if any).
47    ReopenLog,
48    /// Request a graceful shutdown.
49    Shutdown,
50    /// Print a stack trace; the dispatcher also re-raises SIGSEGV
51    /// afterwards so the kernel can produce the standard core dump.
52    StackTrace,
53    /// Ignore the signal entirely (matches `SIG_IGN`).
54    Ignore,
55}
56
57/// One entry in the signal-action table.
58///
59/// # Examples
60///
61/// ```
62/// use dynomite::core::signal::{default_actions, SignalAction};
63/// let entry = default_actions().iter().find(|e| e.name == "SIGINT").unwrap();
64/// assert_eq!(entry.action, SignalAction::Shutdown);
65/// ```
66#[derive(Debug, Clone, Copy)]
67pub struct SignalEntry {
68    /// The POSIX signal number this entry handles.
69    pub signal: Signal,
70    /// Human-readable name used in log messages.
71    pub name: &'static str,
72    /// Action to run when the signal fires.
73    pub action: SignalAction,
74}
75
76/// Return the default Dynomite signal-to-action table.
77///
78/// # Examples
79///
80/// ```
81/// use dynomite::core::signal::default_actions;
82/// let table = default_actions();
83/// assert!(!table.is_empty());
84/// ```
85pub fn default_actions() -> &'static [SignalEntry] {
86    &SIGNAL_TABLE
87}
88
89const SIGNAL_TABLE: [SignalEntry; 8] = [
90    SignalEntry {
91        signal: Signal::SIGUSR1,
92        name: "SIGUSR1",
93        action: SignalAction::Noop,
94    },
95    SignalEntry {
96        signal: Signal::SIGUSR2,
97        name: "SIGUSR2",
98        action: SignalAction::Noop,
99    },
100    SignalEntry {
101        signal: Signal::SIGTTIN,
102        name: "SIGTTIN",
103        action: SignalAction::LogLevelUp,
104    },
105    SignalEntry {
106        signal: Signal::SIGTTOU,
107        name: "SIGTTOU",
108        action: SignalAction::LogLevelDown,
109    },
110    SignalEntry {
111        signal: Signal::SIGHUP,
112        name: "SIGHUP",
113        action: SignalAction::ReopenLog,
114    },
115    SignalEntry {
116        signal: Signal::SIGINT,
117        name: "SIGINT",
118        action: SignalAction::Shutdown,
119    },
120    SignalEntry {
121        signal: Signal::SIGSEGV,
122        name: "SIGSEGV",
123        action: SignalAction::StackTrace,
124    },
125    SignalEntry {
126        signal: Signal::SIGPIPE,
127        name: "SIGPIPE",
128        action: SignalAction::Ignore,
129    },
130];
131
132/// Look up the [`SignalAction`] for a given POSIX signal in the default
133/// table.
134///
135/// # Examples
136///
137/// ```
138/// use dynomite::core::signal::{action_for, SignalAction};
139/// use nix::sys::signal::Signal;
140///
141/// assert_eq!(action_for(Signal::SIGINT), Some(SignalAction::Shutdown));
142/// assert_eq!(action_for(Signal::SIGCHLD), None);
143/// ```
144pub fn action_for(signal: Signal) -> Option<SignalAction> {
145    SIGNAL_TABLE
146        .iter()
147        .find(|entry| entry.signal == signal)
148        .map(|entry| entry.action)
149}
150
151/// Dispatch the action associated with `signal`.
152///
153/// Returns `true` when shutdown was requested. Unknown signals are
154/// reported as `false` and produce no side effect.
155///
156/// # Examples
157///
158/// ```
159/// use dynomite::core::signal::dispatch;
160/// use nix::sys::signal::Signal;
161/// assert!(!dispatch(Signal::SIGUSR1).unwrap());
162/// assert!(dispatch(Signal::SIGINT).unwrap());
163/// ```
164pub fn dispatch(signal: Signal) -> Result<bool, crate::core::types::DynError> {
165    let Some(action) = action_for(signal) else {
166        return Ok(false);
167    };
168    match action {
169        SignalAction::Noop | SignalAction::Ignore => Ok(false),
170        SignalAction::LogLevelUp => {
171            log_level_increment();
172            Ok(false)
173        }
174        SignalAction::LogLevelDown => {
175            log_level_decrement();
176            Ok(false)
177        }
178        SignalAction::ReopenLog => {
179            crate::core::log::reopen_on_sighup()?;
180            Ok(false)
181        }
182        SignalAction::Shutdown => Ok(true),
183        SignalAction::StackTrace => {
184            tracing::error!(signal = %signal, "fatal signal received, terminating");
185            Ok(true)
186        }
187    }
188}
189
190/// Convenience wrapper that returns [`Status`] for callers that prefer
191/// the void-returning shape used elsewhere in the engine.
192///
193/// # Examples
194///
195/// ```
196/// use dynomite::core::signal::handle;
197/// use nix::sys::signal::Signal;
198/// handle(Signal::SIGUSR1).unwrap();
199/// ```
200pub fn handle(signal: Signal) -> Status {
201    dispatch(signal).map(|_| ())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    #[test]
209    fn table_covers_every_c_entry() {
210        for sig in [
211            Signal::SIGUSR1,
212            Signal::SIGUSR2,
213            Signal::SIGTTIN,
214            Signal::SIGTTOU,
215            Signal::SIGHUP,
216            Signal::SIGINT,
217            Signal::SIGSEGV,
218            Signal::SIGPIPE,
219        ] {
220            assert!(action_for(sig).is_some(), "missing entry for {sig:?}");
221        }
222    }
223
224    #[test]
225    fn unknown_signals_return_none() {
226        assert!(action_for(Signal::SIGCHLD).is_none());
227    }
228
229    #[test]
230    fn shutdown_is_signalled_for_sigint() {
231        // The runtime is global; for the dispatch test we only inspect
232        // the boolean shutdown flag returned by the noop arms.
233        assert!(!dispatch(Signal::SIGUSR1).unwrap());
234        assert!(!dispatch(Signal::SIGPIPE).unwrap());
235        assert!(dispatch(Signal::SIGINT).unwrap());
236    }
237}