Skip to main content

liminal_server/server/
shutdown.rs

1use std::fmt;
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::{Arc, Condvar, Mutex};
4use std::thread::{self, JoinHandle};
5use std::time::{Duration, Instant};
6
7use signal_hook::consts::signal::{SIGINT, SIGTERM};
8use signal_hook::iterator::{Handle as SignalIteratorHandle, Signals};
9
10use crate::ServerError;
11use crate::server::connection::{ConnectionSupervisor, WebSocketListener};
12use crate::server::listener::ServerListener;
13
14const DRAIN_PROGRESS_INTERVAL: Duration = Duration::from_millis(100);
15const FORCE_CLOSE_SETTLE_TIMEOUT: Duration = Duration::from_millis(500);
16const FORCE_CLOSE_POLL_INTERVAL: Duration = Duration::from_millis(10);
17
18/// Idempotent shutdown activation handle shared by the runtime and signal thread.
19#[derive(Clone)]
20pub struct ShutdownHandle {
21    inner: Arc<ShutdownState>,
22}
23
24impl ShutdownHandle {
25    /// Creates a new inactive shutdown handle.
26    #[must_use]
27    pub fn new() -> Self {
28        Self {
29            inner: Arc::new(ShutdownState::new()),
30        }
31    }
32
33    /// Initiates shutdown exactly once.
34    ///
35    /// Returns `true` for the first caller that transitions the handle to active,
36    /// and `false` for subsequent calls.
37    pub fn initiate(&self) -> bool {
38        if self.inner.initiated.swap(true, Ordering::SeqCst) {
39            tracing::debug!("shutdown request ignored because shutdown is already active");
40            return false;
41        }
42
43        tracing::info!("shutdown requested");
44        self.inner.notify();
45        true
46    }
47
48    /// Blocks until shutdown is initiated.
49    pub fn wait(&self) {
50        if self.is_initiated() {
51            return;
52        }
53        let Ok(mut guard) = self.inner.wait_lock.lock() else {
54            return;
55        };
56        while !self.is_initiated() {
57            match self.inner.waiter.wait(guard) {
58                Ok(next_guard) => guard = next_guard,
59                Err(_) => return,
60            }
61        }
62    }
63
64    /// Returns whether shutdown has been initiated.
65    #[must_use]
66    pub fn is_initiated(&self) -> bool {
67        self.inner.initiated.load(Ordering::SeqCst)
68    }
69}
70
71impl Default for ShutdownHandle {
72    fn default() -> Self {
73        Self::new()
74    }
75}
76
77impl fmt::Debug for ShutdownHandle {
78    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
79        formatter
80            .debug_struct("ShutdownHandle")
81            .field("initiated", &self.is_initiated())
82            .finish()
83    }
84}
85
86#[derive(Debug)]
87struct ShutdownState {
88    initiated: AtomicBool,
89    wait_lock: Mutex<()>,
90    waiter: Condvar,
91}
92
93impl ShutdownState {
94    const fn new() -> Self {
95        Self {
96            initiated: AtomicBool::new(false),
97            wait_lock: Mutex::new(()),
98            waiter: Condvar::new(),
99        }
100    }
101
102    fn notify(&self) {
103        if let Ok(_guard) = self.wait_lock.lock() {
104            self.waiter.notify_all();
105        }
106    }
107}
108
109/// Process-global OS signal registration for graceful shutdown.
110#[derive(Debug)]
111pub struct SignalShutdownRegistration {
112    signal_handle: SignalIteratorHandle,
113    worker: Option<JoinHandle<()>>,
114}
115
116impl SignalShutdownRegistration {
117    const fn new(signal_handle: SignalIteratorHandle, worker: JoinHandle<()>) -> Self {
118        Self {
119            signal_handle,
120            worker: Some(worker),
121        }
122    }
123}
124
125impl Drop for SignalShutdownRegistration {
126    fn drop(&mut self) {
127        self.signal_handle.close();
128        let Some(worker) = self.worker.take() else {
129            return;
130        };
131        if worker.join().is_err() {
132            tracing::debug!("shutdown signal worker terminated unexpectedly");
133        }
134    }
135}
136
137/// Registers SIGTERM and SIGINT handlers that initiate the supplied handle.
138///
139/// # Errors
140/// Returns [`ServerError::ListenerAccept`] when the OS signal registration fails.
141pub fn register_signal_handlers(
142    handle: ShutdownHandle,
143) -> Result<SignalShutdownRegistration, ServerError> {
144    let mut signals =
145        Signals::new([SIGTERM, SIGINT]).map_err(|error| ServerError::ListenerAccept {
146            message: format!("failed to register shutdown signal handlers: {error}"),
147        })?;
148    let signal_handle = signals.handle();
149    let worker = thread::spawn(move || {
150        for signal in signals.forever() {
151            tracing::info!(signal, "received shutdown signal");
152            handle.initiate();
153        }
154    });
155    Ok(SignalShutdownRegistration::new(signal_handle, worker))
156}
157
158/// Runs the graceful shutdown sequence after the handle has been activated.
159///
160/// The optional sibling WebSocket listener (LP-WS-TRANSPORT R1) stops
161/// accepting — and interrupts its in-flight upgrade handshakes — in the same
162/// pre-notification window as the main listener, so no connection on EITHER
163/// transport can slip past the shutdown broadcast. Already-admitted WebSocket
164/// connections live in the shared supervisor and are drained/force-closed by
165/// the same sequence below.
166///
167/// # Errors
168/// Returns [`ServerError`] when stop-accepting or durable flush fails.
169pub fn run_shutdown_sequence(
170    listener: &mut ServerListener,
171    websocket_listener: Option<&mut WebSocketListener>,
172    supervisor: &ConnectionSupervisor,
173    drain_timeout: Duration,
174) -> Result<(), ServerError> {
175    tracing::info!(?drain_timeout, "starting graceful shutdown sequence");
176    // Stop accepting new connections first so none can slip into the accept
177    // window after shutdown begins and miss the notification broadcast below.
178    if let Some(websocket_listener) = websocket_listener {
179        websocket_listener.stop_accepting()?;
180    }
181    listener.stop_accepting()?;
182    supervisor.notify_shutdown_subscribers();
183
184    let drained = drain_connections(supervisor, drain_timeout);
185    if !drained {
186        supervisor.force_close_active_connections();
187        wait_after_force_close(supervisor);
188    }
189
190    flush_durable_state(supervisor)?;
191    supervisor.shutdown();
192    tracing::info!("graceful shutdown sequence complete");
193    Ok(())
194}
195
196fn drain_connections(supervisor: &ConnectionSupervisor, drain_timeout: Duration) -> bool {
197    let deadline = Instant::now() + drain_timeout;
198    let mut last_log = Instant::now()
199        .checked_sub(DRAIN_PROGRESS_INTERVAL)
200        .unwrap_or_else(Instant::now);
201
202    loop {
203        let reaped = supervisor.reap_crashed_connections();
204        if reaped > 0 {
205            tracing::debug!(
206                reaped_connections = reaped,
207                "reaped connections during drain"
208            );
209        }
210
211        let active = supervisor.active_connection_count();
212        if active == 0 {
213            tracing::info!("all connections drained before timeout");
214            return true;
215        }
216
217        let now = Instant::now();
218        if now >= deadline {
219            tracing::warn!(
220                active_connections = active,
221                ?drain_timeout,
222                "drain timeout expired with active connections"
223            );
224            return false;
225        }
226
227        if now.duration_since(last_log) >= DRAIN_PROGRESS_INTERVAL {
228            tracing::info!(
229                active_connections = active,
230                "waiting for active connections to drain"
231            );
232            last_log = now;
233        }
234
235        let remaining = deadline.saturating_duration_since(now);
236        thread::sleep(remaining.min(FORCE_CLOSE_POLL_INTERVAL));
237    }
238}
239
240fn wait_after_force_close(supervisor: &ConnectionSupervisor) {
241    let deadline = Instant::now() + FORCE_CLOSE_SETTLE_TIMEOUT;
242    while Instant::now() < deadline {
243        let reaped = supervisor.reap_crashed_connections();
244        let active = supervisor.active_connection_count();
245        if active == 0 {
246            return;
247        }
248        if reaped > 0 {
249            tracing::debug!(
250                reaped_connections = reaped,
251                active_connections = active,
252                "reaped connections after force close"
253            );
254        }
255        thread::sleep(FORCE_CLOSE_POLL_INTERVAL);
256    }
257
258    let remaining = supervisor.active_connection_count();
259    if remaining > 0 {
260        tracing::warn!(
261            active_connections = remaining,
262            "connections remained active after force-close settle window"
263        );
264    }
265}
266
267fn flush_durable_state(supervisor: &ConnectionSupervisor) -> Result<(), ServerError> {
268    tracing::info!("flushing durable channel state");
269    supervisor.flush_durable_state().map_err(|error| {
270        tracing::error!(%error, "durable state flush failed during shutdown");
271        match error {
272            ServerError::ShutdownFlush { .. } => error,
273            other => ServerError::ShutdownFlush {
274                message: other.to_string(),
275            },
276        }
277    })?;
278    tracing::info!("durable channel state flushed");
279    Ok(())
280}
281
282#[cfg(test)]
283mod tests {
284    use std::thread;
285    use std::time::Duration;
286
287    use super::{ShutdownHandle, drain_connections};
288    use crate::server::connection::ConnectionSupervisor;
289
290    #[test]
291    fn shutdown_handle_initiates_once() {
292        let handle = ShutdownHandle::new();
293
294        assert!(!handle.is_initiated());
295        assert!(handle.initiate());
296        assert!(handle.is_initiated());
297        assert!(!handle.initiate());
298    }
299
300    #[test]
301    fn shutdown_handle_wait_unblocks_on_initiate() -> Result<(), Box<dyn std::error::Error>> {
302        let handle = ShutdownHandle::new();
303        let waiter = handle.clone();
304        let worker = thread::spawn(move || {
305            waiter.wait();
306            waiter.is_initiated()
307        });
308
309        thread::sleep(Duration::from_millis(10));
310        assert!(handle.initiate());
311        let observed = worker.join().map_err(|_| "wait worker panicked")?;
312
313        assert!(observed);
314        Ok(())
315    }
316
317    #[test]
318    fn drain_returns_immediately_when_no_connections_are_active()
319    -> Result<(), Box<dyn std::error::Error>> {
320        let supervisor = ConnectionSupervisor::new()?;
321
322        let drained = drain_connections(&supervisor, Duration::from_secs(5));
323
324        assert!(drained);
325        supervisor.shutdown();
326        Ok(())
327    }
328}