car_messaging/channel_supervisor.rs
1//! Runtime channel supervisor + per-channel liveness (Units 1, 2, 3).
2//!
3//! The approval-transport adapters (`spawn_channel_pollers`) used to be spawned
4//! ONCE at daemon boot, for channels already enabled in `messaging.json`. There
5//! was no handle the live `messaging.config.set` path could reach to spawn a
6//! channel's watcher when the user flipped it on while car-server was already
7//! running — so a first-time enable did nothing until a restart (the invisible
8//! failure this feature closes). [`ChannelSupervisor`] is that handle.
9//!
10//! It owns three things:
11//!
12//! 1. **The live-channel set** (`live: Mutex<HashSet<ChannelId>>`) — which
13//! channels currently have a spawned watcher. [`ChannelSupervisor::ensure_spawned`]
14//! is idempotent against this set: a second enable of an already-spawned
15//! channel is a no-op (U1). Boot records the channels it spawned via
16//! [`ChannelSupervisor::mark_spawned`].
17//! 2. **The cancel signal** — the same `tokio::sync::watch::Sender<bool>` the
18//! boot registry returned, so shutdown can stop every loop. A channel spawned
19//! at runtime subscribes to the SAME signal, so it stops on shutdown too.
20//! 3. **Per-channel liveness** (`ChannelLiveness`) — the real runtime health the
21//! host-gated `messaging.status` method reads and the outbound send path
22//! writes (U2/U3). `watcher_running` is derived from the live set;
23//! `last_send_*`/`last_error` are written by the send path.
24//!
25//! There are NO cargo feature flags here (CLAUDE.md hard rule #1). The iMessage
26//! adapter the supervisor spawns is `#[cfg(target_os = "macos")]`-gated inside
27//! its own spawn body; the supervisor, the live set, and the liveness struct are
28//! cross-platform.
29
30use car_server_types::channel::{ChannelId, SharedHost};
31use std::collections::{HashMap, HashSet};
32use std::sync::{Arc, Mutex};
33
34/// A point-in-time snapshot of one channel's outbound-send health, recorded by
35/// the send path (U3) and read by `messaging.status` (U2). All fields are
36/// `Option`/`bool` so a never-sent channel reports cleanly (no send recorded).
37#[derive(Debug, Clone, Default, PartialEq, Eq)]
38pub struct ChannelLiveness {
39 /// Unix-epoch milliseconds of the most recent send ATTEMPT that produced a
40 /// recorded outcome (success or failure). `None` until the first send.
41 pub last_send_at_ms: Option<i64>,
42 /// Whether the most recent recorded send succeeded. `None` until the first
43 /// send; `Some(false)` for BOTH a hard error and a soft `sent:false`.
44 pub last_send_ok: Option<bool>,
45 /// Human-readable reason for the most recent FAILURE (hard error string or
46 /// the soft `sent:false` reason). `None` when the last send succeeded or no
47 /// send has happened. NOT cleared to `None` on success — instead a success
48 /// sets `last_send_ok:true` and clears it; see [`Self::record_success`].
49 pub last_error: Option<String>,
50}
51
52impl ChannelLiveness {
53 /// Record a successful send: stamp the time, mark ok, clear any prior error.
54 fn record_success(&mut self, at_ms: i64) {
55 self.last_send_at_ms = Some(at_ms);
56 self.last_send_ok = Some(true);
57 self.last_error = None;
58 }
59
60 /// Record a failed send (hard error OR soft `sent:false`): stamp the time,
61 /// mark not-ok, store the reason.
62 fn record_failure(&mut self, at_ms: i64, reason: impl Into<String>) {
63 self.last_send_at_ms = Some(at_ms);
64 self.last_send_ok = Some(false);
65 self.last_error = Some(reason.into());
66 }
67}
68
69/// The spawn closure a [`ChannelSupervisor`] calls to start a channel's
70/// watcher loop at runtime. It is handed the supervisor's `SharedHost`, the
71/// shared liveness map, and a fresh cancel receiver subscribed to the
72/// supervisor's cancel signal. Returns `Ok(())` if the watcher was spawned (or
73/// is a no-op on this platform), `Err` with a reason if the channel cannot be
74/// spawned (e.g. an unprovisioned Slack channel) — the caller surfaces that.
75///
76/// Boxed + `Send + Sync` so the supervisor can hold one closure for the
77/// process lifetime and call it from the async `messaging.config.set` handler.
78pub type SpawnFn = Box<
79 dyn Fn(
80 ChannelId,
81 &SharedHost,
82 &SharedLiveness,
83 tokio::sync::watch::Receiver<bool>,
84 ) -> Result<(), String>
85 + Send
86 + Sync,
87>;
88
89/// Shared, lock-guarded per-channel liveness map. Written by the send path
90/// (U3) and read by `messaging.status` (U2). A `std::sync::Mutex` (not tokio)
91/// because every access is a tiny, non-await critical section.
92pub type SharedLiveness = Arc<Mutex<HashMap<ChannelId, ChannelLiveness>>>;
93
94/// Runtime supervisor for the approval-transport channels. Held as an
95/// `Arc` on `crate::session::ServerState` (lazy-initialized at boot by
96/// `spawn_channel_pollers`) so the host-gated `messaging.config.set` handler
97/// can reach it to spawn a channel's watcher the instant the user enables it —
98/// no daemon/app restart (U1).
99pub struct ChannelSupervisor {
100 /// Shared host the spawned adapters resolve approvals against.
101 host: SharedHost,
102 /// Which channels currently have a spawned watcher. Guards idempotency:
103 /// `ensure_spawned` is a no-op for a channel already in this set.
104 live: Mutex<HashSet<ChannelId>>,
105 /// Cancel signal shared with every spawned loop (boot + runtime). Flip to
106 /// `true` to stop them all on shutdown.
107 cancel_tx: tokio::sync::watch::Sender<bool>,
108 /// The spawn closure for a single channel's watcher. `None` only in unit
109 /// tests that exercise the live-set bookkeeping without spawning real loops.
110 spawn: Option<SpawnFn>,
111 /// Per-channel send health, shared with the send path and `messaging.status`.
112 liveness: SharedLiveness,
113}
114
115impl ChannelSupervisor {
116 /// Build a supervisor over the given host, cancel signal, and spawn closure.
117 /// The boot path (`spawn_channel_pollers`) constructs it, records the
118 /// channels it already spawned via [`Self::mark_spawned`], and stores it on
119 /// `ServerState`.
120 pub fn new(
121 host: SharedHost,
122 cancel_tx: tokio::sync::watch::Sender<bool>,
123 spawn: SpawnFn,
124 ) -> Self {
125 Self {
126 host,
127 live: Mutex::new(HashSet::new()),
128 cancel_tx,
129 spawn: Some(spawn),
130 liveness: Arc::new(Mutex::new(HashMap::new())),
131 }
132 }
133
134 /// Build a supervisor with NO spawn closure — for unit tests that assert the
135 /// idempotent live-set bookkeeping without standing up real watcher loops.
136 /// `ensure_spawned` records the channel as live but spawns nothing.
137 #[cfg(test)]
138 pub fn new_for_test(host: SharedHost) -> Self {
139 let (cancel_tx, _rx) = tokio::sync::watch::channel(false);
140 Self {
141 host,
142 live: Mutex::new(HashSet::new()),
143 cancel_tx,
144 spawn: None,
145 liveness: Arc::new(Mutex::new(HashMap::new())),
146 }
147 }
148
149 /// The shared liveness map (so the boot path can hand the SAME `Arc` to the
150 /// adapters it spawns, and `messaging.status` can read it).
151 pub fn liveness(&self) -> SharedLiveness {
152 self.liveness.clone()
153 }
154
155 /// Stop every spawned adapter loop (boot + runtime) by flipping the shared
156 /// cancel signal. Called by the daemon shutdown path. Idempotent.
157 pub fn cancel_all(&self) {
158 let _ = self.cancel_tx.send(true);
159 }
160
161 /// Record that `channel`'s watcher is already running (called by the boot
162 /// path for each channel it spawned at startup). Idempotent.
163 pub fn mark_spawned(&self, channel: ChannelId) {
164 self.live.lock().unwrap().insert(channel);
165 }
166
167 /// Whether `channel`'s watcher is currently spawned. Read by
168 /// `messaging.status` for the `watcher_running` field (U2).
169 pub fn is_spawned(&self, channel: ChannelId) -> bool {
170 self.live.lock().unwrap().contains(&channel)
171 }
172
173 /// Ensure `channel`'s watcher is spawned (U1 — the invisible-restart fix).
174 /// Idempotent: if the channel is already live, this is a no-op and returns
175 /// `Ok(())`. Otherwise it calls the spawn closure with a fresh cancel
176 /// receiver, records the channel as live on success, and returns the
177 /// closure's result. A spawn error leaves the channel NOT marked live (so a
178 /// later retry can re-attempt).
179 ///
180 /// Called from `messaging.config.set` after a successful off→on transition.
181 /// Disable does NOT abort the loop (KTD1) — `poll_once`/`observe_and_notify`
182 /// already gate on the enabled flag, so a disabled channel does zero work
183 /// per tick; aborting would only add lifecycle complexity.
184 pub fn ensure_spawned(&self, channel: ChannelId) -> Result<(), String> {
185 // Idempotency guard FIRST — a second enable is a no-op (no duplicate
186 // watcher), held under the live-set lock so two concurrent set calls
187 // can't both pass the check and double-spawn.
188 {
189 let mut live = self.live.lock().unwrap();
190 if live.contains(&channel) {
191 return Ok(());
192 }
193 // Reserve the slot BEFORE spawning so a concurrent call short-
194 // circuits. If the spawn fails we remove it again below.
195 live.insert(channel);
196 }
197
198 let result = match &self.spawn {
199 Some(spawn) => spawn(
200 channel,
201 &self.host,
202 &self.liveness,
203 self.cancel_tx.subscribe(),
204 ),
205 // No spawn closure (test supervisor): the channel is recorded live
206 // above; there is nothing to start.
207 None => Ok(()),
208 };
209
210 if result.is_err() {
211 // Roll back the reservation so a later enable can retry.
212 self.live.lock().unwrap().remove(&channel);
213 }
214 result
215 }
216
217 /// Record a successful send for `channel` into the shared liveness (U3).
218 /// Stamps `now`, marks ok, clears any prior error.
219 pub fn record_send_success(&self, channel: ChannelId) {
220 let now = now_ms();
221 self.liveness
222 .lock()
223 .unwrap()
224 .entry(channel)
225 .or_default()
226 .record_success(now);
227 }
228
229 /// Record a failed send for `channel` (hard error OR soft `sent:false`)
230 /// into the shared liveness (U3). Stamps `now`, marks not-ok, stores the
231 /// reason.
232 pub fn record_send_failure(&self, channel: ChannelId, reason: impl Into<String>) {
233 let now = now_ms();
234 self.liveness
235 .lock()
236 .unwrap()
237 .entry(channel)
238 .or_default()
239 .record_failure(now, reason);
240 }
241
242 /// Snapshot `channel`'s liveness (default if no send recorded yet). Read by
243 /// `messaging.status`.
244 pub fn liveness_snapshot(&self, channel: ChannelId) -> ChannelLiveness {
245 self.liveness
246 .lock()
247 .unwrap()
248 .get(&channel)
249 .cloned()
250 .unwrap_or_default()
251 }
252}
253
254/// Free function: record a successful send into a [`SharedLiveness`] map — used
255/// by the spawned adapters (which hold the `Arc<Mutex<…>>` directly, not the
256/// whole supervisor) so the send path and `messaging.status` agree.
257pub fn liveness_record_success(liveness: &SharedLiveness, channel: ChannelId) {
258 let now = now_ms();
259 liveness
260 .lock()
261 .unwrap()
262 .entry(channel)
263 .or_default()
264 .record_success(now);
265}
266
267/// Free function: record a failed send into a [`SharedLiveness`] map.
268pub fn liveness_record_failure(
269 liveness: &SharedLiveness,
270 channel: ChannelId,
271 reason: impl Into<String>,
272) {
273 let now = now_ms();
274 liveness
275 .lock()
276 .unwrap()
277 .entry(channel)
278 .or_default()
279 .record_failure(now, reason);
280}
281
282/// Current Unix-epoch milliseconds. A monotonic-ish wall clock for the
283/// last-delivered display; a small skew is acceptable (the UI shows "2m ago").
284fn now_ms() -> i64 {
285 std::time::SystemTime::now()
286 .duration_since(std::time::UNIX_EPOCH)
287 .map(|d| d.as_millis() as i64)
288 .unwrap_or(0)
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use car_server_types::host::HostState;
295
296 fn host() -> SharedHost {
297 Arc::new(HostState::new())
298 }
299
300 #[test]
301 fn ensure_spawned_is_idempotent() {
302 let sup = ChannelSupervisor::new_for_test(host());
303 assert!(!sup.is_spawned(ChannelId::IMessage));
304
305 // First enable records the channel live.
306 sup.ensure_spawned(ChannelId::IMessage).unwrap();
307 assert!(sup.is_spawned(ChannelId::IMessage));
308
309 // Second enable is a no-op — still exactly one (no duplicate). We assert
310 // via the public is_spawned (still true) and that no error is returned.
311 sup.ensure_spawned(ChannelId::IMessage).unwrap();
312 assert!(sup.is_spawned(ChannelId::IMessage));
313
314 // A different channel is independent.
315 assert!(!sup.is_spawned(ChannelId::Slack));
316 }
317
318 #[test]
319 fn mark_spawned_reflects_in_is_spawned() {
320 let sup = ChannelSupervisor::new_for_test(host());
321 sup.mark_spawned(ChannelId::IMessage);
322 assert!(sup.is_spawned(ChannelId::IMessage));
323 assert!(!sup.is_spawned(ChannelId::Slack));
324 }
325
326 #[test]
327 fn liveness_records_success_and_failure() {
328 let sup = ChannelSupervisor::new_for_test(host());
329
330 // No send yet ⇒ default snapshot.
331 let snap = sup.liveness_snapshot(ChannelId::IMessage);
332 assert_eq!(snap, ChannelLiveness::default());
333 assert!(snap.last_send_at_ms.is_none());
334
335 // Record success.
336 sup.record_send_success(ChannelId::IMessage);
337 let snap = sup.liveness_snapshot(ChannelId::IMessage);
338 assert_eq!(snap.last_send_ok, Some(true));
339 assert!(snap.last_send_at_ms.is_some());
340 assert!(snap.last_error.is_none());
341
342 // Record failure — overrides ok, sets the reason.
343 sup.record_send_failure(ChannelId::IMessage, "recipient not found");
344 let snap = sup.liveness_snapshot(ChannelId::IMessage);
345 assert_eq!(snap.last_send_ok, Some(false));
346 assert_eq!(snap.last_error.as_deref(), Some("recipient not found"));
347
348 // A later success clears the error.
349 sup.record_send_success(ChannelId::IMessage);
350 let snap = sup.liveness_snapshot(ChannelId::IMessage);
351 assert_eq!(snap.last_send_ok, Some(true));
352 assert!(snap.last_error.is_none());
353 }
354}