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::{BoundAddresses, 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    // SRV-041 R3: the wire addresses bind before Lys is asked and the store
104    // opens; a client connecting during the build waits in the listen
105    // backlog until accepting starts below, and is never refused for it.
106    let bound =
107        crate::server::boot_steps::timed("listener_bind", || BoundAddresses::bind(&config))?;
108    // ACCESS-003 R1 FOUND: the production server built its supervisor by a
109    // path that installed no Lys, so `[auth.lys]` never reached a connection,
110    // and it started with no tokio runtime, so a live ask had nothing to run
111    // on. Both are built here, once, and the Lys install takes the same path
112    // for every profile.
113    let (lys_ask, lys, mut machine_issue) = runtime_links::lys(&config)?;
114    stages.mark("lys");
115    let FullProfile {
116        connection_supervisor,
117        cluster_handle,
118        mut links,
119        services: typed_services,
120        context_watch,
121        mut self_stream_publisher,
122        spine,
123    } = start_profile(
124        &config,
125        (auth_token, pass_verifier),
126        (lys, lys_ask.as_ref()),
127        &shutdown_handle,
128        &health_server,
129        &readiness,
130        &mut stages,
131    )?;
132
133    // P0 #56 R4: readiness now reports whether the server can ADMIT, not just
134    // whether it finished starting. It is installed here rather than at
135    // `SharedReadinessState::new` above because the health endpoint binds before
136    // the supervisor exists — liveness has to be answerable while the rest of
137    // the server is still being built — so the authority that owns the answer
138    // is not available until now.
139    // SRV-028: installed before any connection is admitted; its processes
140    // stop when the guard drops.
141    let (services, mut grant_stream) = bootstrap::lys_owners(
142        &config,
143        &connection_supervisor,
144        lys_ask.as_ref(),
145        typed_services.as_ref(),
146        &health_server,
147    )?;
148    let (mut listener, mut websocket_listener) =
149        crate::server::boot_steps::timed("accept_start", || {
150            open_listeners(
151                bound,
152                &config,
153                connection_supervisor,
154                &readiness,
155                &mut stages,
156                spine.as_ref(),
157            )
158        })?;
159
160    // SRV-028 R5: the address and the live restart after the bind.
161    health_server.install_listen_addresses(listener.local_addresses())?;
162    services.listening(listener.local_addr())?;
163    machine_issue.begin()?;
164    links.start(&health_server.link_bridges(), &listener.supervisor())?;
165    stages.mark("services_registry");
166
167    tracing::debug!(
168        config_path = %config_path.display(),
169        listen_address = %config.listen_address,
170        health_listen_address = %health_server.local_addr(),
171        "liminal server configuration validated"
172    );
173
174    listening::report_started(&listener, &health_server, &stages);
175
176    shutdown_handle.wait();
177    readiness.set_listener_bound(false);
178    // SRV-028: before any connection drains, so a worker leaving with its
179    // connection keeps its service live for the next start.
180    services.shutdown_begun();
181
182    // Tear the cluster down before draining connections: stop accepting peer
183    // links and halt the membership poll loop. Each node shuts down independently
184    // (no cluster-wide coordinated shutdown — that boundary belongs to SRV-004).
185    if let Some(mut cluster_handle) = cluster_handle {
186        cluster_handle.shutdown();
187    }
188
189    let supervisor = listener.supervisor();
190    health_server.release_drafts()?;
191    drop(typed_services);
192    let shutdown_result = runtime_links::shutdown(
193        &supervisor,
194        config.drain_timeout(),
195        || crate::server::shutdown::stop_accepting(&mut listener, websocket_listener.as_mut()),
196        (&mut links, || {
197            let grants = crate::auth_membership::grant_stream::finish_configured(&mut grant_stream);
198            let publisher = finish_self_stream_publisher(&mut self_stream_publisher);
199            grants.and(publisher)
200        }),
201    );
202    let participant_fatal = supervisor.participant_service_fatal();
203    let start_failure = supervisor.incarnation_start_failure();
204    let publisher_shutdown = finish_self_stream_publisher(&mut self_stream_publisher);
205    // The watch and the pass issue stop before the Lys ask runtime they use goes.
206    drop(context_watch);
207    let issue_finish = machine_issue.finish();
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    bootstrap::run_answer(
214        start_failure,
215        raise_in_precedence(
216            health_server.shutdown(),
217            raise_in_precedence(
218                publisher_shutdown,
219                raise_in_precedence(issue_finish, shutdown_result),
220            ),
221        ),
222        participant_fatal,
223    )
224}
225
226/// What the full profile starts: the connection supervisor over the
227/// production services, and the cluster, context watch and self-stream
228/// publisher beside it when configured.
229struct FullProfile {
230    services: Option<Arc<LiminalConnectionServices>>,
231    links: runtime_links::ConfiguredLinks,
232    connection_supervisor: ConnectionSupervisor,
233    /// The spine store opened held (DUR-021), released after the listeners bind.
234    spine: Option<Arc<haematite::EventStore>>,
235    cluster_handle: Option<ClusterHandle>,
236    context_watch: Option<crate::services::context_watch::ContextWatch>,
237    self_stream_publisher: Option<PublisherRuntime>,
238}
239
240fn start_profile(
241    config: &crate::config::types::ServerConfig,
242    (auth_token, pass_verifier): (Option<Vec<u8>>, Option<PassVerifier>),
243    (lys, lys_ask): (Option<LysInstall>, Option<&tokio::runtime::Runtime>),
244    shutdown: &ShutdownHandle,
245    health: &crate::health::HealthServerHandle,
246    readiness: &SharedReadinessState,
247    stages: &mut StartStages,
248) -> Result<FullProfile, ServerError> {
249    match config.services.profile()? {
250        ServiceProfile::Full => start_full_profile(
251            config,
252            (auth_token, pass_verifier),
253            (lys, lys_ask),
254            shutdown,
255            health,
256            readiness,
257            stages,
258        ),
259        ServiceProfile::WorkerFrontDoor => {
260            let services = build_connection_services(config)?;
261            let connection_supervisor = ConnectionSupervisor::with_lys_and_fatal_shutdown(
262                services,
263                auth_token,
264                pass_verifier,
265                lys,
266                config.limits,
267                shutdown.clone(),
268            )?;
269            readiness.set_cluster_configured(false);
270            stages.mark("supervisor");
271            Ok(FullProfile {
272                services: None,
273                links: ConfiguredLinks::default(),
274                connection_supervisor,
275                spine: None,
276                cluster_handle: None,
277                context_watch: None,
278                self_stream_publisher: None,
279            })
280        }
281    }
282}
283
284/// Starts the full profile: the production connection services, the health
285/// records they publish, the Lys install with its service bindings, the
286/// connection supervisor, the context watch, the self-stream publisher and
287/// clustering when configured.
288fn start_full_profile(
289    config: &crate::config::types::ServerConfig,
290    (auth_token, pass_verifier): (Option<Vec<u8>>, Option<PassVerifier>),
291    (lys, lys_ask): (Option<LysInstall>, Option<&tokio::runtime::Runtime>),
292    shutdown_handle: &ShutdownHandle,
293    health_server: &crate::health::HealthServerHandle,
294    readiness: &SharedReadinessState,
295    stages: &mut StartStages,
296) -> Result<FullProfile, ServerError> {
297    let services = Arc::new(LiminalConnectionServices::from_config_with_runtime(
298        config,
299        lys_ask.map(|runtime| runtime.handle().clone()),
300    )?);
301    stages.mark("services");
302    // DUR-021 R4: the health endpoint reads the spine's reclaim status on each
303    // request and offers the one operator route that quiesces it.
304    let spine = services.durable_store().reclaim_spine();
305    if let Some(spine) = &spine {
306        health_server.install_spine(spine);
307    }
308    install_ephemeral_sweep_report(&services, health_server)?;
309    // Publish the participant's refused-load record onto the health
310    // endpoint. It happens HERE and not at `start_health_server`
311    // because the endpoint binds before the participant exists —
312    // liveness has to be answerable while the rest of the server is
313    // still being built. Construction restores no conversation
314    // (DUR-020): the record fills as conversations restore, on first
315    // touch or in the warm, and is complete once the warm has finished.
316    // The worker-front-
317    // door profile configures no participant and installs nothing, and
318    // the route reports that rather than an empty refusal set.
319    if let Some(record) = services.unloadable_conversation_record() {
320        health_server.install_unloadable_record(record);
321    }
322    // R18 amendment A7 (§0.18): the operator credential-re-issue
323    // authority, published on the same line of reasoning and at the
324    // same moment. A profile with no participant installs nothing and
325    // the route says so.
326    if let Some(reissuer) = services.credential_reissuer() {
327        health_server.install_credential_reissuer(reissuer);
328    }
329    let channel_cluster = services.channel_cluster().clone();
330    let signals_owner = Arc::clone(&services);
331    let lys = lys
332        .map(|install| configured_service_bindings(install, config, &services, lys_ask))
333        .transpose()?;
334    let bindings = lys.as_ref().and_then(|install| install.services.clone());
335    let links = runtime_links::prepare(config, lys.as_ref(), &signals_owner)?;
336    let connection_supervisor = ConnectionSupervisor::with_lys_and_fatal_shutdown(
337        services,
338        auth_token,
339        pass_verifier,
340        lys,
341        config.limits,
342        shutdown_handle.clone(),
343    )?;
344    stages.mark("supervisor");
345    let mut context_watch = None;
346    if let Some(bindings) = bindings.as_deref() {
347        context_watch = start_context_watch(signals_owner.as_ref(), bindings, &config.limits)?;
348        stages.mark("services");
349    }
350
351    let self_stream_publisher = start_self_stream_publisher(
352        &signals_owner,
353        &connection_supervisor,
354        config,
355        shutdown_handle,
356    )?;
357    stages.mark("incarnation");
358
359    // SRV-005: start clustering on the channel-supervisor scheduler when a
360    // [cluster] section is configured. The returned handle owns the inbound
361    // distribution listener and the membership poll loop; it must outlive the
362    // server and is torn down in the shutdown sequence below.
363    let cluster_handle =
364        start_configured_cluster(&channel_cluster, config.cluster.as_ref(), readiness)?;
365    stages.mark("cluster");
366    Ok(FullProfile {
367        services: Some(signals_owner),
368        links,
369        connection_supervisor,
370        spine,
371        cluster_handle,
372        context_watch,
373        self_stream_publisher,
374    })
375}
376
377/// Binds the wire listener and, when configured, its WebSocket sibling.
378/// Readiness reports the listeners bound once both are.
379/// Builds the ACCESS-003 service bindings `[auth.lys.services]` configures
380/// into the Lys install: made as this machine, on the Lys ask runtime,
381/// publishing through the server's own connection services.
382fn configured_service_bindings(
383    mut install: LysInstall,
384    config: &crate::config::types::ServerConfig,
385    services: &Arc<LiminalConnectionServices>,
386    lys_ask: Option<&tokio::runtime::Runtime>,
387) -> Result<LysInstall, ServerError> {
388    let Some(lys) = config.auth.as_ref().and_then(|auth| auth.lys.as_ref()) else {
389        return Ok(install);
390    };
391    let Some(runtime) = lys_ask else {
392        return Err(ServerError::ConfigValidation {
393            message: "auth.lys.services needs the Lys ask runtime; none was started".to_owned(),
394        });
395    };
396    let now = std::time::SystemTime::now()
397        .duration_since(std::time::UNIX_EPOCH)
398        .map_or(0, |elapsed| elapsed.as_secs());
399    install.services =
400        ServiceBindings::configure(lys, Arc::clone(services), runtime.handle().clone(), now)
401            .map_err(|error| ServerError::ConfigValidation {
402                message: format!("auth.lys.services: {error}"),
403            })?
404            .map(Arc::new);
405    Ok(install)
406}
407
408/// Binds the listeners and, once they are bound, starts the release of the
409/// spine's held reclaim (DUR-021 R1 and R3) on its own thread, with what each
410/// shard resumed on the log: the start does not wait for a shard to finish
411/// its reclaim step to answer.
412fn open_listeners(
413    bound: BoundAddresses,
414    config: &crate::config::types::ServerConfig,
415    supervisor: ConnectionSupervisor,
416    readiness: &SharedReadinessState,
417    stages: &mut StartStages,
418    spine: Option<&Arc<haematite::EventStore>>,
419) -> Result<(ServerListener, Option<WebSocketListener>), ServerError> {
420    readiness.track_admission(supervisor.admission_readiness());
421    let listeners = bind_listeners(bound, config, supervisor, readiness)?;
422    stages.mark("listeners");
423    crate::server::boot_steps::finished("time_to_listening", stages.began);
424    if let Some(spine) = spine {
425        let release =
426            crate::server::connection::reclaim_placement::release_after_bind(spine, stages.began)?;
427        listeners.0.supervisor().retain_reclaim_release(release)?;
428    }
429    Ok(listeners)
430}
431
432fn bind_listeners(
433    bound: BoundAddresses,
434    config: &crate::config::types::ServerConfig,
435    supervisor: ConnectionSupervisor,
436    readiness: &SharedReadinessState,
437) -> Result<(ServerListener, Option<WebSocketListener>), ServerError> {
438    let listener = ServerListener::accept_on(bound, supervisor)?;
439    // LP-WS-TRANSPORT R1.1: the sibling WebSocket acceptor is explicit opt-in.
440    // Absent `[websocket]` binds nothing — no HTTP surface exists at all — and
441    // a bind failure fails startup BEFORE readiness reports the listeners
442    // bound, exactly like the main listener.
443    let websocket_listener = match config.websocket.as_ref() {
444        Some(websocket_config) => Some(WebSocketListener::bind(
445            websocket_config,
446            listener.supervisor(),
447        )?),
448        None => None,
449    };
450    readiness.set_config_loaded(true);
451    readiness.set_listener_bound(true);
452    Ok((listener, websocket_listener))
453}
454
455#[path = "runtime/start_stages.rs"]
456mod start_stages;
457use start_stages::StartStages;
458
459mod bootstrap;
460#[cfg(any(test, feature = "test-support"))]
461pub(in crate::server) use bootstrap::bind_self_streams;
462pub(in crate::server) use bootstrap::start_self_stream_publisher;
463use bootstrap::{
464    finish_self_stream_publisher, install_ephemeral_sweep_report, raise_in_precedence,
465    start_configured_cluster,
466};
467#[cfg(test)]
468#[path = "runtime/tests.rs"]
469mod tests;
470
471mod listening;