Skip to main content

soothe_client/
heartbeat.rs

1//! Daemon heartbeat tracking for aliveness checks.
2
3use std::sync::Mutex;
4use std::thread;
5use std::time::{Duration, Instant};
6
7use serde_json::Value;
8
9use crate::errors::{HeartbeatError, Result};
10use crate::protocol::as_str;
11
12/// Snapshot of daemon health from heartbeat tracking.
13#[derive(Debug, Clone)]
14pub struct DaemonHealth {
15    /// Instant of last received heartbeat (or tracker start if none yet).
16    pub last_heartbeat: Instant,
17    /// Daemon state: `"running"` or `"idle"` (when known).
18    pub state: String,
19    /// Loop id the daemon is processing (if running).
20    pub loop_id: String,
21    /// True if heartbeat received within the alive threshold (or grace period).
22    pub is_alive: bool,
23}
24
25/// Tracks daemon heartbeat events to monitor aliveness.
26#[derive(Debug)]
27pub struct HeartbeatTracker {
28    inner: Mutex<Inner>,
29}
30
31#[derive(Debug)]
32struct Inner {
33    last_heartbeat: Option<Instant>,
34    daemon_state: String,
35    heartbeat_loop_id: String,
36    alive_threshold: Duration,
37    start_time: Instant,
38}
39
40impl HeartbeatTracker {
41    /// Create a tracker with the default 15s alive threshold.
42    pub fn new() -> Self {
43        Self::with_threshold(Duration::from_secs(15))
44    }
45
46    /// Create a tracker with a custom alive threshold.
47    pub fn with_threshold(alive_threshold: Duration) -> Self {
48        Self {
49            inner: Mutex::new(Inner {
50                last_heartbeat: None,
51                daemon_state: String::new(),
52                heartbeat_loop_id: String::new(),
53                alive_threshold,
54                start_time: Instant::now(),
55            }),
56        }
57    }
58
59    /// Process a heartbeat event payload (typically `event["data"]`).
60    pub fn update(&self, heartbeat_data: Option<&Value>) {
61        let mut g = self.inner.lock().expect("heartbeat lock");
62        g.last_heartbeat = Some(Instant::now());
63        if let Some(data) = heartbeat_data.and_then(|v| v.as_object()) {
64            if let Some(state) = data.get("state").and_then(|v| v.as_str()) {
65                g.daemon_state = state.to_string();
66            }
67            if let Some(loop_id) = data.get("loop_id").and_then(|v| v.as_str()) {
68                g.heartbeat_loop_id = loop_id.to_string();
69            }
70        }
71    }
72
73    /// Record that an application-level pong was received.
74    pub fn note_pong(&self) {
75        let mut g = self.inner.lock().expect("heartbeat lock");
76        g.last_heartbeat = Some(Instant::now());
77    }
78
79    /// Current daemon health snapshot.
80    pub fn get_health(&self) -> DaemonHealth {
81        let g = self.inner.lock().expect("heartbeat lock");
82        let now = Instant::now();
83        let last = g.last_heartbeat.unwrap_or(g.start_time);
84        let grace = Duration::from_secs(20);
85        let is_alive = now.duration_since(last) < g.alive_threshold
86            || now.duration_since(g.start_time) < grace;
87        DaemonHealth {
88            last_heartbeat: last,
89            state: g.daemon_state.clone(),
90            loop_id: g.heartbeat_loop_id.clone(),
91            is_alive,
92        }
93    }
94
95    /// True when daemon state is `"running"`.
96    pub fn is_processing(&self) -> bool {
97        self.inner.lock().expect("heartbeat lock").daemon_state == "running"
98    }
99
100    /// True when daemon state is `"idle"`.
101    pub fn is_idle(&self) -> bool {
102        self.inner.lock().expect("heartbeat lock").daemon_state == "idle"
103    }
104
105    /// Current daemon state (e.g. `"running"`, `"idle"`, or empty when unknown).
106    pub fn get_state(&self) -> String {
107        self.inner
108            .lock()
109            .expect("heartbeat lock")
110            .daemon_state
111            .clone()
112    }
113
114    /// Loop id the daemon is currently processing (empty when idle or unknown).
115    pub fn get_loop_id(&self) -> String {
116        self.inner
117            .lock()
118            .expect("heartbeat lock")
119            .heartbeat_loop_id
120            .clone()
121    }
122
123    /// Instant of the last received heartbeat (or tracker start if none yet).
124    pub fn get_last_heartbeat(&self) -> Instant {
125        let g = self.inner.lock().expect("heartbeat lock");
126        g.last_heartbeat.unwrap_or(g.start_time)
127    }
128
129    /// Update the alive threshold duration.
130    pub fn set_alive_threshold(&self, threshold: Duration) {
131        self.inner.lock().expect("heartbeat lock").alive_threshold = threshold;
132    }
133
134    /// Clear the tracker state and reset the start time.
135    ///
136    /// Useful when reconnecting to the daemon.
137    pub fn reset(&self) {
138        let mut g = self.inner.lock().expect("heartbeat lock");
139        g.last_heartbeat = None;
140        g.daemon_state.clear();
141        g.heartbeat_loop_id.clear();
142        g.start_time = Instant::now();
143    }
144
145    /// Process a full event message and extract heartbeat data.
146    ///
147    /// Returns `true` if the event was recognized as a heartbeat and processed.
148    /// Catalog daemon heartbeats (`soothe.internal.*`) are server-only and are
149    /// not broadcast to WebSocket clients, so this currently returns `false` for
150    /// all event-shape messages — but the hook exists for parity with the Go
151    /// client and future extensibility.
152    pub fn process_heartbeat_event(&self, event: &Value) -> bool {
153        let Some(obj) = event.as_object() else {
154            return false;
155        };
156        let event_type = as_str(obj.get("type").unwrap_or(&Value::Null));
157        if event_type != "event" {
158            return false;
159        }
160        // Future: surface any client-visible heartbeat namespace here.
161        let _data = obj.get("data").unwrap_or(&Value::Null);
162        false
163    }
164
165    /// Block until the daemon is considered alive or `timeout` is reached.
166    ///
167    /// Returns `Ok(())` when alive, or `Err(HeartbeatError)` on timeout.
168    /// Uses a 1-second polling interval (mirrors Go `WaitForAlive`).
169    pub fn wait_for_alive(&self, timeout: Duration) -> Result<()> {
170        let timeout = if timeout.is_zero() {
171            Duration::from_secs(30)
172        } else {
173            timeout
174        };
175        let deadline = Instant::now() + timeout;
176        loop {
177            let health = self.get_health();
178            if health.is_alive {
179                return Ok(());
180            }
181            if Instant::now() >= deadline {
182                return Err(HeartbeatError::new(
183                    Some(health.last_heartbeat),
184                    health.state,
185                    "timeout waiting for daemon to be alive",
186                )
187                .into());
188            }
189            // Poll every 1s (Go parity).
190            thread::sleep(Duration::from_secs(1));
191        }
192    }
193}
194
195impl Default for HeartbeatTracker {
196    fn default() -> Self {
197        Self::new()
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn reset_clears_state() {
207        let t = HeartbeatTracker::new();
208        t.update(Some(
209            &serde_json::json!({"state":"running","loop_id":"abc"}),
210        ));
211        assert_eq!(t.get_state(), "running");
212        assert_eq!(t.get_loop_id(), "abc");
213        t.reset();
214        assert_eq!(t.get_state(), "");
215        assert_eq!(t.get_loop_id(), "");
216    }
217
218    #[test]
219    fn process_heartbeat_event_rejects_non_event() {
220        let t = HeartbeatTracker::new();
221        assert!(!t.process_heartbeat_event(&serde_json::json!({"type":"status"})));
222        assert!(!t.process_heartbeat_event(&serde_json::json!({"type":"event","data":{}})));
223    }
224
225    #[test]
226    fn set_alive_threshold_changes_health() {
227        let t = HeartbeatTracker::with_threshold(Duration::from_secs(60));
228        t.set_alive_threshold(Duration::from_millis(1));
229        // After 5ms the tracker is no longer alive (no heartbeat received).
230        std::thread::sleep(Duration::from_millis(5));
231        let h = t.get_health();
232        // Grace period (20s) keeps it alive initially.
233        assert!(h.is_alive);
234    }
235
236    #[test]
237    fn grace_period_alive() {
238        let t = HeartbeatTracker::new();
239        assert!(t.get_health().is_alive);
240    }
241
242    #[test]
243    fn get_state_and_loop_id_default_empty() {
244        let t = HeartbeatTracker::new();
245        assert_eq!(t.get_state(), "");
246        assert_eq!(t.get_loop_id(), "");
247    }
248
249    #[test]
250    fn last_heartbeat_defaults_to_start_time() {
251        let t = HeartbeatTracker::new();
252        let _h = t.get_last_heartbeat();
253        // Should not panic; equals start_time when no heartbeat received.
254        let health = t.get_health();
255        // last_heartbeat in health snapshot equals start_time when no heartbeat.
256        assert_eq!(t.get_last_heartbeat(), health.last_heartbeat);
257    }
258}