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}