Skip to main content

clawft_kernel/
mesh_system_service.rs

1//! Mesh as a [`SystemService`] — start/stop/health_check lifecycle (WEFT-119).
2//!
3//! Phase 5d historically spawned the mesh transport without registering a
4//! service in the kernel [`ServiceRegistry`]. Operators could not inspect or
5//! restart mesh through the standard service surface. [`MeshService`] wraps
6//! [`MeshRuntime`] and participates in `start_all` / `stop_all` /
7//! `health_all`.
8
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, Ordering};
11
12use async_trait::async_trait;
13use tracing::info;
14
15use crate::health::HealthStatus;
16use crate::mesh_runtime::MeshRuntime;
17use crate::service::{ServiceType, SystemService};
18
19/// SystemService adapter for the K6 mesh transport.
20///
21/// The accept/connect loops are spawned by kernel boot (phase 5d) and handed
22/// to this service ([`MeshService::adopt_tasks`]), which stops them on `stop`;
23/// it owns the **lifecycle surface**: marking the mesh live,
24/// disconnecting peers on stop, and aggregating peer-health into
25/// [`HealthStatus`] for the weaver service registry.
26pub struct MeshService {
27    runtime: Arc<MeshRuntime>,
28    started: AtomicBool,
29    /// Configured listen address (for diagnostics / health messages).
30    listen_addr: Option<String>,
31    /// Configured transport name (`tcp`, `ws`, …).
32    transport: Option<String>,
33    /// Accept loop and seed dial loops spawned by boot; aborted on `stop`
34    /// and on drop so a stopped mesh neither accepts nor redials.
35    tasks: std::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>,
36}
37
38impl Drop for MeshService {
39    fn drop(&mut self) {
40        self.abort_tasks();
41    }
42}
43
44impl MeshService {
45    /// Take ownership of the mesh loops so `stop` can end them. `stop` is
46    /// terminal for these loops: `start` after `stop` does not respawn them.
47    pub fn adopt_tasks(&self, handles: Vec<tokio::task::JoinHandle<()>>) {
48        self.tasks.lock().unwrap().extend(handles);
49    }
50
51    fn abort_tasks(&self) {
52        for h in self.tasks.lock().unwrap().drain(..) {
53            h.abort();
54        }
55    }
56
57    /// Wrap an existing mesh runtime (listen endpoint unknown).
58    pub fn new(runtime: Arc<MeshRuntime>) -> Self {
59        Self {
60            runtime,
61            started: AtomicBool::new(false),
62            listen_addr: None,
63            transport: None,
64            tasks: Default::default(),
65        }
66    }
67
68    /// Wrap a runtime with endpoint metadata from kernel mesh config.
69    pub fn with_endpoints(
70        runtime: Arc<MeshRuntime>,
71        listen_addr: impl Into<String>,
72        transport: impl Into<String>,
73    ) -> Self {
74        Self {
75            runtime,
76            started: AtomicBool::new(false),
77            listen_addr: Some(listen_addr.into()),
78            transport: Some(transport.into()),
79            tasks: Default::default(),
80        }
81    }
82
83    /// Shared handle to the underlying mesh runtime.
84    pub fn runtime(&self) -> &Arc<MeshRuntime> {
85        &self.runtime
86    }
87
88    /// Whether [`SystemService::start`] has been called more recently
89    /// than [`SystemService::stop`].
90    pub fn is_started(&self) -> bool {
91        self.started.load(Ordering::SeqCst)
92    }
93
94    /// Configured listen address, if provided at construction.
95    pub fn listen_addr(&self) -> Option<&str> {
96        self.listen_addr.as_deref()
97    }
98
99    /// Configured transport name, if provided at construction.
100    pub fn transport_name(&self) -> Option<&str> {
101        self.transport.as_deref()
102    }
103}
104
105#[async_trait]
106impl SystemService for MeshService {
107    fn name(&self) -> &str {
108        "mesh"
109    }
110
111    fn service_type(&self) -> ServiceType {
112        ServiceType::Core
113    }
114
115    async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
116        // Idempotent: boot may call start_all after the listener is already
117        // running; operator restart also re-enters start after stop.
118        let was = self.started.swap(true, Ordering::SeqCst);
119        if was {
120            info!(
121                node = %self.runtime.node_id(),
122                peers = self.runtime.peer_count(),
123                "mesh service start (already running)"
124            );
125            return Ok(());
126        }
127        info!(
128            node = %self.runtime.node_id(),
129            peers = self.runtime.peer_count(),
130            listen = self.listen_addr.as_deref().unwrap_or("-"),
131            transport = self.transport.as_deref().unwrap_or("-"),
132            "mesh service started"
133        );
134        Ok(())
135    }
136
137    async fn stop(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
138        let peer_count = self.runtime.peer_count();
139        // Stop accepting and redialling first, so the disconnect below is
140        // not undone by a seed redial.
141        self.abort_tasks();
142        self.runtime.disconnect_all_peers();
143        self.started.store(false, Ordering::SeqCst);
144        info!(
145            node = %self.runtime.node_id(),
146            peers_disconnected = peer_count,
147            "mesh service stopped"
148        );
149        Ok(())
150    }
151
152    async fn health_check(&self) -> HealthStatus {
153        if !self.started.load(Ordering::SeqCst) {
154            return HealthStatus::Unhealthy("mesh service not started".into());
155        }
156
157        let unhealthy = self.runtime.check_peer_health();
158        let peers = self.runtime.peer_count();
159        if unhealthy.is_empty() {
160            HealthStatus::Healthy
161        } else if peers == 0 || unhealthy.len() >= peers {
162            HealthStatus::Unhealthy(format!(
163                "all tracked peers unhealthy: {}",
164                unhealthy.join(", ")
165            ))
166        } else {
167            HealthStatus::Degraded(format!(
168                "{}/{} peers unhealthy: {}",
169                unhealthy.len(),
170                peers,
171                unhealthy.join(", ")
172            ))
173        }
174    }
175
176    #[cfg(feature = "os-patterns")]
177    async fn liveness_check(&self) -> crate::health::ProbeResult {
178        if self.started.load(Ordering::SeqCst) {
179            crate::health::ProbeResult::Live
180        } else {
181            crate::health::ProbeResult::NotLive {
182                reason: "mesh not started".into(),
183            }
184        }
185    }
186
187    #[cfg(feature = "os-patterns")]
188    async fn readiness_check(&self) -> crate::health::ProbeResult {
189        if self.started.load(Ordering::SeqCst) {
190            crate::health::ProbeResult::Ready
191        } else {
192            crate::health::ProbeResult::NotReady {
193                reason: "mesh not started".into(),
194            }
195        }
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use crate::service::ServiceRegistry;
203
204    #[tokio::test]
205    async fn lifecycle_start_stop_health() {
206        let rt = Arc::new(MeshRuntime::new("node-test".into()));
207        let svc = MeshService::with_endpoints(Arc::clone(&rt), "127.0.0.1:9489", "tcp");
208
209        assert!(!svc.is_started());
210        assert!(matches!(
211            svc.health_check().await,
212            HealthStatus::Unhealthy(_)
213        ));
214
215        svc.start().await.expect("start");
216        assert!(svc.is_started());
217        assert_eq!(svc.health_check().await, HealthStatus::Healthy);
218
219        // Idempotent start
220        svc.start().await.expect("start again");
221        assert!(svc.is_started());
222
223        let (tx, _rx) = tokio::sync::mpsc::channel(4);
224        rt.add_peer("peer-a".into(), tx);
225        assert_eq!(rt.peer_count(), 1);
226
227        svc.stop().await.expect("stop");
228        assert!(!svc.is_started());
229        assert_eq!(rt.peer_count(), 0, "stop disconnects all peers");
230        assert!(matches!(
231            svc.health_check().await,
232            HealthStatus::Unhealthy(_)
233        ));
234    }
235
236    #[tokio::test]
237    async fn registry_start_all_stop_all() {
238        let rt = Arc::new(MeshRuntime::new("node-reg".into()));
239        let svc = Arc::new(MeshService::new(Arc::clone(&rt)));
240        let registry = ServiceRegistry::new();
241        registry.register(svc.clone()).expect("register");
242
243        assert!(registry.get("mesh").is_some());
244        assert_eq!(svc.name(), "mesh");
245        assert_eq!(svc.service_type(), ServiceType::Core);
246
247        registry.start_all().await.expect("start_all");
248        assert!(svc.is_started());
249        let health = registry.health_all().await;
250        let mesh_health = health
251            .iter()
252            .find(|(n, _)| n == "mesh")
253            .map(|(_, h)| h.clone())
254            .expect("mesh in health_all");
255        assert_eq!(mesh_health, HealthStatus::Healthy);
256
257        let (tx, _rx) = tokio::sync::mpsc::channel(4);
258        rt.add_peer("p1".into(), tx);
259        assert_eq!(rt.peer_count(), 1);
260
261        registry.stop_all().await.expect("stop_all");
262        assert!(!svc.is_started());
263        assert_eq!(rt.peer_count(), 0);
264    }
265
266    #[tokio::test]
267    async fn health_unhealthy_when_all_discovery_peers_dead() {
268        use crate::mesh_heartbeat::HeartbeatConfig;
269        use std::time::Duration;
270
271        let mut rt = MeshRuntime::with_discovery("node-d".into(), [1u8; 32]);
272        {
273            let disc = rt.discovery_mut().expect("discovery");
274            // Replace tracker with zero suspect timeout for deterministic dead peers.
275            *disc.heartbeat.lock().unwrap() =
276                crate::mesh_heartbeat::HeartbeatTracker::new(HeartbeatConfig {
277                    suspect_timeout: Duration::from_secs(0),
278                    ..HeartbeatConfig::default()
279                });
280            let mut hb = disc.heartbeat.lock().unwrap();
281            hb.add_peer("dying".into());
282            // Two misses with zero timeout → Dead.
283            hb.record_miss("dying");
284            hb.record_miss("dying");
285            assert_eq!(hb.dead_peers().len(), 1);
286        }
287        let rt = Arc::new(rt);
288        // Connected peer map should also list the peer so Unhealthy (all peers) path hits.
289        let (tx, _rx) = tokio::sync::mpsc::channel(4);
290        rt.add_peer("dying".into(), tx);
291
292        let svc = MeshService::new(Arc::clone(&rt));
293        svc.start().await.unwrap();
294        let status = svc.health_check().await;
295        assert!(
296            matches!(status, HealthStatus::Unhealthy(_)),
297            "expected Unhealthy when sole peer is dead, got {status}"
298        );
299    }
300
301    #[tokio::test]
302    async fn stop_ends_the_dial_and_accept_loops() {
303        let rt = Arc::new(MeshRuntime::new("n".into()));
304        let svc = MeshService::new(Arc::clone(&rt));
305        // A dial loop against a dead address would redial forever.
306        let handles = crate::mesh_serve::connect_seeds(
307            &rt,
308            &["127.0.0.1:1".to_string()],
309            "tcp",
310            None,
311            None,
312        );
313        let aborts: Vec<_> = handles.iter().map(|h| h.abort_handle()).collect();
314        svc.adopt_tasks(handles);
315        svc.start().await.unwrap();
316        assert!(aborts.iter().all(|a| !a.is_finished()));
317        svc.stop().await.unwrap();
318        for _ in 0..2000 {
319            if aborts.iter().all(|a| a.is_finished()) {
320                return;
321            }
322            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
323        }
324        panic!("dial loops still running after stop");
325    }
326
327    #[test]
328    fn endpoints_accessors() {
329        let rt = Arc::new(MeshRuntime::new("n".into()));
330        let svc = MeshService::with_endpoints(Arc::clone(&rt), "0.0.0.0:9489", "ws");
331        assert_eq!(svc.listen_addr(), Some("0.0.0.0:9489"));
332        assert_eq!(svc.transport_name(), Some("ws"));
333        assert_eq!(svc.runtime().node_id(), "n");
334    }
335}