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}