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