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}