Skip to main content

agentmux_srv/backend/fs_watch/
pool.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use notify::{EventKind, RecursiveMode, Watcher};
10use tokio::sync::{broadcast, mpsc};
11
12use super::recovery::{HealthState, WatchBackend, FsWatchHealth, RETRY_BACKOFF, HEALTH_SWEEP_INTERVAL};
13
14/// Broadcast channel capacity. Generous relative to real fs-event volume
15/// (file-save bursts, not high-frequency streams) — a slow consumer would
16/// need to fall behind by this many raw events before `RecvError::Lagged`,
17/// at which point it should resync from scratch rather than assume it saw
18/// everything (same "wake signal, not a guaranteed delivery log" contract
19/// the existing three watchers already have via their own debounce logic).
20const EVENT_CHANNEL_CAPACITY: usize = 1024;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum FsWatchEventKind {
24    Created,
25    Modified,
26    Removed,
27    Other,
28}
29
30impl From<EventKind> for FsWatchEventKind {
31    fn from(kind: EventKind) -> Self {
32        match kind {
33            EventKind::Create(_) => FsWatchEventKind::Created,
34            EventKind::Modify(_) => FsWatchEventKind::Modified,
35            EventKind::Remove(_) => FsWatchEventKind::Removed,
36            _ => FsWatchEventKind::Other,
37        }
38    }
39}
40
41#[derive(Debug, Clone)]
42pub struct FsWatchEvent {
43    pub path: PathBuf,
44    pub kind: FsWatchEventKind,
45}
46
47/// Handle returned by `subscribe_file`/`subscribe_dir`. Holding this alive
48/// is not required (unlike the raw `notify::Watcher`, which must be kept
49/// alive by its owner) — the pool itself owns the underlying watcher.
50/// `unsubscribe` takes this by value so a caller can't accidentally
51/// unsubscribe the same handle twice.
52#[derive(Debug, Clone)]
53pub struct Subscription {
54    /// What's actually registered with the OS watcher — the parent
55    /// directory for a file subscription (see module doc: this is the
56    /// baked-in atomic-rename-safety rule), or the directory itself for a
57    /// directory subscription.
58    watch_target: PathBuf,
59}
60
61struct WatchEntry {
62    refcount: usize,
63    mode: RecursiveMode,
64}
65
66struct Inner {
67    watcher: Option<Box<dyn Watcher + Send>>,
68    targets: HashMap<PathBuf, WatchEntry>,
69    /// Polls WSL share paths (see [`Inner::watcher_for`]); built on first use.
70    share_poller: Option<Box<dyn Watcher + Send>>,
71    /// Where the share poller's events go: the same stream as `watcher`'s.
72    raw_tx: mpsc::UnboundedSender<notify::Result<notify::Event>>,
73}
74
75/// How often a WSL share folder is polled for changes.
76const SHARE_POLL_INTERVAL: Duration = Duration::from_secs(2);
77
78impl Inner {
79    /// The watcher for `target`. A WSL distro's folder through
80    /// `\\wsl.localhost` (`remote::wsl_fs`) gets a polling watcher: Windows
81    /// delivers no change notifications for that share (checked on Windows 11:
82    /// none for a change made in the distro, and none for one made through the
83    /// share), so a native watch there would succeed and stay silent.
84    fn watcher_for(&mut self, target: &Path) -> Option<&mut Box<dyn Watcher + Send>> {
85        if crate::backend::remote::wsl_fs::share_of(target).is_none() {
86            return self.watcher.as_mut();
87        }
88        if self.share_poller.is_none() {
89            let tx = self.raw_tx.clone();
90            let config = notify::Config::default().with_poll_interval(SHARE_POLL_INTERVAL);
91            match notify::PollWatcher::new(move |res: notify::Result<notify::Event>| { let _ = tx.send(res); }, config) {
92                Ok(w) => self.share_poller = Some(Box::new(w)),
93                Err(e) => tracing::warn!(error = %e, "fs_watch: no polling watcher for WSL folders"),
94            }
95        }
96        self.share_poller.as_mut()
97    }
98}
99
100pub struct FsWatchPool {
101    inner: Mutex<Inner>,
102    health: HealthState,
103    backend: WatchBackend,
104    tx: broadcast::Sender<FsWatchEvent>,
105}
106
107impl FsWatchPool {
108    /// Construct the pool and start its background event-bridging and
109    /// health-sweep tasks. Always succeeds — if even the `PollWatcher`
110    /// fallback fails to construct (practically never happens; it's pure
111    /// userspace polling), the pool still comes up with `watcher: None` and
112    /// every subscribe call fails cleanly into `degraded_paths` rather than
113    /// panicking or requiring every caller to handle an `Option<Arc<Self>>`
114    /// at the top level. Live-update is additive everywhere it's used.
115    pub fn new() -> Arc<Self> {
116        let (raw_tx, mut raw_rx) = mpsc::unbounded_channel::<notify::Result<notify::Event>>();
117        let (watcher, backend) = construct_watcher(raw_tx.clone());
118
119        let (broadcast_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
120
121        let this = Arc::new(Self {
122            inner: Mutex::new(Inner { watcher, targets: HashMap::new(), share_poller: None, raw_tx }),
123            health: HealthState::default(),
124            backend,
125            tx: broadcast_tx,
126        });
127
128        // Bridge notify's sync callback (already funneled into raw_rx by
129        // construct_watcher's closure) into the broadcast stream. Mirrors
130        // the sync-callback -> mpsc -> async-task shape all three existing
131        // watchers already use, just shared once here instead of three
132        // times.
133        let bridge = this.clone();
134        tokio::spawn(async move {
135            while let Some(res) = raw_rx.recv().await {
136                match res {
137                    Ok(event) => {
138                        let kind = FsWatchEventKind::from(event.kind);
139                        if matches!(kind, FsWatchEventKind::Other) {
140                            continue;
141                        }
142                        for path in event.paths {
143                            let _ = bridge.tx.send(FsWatchEvent { path, kind });
144                        }
145                    }
146                    Err(e) => bridge.handle_backend_error(e),
147                }
148            }
149        });
150
151        // Periodic self-healing sweep, scoped to already-degraded targets —
152        // see `sweep()`'s own doc comment for the full rationale (a
153        // healthy-looking target is deliberately left untouched, to avoid
154        // both a handle leak and a real event-coverage gap notify's Windows
155        // backend would otherwise cost on every tick).
156        let sweeper = this.clone();
157        tokio::spawn(async move {
158            let mut tick = tokio::time::interval(HEALTH_SWEEP_INTERVAL);
159            loop {
160                tick.tick().await;
161                sweeper.sweep();
162            }
163        });
164
165        this
166    }
167
168    /// Watch the file at `path` (specifically: its parent directory,
169    /// non-recursively — see module doc). Returns a fresh `Subscription`
170    /// even when the underlying OS watch is currently failing (tracked in
171    /// `degraded_paths` instead) — a degraded subscription still becomes
172    /// live automatically once retry/sweep succeeds, with no action needed
173    /// from the caller.
174    pub fn subscribe_file(self: &Arc<Self>, path: &Path) -> Subscription {
175        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
176        let target = canonical.parent().map(Path::to_path_buf).unwrap_or(canonical.clone());
177        self.subscribe_target(target, RecursiveMode::NonRecursive)
178    }
179
180    /// Watch the directory at `path` directly (non-recursive — matching
181    /// `media_file_watcher.rs`'s existing behavior; extension/content
182    /// filtering is the caller's concern, not the pool's).
183    pub fn subscribe_dir(self: &Arc<Self>, path: &Path) -> Subscription {
184        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
185        self.subscribe_target(canonical, RecursiveMode::NonRecursive)
186    }
187
188    /// Watch `path` and everything under it (widget packages: an edit deep in
189    /// a package folder reloads it).
190    pub fn subscribe_dir_recursive(self: &Arc<Self>, path: &Path) -> Subscription {
191        let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
192        self.subscribe_target(canonical, RecursiveMode::Recursive)
193    }
194
195    fn subscribe_target(self: &Arc<Self>, target: PathBuf, mode: RecursiveMode) -> Subscription {
196        let is_new = {
197            let mut inner = self.inner.lock().unwrap();
198            let entry = inner.targets.entry(target.clone()).or_insert_with(|| WatchEntry { refcount: 0, mode });
199            entry.refcount += 1;
200            entry.refcount == 1
201        };
202
203        if is_new {
204            self.start_watch(target.clone(), mode);
205        }
206
207        Subscription { watch_target: target }
208    }
209
210    /// Stop watching on behalf of `sub`. Idempotent-safe: unsubscribing the
211    /// same handle twice would underflow the refcount, so this takes `self`
212    /// by shared ref but `sub` by value specifically to discourage reuse —
213    /// callers that need multiple independent subscriptions to the same
214    /// path should call `subscribe_file`/`subscribe_dir` once per logical
215    /// subscriber, matching how `editor_file_watcher.rs` already tracks one
216    /// entry per `block_id` today.
217    pub fn unsubscribe(&self, sub: Subscription) {
218        let should_unwatch = {
219            let mut inner = self.inner.lock().unwrap();
220            let Some(entry) = inner.targets.get_mut(&sub.watch_target) else {
221                return;
222            };
223            entry.refcount = entry.refcount.saturating_sub(1);
224            let empty = entry.refcount == 0;
225            if empty {
226                inner.targets.remove(&sub.watch_target);
227            }
228            empty
229        };
230
231        if should_unwatch {
232            let mut inner = self.inner.lock().unwrap();
233            if let Some(w) = inner.watcher_for(&sub.watch_target) {
234                let _ = w.unwatch(&sub.watch_target);
235            }
236            drop(inner);
237            self.health.clear_degraded(&sub.watch_target);
238        }
239    }
240
241    /// Raw change events for every currently-watched target, pool-wide.
242    /// Each call returns an independent receiver — subscribe to this once
243    /// per domain module (not once per file), and filter down to the paths
244    /// that module actually cares about, same as today's per-watcher
245    /// `Inner.watched_paths` filtering.
246    pub fn events(&self) -> broadcast::Receiver<FsWatchEvent> {
247        self.tx.subscribe()
248    }
249
250    #[allow(dead_code)] // Diagnostics surface, SPEC_SHARED_FS_WATCHER_FRAMEWORK_2026_08_07.md §4 point 4.
251    pub fn health(&self) -> FsWatchHealth {
252        let active_watches = self.inner.lock().unwrap().targets.len();
253        FsWatchHealth {
254            backend: self.backend,
255            active_watches,
256            degraded_paths: self.health.snapshot(),
257        }
258    }
259
260    /// First attempt at a new target, plus a bounded retry-with-backoff if
261    /// it fails — see `RETRY_BACKOFF`'s doc comment. Runs the retries on a
262    /// spawned task so `subscribe_file`/`subscribe_dir` never blocks on it.
263    fn start_watch(self: &Arc<Self>, target: PathBuf, mode: RecursiveMode) {
264        let first_attempt = self.try_watch(&target, mode);
265        if first_attempt.is_ok() {
266            return;
267        }
268        let err = first_attempt.unwrap_err();
269        tracing::warn!(path = %target.display(), error = %err, "fs_watch: initial watch failed, retrying with backoff");
270        self.health.mark_degraded(target.clone(), err);
271
272        let this = self.clone();
273        tokio::spawn(async move {
274            for delay in RETRY_BACKOFF {
275                tokio::time::sleep(delay).await;
276                // The subscription may have been torn down (refcount back to
277                // zero) while we were waiting — don't resurrect a watch
278                // nobody wants anymore.
279                if !this.inner.lock().unwrap().targets.contains_key(&target) {
280                    return;
281                }
282                match this.try_watch(&target, mode) {
283                    Ok(()) => {
284                        this.health.clear_degraded(&target);
285                        tracing::info!(path = %target.display(), "fs_watch: watch recovered after retry");
286                        return;
287                    }
288                    Err(e) => {
289                        this.health.mark_degraded(target.clone(), e);
290                    }
291                }
292            }
293            tracing::warn!(
294                path = %target.display(),
295                "fs_watch: still degraded after all retries — will keep retrying via the periodic health sweep"
296            );
297        });
298    }
299
300    fn try_watch(&self, target: &Path, mode: RecursiveMode) -> Result<(), String> {
301        let mut inner = self.inner.lock().unwrap();
302        let Some(watcher) = inner.watcher_for(target) else {
303            return Err("no fs watcher backend available".to_string());
304        };
305        watcher.watch(target, mode).map_err(|e| e.to_string())
306    }
307
308    /// Handle an async error surfaced by the `notify` backend's own callback
309    /// (piped through `raw_rx` in `Self::new`'s bridge task). Extracted out
310    /// of that task so a test can drive a real `notify::Error` through it
311    /// directly (reagent P2 on #2722: the original three sweep regression
312    /// tests all simulated degradation via a direct `health.mark_degraded`
313    /// call, so none of them actually exercised this fn or proved that
314    /// `notify::Error::paths` lines up with the exact `PathBuf` key
315    /// `inner.targets` uses — the assumption `sweep()`'s `is_degraded()`
316    /// filter depends on to ever re-arm a target that failed this way).
317    ///
318    /// `notify::Error` carries the affected path(s) when it can attribute
319    /// one; mark each degraded so the next sweep re-arms it. A path-less
320    /// error (rare — not attributable to any specific target) can only be
321    /// logged.
322    fn handle_backend_error(&self, e: notify::Error) {
323        tracing::warn!(error = %e, paths = ?e.paths, "fs_watch: backend reported an error");
324        for path in &e.paths {
325            self.health.mark_degraded(path.clone(), e.to_string());
326        }
327    }
328
329    /// Re-arm every currently-*degraded* target — the backstop that lets a
330    /// path recover from a failed `watch()` attempt (at `start_watch` time
331    /// or a prior sweep) without a dedicated retry loop running forever.
332    ///
333    /// **Deliberately does NOT touch a target that already looks healthy**
334    /// (codex P2 on #2722, revising this fn's first version): the original
335    /// fix here unconditionally called `unwatch()` then `watch()` on every
336    /// subscribed target every tick, reasoning that this was needed to
337    /// detect a watch that died *silently* (no error ever surfaced). Two
338    /// problems with that, both real:
339    /// 1. **The handle leak this fn exists to fix** — `notify` 7.0.0's
340    ///    Windows backend (`add_watch` in the vendored `windows.rs`) opens a
341    ///    brand-new `CreateFileW` handle + `CreateSemaphoreW` on *every*
342    ///    `watch()` call, unconditionally, and silently drops the previous
343    ///    entry's handles with no cleanup — see
344    ///    `docs/status/STATUS_FS_WATCH_SWEEP_HANDLE_LEAK_2026_08_22.md`.
345    /// 2. **A real coverage gap** — `unwatch()` then `watch()` leaves the
346    ///    target genuinely unwatched for the gap between the two calls.
347    ///    `native_memory_drift`'s slow-path reconciliation sweep backstops a
348    ///    missed event there, but `config_watcher_fs`, `EditorFileWatcher`,
349    ///    and `MediaFileWatcher` all rely solely on the broadcast stream —
350    ///    a create/modify landing in that gap would go unnoticed until some
351    ///    *later*, unrelated change on the same path happened to trigger a
352    ///    refresh.
353    ///
354    /// Skipping healthy targets fixes both: nothing is ever double-watched
355    /// (no leak) and a healthy watch is never touched (no gap). The
356    /// **honestly-bounded trade-off**: a watch that fails *silently* — dies
357    /// without `notify` or the OS ever reporting an error on it — is no
358    /// longer self-healed by this sweep, only a watch that's already known
359    /// degraded (an explicit prior failure). Accepted because a truly
360    /// silent death has no confirmed occurrence in this codebase's history
361    /// (the scenarios `HEALTH_SWEEP_INTERVAL`'s own doc comment lists —
362    /// inotify instance-limit churn, flaky network mounts — are Linux-
363    /// flavored concerns on a Windows-primary codebase), against a
364    /// certain, systematic cost (leak or gap, every target, every tick) for
365    /// every other watch the alternative would have imposed.
366    ///
367    /// **Race with concurrent `unsubscribe()` — closed by re-checking under
368    /// one held lock** (reagent P1, second review round on #2722): the
369    /// degraded-target list above is a snapshot; without re-verification, a
370    /// concurrent `unsubscribe()` dropping the last subscriber for one of
371    /// these targets between the snapshot and this loop's `watch()` call
372    /// would remove it from `targets` and `unwatch()` it, while this sweep's
373    /// in-flight `watch()` call still re-established a native watch for the
374    /// now-untracked path — orphaning exactly the handle pair this fn exists
375    /// to stop leaking, just via a race instead of per-tick re-watching.
376    /// Each target's re-check + `unwatch()` + `watch()` now happens under a
377    /// single `inner` lock acquisition (not `try_watch()`, which takes its
378    /// own lock and would deadlock if called while this one is held) — a
379    /// concurrent `unsubscribe()` either finishes its own removal entirely
380    /// before this runs (this loop then sees the target is gone and skips
381    /// it) or entirely after (its own `unwatch()` then correctly tears down
382    /// the watch this loop just re-armed) — `Mutex` serializes the two, so
383    /// no interleaving in between is possible.
384    fn sweep(&self) {
385        let targets: Vec<PathBuf> = {
386            let inner = self.inner.lock().unwrap();
387            inner
388                .targets
389                .keys()
390                .filter(|p| self.health.is_degraded(p))
391                .cloned()
392                .collect()
393        };
394        for target in targets {
395            self.rearm_if_still_subscribed(target);
396        }
397    }
398
399    /// The per-target half of a sweep: re-verify `target` is still
400    /// subscribed, then `unwatch()` + `watch()` it, all under one held
401    /// `inner` lock (see `sweep()`'s doc comment for why the re-check and
402    /// the single lock both matter). Split out from `sweep()`'s loop body
403    /// so a test can call it directly with a `target` captured *before* a
404    /// real `unsubscribe()` ran — deterministically reproducing "sweep
405    /// already decided to process this target, then it was removed before
406    /// the action ran" without needing to win a real thread-scheduling
407    /// race.
408    fn rearm_if_still_subscribed(&self, target: PathBuf) {
409        let mut inner = self.inner.lock().unwrap();
410        // Re-check: still subscribed? A concurrent unsubscribe() may have
411        // removed it since the caller's snapshot — don't resurrect a watch
412        // nobody wants anymore.
413        let Some(mode) = inner.targets.get(&target).map(|e| e.mode) else {
414            return;
415        };
416        let Some(w) = inner.watcher_for(&target) else {
417            return;
418        };
419        // Best-effort: a never-established watch has nothing to unwatch —
420        // the watch() call below (re)establishes it regardless of whether
421        // this succeeds.
422        let _ = w.unwatch(&target);
423        let result = w.watch(&target, mode).map_err(|e| e.to_string());
424        drop(inner);
425        match result {
426            Ok(()) => self.health.clear_degraded(&target),
427            Err(e) => self.health.mark_degraded(target, e),
428        }
429    }
430}
431
432/// Build the watcher backend: prefer the native OS backend
433/// (inotify/FSEvents/ReadDirectoryChangesW); fall back to `notify`'s
434/// stat-based `PollWatcher` if the native backend can't even construct
435/// (practically never on the platforms AgentMux ships for, but real on some
436/// restricted/containerized environments). Returns `(None, Native)` only if
437/// both fail to construct — recorded as `WatchBackend::Native` since that
438/// was the attempted backend; every `subscribe_*` call will immediately
439/// fail into `degraded_paths` until the process restarts, matching the
440/// existing three watchers' "live-update becomes unavailable, nothing else
441/// breaks" posture, just now with a health signal instead of silence.
442fn construct_watcher(
443    tx: mpsc::UnboundedSender<notify::Result<notify::Event>>,
444) -> (Option<Box<dyn Watcher + Send>>, WatchBackend) {
445    let tx_native = tx.clone();
446    match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
447        let _ = tx_native.send(res);
448    }) {
449        Ok(w) => return (Some(Box::new(w)), WatchBackend::Native),
450        Err(e) => {
451            tracing::warn!(error = %e, "fs_watch: native backend unavailable, falling back to polling");
452        }
453    }
454
455    let poll_config = notify::Config::default().with_poll_interval(Duration::from_secs(2));
456    match notify::PollWatcher::new(move |res: notify::Result<notify::Event>| { let _ = tx.send(res); }, poll_config) {
457        Ok(w) => (Some(Box::new(w)), WatchBackend::Polling),
458        Err(e) => {
459            tracing::error!(error = %e, "fs_watch: polling fallback also failed to construct — live-update unavailable this session");
460            (None, WatchBackend::Native)
461        }
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468    use std::time::Duration as StdDuration;
469
470    /// A WSL share folder is polled, whatever the native backend is, and every
471    /// other path keeps the native watcher.
472    #[cfg(windows)]
473    #[test]
474    fn a_wsl_share_folder_gets_the_polling_watcher() {
475        let (raw_tx, _raw_rx) = mpsc::unbounded_channel();
476        let mut inner = Inner { watcher: None, targets: HashMap::new(), share_poller: None, raw_tx };
477        assert!(inner.watcher_for(Path::new(r"C:\Users\me\proj")).is_none(), "no native backend here");
478        assert!(inner.watcher_for(Path::new(r"\\?\UNC\wsl.localhost\Ubuntu\home\u")).is_some());
479        assert!(inner.share_poller.is_some(), "built on first use");
480        assert!(inner.watcher_for(Path::new(r"\\wsl$\Debian\srv")).is_some(), "and shared");
481    }
482
483    /// End to end on a Windows machine with WSL: a file written inside a
484    /// distro shows up as an event for its folder watched through the share.
485    /// Needs a distro, so it is not run on CI. Run manually with the distro to
486    /// use: `WSL_TEST_DISTRO=Ubuntu cargo test -p agentmux-srv --bin
487    /// agentmux-srv a_change_inside_a_distro_reaches_a_share_watch -- --ignored`
488    #[cfg(windows)]
489    #[tokio::test]
490    #[ignore]
491    async fn a_change_inside_a_distro_reaches_a_share_watch() {
492        let distro = std::env::var("WSL_TEST_DISTRO").unwrap_or_else(|_| "Ubuntu".to_string());
493        let dir = crate::backend::remote::wsl_fs::share_path(&distro, "/tmp");
494        // What `canonicalize` really returns for a share path is still one: the
495        // editor's write rule and the protected folders read that form.
496        let canonical = Path::new(&dir).canonicalize().expect("the share path resolves");
497        assert_eq!(
498            crate::backend::remote::wsl_fs::split_share(&canonical.to_string_lossy()).map(|s| s.linux),
499            Some("/tmp".to_string()),
500            "{}",
501            canonical.display()
502        );
503        let pool = FsWatchPool::new();
504        let mut events = pool.events();
505        let _sub = pool.subscribe_dir(Path::new(&dir));
506        let name = format!("am-share-watch-{}", std::process::id());
507        let status = std::process::Command::new("wsl.exe")
508            .args(["-d", &distro, "--exec", "sh", "-c", &format!("echo x > /tmp/{name}")])
509            .status()
510            .expect("wsl.exe runs");
511        assert!(status.success());
512        let seen = tokio::time::timeout(StdDuration::from_secs(10), async {
513            loop {
514                if let Ok(ev) = events.recv().await {
515                    if ev.path.to_string_lossy().ends_with(&name) {
516                        return ev;
517                    }
518                }
519            }
520        })
521        .await;
522        let _ = std::process::Command::new("wsl.exe")
523            .args(["-d", &distro, "--exec", "rm", "-f", &format!("/tmp/{name}")])
524            .status();
525        assert!(seen.is_ok(), "no event for {name} in {dir} within 10s");
526    }
527
528    #[tokio::test]
529    async fn subscribe_unsubscribe_refcounting_does_not_panic() {
530        let pool = FsWatchPool::new();
531        let tmp = std::env::temp_dir().join("agentmux_fs_watch_pool_test");
532        std::fs::create_dir_all(&tmp).unwrap();
533
534        let sub1 = pool.subscribe_dir(&tmp);
535        let sub2 = pool.subscribe_dir(&tmp);
536        assert_eq!(pool.health().active_watches, 1, "two subscriptions to the same dir share one OS watch");
537
538        pool.unsubscribe(sub1);
539        assert_eq!(pool.health().active_watches, 1, "still watched — sub2 is still active");
540
541        pool.unsubscribe(sub2);
542        assert_eq!(pool.health().active_watches, 0, "last subscriber gone -> watch torn down");
543
544        let _ = std::fs::remove_dir_all(&tmp);
545    }
546
547    /// Regression test for reagent's P2 on #2722: the round-2 fix
548    /// (`handle_backend_error`, née an inline branch in the bridge task)
549    /// only helps `sweep()` re-arm a target if `notify::Error::paths`
550    /// actually lines up with the exact `PathBuf` key `inner.targets` uses
551    /// — otherwise `mark_degraded` records a path `is_degraded()` will
552    /// faithfully report as degraded, but that never matches any key
553    /// `sweep()`'s `targets.keys().filter(is_degraded)` iterates, so the
554    /// re-arm silently never happens for any *real* backend error. Uses a
555    /// real subscription's `watch_target` (produced by the same
556    /// `canonicalize()` path a live `notify` callback's error would need to
557    /// match) rather than a hand-built path, so this actually exercises
558    /// that assumption instead of just asserting `handle_backend_error`
559    /// calls `mark_degraded` (which the original three sweep tests already
560    /// covered indirectly by calling `mark_degraded` directly).
561    #[tokio::test]
562    async fn handle_backend_error_degrades_the_exact_target_key_sweep_looks_up() {
563        let pool = FsWatchPool::new();
564        let dir = std::env::temp_dir().join("agentmux_fs_watch_pool_backend_error_test");
565        let _ = std::fs::remove_dir_all(&dir);
566        std::fs::create_dir_all(&dir).unwrap();
567
568        let sub = pool.subscribe_dir(&dir);
569        let target = sub.watch_target.clone();
570        assert!(!pool.health.is_degraded(&target), "precondition: not degraded yet");
571
572        pool.handle_backend_error(notify::Error::generic("simulated backend error").add_path(target.clone()));
573
574        assert!(
575            pool.health.is_degraded(&target),
576            "handle_backend_error() must mark the exact watch_target key degraded \
577             so sweep()'s is_degraded() filter (which iterates inner.targets' own \
578             keys) actually picks it up — a mismatched path here would silently \
579             defeat the whole reagent-P1 fix for any real notify backend error"
580        );
581
582        pool.unsubscribe(sub);
583        let _ = std::fs::remove_dir_all(&dir);
584    }
585
586    #[tokio::test]
587    async fn subscribe_file_watches_the_parent_directory() {
588        let pool = FsWatchPool::new();
589        let dir = std::env::temp_dir().join("agentmux_fs_watch_pool_file_test");
590        std::fs::create_dir_all(&dir).unwrap();
591        let file = dir.join("thing.md");
592        std::fs::write(&file, "hello").unwrap();
593
594        let sub = pool.subscribe_file(&file);
595        assert_eq!(sub.watch_target, dir.canonicalize().unwrap());
596
597        pool.unsubscribe(sub);
598        let _ = std::fs::remove_dir_all(&dir);
599    }
600
601    #[tokio::test]
602    async fn a_change_event_is_observable_on_the_broadcast_stream() {
603        let pool = FsWatchPool::new();
604        let dir = std::env::temp_dir().join("agentmux_fs_watch_pool_event_test");
605        let _ = std::fs::remove_dir_all(&dir);
606        std::fs::create_dir_all(&dir).unwrap();
607        let file = dir.join("watched.md");
608        std::fs::write(&file, "v1").unwrap();
609
610        let mut events = pool.events();
611        let sub = pool.subscribe_file(&file);
612        // Give the native backend a moment to actually register before we
613        // write — otherwise this is a flaky "wrote before the watch was live"
614        // race, not a real assertion about the pool's behavior.
615        tokio::time::sleep(StdDuration::from_millis(200)).await;
616        std::fs::write(&file, "v2").unwrap();
617
618        let seen = tokio::time::timeout(StdDuration::from_secs(5), async {
619            loop {
620                match events.recv().await {
621                    Ok(ev) if ev.path.file_name() == file.file_name() => return true,
622                    Ok(_) => continue,
623                    Err(_) => return false,
624                }
625            }
626        })
627        .await
628        .unwrap_or(false);
629
630        assert!(seen, "expected to observe a change event for the watched file");
631
632        pool.unsubscribe(sub);
633        let _ = std::fs::remove_dir_all(&dir);
634    }
635
636    #[tokio::test]
637    async fn health_reports_the_constructed_backend() {
638        let pool = FsWatchPool::new();
639        // On every platform this actually runs on in CI, the native backend
640        // constructs successfully — this just pins that expectation rather
641        // than the (currently untestable-without-platform-mocking) fallback
642        // path, which recovery.rs's own unit tests cover at the bookkeeping
643        // level instead.
644        assert_eq!(pool.health().backend, WatchBackend::Native);
645    }
646
647    /// Regression test for
648    /// `docs/status/STATUS_FS_WATCH_SWEEP_HANDLE_LEAK_2026_08_22.md`, healthy
649    /// side: `sweep()` used to call `watch()` unconditionally on every
650    /// subscribed target on every `HEALTH_SWEEP_INTERVAL` tick, leaking a
651    /// File + Semaphore handle pair per call (see companion test below for
652    /// why) — the fix (codex P2 on #2722) is to skip a target entirely once
653    /// it's healthy, not just to `unwatch()` before re-`watch()`-ing it (that
654    /// still leaked nothing, but cost every other watcher a real event-
655    /// coverage gap). Simulates many sweep ticks on a healthy target and
656    /// asserts both that handle count does not grow *and* that a healthy
657    /// watch is never touched at all.
658    #[cfg(target_os = "windows")]
659    #[tokio::test]
660    async fn sweep_leaves_a_healthy_target_untouched() {
661        let pool = FsWatchPool::new();
662        let tmp = std::env::temp_dir().join("agentmux_fs_watch_pool_sweep_healthy_test");
663        let _ = std::fs::remove_dir_all(&tmp);
664        std::fs::create_dir_all(&tmp).unwrap();
665
666        let sub = pool.subscribe_dir(&tmp);
667        // Let the initial watch settle before measuring.
668        tokio::time::sleep(StdDuration::from_millis(100)).await;
669        assert!(!pool.health.is_degraded(&sub.watch_target), "a freshly-established watch must be healthy");
670
671        let pid = std::process::id();
672        let before = crate::backend::sysinfo::process_handle_count(pid)
673            .expect("own handle count must be queryable");
674
675        const SWEEPS: u32 = 200;
676        for _ in 0..SWEEPS {
677            pool.sweep();
678        }
679
680        let after = crate::backend::sysinfo::process_handle_count(pid)
681            .expect("own handle count must be queryable");
682        let grew_by = after.saturating_sub(before);
683
684        pool.unsubscribe(sub);
685        let _ = std::fs::remove_dir_all(&tmp);
686
687        assert!(
688            grew_by < SWEEPS / 2,
689            "handle count grew by {grew_by} over {SWEEPS} sweep() calls on a \
690             healthy target (before={before}, after={after}) — a healthy \
691             target must never be re-watched at all"
692        );
693    }
694
695    /// Companion to the test above: exercises the actual `unwatch()` +
696    /// `watch()` re-arm path that runs for a *degraded* target, proving that
697    /// path itself doesn't leak either (the original bug — `notify` 7.0.0's
698    /// Windows backend opens a new `CreateFileW` + `CreateSemaphoreW` on
699    /// every `watch()` call, unconditionally, and its internal map's
700    /// `insert()` silently drops the previous entry's handles with no
701    /// cleanup). Forces the target into `degraded` state directly before
702    /// every sweep so each call really re-arms it, then asserts handle
703    /// count does not grow linearly with sweep count.
704    #[cfg(target_os = "windows")]
705    #[tokio::test]
706    async fn sweep_does_not_leak_a_handle_pair_per_call_for_a_degraded_target() {
707        let pool = FsWatchPool::new();
708        let tmp = std::env::temp_dir().join("agentmux_fs_watch_pool_sweep_degraded_test");
709        let _ = std::fs::remove_dir_all(&tmp);
710        std::fs::create_dir_all(&tmp).unwrap();
711
712        let sub = pool.subscribe_dir(&tmp);
713        tokio::time::sleep(StdDuration::from_millis(100)).await;
714        let target = sub.watch_target.clone();
715
716        let pid = std::process::id();
717        let before = crate::backend::sysinfo::process_handle_count(pid)
718            .expect("own handle count must be queryable");
719
720        const SWEEPS: u32 = 200;
721        for _ in 0..SWEEPS {
722            // Force degraded so this tick's sweep() actually re-arms the
723            // watch (unwatch() + watch()), same as it would for a real
724            // failure — clear_degraded() on success would otherwise make
725            // only the first sweep do real work.
726            pool.health.mark_degraded(target.clone(), "simulated for test".to_string());
727            pool.sweep();
728        }
729
730        let after = crate::backend::sysinfo::process_handle_count(pid)
731            .expect("own handle count must be queryable");
732        let grew_by = after.saturating_sub(before);
733
734        pool.unsubscribe(sub);
735        let _ = std::fs::remove_dir_all(&tmp);
736
737        assert!(
738            grew_by < SWEEPS / 2,
739            "handle count grew by {grew_by} over {SWEEPS} sweep() calls on a \
740             degraded target (before={before}, after={after}) — the \
741             unwatch()-before-watch() re-arm path must not leak a \
742             File+Semaphore pair per call"
743        );
744    }
745
746    /// Regression test for reagent's second-round P1 on #2722: `sweep()`
747    /// snapshots the degraded-target list, then acts on each one later.
748    /// Without re-verification, a concurrent `unsubscribe()` dropping the
749    /// last subscriber for one of these targets in that window would remove
750    /// it from `targets` (and `unwatch()` it), while this sweep's own
751    /// in-flight `watch()` call still re-established a native watch for the
752    /// now-untracked path — orphaning the handle pair forever, since nothing
753    /// is left to `unwatch()` it again.
754    ///
755    /// **Deterministic, not a timing-based race** (an earlier version of
756    /// this test tried to actually race `sweep()` against `unsubscribe()`
757    /// on separate tasks — unreliable: `unsubscribe()`'s critical section is
758    /// so much shorter than `spawn_blocking`'s own scheduling latency that
759    /// it essentially always completed entirely before or after `sweep()`
760    /// ran, never in the middle, so it couldn't reliably exercise the
761    /// vulnerable window either way). Instead, `sweep()`'s per-target action
762    /// is split into its own method
763    /// ([`rearm_if_still_subscribed`](Self::rearm_if_still_subscribed)) so
764    /// this test can call it directly with a target captured *before* a
765    /// real `unsubscribe()` runs — deterministically reproducing "sweep
766    /// already decided to process this target, then it was removed before
767    /// the action ran" without needing to win any race at all.
768    /// **Asserts the guard directly, not via OS handle counts.** An earlier
769    /// version of this test measured `process_handle_count()` across 200
770    /// rounds and asserted the total grew by less than half that. The
771    /// measurement is process-GLOBAL, so in a full `cargo test` run the
772    /// ~3400 other tests executing in parallel contribute their own file,
773    /// socket and thread churn to the same window — it failed in CI and
774    /// locally at `grew_by=103` against a threshold of 100 while passing in
775    /// isolation, i.e. it was measuring the test suite, not the code under
776    /// test. (It also had to be `#[cfg(target_os = "windows")]`, since
777    /// `process_handle_count` is Windows-only, so two of three platforms
778    /// got no protection at all.)
779    ///
780    /// The guard's observable signature is used instead:
781    /// `rearm_if_still_subscribed()` only ever touches degraded state on a
782    /// path that actually reaches `watch()` — `clear_degraded` on success,
783    /// `mark_degraded` on failure. Returning early, as it must for an
784    /// unsubscribed target, leaves the entry untouched. So the target is
785    /// marked degraded with a sentinel reason *after* the unsubscribe, and
786    /// the assertion is that the reason is still exactly that sentinel:
787    /// deterministic, unaffected by anything else running, and portable.
788    ///
789    /// **The REASON, not just the presence of a degraded entry** (Codex P2
790    /// on #3235, fixed here). Asserting only `is_degraded()` conflated two
791    /// different outcomes, because `mark_degraded` is also what runs when
792    /// `watch()` FAILS — so a bypassed guard whose `watch()` call errored
793    /// (permissions, resource exhaustion, an unavailable backend) left the
794    /// flag set and passed the test, which is precisely the
795    /// passes-under-broken-logic failure mode #3235 set out to remove.
796    /// Comparing the reason separates all three cases: sentinel intact =
797    /// returned early; entry gone = `watch()` succeeded; any other string =
798    /// `watch()` failed. The last two both mean the guard was bypassed.
799    ///
800    /// The `watcher.is_some()` precondition closes the remaining vacuous
801    /// path — a pool whose watcher failed to construct returns early at a
802    /// *different* guard, so the probe would hold for a reason unrelated to
803    /// what this test checks.
804    #[tokio::test]
805    async fn sweep_does_not_resurrect_a_target_unsubscribed_after_being_snapshotted() {
806        let pool = FsWatchPool::new();
807        let dir = std::env::temp_dir().join("agentmux_fs_watch_pool_sweep_race_test");
808        let _ = std::fs::remove_dir_all(&dir);
809        std::fs::create_dir_all(&dir).unwrap();
810
811        // A watcher that failed to construct makes `rearm_if_still_subscribed()`
812        // return early at its `inner.watcher` guard regardless of the
813        // targets re-check this test is about — which would pass the probe
814        // below vacuously. `FsWatchPool::new()` documents `watcher: None` as
815        // reachable, so assert it explicitly rather than silently skipping.
816        assert!(
817            pool.inner.lock().unwrap().watcher.is_some(),
818            "precondition: the pool must have a live watcher, or the stale-rearm \
819             path short-circuits before the re-check this test exercises"
820        );
821
822        let sub = pool.subscribe_dir(&dir);
823        // Captured now, exactly as sweep()'s own snapshot phase would —
824        // before the real unsubscribe() below runs.
825        let target = sub.watch_target.clone();
826
827        // The real teardown — exactly what a concurrent unsubscribe() does,
828        // landing (for this test) strictly *after* the target was
829        // "snapshotted" above but strictly *before* the re-arm action below.
830        pool.unsubscribe(sub);
831
832        // Mark degraded AFTER the unsubscribe, which clears the flag for a
833        // target it drops. This is the probe — see the doc comment: only a
834        // call that reaches `watch()` touches this entry again, and the
835        // REASON distinguishes which way it went.
836        const PROBE_REASON: &str = "probe sentinel — must survive a correctly-guarded stale rearm";
837        pool.health.mark_degraded(target.clone(), PROBE_REASON.to_string());
838
839        // sweep()'s per-target action, called directly with the stale
840        // (now-unsubscribed) target — the exact call sweep()'s loop would
841        // have made had it reached this target after losing the race.
842        pool.rearm_if_still_subscribed(target.clone());
843
844        // The REASON, not merely the presence of a degraded entry — see the
845        // doc comment for why the boolean alone is not a sufficient probe.
846        let reason_after = pool
847            .health
848            .snapshot()
849            .into_iter()
850            .find(|(p, _)| p == &target)
851            .map(|(_, reason)| reason);
852        let still_untracked = !pool.inner.lock().unwrap().targets.contains_key(&target);
853
854        let _ = std::fs::remove_dir_all(&dir);
855
856        assert!(
857            still_untracked,
858            "precondition: the target must still be absent from `targets` after \
859             unsubscribe — otherwise this test isn't exercising the stale-rearm \
860             path at all"
861        );
862        assert_eq!(
863            reason_after.as_deref(),
864            Some(PROBE_REASON),
865            "rearm_if_still_subscribed() reached watch() for a target that was \
866             unsubscribed after being snapshotted. The degraded reason is the probe: \
867             it is still `{PROBE_REASON}` only if the function returned early without \
868             touching health state. `None` means watch() SUCCEEDED (clear_degraded); \
869             any other string means watch() FAILED (mark_degraded overwrote the \
870             reason with the backend error). Either way the guard was bypassed and a \
871             native watch nobody tracks was re-established, orphaning the handle pair \
872             with nothing left to ever unwatch() it"
873        );
874    }
875}