Skip to main content

liminal_server/server/
runtime.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use crate::ServerError;
5use crate::auth_pass::PassVerifier;
6use crate::cluster::{self, ClusterHandle};
7use crate::config::file::load_config;
8use crate::config::types::{ClusterConfig, ServiceProfile};
9use crate::health::{ReadinessState, SharedReadinessState, start_health_server};
10use crate::server::connection::ConnectionSupervisor;
11use crate::server::connection::WebSocketListener;
12use crate::server::connection::services::{
13    ChannelCluster, LiminalConnectionServices, build_connection_services,
14};
15use crate::server::listener::ServerListener;
16use crate::server::shutdown::{ShutdownHandle, register_signal_handlers, run_shutdown_sequence};
17
18fn configured_authentication(
19    config: &crate::config::types::ServerConfig,
20) -> Result<(Option<Vec<u8>>, Option<PassVerifier>), ServerError> {
21    let auth_token = config
22        .auth
23        .as_ref()
24        .map(|auth| auth.token.clone().into_bytes());
25    let pass_verifier = config
26        .auth
27        .as_ref()
28        .and_then(|auth| auth.pass.as_ref())
29        .map(PassVerifier::from_config)
30        .transpose()?;
31    Ok((auth_token, pass_verifier))
32}
33
34/// Starts the server deployment wrapper for the supplied configuration path.
35///
36/// # Errors
37///
38/// Returns [`ServerError`] when a later server lifecycle phase fails.
39pub fn run(config_path: &Path) -> Result<(), ServerError> {
40    if config_path.as_os_str().is_empty() {
41        return Err(ServerError::ConfigLoad {
42            message: "configuration path is empty".to_owned(),
43        });
44    }
45
46    let config = load_config(config_path)?;
47
48    // Enable metrics for this process before the health server accepts scrapes,
49    // so `/metrics` renders the server families. Standalone liminal library users
50    // never call this, so the registry gate stays off for them.
51    crate::metrics::init();
52
53    let readiness = SharedReadinessState::new(ReadinessState::default());
54    let health_server = start_health_server(config.health_listen_address, readiness.clone())?;
55    let shutdown_handle = ShutdownHandle::new();
56    let signal_registration = register_signal_handlers(shutdown_handle.clone())?;
57
58    // The configured [auth] token must ride along here: these call sites build
59    // services themselves (full mode reaches the shared channel cluster first;
60    // the worker front door builds no cluster at all) and so cannot use
61    // `from_config`, which is the only other place the token is wired.
62    let (auth_token, pass_verifier) = configured_authentication(&config)?;
63
64    // D2: the service profile selects which connection-services stack is built.
65    // Full mode is byte-for-byte the previous construction path (build services,
66    // reach the shared channel cluster, start clustering when configured). The
67    // worker front door constructs the connection supervisor over the
68    // capability-scoped adapter and NOTHING else — no channel/conversation/haematite
69    // services, and therefore no distribution cluster (config validation rejects a
70    // `[cluster]` section under this profile, so none can be present here).
71    let (connection_supervisor, cluster_handle) = match config.services.profile()? {
72        ServiceProfile::Full => {
73            let services = Arc::new(LiminalConnectionServices::from_config(&config)?);
74            // Publish the participant's refused-load record onto the health
75            // endpoint. It happens HERE and not at `start_health_server`
76            // because the endpoint binds before the participant exists —
77            // liveness has to be answerable while the rest of the server is
78            // still being built. Boot has already recorded every conversation
79            // it refused by the time `from_config` returns, so the first scrape
80            // after this line sees the complete boot answer. The worker-front-
81            // door profile configures no participant and installs nothing, and
82            // the route reports that rather than an empty refusal set.
83            if let Some(record) = services.unloadable_conversation_record() {
84                health_server.install_unloadable_record(record);
85            }
86            // R18 amendment A7 (§0.18): the operator credential-re-issue
87            // authority, published on the same line of reasoning and at the
88            // same moment. A profile with no participant installs nothing and
89            // the route says so.
90            if let Some(reissuer) = services.credential_reissuer() {
91                health_server.install_credential_reissuer(reissuer);
92            }
93            let channel_cluster = services.channel_cluster().clone();
94            let connection_supervisor = ConnectionSupervisor::with_fatal_shutdown(
95                services,
96                auth_token,
97                pass_verifier,
98                config.limits,
99                shutdown_handle.clone(),
100            )?;
101
102            // SRV-005: start clustering on the channel-supervisor scheduler when a
103            // [cluster] section is configured. The returned handle owns the inbound
104            // distribution listener and the membership poll loop; it must outlive the
105            // server and is torn down in the shutdown sequence below.
106            readiness.set_cluster_configured(config.cluster.is_some());
107            let cluster_handle = match config.cluster.as_ref() {
108                Some(cluster_config) => {
109                    Some(start_cluster(&channel_cluster, cluster_config, &readiness)?)
110                }
111                None => None,
112            };
113            (connection_supervisor, cluster_handle)
114        }
115        ServiceProfile::WorkerFrontDoor => {
116            let services = build_connection_services(&config)?;
117            let connection_supervisor = ConnectionSupervisor::with_fatal_shutdown(
118                services,
119                auth_token,
120                pass_verifier,
121                config.limits,
122                shutdown_handle.clone(),
123            )?;
124            readiness.set_cluster_configured(false);
125            (connection_supervisor, None)
126        }
127    };
128
129    // P0 #56 R4: readiness now reports whether the server can ADMIT, not just
130    // whether it finished starting. It is installed here rather than at
131    // `SharedReadinessState::new` above because the health endpoint binds before
132    // the supervisor exists — liveness has to be answerable while the rest of
133    // the server is still being built — so the authority that owns the answer
134    // is not available until now.
135    readiness.track_admission(connection_supervisor.admission_readiness());
136
137    let mut listener = ServerListener::bind(&config, connection_supervisor)?;
138    // LP-WS-TRANSPORT R1.1: the sibling WebSocket acceptor is explicit opt-in.
139    // Absent `[websocket]` binds nothing — no HTTP surface exists at all — and
140    // a bind failure fails startup BEFORE readiness reports the listeners
141    // bound, exactly like the main listener.
142    let mut websocket_listener = match config.websocket.as_ref() {
143        Some(websocket_config) => Some(WebSocketListener::bind(
144            websocket_config,
145            listener.supervisor(),
146        )?),
147        None => None,
148    };
149    readiness.set_config_loaded(true);
150    readiness.set_listener_bound(true);
151
152    tracing::debug!(
153        config_path = %config_path.display(),
154        listen_address = %config.listen_address,
155        health_listen_address = %health_server.local_addr(),
156        "liminal server configuration validated"
157    );
158
159    tracing::info!(
160        listen_address = %listener.local_addr(),
161        health_listen_address = %health_server.local_addr(),
162        "liminal server started"
163    );
164
165    shutdown_handle.wait();
166    readiness.set_listener_bound(false);
167
168    // Tear the cluster down before draining connections: stop accepting peer
169    // links and halt the membership poll loop. Each node shuts down independently
170    // (no cluster-wide coordinated shutdown — that boundary belongs to SRV-004).
171    if let Some(mut cluster_handle) = cluster_handle {
172        cluster_handle.shutdown();
173    }
174
175    let supervisor = listener.supervisor();
176    let shutdown_result = run_shutdown_sequence(
177        &mut listener,
178        websocket_listener.as_mut(),
179        &supervisor,
180        config.drain_timeout(),
181    );
182    let participant_fatal = supervisor.participant_service_fatal();
183    drop(websocket_listener);
184    drop(signal_registration);
185    health_server.shutdown()?;
186    shutdown_result?;
187    participant_fatal?.map_or(Ok(()), |fatal| {
188        Err(ServerError::ParticipantServiceFatal { fatal })
189    })
190}
191
192/// Starts clustering on the shared channel supervisor's scheduler (SRV-005).
193///
194/// Installs the cluster `sync` as the supervisor's [`ClusterObserver`] so channel
195/// subscribe/unsubscribe/publish events drive process-group membership and
196/// cross-node fan-out.
197///
198/// On the success path this marks cluster membership as established on `readiness`
199/// (G2) via [`cluster::start`]'s `on_established` hook, so a clustered server's
200/// `/ready` endpoint transitions from 503 to 200 once the cluster stack is up.
201/// Every early return here (missing resolver, listener bind failure, no reachable
202/// seed) leaves the flag unset, so `/ready` stays 503.
203fn start_cluster(
204    channel_cluster: &ChannelCluster,
205    cluster_config: &ClusterConfig,
206    readiness: &SharedReadinessState,
207) -> Result<ClusterHandle, ServerError> {
208    let resolver = channel_cluster
209        .resolver()
210        .cloned()
211        .ok_or_else(|| ServerError::ClusterJoin {
212            message: "clustering configured but channel supervisor has no distribution resolver"
213                .to_owned(),
214        })?;
215    let scheduler = channel_cluster.supervisor().scheduler();
216    let supervisor = channel_cluster.supervisor().clone();
217    let readiness = readiness.clone();
218    cluster::start(
219        &scheduler,
220        resolver,
221        cluster_config,
222        move |sync| {
223            supervisor.install_observer(Arc::new(sync));
224        },
225        move || readiness.set_cluster_membership_established(true),
226    )
227}
228
229#[cfg(test)]
230mod tests {
231    use std::net::SocketAddr;
232
233    use super::{ChannelCluster, ClusterConfig, SharedReadinessState, start_cluster};
234    use crate::ServerError;
235    use crate::health::{ClusterReadiness, ReadinessCondition, ReadinessState, readiness_check};
236    use crate::server::connection::services::LiminalConnectionServices;
237
238    /// A channel cluster with NO distribution resolver — the shape produced when a
239    /// server was built without a `[cluster]` section. `start_cluster` must reject
240    /// it before touching `cluster::start`, so its `on_established` hook never runs.
241    fn unclustered_channel_cluster() -> Result<ChannelCluster, ServerError> {
242        Ok(LiminalConnectionServices::empty()?
243            .channel_cluster()
244            .clone())
245    }
246
247    fn clustered_but_unmet_readiness() -> SharedReadinessState {
248        SharedReadinessState::new(ReadinessState::new(
249            true,
250            true,
251            ClusterReadiness::Configured {
252                membership_established: false,
253            },
254        ))
255    }
256
257    fn sample_cluster_config() -> Result<ClusterConfig, Box<dyn std::error::Error>> {
258        let listen_address: SocketAddr = "127.0.0.1:0".parse()?;
259        Ok(ClusterConfig {
260            node_name: "node-under-test@127.0.0.1".to_owned(),
261            listen_address,
262            seed_nodes: Vec::new(),
263            cookie: "runtime-test-cookie".to_owned(),
264        })
265    }
266
267    #[test]
268    fn failed_cluster_start_leaves_membership_unestablished()
269    -> Result<(), Box<dyn std::error::Error>> {
270        let readiness = clustered_but_unmet_readiness();
271        let channel_cluster = unclustered_channel_cluster()?;
272        let config = sample_cluster_config()?;
273
274        // Missing-resolver failure path: start_cluster returns Err before the
275        // established hook can fire.
276        let result = start_cluster(&channel_cluster, &config, &readiness);
277        assert!(
278            result.is_err(),
279            "start_cluster must fail without a distribution resolver"
280        );
281
282        // The readiness flag stays unset, so /ready still lists the unmet gate.
283        let status = readiness_check(&readiness.snapshot());
284        assert!(
285            !status.ready,
286            "readiness must remain not-ready after a failed start"
287        );
288        assert!(
289            status
290                .unmet_conditions
291                .contains(&ReadinessCondition::ClusterMembershipEstablished),
292            "cluster membership gate must stay unmet after a failed start"
293        );
294
295        Ok(())
296    }
297}