Skip to main content

liminal_server/server/
runtime.rs

1use std::fmt;
2use std::path::Path;
3use std::sync::Arc;
4use std::time::Instant;
5
6use crate::ServerError;
7use crate::auth_pass::PassVerifier;
8use crate::cluster::{self, ClusterHandle};
9use crate::config::file::load_config;
10use crate::config::types::{ClusterConfig, ServiceProfile};
11use crate::health::{ReadinessState, SharedReadinessState, start_health_server};
12use crate::server::connection::ConnectionSupervisor;
13use crate::server::connection::WebSocketListener;
14use crate::server::connection::services::{
15    ChannelCluster, LiminalConnectionServices, build_connection_services,
16};
17use crate::server::listener::ServerListener;
18use crate::server::self_streams::cost::ServerMetadata;
19use crate::server::self_streams::owner::PublisherRuntime;
20use crate::server::self_streams::sink::SelfStreams;
21use crate::server::shutdown::{
22    ShutdownHandle, register_signal_handlers, run_shutdown_sequence_before_flush,
23};
24
25fn configured_authentication(
26    config: &crate::config::types::ServerConfig,
27) -> Result<(Option<Vec<u8>>, Option<PassVerifier>), ServerError> {
28    let auth_token = config
29        .auth
30        .as_ref()
31        .map(|auth| auth.token.clone().into_bytes());
32    let pass_verifier = config
33        .auth
34        .as_ref()
35        .map(PassVerifier::from_auth)
36        .transpose()?
37        .flatten();
38    Ok((auth_token, pass_verifier))
39}
40
41/// Rewrites every participant conversation log under the configured
42/// persistence path into the current row grammar, offline.
43///
44/// The store is opened exclusively, so this refuses while a server has it
45/// open. Every conversation is replayed once, rewritten into its next log
46/// generation, proved to replay to the same state, and only then switched.
47///
48/// # Errors
49///
50/// Returns [`ServerError`] when the configuration names no persistence path
51/// or no `[participant]` section, the store cannot be opened, or a
52/// conversation refuses the migration (named in the error).
53pub fn run_participant_log_migration(
54    config_path: &Path,
55) -> Result<crate::server::participant::ParticipantLogMigration, ServerError> {
56    let config = load_config(config_path)?;
57    let Some(persistence_path) = config.persistence_path.as_deref() else {
58        return Err(ServerError::ConfigValidation {
59            message: "the participant log migration needs persistence_path; an ephemeral store has no logs to migrate".to_owned(),
60        });
61    };
62    let Some(participant) = config.participant.as_ref() else {
63        return Err(ServerError::ConfigValidation {
64            message: "the participant log migration needs the [participant] section".to_owned(),
65        });
66    };
67    let store = crate::server::connection::services::open_persistent_store(persistence_path)?;
68    Ok(crate::server::participant::migrate_participant_logs(
69        &store,
70        participant,
71    )?)
72}
73
74/// Starts the server deployment wrapper for the supplied configuration path.
75///
76/// # Errors
77///
78/// Returns [`ServerError`] when a later server lifecycle phase fails.
79pub fn run(config_path: &Path) -> Result<(), ServerError> {
80    let mut stages = StartStages::new();
81    validate_config_path(config_path)?;
82
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
104    // D2: the service profile selects which connection-services stack is built.
105    // Full mode is byte-for-byte the previous construction path (build services,
106    // reach the shared channel cluster, start clustering when configured). The
107    // worker front door constructs the connection supervisor over the
108    // capability-scoped adapter and NOTHING else — no channel/conversation/haematite
109    // services, and therefore no distribution cluster (config validation rejects a
110    // `[cluster]` section under this profile, so none can be present here).
111    let mut self_stream_publisher = None;
112    let (connection_supervisor, cluster_handle) = match config.services.profile()? {
113        ServiceProfile::Full => {
114            let services = Arc::new(LiminalConnectionServices::from_config(&config)?);
115            stages.mark("services");
116            // Publish the participant's refused-load record onto the health
117            // endpoint. It happens HERE and not at `start_health_server`
118            // because the endpoint binds before the participant exists —
119            // liveness has to be answerable while the rest of the server is
120            // still being built. Boot has already recorded every conversation
121            // it refused by the time `from_config` returns, so the first scrape
122            // after this line sees the complete boot answer. The worker-front-
123            // door profile configures no participant and installs nothing, and
124            // the route reports that rather than an empty refusal set.
125            if let Some(record) = services.unloadable_conversation_record() {
126                health_server.install_unloadable_record(record);
127            }
128            // R18 amendment A7 (§0.18): the operator credential-re-issue
129            // authority, published on the same line of reasoning and at the
130            // same moment. A profile with no participant installs nothing and
131            // the route says so.
132            if let Some(reissuer) = services.credential_reissuer() {
133                health_server.install_credential_reissuer(reissuer);
134            }
135            let channel_cluster = services.channel_cluster().clone();
136            let signals_owner = Arc::clone(&services);
137            let connection_supervisor = ConnectionSupervisor::with_fatal_shutdown(
138                services,
139                auth_token,
140                pass_verifier,
141                config.limits,
142                shutdown_handle.clone(),
143            )?;
144            stages.mark("supervisor");
145
146            self_stream_publisher = start_self_stream_publisher(
147                &signals_owner,
148                &connection_supervisor,
149                &config,
150                &shutdown_handle,
151            )?;
152            stages.mark("incarnation");
153
154            // SRV-005: start clustering on the channel-supervisor scheduler when a
155            // [cluster] section is configured. The returned handle owns the inbound
156            // distribution listener and the membership poll loop; it must outlive the
157            // server and is torn down in the shutdown sequence below.
158            let cluster_handle =
159                start_configured_cluster(&channel_cluster, config.cluster.as_ref(), &readiness)?;
160            stages.mark("cluster");
161            (connection_supervisor, cluster_handle)
162        }
163        ServiceProfile::WorkerFrontDoor => {
164            let services = build_connection_services(&config)?;
165            let connection_supervisor = ConnectionSupervisor::with_fatal_shutdown(
166                services,
167                auth_token,
168                pass_verifier,
169                config.limits,
170                shutdown_handle.clone(),
171            )?;
172            readiness.set_cluster_configured(false);
173            stages.mark("supervisor");
174            (connection_supervisor, None)
175        }
176    };
177
178    // P0 #56 R4: readiness now reports whether the server can ADMIT, not just
179    // whether it finished starting. It is installed here rather than at
180    // `SharedReadinessState::new` above because the health endpoint binds before
181    // the supervisor exists — liveness has to be answerable while the rest of
182    // the server is still being built — so the authority that owns the answer
183    // is not available until now.
184    readiness.track_admission(connection_supervisor.admission_readiness());
185
186    let (mut listener, mut websocket_listener) =
187        bind_listeners(&config, connection_supervisor, &readiness)?;
188    stages.mark("listeners");
189
190    tracing::debug!(
191        config_path = %config_path.display(),
192        listen_address = %config.listen_address,
193        health_listen_address = %health_server.local_addr(),
194        "liminal server configuration validated"
195    );
196
197    tracing::info!(
198        listen_address = %listener.local_addr(),
199        health_listen_address = %health_server.local_addr(),
200        start_stages = %stages,
201        "liminal server started"
202    );
203
204    shutdown_handle.wait();
205    readiness.set_listener_bound(false);
206
207    // Tear the cluster down before draining connections: stop accepting peer
208    // links and halt the membership poll loop. Each node shuts down independently
209    // (no cluster-wide coordinated shutdown — that boundary belongs to SRV-004).
210    if let Some(mut cluster_handle) = cluster_handle {
211        cluster_handle.shutdown();
212    }
213
214    let supervisor = listener.supervisor();
215    let shutdown_result = run_shutdown_sequence_before_flush(
216        &mut listener,
217        websocket_listener.as_mut(),
218        &supervisor,
219        config.drain_timeout(),
220        || finish_self_stream_publisher(&mut self_stream_publisher),
221    );
222    let participant_fatal = supervisor.participant_service_fatal();
223    let publisher_shutdown = finish_self_stream_publisher(&mut self_stream_publisher);
224    drop(websocket_listener);
225    drop(signal_registration);
226    // The health endpoint is torn down before the shutdown-sequence result is
227    // raised: the teardown has to happen either way.
228    raise_in_precedence(
229        health_server.shutdown(),
230        raise_in_precedence(publisher_shutdown, shutdown_result),
231    )?;
232    participant_fatal?.map_or(Ok(()), |fatal| {
233        Err(ServerError::ParticipantServiceFatal { fatal })
234    })
235}
236
237/// Binds the wire listener and, when configured, its WebSocket sibling.
238/// Readiness reports the listeners bound once both are.
239fn bind_listeners(
240    config: &crate::config::types::ServerConfig,
241    supervisor: ConnectionSupervisor,
242    readiness: &SharedReadinessState,
243) -> Result<(ServerListener, Option<WebSocketListener>), ServerError> {
244    let listener = ServerListener::bind(config, supervisor)?;
245    // LP-WS-TRANSPORT R1.1: the sibling WebSocket acceptor is explicit opt-in.
246    // Absent `[websocket]` binds nothing — no HTTP surface exists at all — and
247    // a bind failure fails startup BEFORE readiness reports the listeners
248    // bound, exactly like the main listener.
249    let websocket_listener = match config.websocket.as_ref() {
250        Some(websocket_config) => Some(WebSocketListener::bind(
251            websocket_config,
252            listener.supervisor(),
253        )?),
254        None => None,
255    };
256    readiness.set_config_loaded(true);
257    readiness.set_listener_bound(true);
258    Ok((listener, websocket_listener))
259}
260
261/// The start path's stages, each as milliseconds since `run` began, so a
262/// slow start says where its time went. Logged once, on the started line.
263struct StartStages {
264    began: Instant,
265    marks: Vec<(&'static str, u128)>,
266}
267
268impl StartStages {
269    fn new() -> Self {
270        Self {
271            began: Instant::now(),
272            marks: Vec::with_capacity(8),
273        }
274    }
275
276    /// The stage `name` has ended, now.
277    fn mark(&mut self, name: &'static str) {
278        self.marks.push((name, self.began.elapsed().as_millis()));
279    }
280}
281
282/// Each stage with its own length and where it ended: `config 3 (3),
283/// health 1 (4), ...` in milliseconds.
284impl fmt::Display for StartStages {
285    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
286        let mut before = 0;
287        for (index, (name, at)) in self.marks.iter().enumerate() {
288            if index > 0 {
289                formatter.write_str(", ")?;
290            }
291            write!(formatter, "{name} {} ({at})", at.saturating_sub(before))?;
292            before = *at;
293        }
294        Ok(())
295    }
296}
297
298fn validate_config_path(config_path: &Path) -> Result<(), ServerError> {
299    if config_path.as_os_str().is_empty() {
300        return Err(ServerError::ConfigLoad {
301            message: "configuration path is empty".to_owned(),
302        });
303    }
304    Ok(())
305}
306
307pub(in crate::server) fn start_self_stream_publisher(
308    services: &Arc<LiminalConnectionServices>,
309    supervisor: &ConnectionSupervisor,
310    config: &crate::config::types::ServerConfig,
311    shutdown: &ShutdownHandle,
312) -> Result<Option<PublisherRuntime>, ServerError> {
313    bind_self_streams(services, supervisor, config, shutdown)?
314        .map(|streams| {
315            PublisherRuntime::start(services.signals().wake(), streams, shutdown.clone())
316        })
317        .transpose()
318}
319
320fn finish_self_stream_publisher(
321    publisher: &mut Option<PublisherRuntime>,
322) -> Result<(), ServerError> {
323    publisher.as_mut().map_or(Ok(()), PublisherRuntime::finish)
324}
325
326fn start_configured_cluster(
327    channels: &ChannelCluster,
328    config: Option<&ClusterConfig>,
329    readiness: &SharedReadinessState,
330) -> Result<Option<ClusterHandle>, ServerError> {
331    readiness.set_cluster_configured(config.is_some());
332    config
333        .map(|config| start_cluster(channels, config, readiness))
334        .transpose()
335}
336
337/// Binds the shared signals and incarnation-owned subjects before cluster startup.
338pub(in crate::server) fn bind_self_streams(
339    services: &Arc<LiminalConnectionServices>,
340    supervisor: &ConnectionSupervisor,
341    config: &crate::config::types::ServerConfig,
342    shutdown: &ShutdownHandle,
343) -> Result<Option<Arc<SelfStreams>>, ServerError> {
344    let signals = services.signals();
345    supervisor.install_signals(signals.clone())?;
346    let Some(incarnation) = supervisor.current_server_incarnation()? else {
347        crate::metrics::self_streams_disabled();
348        tracing::warn!(
349            "SELF_STREAMS_REQUIRE_PARTICIPANT: self streams disabled because no participant is configured"
350        );
351        // SRV-012 R4: the console streams ride the same publisher owner, so
352        // without one no console subject is registered and a console
353        // subscribe answers CHANNEL_NOT_REGISTERED.
354        crate::metrics::console_streams_disabled();
355        tracing::warn!(
356            "CONSOLE_STREAMS_REQUIRE_PARTICIPANT: console streams disabled because no participant is configured"
357        );
358        return Ok(None);
359    };
360    services.install_self_stream_channels()?;
361    // SRV-012 R7: this device's uptime runs from the incarnation now bound.
362    services
363        .console()
364        .incarnation_started(std::time::Instant::now())
365        .map_err(|error| ServerError::ListenerAccept {
366            message: format!("console hub: {error}"),
367        })?;
368    let metadata = ServerMetadata::from_config(config, incarnation).map_err(|error| {
369        ServerError::ConfigValidation {
370            message: error.to_string(),
371        }
372    })?;
373    let streams = Arc::new(SelfStreams::new(
374        metadata,
375        services,
376        signals.wake(),
377        shutdown.clone(),
378        config.services.self_streams,
379    ));
380    signals.install(Arc::clone(&streams))?;
381    services.install_console_channels()?;
382    signals.enable_console()?;
383    let wake = signals.wake();
384    wake.install_source(services, &streams)?;
385    let counts = services.store_counts();
386    let names = ["durable", "ephemeral-dedup"];
387    let configured = names
388        .get(..counts.store_count())
389        .ok_or_else(|| SelfStreams::failure("unexpected number of counted stores"))?;
390    counts.install_observer(configured, wake).map_err(|error| {
391        SelfStreams::failure(&format!("store count observer installation: {error}"))
392    })?;
393    Ok(Some(streams))
394}
395
396/// Raises the health endpoint's shutdown error ahead of the shutdown
397/// sequence's, a precedence chosen deliberately: a health-shutdown failure takes
398/// the exit code. The sequence error it masks is logged rather than dropped, so
399/// only its exit code goes missing.
400fn raise_in_precedence(
401    health_shutdown: Result<(), ServerError>,
402    shutdown_sequence: Result<(), ServerError>,
403) -> Result<(), ServerError> {
404    if let Err(error) = health_shutdown {
405        if let Err(sequence_error) = &shutdown_sequence {
406            tracing::error!(
407                %sequence_error,
408                "shutdown sequence failed; the health endpoint shutdown failure takes the exit code"
409            );
410        }
411        return Err(error);
412    }
413    shutdown_sequence
414}
415
416/// Starts clustering on the shared channel supervisor's scheduler (SRV-005).
417///
418/// Installs the cluster `sync` as the supervisor's [`ClusterObserver`] so channel
419/// subscribe/unsubscribe/publish events drive process-group membership and
420/// cross-node fan-out.
421///
422/// On the success path this marks cluster membership as established on `readiness`
423/// (G2) via [`cluster::start`]'s `on_established` hook, so a clustered server's
424/// `/ready` endpoint transitions from 503 to 200 once the cluster stack is up.
425/// Every early return here (missing resolver, listener bind failure, no reachable
426/// seed) leaves the flag unset, so `/ready` stays 503.
427fn start_cluster(
428    channel_cluster: &ChannelCluster,
429    cluster_config: &ClusterConfig,
430    readiness: &SharedReadinessState,
431) -> Result<ClusterHandle, ServerError> {
432    let resolver = channel_cluster
433        .resolver()
434        .cloned()
435        .ok_or_else(|| ServerError::ClusterJoin {
436            message: "clustering configured but channel supervisor has no distribution resolver"
437                .to_owned(),
438        })?;
439    let scheduler = channel_cluster.supervisor().scheduler();
440    let observer = Arc::clone(channel_cluster.observer());
441    let readiness = readiness.clone();
442    cluster::start(
443        &scheduler,
444        resolver,
445        cluster_config,
446        move |sync| {
447            // Peers become the console topology's devices and links (R7),
448            // set before the initial view is applied.
449            let peers = Arc::clone(&observer);
450            if !sync.set_peer_listener(Arc::new(move |name, present| peers.peer(name, present))) {
451                tracing::error!("cluster peer listener was already installed");
452            }
453            // The supervisor's one observer forwards to the cluster from now
454            // on (SRV-012 R6); it was installed when the supervisor was built.
455            if let Err(error) = observer.set_cluster(Arc::new(sync)) {
456                tracing::error!(%error, "cluster observer was not installed");
457            }
458        },
459        move || readiness.set_cluster_membership_established(true),
460    )
461}
462
463#[cfg(test)]
464mod tests {
465    use std::net::SocketAddr;
466
467    use super::{ChannelCluster, ClusterConfig, SharedReadinessState, StartStages, start_cluster};
468    use crate::ServerError;
469    use crate::health::{ClusterReadiness, ReadinessCondition, ReadinessState, readiness_check};
470    use crate::server::connection::services::LiminalConnectionServices;
471
472    /// The started line names each stage with its own length and where it
473    /// ended, so a slow start says which stage took the time.
474    #[test]
475    fn start_stages_read_as_lengths_and_ends() {
476        let stages = StartStages {
477            began: std::time::Instant::now(),
478            marks: vec![("config", 3), ("health", 4), ("services", 2_104)],
479        };
480        assert_eq!(
481            stages.to_string(),
482            "config 3 (3), health 1 (4), services 2100 (2104)"
483        );
484    }
485
486    /// A channel cluster with NO distribution resolver — the shape produced when a
487    /// server was built without a `[cluster]` section. `start_cluster` must reject
488    /// it before touching `cluster::start`, so its `on_established` hook never runs.
489    fn unclustered_channel_cluster() -> Result<ChannelCluster, ServerError> {
490        Ok(LiminalConnectionServices::empty()?
491            .channel_cluster()
492            .clone())
493    }
494
495    fn clustered_but_unmet_readiness() -> SharedReadinessState {
496        SharedReadinessState::new(ReadinessState::new(
497            true,
498            true,
499            ClusterReadiness::Configured {
500                membership_established: false,
501            },
502        ))
503    }
504
505    fn sample_cluster_config() -> Result<ClusterConfig, Box<dyn std::error::Error>> {
506        let listen_address: SocketAddr = "127.0.0.1:0".parse()?;
507        Ok(ClusterConfig {
508            node_name: "node-under-test@127.0.0.1".to_owned(),
509            listen_address,
510            seed_nodes: Vec::new(),
511            cookie: "runtime-test-cookie".to_owned(),
512        })
513    }
514
515    #[test]
516    fn failed_cluster_start_leaves_membership_unestablished()
517    -> Result<(), Box<dyn std::error::Error>> {
518        let readiness = clustered_but_unmet_readiness();
519        let channel_cluster = unclustered_channel_cluster()?;
520        let config = sample_cluster_config()?;
521
522        // Missing-resolver failure path: start_cluster returns Err before the
523        // established hook can fire.
524        let result = start_cluster(&channel_cluster, &config, &readiness);
525        assert!(
526            result.is_err(),
527            "start_cluster must fail without a distribution resolver"
528        );
529
530        // The readiness flag stays unset, so /ready still lists the unmet gate.
531        let status = readiness_check(&readiness.snapshot());
532        assert!(
533            !status.ready,
534            "readiness must remain not-ready after a failed start"
535        );
536        assert!(
537            status
538                .unmet_conditions
539                .contains(&ReadinessCondition::ClusterMembershipEstablished),
540            "cluster membership gate must stay unmet after a failed start"
541        );
542
543        Ok(())
544    }
545}