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.
33pub(crate) const 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        // A test's count of every return from here, taken as this guard
68        // drops, so no test waits on a waiter that left without parking.
69        #[cfg(test)]
70        let _returned = Returned(&self.inner);
71        if self.is_initiated() {
72            return;
73        }
74        let Ok(mut guard) = self.inner.wait_lock.lock() else {
75            // Behaviour unchanged (return as if shutdown was requested), but a
76            // poisoned lock must not masquerade silently as a completed wait.
77            tracing::error!(
78                "shutdown wait lock poisoned; returning from wait as if shutdown was requested"
79            );
80            return;
81        };
82        while !self.is_initiated() {
83            // A test's signal that this waiter is about to release the lock by
84            // parking, so no test waits on a clock for it.
85            #[cfg(test)]
86            self.inner.signal_parked();
87            let Ok(next_guard) = self.inner.waiter.wait(guard) else {
88                tracing::error!(
89                    "shutdown wait condvar reacquired a poisoned lock; returning from wait as if shutdown was requested"
90                );
91                return;
92            };
93            guard = next_guard;
94        }
95    }
96
97    /// Returns whether shutdown has been initiated.
98    #[must_use]
99    pub fn is_initiated(&self) -> bool {
100        self.inner.initiated.load(Ordering::SeqCst)
101    }
102}
103
104impl Default for ShutdownHandle {
105    fn default() -> Self {
106        Self::new()
107    }
108}
109
110impl fmt::Debug for ShutdownHandle {
111    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
112        formatter
113            .debug_struct("ShutdownHandle")
114            .field("initiated", &self.is_initiated())
115            .finish()
116    }
117}
118
119#[derive(Debug)]
120struct ShutdownState {
121    initiated: AtomicBool,
122    wait_lock: Mutex<()>,
123    waiter: Condvar,
124    /// Times a waiter parked on `waiter`, counted under `wait_lock`.
125    #[cfg(test)]
126    parked: std::sync::atomic::AtomicUsize,
127    /// Times `wait` returned, counted under `wait_lock`.
128    #[cfg(test)]
129    returned: std::sync::atomic::AtomicUsize,
130    /// Times `notify` sent its wake to `waiter`.
131    #[cfg(test)]
132    notified: std::sync::atomic::AtomicUsize,
133    /// The test-only condvar `parked` and `returned` are signalled on, kept
134    /// apart from `waiter` so a test build never wakes a production waiter.
135    #[cfg(test)]
136    signal: Condvar,
137    /// The test-only lock `signal` pairs with: `parked` and `returned` rise and
138    /// are signalled under it, and the test helpers wait under it. It is kept
139    /// apart from `wait_lock`, the production lock under test, so a test that
140    /// poisons `wait_lock` on purpose still reads every count, and nothing
141    /// recovers a poisoned guard. Lock order, the only nesting: `wait_lock`,
142    /// then `signal_lock` (in `wait`, around `signal_parked`). Nothing takes
143    /// `signal_lock` and then `wait_lock`.
144    #[cfg(test)]
145    signal_lock: Mutex<()>,
146}
147
148/// Test-only: counts one return from [`ShutdownHandle::wait`] as it drops,
149/// under `signal_lock`, and signals it. It drops after `wait`'s own guard, so
150/// `wait_lock` is not held here.
151#[cfg(test)]
152struct Returned<'a>(&'a ShutdownState);
153
154#[cfg(test)]
155impl Drop for Returned<'_> {
156    fn drop(&mut self) {
157        self.0.count_and_signal(&self.0.returned);
158    }
159}
160
161impl ShutdownState {
162    const fn new() -> Self {
163        Self {
164            initiated: AtomicBool::new(false),
165            wait_lock: Mutex::new(()),
166            waiter: Condvar::new(),
167            #[cfg(test)]
168            parked: std::sync::atomic::AtomicUsize::new(0),
169            #[cfg(test)]
170            returned: std::sync::atomic::AtomicUsize::new(0),
171            #[cfg(test)]
172            notified: std::sync::atomic::AtomicUsize::new(0),
173            #[cfg(test)]
174            signal: Condvar::new(),
175            #[cfg(test)]
176            signal_lock: Mutex::new(()),
177        }
178    }
179
180    /// Test-only: counts a waiter about to park, called under `wait_lock`, and
181    /// signals it on the test condvar.
182    #[cfg(test)]
183    fn signal_parked(&self) {
184        self.count_and_signal(&self.parked);
185    }
186
187    /// Test-only: raises `count` and signals it under `signal_lock`, so a
188    /// helper that read the count under that lock and is about to wait cannot
189    /// miss the signal.
190    ///
191    /// A poisoned `signal_lock` is neither recovered nor a panic (a panic in
192    /// `Returned`'s drop during unwinding would abort the test binary): the
193    /// count rises and the signal goes with no guard, and the helper's next
194    /// lock refuses the poison by name. That arm is unreachable: the lock's
195    /// only holders are this count and the helpers, and none of them panics.
196    #[cfg(test)]
197    fn count_and_signal(&self, count: &std::sync::atomic::AtomicUsize) {
198        let guard = self.signal_lock.lock();
199        count.fetch_add(1, Ordering::SeqCst);
200        self.signal.notify_all();
201        drop(guard);
202    }
203
204    fn notify(&self) {
205        match self.wait_lock.lock() {
206            Ok(_guard) => self.waiter.notify_all(),
207            Err(poisoned) => {
208                tracing::error!("shutdown wait lock poisoned; notifying waiters anyway");
209                // Recover the guard from the poison so the notify_all still
210                // runs under the lock: a bare notify-without-lock could race a
211                // waiter between its under-the-lock `is_initiated` check and
212                // its park, losing the wake. Holding the recovered guard keeps
213                // the initiate-sets-flag-then-notify ordering airtight while a
214                // parked waiter (which released the lock) is still woken.
215                let _guard = poisoned.into_inner();
216                self.waiter.notify_all();
217            }
218        }
219        // A test's count that the wake above was sent, on either arm.
220        #[cfg(test)]
221        self.notified.fetch_add(1, Ordering::SeqCst);
222    }
223}
224
225/// Process-global OS signal registration for graceful shutdown.
226#[derive(Debug)]
227pub struct SignalShutdownRegistration {
228    signal_handle: SignalIteratorHandle,
229    worker: Option<JoinHandle<()>>,
230}
231
232impl SignalShutdownRegistration {
233    const fn new(signal_handle: SignalIteratorHandle, worker: JoinHandle<()>) -> Self {
234        Self {
235            signal_handle,
236            worker: Some(worker),
237        }
238    }
239}
240
241impl Drop for SignalShutdownRegistration {
242    fn drop(&mut self) {
243        self.signal_handle.close();
244        let Some(worker) = self.worker.take() else {
245            return;
246        };
247        if worker.join().is_err() {
248            tracing::warn!("shutdown signal worker terminated unexpectedly");
249        }
250    }
251}
252
253/// Registers SIGTERM and SIGINT handlers that initiate the supplied handle.
254///
255/// # Errors
256/// Returns [`ServerError::ListenerAccept`] when the OS signal registration fails.
257pub fn register_signal_handlers(
258    handle: ShutdownHandle,
259) -> Result<SignalShutdownRegistration, ServerError> {
260    let mut signals =
261        Signals::new([SIGTERM, SIGINT]).map_err(|error| ServerError::ListenerAccept {
262            message: format!("failed to register shutdown signal handlers: {error}"),
263        })?;
264    let signal_handle = signals.handle();
265    let worker = thread::spawn(move || {
266        for signal in signals.forever() {
267            tracing::info!(signal, "received shutdown signal");
268            handle.initiate();
269        }
270    });
271    Ok(SignalShutdownRegistration::new(signal_handle, worker))
272}
273
274/// Runs the shutdown sequence after the handle has been activated.
275///
276/// Five steps, in this order, with no timer between them: stop accepting, close
277/// every connection this server holds, settle the connection fates the close
278/// parked, flush durable channel state, exit.
279///
280/// The optional sibling WebSocket listener (LP-WS-TRANSPORT R1) stops
281/// accepting — and interrupts its in-flight upgrade handshakes — in the same
282/// pre-notification window as the main listener, so no connection on EITHER
283/// transport can slip past the shutdown broadcast. Already-admitted WebSocket
284/// connections live in the shared supervisor and are closed by the same
285/// sequence below.
286///
287/// # The retired drain (0.14.3)
288///
289/// Until 0.14.3 this function waited up to `drain_timeout` for connections to
290/// hang up by themselves before closing them. `drain_timeout` is now IGNORED:
291/// it is still accepted so an existing config file and an existing embedder
292/// call site both keep compiling and loading, and it is logged once, by name,
293/// as ignored. See [`WEDGED_CONNECTION_STOP`] for why the wait bought nothing
294/// and what the one remaining bound is for.
295///
296/// The close itself is unchanged and was always the orderly one: each
297/// connection process enqueues a protocol `Disconnect`, drains its outbound
298/// buffer, completes its connection fate as `ServerShutdown`, and — on the
299/// WebSocket transport — writes a close frame carrying `CloseCode::Away` and
300/// the reason "server shutdown" before exiting `Normal`. A peer reads a
301/// shutdown, never a reset.
302///
303/// # Errors
304/// Returns [`ServerError`] when stop-accepting or durable flush fails, and
305/// [`ServerError::ShutdownUnsettledConnectionFates`] when a parked connection
306/// fate could not be completed before exit.
307pub fn run_shutdown_sequence(
308    listener: &mut ServerListener,
309    websocket_listener: Option<&mut WebSocketListener>,
310    supervisor: &ConnectionSupervisor,
311    drain_timeout: Duration,
312) -> Result<(), ServerError> {
313    run_shutdown_sequence_before_flush(
314        listener,
315        websocket_listener,
316        supervisor,
317        drain_timeout,
318        || Ok(()),
319    )
320}
321
322pub(in crate::server) fn run_shutdown_sequence_before_flush(
323    listener: &mut ServerListener,
324    websocket_listener: Option<&mut WebSocketListener>,
325    supervisor: &ConnectionSupervisor,
326    drain_timeout: Duration,
327    finalize: impl FnOnce() -> Result<(), ServerError>,
328) -> Result<(), ServerError> {
329    crate::server::runtime::runtime_links::shutdown(
330        supervisor,
331        drain_timeout,
332        || stop_accepting(listener, websocket_listener),
333        (
334            &mut crate::server::runtime::runtime_links::ConfiguredLinks::default(),
335            finalize,
336        ),
337    )
338}
339
340pub(in crate::server) fn stop_accepting(
341    listener: &mut ServerListener,
342    websocket_listener: Option<&mut WebSocketListener>,
343) -> Result<(), ServerError> {
344    let websocket_result = websocket_listener.map_or(Ok(()), WebSocketListener::stop_accepting);
345    let wire_result = listener.stop_accepting();
346    match (websocket_result, wire_result) {
347        (Err(first), Err(error)) => {
348            tracing::error!(%error, "wire listeners also failed to stop accepting");
349            Err(first)
350        }
351        (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error),
352        (Ok(()), Ok(())) => Ok(()),
353    }
354}
355
356/// Waits for the closed connections to deliver their exits.
357///
358/// Parks on host removal and native handler disposal, which follows removal
359/// from the scheduler's process table, and returns when the last one lands.
360/// There is no poll loop and no reap scan; [`WEDGED_CONNECTION_STOP`] only stops a wedged process from
361/// holding the restart open.
362///
363/// The name is kept from the pre-0.14.3 shape, where this ran only after a
364/// drain had expired. Since 0.14.3 the close is the sole route, so this runs on
365/// every shutdown.
366pub(crate) fn wait_after_force_close(supervisor: &ConnectionSupervisor) {
367    let deadline = Instant::now() + WEDGED_CONNECTION_STOP;
368    if supervisor.wait_for_connections_drained(deadline) {
369        return;
370    }
371    let remaining = supervisor.active_connection_count();
372    if remaining > 0 {
373        tracing::warn!(
374            active_connections = remaining,
375            stop = ?WEDGED_CONNECTION_STOP,
376            "connections remained active after the wedged-connection stop"
377        );
378    }
379}
380
381fn flush_durable_state(supervisor: &ConnectionSupervisor) -> Result<(), ServerError> {
382    tracing::info!("flushing durable channel state");
383    supervisor.flush_durable_state().map_err(|error| {
384        tracing::error!(%error, "durable state flush failed during shutdown");
385        match error {
386            ServerError::ShutdownFlush { .. } => error,
387            other => ServerError::ShutdownFlush {
388                message: other.to_string(),
389            },
390        }
391    })?;
392    tracing::info!("durable channel state flushed");
393    Ok(())
394}
395
396#[cfg(test)]
397#[path = "shutdown_inflight_tests.rs"]
398mod shutdown_inflight_tests;
399
400#[cfg(test)]
401#[path = "shutdown_tests.rs"]
402mod tests;
403
404#[path = "shutdown_sequence.rs"]
405pub(in crate::server) mod sequence;