Skip to main content

soothe_client/
heartbeat.rs

1//! Daemon heartbeat tracking for aliveness checks.
2
3use std::sync::Mutex;
4use std::time::{Duration, Instant};
5
6use serde_json::Value;
7
8/// Snapshot of daemon health from heartbeat tracking.
9#[derive(Debug, Clone)]
10pub struct DaemonHealth {
11    /// Instant of last received heartbeat (or tracker start if none yet).
12    pub last_heartbeat: Instant,
13    /// Daemon state: `"running"` or `"idle"` (when known).
14    pub state: String,
15    /// Loop id the daemon is processing (if running).
16    pub loop_id: String,
17    /// True if heartbeat received within the alive threshold (or grace period).
18    pub is_alive: bool,
19}
20
21/// Tracks daemon heartbeat events to monitor aliveness.
22#[derive(Debug)]
23pub struct HeartbeatTracker {
24    inner: Mutex<Inner>,
25}
26
27#[derive(Debug)]
28struct Inner {
29    last_heartbeat: Option<Instant>,
30    daemon_state: String,
31    heartbeat_loop_id: String,
32    alive_threshold: Duration,
33    start_time: Instant,
34}
35
36impl HeartbeatTracker {
37    /// Create a tracker with the default 15s alive threshold.
38    pub fn new() -> Self {
39        Self::with_threshold(Duration::from_secs(15))
40    }
41
42    /// Create a tracker with a custom alive threshold.
43    pub fn with_threshold(alive_threshold: Duration) -> Self {
44        Self {
45            inner: Mutex::new(Inner {
46                last_heartbeat: None,
47                daemon_state: String::new(),
48                heartbeat_loop_id: String::new(),
49                alive_threshold,
50                start_time: Instant::now(),
51            }),
52        }
53    }
54
55    /// Process a heartbeat event payload (typically `event["data"]`).
56    pub fn update(&self, heartbeat_data: Option<&Value>) {
57        let mut g = self.inner.lock().expect("heartbeat lock");
58        g.last_heartbeat = Some(Instant::now());
59        if let Some(data) = heartbeat_data.and_then(|v| v.as_object()) {
60            if let Some(state) = data.get("state").and_then(|v| v.as_str()) {
61                g.daemon_state = state.to_string();
62            }
63            if let Some(loop_id) = data.get("loop_id").and_then(|v| v.as_str()) {
64                g.heartbeat_loop_id = loop_id.to_string();
65            }
66        }
67    }
68
69    /// Record that an application-level pong was received.
70    pub fn note_pong(&self) {
71        let mut g = self.inner.lock().expect("heartbeat lock");
72        g.last_heartbeat = Some(Instant::now());
73    }
74
75    /// Current daemon health snapshot.
76    pub fn get_health(&self) -> DaemonHealth {
77        let g = self.inner.lock().expect("heartbeat lock");
78        let now = Instant::now();
79        let last = g.last_heartbeat.unwrap_or(g.start_time);
80        let grace = Duration::from_secs(20);
81        let is_alive = now.duration_since(last) < g.alive_threshold
82            || now.duration_since(g.start_time) < grace;
83        DaemonHealth {
84            last_heartbeat: last,
85            state: g.daemon_state.clone(),
86            loop_id: g.heartbeat_loop_id.clone(),
87            is_alive,
88        }
89    }
90
91    /// True when daemon state is `"running"`.
92    pub fn is_processing(&self) -> bool {
93        self.inner.lock().expect("heartbeat lock").daemon_state == "running"
94    }
95
96    /// True when daemon state is `"idle"`.
97    pub fn is_idle(&self) -> bool {
98        self.inner.lock().expect("heartbeat lock").daemon_state == "idle"
99    }
100}
101
102impl Default for HeartbeatTracker {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108#[cfg(test)]
109mod tests {
110    use super::*;
111
112    #[test]
113    fn grace_period_alive() {
114        let t = HeartbeatTracker::new();
115        assert!(t.get_health().is_alive);
116    }
117}