sysprims_core/guard_signals.rs
1//! Signal handling for guard loops.
2//!
3//! Wraps `rsfulmen::signals::SignalManager` with a stop-flag pattern
4//! suitable for tick-based guard loops: register SIGINT/SIGTERM handlers
5//! that set a shared flag, spawn the listener thread, and poll `should_stop()`
6//! between ticks.
7
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10use std::thread::{self, JoinHandle};
11
12use rsfulmen::signals::testing::SignalInjector;
13pub use rsfulmen::signals::{DoubleTapConfig, SignalManager, SignalManagerError};
14
15use crate::SysprimsResult;
16
17/// Write-only handle to a guard stop flag.
18///
19/// Can only set the flag to `true` (request stop). Cannot read or reset it.
20/// This prevents external callers from observing or clearing internal state.
21#[derive(Clone)]
22pub struct StopFlagHandle(Arc<AtomicBool>);
23
24impl StopFlagHandle {
25 /// Set the stop flag to `true`.
26 pub fn set(&self) {
27 self.0.store(true, Ordering::SeqCst);
28 }
29}
30
31/// Signal controller for guard-style tick loops.
32///
33/// Sets up SIGINT/SIGTERM handling with double-tap support and exposes
34/// a `should_stop()` flag that the loop checks between ticks.
35///
36/// Dropping `GuardSignals` stops the listener thread and joins it.
37///
38/// # Example
39///
40/// ```no_run
41/// use std::time::Duration;
42/// use sysprims_core::guard_signals::GuardSignals;
43/// use sysprims_core::time::Tick;
44///
45/// let signals = GuardSignals::start().unwrap();
46/// let mut tick = Tick::new(Duration::from_secs(5)).unwrap();
47/// while !signals.should_stop() {
48/// // do guard work …
49/// tick.sleep_until_next();
50/// }
51/// ```
52pub struct GuardSignals {
53 stop_flag: Arc<AtomicBool>,
54 manager: SignalManager,
55 /// Used internally by Drop to inject a synthetic SIGTERM for clean shutdown.
56 injector: SignalInjector,
57 listener_thread: Option<JoinHandle<()>>,
58}
59
60impl GuardSignals {
61 /// Set up signal handlers and start the listener thread.
62 ///
63 /// Registers SIGINT and SIGTERM handlers that set the stop flag,
64 /// enables double-tap (catalog defaults), and spawns a background
65 /// thread running `SignalManager::listen()`.
66 ///
67 /// If the listener exits early (error or unexpected return), the
68 /// stop flag is set so the guard loop does not run without signal
69 /// responsiveness.
70 pub fn start() -> SysprimsResult<Self> {
71 let manager = SignalManager::new();
72 let stop_flag = Arc::new(AtomicBool::new(false));
73 let injector = SignalInjector::new(&manager);
74
75 // Register SIGTERM handler
76 let flag = Arc::clone(&stop_flag);
77 let mgr = manager.clone();
78 let _term_reg = manager
79 .handle(crate::signals::SIGTERM, move || {
80 flag.store(true, Ordering::SeqCst);
81 mgr.stop();
82 Ok(())
83 })
84 .map_err(|e| crate::SysprimsError::internal(format!("SIGTERM handler: {e}")))?;
85
86 // Register SIGINT handler
87 let flag = Arc::clone(&stop_flag);
88 let mgr = manager.clone();
89 let _int_reg = manager
90 .handle(crate::signals::SIGINT, move || {
91 flag.store(true, Ordering::SeqCst);
92 mgr.stop();
93 Ok(())
94 })
95 .map_err(|e| crate::SysprimsError::internal(format!("SIGINT handler: {e}")))?;
96
97 // Enable double-tap Ctrl+C (catalog defaults: 2s window, exit 130)
98 manager.enable_double_tap(DoubleTapConfig::from_catalog());
99
100 // Spawn listener thread — routes OS signals to registered handlers.
101 // The registrations (_term_reg, _int_reg) are moved into the thread
102 // so they stay alive as long as the listener runs.
103 // If listen() exits early (error or unexpected return), set the stop
104 // flag so the guard loop doesn't continue deaf to signals.
105 let listener_mgr = manager.clone();
106 let listener_flag = Arc::clone(&stop_flag);
107 let handle = thread::Builder::new()
108 .name("guard-signals".into())
109 .spawn(move || {
110 // Keep registrations alive for the lifetime of the listener
111 let _term = _term_reg;
112 let _int = _int_reg;
113 if let Err(_e) = listener_mgr.listen() {
114 // Listener failed — mark stop so guard loop won't run deaf
115 listener_flag.store(true, Ordering::SeqCst);
116 }
117 })
118 .map_err(|e| crate::SysprimsError::internal(format!("signal thread: {e}")))?;
119
120 Ok(Self {
121 stop_flag,
122 manager,
123 injector,
124 listener_thread: Some(handle),
125 })
126 }
127
128 /// Check whether a shutdown signal has been received.
129 #[inline]
130 pub fn should_stop(&self) -> bool {
131 self.stop_flag.load(Ordering::SeqCst)
132 }
133
134 /// Request stop (useful for programmatic shutdown, e.g. max-iterations).
135 pub fn request_stop(&self) {
136 self.stop_flag.store(true, Ordering::SeqCst);
137 self.manager.stop();
138 }
139
140 /// Access the underlying `SignalManager` (for shutdown hooks or test injection).
141 pub fn manager(&self) -> &SignalManager {
142 &self.manager
143 }
144
145 /// Get a write handle to the shared stop flag.
146 ///
147 /// Used by [`GuardStopHandle`](crate) to request stop from another thread
148 /// without holding a reference to `GuardSignals`.
149 pub fn stop_flag_handle(&self) -> StopFlagHandle {
150 StopFlagHandle(Arc::clone(&self.stop_flag))
151 }
152}
153
154impl Drop for GuardSignals {
155 fn drop(&mut self) {
156 // Ensure the listener thread shuts down cleanly.
157 //
158 // Key constraint: rsfulmen's listen() resets its internal stop_flag
159 // to false on entry. Calling manager.stop() before listen() enters
160 // its loop is a no-op — the flag gets cleared and the thread hangs.
161 //
162 // Solution: inject a synthetic SIGTERM into the manager's mpsc
163 // channel. This is timing-independent:
164 // - If listen() hasn't started yet: the signal queues in the
165 // channel and gets drained once listen() enters its loop.
166 // - If listen() is already running: it drains the signal on
167 // the next 25ms poll cycle.
168 // - If listen() already exited: the send may fail (channel
169 // closed), which is fine — the thread is already done.
170 //
171 // In all cases, our registered SIGTERM handler fires, which sets
172 // our stop_flag and calls manager.stop(), cleanly exiting listen().
173 let _ = self.injector.inject(crate::signals::SIGTERM);
174 if let Some(handle) = self.listener_thread.take() {
175 let _ = handle.join();
176 }
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183 use std::time::Duration;
184
185 fn test_injector(gs: &GuardSignals) -> SignalInjector {
186 SignalInjector::new(gs.manager())
187 }
188
189 #[test]
190 fn test_guard_signals_starts_and_drops_cleanly() {
191 let gs = GuardSignals::start();
192 assert!(gs.is_ok(), "GuardSignals::start() should succeed");
193 let gs = gs.unwrap();
194 assert!(!gs.should_stop(), "should not be stopped initially");
195 // Drop injects synthetic SIGTERM, joins listener — must not hang
196 drop(gs);
197 }
198
199 #[test]
200 fn test_guard_signals_request_stop_and_drop() {
201 let gs = GuardSignals::start().unwrap();
202 assert!(!gs.should_stop());
203 gs.request_stop();
204 assert!(gs.should_stop(), "should be stopped after request_stop()");
205 // Drop skips inject since flag is set, joins cleanly
206 drop(gs);
207 }
208
209 #[test]
210 fn test_guard_signals_injected_sigterm() {
211 let gs = GuardSignals::start().unwrap();
212 let inj = test_injector(&gs);
213
214 // Inject SIGTERM
215 inj.inject(crate::signals::SIGTERM)
216 .expect("inject should succeed");
217
218 #[cfg(windows)]
219 let deadline = std::time::Instant::now() + Duration::from_secs(10);
220 #[cfg(not(windows))]
221 let deadline = std::time::Instant::now() + Duration::from_secs(2);
222 while !gs.should_stop() && std::time::Instant::now() < deadline {
223 thread::sleep(Duration::from_millis(25));
224 }
225 assert!(gs.should_stop(), "should be stopped after injected SIGTERM");
226 // Drop joins the (now-stopped) listener thread
227 drop(gs);
228 }
229}