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