Skip to main content

dynomite/core/
signal.rs

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