Skip to main content

agentmux_srv/backend/fs_watch/
recovery.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Health tracking + retry policy for `FsWatchPool`. Split out of `pool.rs`
5//! so the recovery bookkeeping (what's degraded, which backend is active)
6//! is testable independent of the notify/tokio wiring.
7
8use std::collections::HashMap;
9use std::path::PathBuf;
10use std::sync::Mutex;
11use std::time::Duration;
12
13/// Which `notify` backend is actually driving watches right now. `Polling`
14/// only happens if the native backend failed to construct at all (rare —
15/// e.g. inotify unavailable) — see `SPEC_SHARED_FS_WATCHER_FRAMEWORK_2026_08_07.md`
16/// §4 point 2. Once picked at pool construction, this doesn't change for the
17/// pool's lifetime; per-path failures are tracked in `degraded_paths`
18/// instead, independent of which backend is in use.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum WatchBackend {
21    Native,
22    Polling,
23}
24
25/// Snapshot of the pool's current health, for diagnostics (`muxlog`,
26/// eventually a UI surface — not built here, see spec §6 non-goals).
27#[derive(Debug, Clone)]
28#[allow(dead_code)] // Diagnostics surface, SPEC_SHARED_FS_WATCHER_FRAMEWORK_2026_08_07.md §4 point 4.
29pub struct FsWatchHealth {
30    pub backend: WatchBackend,
31    pub active_watches: usize,
32    /// Paths that failed to (re-)establish a watch on their last attempt,
33    /// paired with the error. Retried in the background per
34    /// `RETRY_BACKOFF`/the periodic sweep; a path leaves this list the
35    /// moment any attempt succeeds.
36    pub degraded_paths: Vec<(PathBuf, String)>,
37}
38
39/// Backoff schedule for retrying a failed `watch()` call, per spec §4 point
40/// 1 — bounded at 3 attempts over ~4.2s total, not indefinite: a path that's
41/// still failing after this moves into `degraded_paths` and is picked up by
42/// the periodic health sweep (`pool.rs`'s `HEALTH_SWEEP_INTERVAL`) instead of
43/// spinning a dedicated retry loop forever.
44pub const RETRY_BACKOFF: [Duration; 3] = [
45    Duration::from_millis(200),
46    Duration::from_millis(800),
47    Duration::from_millis(3200),
48];
49
50/// How often the background sweep re-arms every currently-*degraded*
51/// path, self-healing a watch that failed to establish (or a prior sweep
52/// found dead) without needing a dedicated retry loop running forever.
53/// **Deliberately scoped to degraded targets only, not every subscribed
54/// path** — an earlier version of this sweep re-issued `watch()`
55/// unconditionally on everything, every tick, to also catch a *silently*
56/// dead healthy-looking watch (inotify instance-limit churn, a watched
57/// directory deleted and recreated at a new inode, a flaky network mount).
58/// That cost more than it bought: `notify` 7.0.0's Windows backend leaks a
59/// File + Semaphore handle pair on every redundant `watch()` call (no
60/// cleanup of the previous entry), and even with an `unwatch()` first to
61/// avoid the leak, the gap between `unwatch()` and `watch()` drops event
62/// coverage for consumers with no reconciliation backstop of their own
63/// (`config_watcher_fs`, `EditorFileWatcher`, `MediaFileWatcher`). See
64/// `pool.rs`'s `sweep()` and
65/// `docs/status/STATUS_FS_WATCH_SWEEP_HANDLE_LEAK_2026_08_22.md` for the
66/// full history and the accepted trade-off (a truly silent death of an
67/// already-healthy watch is no longer self-healed by this sweep).
68pub const HEALTH_SWEEP_INTERVAL: Duration = Duration::from_secs(30);
69
70#[derive(Default)]
71pub(super) struct HealthState {
72    degraded: Mutex<HashMap<PathBuf, String>>,
73}
74
75impl HealthState {
76    pub(super) fn mark_degraded(&self, path: PathBuf, err: String) {
77        self.degraded.lock().unwrap().insert(path, err);
78    }
79
80    pub(super) fn clear_degraded(&self, path: &std::path::Path) {
81        self.degraded.lock().unwrap().remove(path);
82    }
83
84    pub(super) fn is_degraded(&self, path: &std::path::Path) -> bool {
85        self.degraded.lock().unwrap().contains_key(path)
86    }
87
88    pub(super) fn snapshot(&self) -> Vec<(PathBuf, String)> {
89        self.degraded
90            .lock()
91            .unwrap()
92            .iter()
93            .map(|(p, e)| (p.clone(), e.clone()))
94            .collect()
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101
102    #[test]
103    fn mark_then_clear_round_trips() {
104        let state = HealthState::default();
105        let path = PathBuf::from("/tmp/watched-thing");
106        state.mark_degraded(path.clone(), "permission denied".to_string());
107        assert_eq!(state.snapshot(), vec![(path.clone(), "permission denied".to_string())]);
108
109        state.clear_degraded(&path);
110        assert!(state.snapshot().is_empty());
111    }
112
113    #[test]
114    fn clearing_an_unknown_path_is_a_no_op() {
115        let state = HealthState::default();
116        state.clear_degraded(std::path::Path::new("/never/watched"));
117        assert!(state.snapshot().is_empty());
118    }
119
120    #[test]
121    fn retry_backoff_is_bounded_and_increasing() {
122        assert_eq!(RETRY_BACKOFF.len(), 3);
123        assert!(RETRY_BACKOFF[0] < RETRY_BACKOFF[1]);
124        assert!(RETRY_BACKOFF[1] < RETRY_BACKOFF[2]);
125    }
126}