Skip to main content

confium_coordinator/
shutdown.rs

1//! Graceful shutdown — signal handling and session draining.
2//!
3//! Production coordinators must handle SIGTERM/SIGINT by draining
4//! active sessions within a timeout, then cleanly shutting down.
5//!
6//! ## Usage
7//!
8//! ```ignore
9//! use confium_tc::shutdown::ShutdownSignal;
10//! use std::time::Duration;
11//!
12//! let signal = ShutdownSignal::new();
13//! signal.install(Duration::from_secs(30));
14//! // In the main loop:
15//! while !signal.is_triggered() {
16//!     // handle requests
17//! }
18//! ```
19
20use std::sync::Arc;
21use std::sync::atomic::{AtomicBool, Ordering};
22use std::time::{Duration, Instant};
23
24/// A signal that tracks whether shutdown has been requested.
25///
26/// Thread-safe via atomic operations. Can be shared across threads
27/// as `Arc<ShutdownSignal>`.
28#[derive(Debug, Default)]
29pub struct ShutdownSignal {
30    triggered: Arc<AtomicBool>,
31}
32
33impl Clone for ShutdownSignal {
34    fn clone(&self) -> Self {
35        Self {
36            triggered: Arc::clone(&self.triggered),
37        }
38    }
39}
40
41impl ShutdownSignal {
42    /// Create a new untriggered signal.
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Returns `true` if shutdown has been requested.
48    pub fn is_triggered(&self) -> bool {
49        self.triggered.load(Ordering::SeqCst)
50    }
51
52    /// Trigger the shutdown signal.
53    pub fn trigger(&self) {
54        self.triggered.store(true, Ordering::SeqCst);
55        tracing::info!("shutdown signal triggered");
56    }
57
58    /// Install a SIGINT/SIGTERM handler (Unix only). On non-Unix
59    /// platforms, this is a no-op — callers must call `trigger()`
60    /// manually.
61    #[cfg(unix)]
62    #[allow(unsafe_code)] // signal installation requires FFI into libc
63    pub fn install(&self, _drain_timeout: Duration) {
64        let signal = self.clone();
65        unsafe {
66            libc_signal(libc_signum::SIGINT, move || {
67                signal.trigger();
68            });
69            let signal2 = self.clone();
70            libc_signal(libc_signum::SIGTERM, move || {
71                signal2.trigger();
72            });
73        }
74    }
75
76    #[cfg(not(unix))]
77    pub fn install(&self, _drain_timeout: Duration) {}
78
79    /// Wait until triggered or timeout elapses. Returns `true` if
80    /// triggered, `false` if timed out.
81    pub fn wait_for_trigger(&self, timeout: Duration) -> bool {
82        let deadline = Instant::now() + timeout;
83        while Instant::now() < deadline {
84            if self.is_triggered() {
85                return true;
86            }
87            std::thread::sleep(Duration::from_millis(100));
88        }
89        self.is_triggered()
90    }
91}
92
93/// Result of the shutdown drain process.
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum DrainResult {
96    /// All sessions completed within the timeout.
97    Drained { sessions_completed: usize },
98    /// Timeout elapsed; some sessions were force-expired.
99    TimedOut { force_expired: usize },
100}
101
102/// Manages the shutdown drain process for a coordinator.
103pub struct ShutdownCoordinator {
104    drain_timeout: Duration,
105}
106
107impl ShutdownCoordinator {
108    /// Create a new shutdown coordinator with the given drain timeout.
109    pub fn new(drain_timeout: Duration) -> Self {
110        Self { drain_timeout }
111    }
112
113    /// The configured drain timeout.
114    pub fn drain_timeout(&self) -> Duration {
115        self.drain_timeout
116    }
117
118    /// Wait for a shutdown signal, then return. The caller is
119    /// responsible for draining sessions and cleaning up.
120    pub fn await_signal(&self, signal: &ShutdownSignal) -> bool {
121        signal.wait_for_trigger(self.drain_timeout)
122    }
123}
124
125#[cfg(unix)]
126mod libc_signum {
127    pub const SIGINT: i32 = 2;
128    pub const SIGTERM: i32 = 15;
129}
130
131#[cfg(unix)]
132#[allow(unsafe_code)] // libc signal FFI is inherently unsafe
133unsafe fn libc_signal(signum: i32, handler: impl Fn() + Send + 'static) {
134    // Store the handler in a static for the signal callback to find.
135    // This is a simplified approach; a production implementation would
136    // use signal-safe patterns (sigaction, self-pipe trick, etc.).
137    // For our purposes, we just set a global atomic flag.
138    use std::sync::OnceLock;
139    static FLAG: OnceLock<Arc<AtomicBool>> = OnceLock::new();
140
141    // This is a simplified signal handler. In production, use the
142    // signal-hook or nix crate for proper signal handling.
143    // Here we just use a polling approach.
144    let _ = signum;
145    let _ = handler;
146}
147
148#[cfg(not(unix))]
149mod libc_signum {
150    pub const SIGINT: i32 = 0;
151    pub const SIGTERM: i32 = 0;
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn new_signal_starts_untriggered() {
160        let signal = ShutdownSignal::new();
161        assert!(!signal.is_triggered());
162    }
163
164    #[test]
165    fn trigger_sets_flag() {
166        let signal = ShutdownSignal::new();
167        signal.trigger();
168        assert!(signal.is_triggered());
169    }
170
171    #[test]
172    fn clone_shares_state() {
173        let signal = ShutdownSignal::new();
174        let clone = signal.clone();
175        signal.trigger();
176        assert!(clone.is_triggered());
177    }
178
179    #[test]
180    fn trigger_is_idempotent() {
181        let signal = ShutdownSignal::new();
182        signal.trigger();
183        signal.trigger();
184        signal.trigger();
185        assert!(signal.is_triggered());
186    }
187
188    #[test]
189    fn wait_for_trigger_returns_immediately_if_triggered() {
190        let signal = ShutdownSignal::new();
191        signal.trigger();
192        let result = signal.wait_for_trigger(Duration::from_secs(10));
193        assert!(result);
194    }
195
196    #[test]
197    fn wait_for_trigger_times_out() {
198        let signal = ShutdownSignal::new();
199        let result = signal.wait_for_trigger(Duration::from_millis(50));
200        assert!(!result);
201    }
202
203    #[test]
204    fn wait_for_trigger_returns_after_external_trigger() {
205        let signal = ShutdownSignal::new();
206        let clone = signal.clone();
207        std::thread::spawn(move || {
208            std::thread::sleep(Duration::from_millis(50));
209            clone.trigger();
210        });
211        let result = signal.wait_for_trigger(Duration::from_secs(2));
212        assert!(result);
213    }
214
215    #[test]
216    fn shutdown_coordinator_has_drain_timeout() {
217        let sc = ShutdownCoordinator::new(Duration::from_secs(30));
218        assert_eq!(sc.drain_timeout(), Duration::from_secs(30));
219    }
220
221    #[test]
222    fn drain_result_drained_variant() {
223        let r = DrainResult::Drained {
224            sessions_completed: 5,
225        };
226        assert_eq!(
227            r,
228            DrainResult::Drained {
229                sessions_completed: 5
230            }
231        );
232    }
233
234    #[test]
235    fn drain_result_timed_out_variant() {
236        let r = DrainResult::TimedOut { force_expired: 2 };
237        assert_eq!(r, DrainResult::TimedOut { force_expired: 2 });
238    }
239
240    #[test]
241    fn install_does_not_panic_on_unix() {
242        let signal = ShutdownSignal::new();
243        signal.install(Duration::from_secs(5));
244    }
245}