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