Skip to main content

liminal_server/server/
runtime.rs

1use crate::ServerError;
2use crate::auth_pass::PassVerifier;
3use crate::cluster::{self, ClusterHandle};
4use crate::config::file::load_config;
5use crate::config::types::{ClusterConfig, ServiceProfile};
6use crate::health::{ReadinessState, SharedReadinessState, start_health_server};
7use crate::server::connection::WebSocketListener;
8use crate::server::connection::services::{
9    ChannelCluster, LiminalConnectionServices, build_connection_services,
10};
11use crate::server::connection::{
12    ConnectionSupervisor, LysInstall, configured_lys, lys_ask_runtime, start_context_watch,
13};
14use crate::server::listener::ServerListener;
15use crate::server::self_streams::cost::ServerMetadata;
16use crate::server::self_streams::owner::PublisherRuntime;
17use crate::server::self_streams::sink::SelfStreams;
18use crate::server::shutdown::{
19    ShutdownHandle, register_signal_handlers, run_shutdown_sequence_before_flush,
20};
21use crate::services::production::ServiceBindings;
22
23use std::fmt;
24use std::path::Path;
25use std::sync::Arc;
26use std::time::Instant;
27
28fn configured_authentication(
29    config: &crate::config::types::ServerConfig,
30) -> Result<(Option<Vec<u8>>, Option<PassVerifier>), ServerError> {
31    let auth_token = config
32        .auth
33        .as_ref()
34        .map(|auth| auth.token.clone().into_bytes());
35    let pass_verifier = config
36        .auth
37        .as_ref()
38        .map(PassVerifier::from_auth)
39        .transpose()?
40        .flatten();
41    Ok((auth_token, pass_verifier))
42}
43
44/// Rewrites every participant conversation log under the configured
45/// persistence path into the current row grammar, offline.
46///
47/// The store is opened exclusively, so this refuses while a server has it
48/// open. Every conversation is replayed once, rewritten into its next log
49/// generation, proved to replay to the same state, and only then switched.
50///
51/// # Errors
52///
53/// Returns [`ServerError`] when the configuration names no persistence path
54/// or no `[participant]` section, the store cannot be opened, or a
55/// conversation refuses the migration (named in the error).
56pub fn run_participant_log_migration(
57    config_path: &Path,
58) -> Result<crate::server::participant::ParticipantLogMigration, ServerError> {
59    let config = load_config(config_path)?;
60    let Some(persistence_path) = config.persistence_path.as_deref() else {
61        return Err(ServerError::ConfigValidation {
62            message: "the participant log migration needs persistence_path; an ephemeral store has no logs to migrate".to_owned(),
63        });
64    };
65    let Some(participant) = config.participant.as_ref() else {
66        return Err(ServerError::ConfigValidation {
67            message: "the participant log migration needs the [participant] section".to_owned(),
68        });
69    };
70    let store = crate::server::connection::services::open_persistent_store(persistence_path)?;
71    Ok(crate::server::participant::migrate_participant_logs(
72        &store,
73        participant,
74    )?)
75}
76
77/// Starts the server deployment wrapper for the supplied configuration path.
78///
79/// # Errors
80///
81/// Returns [`ServerError`] when a later server lifecycle phase fails.
82pub fn run(config_path: &Path) -> Result<(), ServerError> {
83    let mut stages = StartStages::new();
84    validate_config_path(config_path)?;
85
86    let config = load_config(config_path)?;
87    stages.mark("config");
88
89    // Enable metrics for this process before the health server accepts scrapes,
90    // so `/metrics` renders the server families. Standalone liminal library users
91    // never call this, so the registry gate stays off for them.
92    crate::metrics::init();
93
94    let readiness = SharedReadinessState::new(ReadinessState::default());
95    let health_server = start_health_server(config.health_listen_address, readiness.clone())?;
96    stages.mark("health");
97    let shutdown_handle = ShutdownHandle::new();
98    let signal_registration = register_signal_handlers(shutdown_handle.clone())?;
99
100    // The configured [auth] token must ride along here: these call sites build
101    // services themselves (full mode reaches the shared channel cluster first;
102    // the worker front door builds no cluster at all) and so cannot use
103    // `from_config`, which is the only other place the token is wired.
104    let (auth_token, pass_verifier) = configured_authentication(&config)?;
105    stages.mark("auth");
106    // ACCESS-003 R1 FOUND: the production server built its supervisor by a
107    // path that installed no Lys, so `[auth.lys]` never reached a connection,
108    // and it started with no tokio runtime, so a live ask had nothing to run
109    // on. Both are built here, once, and the Lys install takes the same path
110    // for every profile.
111    let lys_ask = lys_ask_runtime(config.auth.as_ref())?;
112    let lys = lys_for(&config, lys_ask.as_ref())?;
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) = 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)
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)
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    stages.mark("services_registry");
181
182    tracing::debug!(
183        config_path = %config_path.display(),
184        listen_address = %config.listen_address,
185        health_listen_address = %health_server.local_addr(),
186        "liminal server configuration validated"
187    );
188
189    tracing::info!(
190        listen_address = %listener.local_addr(),
191        health_listen_address = %health_server.local_addr(),
192        start_stages = %stages,
193        // DUR-020 R1: the participant service restores no conversation
194        // before this line; its own line names the registered count.
195        time_to_listening_ms = u64::try_from(stages.began.elapsed().as_millis()).unwrap_or(u64::MAX),
196        "liminal server started"
197    );
198
199    shutdown_handle.wait();
200    readiness.set_listener_bound(false);
201    // SRV-028: before any connection drains, so a worker leaving with its
202    // connection keeps its service live for the next start.
203    services.shutdown_begun();
204
205    // Tear the cluster down before draining connections: stop accepting peer
206    // links and halt the membership poll loop. Each node shuts down independently
207    // (no cluster-wide coordinated shutdown — that boundary belongs to SRV-004).
208    if let Some(mut cluster_handle) = cluster_handle {
209        cluster_handle.shutdown();
210    }
211
212    let supervisor = listener.supervisor();
213    let shutdown_result = run_shutdown_sequence_before_flush(
214        &mut listener,
215        websocket_listener.as_mut(),
216        &supervisor,
217        config.drain_timeout(),
218        || finish_self_stream_publisher(&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    connection_supervisor: ConnectionSupervisor,
243    /// The spine store opened held (DUR-021), released after the listeners bind.
244    spine: Option<Arc<haematite::EventStore>>,
245    cluster_handle: Option<ClusterHandle>,
246    context_watch: Option<crate::services::context_watch::ContextWatch>,
247    self_stream_publisher: Option<PublisherRuntime>,
248}
249
250/// Starts the full profile: the production connection services, the health
251/// records they publish, the Lys install with its service bindings, the
252/// connection supervisor, the context watch, the self-stream publisher and
253/// clustering when configured.
254fn start_full_profile(
255    config: &crate::config::types::ServerConfig,
256    (auth_token, pass_verifier): (Option<Vec<u8>>, Option<PassVerifier>),
257    (lys, lys_ask): (Option<LysInstall>, Option<&tokio::runtime::Runtime>),
258    shutdown_handle: &ShutdownHandle,
259    health_server: &crate::health::HealthServerHandle,
260    readiness: &SharedReadinessState,
261    stages: &mut StartStages,
262) -> Result<FullProfile, ServerError> {
263    let services = Arc::new(LiminalConnectionServices::from_config(config)?);
264    stages.mark("services");
265    // DUR-021 R4: the health endpoint reads the spine's reclaim status on each
266    // request and offers the one operator route that quiesces it.
267    let spine = services.durable_store().reclaim_spine();
268    if let Some(spine) = &spine {
269        health_server.install_spine(spine);
270    }
271    install_ephemeral_sweep_report(&services, health_server)?;
272    // Publish the participant's refused-load record onto the health
273    // endpoint. It happens HERE and not at `start_health_server`
274    // because the endpoint binds before the participant exists —
275    // liveness has to be answerable while the rest of the server is
276    // still being built. Construction restores no conversation
277    // (DUR-020): the record fills as conversations restore, on first
278    // touch or in the warm, and is complete once the warm has finished.
279    // The worker-front-
280    // door profile configures no participant and installs nothing, and
281    // the route reports that rather than an empty refusal set.
282    if let Some(record) = services.unloadable_conversation_record() {
283        health_server.install_unloadable_record(record);
284    }
285    // R18 amendment A7 (§0.18): the operator credential-re-issue
286    // authority, published on the same line of reasoning and at the
287    // same moment. A profile with no participant installs nothing and
288    // the route says so.
289    if let Some(reissuer) = services.credential_reissuer() {
290        health_server.install_credential_reissuer(reissuer);
291    }
292    let channel_cluster = services.channel_cluster().clone();
293    let signals_owner = Arc::clone(&services);
294    let lys = lys
295        .map(|install| configured_service_bindings(install, config, &services, lys_ask))
296        .transpose()?;
297    let bindings = lys.as_ref().and_then(|install| install.services.clone());
298    let connection_supervisor = ConnectionSupervisor::with_lys_and_fatal_shutdown(
299        services,
300        auth_token,
301        pass_verifier,
302        lys,
303        config.limits,
304        shutdown_handle.clone(),
305    )?;
306    stages.mark("supervisor");
307    let mut context_watch = None;
308    if let Some(bindings) = bindings.as_deref() {
309        context_watch = start_context_watch(signals_owner.as_ref(), bindings, &config.limits)?;
310        stages.mark("services");
311    }
312
313    let self_stream_publisher = start_self_stream_publisher(
314        &signals_owner,
315        &connection_supervisor,
316        config,
317        shutdown_handle,
318    )?;
319    stages.mark("incarnation");
320
321    // SRV-005: start clustering on the channel-supervisor scheduler when a
322    // [cluster] section is configured. The returned handle owns the inbound
323    // distribution listener and the membership poll loop; it must outlive the
324    // server and is torn down in the shutdown sequence below.
325    let cluster_handle =
326        start_configured_cluster(&channel_cluster, config.cluster.as_ref(), readiness)?;
327    stages.mark("cluster");
328    Ok(FullProfile {
329        connection_supervisor,
330        spine,
331        cluster_handle,
332        context_watch,
333        self_stream_publisher,
334    })
335}
336
337/// Binds the wire listener and, when configured, its WebSocket sibling.
338/// Readiness reports the listeners bound once both are.
339/// Builds the ACCESS-003 service bindings `[auth.lys.services]` configures
340/// into the Lys install: made as this machine, on the Lys ask runtime,
341/// publishing through the server's own connection services.
342fn configured_service_bindings(
343    mut install: LysInstall,
344    config: &crate::config::types::ServerConfig,
345    services: &Arc<LiminalConnectionServices>,
346    lys_ask: Option<&tokio::runtime::Runtime>,
347) -> Result<LysInstall, ServerError> {
348    let Some(lys) = config.auth.as_ref().and_then(|auth| auth.lys.as_ref()) else {
349        return Ok(install);
350    };
351    let Some(runtime) = lys_ask else {
352        return Err(ServerError::ConfigValidation {
353            message: "auth.lys.services needs the Lys ask runtime; none was started".to_owned(),
354        });
355    };
356    let now = std::time::SystemTime::now()
357        .duration_since(std::time::UNIX_EPOCH)
358        .map_or(0, |elapsed| elapsed.as_secs());
359    install.services =
360        ServiceBindings::configure(lys, Arc::clone(services), runtime.handle().clone(), now)
361            .map_err(|error| ServerError::ConfigValidation {
362                message: format!("auth.lys.services: {error}"),
363            })?
364            .map(Arc::new);
365    Ok(install)
366}
367
368/// The Lys install for the configured `[auth.lys]`, on the ask runtime's
369/// handle when one runs; none when no `[auth]` is configured.
370fn lys_for(
371    config: &crate::config::types::ServerConfig,
372    lys_ask: Option<&tokio::runtime::Runtime>,
373) -> Result<Option<LysInstall>, ServerError> {
374    config
375        .auth
376        .as_ref()
377        .map(|auth| configured_lys(auth, lys_ask.map(|runtime| runtime.handle().clone())))
378        .transpose()
379        .map(Option::flatten)
380}
381
382/// Binds the listeners and, once they are bound, starts the release of the
383/// spine's held reclaim (DUR-021 R1 and R3) on its own thread, with what each
384/// shard resumed on the log: the start does not wait for a shard to finish
385/// its reclaim step to answer.
386fn open_listeners(
387    config: &crate::config::types::ServerConfig,
388    supervisor: ConnectionSupervisor,
389    readiness: &SharedReadinessState,
390    stages: &mut StartStages,
391    spine: Option<&Arc<haematite::EventStore>>,
392) -> Result<(ServerListener, Option<WebSocketListener>), ServerError> {
393    readiness.track_admission(supervisor.admission_readiness());
394    let listeners = bind_listeners(config, supervisor, readiness)?;
395    stages.mark("listeners");
396    if let Some(spine) = spine {
397        crate::server::connection::reclaim_placement::release_after_bind(spine, stages.began);
398    }
399    Ok(listeners)
400}
401
402fn bind_listeners(
403    config: &crate::config::types::ServerConfig,
404    supervisor: ConnectionSupervisor,
405    readiness: &SharedReadinessState,
406) -> Result<(ServerListener, Option<WebSocketListener>), ServerError> {
407    let listener = ServerListener::bind(config, supervisor)?;
408    // LP-WS-TRANSPORT R1.1: the sibling WebSocket acceptor is explicit opt-in.
409    // Absent `[websocket]` binds nothing — no HTTP surface exists at all — and
410    // a bind failure fails startup BEFORE readiness reports the listeners
411    // bound, exactly like the main listener.
412    let websocket_listener = match config.websocket.as_ref() {
413        Some(websocket_config) => Some(WebSocketListener::bind(
414            websocket_config,
415            listener.supervisor(),
416        )?),
417        None => None,
418    };
419    readiness.set_config_loaded(true);
420    readiness.set_listener_bound(true);
421    Ok((listener, websocket_listener))
422}
423
424/// The start path's stages, each as milliseconds since `run` began, so a
425/// slow start says where its time went. Logged once, on the started line.
426struct StartStages {
427    began: Instant,
428    marks: Vec<(&'static str, u128)>,
429}
430
431impl StartStages {
432    fn new() -> Self {
433        Self {
434            began: Instant::now(),
435            marks: Vec::with_capacity(8),
436        }
437    }
438
439    /// The stage `name` has ended, now.
440    fn mark(&mut self, name: &'static str) {
441        self.marks.push((name, self.began.elapsed().as_millis()));
442    }
443}
444
445/// Each stage with its own length and where it ended: `config 3 (3),
446/// health 1 (4), ...` in milliseconds.
447impl fmt::Display for StartStages {
448    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
449        let mut before = 0;
450        for (index, (name, at)) in self.marks.iter().enumerate() {
451            if index > 0 {
452                formatter.write_str(", ")?;
453            }
454            write!(formatter, "{name} {} ({at})", at.saturating_sub(before))?;
455            before = *at;
456        }
457        Ok(())
458    }
459}
460
461fn validate_config_path(config_path: &Path) -> Result<(), ServerError> {
462    if config_path.as_os_str().is_empty() {
463        return Err(ServerError::ConfigLoad {
464            message: "configuration path is empty".to_owned(),
465        });
466    }
467    Ok(())
468}
469
470pub(in crate::server) fn start_self_stream_publisher(
471    services: &Arc<LiminalConnectionServices>,
472    supervisor: &ConnectionSupervisor,
473    config: &crate::config::types::ServerConfig,
474    shutdown: &ShutdownHandle,
475) -> Result<Option<PublisherRuntime>, ServerError> {
476    bind_self_streams(services, supervisor, config, shutdown)?
477        .map(|streams| {
478            PublisherRuntime::start(services.signals().wake(), streams, shutdown.clone())
479        })
480        .transpose()
481}
482
483fn finish_self_stream_publisher(
484    publisher: &mut Option<PublisherRuntime>,
485) -> Result<(), ServerError> {
486    publisher.as_mut().map_or(Ok(()), PublisherRuntime::finish)
487}
488
489fn start_configured_cluster(
490    channels: &ChannelCluster,
491    config: Option<&ClusterConfig>,
492    readiness: &SharedReadinessState,
493) -> Result<Option<ClusterHandle>, ServerError> {
494    readiness.set_cluster_configured(config.is_some());
495    config
496        .map(|config| start_cluster(channels, config, readiness))
497        .transpose()
498}
499
500/// Binds the shared signals and incarnation-owned subjects before cluster startup.
501pub(in crate::server) fn bind_self_streams(
502    services: &Arc<LiminalConnectionServices>,
503    supervisor: &ConnectionSupervisor,
504    config: &crate::config::types::ServerConfig,
505    shutdown: &ShutdownHandle,
506) -> Result<Option<Arc<SelfStreams>>, ServerError> {
507    let signals = services.signals();
508    supervisor.install_signals(signals.clone())?;
509    let Some(incarnation) = supervisor.current_server_incarnation()? else {
510        crate::metrics::self_streams_disabled();
511        tracing::warn!(
512            "SELF_STREAMS_REQUIRE_PARTICIPANT: self streams disabled because no participant is configured"
513        );
514        // SRV-012 R4: the console streams ride the same publisher owner, so
515        // without one no console subject is registered and a console
516        // subscribe answers CHANNEL_NOT_REGISTERED.
517        crate::metrics::console_streams_disabled();
518        tracing::warn!(
519            "CONSOLE_STREAMS_REQUIRE_PARTICIPANT: console streams disabled because no participant is configured"
520        );
521        return Ok(None);
522    };
523    services.install_self_stream_channels()?;
524    // SRV-012 R7: this device's uptime runs from the incarnation now bound.
525    services
526        .console()
527        .incarnation_started(std::time::Instant::now())
528        .map_err(|error| ServerError::ListenerAccept {
529            message: format!("console hub: {error}"),
530        })?;
531    let metadata = ServerMetadata::from_config(config, incarnation).map_err(|error| {
532        ServerError::ConfigValidation {
533            message: error.to_string(),
534        }
535    })?;
536    let streams = Arc::new(SelfStreams::new(
537        metadata,
538        services,
539        signals.wake(),
540        shutdown.clone(),
541        config.services.self_streams,
542    ));
543    signals.install(Arc::clone(&streams))?;
544    services.install_console_channels()?;
545    signals.enable_console()?;
546    let wake = signals.wake();
547    wake.install_source(&streams)?;
548    let counts = services.store_counts();
549    let names = ["durable", "ephemeral-dedup"];
550    let configured = names
551        .get(..counts.store_count())
552        .ok_or_else(|| SelfStreams::failure("unexpected number of counted stores"))?;
553    counts.install_observer(configured, wake).map_err(|error| {
554        SelfStreams::failure(&format!("store count observer installation: {error}"))
555    })?;
556    Ok(Some(streams))
557}
558
559/// Raises the health endpoint's shutdown error ahead of the shutdown
560/// sequence's, a precedence chosen deliberately: a health-shutdown failure takes
561/// the exit code. The sequence error it masks is logged rather than dropped, so
562/// only its exit code goes missing.
563fn raise_in_precedence(
564    health_shutdown: Result<(), ServerError>,
565    shutdown_sequence: Result<(), ServerError>,
566) -> Result<(), ServerError> {
567    if let Err(error) = health_shutdown {
568        if let Err(sequence_error) = &shutdown_sequence {
569            tracing::error!(
570                %sequence_error,
571                "shutdown sequence failed; the health endpoint shutdown failure takes the exit code"
572            );
573        }
574        return Err(error);
575    }
576    shutdown_sequence
577}
578
579/// Starts clustering on the shared channel supervisor's scheduler (SRV-005).
580///
581/// Installs the cluster `sync` as the supervisor's [`ClusterObserver`] so channel
582/// subscribe/unsubscribe/publish events drive process-group membership and
583/// cross-node fan-out.
584///
585/// On the success path this marks cluster membership as established on `readiness`
586/// (G2) via [`cluster::start`]'s `on_established` hook, so a clustered server's
587/// `/ready` endpoint transitions from 503 to 200 once the cluster stack is up.
588/// Every early return here (missing resolver, listener bind failure, no reachable
589/// seed) leaves the flag unset, so `/ready` stays 503.
590fn start_cluster(
591    channel_cluster: &ChannelCluster,
592    cluster_config: &ClusterConfig,
593    readiness: &SharedReadinessState,
594) -> Result<ClusterHandle, ServerError> {
595    let resolver = channel_cluster
596        .resolver()
597        .cloned()
598        .ok_or_else(|| ServerError::ClusterJoin {
599            message: "clustering configured but channel supervisor has no distribution resolver"
600                .to_owned(),
601        })?;
602    let scheduler = channel_cluster.supervisor().scheduler();
603    let observer = Arc::clone(channel_cluster.observer());
604    let readiness = readiness.clone();
605    cluster::start(
606        &scheduler,
607        resolver,
608        cluster_config,
609        move |sync| {
610            // Peers become the console topology's devices and links (R7),
611            // set before the initial view is applied.
612            let peers = Arc::clone(&observer);
613            if !sync.set_peer_listener(Arc::new(move |name, present| peers.peer(name, present))) {
614                tracing::error!("cluster peer listener was already installed");
615            }
616            // The supervisor's one observer forwards to the cluster from now
617            // on (SRV-012 R6); it was installed when the supervisor was built.
618            if let Err(error) = observer.set_cluster(Arc::new(sync)) {
619                tracing::error!(%error, "cluster observer was not installed");
620            }
621        },
622        move || readiness.set_cluster_membership_established(true),
623    )
624}
625
626fn install_ephemeral_sweep_report(
627    services: &LiminalConnectionServices,
628    health: &crate::health::HealthServerHandle,
629) -> Result<(), ServerError> {
630    let report = services
631        .ephemeral_sweep_counts()
632        .ok_or_else(|| ServerError::HealthEndpoint {
633            message: "full services have no ephemeral root startup sweep report".to_owned(),
634        })?;
635    health.install_ephemeral_sweep_report(report)
636}
637
638#[cfg(test)]
639mod tests {
640    use std::net::SocketAddr;
641
642    use super::{ChannelCluster, ClusterConfig, SharedReadinessState, StartStages, start_cluster};
643    use crate::ServerError;
644    use crate::health::{ClusterReadiness, ReadinessCondition, ReadinessState, readiness_check};
645    use crate::server::connection::services::LiminalConnectionServices;
646
647    /// The started line names each stage with its own length and where it
648    /// ended, so a slow start says which stage took the time.
649    #[test]
650    fn start_stages_read_as_lengths_and_ends() {
651        let stages = StartStages {
652            began: std::time::Instant::now(),
653            marks: vec![("config", 3), ("health", 4), ("services", 2_104)],
654        };
655        assert_eq!(
656            stages.to_string(),
657            "config 3 (3), health 1 (4), services 2100 (2104)"
658        );
659    }
660
661    /// A channel cluster with NO distribution resolver — the shape produced when a
662    /// server was built without a `[cluster]` section. `start_cluster` must reject
663    /// it before touching `cluster::start`, so its `on_established` hook never runs.
664    fn unclustered_channel_cluster() -> Result<ChannelCluster, ServerError> {
665        Ok(LiminalConnectionServices::empty()?
666            .channel_cluster()
667            .clone())
668    }
669
670    fn clustered_but_unmet_readiness() -> SharedReadinessState {
671        SharedReadinessState::new(ReadinessState::new(
672            true,
673            true,
674            ClusterReadiness::Configured {
675                membership_established: false,
676            },
677        ))
678    }
679
680    fn sample_cluster_config() -> Result<ClusterConfig, Box<dyn std::error::Error>> {
681        let listen_address: SocketAddr = "127.0.0.1:0".parse()?;
682        Ok(ClusterConfig {
683            node_name: "node-under-test@127.0.0.1".to_owned(),
684            listen_address,
685            seed_nodes: Vec::new(),
686            cookie: "runtime-test-cookie".to_owned(),
687        })
688    }
689
690    #[test]
691    fn failed_cluster_start_leaves_membership_unestablished()
692    -> Result<(), Box<dyn std::error::Error>> {
693        let readiness = clustered_but_unmet_readiness();
694        let channel_cluster = unclustered_channel_cluster()?;
695        let config = sample_cluster_config()?;
696
697        // Missing-resolver failure path: start_cluster returns Err before the
698        // established hook can fire.
699        let result = start_cluster(&channel_cluster, &config, &readiness);
700        assert!(
701            result.is_err(),
702            "start_cluster must fail without a distribution resolver"
703        );
704
705        // The readiness flag stays unset, so /ready still lists the unmet gate.
706        let status = readiness_check(&readiness.snapshot());
707        assert!(
708            !status.ready,
709            "readiness must remain not-ready after a failed start"
710        );
711        assert!(
712            status
713                .unmet_conditions
714                .contains(&ReadinessCondition::ClusterMembershipEstablished),
715            "cluster membership gate must stay unmet after a failed start"
716        );
717
718        Ok(())
719    }
720}