Skip to main content

liminal_server/server/
runtime.rs

1use std::path::Path;
2use std::sync::Arc;
3
4use crate::ServerError;
5use crate::cluster::{self, ClusterHandle};
6use crate::config::file::load_config;
7use crate::config::types::ClusterConfig;
8use crate::health::{ReadinessState, SharedReadinessState, start_health_server};
9use crate::server::connection::ConnectionSupervisor;
10use crate::server::connection::services::{ChannelCluster, LiminalConnectionServices};
11use crate::server::listener::ServerListener;
12use crate::server::shutdown::{ShutdownHandle, register_signal_handlers, run_shutdown_sequence};
13
14/// Starts the server deployment wrapper for the supplied configuration path.
15///
16/// # Errors
17///
18/// Returns [`ServerError`] when a later server lifecycle phase fails.
19pub fn run(config_path: &Path) -> Result<(), ServerError> {
20    if config_path.as_os_str().is_empty() {
21        return Err(ServerError::ConfigLoad {
22            message: "configuration path is empty".to_owned(),
23        });
24    }
25
26    let config = load_config(config_path)?;
27
28    // Enable metrics for this process before the health server accepts scrapes,
29    // so `/metrics` renders the server families. Standalone liminal library users
30    // never call this, so the registry gate stays off for them.
31    crate::metrics::init();
32
33    let readiness = SharedReadinessState::new(ReadinessState::default());
34    let health_server = start_health_server(config.health_listen_address, readiness.clone())?;
35    let shutdown_handle = ShutdownHandle::new();
36    let signal_registration = register_signal_handlers(shutdown_handle.clone())?;
37
38    // Build the library-backed services once so we can reach the shared (possibly
39    // clustered) channel supervisor before the connection supervisor takes
40    // ownership of the services as a trait object.
41    let services = Arc::new(LiminalConnectionServices::from_config(&config)?);
42    let channel_cluster = services.channel_cluster().clone();
43    // The configured [auth] token must ride along here: this call site builds
44    // services itself (to reach the shared channel cluster first) and so cannot
45    // use from_config, which is the only other place the token is wired.
46    let auth_token = config
47        .auth
48        .as_ref()
49        .map(|auth| auth.token.clone().into_bytes());
50    let connection_supervisor = ConnectionSupervisor::with_services_and_auth(services, auth_token)?;
51
52    // SRV-005: start clustering on the channel-supervisor scheduler when a
53    // [cluster] section is configured. The returned handle owns the inbound
54    // distribution listener and the membership poll loop; it must outlive the
55    // server and is torn down in the shutdown sequence below.
56    readiness.set_cluster_configured(config.cluster.is_some());
57    let cluster_handle = match config.cluster.as_ref() {
58        Some(cluster_config) => Some(start_cluster(&channel_cluster, cluster_config, &readiness)?),
59        None => None,
60    };
61
62    let mut listener = ServerListener::bind(&config, connection_supervisor)?;
63    readiness.set_config_loaded(true);
64    readiness.set_listener_bound(true);
65
66    tracing::debug!(
67        config_path = %config_path.display(),
68        listen_address = %config.listen_address,
69        health_listen_address = %health_server.local_addr(),
70        "liminal server configuration validated"
71    );
72
73    tracing::info!(
74        listen_address = %listener.local_addr(),
75        health_listen_address = %health_server.local_addr(),
76        "liminal server started"
77    );
78
79    shutdown_handle.wait();
80    readiness.set_listener_bound(false);
81
82    // Tear the cluster down before draining connections: stop accepting peer
83    // links and halt the membership poll loop. Each node shuts down independently
84    // (no cluster-wide coordinated shutdown — that boundary belongs to SRV-004).
85    if let Some(mut cluster_handle) = cluster_handle {
86        cluster_handle.shutdown();
87    }
88
89    let supervisor = listener.supervisor();
90    let shutdown_result = run_shutdown_sequence(&mut listener, &supervisor, config.drain_timeout());
91    drop(signal_registration);
92    health_server.shutdown()?;
93    shutdown_result
94}
95
96/// Starts clustering on the shared channel supervisor's scheduler (SRV-005).
97///
98/// Installs the cluster `sync` as the supervisor's [`ClusterObserver`] so channel
99/// subscribe/unsubscribe/publish events drive process-group membership and
100/// cross-node fan-out.
101///
102/// On the success path this marks cluster membership as established on `readiness`
103/// (G2) via [`cluster::start`]'s `on_established` hook, so a clustered server's
104/// `/ready` endpoint transitions from 503 to 200 once the cluster stack is up.
105/// Every early return here (missing resolver, listener bind failure, no reachable
106/// seed) leaves the flag unset, so `/ready` stays 503.
107fn start_cluster(
108    channel_cluster: &ChannelCluster,
109    cluster_config: &ClusterConfig,
110    readiness: &SharedReadinessState,
111) -> Result<ClusterHandle, ServerError> {
112    let resolver = channel_cluster
113        .resolver()
114        .cloned()
115        .ok_or_else(|| ServerError::ClusterJoin {
116            message: "clustering configured but channel supervisor has no distribution resolver"
117                .to_owned(),
118        })?;
119    let scheduler = channel_cluster.supervisor().scheduler();
120    let supervisor = channel_cluster.supervisor().clone();
121    let readiness = readiness.clone();
122    cluster::start(
123        &scheduler,
124        resolver,
125        cluster_config,
126        move |sync| {
127            supervisor.install_observer(Arc::new(sync));
128        },
129        move || readiness.set_cluster_membership_established(true),
130    )
131}
132
133#[cfg(test)]
134mod tests {
135    use std::net::SocketAddr;
136
137    use super::{ChannelCluster, ClusterConfig, SharedReadinessState, start_cluster};
138    use crate::ServerError;
139    use crate::health::{ClusterReadiness, ReadinessCondition, ReadinessState, readiness_check};
140    use crate::server::connection::services::LiminalConnectionServices;
141
142    /// A channel cluster with NO distribution resolver — the shape produced when a
143    /// server was built without a `[cluster]` section. `start_cluster` must reject
144    /// it before touching `cluster::start`, so its `on_established` hook never runs.
145    fn unclustered_channel_cluster() -> Result<ChannelCluster, ServerError> {
146        Ok(LiminalConnectionServices::empty()?
147            .channel_cluster()
148            .clone())
149    }
150
151    fn clustered_but_unmet_readiness() -> SharedReadinessState {
152        SharedReadinessState::new(ReadinessState::new(
153            true,
154            true,
155            ClusterReadiness::Configured {
156                membership_established: false,
157            },
158        ))
159    }
160
161    fn sample_cluster_config() -> Result<ClusterConfig, Box<dyn std::error::Error>> {
162        let listen_address: SocketAddr = "127.0.0.1:0".parse()?;
163        Ok(ClusterConfig {
164            node_name: "node-under-test@127.0.0.1".to_owned(),
165            listen_address,
166            seed_nodes: Vec::new(),
167            cookie: "runtime-test-cookie".to_owned(),
168        })
169    }
170
171    #[test]
172    fn failed_cluster_start_leaves_membership_unestablished()
173    -> Result<(), Box<dyn std::error::Error>> {
174        let readiness = clustered_but_unmet_readiness();
175        let channel_cluster = unclustered_channel_cluster()?;
176        let config = sample_cluster_config()?;
177
178        // Missing-resolver failure path: start_cluster returns Err before the
179        // established hook can fire.
180        let result = start_cluster(&channel_cluster, &config, &readiness);
181        assert!(
182            result.is_err(),
183            "start_cluster must fail without a distribution resolver"
184        );
185
186        // The readiness flag stays unset, so /ready still lists the unmet gate.
187        let status = readiness_check(&readiness.snapshot());
188        assert!(
189            !status.ready,
190            "readiness must remain not-ready after a failed start"
191        );
192        assert!(
193            status
194                .unmet_conditions
195                .contains(&ReadinessCondition::ClusterMembershipEstablished),
196            "cluster membership gate must stay unmet after a failed start"
197        );
198
199        Ok(())
200    }
201}