1#![doc = include_str!("../README.md")]
28
29use std::sync::Arc;
30use std::sync::Mutex;
31use std::time::Duration;
32use std::time::Instant;
33
34use tokio::sync::broadcast;
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43#[non_exhaustive]
44pub enum SimHealthState {
45 Healthy,
46 Degraded,
47 Dead,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
53#[non_exhaustive]
54pub enum HealthReason {
55 None,
56 ScreenshotSlow { p95_ms: u64 },
57 ScreenshotFailed,
58 HealthStale { age_ms: u64 },
59 HealthFailedNoBaseline,
60 ProcessGone { name: String },
61 ProcessRecovered { name: String },
62 Recovered,
63}
64
65#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub struct SimHealthEvent {
69 pub previous: SimHealthState,
70 pub current: SimHealthState,
71 pub reason: HealthReason,
72}
73
74#[derive(Debug, Clone)]
77#[non_exhaustive]
78pub struct SimHealthConfig {
79 pub screenshot_slow: Duration,
81 pub health_stale: Duration,
83 pub health_dead: Duration,
85 pub rolling_window: usize,
88 pub channel_capacity: usize,
91}
92
93impl Default for SimHealthConfig {
94 fn default() -> Self {
95 Self {
96 screenshot_slow: Duration::from_millis(800),
97 health_stale: Duration::from_secs(5),
98 health_dead: Duration::from_secs(15),
99 rolling_window: 32,
100 channel_capacity: 64,
101 }
102 }
103}
104
105#[derive(Clone)]
107pub struct SimHealthMonitor {
108 inner: Arc<Inner>,
109}
110
111impl std::fmt::Debug for SimHealthMonitor {
112 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113 f.debug_struct("SimHealthMonitor")
114 .field("state", &self.state())
115 .field("subscribers", &self.inner.tx.receiver_count())
116 .finish()
117 }
118}
119
120struct Inner {
121 cfg: SimHealthConfig,
122 state: Mutex<MonitorState>,
123 tx: broadcast::Sender<SimHealthEvent>,
124}
125
126struct MonitorState {
127 current: SimHealthState,
128 screenshot_samples: Vec<Duration>,
130 screenshot_failed: Vec<bool>,
132 last_health_ok: Option<Instant>,
135 saw_health_fail: bool,
139 process_alive: std::collections::BTreeMap<String, bool>,
141}
142
143impl SimHealthMonitor {
144 pub fn new(cfg: SimHealthConfig) -> Self {
146 let (tx, _rx) = broadcast::channel(cfg.channel_capacity);
147 Self {
148 inner: Arc::new(Inner {
149 cfg,
150 state: Mutex::new(MonitorState {
151 current: SimHealthState::Healthy,
152 screenshot_samples: Vec::new(),
153 screenshot_failed: Vec::new(),
154 last_health_ok: None,
155 saw_health_fail: false,
156 process_alive: std::collections::BTreeMap::new(),
157 }),
158 tx,
159 }),
160 }
161 }
162
163 pub fn state(&self) -> SimHealthState {
165 self.lock().current
166 }
167
168 pub fn subscribe(&self) -> broadcast::Receiver<SimHealthEvent> {
170 self.inner.tx.subscribe()
171 }
172
173 pub fn record_screenshot(&self, wall: Duration, failed: bool) {
176 let reason;
177 {
178 let mut st = self.lock();
179 let cap = self.inner.cfg.rolling_window;
180 if st.screenshot_samples.len() >= cap {
181 st.screenshot_samples.remove(0);
182 st.screenshot_failed.remove(0);
183 }
184 st.screenshot_samples.push(wall);
185 st.screenshot_failed.push(failed);
186 reason = Self::classify_screenshot(&self.inner.cfg, &st);
187 }
188 self.recompute(reason);
189 }
190
191 pub fn record_health_ok(&self) {
193 {
194 let mut st = self.lock();
195 st.last_health_ok = Some(Instant::now());
196 st.saw_health_fail = false;
197 }
198 self.recompute(HealthReason::Recovered);
199 }
200
201 pub fn record_health_fail(&self) {
205 {
206 let mut st = self.lock();
207 st.saw_health_fail = true;
208 }
209 self.recompute(HealthReason::HealthFailedNoBaseline);
210 }
211
212 pub fn record_process(&self, name: impl Into<String>, alive: bool) {
216 let name = name.into();
217 let reason;
218 {
219 let mut st = self.lock();
220 st.process_alive.insert(name.clone(), alive);
221 reason = if alive {
222 HealthReason::ProcessRecovered { name }
223 } else {
224 HealthReason::ProcessGone { name }
225 };
226 }
227 self.recompute(reason);
228 }
229
230 fn lock(&self) -> std::sync::MutexGuard<'_, MonitorState> {
233 self.inner
238 .state
239 .lock()
240 .unwrap_or_else(|e| e.into_inner())
241 }
242
243 fn classify_screenshot(cfg: &SimHealthConfig, st: &MonitorState) -> HealthReason {
244 if st.screenshot_failed.iter().rev().take(3).all(|f| *f)
245 && st.screenshot_failed.len() >= 3
246 {
247 return HealthReason::ScreenshotFailed;
248 }
249 let p95 = p95_ms(&st.screenshot_samples);
250 if p95 > cfg.screenshot_slow.as_millis() as u64 {
251 return HealthReason::ScreenshotSlow { p95_ms: p95 };
252 }
253 HealthReason::None
254 }
255
256 fn recompute(&self, incoming: HealthReason) {
257 let (previous, current, chosen_reason) = {
258 let st = self.lock();
259 let (target, reason) = compute_target(&self.inner.cfg, &st, incoming);
260 (st.current, target, reason)
261 };
262 if previous == current {
263 return;
267 }
268 {
269 let mut st = self.lock();
270 st.current = current;
271 }
272 let _ = self.inner.tx.send(SimHealthEvent {
275 previous,
276 current,
277 reason: chosen_reason,
278 });
279 }
280}
281
282fn compute_target(
286 cfg: &SimHealthConfig,
287 st: &MonitorState,
288 incoming: HealthReason,
289) -> (SimHealthState, HealthReason) {
290 for (name, alive) in st.process_alive.iter() {
292 if !alive {
293 return (
294 SimHealthState::Dead,
295 HealthReason::ProcessGone { name: name.clone() },
296 );
297 }
298 }
299
300 let health_age_reason = match st.last_health_ok {
304 None => {
305 if st.saw_health_fail {
306 Some((
307 SimHealthState::Degraded,
308 HealthReason::HealthFailedNoBaseline,
309 ))
310 } else {
311 None
312 }
313 }
314 Some(last) => {
315 let age = last.elapsed();
316 if age >= cfg.health_dead {
317 Some((
318 SimHealthState::Dead,
319 HealthReason::HealthStale {
320 age_ms: age.as_millis() as u64,
321 },
322 ))
323 } else if age >= cfg.health_stale {
324 Some((
325 SimHealthState::Degraded,
326 HealthReason::HealthStale {
327 age_ms: age.as_millis() as u64,
328 },
329 ))
330 } else if st.saw_health_fail {
331 Some((
335 SimHealthState::Degraded,
336 HealthReason::HealthFailedNoBaseline,
337 ))
338 } else {
339 None
340 }
341 }
342 };
343
344 let screenshot_reason = match &incoming {
347 HealthReason::ScreenshotSlow { p95_ms } => Some((
348 SimHealthState::Degraded,
349 HealthReason::ScreenshotSlow { p95_ms: *p95_ms },
350 )),
351 HealthReason::ScreenshotFailed => Some((
352 SimHealthState::Degraded,
353 HealthReason::ScreenshotFailed,
354 )),
355 _ => None,
356 };
357
358 let candidate = match (health_age_reason, screenshot_reason) {
360 (Some(a), Some(b)) => Some(if a.0 >= b.0 { a } else { b }),
361 (Some(x), None) | (None, Some(x)) => Some(x),
362 (None, None) => None,
363 };
364
365 match candidate {
366 Some((state, reason)) => (state, reason),
367 None => (SimHealthState::Healthy, HealthReason::Recovered),
368 }
369}
370
371fn p95_ms(samples: &[Duration]) -> u64 {
372 if samples.is_empty() {
373 return 0;
374 }
375 let mut ms: Vec<u64> = samples.iter().map(|d| d.as_millis() as u64).collect();
376 ms.sort_unstable();
377 let idx = ((samples.len() as f64) * 0.95).ceil() as usize - 1;
378 let idx = idx.min(ms.len() - 1);
379 ms[idx]
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385
386 fn cfg() -> SimHealthConfig {
387 SimHealthConfig::default()
388 }
389
390 #[test]
391 fn starts_healthy() {
392 let m = SimHealthMonitor::new(cfg());
393 assert_eq!(m.state(), SimHealthState::Healthy);
394 }
395
396 #[test]
397 fn fast_screenshot_stays_healthy() {
398 let m = SimHealthMonitor::new(cfg());
399 m.record_health_ok();
400 for _ in 0..10 {
401 m.record_screenshot(Duration::from_millis(100), false);
402 }
403 assert_eq!(m.state(), SimHealthState::Healthy);
404 }
405
406 #[test]
407 fn slow_screenshot_degrades() {
408 let m = SimHealthMonitor::new(cfg());
409 m.record_health_ok();
410 for _ in 0..10 {
411 m.record_screenshot(Duration::from_millis(2000), false);
412 }
413 assert_eq!(m.state(), SimHealthState::Degraded);
414 }
415
416 #[test]
417 fn triple_screenshot_failure_degrades() {
418 let m = SimHealthMonitor::new(cfg());
419 m.record_health_ok();
420 m.record_screenshot(Duration::from_millis(2000), true);
421 m.record_screenshot(Duration::from_millis(2000), true);
422 m.record_screenshot(Duration::from_millis(2000), true);
423 assert_eq!(m.state(), SimHealthState::Degraded);
424 }
425
426 #[test]
427 fn watched_process_gone_kills() {
428 let m = SimHealthMonitor::new(cfg());
429 m.record_health_ok();
430 m.record_process("SimRenderServer", true);
431 assert_eq!(m.state(), SimHealthState::Healthy);
432 m.record_process("SimRenderServer", false);
433 assert_eq!(m.state(), SimHealthState::Dead);
434 m.record_process("SimRenderServer", true);
435 assert_eq!(m.state(), SimHealthState::Healthy);
436 }
437
438 #[test]
439 fn transitions_are_broadcast() {
440 let m = SimHealthMonitor::new(cfg());
441 let mut rx = m.subscribe();
442 m.record_health_ok();
443 m.record_process("xcodebuild", false);
444 let evt = rx.try_recv().expect("should receive transition");
445 assert_eq!(evt.previous, SimHealthState::Healthy);
446 assert_eq!(evt.current, SimHealthState::Dead);
447 }
448
449 #[test]
450 fn no_transition_no_event() {
451 let m = SimHealthMonitor::new(cfg());
452 let mut rx = m.subscribe();
453 m.record_health_ok();
454 for _ in 0..5 {
455 m.record_screenshot(Duration::from_millis(50), false);
456 }
457 assert!(rx.try_recv().is_err(), "no transition should be broadcast");
458 }
459
460 #[test]
461 fn rolling_window_evicts_old_samples() {
462 let mut c = cfg();
463 c.rolling_window = 4;
464 let m = SimHealthMonitor::new(c);
465 m.record_health_ok();
466 for _ in 0..4 {
468 m.record_screenshot(Duration::from_millis(2000), false);
469 }
470 assert_eq!(m.state(), SimHealthState::Degraded);
471 for _ in 0..4 {
472 m.record_screenshot(Duration::from_millis(50), false);
473 }
474 assert_eq!(m.state(), SimHealthState::Healthy);
475 }
476
477 #[test]
478 fn p95_ms_basic() {
479 let samples: Vec<Duration> = (0..19)
481 .map(|_| Duration::from_millis(10))
482 .chain(std::iter::once(Duration::from_millis(1000)))
483 .collect();
484 assert_eq!(p95_ms(&samples), 10);
486 let samples2: Vec<Duration> = (0..18)
489 .map(|_| Duration::from_millis(10))
490 .chain(std::iter::repeat_n(Duration::from_millis(1000), 2))
491 .collect();
492 assert_eq!(p95_ms(&samples2), 1000);
494 }
495}