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