teamctl_ui/watch.rs
1//! `notify`-based file-watch on the broker SQLite at `state/mailbox.db`.
2//!
3//! Replaces (or augments) the 1s long-poll established in PR-UI-2:
4//! when the platform supports `notify` (Linux inotify / macOS
5//! FSEvents / Windows ReadDirectoryChangesW), the run loop refreshes
6//! immediately on a `mailbox.db`-WAL/SHM write event. Platforms
7//! without `notify` support fall back to the 1s poll — same shape
8//! as the truecolor-detection capability fallback in PR-UI-1.
9//!
10//! The watcher writes to a shared `AtomicBool` "dirty" flag rather
11//! than emitting events through a channel, because the run loop
12//! already has its own poll cadence and we just need the watcher to
13//! say "something changed, refresh sooner than the deadline."
14
15use std::path::Path;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::Arc;
18
19use notify::{Event, EventKind, RecursiveMode, Watcher};
20
21/// Spawn a `notify` watcher rooted at the broker's `state/`
22/// directory. Returns `None` on platforms (or filesystems) where
23/// `notify` can't bring up a recommended watcher — the run loop
24/// then falls back to the 1s poll. The returned `Watch` keeps the
25/// underlying watcher alive; drop it to stop watching.
26pub struct Watch {
27 /// Shared dirty flag — flipped to `true` on every relevant
28 /// filesystem event. The run loop reads + clears it via
29 /// `take_dirty`.
30 pub dirty: Arc<AtomicBool>,
31 /// Holds the platform watcher so it doesn't drop and stop
32 /// emitting events. The field is read in
33 /// [`Watch::take_dirty`]'s impl boundary only, never directly.
34 _watcher: Option<Box<dyn Watcher + Send>>,
35}
36
37impl Watch {
38 /// Construct a watch with no underlying watcher. Used by tests
39 /// and as the fallback when `notify::recommended_watcher`
40 /// fails to initialise.
41 pub fn idle() -> Self {
42 Self {
43 dirty: Arc::new(AtomicBool::new(false)),
44 _watcher: None,
45 }
46 }
47
48 /// Try to build a recommended watcher rooted at `state_dir`.
49 /// Returns an idle (no-watcher) Watch on any failure so the
50 /// caller can still rely on the dirty-flag interface even when
51 /// the platform doesn't support filesystem notifications.
52 pub fn try_new(state_dir: &Path) -> Self {
53 let dirty = Arc::new(AtomicBool::new(false));
54 let dirty_for_cb = dirty.clone();
55 let cb = move |res: notify::Result<Event>| {
56 if let Ok(ev) = res {
57 if relevant(&ev.kind) {
58 dirty_for_cb.store(true, Ordering::SeqCst);
59 }
60 }
61 };
62 let watcher = notify::recommended_watcher(cb).ok();
63 let mut watcher: Option<Box<dyn Watcher + Send>> =
64 watcher.map(|w| Box::new(w) as Box<dyn Watcher + Send>);
65 if let Some(w) = watcher.as_mut() {
66 // `mailbox.db` lives at `<state_dir>/mailbox.db`; SQLite
67 // also writes WAL + SHM siblings (`mailbox.db-wal`,
68 // `mailbox.db-shm`) on every commit. Watching the
69 // parent dir non-recursively catches all three with one
70 // subscription.
71 if w.watch(state_dir, RecursiveMode::NonRecursive).is_err() {
72 // Watcher started but couldn't subscribe (permissions,
73 // missing dir) — fall back to idle.
74 return Self::idle();
75 }
76 }
77 Self {
78 dirty,
79 _watcher: watcher,
80 }
81 }
82
83 /// Read + clear the dirty flag. Returns `true` exactly once per
84 /// batch of events the watcher saw since the previous call.
85 pub fn take_dirty(&self) -> bool {
86 self.dirty.swap(false, Ordering::SeqCst)
87 }
88}
89
90/// Filter `notify` event kinds down to the ones SQLite actually
91/// triggers on commit. Create / Modify / Remove cover every shape
92/// we care about; `Access` events (read-only opens) would refresh
93/// uselessly on every UI tick and are dropped here.
94fn relevant(kind: &EventKind) -> bool {
95 matches!(
96 kind,
97 EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
98 )
99}
100
101#[cfg(test)]
102mod tests {
103 use super::*;
104 use notify::event::{CreateKind, ModifyKind, RemoveKind};
105
106 #[test]
107 fn idle_watch_is_never_dirty() {
108 let w = Watch::idle();
109 assert!(!w.take_dirty());
110 assert!(!w.take_dirty());
111 }
112
113 #[test]
114 fn dirty_flag_clears_on_take() {
115 let w = Watch::idle();
116 w.dirty.store(true, Ordering::SeqCst);
117 assert!(w.take_dirty());
118 assert!(!w.take_dirty(), "second call sees the cleared flag");
119 }
120
121 #[test]
122 fn relevant_kinds_match_sqlite_commit_shape() {
123 assert!(relevant(&EventKind::Create(CreateKind::File)));
124 assert!(relevant(&EventKind::Modify(ModifyKind::Any)));
125 assert!(relevant(&EventKind::Remove(RemoveKind::File)));
126 // Access events (e.g. an `inbox_peek` reader open) must
127 // not trigger a refresh — the UI doesn't care about reads.
128 assert!(!relevant(&EventKind::Access(
129 notify::event::AccessKind::Open(notify::event::AccessMode::Read)
130 )));
131 }
132
133 #[test]
134 fn try_new_on_missing_dir_returns_idle() {
135 // Non-existent path → watcher subscribe fails → fallback.
136 let w = Watch::try_new(Path::new("/definitely/does/not/exist/teamctl-ui-test"));
137 assert!(!w.take_dirty(), "idle fallback never goes dirty");
138 }
139}