Skip to main content

webui_dev_server/
watch.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT license.
3
4//! Debounced filesystem watcher.
5//!
6//! Wraps `notify-debouncer-mini` so callers don't need to deal with the
7//! debouncer event type. Returns a [`WatcherHandle`] that owns the
8//! background thread; **the handle must be kept alive** for the watcher
9//! to run.
10
11use std::collections::{HashMap, HashSet};
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14use std::time::Duration;
15
16use anyhow::{Context, Result};
17use notify::{
18    event::{AccessKind, AccessMode},
19    Event, EventHandler, EventKind, RecommendedWatcher, RecursiveMode, Watcher, WatcherKind,
20};
21use notify_debouncer_mini::{
22    new_debouncer_opt, Config as DebouncerConfig, DebounceEventResult, Debouncer,
23};
24
25#[path = "watch_hash.rs"]
26mod hash;
27
28#[cfg(test)]
29#[path = "watch_hash_tests.rs"]
30mod hash_tests;
31
32use hash::{hash_file, HASH_BUFFER_SIZE};
33
34/// Owns the watcher background thread. Drop to stop watching.
35///
36/// `notify-debouncer-mini` spawns its background thread inside the
37/// debouncer struct; dropping the struct kills the thread. Consumers
38/// should hold this handle for the lifetime of their server.
39pub struct WatcherHandle {
40    _debouncer: Debouncer<ContentChangeWatcher>,
41}
42
43struct ContentChangeWatcher(RecommendedWatcher);
44
45impl Watcher for ContentChangeWatcher {
46    fn new<F: EventHandler>(mut event_handler: F, config: notify::Config) -> notify::Result<Self> {
47        RecommendedWatcher::new(
48            move |event| {
49                if matches!(&event, Ok(event) if is_read_only_access_event(event)) {
50                    return;
51                }
52                event_handler.handle_event(event);
53            },
54            config,
55        )
56        .map(Self)
57    }
58
59    fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> notify::Result<()> {
60        self.0.watch(path, recursive_mode)
61    }
62
63    fn unwatch(&mut self, path: &Path) -> notify::Result<()> {
64        self.0.unwatch(path)
65    }
66
67    fn configure(&mut self, config: notify::Config) -> notify::Result<bool> {
68        self.0.configure(config)
69    }
70
71    fn kind() -> WatcherKind {
72        RecommendedWatcher::kind()
73    }
74}
75
76fn is_read_only_access_event(event: &Event) -> bool {
77    matches!(
78        event.kind,
79        EventKind::Access(
80            AccessKind::Read
81                | AccessKind::Open(AccessMode::Read)
82                | AccessKind::Close(AccessMode::Read)
83        )
84    )
85}
86
87/// Configuration for [`spawn_watcher`].
88pub struct WatchConfig {
89    /// Roots to watch recursively. Non-existent entries are silently
90    /// skipped.
91    pub paths: Vec<PathBuf>,
92    /// Exact files to watch through their parent directories.
93    ///
94    /// These files bypass `ignore` filtering, which lets a manifest under an
95    /// ignored output directory act as an explicit synchronization point
96    /// without forwarding unrelated bundle writes.
97    pub explicit_files: Vec<PathBuf>,
98    /// Subtrees to ignore. An event is suppressed when its path lives
99    /// underneath any entry here. Typical values: the build's `out_dir`,
100    /// `node_modules`, `.git`, `target`.
101    ///
102    /// Each entry is canonicalized at registration time so symlink and
103    /// path-form differences (`./dist` vs `dist`) compare correctly.
104    pub ignore: Vec<PathBuf>,
105    /// Debounce window — events arriving within this window are
106    /// coalesced into a single callback invocation.
107    pub debounce: Duration,
108    /// Optional predicate that allows byte-identical file events through.
109    ///
110    /// `webui serve --watch` uses this while a rebuild error is active so a
111    /// no-op save can retry transient failures. When the predicate returns
112    /// `false` (the common clean state), identical saves are still dropped.
113    pub retry_unchanged_when: Option<Arc<dyn Fn() -> bool + Send + Sync>>,
114}
115
116/// Start a debounced recursive watcher.
117///
118/// The closure `on_event` is invoked once per debounce window with the
119/// owned, deduplicated list of paths that changed outside any
120/// `cfg.ignore` root. If every event in a window targets an ignored
121/// subtree, the callback is not invoked.
122///
123/// Paths whose file content is byte-identical to the previous event are dropped
124/// in the clean state, so a no-op save (e.g. repeated Ctrl+S that rewrites the
125/// same bytes) triggers **no** rebuild. If `retry_unchanged_when` returns true,
126/// unchanged events are forwarded so callers can retry an active error.
127/// Deletions and files larger than an internal cap always count as changed.
128///
129/// Non-existent paths in `cfg.paths` are silently skipped; this matches
130/// the dev-server use case where some watched directories (e.g. an
131/// optional `public/`) may not exist yet.
132///
133/// # Errors
134///
135/// Returns an error if the watcher cannot be created or if a path that
136/// exists cannot be watched (typically a permissions issue).
137pub fn spawn_watcher<F>(cfg: WatchConfig, on_event: F) -> Result<WatcherHandle>
138where
139    F: Fn(Vec<PathBuf>) + Send + 'static,
140{
141    // Canonicalize ignore paths once, up front. Non-existent ignore
142    // entries are kept as-is so they still match if the path appears
143    // mid-session (e.g. a fresh `dist/` created by the first build).
144    let ignore: Vec<PathBuf> = cfg
145        .ignore
146        .iter()
147        .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
148        .collect();
149    let explicit_files: Vec<PathBuf> = cfg
150        .explicit_files
151        .iter()
152        .filter_map(|path| normalize_explicit_file(path))
153        .collect();
154
155    let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
156    let mut hash_buffer = [0_u8; HASH_BUFFER_SIZE];
157    let retry_unchanged_when = cfg.retry_unchanged_when.clone();
158    let explicit_filter = explicit_files.clone();
159    let notify_config = notify::Config::default().with_follow_symlinks(false);
160    let debouncer_config = DebouncerConfig::default()
161        .with_timeout(cfg.debounce)
162        .with_notify_config(notify_config);
163    let mut debouncer = new_debouncer_opt::<_, ContentChangeWatcher>(
164        debouncer_config,
165        move |res: DebounceEventResult| match res {
166            Ok(events) => {
167                // Filter out ignored paths and dedupe (notify can emit
168                // duplicate events for the same path within a window).
169                let mut paths: Vec<PathBuf> = Vec::with_capacity(events.len());
170                let mut seen: HashSet<PathBuf> = HashSet::with_capacity(events.len());
171                for e in events {
172                    if should_ignore_event(&e.path, &ignore, &explicit_filter) {
173                        continue;
174                    }
175                    if seen.insert(e.path.clone()) {
176                        paths.push(e.path);
177                    }
178                }
179                // Recursive backends emit directory metadata events alongside
180                // file writes. Existing directories are not source changes and
181                // would otherwise retrigger a rebuild after every build; keep
182                // missing paths so directory/file deletions still rebuild.
183                paths.retain(|path| !path.is_dir());
184                // Drop paths whose content is byte-identical to the last event.
185                // Editors fire a write on every Ctrl+S even when nothing changed;
186                // rebuilding then is pure wasted work, so a no-op save triggers no
187                // rebuild at all in the clean state. When the caller reports that a
188                // rebuild error is active, unchanged events are allowed through so a
189                // no-op save can retry transient failures. Deletions and oversized
190                // files always count as changed (see `content_changed`).
191                let retry_unchanged = retry_unchanged_when
192                    .as_ref()
193                    .is_some_and(|predicate| predicate());
194                paths.retain(|path| {
195                    should_forward_path(
196                        &mut content_hashes,
197                        path,
198                        retry_unchanged,
199                        &mut hash_buffer,
200                    )
201                });
202                if !paths.is_empty() {
203                    on_event(paths);
204                }
205            }
206            Err(e) => {
207                eprintln!("watcher error: {e:?}");
208            }
209        },
210    )
211    .context("Cannot start file watcher")?;
212
213    let mut watched_roots = Vec::with_capacity(cfg.paths.len());
214    for p in &cfg.paths {
215        if p.exists() {
216            watched_roots.push(std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()));
217            debouncer
218                .watcher()
219                .watch(p, RecursiveMode::Recursive)
220                .with_context(|| format!("Cannot watch {}", p.display()))?;
221        }
222    }
223    let mut explicit_parents = HashSet::new();
224    for file in &explicit_files {
225        if watched_roots.iter().any(|root| file.starts_with(root)) {
226            continue;
227        }
228        let Some(parent) = file.parent() else {
229            continue;
230        };
231        if parent.exists() && explicit_parents.insert(parent.to_path_buf()) {
232            debouncer
233                .watcher()
234                .watch(parent, RecursiveMode::NonRecursive)
235                .with_context(|| format!("Cannot watch {}", parent.display()))?;
236        }
237    }
238
239    Ok(WatcherHandle {
240        _debouncer: debouncer,
241    })
242}
243
244fn normalize_explicit_file(path: &Path) -> Option<PathBuf> {
245    if let Ok(canonical) = std::fs::canonicalize(path) {
246        return Some(canonical);
247    }
248    let parent = path
249        .parent()
250        .filter(|value| !value.as_os_str().is_empty())
251        .unwrap_or_else(|| Path::new("."));
252    let parent = std::fs::canonicalize(parent).ok()?;
253    Some(parent.join(path.file_name()?))
254}
255
256fn is_explicit_file(event_path: &Path, explicit_files: &[PathBuf]) -> bool {
257    let normalized = std::fs::canonicalize(event_path).unwrap_or_else(|_| event_path.to_path_buf());
258    explicit_files.iter().any(|path| path == &normalized)
259}
260
261fn should_ignore_event(event_path: &Path, ignore: &[PathBuf], explicit_files: &[PathBuf]) -> bool {
262    is_ignored(event_path, ignore) && !is_explicit_file(event_path, explicit_files)
263}
264
265/// Returns true when `event_path` lives under any ignored root.
266///
267/// Matching uses two strategies:
268/// 1. **Absolute roots** (e.g. canonicalized `dist/`): the event path
269///    must literally start with the root. Handles "ignore this exact
270///    output directory" cases.
271/// 2. **Single-component relative roots** (e.g. `node_modules`,
272///    `.git`, `target`): match if any component of the event path
273///    equals that name. This is what makes `default_ignore_paths()`
274///    work universally — `target` matches `/repo/target/...`,
275///    `/repo/sub/target/...`, etc., regardless of where the watcher
276///    cwd was when the path was registered.
277fn is_ignored(event_path: &Path, ignore: &[PathBuf]) -> bool {
278    if ignore.is_empty() {
279        return false;
280    }
281    let canon = std::fs::canonicalize(event_path).ok();
282    let candidate: &Path = canon.as_deref().unwrap_or(event_path);
283
284    for root in ignore {
285        if root.is_absolute() {
286            if candidate.starts_with(root) {
287                return true;
288            }
289            continue;
290        }
291        // Relative root: treat as a name to match against any path
292        // component. Skips the absolute-prefix mismatch trap that
293        // would otherwise let `target` / `node_modules` through.
294        let mut components = root.components();
295        let first = components.next();
296        let only_one_component = components.next().is_none();
297        if let (Some(first), true) = (first, only_one_component) {
298            let name = first.as_os_str();
299            // Check the lexical event path before its canonical form. pnpm
300            // dependencies are symlinks, so canonicalization removes the
301            // `node_modules` component that identifies the ignored subtree.
302            if event_path.components().any(|c| c.as_os_str() == name)
303                || candidate.components().any(|c| c.as_os_str() == name)
304            {
305                return true;
306            }
307        } else if candidate.starts_with(root) {
308            // Multi-component relative root — fall back to prefix.
309            return true;
310        }
311    }
312    false
313}
314
315/// Default ignore subtrees common to dev servers. Includes the universal
316/// junk: `node_modules`, `.git`, `target`. Callers should append their
317/// own build-output directory and any tool-specific cache directories
318/// before passing this to [`spawn_watcher`].
319#[must_use]
320pub fn default_ignore_paths() -> Vec<PathBuf> {
321    vec![
322        PathBuf::from("node_modules"),
323        PathBuf::from(".git"),
324        PathBuf::from("target"),
325    ]
326}
327
328/// Whether `path`'s content changed since the previous event, updating `cache`.
329///
330/// A path that cannot be read as a regular file within the size cap (deleted,
331/// a directory, a permissions error, or oversized) is treated as **changed** so
332/// deletions still trigger a rebuild and large files are never silently skipped.
333fn content_changed(
334    cache: &mut HashMap<PathBuf, u64>,
335    path: &Path,
336    buffer: &mut [u8; HASH_BUFFER_SIZE],
337) -> bool {
338    match hash_file(path, buffer) {
339        Some(hash) => match cache.insert(path.to_path_buf(), hash) {
340            Some(previous) => previous != hash,
341            None => true,
342        },
343        None => {
344            cache.remove(path);
345            true
346        }
347    }
348}
349
350fn should_forward_path(
351    cache: &mut HashMap<PathBuf, u64>,
352    path: &Path,
353    retry_unchanged: bool,
354    buffer: &mut [u8; HASH_BUFFER_SIZE],
355) -> bool {
356    content_changed(cache, path, buffer) || retry_unchanged
357}
358
359#[cfg(test)]
360#[allow(clippy::disallowed_methods)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn is_ignored_matches_descendant() {
366        let dir = tempfile::tempdir().unwrap();
367        let dist = dir.path().join("dist");
368        std::fs::create_dir(&dist).unwrap();
369        let nested = dist.join("a/b.html");
370        std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
371        std::fs::write(&nested, "x").unwrap();
372
373        let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
374        assert!(is_ignored(&nested, &ignore));
375    }
376
377    #[test]
378    fn explicit_manifest_bypasses_ignored_output_directory() {
379        let dir = tempfile::tempdir().unwrap();
380        let dist = dir.path().join("dist");
381        std::fs::create_dir(&dist).unwrap();
382        let manifest = dist.join("webui-projection.json");
383        let bundle = dist.join("index.js");
384        std::fs::write(&manifest, "{}").unwrap();
385        std::fs::write(&bundle, "export {};").unwrap();
386
387        let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
388        let explicit = vec![std::fs::canonicalize(&manifest).unwrap()];
389        assert!(!should_ignore_event(&manifest, &ignore, &explicit));
390        assert!(should_ignore_event(&bundle, &ignore, &explicit));
391    }
392
393    #[test]
394    fn is_ignored_misses_sibling() {
395        let dir = tempfile::tempdir().unwrap();
396        let dist = dir.path().join("dist");
397        let other = dir.path().join("src/a.md");
398        std::fs::create_dir(&dist).unwrap();
399        std::fs::create_dir_all(other.parent().unwrap()).unwrap();
400        std::fs::write(&other, "x").unwrap();
401
402        let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
403        assert!(!is_ignored(&other, &ignore));
404    }
405
406    #[test]
407    fn is_ignored_is_false_when_no_ignores() {
408        let p = Path::new("/anything/at/all");
409        assert!(!is_ignored(p, &[]));
410    }
411
412    #[test]
413    fn is_ignored_matches_relative_component_anywhere() {
414        // `target` (single relative component) must match no matter
415        // how deep — this is how default_ignore_paths() actually
416        // works in the wild against absolute paths from notify.
417        let ignore = vec![PathBuf::from("target")];
418        assert!(is_ignored(Path::new("/repo/target/debug/build"), &ignore));
419        assert!(is_ignored(
420            Path::new("/repo/sub/crate/target/foo.rs"),
421            &ignore
422        ));
423        assert!(!is_ignored(Path::new("/repo/src/target_name.rs"), &ignore));
424    }
425
426    #[test]
427    fn is_ignored_handles_node_modules_and_git() {
428        let ignore = default_ignore_paths();
429        assert!(is_ignored(
430            Path::new("/repo/node_modules/foo/index.js"),
431            &ignore
432        ));
433        assert!(is_ignored(Path::new("/repo/.git/HEAD"), &ignore));
434        assert!(!is_ignored(Path::new("/repo/src/index.ts"), &ignore));
435    }
436
437    #[test]
438    fn read_only_access_events_are_not_content_changes() {
439        use notify::event::ModifyKind;
440
441        for kind in [
442            AccessKind::Read,
443            AccessKind::Open(AccessMode::Read),
444            AccessKind::Close(AccessMode::Read),
445        ] {
446            assert!(is_read_only_access_event(&Event::new(EventKind::Access(
447                kind
448            ))));
449        }
450        for kind in [
451            AccessKind::Any,
452            AccessKind::Open(AccessMode::Write),
453            AccessKind::Close(AccessMode::Write),
454        ] {
455            assert!(!is_read_only_access_event(&Event::new(EventKind::Access(
456                kind
457            ))));
458        }
459        assert!(!is_read_only_access_event(&Event::new(EventKind::Modify(
460            ModifyKind::Any
461        ))));
462    }
463
464    #[cfg(unix)]
465    #[test]
466    fn is_ignored_matches_node_modules_symlink_path() {
467        use std::os::unix::fs::symlink;
468
469        let dir = tempfile::tempdir().unwrap();
470        let package = dir.path().join("packages/example");
471        let node_modules = dir.path().join("node_modules");
472        std::fs::create_dir_all(&package).unwrap();
473        std::fs::create_dir(&node_modules).unwrap();
474        std::fs::write(package.join("index.js"), "export {};").unwrap();
475        symlink(&package, node_modules.join("example")).unwrap();
476
477        let linked_file = node_modules.join("example/index.js");
478        let canonical = std::fs::canonicalize(&linked_file).unwrap();
479        assert!(!canonical
480            .components()
481            .any(|component| component.as_os_str() == "node_modules"));
482        assert!(is_ignored(&linked_file, &[PathBuf::from("node_modules")]));
483    }
484
485    #[test]
486    fn content_changed_skips_identical_saves() {
487        let dir = tempfile::tempdir().unwrap();
488        let file = dir.path().join("a.css");
489        std::fs::write(&file, "a { color: red; }").unwrap();
490        let mut cache = HashMap::new();
491        let mut buffer = [0_u8; HASH_BUFFER_SIZE];
492
493        // First sighting → changed (nothing cached yet).
494        assert!(content_changed(&mut cache, &file, &mut buffer));
495        // Re-saving identical bytes (repeated Ctrl+S) → no change → no rebuild.
496        assert!(!content_changed(&mut cache, &file, &mut buffer));
497        assert!(!content_changed(&mut cache, &file, &mut buffer));
498
499        // A real edit → changed.
500        std::fs::write(&file, "a { color: blue; }").unwrap();
501        assert!(content_changed(&mut cache, &file, &mut buffer));
502        // Identical again → unchanged.
503        assert!(!content_changed(&mut cache, &file, &mut buffer));
504
505        // Deletion → changed, so a rebuild can clear stale output.
506        std::fs::remove_file(&file).unwrap();
507        assert!(content_changed(&mut cache, &file, &mut buffer));
508        // The cache forgot it, so a later recreation is a fresh change.
509        std::fs::write(&file, "a { color: blue; }").unwrap();
510        assert!(content_changed(&mut cache, &file, &mut buffer));
511    }
512
513    #[test]
514    fn unchanged_save_can_retry_when_error_active() {
515        let dir = tempfile::tempdir().unwrap();
516        let file = dir.path().join("a.css");
517        std::fs::write(&file, "a { color: red; }").unwrap();
518        let mut cache = HashMap::new();
519        let mut buffer = [0_u8; HASH_BUFFER_SIZE];
520
521        assert!(should_forward_path(&mut cache, &file, false, &mut buffer));
522        assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
523        assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
524        assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
525
526        std::fs::write(&file, "a { color: blue; }").unwrap();
527        assert!(should_forward_path(&mut cache, &file, true, &mut buffer));
528        assert!(!should_forward_path(&mut cache, &file, false, &mut buffer));
529    }
530
531    #[test]
532    fn unhashable_paths_forget_cached_content() -> std::io::Result<()> {
533        let dir = tempfile::tempdir()?;
534        let file = dir.path().join("a.css");
535        let mut cache = HashMap::new();
536        let mut buffer = [0_u8; HASH_BUFFER_SIZE];
537
538        for replacement in ["deleted", "directory", "oversized"] {
539            std::fs::write(&file, "original")?;
540            assert!(content_changed(&mut cache, &file, &mut buffer));
541            assert!(!content_changed(&mut cache, &file, &mut buffer));
542
543            std::fs::remove_file(&file)?;
544            match replacement {
545                "directory" => std::fs::create_dir(&file)?,
546                "oversized" => std::fs::File::create(&file)?.set_len(8 * 1024 * 1024 + 1)?,
547                _ => {}
548            }
549            for _ in 0..2 {
550                assert!(content_changed(&mut cache, &file, &mut buffer));
551                assert!(!cache.contains_key(&file));
552            }
553            match replacement {
554                "directory" => std::fs::remove_dir(&file)?,
555                "oversized" => std::fs::remove_file(&file)?,
556                _ => {}
557            }
558        }
559        std::fs::write(&file, "original")?;
560        assert!(content_changed(&mut cache, &file, &mut buffer));
561        Ok(())
562    }
563
564    #[test]
565    fn hash_buffer_is_reused_across_files_and_event_batches() -> std::io::Result<()> {
566        let dir = tempfile::tempdir()?;
567        let paths = [
568            dir.path().join("large.css"),
569            dir.path().join("empty.css"),
570            dir.path().join("small.css"),
571        ];
572        for (path, size) in paths.iter().zip([3 * HASH_BUFFER_SIZE + 1, 0, 7]) {
573            std::fs::write(path, vec![b'x'; size])?;
574        }
575        let mut cache = HashMap::new();
576        let mut buffer = [0_u8; HASH_BUFFER_SIZE];
577        for changed in [true, false, false] {
578            for path in &paths {
579                assert_eq!(content_changed(&mut cache, path, &mut buffer), changed);
580            }
581        }
582        Ok(())
583    }
584
585    #[cfg(unix)]
586    #[test]
587    fn unreadable_symlink_loop_forgets_cached_content() -> std::io::Result<()> {
588        let dir = tempfile::tempdir()?;
589        let file = dir.path().join("unreadable.css");
590        std::fs::write(&file, "original")?;
591        let mut cache = HashMap::new();
592        let mut buffer = [0_u8; HASH_BUFFER_SIZE];
593        assert!(content_changed(&mut cache, &file, &mut buffer));
594        std::fs::remove_file(&file)?;
595        std::os::unix::fs::symlink("unreadable.css", &file)?;
596        assert!(content_changed(&mut cache, &file, &mut buffer));
597        assert!(!cache.contains_key(&file));
598        std::fs::remove_file(&file)?;
599        std::fs::write(&file, "original")?;
600        assert!(content_changed(&mut cache, &file, &mut buffer));
601        Ok(())
602    }
603}