soothe_client/
heartbeat.rs1use 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#[derive(Debug, Clone)]
14pub struct DaemonHealth {
15 pub last_heartbeat: Instant,
17 pub state: String,
19 pub loop_id: String,
21 pub is_alive: bool,
23}
24
25#[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 pub fn new() -> Self {
43 Self::with_threshold(Duration::from_secs(15))
44 }
45
46 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 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 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 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 pub fn is_processing(&self) -> bool {
97 self.inner.lock().expect("heartbeat lock").daemon_state == "running"
98 }
99
100 pub fn is_idle(&self) -> bool {
102 self.inner.lock().expect("heartbeat lock").daemon_state == "idle"
103 }
104
105 pub fn get_state(&self) -> String {
107 self.inner
108 .lock()
109 .expect("heartbeat lock")
110 .daemon_state
111 .clone()
112 }
113
114 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 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 pub fn set_alive_threshold(&self, threshold: Duration) {
131 self.inner.lock().expect("heartbeat lock").alive_threshold = threshold;
132 }
133
134 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 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 let _data = obj.get("data").unwrap_or(&Value::Null);
162 false
163 }
164
165 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 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 std::thread::sleep(Duration::from_millis(5));
231 let h = t.get_health();
232 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 let health = t.get_health();
255 assert_eq!(t.get_last_heartbeat(), health.last_heartbeat);
257 }
258}