Skip to main content

smix_sim_health/
lib.rs

1// smix-sim-health — sim-side liveness sense layer.
2//
3// The runner client, the simctl client, and the maestro adapter each
4// observe one aspect of sim health (respectively: /health response
5// age, screenshot wall time, per-flow XCTest state). They feed the
6// observations into a `SimHealthMonitor`; the monitor collapses them
7// into a `SimHealthState` and broadcasts transitions. Callers that
8// want to react (throttle, cycle, bail out) subscribe.
9//
10// The sense layer does not act. Actions live in the crates that own
11// the affected surface — this crate is deliberately business-unaware
12// (it does not know what "iOS", "simulator", or "insight" mean).
13//
14// State machine
15// -------------
16// - Healthy   — every observation is inside its normal envelope.
17// - Degraded  — at least one signal is bad but not fatal (screenshot
18//               p95 above the slow threshold, or /health age above
19//               the stale threshold but below the dead threshold).
20// - Dead      — the runner is unreachable, or a watched process
21//               (SimRenderServer / xcodebuild test-host) is gone.
22//
23// Transitions publish `SimHealthEvent { previous, current, reason }`
24// on a broadcast channel. Only real transitions are published;
25// repeated identical observations do not spam subscribers.
26
27#![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/// Coarse sim health classification.
37///
38/// The three states form a strictly-ordered severity ladder —
39/// `Healthy < Degraded < Dead`. Callers routinely branch on the
40/// severity rather than the specific reason (throttle on `Degraded`,
41/// bail on `Dead`), so `Ord` is derived to make that idiomatic.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
43#[non_exhaustive]
44pub enum SimHealthState {
45    Healthy,
46    Degraded,
47    Dead,
48}
49
50/// Reason a transition happened. `Reason::None` is used for the
51/// initial state before any observation arrives.
52#[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/// Snapshot published on each real state transition.
66#[derive(Debug, Clone)]
67#[non_exhaustive]
68pub struct SimHealthEvent {
69    pub previous: SimHealthState,
70    pub current: SimHealthState,
71    pub reason: HealthReason,
72}
73
74/// Config knobs. Defaults match the values in
75/// `.claude/rfcs/1.0.4-sim-health-and-backpressure.md` §D1.
76#[derive(Debug, Clone)]
77#[non_exhaustive]
78pub struct SimHealthConfig {
79    /// Screenshot p95 above this = `Degraded`.
80    pub screenshot_slow: Duration,
81    /// `/health` last-response age above this = `Degraded`.
82    pub health_stale: Duration,
83    /// `/health` last-response age above this = `Dead`.
84    pub health_dead: Duration,
85    /// Rolling window size for screenshot observations. Also the
86    /// window over which "recent failures" are counted.
87    pub rolling_window: usize,
88    /// Broadcast channel capacity. If a subscriber lags past this,
89    /// they get a `RecvError::Lagged`; the monitor keeps running.
90    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/// The monitor. Cheap to clone (internally `Arc`).
106#[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    /// Ring of recent screenshot wall times. Newest at back.
129    screenshot_samples: Vec<Duration>,
130    /// Ring of recent screenshot failure flags aligned with samples.
131    screenshot_failed: Vec<bool>,
132    /// Last time `/health` was observed to be OK. `None` until the
133    /// first successful health call.
134    last_health_ok: Option<Instant>,
135    /// Set to `true` on any observed `/health` failure; cleared on
136    /// any observed `/health` success. Used to distinguish "never
137    /// observed" (neutral) from "observed and failed" (Degraded).
138    saw_health_fail: bool,
139    /// Which watched processes are currently believed alive.
140    process_alive: std::collections::BTreeMap<String, bool>,
141}
142
143impl SimHealthMonitor {
144    /// Build a monitor with the given config, starting in `Healthy`.
145    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    /// Current classification. O(1).
164    pub fn state(&self) -> SimHealthState {
165        self.lock().current
166    }
167
168    /// Subscribe to state transitions. See `SimHealthEvent`.
169    pub fn subscribe(&self) -> broadcast::Receiver<SimHealthEvent> {
170        self.inner.tx.subscribe()
171    }
172
173    /// Record a screenshot wall time. Feed on every call, success or
174    /// failure — set `failed = true` for a failed call.
175    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    /// Record a successful `/health` observation.
192    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    /// Record a failed `/health` observation. The monitor considers
202    /// the runner dead once `health_dead` has passed since the last
203    /// successful `/health`; before then it goes `Degraded`.
204    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    /// Report on a watched process. `alive = false` transitions the
213    /// state to `Dead` on any process gone; `alive = true` allows
214    /// recovery when all watched processes are alive again.
215    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    // ---- internals ------------------------------------------------
231
232    fn lock(&self) -> std::sync::MutexGuard<'_, MonitorState> {
233        // Poisoned mutexes only happen if a caller panicked while
234        // holding the guard; the monitor's business is aggregating
235        // observations, so a panic upstream should not silently drop
236        // the whole health signal. We surface the last-known state.
237        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            // No transition, don't broadcast. But if the reason is a
264            // fresh problem worth logging even without a transition,
265            // future work may surface it via a separate probe channel.
266            return;
267        }
268        {
269            let mut st = self.lock();
270            st.current = current;
271        }
272        // A send to an empty broadcast (no subscribers) returns
273        // `Err`; that's fine, we do not require subscribers.
274        let _ = self.inner.tx.send(SimHealthEvent {
275            previous,
276            current,
277            reason: chosen_reason,
278        });
279    }
280}
281
282/// Compute the target state given the current observation. Pure
283/// function on `(cfg, state, incoming)` — kept out of the impl for
284/// direct unit-testing.
285fn compute_target(
286    cfg: &SimHealthConfig,
287    st: &MonitorState,
288    incoming: HealthReason,
289) -> (SimHealthState, HealthReason) {
290    // A watched process being dead is the highest severity.
291    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    // Health-age based severity. Startup is optimistic: no observation
301    // is neutral (does not push state up). Only a real failure or a
302    // real timeout past the stale/dead threshold moves us off Healthy.
303    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                // We had a baseline OK but a subsequent /health failed.
332                // Consider Degraded until either another OK or the age
333                // threshold escalates us.
334                Some((
335                    SimHealthState::Degraded,
336                    HealthReason::HealthFailedNoBaseline,
337                ))
338            } else {
339                None
340            }
341        }
342    };
343
344    // Screenshot-based severity is at most Degraded (a slow SimRenderServer
345    // does not by itself prove the runner is dead).
346    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    // Merge: worst of the two candidates wins.
359    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        // Fill with slow samples, then flush with fast ones.
467        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        // 20 samples: 19 fast, 1 slow at index 19 → p95 = ceil(20*0.95) - 1 = 18 → still the slow one for small sets? Verify.
480        let samples: Vec<Duration> = (0..19)
481            .map(|_| Duration::from_millis(10))
482            .chain(std::iter::once(Duration::from_millis(1000)))
483            .collect();
484        // sorted: 19 tens + 1 thousand. p95 index = ceil(20*.95)-1 = 18 (0-based). index 18 = 10 (still fast side).
485        assert_eq!(p95_ms(&samples), 10);
486        // 40 samples: 38 fast, 2 slow → p95 index = ceil(40*.95)-1 = 37. sorted first 38 are fast, index 37 is fast, so 10.
487        // Confirms p95 is robust to a single outlier at n=20 but flags at higher slow fractions.
488        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        // 20 samples, 2 slow at end. sorted index 18 is slow (1000).
493        assert_eq!(p95_ms(&samples2), 1000);
494    }
495}