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;