Skip to main content

liminal_server/server/
runtime.rs

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