Skip to main content

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}