1use std::collections::{HashMap, HashSet, VecDeque};
15use std::path::{Path, PathBuf};
16use std::sync::{Arc, Mutex};
17use std::time::Duration;
18
19use tokio::sync::broadcast;
20
21use super::fs_ops::platform::display_path;
22use super::fs_watch::{FsWatchPool, Subscription};
23use super::mps::Broker;
24
25pub const EVENT_FILES_CHANGED: &str = "files:changed";
29
30const COALESCE: Duration = Duration::from_millis(150);
33
34pub const MAX_WATCHES_PER_BLOCK: usize = 64;
38
39struct Watch {
40 block_id: String,
41 dir: PathBuf,
42 sub: Subscription,
43}
44
45#[derive(Default)]
46struct Inner {
47 watches: HashMap<String, Watch>,
48 by_block: HashMap<String, VecDeque<String>>,
50 by_dir: HashMap<PathBuf, (String, HashSet<String>)>,
52 pending: HashSet<PathBuf>,
54}
55
56pub struct FilesWatcher {
57 pool: Arc<FsWatchPool>,
58 broker: Arc<Broker>,
59 inner: Mutex<Inner>,
60}
61
62impl FilesWatcher {
63 pub fn new(pool: Arc<FsWatchPool>, broker: Arc<Broker>) -> Arc<Self> {
65 let this = Arc::new(Self { pool: pool.clone(), broker, inner: Mutex::new(Inner::default()) });
66 let mut events = pool.events();
67 let worker = this.clone();
68 tokio::spawn(async move {
69 loop {
70 match events.recv().await {
71 Ok(ev) => worker.on_fs_event(&ev.path),
72 Err(broadcast::error::RecvError::Closed) => break,
73 Err(broadcast::error::RecvError::Lagged(skipped)) => {
77 tracing::warn!(skipped, "files watcher lagged behind the fs_watch stream; rescanning every watched folder");
78 worker.on_lagged();
79 }
80 }
81 }
82 });
83 this
84 }
85
86 fn lock(&self) -> std::sync::MutexGuard<'_, Inner> {
87 self.inner.lock().unwrap_or_else(|e| e.into_inner())
88 }
89
90 pub fn watch(&self, dir: &Path, block_id: &str) -> String {
95 let watch_id = uuid::Uuid::new_v4().to_string();
96 let sub = self.pool.subscribe_dir(dir);
97 let mut inner = self.lock();
98 inner.watches.insert(watch_id.clone(), Watch { block_id: block_id.to_string(), dir: dir.to_path_buf(), sub });
99 inner
100 .by_dir
101 .entry(dir.to_path_buf())
102 .or_insert_with(|| (display_path(dir), HashSet::new()))
103 .1
104 .insert(watch_id.clone());
105 let ids = inner.by_block.entry(block_id.to_string()).or_default();
106 ids.push_back(watch_id.clone());
107 let evicted: Vec<String> = if ids.len() > MAX_WATCHES_PER_BLOCK {
108 ids.drain(..ids.len() - MAX_WATCHES_PER_BLOCK).collect()
109 } else {
110 Vec::new()
111 };
112 for id in &evicted {
113 self.remove_locked(&mut inner, id);
114 }
115 if !evicted.is_empty() {
116 tracing::debug!(block_id, dropped = evicted.len(), "fs.watch: per-block cap reached, dropped the oldest");
117 }
118 tracing::debug!(dir = %dir.display(), block_id, watch_id, "fs.watch: started");
119 watch_id
120 }
121
122 pub fn unwatch(&self, watch_id: &str) {
125 let mut inner = self.lock();
126 let Some(block_id) = inner.watches.get(watch_id).map(|w| w.block_id.clone()) else {
127 return;
128 };
129 if let Some(ids) = inner.by_block.get_mut(&block_id) {
130 ids.retain(|id| id != watch_id);
131 if ids.is_empty() {
132 inner.by_block.remove(&block_id);
133 }
134 }
135 self.remove_locked(&mut inner, watch_id);
136 }
137
138 fn remove_locked(&self, inner: &mut Inner, watch_id: &str) {
141 let Some(w) = inner.watches.remove(watch_id) else { return };
142 if let Some((_, ids)) = inner.by_dir.get_mut(&w.dir) {
143 ids.remove(watch_id);
144 if ids.is_empty() {
145 inner.by_dir.remove(&w.dir);
146 }
147 }
148 self.pool.unsubscribe(w.sub);
149 }
150
151 fn on_fs_event(self: &Arc<Self>, path: &Path) {
156 let parent = path.parent();
157 let (parent_hit, self_hit) = {
158 let inner = self.lock();
159 if inner.by_dir.is_empty() {
162 return;
163 }
164 (parent.filter(|p| inner.by_dir.contains_key(*p)).map(Path::to_path_buf), inner.by_dir.contains_key(path))
165 };
166 let parent_hit = parent_hit.or_else(|| {
171 let canonical = parent?.canonicalize().ok()?;
172 self.lock().by_dir.contains_key(&canonical).then_some(canonical)
173 });
174 if let Some(dir) = parent_hit {
175 self.schedule(dir);
176 }
177 if self_hit {
178 self.schedule(path.to_path_buf());
179 }
180 }
181
182 fn on_lagged(self: &Arc<Self>) {
183 let dirs: Vec<PathBuf> = self.lock().by_dir.keys().cloned().collect();
184 for dir in dirs {
185 self.schedule(dir);
186 }
187 }
188
189 fn schedule(self: &Arc<Self>, dir: PathBuf) {
192 if !self.lock().pending.insert(dir.clone()) {
193 return;
194 }
195 let this = self.clone();
196 tokio::spawn(async move {
197 tokio::time::sleep(COALESCE).await;
198 let target = {
199 let mut inner = this.lock();
200 inner.pending.remove(&dir);
201 inner.by_dir.get(&dir).map(|(display, ids)| {
204 let blocks: HashSet<String> =
205 ids.iter().filter_map(|id| inner.watches.get(id)).map(|w| w.block_id.clone()).collect();
206 (display.clone(), blocks)
207 })
208 };
209 if let Some((display, blocks)) = target {
210 publish_files_changed(&this.broker, &display, blocks.iter());
211 }
212 });
213 }
214
215 #[cfg(test)]
216 fn watch_count(&self, block_id: &str) -> usize {
217 self.lock().by_block.get(block_id).map_or(0, VecDeque::len)
218 }
219}
220
221fn publish_files_changed<'a>(broker: &Broker, display_dir: &str, block_ids: impl Iterator<Item = &'a String>) {
224 let scopes: Vec<String> = block_ids.map(|id| format!("block:{id}")).collect();
225 if scopes.is_empty() {
226 return;
227 }
228 broker.publish_event::<crate::backend::mps_events::FilesChanged>(
229 &crate::backend::mps_events::FilesChangedEvent { dir: display_dir.to_string() },
230 scopes,
231 0,
232 );
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::backend::mps::MuxEvent;
239 use std::sync::Mutex as StdMutex;
240
241 struct TestClient {
242 events: StdMutex<Vec<(String, MuxEvent)>>,
243 }
244
245 impl super::super::mps::WpsClient for Arc<TestClient> {
246 fn send_event(&self, route_id: &str, event: MuxEvent) {
247 self.events.lock().unwrap().push((route_id.to_string(), event));
248 }
249 }
250
251 fn subscribed_broker(route: &str, block: &str) -> (Arc<Broker>, Arc<TestClient>) {
252 let broker = Arc::new(Broker::new());
253 let client = Arc::new(TestClient { events: StdMutex::new(Vec::new()) });
254 broker.set_client(Box::new(client.clone()));
255 broker.subscribe(
256 route,
257 super::super::mps::SubscriptionRequest {
258 event: EVENT_FILES_CHANGED.to_string(),
259 scopes: vec![format!("block:{block}")],
260 allscopes: false,
261 },
262 );
263 (broker, client)
264 }
265
266 #[tokio::test]
267 async fn each_block_keeps_at_most_the_cap_dropping_the_oldest() {
268 let watcher = FilesWatcher::new(FsWatchPool::new(), Arc::new(Broker::new()));
269 let dir = tempfile::tempdir().unwrap();
270 let canonical = dir.path().canonicalize().unwrap();
271 let first = watcher.watch(&canonical, "b1");
272 for _ in 0..MAX_WATCHES_PER_BLOCK {
273 watcher.watch(&canonical, "b1");
274 }
275 assert_eq!(watcher.watch_count("b1"), MAX_WATCHES_PER_BLOCK);
276 assert!(!watcher.lock().watches.contains_key(&first), "the oldest was dropped");
277
278 watcher.watch(&canonical, "b2");
280 assert_eq!(watcher.watch_count("b2"), 1);
281 }
282
283 #[tokio::test]
284 async fn unwatch_releases_and_unknown_ids_are_fine() {
285 let watcher = FilesWatcher::new(FsWatchPool::new(), Arc::new(Broker::new()));
286 let dir = tempfile::tempdir().unwrap();
287 let canonical = dir.path().canonicalize().unwrap();
288 let a = watcher.watch(&canonical, "b1");
289 let b = watcher.watch(&canonical, "b2");
290 watcher.unwatch(&a);
291 assert!(watcher.lock().by_dir.contains_key(&canonical), "b2 still watches it");
292 watcher.unwatch(&b);
293 assert!(watcher.lock().by_dir.is_empty());
294 assert!(watcher.lock().by_block.is_empty());
295 watcher.unwatch(&b);
296 watcher.unwatch("never-issued");
297 }
298
299 #[test]
300 fn publish_is_scoped_to_the_watching_blocks() {
301 let (broker, client) = subscribed_broker("route-1", "abc");
302 publish_files_changed(&broker, "/tmp/x", ["abc".to_string(), "xyz".to_string()].iter());
303 let events = client.events.lock().unwrap();
304 assert_eq!(events.len(), 1);
305 assert_eq!(events[0].1.event, EVENT_FILES_CHANGED);
306 assert_eq!(events[0].1.data.as_ref().unwrap()["dir"], "/tmp/x");
307 assert_eq!(events[0].1.persist, 0);
308 }
309
310 #[tokio::test]
311 async fn a_real_change_publishes_once_for_its_folder() {
312 let (broker, client) = subscribed_broker("route-e2e", "e2e");
313 let watcher = FilesWatcher::new(FsWatchPool::new(), broker);
314 let dir = tempfile::tempdir().unwrap();
315 let canonical = dir.path().canonicalize().unwrap();
316 watcher.watch(&canonical, "e2e");
317 tokio::time::sleep(Duration::from_millis(200)).await;
318
319 for i in 0..5 {
321 std::fs::write(canonical.join(format!("f{i}.txt")), "x").unwrap();
322 }
323 let saw = tokio::time::timeout(Duration::from_secs(5), async {
324 loop {
325 if !client.events.lock().unwrap().is_empty() {
326 return true;
327 }
328 tokio::time::sleep(Duration::from_millis(25)).await;
329 }
330 })
331 .await
332 .unwrap_or(false);
333 assert!(saw, "a write in a watched folder must publish files:changed");
334
335 tokio::time::sleep(Duration::from_millis(400)).await;
336 let events = client.events.lock().unwrap();
337 assert!(events.len() <= 2, "a burst is coalesced, got {} events", events.len());
338 assert_eq!(events[0].1.data.as_ref().unwrap()["dir"], display_path(&canonical));
339 }
340
341 #[tokio::test]
342 async fn lag_rescans_every_watched_folder() {
343 let (broker, client) = subscribed_broker("route-lag", "lag");
344 let watcher = FilesWatcher::new(FsWatchPool::new(), broker);
345 let (d1, d2) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
346 watcher.watch(&d1.path().canonicalize().unwrap(), "lag");
347 watcher.watch(&d2.path().canonicalize().unwrap(), "lag");
348 watcher.on_lagged();
349 tokio::time::sleep(COALESCE + Duration::from_millis(200)).await;
350 let dirs: HashSet<String> = client
351 .events
352 .lock()
353 .unwrap()
354 .iter()
355 .map(|(_, e)| e.data.as_ref().unwrap()["dir"].as_str().unwrap().to_string())
356 .collect();
357 assert_eq!(dirs.len(), 2, "one event per watched folder");
358 }
359}