Skip to main content

agentmux_launcher/
ui_liveness.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! SPEC_LAUNCHER_TEARDOWN_BACKSTOP_2026_07_11 Phase 1 — UI-thread liveness
5//! telemetry (observe-only).
6//!
7//! The supervisor's prober sends `Command::ProbeUiThread { nonce }` over the
8//! host pipe on a low-rate interval; the host answers with
9//! `Command::ReportUiThreadAlive { nonce }` from a posted CEF UI task —
10//! i.e. a reply is evidence the UI thread pumped AFTER the probe was sent.
11//! Silence is the signal: a wedged UI thread never replies, and there is no
12//! host-side timeout.
13//!
14//! Phase 1 records `last_alive` + logs round-trip latency; Phase 2's armed
15//! teardown rule will consume `last_alive()`. Kept as a tiny standalone
16//! module (not reducer state): this is transport+thread telemetry about the
17//! host process, not domain state the reducer owns.
18//!
19//! All logic lives on the `UiLiveness` struct (unit-testable in isolation —
20//! reagent P1 on the first version: tests sharing the process-global cell
21//! interleave under parallel execution); the module-level functions are
22//! thin delegates to the single process-global instance.
23
24use std::sync::{Mutex, OnceLock};
25use std::time::Instant;
26
27#[derive(Default)]
28pub struct UiLiveness {
29    /// Most recent probe: (nonce, when sent). One outstanding probe at a
30    /// time is enough at the Phase-1 rate; an unanswered probe is simply
31    /// overwritten by the next tick (the gap shows up as `last_alive`
32    /// growing stale, which is the exact signal Phase 2 consumes).
33    probe_sent: Option<(u64, Instant)>,
34    /// Last time ANY `ReportUiThreadAlive` arrived — proof the UI thread
35    /// pumped at that moment, regardless of nonce matching.
36    last_alive: Option<Instant>,
37    /// Phase 2 — consecutive DELIVERED-but-unanswered probes. Incremented
38    /// when a new probe overwrites an unanswered one; reset by any reply.
39    /// Retracted probes (send failed — transport evidence, not liveness
40    /// evidence) never reach the overwrite path, so they can't inflate
41    /// this. Consumed by the armed teardown rule
42    /// (`teardown_backstop::should_teardown`).
43    consecutive_misses: u32,
44}
45
46impl UiLiveness {
47    /// Record a probe about to be sent. Returns the previous probe's
48    /// `(nonce, sent)` if it was never answered — the caller logs the miss.
49    pub fn record_probe_sent(&mut self, nonce: u64) -> Option<(u64, Instant)> {
50        let unanswered = self.probe_sent.take();
51        if let Some((_, sent_at)) = unanswered {
52            // Wedge evidence only if the UI thread has NOT pumped since this
53            // probe was sent. A nonce-unmatched (late) reply doesn't clear
54            // the outstanding probe (Phase-1 semantics: it can still be
55            // answered for latency), but it DOES prove the thread pumped
56            // after `sent_at` — so the probe aging out is telemetry noise,
57            // not a wedge signal.
58            let pumped_since = self.last_alive.is_some_and(|la| la >= sent_at);
59            if !pumped_since {
60                self.consecutive_misses = self.consecutive_misses.saturating_add(1);
61            }
62        }
63        self.probe_sent = Some((nonce, Instant::now()));
64        unanswered
65    }
66
67    /// Retract an outstanding probe whose SEND failed (reagent P1: a
68    /// transport failure must never age into a false "UI thread did not
69    /// pump" report — the probe was never delivered, so its silence is
70    /// transport evidence, not liveness evidence). No-op if a different
71    /// probe is outstanding.
72    pub fn retract_probe(&mut self, nonce: u64) {
73        if matches!(self.probe_sent, Some((n, _)) if n == nonce) {
74            self.probe_sent = None;
75        }
76    }
77
78    /// Record a `ReportUiThreadAlive`. Returns the round-trip latency when
79    /// the nonce matches the outstanding probe (`None` for a late/unmatched
80    /// reply — still recorded as aliveness, since ANY reply proves the UI
81    /// thread pumped after its probe was sent).
82    pub fn record_alive(&mut self, nonce: u64) -> Option<std::time::Duration> {
83        self.last_alive = Some(Instant::now());
84        // ANY reply proves the UI thread pumped — the wedge streak is over
85        // regardless of nonce matching (a late reply to an old probe is
86        // still a pump that happened after that probe was sent).
87        self.consecutive_misses = 0;
88        match self.probe_sent {
89            Some((sent_nonce, sent_at)) if sent_nonce == nonce => {
90                self.probe_sent = None;
91                Some(sent_at.elapsed())
92            }
93            _ => None,
94        }
95    }
96
97    /// Phase-2 consumer surface: when did the host's UI thread last prove
98    /// itself alive? `None` = never (host hasn't answered a single probe
99    /// yet — startup, or standalone mode with no pipe).
100    pub fn last_alive(&self) -> Option<Instant> {
101        self.last_alive
102    }
103
104    /// Phase-2 consumer surface: consecutive delivered-but-unanswered
105    /// probes. See the field doc for what does (and doesn't) count.
106    pub fn consecutive_misses(&self) -> u32 {
107        self.consecutive_misses
108    }
109
110    /// Forget everything about the host this state described. Called when
111    /// the host exits: a probe the old host never answered must not age into
112    /// a miss charged to its replacement (the hang dump in
113    /// `host_hang_dump` would otherwise dump a healthy, just-respawned host).
114    #[cfg(any(target_os = "windows", test))]
115    pub fn reset(&mut self) {
116        *self = Self::default();
117    }
118}
119
120fn cell() -> &'static Mutex<UiLiveness> {
121    static S: OnceLock<Mutex<UiLiveness>> = OnceLock::new();
122    S.get_or_init(|| Mutex::new(UiLiveness::default()))
123}
124
125/// See [`UiLiveness::record_probe_sent`]. Process-global instance.
126pub fn record_probe_sent(nonce: u64) -> Option<(u64, Instant)> {
127    cell().lock().unwrap().record_probe_sent(nonce)
128}
129
130/// See [`UiLiveness::retract_probe`]. Process-global instance.
131pub fn retract_probe(nonce: u64) {
132    cell().lock().unwrap().retract_probe(nonce)
133}
134
135/// See [`UiLiveness::record_alive`]. Process-global instance.
136pub fn record_alive(nonce: u64) -> Option<std::time::Duration> {
137    cell().lock().unwrap().record_alive(nonce)
138}
139
140/// See [`UiLiveness::last_alive`]. Process-global instance.
141#[allow(dead_code)] // kept as telemetry surface; Phase 2 consumes consecutive_misses
142pub fn last_alive() -> Option<Instant> {
143    cell().lock().unwrap().last_alive()
144}
145
146/// See [`UiLiveness::consecutive_misses`]. Process-global instance.
147pub fn consecutive_misses() -> u32 {
148    cell().lock().unwrap().consecutive_misses()
149}
150
151/// See [`UiLiveness::reset`]. Process-global instance.
152#[cfg(target_os = "windows")]
153pub fn reset() {
154    cell().lock().unwrap().reset()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::UiLiveness;
160
161    // Each test owns its OWN UiLiveness instance — no process-global state,
162    // no parallel-execution interleaving (reagent P1 on the first version).
163
164    #[test]
165    fn matched_reply_yields_latency_and_clears_outstanding() {
166        let mut l = UiLiveness::default();
167        l.record_probe_sent(1);
168        let latency = l.record_alive(1);
169        assert!(latency.is_some(), "matched nonce must yield a latency");
170        // Second reply to the same nonce: still aliveness, no latency.
171        assert!(l.record_alive(1).is_none());
172        assert!(l.last_alive().is_some());
173    }
174
175    #[test]
176    fn unmatched_reply_is_aliveness_without_latency() {
177        let mut l = UiLiveness::default();
178        l.record_probe_sent(1);
179        assert!(l.record_alive(999).is_none(), "wrong nonce must not match");
180        assert!(l.last_alive().is_some(), "but it still proves aliveness");
181        // The outstanding probe survives an unmatched reply and can still
182        // be answered.
183        assert!(l.record_alive(1).is_some());
184    }
185
186    #[test]
187    fn overwriting_an_unanswered_probe_reports_the_miss() {
188        let mut l = UiLiveness::default();
189        l.record_probe_sent(1);
190        let missed = l.record_probe_sent(2);
191        assert!(
192            matches!(missed, Some((1, _))),
193            "the unanswered probe must be surfaced to the caller"
194        );
195    }
196
197    #[test]
198    fn consecutive_misses_count_and_reset() {
199        let mut l = UiLiveness::default();
200        assert_eq!(l.consecutive_misses(), 0);
201        // Two unanswered probes overwritten in sequence → 2 misses.
202        l.record_probe_sent(1);
203        l.record_probe_sent(2);
204        assert_eq!(l.consecutive_misses(), 1);
205        l.record_probe_sent(3);
206        assert_eq!(l.consecutive_misses(), 2);
207        // ANY reply — even nonce-unmatched — resets the streak, AND marks
208        // the still-outstanding probe (3) as pumped-since, so its later
209        // overwrite is telemetry noise, not a new miss.
210        l.record_alive(999);
211        assert_eq!(l.consecutive_misses(), 0);
212        // A retracted (send-failed) probe never inflates the count.
213        l.record_probe_sent(4);
214        l.retract_probe(4);
215        l.record_probe_sent(5);
216        assert_eq!(l.consecutive_misses(), 0);
217    }
218
219    #[test]
220    fn reset_drops_the_old_hosts_unanswered_probe() {
221        let mut l = UiLiveness::default();
222        l.record_probe_sent(1);
223        l.record_probe_sent(2);
224        assert_eq!(l.consecutive_misses(), 1);
225        // Host exits and is respawned.
226        l.reset();
227        assert_eq!(l.consecutive_misses(), 0);
228        assert!(l.last_alive().is_none());
229        // The first probe to the NEW host: nothing outstanding to overwrite,
230        // so the old host's silence is not charged to it.
231        assert!(l.record_probe_sent(3).is_none());
232        assert_eq!(l.consecutive_misses(), 0);
233    }
234
235    #[test]
236    fn retracted_send_failure_is_not_reported_as_a_miss() {
237        // reagent P1: a probe whose send failed must not age into a false
238        // "UI thread did not pump" miss on the next tick.
239        let mut l = UiLiveness::default();
240        l.record_probe_sent(1);
241        l.retract_probe(1);
242        assert!(
243            l.record_probe_sent(2).is_none(),
244            "a retracted probe must not be surfaced as unanswered"
245        );
246        // Retract only touches the matching nonce.
247        l.retract_probe(999);
248        let missed = l.record_probe_sent(3);
249        assert!(
250            matches!(missed, Some((2, _))),
251            "unrelated retract must not clear a live probe"
252        );
253    }
254}