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}