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