Skip to main content

slint_interpreter/
file_watcher.rs

1// Copyright © SixtyFPS GmbH <info@slint.dev>
2// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
3
4use std::collections::{HashMap, HashSet};
5use std::path::{Path, PathBuf};
6use std::sync::mpsc;
7use std::thread::{self, JoinHandle};
8
9use notify::Watcher as _;
10
11/// A normalized file-system change emitted by [`FileWatcher`].
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum FileChangeKind {
14    /// A watched file appeared on disk.
15    Created,
16    /// A watched file changed on disk.
17    Changed,
18    /// A watched file disappeared from disk.
19    Deleted,
20}
21
22/// A file-system event for one watched path.
23#[derive(Clone, Debug, Eq, PartialEq)]
24pub struct WatchEvent {
25    /// The affected watched path.
26    pub path: PathBuf,
27    /// The normalized change kind for this path.
28    pub kind: FileChangeKind,
29}
30
31/// A file watcher for a set of source or resource paths.
32pub struct FileWatcher {
33    tx: mpsc::Sender<WorkerMessage>,
34
35    /// Use a worker thread for processing file events and updating watches.
36    ///
37    /// `notify` already invokes callbacks from backend-managed threads/event loops, but
38    /// reconcile performs `watch()` / `unwatch()` calls as it updates probe directories.
39    /// Backends such as inotify and kqueue route those operations through the same backend
40    /// loop and wait synchronously for an acknowledgement, so running reconcile directly in
41    /// the callback can deadlock. The dedicated worker thread keeps that work off the
42    /// backend callback thread while still serializing all watcher state transitions.
43    worker: Option<JoinHandle<()>>,
44}
45
46impl FileWatcher {
47    /// Creates a watcher and invokes `on_event` for matching watched-path changes.
48    ///
49    /// Runtime watcher errors are forwarded to `on_error`.
50    pub fn start(
51        on_event: impl FnMut(WatchEvent) + Send + 'static,
52        on_error: impl FnMut(notify::Error) + Send + 'static,
53    ) -> notify::Result<Self> {
54        let (tx, rx) = mpsc::channel();
55        let (startup_tx, startup_rx) = mpsc::sync_channel(1);
56        let worker_tx = tx.clone();
57        let worker = thread::spawn(move || {
58            worker_loop(rx, worker_tx, startup_tx, on_event, on_error);
59        });
60
61        match startup_rx.recv() {
62            Ok(Ok(())) => Ok(Self { tx, worker: Some(worker) }),
63            Ok(Err(err)) => {
64                let _ = worker.join();
65                Err(err)
66            }
67            Err(_) => {
68                let _ = worker.join();
69                Err(worker_stopped_error())
70            }
71        }
72    }
73
74    /// Replaces the watched path set with `paths`.
75    pub fn update_watched_paths<I>(&mut self, paths: I) -> notify::Result<()>
76    where
77        I: IntoIterator<Item = PathBuf>,
78    {
79        let watched_files = paths
80            .into_iter()
81            .map(|path| i_slint_compiler::pathutils::clean_path(&path))
82            .collect::<HashSet<_>>();
83
84        let (response_tx, response_rx) = mpsc::sync_channel(1);
85        self.tx
86            .send(WorkerMessage::UpdateWatchedPaths { watched_files, response: response_tx })
87            .map_err(|_| worker_stopped_error())?;
88        response_rx.recv().map_err(|_| worker_stopped_error())?
89    }
90}
91
92impl Drop for FileWatcher {
93    fn drop(&mut self) {
94        let _ = self.tx.send(WorkerMessage::Shutdown);
95        if let Some(worker) = self.worker.take() {
96            let _ = worker.join();
97        }
98    }
99}
100
101fn classify_event(event: notify::Event) -> Vec<(PathBuf, FileChangeKind)> {
102    use notify::EventKind;
103    use notify::event::{ModifyKind, RenameMode};
104
105    fn map_event(event: notify::Event, kind: FileChangeKind) -> Vec<(PathBuf, FileChangeKind)> {
106        event
107            .paths
108            .into_iter()
109            .map(|path| (i_slint_compiler::pathutils::clean_path(&path), kind))
110            .collect()
111    }
112
113    match event.kind {
114        EventKind::Create(_) => map_event(event, FileChangeKind::Created),
115        EventKind::Remove(_) => map_event(event, FileChangeKind::Deleted),
116        EventKind::Modify(ModifyKind::Name(RenameMode::From)) => {
117            map_event(event, FileChangeKind::Deleted)
118        }
119        EventKind::Modify(ModifyKind::Name(RenameMode::To)) => {
120            map_event(event, FileChangeKind::Created)
121        }
122        EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
123            let mut paths = event.paths.into_iter();
124            [
125                paths.next().map(|path| {
126                    (i_slint_compiler::pathutils::clean_path(&path), FileChangeKind::Deleted)
127                }),
128                paths.next().map(|path| {
129                    (i_slint_compiler::pathutils::clean_path(&path), FileChangeKind::Created)
130                }),
131            ]
132            .into_iter()
133            .flatten()
134            .collect()
135        }
136        EventKind::Modify(_) => map_event(event, FileChangeKind::Changed),
137        _ => Vec::new(),
138    }
139}
140
141enum WorkerMessage {
142    UpdateWatchedPaths {
143        watched_files: HashSet<PathBuf>,
144        response: mpsc::SyncSender<notify::Result<()>>,
145    },
146    RawEvent(notify::Result<notify::Event>),
147    Shutdown,
148}
149
150#[derive(Clone, Debug, Eq, PartialEq)]
151enum TargetState {
152    Existing { probe_dir: Option<PathBuf> },
153    Missing { probe_dir: Option<PathBuf> },
154}
155
156impl TargetState {
157    fn exists(&self) -> bool {
158        matches!(self, Self::Existing { .. })
159    }
160
161    fn probe_dir(&self) -> Option<&PathBuf> {
162        match self {
163            Self::Existing { probe_dir } | Self::Missing { probe_dir } => probe_dir.as_ref(),
164        }
165    }
166}
167
168#[derive(Default, Debug)]
169struct WorkerState {
170    /// The set of paths to watch
171    watched_files: HashSet<PathBuf>,
172    target_states: HashMap<PathBuf, TargetState>,
173    /// The set of actually registered watch paths, which may include probe directories and/or directly watched files.
174    registered_watches: HashSet<PathBuf>,
175}
176
177impl WorkerState {
178    fn update_watched_paths(
179        &mut self,
180        watcher: &mut notify::RecommendedWatcher,
181        watched_files: HashSet<PathBuf>,
182        on_event: &mut impl FnMut(WatchEvent),
183    ) -> notify::Result<()> {
184        let previous_states = watched_files
185            .iter()
186            .map(|path| {
187                let state = self
188                    .target_states
189                    .get(path)
190                    .cloned()
191                    .unwrap_or_else(|| scan_target_state(path));
192                (path.clone(), state)
193            })
194            .collect::<HashMap<_, _>>();
195
196        self.watched_files = watched_files;
197        self.target_states = previous_states.clone();
198        self.reconcile(watcher, previous_states, HashSet::new(), on_event)
199    }
200
201    fn handle_raw_event(
202        &mut self,
203        watcher: &mut notify::RecommendedWatcher,
204        event: notify::Event,
205        on_event: &mut impl FnMut(WatchEvent),
206    ) -> notify::Result<()> {
207        if self.watched_files.is_empty() {
208            return Ok(());
209        }
210
211        let previous_states = self.target_states.clone();
212        let changed_paths = classify_event(event)
213            .into_iter()
214            .filter_map(|(path, kind)| {
215                (kind == FileChangeKind::Changed && self.watched_files.contains(&path))
216                    .then_some(path)
217            })
218            .collect::<HashSet<_>>();
219
220        self.reconcile(watcher, previous_states, changed_paths, on_event)
221    }
222
223    fn reconcile(
224        &mut self,
225        watcher: &mut notify::RecommendedWatcher,
226        previous_states: HashMap<PathBuf, TargetState>,
227        changed_paths: HashSet<PathBuf>,
228        on_event: &mut impl FnMut(WatchEvent),
229    ) -> notify::Result<()> {
230        const MAX_RECONCILE_PASSES: usize = 8;
231
232        let mut target_states = scan_target_states(&self.watched_files);
233
234        for _ in 0..MAX_RECONCILE_PASSES {
235            let desired_watches = desired_watches_for_states(&target_states);
236            if desired_watches == self.registered_watches {
237                break;
238            }
239
240            self.apply_watch_plan(watcher, &desired_watches)?;
241            target_states = scan_target_states(&self.watched_files);
242        }
243
244        self.target_states = target_states;
245
246        let mut transitioned_paths = HashSet::new();
247        for path in &self.watched_files {
248            let previous = previous_states.get(path).map(TargetState::exists).unwrap_or(false);
249            let current = self.target_states.get(path).map(TargetState::exists).unwrap_or(false);
250
251            match (previous, current) {
252                (false, true) => {
253                    transitioned_paths.insert(path.clone());
254                    on_event(WatchEvent { path: path.clone(), kind: FileChangeKind::Created });
255                }
256                (true, false) => {
257                    transitioned_paths.insert(path.clone());
258                    on_event(WatchEvent { path: path.clone(), kind: FileChangeKind::Deleted });
259                }
260                _ => {}
261            }
262        }
263
264        for path in changed_paths {
265            if transitioned_paths.contains(&path) {
266                continue;
267            }
268
269            if self.target_states.get(&path).map(TargetState::exists).unwrap_or(false) {
270                on_event(WatchEvent { path, kind: FileChangeKind::Changed });
271            }
272        }
273
274        Ok(())
275    }
276
277    fn apply_watch_plan(
278        &mut self,
279        watcher: &mut notify::RecommendedWatcher,
280        desired_registrations: &HashSet<PathBuf>,
281    ) -> notify::Result<()> {
282        let current_watches = self.registered_watches.clone();
283
284        for registration in desired_registrations.difference(&current_watches) {
285            match watcher.watch(registration, notify::RecursiveMode::NonRecursive) {
286                Ok(()) => {
287                    self.registered_watches.insert(registration.clone());
288                }
289                Err(err) if is_transient_watch_error(&err) => {}
290                Err(err) => return Err(err),
291            }
292        }
293
294        for registration in current_watches.difference(desired_registrations) {
295            match watcher.unwatch(registration) {
296                Ok(()) => {}
297                Err(err) if is_transient_watch_error(&err) => {}
298                Err(err) => return Err(err),
299            }
300            self.registered_watches.remove(registration);
301        }
302
303        Ok(())
304    }
305}
306
307fn worker_loop(
308    rx: mpsc::Receiver<WorkerMessage>,
309    tx: mpsc::Sender<WorkerMessage>,
310    startup_tx: mpsc::SyncSender<notify::Result<()>>,
311    mut on_event: impl FnMut(WatchEvent) + Send + 'static,
312    mut on_error: impl FnMut(notify::Error) + Send + 'static,
313) {
314    let watcher = notify::recommended_watcher(move |event| {
315        // Keep the backend callback lightweight and forward the real work to the worker.
316        //
317        // This is especially needed on inotify backends, where calling watch/unwatch within
318        // the callback can cause a deadlock.
319        let _ = tx.send(WorkerMessage::RawEvent(event));
320    });
321
322    let mut watcher = match watcher {
323        Ok(watcher) => {
324            let _ = startup_tx.send(Ok(()));
325            watcher
326        }
327        Err(err) => {
328            let _ = startup_tx.send(Err(err));
329            return;
330        }
331    };
332
333    let mut state = WorkerState::default();
334
335    while let Ok(message) = rx.recv() {
336        match message {
337            WorkerMessage::UpdateWatchedPaths { watched_files, response } => {
338                let _ = response.send(state.update_watched_paths(
339                    &mut watcher,
340                    watched_files,
341                    &mut on_event,
342                ));
343            }
344            WorkerMessage::RawEvent(Ok(event)) => {
345                if let Err(err) = state.handle_raw_event(&mut watcher, event, &mut on_event) {
346                    on_error(err);
347                }
348            }
349            WorkerMessage::RawEvent(Err(err)) => on_error(err),
350            WorkerMessage::Shutdown => break,
351        }
352    }
353}
354
355fn scan_target_states(watched_files: &HashSet<PathBuf>) -> HashMap<PathBuf, TargetState> {
356    watched_files.iter().map(|path| (path.clone(), scan_target_state(path))).collect()
357}
358
359fn scan_target_state(path: &Path) -> TargetState {
360    let probe_dir = probe_dir_for_path(path);
361    if path.exists() {
362        TargetState::Existing { probe_dir }
363    } else {
364        TargetState::Missing { probe_dir }
365    }
366}
367
368fn desired_watches_for_states(target_states: &HashMap<PathBuf, TargetState>) -> HashSet<PathBuf> {
369    let mut watches = target_states
370        .values()
371        .filter_map(|state| state.probe_dir().cloned())
372        .collect::<HashSet<_>>();
373
374    if needs_direct_file_watches() {
375        watches.extend(
376            target_states
377                .iter()
378                .filter(|(_path, state)| state.exists())
379                .map(|(path, _state)| path.clone()),
380        );
381    }
382
383    watches
384}
385
386fn probe_dir_for_path(path: &Path) -> Option<PathBuf> {
387    if path.exists() {
388        let parent = path.parent()?;
389        parent.is_dir().then(|| i_slint_compiler::pathutils::clean_path(parent))
390    } else {
391        nearest_existing_ancestor(path)
392    }
393}
394
395fn nearest_existing_ancestor(path: &Path) -> Option<PathBuf> {
396    let mut current = path.parent()?;
397    while !current.is_dir() {
398        current = current.parent()?;
399    }
400
401    Some(i_slint_compiler::pathutils::clean_path(current))
402}
403
404fn is_transient_watch_error(err: &notify::Error) -> bool {
405    matches!(
406        err.kind,
407        notify::ErrorKind::PathNotFound
408            | notify::ErrorKind::WatchNotFound
409            | notify::ErrorKind::Generic(_)
410    )
411}
412
413fn worker_stopped_error() -> notify::Error {
414    notify::Error::generic("file watcher worker thread stopped")
415}
416
417fn needs_direct_file_watches() -> bool {
418    // On macOS, notify does not report file changed events, if we only watch the parent
419    // directory, so we need to add a direct file watch as well.
420    cfg!(target_os = "macos")
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426
427    use std::fs;
428    use std::sync::atomic::{AtomicUsize, Ordering};
429    use std::sync::mpsc::{self, Receiver};
430    use std::time::{Duration, SystemTime, UNIX_EPOCH};
431
432    const WATCHER_SETTLE_DELAY: Duration = Duration::from_millis(50);
433    const EVENT_TIMEOUT: Duration = Duration::from_millis(100);
434    const QUIET_TIMEOUT: Duration = Duration::from_millis(50);
435
436    struct TestContext {
437        root: PathBuf,
438        watcher: FileWatcher,
439        events: Receiver<WatchEvent>,
440        errors: Receiver<notify::Error>,
441    }
442
443    impl TestContext {
444        fn new() -> Self {
445            static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
446
447            let unique_id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
448            let timestamp = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
449            let root = std::env::temp_dir()
450                .join(format!("slint-file-watcher-{timestamp}-{unique_id}-{}", std::process::id()));
451            fs::create_dir_all(&root).unwrap();
452            let (event_tx, events) = mpsc::channel();
453            let (error_tx, errors) = mpsc::channel();
454
455            let watcher = FileWatcher::start(
456                move |event| {
457                    event_tx.send(event).unwrap();
458                },
459                move |error| {
460                    error_tx.send(error).unwrap();
461                },
462            )
463            .unwrap();
464
465            Self { root, watcher, events, errors }
466        }
467
468        fn path(&self, relative: impl AsRef<Path>) -> PathBuf {
469            self.root.join(relative)
470        }
471
472        fn create_dir_all(&self, relative: impl AsRef<Path>) -> PathBuf {
473            let path = self.path(relative);
474            fs::create_dir_all(&path).unwrap();
475            path
476        }
477
478        fn write(&self, relative: impl AsRef<Path>, contents: &str) -> PathBuf {
479            let path = self.path(relative);
480            if let Some(parent) = path.parent() {
481                fs::create_dir_all(parent).unwrap();
482            }
483            fs::write(&path, contents).unwrap();
484            path
485        }
486
487        fn remove_file(&self, relative: impl AsRef<Path>) {
488            fs::remove_file(self.path(relative)).unwrap();
489        }
490
491        fn remove_dir_all(&self, relative: impl AsRef<Path>) {
492            fs::remove_dir_all(self.path(relative)).unwrap();
493        }
494
495        fn rename(&self, from: impl AsRef<Path>, to: impl AsRef<Path>) {
496            let from = self.path(from);
497            let to = self.path(to);
498            if let Some(parent) = to.parent() {
499                fs::create_dir_all(parent).unwrap();
500            }
501            fs::rename(from, to).unwrap();
502        }
503
504        fn watch(&mut self, relative_paths: &[&str]) {
505            let paths = relative_paths.iter().map(|path| self.path(*path)).collect::<Vec<_>>();
506            self.watcher.update_watched_paths(paths).unwrap();
507            self.settle();
508            self.drain_events();
509            self.assert_no_errors();
510        }
511
512        fn settle(&self) {
513            std::thread::sleep(WATCHER_SETTLE_DELAY);
514        }
515
516        fn drain_events(&self) -> Vec<WatchEvent> {
517            let mut events = Vec::new();
518            while let Ok(event) = self.events.try_recv() {
519                events.push(event);
520            }
521            events
522        }
523
524        fn drain_errors(&self) -> Vec<notify::Error> {
525            let mut errors = Vec::new();
526            while let Ok(error) = self.errors.try_recv() {
527                errors.push(error);
528            }
529            errors
530        }
531
532        fn assert_no_errors(&self) {
533            let errors = self.drain_errors();
534            assert!(errors.is_empty(), "unexpected watcher errors: {errors:?}");
535        }
536
537        fn expect_event(&self, path: &Path, kind: FileChangeKind) {
538            let expected = WatchEvent { path: path.to_path_buf(), kind };
539            let mut seen = Vec::new();
540
541            loop {
542                self.assert_no_errors();
543
544                match self.events.recv_timeout(EVENT_TIMEOUT) {
545                    Ok(event) if event == expected => return,
546                    Ok(event) => seen.push(event),
547                    Err(mpsc::RecvTimeoutError::Timeout) => {
548                        panic!("timed out waiting for {expected:?}; saw {seen:?}")
549                    }
550                    Err(mpsc::RecvTimeoutError::Disconnected) => {
551                        panic!("watcher event channel disconnected while waiting for {expected:?}")
552                    }
553                }
554            }
555        }
556
557        fn expect_quiet(&self) {
558            match self.events.recv_timeout(QUIET_TIMEOUT) {
559                Ok(event) => panic!("unexpected event during quiet period: {event:?}"),
560                Err(mpsc::RecvTimeoutError::Timeout) => {}
561                Err(mpsc::RecvTimeoutError::Disconnected) => {
562                    panic!("watcher event channel disconnected while waiting for quiet period")
563                }
564            }
565
566            self.assert_no_errors();
567        }
568    }
569
570    impl Drop for TestContext {
571        fn drop(&mut self) {
572            let _ = fs::remove_dir_all(&self.root);
573        }
574    }
575
576    #[test]
577    fn reports_changed_for_existing_watched_file() {
578        let mut ctx = TestContext::new();
579        let watched = ctx.write("ui/main.slint", "first");
580
581        ctx.watch(&["ui/main.slint"]);
582        ctx.write("ui/main.slint", "second");
583
584        ctx.expect_event(&watched, FileChangeKind::Changed);
585    }
586
587    #[test]
588    fn reports_deleted_and_created_for_existing_watched_file() {
589        let mut ctx = TestContext::new();
590        let watched = ctx.write("ui/main.slint", "first");
591
592        ctx.watch(&["ui/main.slint"]);
593        ctx.remove_file("ui/main.slint");
594        ctx.expect_event(&watched, FileChangeKind::Deleted);
595
596        ctx.write("ui/main.slint", "second");
597        ctx.expect_event(&watched, FileChangeKind::Created);
598    }
599
600    #[test]
601    fn reports_deleted_when_watched_file_is_renamed_away() {
602        let mut ctx = TestContext::new();
603        let watched = ctx.write("ui/main.slint", "first");
604
605        ctx.watch(&["ui/main.slint"]);
606        ctx.rename("ui/main.slint", "ui/renamed.slint");
607
608        ctx.expect_event(&watched, FileChangeKind::Deleted);
609    }
610
611    #[test]
612    fn reports_created_when_file_is_renamed_into_watched_path() {
613        let mut ctx = TestContext::new();
614        let watched = ctx.path("ui/main.slint");
615
616        ctx.create_dir_all("ui");
617        ctx.write("ui/temp.slint", "temporary");
618        ctx.watch(&["ui/main.slint"]);
619        ctx.drain_events();
620
621        ctx.rename("ui/temp.slint", "ui/main.slint");
622
623        ctx.expect_event(&watched, FileChangeKind::Created);
624    }
625
626    #[test]
627    fn ignores_changes_to_unwatched_sibling_files() {
628        let mut ctx = TestContext::new();
629        ctx.write("ui/main.slint", "main");
630        ctx.write("ui/sibling.slint", "sibling");
631
632        ctx.watch(&["ui/main.slint"]);
633        ctx.write("ui/sibling.slint", "sibling changed");
634
635        ctx.expect_quiet();
636    }
637
638    #[test]
639    fn reports_created_for_missing_file_when_parent_directory_exists() {
640        let mut ctx = TestContext::new();
641        let watched = ctx.path("ui/missing.slint");
642
643        ctx.create_dir_all("ui");
644        ctx.watch(&["ui/missing.slint"]);
645        ctx.write("ui/missing.slint", "created later");
646
647        ctx.expect_event(&watched, FileChangeKind::Created);
648    }
649
650    #[test]
651    fn reports_created_for_missing_file_when_intermediate_directory_is_created_later() {
652        let mut ctx = TestContext::new();
653        let watched = ctx.path("ui/generated/missing.slint");
654
655        ctx.create_dir_all("ui");
656        ctx.watch(&["ui/generated/missing.slint"]);
657        ctx.write("ui/generated/missing.slint", "created with parent later");
658
659        ctx.expect_event(&watched, FileChangeKind::Created);
660    }
661
662    #[test]
663    fn reports_created_for_missing_file_when_directory_chain_is_created_later() {
664        let mut ctx = TestContext::new();
665        let watched = ctx.path("ui/generated/deep/missing.slint");
666
667        ctx.watch(&["ui/generated/deep/missing.slint"]);
668        ctx.write("ui/generated/deep/missing.slint", "created with full chain later");
669
670        ctx.expect_event(&watched, FileChangeKind::Created);
671    }
672
673    #[test]
674    fn refreshing_watch_set_stops_forwarding_old_paths() {
675        let mut ctx = TestContext::new();
676        let first = ctx.write("ui/first.slint", "first");
677        let second = ctx.write("ui/second.slint", "first");
678
679        ctx.watch(&["ui/first.slint"]);
680        ctx.write("ui/first.slint", "first updated");
681        ctx.expect_event(&first, FileChangeKind::Changed);
682        ctx.drain_events();
683
684        ctx.watch(&["ui/second.slint"]);
685        ctx.write("ui/first.slint", "should now be ignored");
686        ctx.expect_quiet();
687
688        ctx.write("ui/second.slint", "second updated");
689        ctx.expect_event(&second, FileChangeKind::Changed);
690    }
691
692    #[test]
693    fn refreshing_after_probe_directory_is_removed_recovers_cleanly() {
694        let mut ctx = TestContext::new();
695        ctx.write("test.slint", "export component Test { }");
696        let watched_nested = ctx.write("thing/thing.slint", "export component Thing { }");
697
698        ctx.watch(&["test.slint", "thing/thing.slint"]);
699        ctx.remove_dir_all("thing");
700        ctx.settle();
701        ctx.expect_event(&watched_nested, FileChangeKind::Deleted);
702        ctx.drain_events();
703        ctx.assert_no_errors();
704
705        ctx.watch(&["test.slint", "thing/thing.slint"]);
706
707        ctx.write("thing/thing.slint", "export component Thing { in property<string> x; }");
708        ctx.expect_event(&watched_nested, FileChangeKind::Created);
709    }
710}