Skip to main content

liminal_server/server/
runtime.rs

1use crate::ServerError;
2use crate::auth_pass::PassVerifier;
3use crate::cluster::ClusterHandle;
4use crate::config::file::load_config;
5use crate::config::types::ServiceProfile;
6use crate::health::{ReadinessState, SharedReadinessState, start_health_server};
7use crate::server::connection::WebSocketListener;
8use crate::server::connection::services::{LiminalConnectionServices, build_connection_services};
9use crate::server::connection::{
10    ConnectionSupervisor, LysInstall, configured_lys, start_context_watch,
11};
12use crate::server::listener::ServerListener;
13use crate::server::self_streams::owner::PublisherRuntime;
14use crate::server::shutdown::{ShutdownHandle, register_signal_handlers};
15use crate::services::production::ServiceBindings;
16
17use std::path::Path;
18use std::sync::Arc;
19
20#[path = "runtime_links.rs"]
21pub(in crate::server) mod runtime_links;
22use runtime_links::ConfiguredLinks;
23#[path = "link_udp_endpoint.rs"]
24pub mod link_udp_endpoint;
25
26#[cfg(feature = "test-support")]
27pub use runtime_links::test_support as link_test_support;
28
29fn configured_authentication(
30    config: &crate::config::types::ServerConfig,
31) -> Result<(Option<Vec<u8>>, Option<PassVerifier>), ServerError> {
32    let auth_token = config
33        .auth
34        .as_ref()
35        .map(|auth| auth.token.clone().into_bytes());
36    let pass_verifier = config
37        .auth
38        .as_ref()
39        .map(PassVerifier::from_auth)
40        .transpose()?
41        .flatten();
42    Ok((auth_token, pass_verifier))
43}
44
45/// Rewrites every participant conversation log under the configured
46/// persistence path into the current row grammar, offline.
47///
48/// The store is opened exclusively, so this refuses while a server has it
49/// open. Every conversation is replayed once, rewritten into its next log
50/// generation, proved to replay to the same state, and only then switched.
51///
52/// # Errors
53///
54/// Returns [`ServerError`] when the configuration names no persistence path
55/// or no `[participant]` section, the store cannot be opened, or a
56/// conversation refuses the migration (named in the error).
57pub fn run_participant_log_migration(
58    config_path: &Path,
59) -> Result<crate::server::participant::ParticipantLogMigration, ServerError> {
60    let config = load_config(config_path)?;
61    let Some(persistence_path) = config.persistence_path.as_deref() else {
62        return Err(ServerError::ConfigValidation {
63            message: "the participant log migration needs persistence_path; an ephemeral store has no logs to migrate".to_owned(),
64        });
65    };
66    let Some(participant) = config.participant.as_ref() else {
67        return Err(ServerError::ConfigValidation {
68            message: "the participant log migration needs the [participant] section".to_owned(),
69        });
70    };
71    let store = crate::server::connection::services::open_persistent_store(persistence_path)?;
72    Ok(crate::server::participant::migrate_participant_logs(
73        &store,
74        participant,
75    )?)
76}
77
78/// Starts the server deployment wrapper for the supplied configuration path.
79///
80/// # Errors
81///
82/// Returns [`ServerError`] when a later server lifecycle phase fails.
83pub fn run(config_path: &Path) -> Result<(), ServerError> {
84    let mut stages = StartStages::new();
85    let config = load_config(config_path)?;
86    stages.mark("config");
87
88    // Enable metrics for this process before the health server accepts scrapes,
89    // so `/metrics` renders the server families. Standalone liminal library users
90    // never call this, so the registry gate stays off for them.
91    crate::metrics::init();
92
93    let readiness = SharedReadinessState::new(ReadinessState::default());
94    let health_server = start_health_server(config.health_listen_address, readiness.clone())?;
95    stages.mark("health");
96    let shutdown_handle = ShutdownHandle::new();
97    let signal_registration = register_signal_handlers(shutdown_handle.clone())?;
98
99    // The configured [auth] token must ride along here: these call sites build
100    // services themselves (full mode reaches the shared channel cluster first;
101    // the worker front door builds no cluster at all) and so cannot use
102    // `from_config`, which is the only other place the token is wired.
103    let (auth_token, pass_verifier) = configured_authentication(&config)?;
104    stages.mark("auth");
105    // ACCESS-003 R1 FOUND: the production server built its supervisor by a
106    // path that installed no Lys, so `[auth.lys]` never reached a connection,
107    // and it started with no tokio runtime, so a live ask had nothing to run
108    // on. Both are built here, once, and the Lys install takes the same path
109    // for every profile.
110    let (lys_ask, lys) = runtime_links::lys(&config)?;
111    stages.mark("lys");
112    let FullProfile {
113        connection_supervisor,
114        cluster_handle,
115        mut links,
116        services: typed_services,
117        context_watch,
118        mut self_stream_publisher,
119        spine,
120    } = start_profile(
121        &config,
122        (auth_token, pass_verifier),
123        (lys, lys_ask.as_ref()),
124        &shutdown_handle,
125        &health_server,
126        &readiness,
127        &mut stages,
128    )?;
129
130    // P0 #56 R4: readiness now reports whether the server can ADMIT, not just
131    // whether it finished starting. It is installed here rather than at
132    // `SharedReadinessState::new` above because the health endpoint binds before
133    // the supervisor exists — liveness has to be answerable while the rest of
134    // the server is still being built — so the authority that owns the answer
135    // is not available until now.
136    // SRV-028: installed before any connection is admitted; its processes
137    // stop when the guard drops.
138    let services = super::services_boot::install(
139        &config,
140        &connection_supervisor,
141        lys_ask.as_ref(),
142        typed_services.as_ref(),
143        &health_server,
144    )?;
145    let mut grant_stream =
146        crate::auth_membership::grant_stream::GrantStreamOwner::start_for_server(
147            &config,
148            &connection_supervisor,
149            &health_server,
150            lys_ask.as_ref(),
151            typed_services
152                .as_ref()
153                .and_then(|services| services.draft_decision_hook()),
154        )?;
155    let (mut listener, mut websocket_listener) = open_listeners(
156        &config,
157        connection_supervisor,
158        &readiness,
159        &mut stages,
160        spine.as_ref(),
161    )?;
162
163    // SRV-028 R5: the address and the live restart after the bind.
164    health_server.install_listen_addresses(listener.local_addresses())?;
165    services.listening(listener.local_addr())?;
166    links.start(&health_server.link_bridges(), &listener.supervisor())?;
167    stages.mark("services_registry");
168
169    tracing::debug!(
170        config_path = %config_path.display(),
171        listen_address = %config.listen_address,
172        health_listen_address = %health_server.local_addr(),
173        "liminal server configuration validated"
174    );
175
176    listening::report_started(&listener, &health_server, &stages);
177
178    shutdown_handle.wait();
179    readiness.set_listener_bound(false);
180    // SRV-028: before any connection drains, so a worker leaving with its
181    // connection keeps its service live for the next start.
182    services.shutdown_begun();
183
184    // Tear the cluster down before draining connections: stop accepting peer
185    // links and halt the membership poll loop. Each node shuts down independently
186    // (no cluster-wide coordinated shutdown — that boundary belongs to SRV-004).
187    if let Some(mut cluster_handle) = cluster_handle {
188        cluster_handle.shutdown();
189    }
190
191    let supervisor = listener.supervisor();
192    health_server.release_drafts()?;
193    drop(typed_services);
194    let shutdown_result = runtime_links::shutdown(
195        &supervisor,
196        config.drain_timeout(),
197        || crate::server::shutdown::stop_accepting(&mut listener, websocket_listener.as_mut()),
198        (&mut links, || {
199            let grants = crate::auth_membership::grant_stream::finish_configured(&mut grant_stream);
200            let publisher = finish_self_stream_publisher(&mut self_stream_publisher);
201            grants.and(publisher)
202        }),
203    );
204    let participant_fatal = supervisor.participant_service_fatal();
205    let publisher_shutdown = finish_self_stream_publisher(&mut self_stream_publisher);
206    // The watch stops before the Lys ask runtime it reports through goes.
207    drop(context_watch);
208    drop(lys_ask);
209    drop(websocket_listener);
210    drop(signal_registration);
211    // The health endpoint is torn down before the shutdown-sequence result is
212    // raised: the teardown has to happen either way.
213    raise_in_precedence(
214        health_server.shutdown(),
215        raise_in_precedence(publisher_shutdown, shutdown_result),
216    )?;
217    participant_fatal?.map_or(Ok(()), |fatal| {
218        Err(ServerError::ParticipantServiceFatal { fatal })
219    })
220}
221
222/// What the full profile starts: the connection supervisor over the
223/// production services, and the cluster, context watch and self-stream
224/// publisher beside it when configured.
225struct FullProfile {
226    services: Option<Arc<LiminalConnectionServices>>,
227    links: runtime_links::ConfiguredLinks,
228    connection_supervisor: ConnectionSupervisor,
229    /// The spine store opened held (DUR-021), released after the listeners bind.
230    spine: Option<Arc<haematite::EventStore>>,
231    cluster_handle: Option<ClusterHandle>,
232    context_watch: Option<crate::services::context_watch::ContextWatch>,
233    self_stream_publisher: Option<PublisherRuntime>,
234}
235
236fn start_profile(
237    config: &crate::config::types::ServerConfig,
238    (auth_token, pass_verifier): (Option<Vec<u8>>, Option<PassVerifier>),
239    (lys, lys_ask): (Option<LysInstall>, Option<&tokio::runtime::Runtime>),
240    shutdown: &ShutdownHandle,
241    health: &crate::health::HealthServerHandle,
242    readiness: &SharedReadinessState,
243    stages: &mut StartStages,
244) -> Result<FullProfile, ServerError> {
245    match config.services.profile()? {
246        ServiceProfile::Full => start_full_profile(
247            config,
248            (auth_token, pass_verifier),
249            (lys, lys_ask),
250            shutdown,
251            health,
252            readiness,
253            stages,
254        ),
255        ServiceProfile::WorkerFrontDoor => {
256            let services = build_connection_services(config)?;
257            let connection_supervisor = ConnectionSupervisor::with_lys_and_fatal_shutdown(
258                services,
259                auth_token,
260                pass_verifier,
261                lys,
262                config.limits,
263                shutdown.clone(),
264            )?;
265            readiness.set_cluster_configured(false);
266            stages.mark("supervisor");
267            Ok(FullProfile {
268                services: None,
269                links: ConfiguredLinks::default(),
270                connection_supervisor,
271                spine: None,
272                cluster_handle: None,
273                context_watch: None,
274                self_stream_publisher: None,
275            })
276        }
277    }
278}
279
280/// Starts the full profile: the production connection services, the health
281/// records they publish, the Lys install with its service bindings, the
282/// connection supervisor, the context watch, the self-stream publisher and
283/// clustering when configured.
284fn start_full_profile(
285    config: &crate::config::types::ServerConfig,
286    (auth_token, pass_verifier): (Option<Vec<u8>>, Option<PassVerifier>),
287    (lys, lys_ask): (Option<LysInstall>, Option<&tokio::runtime::Runtime>),
288    shutdown_handle: &ShutdownHandle,
289    health_server: &crate::health::HealthServerHandle,
290    readiness: &SharedReadinessState,
291    stages: &mut StartStages,
292) -> Result<FullProfile, ServerError> {
293    let services = Arc::new(LiminalConnectionServices::from_config_with_runtime(
294        config,
295        lys_ask.map(|runtime| runtime.handle().clone()),
296    )?);
297    stages.mark("services");
298    // DUR-021 R4: the health endpoint reads the spine's reclaim status on each
299    // request and offers the one operator route that quiesces it.
300    let spine = services.durable_store().reclaim_spine();
301    if let Some(spine) = &spine {
302        health_server.install_spine(spine);
303    }
304    install_ephemeral_sweep_report(&services, health_server)?;
305    // Publish the participant's refused-load record onto the health
306    // endpoint. It happens HERE and not at `start_health_server`
307    // because the endpoint binds before the participant exists —
308    // liveness has to be answerable while the rest of the server is
309    // still being built. Construction restores no conversation
310    // (DUR-020): the record fills as conversations restore, on first
311    // touch or in the warm, and is complete once the warm has finished.
312    // The worker-front-
313    // door profile configures no participant and installs nothing, and
314    // the route reports that rather than an empty refusal set.
315    if let Some(record) = services.unloadable_conversation_record() {
316        health_server.install_unloadable_record(record);
317    }
318    // R18 amendment A7 (§0.18): the operator credential-re-issue
319    // authority, published on the same line of reasoning and at the
320    // same moment. A profile with no participant installs nothing and
321    // the route says so.
322    if let Some(reissuer) = services.credential_reissuer() {
323        health_server.install_credential_reissuer(reissuer);
324    }
325    let channel_cluster = services.channel_cluster().clone();
326    let signals_owner = Arc::clone(&services);
327    let lys = lys
328        .map(|install| configured_service_bindings(install, config, &services, lys_ask))
329        .transpose()?;
330    let bindings = lys.as_ref().and_then(|install| install.services.clone());
331    let links = runtime_links::prepare(config, lys.as_ref(), &signals_owner)?;
332    let connection_supervisor = ConnectionSupervisor::with_lys_and_fatal_shutdown(
333        services,
334        auth_token,
335        pass_verifier,
336        lys,
337        config.limits,
338        shutdown_handle.clone(),
339    )?;
340    stages.mark("supervisor");
341    let mut context_watch = None;
342    if let Some(bindings) = bindings.as_deref() {
343        context_watch = start_context_watch(signals_owner.as_ref(), bindings, &config.limits)?;
344        stages.mark("services");
345    }
346
347    let self_stream_publisher = start_self_stream_publisher(
348        &signals_owner,
349        &connection_supervisor,
350        config,
351        shutdown_handle,
352    )?;
353    stages.mark("incarnation");
354
355    // SRV-005: start clustering on the channel-supervisor scheduler when a
356    // [cluster] section is configured. The returned handle owns the inbound
357    // distribution listener and the membership poll loop; it must outlive the
358    // server and is torn down in the shutdown sequence below.
359    let cluster_handle =
360        start_configured_cluster(&channel_cluster, config.cluster.as_ref(), readiness)?;
361    stages.mark("cluster");
362    Ok(FullProfile {
363        services: Some(signals_owner),
364        links,
365        connection_supervisor,
366        spine,
367        cluster_handle,
368        context_watch,
369        self_stream_publisher,
370    })
371}
372
373/// Binds the wire listener and, when configured, its WebSocket sibling.
374/// Readiness reports the listeners bound once both are.
375/// Builds the ACCESS-003 service bindings `[auth.lys.services]` configures
376/// into the Lys install: made as this machine, on the Lys ask runtime,
377/// publishing through the server's own connection services.
378fn configured_service_bindings(
379    mut install: LysInstall,
380    config: &crate::config::types::ServerConfig,
381    services: &Arc<LiminalConnectionServices>,
382    lys_ask: Option<&tokio::runtime::Runtime>,
383) -> Result<LysInstall, ServerError> {
384    let Some(lys) = config.auth.as_ref().and_then(|auth| auth.lys.as_ref()) else {
385        return Ok(install);
386    };
387    let Some(runtime) = lys_ask else {
388        return Err(ServerError::ConfigValidation {
389            message: "auth.lys.services needs the Lys ask runtime; none was started".to_owned(),
390        });
391    };
392    let now = std::time::SystemTime::now()
393        .duration_since(std::time::UNIX_EPOCH)
394        .map_or(0, |elapsed| elapsed.as_secs());
395    install.services =
396        ServiceBindings::configure(lys, Arc::clone(services), runtime.handle().clone(), now)
397            .map_err(|error| ServerError::ConfigValidation {
398                message: format!("auth.lys.services: {error}"),
399            })?
400            .map(Arc::new);
401    Ok(install)
402}
403
404/// The Lys install for the configured `[auth.lys]`, on the ask runtime's
405/// handle when one runs; none when no `[auth]` is configured.
406fn lys_for(
407    config: &crate::config::types::ServerConfig,
408    lys_ask: Option<&tokio::runtime::Runtime>,
409) -> Result<Option<LysInstall>, ServerError> {
410    config
411        .auth
412        .as_ref()
413        .map(|auth| configured_lys(auth, lys_ask.map(|runtime| runtime.handle().clone())))
414        .transpose()
415        .map(Option::flatten)
416}
417
418/// Binds the listeners and, once they are bound, starts the release of the
419/// spine's held reclaim (DUR-021 R1 and R3) on its own thread, with what each
420/// shard resumed on the log: the start does not wait for a shard to finish
421/// its reclaim step to answer.
422fn open_listeners(
423    config: &crate::config::types::ServerConfig,
424    supervisor: ConnectionSupervisor,
425    readiness: &SharedReadinessState,
426    stages: &mut StartStages,
427    spine: Option<&Arc<haematite::EventStore>>,
428) -> Result<(ServerListener, Option<WebSocketListener>), ServerError> {
429    readiness.track_admission(supervisor.admission_readiness());
430    let listeners = bind_listeners(config, supervisor, readiness)?;
431    stages.mark("listeners");
432    crate::server::boot_steps::finished("time_to_listening", stages.began);
433    if let Some(spine) = spine {
434        let release =
435            crate::server::connection::reclaim_placement::release_after_bind(spine, stages.began)?;
436        listeners.0.supervisor().retain_reclaim_release(release)?;
437    }
438    Ok(listeners)
439}
440
441fn bind_listeners(
442    config: &crate::config::types::ServerConfig,
443    supervisor: ConnectionSupervisor,
444    readiness: &SharedReadinessState,
445) -> Result<(ServerListener, Option<WebSocketListener>), ServerError> {
446    let listener = ServerListener::bind(config, supervisor)?;
447    // LP-WS-TRANSPORT R1.1: the sibling WebSocket acceptor is explicit opt-in.
448    // Absent `[websocket]` binds nothing — no HTTP surface exists at all — and
449    // a bind failure fails startup BEFORE readiness reports the listeners
450    // bound, exactly like the main listener.
451    let websocket_listener = match config.websocket.as_ref() {
452        Some(websocket_config) => Some(WebSocketListener::bind(
453            websocket_config,
454            listener.supervisor(),
455        )?),
456        None => None,
457    };
458    readiness.set_config_loaded(true);
459    readiness.set_listener_bound(true);
460    Ok((listener, websocket_listener))
461}
462
463#[path = "runtime/start_stages.rs"]
464mod start_stages;
465use start_stages::StartStages;
466
467mod bootstrap;
468#[cfg(any(test, feature = "test-support"))]
469pub(in crate::server) use bootstrap::bind_self_streams;
470pub(in crate::server) use bootstrap::start_self_stream_publisher;
471use bootstrap::{
472    finish_self_stream_publisher, install_ephemeral_sweep_report, raise_in_precedence,
473    start_configured_cluster,
474};
475#[cfg(test)]
476#[path = "runtime/tests.rs"]
477mod tests;
478
479mod listening;