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/// The ONE bounded park left in the shutdown path, and it is a HANG STOP — not
15/// a grace period, not a drain, and never a delay anything waits out.
16///
17/// 0.14.3 retired the graceful drain because its stated purpose did not hold.
18/// A drain exists to let in-flight requests finish; in this server every write
19/// is durable and flushed BEFORE it is acknowledged (`OperationLog::append`
20/// flushes the store inside the append), so at the instant shutdown begins
21/// there is nothing in flight to finish. What the drain actually did on an
22/// estate whose connections are long-lived idle seats — seats that never hang
23/// up by themselves — was wait out its whole configured budget (5 s on Tom's
24/// estate, measured 2026-09-14) and then force-close anyway. The close was
25/// always the real mechanism; the wait in front of it bought nothing.
26///
27/// Both remaining waits park on an EVENT (the supervisor's delivery-quiescence
28/// signal and its TOLD drain-completion notification, W4 leg 3 §4.3) and return
29/// the instant that event arrives — with every connection idle they return in
30/// microseconds. This bound exists solely so that ONE wedged connection process
31/// cannot hold a restart open forever; it is the stop on a hang, which is why
32/// it is a number at all and why it is short.
33const WEDGED_CONNECTION_STOP: Duration = Duration::from_millis(500);
34
35/// Idempotent shutdown activation handle shared by the runtime and signal thread.
36#[derive(Clone)]
37pub struct ShutdownHandle {
38    inner: Arc<ShutdownState>,
39}
40
41impl ShutdownHandle {
42    /// Creates a new inactive shutdown handle.
43    #[must_use]
44    pub fn new() -> Self {
45        Self {
46            inner: Arc::new(ShutdownState::new()),
47        }
48    }
49
50    /// Initiates shutdown exactly once.
51    ///
52    /// Returns `true` for the first caller that transitions the handle to active,
53    /// and `false` for subsequent calls.
54    pub fn initiate(&self) -> bool {
55        if self.inner.initiated.swap(true, Ordering::SeqCst) {
56            tracing::debug!("shutdown request ignored because shutdown is already active");
57            return false;
58        }
59
60        tracing::info!("shutdown requested");
61        self.inner.notify();
62        true
63    }
64
65    /// Blocks until shutdown is initiated.
66    pub fn wait(&self) {
67        if self.is_initiated() {
68            return;
69        }
70        let Ok(mut guard) = self.inner.wait_lock.lock() else {
71            return;
72        };
73        while !self.is_initiated() {
74            match self.inner.waiter.wait(guard) {
75                Ok(next_guard) => guard = next_guard,
76                Err(_) => return,
77            }
78        }
79    }
80
81    /// Returns whether shutdown has been initiated.
82    #[must_use]
83    pub fn is_initiated(&self) -> bool {
84        self.inner.initiated.load(Ordering::SeqCst)
85    }
86}
87
88impl Default for ShutdownHandle {
89    fn default() -> Self {
90        Self::new()
91    }
92}
93
94impl fmt::Debug for ShutdownHandle {
95    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
96        formatter
97            .debug_struct("ShutdownHandle")
98            .field("initiated", &self.is_initiated())
99            .finish()
100    }
101}
102
103#[derive(Debug)]
104struct ShutdownState {
105    initiated: AtomicBool,
106    wait_lock: Mutex<()>,
107    waiter: Condvar,
108}
109
110impl ShutdownState {
111    const fn new() -> Self {
112        Self {
113            initiated: AtomicBool::new(false),
114            wait_lock: Mutex::new(()),
115            waiter: Condvar::new(),
116        }
117    }
118
119    fn notify(&self) {
120        if let Ok(_guard) = self.wait_lock.lock() {
121            self.waiter.notify_all();
122        }
123    }
124}
125
126/// Process-global OS signal registration for graceful shutdown.
127#[derive(Debug)]
128pub struct SignalShutdownRegistration {
129    signal_handle: SignalIteratorHandle,
130    worker: Option<JoinHandle<()>>,
131}
132
133impl SignalShutdownRegistration {
134    const fn new(signal_handle: SignalIteratorHandle, worker: JoinHandle<()>) -> Self {
135        Self {
136            signal_handle,
137            worker: Some(worker),
138        }
139    }
140}
141
142impl Drop for SignalShutdownRegistration {
143    fn drop(&mut self) {
144        self.signal_handle.close();
145        let Some(worker) = self.worker.take() else {
146            return;
147        };
148        if worker.join().is_err() {
149            tracing::debug!("shutdown signal worker terminated unexpectedly");
150        }
151    }
152}
153
154/// Registers SIGTERM and SIGINT handlers that initiate the supplied handle.
155///
156/// # Errors
157/// Returns [`ServerError::ListenerAccept`] when the OS signal registration fails.
158pub fn register_signal_handlers(
159    handle: ShutdownHandle,
160) -> Result<SignalShutdownRegistration, ServerError> {
161    let mut signals =
162        Signals::new([SIGTERM, SIGINT]).map_err(|error| ServerError::ListenerAccept {
163            message: format!("failed to register shutdown signal handlers: {error}"),
164        })?;
165    let signal_handle = signals.handle();
166    let worker = thread::spawn(move || {
167        for signal in signals.forever() {
168            tracing::info!(signal, "received shutdown signal");
169            handle.initiate();
170        }
171    });
172    Ok(SignalShutdownRegistration::new(signal_handle, worker))
173}
174
175/// Runs the shutdown sequence after the handle has been activated.
176///
177/// Four steps, in this order, with no timer between them: stop accepting, close
178/// every connection this server holds, flush durable channel state, exit.
179///
180/// The optional sibling WebSocket listener (LP-WS-TRANSPORT R1) stops
181/// accepting — and interrupts its in-flight upgrade handshakes — in the same
182/// pre-notification window as the main listener, so no connection on EITHER
183/// transport can slip past the shutdown broadcast. Already-admitted WebSocket
184/// connections live in the shared supervisor and are closed by the same
185/// sequence below.
186///
187/// # The retired drain (0.14.3)
188///
189/// Until 0.14.3 this function waited up to `drain_timeout` for connections to
190/// hang up by themselves before closing them. `drain_timeout` is now IGNORED:
191/// it is still accepted so an existing config file and an existing embedder
192/// call site both keep compiling and loading, and it is logged once, by name,
193/// as ignored. See [`WEDGED_CONNECTION_STOP`] for why the wait bought nothing
194/// and what the one remaining bound is for.
195///
196/// The close itself is unchanged and was always the orderly one: each
197/// connection process enqueues a protocol `Disconnect`, drains its outbound
198/// buffer, completes its connection fate as `ServerShutdown`, and — on the
199/// WebSocket transport — writes a close frame carrying `CloseCode::Away` and
200/// the reason "server shutdown" before exiting `Normal`. A peer reads a
201/// shutdown, never a reset.
202///
203/// # Errors
204/// Returns [`ServerError`] when stop-accepting or durable flush fails.
205pub fn run_shutdown_sequence(
206    listener: &mut ServerListener,
207    websocket_listener: Option<&mut WebSocketListener>,
208    supervisor: &ConnectionSupervisor,
209    drain_timeout: Duration,
210) -> Result<(), ServerError> {
211    let started = Instant::now();
212    tracing::info!(
213        ignored_drain_timeout = ?drain_timeout,
214        "starting shutdown sequence; the configured drain timeout has been ignored since 0.14.3 \
215         because every write is durable before it is acknowledged, so no request is in flight to \
216         drain"
217    );
218    // Stop accepting new connections first so none can slip into the accept
219    // window after shutdown begins and miss the notification broadcast below.
220    if let Some(websocket_listener) = websocket_listener {
221        websocket_listener.stop_accepting()?;
222    }
223    listener.stop_accepting()?;
224
225    // FIX A-ii: flush accepted-but-unfanned-out publishes to their subscriber
226    // connections BEFORE broadcasting the shutdown Disconnect. Accept is now
227    // stopped, so the set of accepted publishes is bounded; this TOLD barrier
228    // parks on the delivery-quiescence signal (a connection parks only once every
229    // accepted publish has been pumped to its socket) and returns the instant it
230    // arrives. Without it, `notify_shutdown_subscribers` below could enqueue a
231    // subscriber's Disconnect ahead of an in-flight fan-out (measured 8-131 ms)
232    // and the subscriber's reader would exit before delivery. The bound is the
233    // hang stop, not a budget anything waits out; missing it is logged, not
234    // fatal — the close and flush legs below still run.
235    if !supervisor.wait_for_delivery_quiesced(Instant::now() + WEDGED_CONNECTION_STOP) {
236        tracing::warn!(
237            stop = ?WEDGED_CONNECTION_STOP,
238            "delivery flush barrier did not quiesce before the wedged-connection stop; proceeding \
239             to shutdown notification"
240        );
241    }
242
243    supervisor.notify_shutdown_subscribers();
244
245    // The close is the mechanism, and it is now the only route. Nothing waits
246    // for a peer to hang up first.
247    supervisor.force_close_active_connections();
248    wait_after_force_close(supervisor);
249
250    flush_durable_state(supervisor)?;
251    supervisor.shutdown();
252    tracing::info!(
253        elapsed_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX),
254        "shutdown sequence complete"
255    );
256    Ok(())
257}
258
259/// Waits for the closed connections to deliver their exits.
260///
261/// Parks on the supervisor's TOLD exit notification (§4.3) — every exit route
262/// funnels through the one `remove()` teardown that bumps the drain generation
263/// — and returns the instant the last one lands. There is no poll loop and no
264/// reap scan; [`WEDGED_CONNECTION_STOP`] only stops a wedged process from
265/// holding the restart open.
266///
267/// The name is kept from the pre-0.14.3 shape, where this ran only after a
268/// drain had expired. Since 0.14.3 the close is the sole route, so this runs on
269/// every shutdown.
270pub(crate) fn wait_after_force_close(supervisor: &ConnectionSupervisor) {
271    let deadline = Instant::now() + WEDGED_CONNECTION_STOP;
272    if supervisor.wait_for_connections_drained(deadline) {
273        return;
274    }
275    let remaining = supervisor.active_connection_count();
276    if remaining > 0 {
277        tracing::warn!(
278            active_connections = remaining,
279            stop = ?WEDGED_CONNECTION_STOP,
280            "connections remained active after the wedged-connection stop"
281        );
282    }
283}
284
285fn flush_durable_state(supervisor: &ConnectionSupervisor) -> Result<(), ServerError> {
286    tracing::info!("flushing durable channel state");
287    supervisor.flush_durable_state().map_err(|error| {
288        tracing::error!(%error, "durable state flush failed during shutdown");
289        match error {
290            ServerError::ShutdownFlush { .. } => error,
291            other => ServerError::ShutdownFlush {
292                message: other.to_string(),
293            },
294        }
295    })?;
296    tracing::info!("durable channel state flushed");
297    Ok(())
298}
299
300#[cfg(test)]
301mod tests {
302    use std::thread;
303    use std::time::Duration;
304
305    use super::{ShutdownHandle, wait_after_force_close};
306    use crate::server::connection::ConnectionSupervisor;
307
308    #[test]
309    fn shutdown_handle_initiates_once() {
310        let handle = ShutdownHandle::new();
311
312        assert!(!handle.is_initiated());
313        assert!(handle.initiate());
314        assert!(handle.is_initiated());
315        assert!(!handle.initiate());
316    }
317
318    #[test]
319    fn shutdown_handle_wait_unblocks_on_initiate() -> Result<(), Box<dyn std::error::Error>> {
320        let handle = ShutdownHandle::new();
321        let waiter = handle.clone();
322        let worker = thread::spawn(move || {
323            waiter.wait();
324            waiter.is_initiated()
325        });
326
327        thread::sleep(Duration::from_millis(10));
328        assert!(handle.initiate());
329        let observed = worker.join().map_err(|_| "wait worker panicked")?;
330
331        assert!(observed);
332        Ok(())
333    }
334
335    /// The close-settle wait returns immediately when nothing is tracked, so a
336    /// shutdown with no connections spends no time here at all.
337    #[test]
338    fn the_close_settle_returns_immediately_when_no_connections_are_active()
339    -> Result<(), Box<dyn std::error::Error>> {
340        let supervisor = ConnectionSupervisor::new()?;
341
342        let started = std::time::Instant::now();
343        wait_after_force_close(&supervisor);
344        let elapsed = started.elapsed();
345
346        assert!(
347            elapsed < Duration::from_millis(50),
348            "the close settle took {elapsed:?} with no connections tracked"
349        );
350        supervisor.shutdown();
351        Ok(())
352    }
353
354    /// Oracle 13 (W4 leg 3, §4.3) — absence proof over the close/settle
355    /// implementation (this module before its `mod tests`): none of the retired
356    /// poll constants nor the per-iteration reap scan survive, AND neither does
357    /// the graceful drain retired in 0.14.3. The forbid-list literals below live
358    /// in the test section, so `split` excludes them from the implementation
359    /// slice under inspection.
360    #[test]
361    fn shutdown_source_has_no_drain_and_no_reap_count_sleep_loop() {
362        let source = include_str!("shutdown.rs");
363        // `split` always yields a first segment; `unwrap_or` keeps this panic-free
364        // under the workspace lint deny while never falling back in practice.
365        let implementation = source.split("mod tests").next().unwrap_or(source);
366        for forbidden in [
367            "DRAIN_PROGRESS_INTERVAL",
368            "FORCE_CLOSE_SETTLE_TIMEOUT",
369            "FORCE_CLOSE_POLL_INTERVAL",
370            "reap_crashed_connections",
371            "fn drain_connections",
372        ] {
373            assert!(
374                !implementation.contains(forbidden),
375                "retired poll/reap/drain token `{forbidden}` must not appear in the shutdown implementation"
376            );
377        }
378    }
379
380    /// The configured drain timeout reaches the shutdown path and is used for
381    /// exactly one thing: being named in the line that says it is ignored. If a
382    /// future edit re-arms it as a deadline, this goes red.
383    #[test]
384    fn the_configured_drain_timeout_is_never_turned_into_a_deadline() {
385        let source = include_str!("shutdown.rs");
386        let implementation = source.split("mod tests").next().unwrap_or(source);
387        assert!(
388            !implementation.contains("+ drain_timeout"),
389            "drain_timeout must never be added to an Instant to form a shutdown deadline"
390        );
391        assert!(
392            implementation.contains("ignored_drain_timeout = ?drain_timeout"),
393            "drain_timeout must still be named in the shutdown log line that reports it ignored"
394        );
395    }
396}