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
14/// Bounded window the force-close settle waits for the forced connections to
15/// deliver their exits, as a single admitted one-shot deadline — not a poll
16/// interval. The waiter parks on the shared TOLD drain-completion notification
17/// (W4 leg 3, §4.3) and this deadline only bounds how long it will wait.
18const FORCE_CLOSE_SETTLE_WINDOW: Duration = Duration::from_millis(500);
19
20/// Idempotent shutdown activation handle shared by the runtime and signal thread.
21#[derive(Clone)]
22pub struct ShutdownHandle {
23    inner: Arc<ShutdownState>,
24}
25
26impl ShutdownHandle {
27    /// Creates a new inactive shutdown handle.
28    #[must_use]
29    pub fn new() -> Self {
30        Self {
31            inner: Arc::new(ShutdownState::new()),
32        }
33    }
34
35    /// Initiates shutdown exactly once.
36    ///
37    /// Returns `true` for the first caller that transitions the handle to active,
38    /// and `false` for subsequent calls.
39    pub fn initiate(&self) -> bool {
40        if self.inner.initiated.swap(true, Ordering::SeqCst) {
41            tracing::debug!("shutdown request ignored because shutdown is already active");
42            return false;
43        }
44
45        tracing::info!("shutdown requested");
46        self.inner.notify();
47        true
48    }
49
50    /// Blocks until shutdown is initiated.
51    pub fn wait(&self) {
52        if self.is_initiated() {
53            return;
54        }
55        let Ok(mut guard) = self.inner.wait_lock.lock() else {
56            return;
57        };
58        while !self.is_initiated() {
59            match self.inner.waiter.wait(guard) {
60                Ok(next_guard) => guard = next_guard,
61                Err(_) => return,
62            }
63        }
64    }
65
66    /// Returns whether shutdown has been initiated.
67    #[must_use]
68    pub fn is_initiated(&self) -> bool {
69        self.inner.initiated.load(Ordering::SeqCst)
70    }
71}
72
73impl Default for ShutdownHandle {
74    fn default() -> Self {
75        Self::new()
76    }
77}
78
79impl fmt::Debug for ShutdownHandle {
80    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
81        formatter
82            .debug_struct("ShutdownHandle")
83            .field("initiated", &self.is_initiated())
84            .finish()
85    }
86}
87
88#[derive(Debug)]
89struct ShutdownState {
90    initiated: AtomicBool,
91    wait_lock: Mutex<()>,
92    waiter: Condvar,
93}
94
95impl ShutdownState {
96    const fn new() -> Self {
97        Self {
98            initiated: AtomicBool::new(false),
99            wait_lock: Mutex::new(()),
100            waiter: Condvar::new(),
101        }
102    }
103
104    fn notify(&self) {
105        if let Ok(_guard) = self.wait_lock.lock() {
106            self.waiter.notify_all();
107        }
108    }
109}
110
111/// Process-global OS signal registration for graceful shutdown.
112#[derive(Debug)]
113pub struct SignalShutdownRegistration {
114    signal_handle: SignalIteratorHandle,
115    worker: Option<JoinHandle<()>>,
116}
117
118impl SignalShutdownRegistration {
119    const fn new(signal_handle: SignalIteratorHandle, worker: JoinHandle<()>) -> Self {
120        Self {
121            signal_handle,
122            worker: Some(worker),
123        }
124    }
125}
126
127impl Drop for SignalShutdownRegistration {
128    fn drop(&mut self) {
129        self.signal_handle.close();
130        let Some(worker) = self.worker.take() else {
131            return;
132        };
133        if worker.join().is_err() {
134            tracing::debug!("shutdown signal worker terminated unexpectedly");
135        }
136    }
137}
138
139/// Registers SIGTERM and SIGINT handlers that initiate the supplied handle.
140///
141/// # Errors
142/// Returns [`ServerError::ListenerAccept`] when the OS signal registration fails.
143pub fn register_signal_handlers(
144    handle: ShutdownHandle,
145) -> Result<SignalShutdownRegistration, ServerError> {
146    let mut signals =
147        Signals::new([SIGTERM, SIGINT]).map_err(|error| ServerError::ListenerAccept {
148            message: format!("failed to register shutdown signal handlers: {error}"),
149        })?;
150    let signal_handle = signals.handle();
151    let worker = thread::spawn(move || {
152        for signal in signals.forever() {
153            tracing::info!(signal, "received shutdown signal");
154            handle.initiate();
155        }
156    });
157    Ok(SignalShutdownRegistration::new(signal_handle, worker))
158}
159
160/// Runs the graceful shutdown sequence after the handle has been activated.
161///
162/// The optional sibling WebSocket listener (LP-WS-TRANSPORT R1) stops
163/// accepting — and interrupts its in-flight upgrade handshakes — in the same
164/// pre-notification window as the main listener, so no connection on EITHER
165/// transport can slip past the shutdown broadcast. Already-admitted WebSocket
166/// connections live in the shared supervisor and are drained/force-closed by
167/// the same sequence below.
168///
169/// # Errors
170/// Returns [`ServerError`] when stop-accepting or durable flush fails.
171pub fn run_shutdown_sequence(
172    listener: &mut ServerListener,
173    websocket_listener: Option<&mut WebSocketListener>,
174    supervisor: &ConnectionSupervisor,
175    drain_timeout: Duration,
176) -> Result<(), ServerError> {
177    tracing::info!(?drain_timeout, "starting graceful shutdown sequence");
178    // Stop accepting new connections first so none can slip into the accept
179    // window after shutdown begins and miss the notification broadcast below.
180    if let Some(websocket_listener) = websocket_listener {
181        websocket_listener.stop_accepting()?;
182    }
183    listener.stop_accepting()?;
184
185    // FIX A-ii: flush accepted-but-unfanned-out publishes to their subscriber
186    // connections BEFORE broadcasting the shutdown Disconnect. Accept is now
187    // stopped, so the set of accepted publishes is bounded; this TOLD barrier
188    // parks on the delivery-quiescence signal (a connection parks only once every
189    // accepted publish has been pumped to its socket) until every active
190    // connection has quiesced, bounded by the same `drain_timeout` budget. Without
191    // it, `notify_shutdown_subscribers` below could enqueue a subscriber's
192    // Disconnect ahead of an in-flight fan-out (measured 8-131 ms) and the
193    // subscriber's reader would exit before delivery. A timeout here is logged,
194    // not fatal — the drain and force-close legs below still run.
195    let flush_deadline = Instant::now() + drain_timeout;
196    if !supervisor.wait_for_delivery_quiesced(flush_deadline) {
197        tracing::warn!(
198            ?drain_timeout,
199            "delivery flush barrier did not quiesce before its budget; proceeding to shutdown notification"
200        );
201    }
202
203    supervisor.notify_shutdown_subscribers();
204
205    let drained = drain_connections(supervisor, drain_timeout);
206    if !drained {
207        supervisor.force_close_active_connections();
208        wait_after_force_close(supervisor);
209    }
210
211    flush_durable_state(supervisor)?;
212    supervisor.shutdown();
213    tracing::info!("graceful shutdown sequence complete");
214    Ok(())
215}
216
217/// Waits for every active connection to exit, or for `drain_timeout` to elapse.
218///
219/// This is the TOLD drain (W4 leg 3, §4.3): it parks on the supervisor's
220/// drain-completion notification, woken only by a delivered connection exit —
221/// every exit route (in-slice `mark_crashed`/`finish`, the reclaim reactor, and
222/// the reconciliation scan) funnels through the single `remove()` teardown that
223/// bumps the drain generation — or by the one admitted `drain_timeout` deadline.
224/// It runs no per-iteration reap or active-count poll: the retired
225/// reap/count/sleep loop sampled completion ~100 times a second; this samples it
226/// zero times while the connections are held and drops the deadline exactly once.
227fn drain_connections(supervisor: &ConnectionSupervisor, drain_timeout: Duration) -> bool {
228    let active_at_start = supervisor.active_connection_count();
229    if active_at_start == 0 {
230        return true;
231    }
232    tracing::info!(
233        active_connections = active_at_start,
234        ?drain_timeout,
235        "waiting for active connections to drain"
236    );
237    let deadline = Instant::now() + drain_timeout;
238    let drained = supervisor.wait_for_connections_drained(deadline);
239    if drained {
240        tracing::info!("all connections drained before timeout");
241    } else {
242        tracing::warn!(
243            active_connections = supervisor.active_connection_count(),
244            ?drain_timeout,
245            "drain timeout expired with active connections"
246        );
247    }
248    drained
249}
250
251pub(crate) fn wait_after_force_close(supervisor: &ConnectionSupervisor) {
252    // Force-close reuses the SAME TOLD exit notification the graceful drain parks
253    // on (§4.3) — the forced connections deliver their exits through the one
254    // `remove()` funnel — bounded by its own single settle deadline. There is no
255    // second settle poll loop and no reap scan.
256    let deadline = Instant::now() + FORCE_CLOSE_SETTLE_WINDOW;
257    if supervisor.wait_for_connections_drained(deadline) {
258        return;
259    }
260    let remaining = supervisor.active_connection_count();
261    if remaining > 0 {
262        tracing::warn!(
263            active_connections = remaining,
264            "connections remained active after force-close settle window"
265        );
266    }
267}
268
269fn flush_durable_state(supervisor: &ConnectionSupervisor) -> Result<(), ServerError> {
270    tracing::info!("flushing durable channel state");
271    supervisor.flush_durable_state().map_err(|error| {
272        tracing::error!(%error, "durable state flush failed during shutdown");
273        match error {
274            ServerError::ShutdownFlush { .. } => error,
275            other => ServerError::ShutdownFlush {
276                message: other.to_string(),
277            },
278        }
279    })?;
280    tracing::info!("durable channel state flushed");
281    Ok(())
282}
283
284#[cfg(test)]
285mod tests {
286    use std::thread;
287    use std::time::Duration;
288
289    use super::{ShutdownHandle, drain_connections};
290    use crate::server::connection::ConnectionSupervisor;
291
292    #[test]
293    fn shutdown_handle_initiates_once() {
294        let handle = ShutdownHandle::new();
295
296        assert!(!handle.is_initiated());
297        assert!(handle.initiate());
298        assert!(handle.is_initiated());
299        assert!(!handle.initiate());
300    }
301
302    #[test]
303    fn shutdown_handle_wait_unblocks_on_initiate() -> Result<(), Box<dyn std::error::Error>> {
304        let handle = ShutdownHandle::new();
305        let waiter = handle.clone();
306        let worker = thread::spawn(move || {
307            waiter.wait();
308            waiter.is_initiated()
309        });
310
311        thread::sleep(Duration::from_millis(10));
312        assert!(handle.initiate());
313        let observed = worker.join().map_err(|_| "wait worker panicked")?;
314
315        assert!(observed);
316        Ok(())
317    }
318
319    #[test]
320    fn drain_returns_immediately_when_no_connections_are_active()
321    -> Result<(), Box<dyn std::error::Error>> {
322        let supervisor = ConnectionSupervisor::new()?;
323
324        let drained = drain_connections(&supervisor, Duration::from_secs(5));
325
326        assert!(drained);
327        supervisor.shutdown();
328        Ok(())
329    }
330
331    /// Oracle 13 (W4 leg 3, §4.3) — absence proof over the drain/settle
332    /// implementation (this module before its `mod tests`): none of the retired
333    /// poll constants nor the per-iteration reap scan survive. The forbid-list
334    /// literals below live in the test section, so `split` excludes them from the
335    /// implementation slice under inspection.
336    #[test]
337    fn drain_source_has_no_reap_count_sleep_loop() {
338        let source = include_str!("shutdown.rs");
339        // `split` always yields a first segment; `unwrap_or` keeps this panic-free
340        // under the workspace lint deny while never falling back in practice.
341        let implementation = source.split("mod tests").next().unwrap_or(source);
342        for forbidden in [
343            "DRAIN_PROGRESS_INTERVAL",
344            "FORCE_CLOSE_SETTLE_TIMEOUT",
345            "FORCE_CLOSE_POLL_INTERVAL",
346            "reap_crashed_connections",
347        ] {
348            assert!(
349                !implementation.contains(forbidden),
350                "retired poll/reap token `{forbidden}` must not appear in the drain/settle implementation"
351            );
352        }
353    }
354}