Skip to main content

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}