Skip to main content

liminal_server/health/
endpoint.rs

1use std::io::Write;
2use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream};
3use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
4use std::sync::{Arc, Mutex, OnceLock, PoisonError};
5use std::thread::{self, JoinHandle};
6use std::time::Duration;
7
8use crate::ServerError;
9use crate::server::listener::{
10    acquire_reserve_descriptor, loopback_interrupt_target, prepare_accepted_socket,
11    shed_on_fd_exhaustion,
12};
13
14use super::checks::{SharedReadinessState, health_check, readiness_check};
15use super::reissue::{
16    OperatorCredentialReissueOutcome, OperatorCredentialReissueRefusal,
17    OperatorCredentialReissueRequest, OperatorCredentialReissuer, SharedOperatorCredentialReissue,
18};
19use super::unloadable::{SharedUnloadableConversations, UnloadableConversationRecord};
20
21use super::metrics_route;
22
23const HEALTH_PATH: &str = "/health";
24const READY_PATH: &str = "/ready";
25const METRICS_PATH: &str = "/metrics";
26const UNLOADABLE_PATH: &str = "/unloadable-conversations";
27/// R18 amendment A7 (§0.18): the operator credential re-issue operation.
28const REISSUE_PATH: &str = "/operator/credential-reissue";
29/// DUR-021 R4: each shard's reclaim status, read from the store on each request.
30const RECLAIM_PATH: &str = "/reclaim";
31/// DUR-021 R4: the one operator act that drives a pass to rest.
32const QUIESCE_PATH: &str = "/operator/reclaim-quiesce";
33const APPLICATION_JSON: &str = "application/json";
34const READ_BUFFER_BYTES: usize = 2048;
35
36/// Handle for a running health endpoint server.
37///
38/// W4 leg 2 (§4.2): the health accept worker BLOCKS in `accept` (kernel-parked,
39/// zero idle wakes) rather than spinning a non-blocking poll with a backoff
40/// sleep. Shutdown wakes the blocked `accept` with an explicit self-connect
41/// interrupt to `interrupt_target` (the bound address, loopback-normalised),
42/// mirroring the leg-1 listeners. Shutdown also interrupts an in-flight request
43/// read directly via `active_stream`, so a silent client parked in
44/// `handle_connection`'s read cannot defer shutdown by its admitted deadline.
45#[derive(Debug)]
46pub struct HealthServerHandle {
47    drafts: admin::DraftRoute,
48    grant_stream: Arc<OnceLock<Arc<crate::auth_membership::grant_stream::StreamState>>>,
49    local_addr: SocketAddr,
50    /// Loopback-normalised self-connect target used to interrupt the blocking
51    /// `accept` at shutdown.
52    interrupt_target: SocketAddr,
53    shutdown: Arc<AtomicBool>,
54    /// Slot holding a `try_clone` of the request stream the worker is currently
55    /// reading, if any. The worker registers it under the lock (with a shutdown
56    /// recheck) before blocking on the request read and clears it on completion;
57    /// `stop_worker` takes it and `shutdown(Both)`s it to interrupt an in-flight
58    /// blocking read directly (TOLD) rather than waiting out the read's admitted
59    /// deadline. At most one stream exists at a time (the worker is serial), so
60    /// one slot suffices.
61    active_stream: Arc<Mutex<Option<TcpStream>>>,
62    /// The operator read surface for refused conversation loads, shared with
63    /// the worker. The health server binds before any participant handler
64    /// exists, so this starts empty and the participant's record is published
65    /// into it by [`Self::install_unloadable_record`] once built. Pull-only:
66    /// nothing reads it until a request arrives, so it cannot wake the worker.
67    unloadable: SharedUnloadableConversations,
68    /// The operator WRITE surface for A7 credential re-issue, shared with the
69    /// worker. Empty until the participant is built, exactly like `unloadable`
70    /// above and for the same reason. Call-driven only: nothing reads it until
71    /// a request arrives, so it cannot wake the worker.
72    reissue: SharedOperatorCredentialReissue,
73    /// DUR-021 R4: the spine store, installed once the services exist, read
74    /// on each request to `/reclaim` and driven once by the quiesce route.
75    reclaim: super::reclaim_route::SharedReclaimSpine,
76    ephemeral_sweep: Arc<OnceLock<liminal::durability::EphemeralSweepCounts>>,
77    listen_addresses: Arc<OnceLock<Vec<SocketAddr>>>,
78    link_bridges: crate::link_bridge::BridgeRegistry,
79    worker: Option<JoinHandle<Result<(), ServerError>>>,
80    /// Count of `accept` calls issued by the worker (test observability for the
81    /// zero-idle-wakes oracle: on a silent listener this stays at the single
82    /// parked call). The worker always maintains the counter; only the host-side
83    /// handle for reading it is test-scoped.
84    #[cfg(test)]
85    accept_attempts: Arc<AtomicU64>,
86    /// Count of connections shed under fd exhaustion via the reserve descriptor
87    /// (test observability for the shed helper reused from leg 1).
88    #[cfg(test)]
89    shed_count: Arc<AtomicU64>,
90    /// Count of accepted requests the worker has entered (incremented just
91    /// before it blocks reading the request). Test observability so a race can
92    /// deterministically catch the worker mid-request rather than parked in
93    /// `accept`.
94    #[cfg(test)]
95    requests_entered: Arc<AtomicU64>,
96}
97
98impl HealthServerHandle {
99    /// Shares the owner's live cursor; a second owner is refused before serving.
100    ///
101    /// # Errors
102    /// Names an attempted replacement of the process's installed grant reader.
103    pub fn install_grant_stream(
104        &self,
105        state: Arc<crate::auth_membership::grant_stream::StreamState>,
106    ) -> Result<(), ServerError> {
107        self.grant_stream
108            .set(state)
109            .map_err(|_| ServerError::HealthEndpoint {
110                message: "grant_stream_owner_already_installed".into(),
111            })
112    }
113    /// Publishes all bound wire addresses after the listeners are opened.
114    ///
115    /// # Errors
116    /// Refuses a second publication by name.
117    pub fn install_listen_addresses(&self, addresses: Vec<SocketAddr>) -> Result<(), ServerError> {
118        self.listen_addresses
119            .set(addresses)
120            .map_err(|_| ServerError::HealthEndpoint {
121                message: "wire listen addresses are already installed".to_owned(),
122            })
123    }
124
125    /// The live per-link counters shared with exporting bridge workers.
126    #[must_use]
127    pub fn link_bridges(&self) -> crate::link_bridge::BridgeRegistry {
128        self.link_bridges.clone()
129    }
130    /// Returns the bound address for the health endpoint server.
131    #[must_use]
132    pub const fn local_addr(&self) -> SocketAddr {
133        self.local_addr
134    }
135
136    /// Publishes a participant's unloadable-conversation record onto
137    /// `GET /unloadable-conversations`.
138    ///
139    /// Server startup binds the health endpoint FIRST — liveness has to be
140    /// answerable while the rest of the server is still being built — so the
141    /// record cannot be a constructor argument. Until this is called the route
142    /// answers `participant_installed: false`, which is a different answer from
143    /// "nothing is refused" and is reported as such.
144    ///
145    /// Installing shares the handler's live record rather than copying it: a
146    /// refusal recorded after this call is reported by the next scrape. No
147    /// thread, timer, or notification is created here.
148    /// Publishes the spine store onto `/reclaim` and the quiesce route (DUR-021 R4).
149    pub fn install_spine(&self, spine: &Arc<haematite::EventStore>) {
150        if !self.reclaim.install(spine) {
151            tracing::warn!(
152                "the spine was installed on the health endpoint already; the first stands"
153            );
154        }
155    }
156
157    pub fn install_unloadable_record(&self, record: UnloadableConversationRecord) {
158        self.unloadable.install(record);
159    }
160
161    /// Publishes the participant authority onto
162    /// `POST /operator/credential-reissue` (R18 amendment A7, §0.18).
163    ///
164    /// Same timing constraint as [`Self::install_unloadable_record`]: the
165    /// health endpoint binds before the participant exists, so the authority
166    /// arrives here once built. Until it does the route answers 503 and says
167    /// no participant is installed, which is a different answer from an
168    /// unknown identity and is reported as such. No thread, timer, or
169    /// notification is created here.
170    pub fn install_credential_reissuer(&self, reissuer: Arc<dyn OperatorCredentialReissuer>) {
171        self.reissue.install(reissuer);
172    }
173
174    /// Installs the immutable startup sweep answer.
175    ///
176    /// # Errors
177    /// Returns an endpoint error if startup attempts a second installation.
178    pub fn install_ephemeral_sweep_report(
179        &self,
180        report: liminal::durability::EphemeralSweepCounts,
181    ) -> Result<(), ServerError> {
182        self.ephemeral_sweep
183            .set(report)
184            .map_err(|_| ServerError::HealthEndpoint {
185                message: "ephemeral sweep report is already installed".to_owned(),
186            })
187    }
188
189    /// Stops the health endpoint server and waits for its worker thread to exit.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`ServerError::HealthEndpoint`] if the worker thread cannot be
194    /// joined cleanly or if the server loop recorded a serving error.
195    pub fn shutdown(mut self) -> Result<(), ServerError> {
196        self.stop_worker()
197    }
198
199    fn stop_worker(&mut self) -> Result<(), ServerError> {
200        self.shutdown.store(true, Ordering::SeqCst);
201        let Some(worker) = self.worker.take() else {
202            return Ok(());
203        };
204        // Interrupt an in-flight request read directly (TOLD): the flag store
205        // above happens-before this lock acquire, so any stream the worker
206        // registered before observing the flag is taken here and shut down,
207        // waking its blocked read at once. `shutdown(Both)` on the clone reaches
208        // the same underlying socket the worker is reading (a dup'd fd). If the
209        // worker is instead parked in `accept`, the slot is empty and the
210        // self-connect below wakes it. The guard is released (statement end)
211        // before the socket call, so no lock is held across `shutdown`.
212        let in_flight = self
213            .active_stream
214            .lock()
215            .unwrap_or_else(PoisonError::into_inner)
216            .take();
217        if let Some(stream) = in_flight {
218            let _ = stream.shutdown(Shutdown::Both);
219        }
220        // Explicit cross-platform interrupt (mirrors the leg-1 listeners): a
221        // single self-connect wakes a blocked `accept`. The worker sees the
222        // shutdown flag it observed under the same ordering and sheds the woken
223        // (spurious) socket rather than serving it — at most one spurious accept
224        // per interrupt. If the worker already exited (a real accept then flag
225        // check), the listener is gone and this connect fails fast; the join then
226        // returns immediately. A failure while the worker is still parked in
227        // `accept` is the other case, and there nothing else will wake it — the
228        // join below blocks forever, so it is named rather than swallowed.
229        match TcpStream::connect(self.interrupt_target) {
230            Ok(waker) => drop(waker),
231            Err(error) => tracing::warn!(
232                interrupt_target = %self.interrupt_target,
233                %error,
234                "health endpoint accept interrupt could not connect; the accept \
235                 worker will only stop if it has already exited"
236            ),
237        }
238
239        worker.join().map_err(|_| ServerError::HealthEndpoint {
240            message: "health endpoint worker thread terminated unexpectedly".to_owned(),
241        })?
242    }
243
244    /// `accept` calls issued by the worker (test observability, oracle 8).
245    #[cfg(test)]
246    fn accept_attempts(&self) -> u64 {
247        self.accept_attempts.load(Ordering::SeqCst)
248    }
249
250    /// Connections shed under fd exhaustion (test observability).
251    #[cfg(test)]
252    fn shed_count(&self) -> u64 {
253        self.shed_count.load(Ordering::SeqCst)
254    }
255
256    /// Accepted requests the worker has entered (test observability).
257    #[cfg(test)]
258    fn requests_entered(&self) -> u64 {
259        self.requests_entered.load(Ordering::SeqCst)
260    }
261}
262
263impl Drop for HealthServerHandle {
264    fn drop(&mut self) {
265        if let Err(error) = self.stop_worker() {
266            tracing::debug!(%error, "health endpoint shutdown during drop failed");
267        }
268    }
269}
270
271/// Starts the health endpoint HTTP server on a distinct health bind address.
272///
273/// The returned server handle is independent from the main wire protocol
274/// listener. Binding the health endpoint does not mark the main listener ready.
275///
276/// # Errors
277///
278/// Returns [`ServerError::HealthEndpoint`] when the health listener cannot bind
279/// or cannot report its local address. The listener stays BLOCKING (W4 leg 2):
280/// the accept worker kernel-parks in `accept` with zero idle wakes; shutdown
281/// wakes it via the self-connect interrupt.
282pub fn start_health_server(
283    bind_address: SocketAddr,
284    readiness: SharedReadinessState,
285) -> Result<HealthServerHandle, ServerError> {
286    let started_at = std::time::SystemTime::now()
287        .duration_since(std::time::UNIX_EPOCH)
288        .map_err(|error| ServerError::HealthEndpoint {
289            message: format!("startup clock: {error}"),
290        })?;
291    let started_at =
292        u64::try_from(started_at.as_millis()).map_err(|error| ServerError::HealthEndpoint {
293            message: format!("startup clock: {error}"),
294        })?;
295    let listener =
296        TcpListener::bind(bind_address).map_err(|error| ServerError::HealthEndpoint {
297            message: format!("failed to bind health endpoint at {bind_address}: {error}"),
298        })?;
299    // The listener stays BLOCKING (W4 leg 2): the accept worker kernel-parks in
300    // `accept` with zero idle wakes; shutdown wakes it via the self-connect
301    // interrupt below.
302    let local_addr = listener
303        .local_addr()
304        .map_err(|error| ServerError::HealthEndpoint {
305            message: format!("failed to inspect health endpoint listener address: {error}"),
306        })?;
307    let interrupt_target = loopback_interrupt_target(local_addr);
308    let shutdown = Arc::new(AtomicBool::new(false));
309    let active_stream = Arc::new(Mutex::new(None));
310    let unloadable = SharedUnloadableConversations::default();
311    let reissue = SharedOperatorCredentialReissue::default();
312    let reclaim = super::reclaim_route::SharedReclaimSpine::default();
313    let ephemeral_sweep = Arc::new(OnceLock::new());
314    let listen_addresses = Arc::new(OnceLock::new());
315    let link_bridges = crate::link_bridge::BridgeRegistry::default();
316    let grant_stream = Arc::new(OnceLock::new());
317    let drafts = Arc::new(Mutex::new(None));
318    let accept_attempts = Arc::new(AtomicU64::new(0));
319    let shed_count = Arc::new(AtomicU64::new(0));
320    let requests_entered = Arc::new(AtomicU64::new(0));
321    let worker_shutdown = Arc::clone(&shutdown);
322    let worker_stream = Arc::clone(&active_stream);
323    let worker_attempts = Arc::clone(&accept_attempts);
324    let worker_shed = Arc::clone(&shed_count);
325    let worker_entered = Arc::clone(&requests_entered);
326    let served = ServedState {
327        started_at,
328        drafts: drafts.clone(),
329        grant_stream: Arc::clone(&grant_stream),
330        readiness,
331        unloadable: unloadable.clone(),
332        reissue: reissue.clone(),
333        reclaim: reclaim.clone(),
334        ephemeral_sweep: Arc::clone(&ephemeral_sweep),
335        listen_addresses: Arc::clone(&listen_addresses),
336        link_bridges: link_bridges.clone(),
337    };
338    let worker = thread::spawn(move || {
339        serve(
340            &listener,
341            &served,
342            &worker_shutdown,
343            &worker_stream,
344            &worker_attempts,
345            &worker_shed,
346            &worker_entered,
347        )
348    });
349
350    Ok(HealthServerHandle {
351        drafts,
352        grant_stream,
353        local_addr,
354        interrupt_target,
355        shutdown,
356        active_stream,
357        unloadable,
358        reissue,
359        reclaim,
360        ephemeral_sweep,
361        listen_addresses,
362        link_bridges,
363        worker: Some(worker),
364        #[cfg(test)]
365        accept_attempts,
366        #[cfg(test)]
367        shed_count,
368        #[cfg(test)]
369        requests_entered,
370    })
371}
372
373/// Everything a request is answered from, carried as one value so the worker's
374/// parameter list does not grow a slot per operator surface.
375#[derive(Debug, Clone)]
376struct ServedState {
377    started_at: u64,
378    drafts: admin::DraftRoute,
379    grant_stream: Arc<OnceLock<Arc<crate::auth_membership::grant_stream::StreamState>>>,
380    readiness: SharedReadinessState,
381    unloadable: SharedUnloadableConversations,
382    reissue: SharedOperatorCredentialReissue,
383    reclaim: super::reclaim_route::SharedReclaimSpine,
384    ephemeral_sweep: Arc<OnceLock<liminal::durability::EphemeralSweepCounts>>,
385    listen_addresses: Arc<OnceLock<Vec<SocketAddr>>>,
386    link_bridges: crate::link_bridge::BridgeRegistry,
387}
388
389#[derive(serde::Serialize)]
390struct HealthWithLinks {
391    started_at: u64,
392    grant_stream: Option<crate::auth_membership::grant_stream::StreamHealth>,
393    #[serde(flatten, serialize_with = "health_with_lys_asks")]
394    health: super::checks::HealthStatus,
395    links: std::collections::BTreeMap<String, crate::link_bridge::BridgeCounters>,
396    listen_addresses: Vec<SocketAddr>,
397}
398
399fn health_with_lys_asks<S: serde::Serializer>(
400    health: &super::checks::HealthStatus,
401    serializer: S,
402) -> Result<S::Ok, S::Error> {
403    // Read at serialization so a client installed after the listener starts is
404    // included without rebinding the grant-stream owner's health state.
405    #[derive(serde::Serialize)]
406    struct WithAsks<'a> {
407        #[serde(flatten)]
408        health: &'a super::checks::HealthStatus,
409        lys_ask: crate::auth_lys::live::AskStatistics,
410    }
411    let lys_ask = crate::auth_lys::live::ask_statistics().map_err(serde::ser::Error::custom)?;
412    serde::Serialize::serialize(&WithAsks { health, lys_ask }, serializer)
413}
414
415#[derive(serde::Serialize)]
416struct ReadyWithStream {
417    ready: bool,
418    unmet_conditions: Vec<ReadyCondition>,
419    grant_stream: Option<crate::auth_membership::grant_stream::StreamHealth>,
420}
421
422#[derive(serde::Serialize)]
423#[serde(untagged)]
424enum ReadyCondition {
425    Existing(super::checks::ReadinessCondition),
426    Stream(&'static str),
427}
428
429mod responses;
430mod routes;
431mod serve;
432use responses::{StatusCode, empty_response, json_response, parse_request_line, response};
433use routes::response_for_request;
434use serve::serve;
435
436#[cfg(test)]
437#[path = "endpoint/tests.rs"]
438mod tests;
439
440#[cfg(test)]
441mod lys_ask_health_tests {
442    use super::*;
443
444    #[test]
445    fn health_reports_lys_ask_count_and_running_maximum() -> Result<(), Box<dyn std::error::Error>>
446    {
447        let health = HealthWithLinks {
448            started_at: 1,
449            grant_stream: None,
450            health: health_check(),
451            links: std::collections::BTreeMap::new(),
452            listen_addresses: Vec::new(),
453        };
454        let value = serde_json::to_value(health)?;
455        assert!(value["lys_ask"]["count"].is_u64());
456        assert!(value["lys_ask"]["max_elapsed_ms"].is_f64());
457        assert_eq!(value["status"], "healthy");
458        assert!(value["grant_stream"].is_null());
459        Ok(())
460    }
461}
462
463pub(crate) mod admin;