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