Skip to main content

turbo_tasks_fs/watcher/
mod.rs

1mod fs_api;
2#[cfg(test)]
3mod mock_fs_api;
4
5use std::{
6    any::Any,
7    collections::BTreeSet,
8    env, fmt,
9    path::{Path, PathBuf},
10    sync::{
11        Arc, LazyLock,
12        mpsc::{Receiver, RecvTimeoutError, channel},
13    },
14    time::{Duration, Instant},
15};
16
17use anyhow::{Context, Result};
18use bincode::{
19    Decode, Encode,
20    de::Decoder,
21    enc::Encoder,
22    error::{DecodeError, EncodeError},
23};
24use bitflags::bitflags;
25use notify::{
26    Config, EventKind, PollWatcher, RecommendedWatcher, Watcher,
27    event::{MetadataKind, ModifyKind, RenameMode},
28};
29use rustc_hash::{FxHashMap, FxHashSet};
30use tokio::sync::{RwLock, RwLockWriteGuard};
31use tracing::instrument;
32use turbo_rcstr::RcStr;
33use turbo_tasks::{
34    FxIndexSet, InvalidationReason, InvalidationReasonKind, Invalidator, NonLocalValue, TaskInput,
35    TurboTasksApi, spawn_thread, trace::TraceRawVcs, util::StaticOrArc,
36};
37
38use crate::{
39    format_absolute_fs_path,
40    invalidation::{WatchChange, WatchStart},
41    invalidator_map::InvalidatorMap,
42    path_map::OrderedPathMapExt,
43    watcher::fs_api::DiskFileSystemWatcherApi,
44};
45
46/// Overrides [`DiskWatcherConfig::recursive_mode`]. Users shouldn't need to set this, this is
47/// intended only for debugging purposes.
48static FORCED_WATCH_RECURSIVE_MODE: LazyLock<Option<DiskWatcherRecursiveMode>> = LazyLock::new(
49    || match env::var("TURBO_TASKS_FORCE_WATCH_MODE").as_deref() {
50        Ok("recursive") => Some(DiskWatcherRecursiveMode::Recursive),
51        Ok("nonrecursive") => Some(DiskWatcherRecursiveMode::NonRecursive),
52        Ok(_) => {
53            eprintln!(
54                "unsupported `TURBO_TASKS_FORCE_WATCH_MODE`, must be `recursive` or `nonrecursive`"
55            );
56            None
57        }
58        _ => None,
59    },
60);
61
62#[derive(
63    Clone, Copy, Debug, Default, Eq, PartialEq, Hash, TraceRawVcs, NonLocalValue, Encode, Decode,
64)]
65pub struct DiskWatcherConfig {
66    /// Whether to let the [`notify::Watcher`] recurse into subdirectories itself, or to track and
67    /// watch each directory we care about ourselves.
68    ///
69    /// [`None`] picks a default based on the platform and [`Self::poll_interval`], which is
70    /// normally what you want.
71    ///
72    /// The `TURBO_TASKS_FORCE_WATCH_MODE` environment variable will override this configuration
73    /// value (intended for debugging purposes).
74    pub recursive_mode: Option<DiskWatcherRecursiveMode>,
75    /// Poll the filesystem at this interval instead of using the platform's native file watching,
76    /// for cases where native watching doesn't work (e.g. some Docker setups). This is slow and
77    /// inefficient, it should only be used as a last resort.
78    ///
79    /// [`None`] disables polling, using the platform's native watcher ([`RecommendedWatcher`])
80    /// instead of [`PollWatcher`].
81    pub poll_interval: Option<Duration>,
82    /// Attach an [`InvalidationReason`] to every invalidation the watcher causes ([`WatchStart`],
83    /// [`WatchChange`], or [`InvalidateRescan`]), so that it can be reported to the user.
84    ///
85    /// This costs an extra allocation per invalidated path, so it's only worth enabling when
86    /// something actually consumes the reasons.
87    pub report_invalidation_reason: bool,
88}
89
90impl TaskInput for DiskWatcherConfig {
91    fn is_transient(&self) -> bool {
92        false
93    }
94}
95
96/// Equivalent to [`notify::RecursiveMode`], but implements traits needed by [`turbo_tasks`].
97///
98/// When using [`Self::Recursive`], [`notify::Watcher`] will recursively track all contents
99/// of the filesystem root. This should only be used on platforms with efficient recursive watcher
100/// implementations (i.e. macOS and Windows).
101///
102/// When using [`Self::NonRecursive`], we only track previously read files and their parent
103/// directories.
104#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, TraceRawVcs, NonLocalValue, Encode, Decode)]
105pub enum DiskWatcherRecursiveMode {
106    Recursive,
107    NonRecursive,
108}
109
110impl From<DiskWatcherRecursiveMode> for notify::RecursiveMode {
111    fn from(value: DiskWatcherRecursiveMode) -> Self {
112        match value {
113            DiskWatcherRecursiveMode::Recursive => notify::RecursiveMode::Recursive,
114            DiskWatcherRecursiveMode::NonRecursive => notify::RecursiveMode::NonRecursive,
115        }
116    }
117}
118
119impl DiskWatcherConfig {
120    /// Resolves [`Self::recursive_mode`], falling back to a default based on
121    /// [`Self::poll_interval`] and the platform.
122    fn resolve_recursive_mode(&self) -> DiskWatcherRecursiveMode {
123        // macOS and Windows have efficient recursive watchers, so it's best to track the entire
124        // directory and filter events to the files we care about. inotify on Linux is
125        // non-recursive, so notify-rs's implementation is inefficient; better for us to track it
126        // ourselves and only watch the directories we know we care about.
127        //
128        // See: <https://github.com/vercel/turborepo/pull/4100>
129        let platform_has_efficient_recursive_watcher =
130            cfg!(any(target_os = "macos", target_os = "windows"));
131
132        // the env var is a debugging escape hatch, so it wins over everything else
133        if let Some(forced) = *FORCED_WATCH_RECURSIVE_MODE {
134            forced
135        } else if let Some(recursive_mode) = self.recursive_mode {
136            recursive_mode
137        } else if self.poll_interval.is_some() {
138            // `PollWatcher` implements recursive watching by walking the entire subtree on every
139            // poll, so watching the fs root recursively would stat every file in the project each
140            // interval. Watching non-recursively keeps each poll to the directories we've read.
141            DiskWatcherRecursiveMode::NonRecursive
142        } else if platform_has_efficient_recursive_watcher {
143            DiskWatcherRecursiveMode::Recursive
144        } else {
145            DiskWatcherRecursiveMode::NonRecursive
146        }
147    }
148}
149
150/// How long to extend an invalidation batch by when receiving new events, before flushing. This
151/// reduces invalidations if the same file or directory is modified many times.
152///
153/// Linux watching is too fast, so we need a longer delay there to avoid reading wip files.
154#[cfg(target_os = "linux")]
155const BATCH_DELAY: Duration = Duration::from_millis(10);
156#[cfg(not(target_os = "linux"))]
157const BATCH_DELAY: Duration = Duration::from_millis(1);
158
159pub(crate) struct DiskWatcher {
160    state: State,
161    config: DiskWatcherConfig,
162}
163
164/// Only [`Self::config`] is serialized: a decoded [`DiskWatcher`] is always stopped.
165impl Encode for DiskWatcher {
166    fn encode<E: Encoder>(&self, encoder: &mut E) -> Result<(), EncodeError> {
167        self.config.encode(encoder)
168    }
169}
170
171impl<Ctx> Decode<Ctx> for DiskWatcher {
172    fn decode<D: Decoder<Context = Ctx>>(decoder: &mut D) -> Result<Self, DecodeError> {
173        Ok(Self::new(DiskWatcherConfig::decode(decoder)?))
174    }
175}
176bincode::impl_borrow_decode!(DiskWatcher);
177
178enum State {
179    // Note: Information about if we're a recursive or non-recursive watcher must live outside the
180    // `RwLock` to allow us to quickly bail out before calling functions in
181    // `non_recursive_helpers`.
182    Recursive(RwLock<RecursiveState>),
183    NonRecursive(RwLock<NonRecursiveState>),
184}
185
186enum StateWriteGuard<'a> {
187    Recursive(RwLockWriteGuard<'a, RecursiveState>),
188    NonRecursive(RwLockWriteGuard<'a, NonRecursiveState>),
189}
190
191impl State {
192    fn new_stopped(recursive_mode: DiskWatcherRecursiveMode) -> Self {
193        match recursive_mode {
194            DiskWatcherRecursiveMode::Recursive => {
195                Self::Recursive(RwLock::new(RecursiveState::Stopped))
196            }
197            DiskWatcherRecursiveMode::NonRecursive => {
198                Self::NonRecursive(RwLock::new(NonRecursiveState::Stopped))
199            }
200        }
201    }
202
203    async fn write(&self) -> StateWriteGuard<'_> {
204        match self {
205            Self::Recursive(state) => StateWriteGuard::Recursive(state.write().await),
206            Self::NonRecursive(state) => StateWriteGuard::NonRecursive(state.write().await),
207        }
208    }
209
210    fn recursive_mode(&self) -> DiskWatcherRecursiveMode {
211        match self {
212            Self::Recursive(_) => DiskWatcherRecursiveMode::Recursive,
213            Self::NonRecursive(_) => DiskWatcherRecursiveMode::NonRecursive,
214        }
215    }
216}
217
218/// Used when [`DiskWatcherConfig::recursive_mode`] returns [`RecursiveMode::Recursive`] (default on
219/// macOS and Windows when not polling).
220enum RecursiveState {
221    /// Used when [`DiskWatcher::start_watching`] hasn't been called yet or after
222    /// [`DiskWatcher::stop_watching`] is called.
223    Stopped,
224    Watching {
225        /// Hold onto the watcher: When this is dropped, it will cause the channel to disconnect
226        _notify_watcher: NotifyWatcher,
227    },
228}
229
230/// Used when [`DiskWatcherConfig::recursive_mode`] returns [`RecursiveMode::NonRecursive`] (default
231/// on Linux, and everywhere when polling).
232enum NonRecursiveState {
233    /// Used when [`DiskWatcher::start_watching`] hasn't been called yet or after
234    /// [`DiskWatcher::stop_watching`] is called.
235    Stopped,
236    Watching(NonRecursiveWatchingState),
237}
238
239// split out from the `NonRecursiveState` enum because we want to pass this value around
240struct NonRecursiveWatchingState {
241    notify_watcher: NotifyWatcher,
242    /// Keeps track of which directories are currently or were previously watched by
243    /// [`Self::notify_watcher`].
244    ///
245    /// Invariants:
246    /// - Never contains `root_path`. A watcher for `root_path` is implicitly set up during
247    ///   [`DiskWatcher::start_watching`].
248    /// - Contains all parent directories up to `root_path` for every entry.
249    watched: BTreeSet<PathBuf>,
250}
251
252/// A thin wrapper around [`RecommendedWatcher`] and [`PollWatcher`].
253enum NotifyWatcher {
254    Recommended(RecommendedWatcher),
255    Polling(PollWatcher),
256}
257
258impl NotifyWatcher {
259    fn watch(&mut self, path: &Path, recursive_mode: notify::RecursiveMode) -> notify::Result<()> {
260        match self {
261            Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
262            Self::Polling(watcher) => watcher.watch(path, recursive_mode),
263        }
264    }
265}
266
267mod non_recursive_helpers {
268    use super::*;
269    use crate::path_map::OrderedPathSetExt;
270
271    /// Called after a rescan in case a previously watched-but-deleted directory was recreated.
272    #[instrument(skip_all, level = "trace")]
273    pub async fn restore_all_watched_ignore_errors(
274        state: &RwLock<NonRecursiveState>,
275        root_path: &Path,
276    ) {
277        let mut guard = state.write().await;
278        let NonRecursiveState::Watching(watching_state) = &mut *guard else {
279            return;
280        };
281        for dir_path in watching_state.watched.iter() {
282            // TODO: Report diagnostics if this error happens
283            //
284            // Don't watch the parents, because those are already included in `self.watched` (so
285            // it'd be redundant), but also because this could deadlock, since we'd try to modify
286            // `self.watched` while iterating over it (write lock overlapping with a read lock).
287            let _ = start_watching_dir(&mut watching_state.notify_watcher, dir_path, root_path);
288        }
289    }
290
291    /// Called when a new directory is found in a parent directory we're watching. Restores the
292    /// watcher if we were previously watching it.
293    #[instrument(skip_all, level = "trace")]
294    pub async fn restore_if_watched(
295        state: &RwLock<NonRecursiveState>,
296        dir_path: &Path,
297        root_path: &Path,
298    ) -> Result<()> {
299        // fast path: The root directory is always implicitly watched during
300        // `DiskWatcher::start_watching`, we assume it is never deleted and never needs to be
301        // restored.
302        if dir_path == root_path {
303            return Ok(());
304        }
305
306        // fast path: the directory isn't in `watched`, only take a read lock and bail out early
307        {
308            let guard = state.read().await;
309            let NonRecursiveState::Watching(watching_state) = &*guard else {
310                return Ok(());
311            };
312            if !watching_state.watched.contains(dir_path) {
313                return Ok(());
314            }
315        }
316
317        // slow path: re-watch the path
318        let mut guard = state.write().await;
319        let NonRecursiveState::Watching(watching_state) = &mut *guard else {
320            return Ok(());
321        };
322
323        // watch the new directory
324        start_watching_dir(&mut watching_state.notify_watcher, dir_path, root_path)?;
325
326        // Also try to restore any watchers for children of this directory
327        for child_path in watching_state.watched.iter_path_children(dir_path) {
328            // Don't watch the parents -- see the comment on `restore_all_watched`
329            start_watching_dir(&mut watching_state.notify_watcher, child_path, root_path)?;
330        }
331        Ok(())
332    }
333
334    /// Called when a file in `dir_path` or `dir_path` itself is read or written. Adds a new watcher
335    /// if we're not already watching the directory.
336    ///
337    /// This should be called *before* reading a file to avoid a race condition.
338    #[instrument(skip_all, level = "trace")]
339    pub async fn ensure_watched(
340        state: &RwLock<NonRecursiveState>,
341        dir_path: &Path,
342        root_path: &Path,
343    ) -> Result<()> {
344        // fast path: The root directory is always implicitly watched during
345        // `DiskWatcher::start_watching`.
346        if dir_path == root_path {
347            return Ok(());
348        }
349
350        // fast path: the directory is already in `watched`, only take a read lock and bail out
351        // early
352        {
353            let guard = state.read().await;
354            let NonRecursiveState::Watching(watching_state) = &*guard else {
355                return Ok(());
356            };
357            if watching_state.watched.contains(dir_path) {
358                return Ok(());
359            }
360        }
361
362        // slow path: watch the path
363        let mut guard = state.write().await;
364        let NonRecursiveState::Watching(watching_state) = &mut *guard else {
365            return Ok(());
366        };
367        if watching_state.watched.insert(dir_path.to_path_buf()) {
368            start_watching_dir_and_parents(watching_state, dir_path, root_path)?;
369        }
370        Ok(())
371    }
372
373    /// Private helper, assumes that `dir_path` has already been added to
374    /// [`NonRecursiveWatchingState::watched`].
375    ///
376    /// This does not watch any of the parent directories. For that, use
377    /// [`start_watching_dir_and_parents`]. Use this method when iterating over previously-watched
378    /// values in `self.watching`.
379    fn start_watching_dir(
380        notify_watcher: &mut NotifyWatcher,
381        dir_path: &Path,
382        root_path: &Path,
383    ) -> Result<()> {
384        debug_assert_ne!(dir_path, root_path);
385
386        match notify_watcher.watch(dir_path, notify::RecursiveMode::NonRecursive) {
387            Ok(())
388            | Err(notify::Error {
389                // The path was probably deleted before we could process the event, but the parent
390                // should still be watched. The codepaths that care about this either call
391                // `start_watching_dir_and_parents` or handle the parents themselves.
392                kind: notify::ErrorKind::PathNotFound,
393                ..
394            }) => Ok(()),
395            Err(err) => {
396                // ast-grep-ignore: no-context-format
397                return Err(err).context(format!("Unable to watch {}", dir_path.display(),));
398            }
399        }
400    }
401
402    /// Private helper, assumes that `dir_path` has already been added to
403    /// [`NonRecursiveWatchingState::watched`].
404    ///
405    /// Watches the given `dir_path` and every parent up to `root_path`. Parents must be recursively
406    /// watched in case any of them change:
407    /// https://docs.rs/notify/latest/notify/#parent-folder-deletion
408    fn start_watching_dir_and_parents(
409        state: &mut NonRecursiveWatchingState,
410        dir_path: &Path,
411        root_path: &Path,
412    ) -> Result<()> {
413        let mut found_watched_ancestor = false;
414
415        // NOTE: `Path::ancestors` yields ancestors from longest to shortest path.
416        let dir_and_ancestor_paths: Vec<_> = [dir_path]
417            .into_iter()
418            .chain(
419                dir_path
420                    .ancestors()
421                    // skip: `ancestors` includes `dir_path` itself, as well as the ancestors, but
422                    // we only want to apply the `take_while` check to parents
423                    .skip(1)
424                    .take_while(|p| {
425                        found_watched_ancestor = *p == root_path || state.watched.contains(*p);
426                        !found_watched_ancestor
427                    }),
428            )
429            .collect();
430
431        if !found_watched_ancestor {
432            // this should never happen, as we should eventually hit the `root_path`
433            anyhow::bail!(
434                "failed to find the fs root of {root_path:?} while watching {dir_path:?}"
435            );
436        }
437
438        // Reverse the iterator: We want to start closest to the root and work towards `dir_path`
439        // (opposite of `Path::ancestors`), to avoid a potential race condition if directories are
440        // removed and re-added before we've watched their parent.
441        for path in dir_and_ancestor_paths.into_iter().rev() {
442            // this will silently ignore if the path is not found, expecting that we've watched the
443            // parent directory
444            start_watching_dir(&mut state.notify_watcher, path, root_path)?;
445            state.watched.insert(path.to_owned());
446        }
447
448        Ok(())
449    }
450}
451
452impl DiskWatcher {
453    pub fn new(config: DiskWatcherConfig) -> Self {
454        Self {
455            state: State::new_stopped(config.resolve_recursive_mode()),
456            config,
457        }
458    }
459
460    pub async fn start_watching<FsApi: DiskFileSystemWatcherApi>(fs: Arc<FsApi>) -> Result<()> {
461        let watcher: &Self = fs.watcher();
462        let state_guard = watcher.state.write().await;
463
464        // bail out if we're already watching
465        if let StateWriteGuard::Recursive(guard) = &state_guard
466            && matches!(**guard, RecursiveState::Watching { .. })
467        {
468            return Ok(());
469        } else if let StateWriteGuard::NonRecursive(guard) = &state_guard
470            && matches!(**guard, NonRecursiveState::Watching(..))
471        {
472            return Ok(());
473        }
474
475        // Create a channel to receive the events.
476        let (tx, rx) = channel();
477        // Create a watcher object, delivering debounced events.
478        // The notification back-end is selected based on the platform.
479        let config = Config::default();
480        // we should track and invalidate each part of a symlink chain ourselves in
481        // turbo-tasks-fs
482        let config = config.with_follow_symlinks(false);
483
484        let mut notify_watcher = if let Some(poll_interval) = watcher.config.poll_interval {
485            let config = config.with_poll_interval(poll_interval);
486            NotifyWatcher::Polling(PollWatcher::new(tx, config)?)
487        } else {
488            NotifyWatcher::Recommended(RecommendedWatcher::new(tx, config)?)
489        };
490
491        // TOCTOU: we must watch `root_path` before calling any invalidators and setting up the
492        // watchers in their associated functions
493        let root_path = fs.root_path();
494        notify_watcher.watch(
495            root_path,
496            notify::RecursiveMode::from(watcher.state.recursive_mode()),
497        )?;
498
499        // We need to invalidate all reads or writes that happened before watching. As a
500        // side-effect, this will call `ensure_watched` again, setting up any watchers needed.
501        //
502        // Best is to start_watching before starting to read
503        if watcher.config.report_invalidation_reason {
504            let name = fs.name().clone();
505            fs.invalidate_all_with_reason(|path| WatchStart {
506                name: name.clone(),
507                // this path is just used for display purposes
508                path: RcStr::from(path.to_string_lossy()),
509            });
510        } else {
511            fs.invalidate_all();
512        }
513
514        spawn_thread({
515            let fs = fs.clone();
516            move || Self::watch_thread(fs, rx)
517        });
518
519        // Updating `self.state` is done last. If we panic while setting up the watcher, it'll
520        // stay in the `Stopped` state.
521        match state_guard {
522            StateWriteGuard::Recursive(mut recursive) => {
523                *recursive = RecursiveState::Watching {
524                    _notify_watcher: notify_watcher,
525                }
526            }
527            StateWriteGuard::NonRecursive(mut non_recursive) => {
528                *non_recursive = NonRecursiveState::Watching(NonRecursiveWatchingState {
529                    notify_watcher,
530                    watched: BTreeSet::new(),
531                })
532            }
533        };
534
535        Ok(())
536    }
537
538    pub async fn stop_watching(&self) {
539        match &self.state {
540            State::Recursive(state) => *state.write().await = RecursiveState::Stopped,
541            State::NonRecursive(state) => *state.write().await = NonRecursiveState::Stopped,
542        }
543        // thread will detect the stop because the channel is disconnected when `NotifyWatcher` is
544        // dropped
545    }
546
547    /// Internal thread that processes the events from the watcher
548    /// and invalidates the cache.
549    ///
550    /// Should only be called once from `start_watching`.
551    fn watch_thread<FsApi: DiskFileSystemWatcherApi>(
552        fs: Arc<FsApi>,
553        rx: Receiver<notify::Result<notify::Event>>,
554    ) {
555        let watcher: &Self = fs.watcher();
556        let report_invalidation_reason = watcher.config.report_invalidation_reason;
557        let mut batch = BatchedInvalidations::new(
558            watcher.state.recursive_mode(),
559            watcher.config.poll_interval.is_some(),
560        );
561
562        'outer: loop {
563            let mut deadline: Option<Instant> = None;
564            loop {
565                let event_result = match deadline {
566                    None => rx.recv().map_err(|_| RecvTimeoutError::Disconnected),
567                    Some(deadline) => {
568                        rx.recv_timeout(deadline.saturating_duration_since(Instant::now()))
569                    }
570                };
571                match event_result {
572                    Ok(Ok(event)) => {
573                        // TODO: We might benefit from some user-facing diagnostics if it rescans
574                        // occur frequently (i.e. more than X times in Y minutes)
575                        //
576                        // You can test rescans on Linux by reducing the inotify queue to something
577                        // really small:
578                        //
579                        // ```
580                        // echo 3 | sudo tee /proc/sys/fs/inotify/max_queued_events
581                        // ```
582                        if event.need_rescan() {
583                            let _lock = fs.invalidation_lock().blocking_write();
584
585                            // flush the whole mpsc queue, we're about to rescan, we don't need to
586                            // process any other update events that have already happened
587                            while rx.try_recv().is_ok() {}
588
589                            if let State::NonRecursive(non_recursive) = &watcher.state {
590                                // we can't narrow this down to a smaller set of paths: Rescan
591                                // events (at least when tested on
592                                // Linux) come with no `paths`, and we use
593                                // only one global `notify::Watcher` instance.
594                                //
595                                // TODO: Report diagnostics if an error happens
596                                fs.tokio_handle().block_on(
597                                    non_recursive_helpers::restore_all_watched_ignore_errors(
598                                        non_recursive,
599                                        fs.root_path(),
600                                    ),
601                                );
602                            }
603
604                            if report_invalidation_reason {
605                                fs.invalidate_all_with_reason(|path| InvalidateRescan {
606                                    // this path is just used for display purposes
607                                    path: RcStr::from(path.to_string_lossy()),
608                                });
609                            } else {
610                                fs.invalidate_all();
611                            }
612
613                            // no need to process the rest of the batch as we just
614                            // invalidated everything
615                            batch.clear();
616                            break;
617                        }
618
619                        // Only an event that contributes to the batch keeps it open for another
620                        // `BATCH_DELAY`.
621                        if batch.add_event(event) {
622                            deadline = Some(Instant::now() + BATCH_DELAY);
623                        }
624                    }
625                    // Error raised by notify watcher itself
626                    Ok(Err(notify::Error { kind, paths })) => {
627                        println!("watch error ({paths:?}): {kind:?} ");
628
629                        let flags = InvalidationFlags::PATH_AND_CHILDREN
630                            | InvalidationFlags::PATH_AND_CHILDREN_DIR;
631                        if paths.is_empty() {
632                            batch.mark(fs.root_path().into(), flags);
633                        } else {
634                            for path in paths {
635                                batch.mark(path.into_boxed_path(), flags);
636                            }
637                        }
638                        deadline = Some(Instant::now() + BATCH_DELAY);
639                    }
640                    Err(RecvTimeoutError::Timeout) => {
641                        // The batch is complete: break out to invalidate the collected paths.
642                        break;
643                    }
644                    Err(RecvTimeoutError::Disconnected) => {
645                        // Sender has been disconnected, which means DiskFileSystem has been dropped
646                        // exit thread
647                        break 'outer;
648                    }
649                }
650            }
651
652            // We need to start watching first before invalidating the changed paths...
653            // This is only needed on platforms we don't do recursive watching on.
654            if let State::NonRecursive(non_recursive) = &watcher.state {
655                for path in batch.new_paths() {
656                    // TODO: Report diagnostics if this error happens
657                    let _ = fs
658                        .tokio_handle()
659                        .block_on(non_recursive_helpers::restore_if_watched(
660                            non_recursive,
661                            path,
662                            fs.root_path(),
663                        ));
664                }
665            }
666
667            let Some(turbo_tasks) = fs.turbo_tasks() else {
668                // TurboTasks was dropped, stop watching
669                break 'outer;
670            };
671            let _guard = fs.tokio_handle().enter();
672
673            let _lock = fs.invalidation_lock().blocking_write();
674            batch.execute(
675                fs.invalidator_map(),
676                fs.dir_invalidator_map(),
677                |invalidation_reason_path, invalidator| {
678                    invalidate(
679                        &*fs,
680                        &*turbo_tasks,
681                        report_invalidation_reason,
682                        invalidation_reason_path,
683                        invalidator,
684                    )
685                },
686            );
687        }
688    }
689
690    pub async fn ensure_watched_file(&self, path: &Path, root_path: &Path) -> Result<()> {
691        // Watch the parent directory instead of the specified file, since directories also track
692        // their immediate children (even in non-recursive mode), and we need to watch all the
693        // parents anyways.
694        if let State::NonRecursive(non_recursive) = &self.state
695            && let Some(dir_path) = path.parent()
696        {
697            non_recursive_helpers::ensure_watched(non_recursive, dir_path, root_path).await?;
698        }
699        Ok(())
700    }
701
702    pub async fn ensure_watched_dir(&self, dir_path: &Path, root_path: &Path) -> Result<()> {
703        if let State::NonRecursive(non_recursive) = &self.state {
704            non_recursive_helpers::ensure_watched(non_recursive, dir_path, root_path).await?;
705        }
706        Ok(())
707    }
708}
709
710bitflags! {
711    /// Describes how a single path in a [`BatchedInvalidations`] should be invalidated. A path may
712    /// carry any combination of these (accumulated across the events in a batch).
713    struct InvalidationFlags: u8 {
714        /// Invalidate exactly this path in the file-content invalidator map.
715        const PATH = 1 << 0;
716        /// Invalidate exactly this path in the directory-listing invalidator map.
717        const PATH_DIR = 1 << 1;
718        /// Invalidate this path and all of its children in the file-content invalidator map.
719        const PATH_AND_CHILDREN = 1 << 2;
720        /// Invalidate this path and all of its children in the directory-listing invalidator map.
721        const PATH_AND_CHILDREN_DIR = 1 << 3;
722    }
723}
724
725/// A set of deferred invalidations. Because one or more files may be updated many times in quick
726/// succession, we don't want to perform invalidations until we think the filesystem has settled.
727///
728/// This avoids reading partially-written files which might generate transient errors, and reduces
729/// CPU and memory usage by producing less wasted work.
730///
731/// Paths are stored once in a flag-keyed map, with a set of [`InvalidationFlags`] describing what
732/// needs to happen for each, rather than in several separate sets. This avoids cloning each
733/// `PathBuf` into multiple collections.
734struct BatchedInvalidations {
735    paths: FxHashMap<Box<Path>, InvalidationFlags>,
736    /// See [`Self::new_paths`]). Stored as [`None`] in non-recursive mode.
737    new_paths: Option<FxHashSet<Box<Path>>>,
738    /// Whether events are coming from [`PollWatcher`] instead of [`RecommendedWatcher`], which
739    /// changes how a file content change is reported. See [`Self::is_content_change`].
740    polling: bool,
741}
742
743impl BatchedInvalidations {
744    fn new(recursive_mode: DiskWatcherRecursiveMode, polling: bool) -> Self {
745        Self {
746            paths: FxHashMap::default(),
747            new_paths: match recursive_mode {
748                DiskWatcherRecursiveMode::NonRecursive => Some(FxHashSet::default()),
749                DiskWatcherRecursiveMode::Recursive => None,
750            },
751            polling,
752        }
753    }
754
755    /// Whether a [`ModifyKind::Metadata`] event means the file's *contents* changed.
756    ///
757    /// Some backends don't report content changes as [`ModifyKind::Data`] at all, so we have to
758    /// treat one specific metadata change per backend as a content change. Accepting these
759    /// unconditionally would mean invalidating on every `chmod`/`touch` on the backends that do
760    /// report `Data` properly.
761    fn is_content_change(&self, kind: MetadataKind) -> bool {
762        match kind {
763            // `PollWatcher` detects changes by comparing mtimes, so a content change surfaces as a
764            // write-time change. It only emits `Data` when `Config::with_compare_contents` is
765            // enabled, which we don't do because hashing every watched file is too expensive.
766            MetadataKind::WriteTime => self.polling,
767            // fsevents does not always emit `kFSEventStreamEventFlagItemModified` for a content
768            // change; sometimes it only emits `kFSEventStreamEventFlagItemInodeMetaMod`, which
769            // notify maps to `MetadataKind::Any`. This causes redundant invalidations, but it's the
770            // only way to reliably detect content changes there. Fix for PACK-2437.
771            //
772            // libuv does the same thing to trigger `UV_CHANGES`:
773            // https://github.com/libuv/libuv/commit/73cf3600d75a5884b890a1a94048b8f3f9c66876
774            //
775            // inotify and ReadDirectoryChangesW both report content changes on their own, so
776            // `MetadataKind::Any` there only ever means an actual attribute change.
777            MetadataKind::Any => cfg!(target_os = "macos"),
778            _ => false,
779        }
780    }
781
782    fn clear(&mut self) {
783        self.paths.clear();
784        if let Some(new_paths) = &mut self.new_paths {
785            new_paths.clear();
786        }
787    }
788
789    /// Records `path` as newly-created so its watch can be (re-)established. No-op in recursive
790    /// watching mode.
791    fn mark_new_path(&mut self, path: &Path) {
792        if let Some(new_paths) = &mut self.new_paths {
793            new_paths.insert(Box::from(path));
794        }
795    }
796
797    fn mark(&mut self, path: Box<Path>, flags: InvalidationFlags) {
798        *self.paths.entry(path).or_insert(InvalidationFlags::empty()) |= flags;
799    }
800
801    fn mark_parent_dir(&mut self, path: &Path) {
802        if let Some(parent) = path.parent() {
803            self.mark(Box::from(parent), InvalidationFlags::PATH_DIR);
804        }
805    }
806
807    /// Iterates over the newly-created paths in this batch. In non-recursive watching mode, these
808    /// must have their watches (re-)established before [`Self::execute`] is called (see the note
809    /// there). Always empty in recursive mode.
810    fn new_paths(&self) -> impl Iterator<Item = &Path> {
811        self.new_paths.iter().flatten().map(|path| &**path)
812    }
813
814    /// Updates the batch to contain updated paths from the given event. Does not perform any
815    /// invalidations.
816    ///
817    /// Returns `true` if the event contained relevant events, or `false` if it was filtered out.
818    #[must_use]
819    fn add_event(&mut self, event: notify::Event) -> bool {
820        let paths: Vec<PathBuf> = event.paths;
821        if paths.is_empty() {
822            return false;
823        }
824        match event.kind {
825            EventKind::Modify(ModifyKind::Data(_)) => {
826                for path in paths {
827                    self.mark(path.into_boxed_path(), InvalidationFlags::PATH);
828                }
829                true
830            }
831            // Some backends (fsevents, polling) can report metadata events for file content changes
832            EventKind::Modify(ModifyKind::Metadata(kind)) if self.is_content_change(kind) => {
833                for path in paths {
834                    self.mark(path.into_boxed_path(), InvalidationFlags::PATH);
835                }
836                true
837            }
838            EventKind::Create(_) => {
839                for path in paths {
840                    self.mark_parent_dir(&path);
841                    self.mark_new_path(&path);
842                    self.mark(
843                        path.into_boxed_path(),
844                        InvalidationFlags::PATH_AND_CHILDREN
845                            | InvalidationFlags::PATH_AND_CHILDREN_DIR,
846                    );
847                }
848                true
849            }
850            EventKind::Remove(_) => {
851                for path in paths {
852                    self.mark_parent_dir(&path);
853                    self.mark(
854                        path.into_boxed_path(),
855                        InvalidationFlags::PATH_AND_CHILDREN
856                            | InvalidationFlags::PATH_AND_CHILDREN_DIR,
857                    );
858                }
859                true
860            }
861            // A single event emitted with both the `From` and `To` paths.
862            EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
863                let [source, destination] = <[PathBuf; 2]>::try_from(paths)
864                    .expect("RenameMode::Both event must contain exactly two paths");
865                self.mark_parent_dir(&source);
866                self.mark(
867                    source.into_boxed_path(),
868                    InvalidationFlags::PATH_AND_CHILDREN,
869                );
870                self.mark_parent_dir(&destination);
871                self.mark_new_path(&destination);
872                self.mark(
873                    destination.into_boxed_path(),
874                    InvalidationFlags::PATH_AND_CHILDREN,
875                );
876                true
877            }
878            // We expect `RenameMode::Both` to cover most of the cases we need to invalidate,
879            // but we also check other RenameModes to cover cases where notify couldn't match the
880            // two rename events.
881            EventKind::Any | EventKind::Modify(ModifyKind::Any | ModifyKind::Name(..)) => {
882                for path in paths {
883                    self.mark_parent_dir(&path);
884                    self.mark(
885                        path.into_boxed_path(),
886                        InvalidationFlags::PATH_AND_CHILDREN
887                            | InvalidationFlags::PATH_AND_CHILDREN_DIR,
888                    );
889                }
890                true
891            }
892            EventKind::Modify(ModifyKind::Metadata(..) | ModifyKind::Other)
893            | EventKind::Access(_)
894            | EventKind::Other => {
895                // ignored
896                false
897            }
898        }
899    }
900
901    /// Performs all batched invalidations, calling `invalidate` once for each `(path, invalidator)`
902    /// pair that needs to be invalidated, then clears the batch.
903    ///
904    /// In non-recursive watching mode, [`Self::new_paths`] must be processed (to (re-)establish
905    /// watches) *before* calling this.
906    ///
907    /// For each path, a recursive invalidation subsumes an exact one, as
908    /// [`extract_path_with_children`][OrderedPathMapExt::extract_path_with_children] removes the
909    /// path itself in addition to its children.
910    fn execute(
911        &mut self,
912        invalidator_map: &InvalidatorMap,
913        dir_invalidator_map: &InvalidatorMap,
914        invalidate: impl Fn(&Path, Invalidator),
915    ) {
916        for (map, exact_flag, recursive_flag) in [
917            (
918                invalidator_map,
919                InvalidationFlags::PATH,
920                InvalidationFlags::PATH_AND_CHILDREN,
921            ),
922            (
923                dir_invalidator_map,
924                InvalidationFlags::PATH_DIR,
925                InvalidationFlags::PATH_AND_CHILDREN_DIR,
926            ),
927        ] {
928            let mut map = map.lock().unwrap();
929            for (path, flags) in &self.paths {
930                if flags.contains(recursive_flag) {
931                    for (_, invalidators) in map.extract_path_with_children(path) {
932                        for invalidator in invalidators {
933                            invalidate(path, invalidator);
934                        }
935                    }
936                } else if flags.contains(exact_flag)
937                    && let Some(invalidators) = map.remove(&**path)
938                {
939                    for invalidator in invalidators {
940                        invalidate(path, invalidator);
941                    }
942                }
943            }
944        }
945        self.clear();
946    }
947}
948
949#[instrument(
950    parent = None,
951    level = "info",
952    name = "file change",
953    skip_all,
954    fields(name = %invalidation_reason_path.display())
955)]
956fn invalidate(
957    inner: &impl DiskFileSystemWatcherApi,
958    turbo_tasks: &dyn TurboTasksApi,
959    report_invalidation_reason: bool,
960    invalidation_reason_path: &Path,
961    invalidator: Invalidator,
962) {
963    if report_invalidation_reason
964        && let Some(path) =
965            format_absolute_fs_path(invalidation_reason_path, inner.name(), inner.root_path())
966    {
967        invalidator.invalidate_with_reason(turbo_tasks, WatchChange { path });
968        return;
969    }
970    invalidator.invalidate(turbo_tasks);
971}
972
973/// Invalidation was caused by a watcher rescan event. This will likely invalidate *every* watched
974/// file.
975#[derive(Clone, PartialEq, Eq, Hash)]
976pub struct InvalidateRescan {
977    path: RcStr,
978}
979
980impl InvalidationReason for InvalidateRescan {
981    fn kind(&self) -> Option<StaticOrArc<dyn InvalidationReasonKind>> {
982        Some(StaticOrArc::Static(&INVALIDATE_RESCAN_KIND))
983    }
984}
985
986impl fmt::Display for InvalidateRescan {
987    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
988        write!(f, "{} in filesystem invalidated", self.path)
989    }
990}
991
992/// [Invalidation kind][InvalidationReasonKind] for [`InvalidateRescan`].
993#[derive(PartialEq, Eq, Hash)]
994struct InvalidateRescanKind;
995
996static INVALIDATE_RESCAN_KIND: InvalidateRescanKind = InvalidateRescanKind;
997
998impl InvalidationReasonKind for InvalidateRescanKind {
999    fn fmt(
1000        &self,
1001        reasons: &FxIndexSet<StaticOrArc<dyn InvalidationReason>>,
1002        f: &mut fmt::Formatter<'_>,
1003    ) -> fmt::Result {
1004        let first_reason: &dyn InvalidationReason = &*reasons[0];
1005        write!(
1006            f,
1007            "{} items in filesystem invalidated due to notify::Watcher rescan event ({}, ...)",
1008            reasons.len(),
1009            (first_reason as &dyn Any)
1010                .downcast_ref::<InvalidateRescan>()
1011                .unwrap()
1012                .path
1013        )
1014    }
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019    use std::{fs, time::SystemTime};
1020
1021    use rstest::rstest;
1022    use turbo_tasks::TurboTasks;
1023    use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
1024
1025    use super::*;
1026    use crate::watcher::mock_fs_api::MockFileSystem;
1027
1028    /// Polls [`tracked_read`] until it has executed more than `previous_runs` times, i.e. until the
1029    /// watcher has invalidated it.
1030    async fn wait_for_rerun(fs: &Arc<MockFileSystem>, path: &Path, previous_runs: u64) {
1031        const WATCH_TIMEOUT: Duration = Duration::from_secs(5);
1032        let deadline = Instant::now() + WATCH_TIMEOUT;
1033        loop {
1034            if fs.tracked_read_strongly_consistent(path).await > previous_runs {
1035                return;
1036            }
1037            assert!(
1038                Instant::now() < deadline,
1039                "the watcher did not invalidate {path:?} within {WATCH_TIMEOUT:?}",
1040            );
1041            tokio::time::sleep(Duration::from_millis(10)).await;
1042        }
1043    }
1044
1045    /// Backdates `path`'s mtime so that a [`PollWatcher`] can see the write that follows it to
1046    /// avoid mtime truncation issues.
1047    fn backdate(path: &Path) {
1048        fs::File::options()
1049            .write(true)
1050            .open(path)
1051            .unwrap()
1052            .set_modified(SystemTime::now() - Duration::from_secs(10))
1053            .unwrap();
1054    }
1055
1056    /// `recursive_mode` is set explicitly rather than left to the platform default so that both
1057    /// watching strategies are covered on every host. `TURBO_TASKS_FORCE_WATCH_MODE` still
1058    /// overrides it, collapsing these into two cases.
1059    #[rstest]
1060    #[case::native_recursive(None, DiskWatcherRecursiveMode::Recursive)]
1061    #[case::native_non_recursive(None, DiskWatcherRecursiveMode::NonRecursive)]
1062    #[case::polling_recursive(Some(Duration::from_millis(20)), DiskWatcherRecursiveMode::Recursive)]
1063    #[case::polling_non_recursive(
1064        Some(Duration::from_millis(20)),
1065        DiskWatcherRecursiveMode::NonRecursive
1066    )]
1067    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1068    async fn watches_file_and_directory_changes(
1069        #[case] poll_interval: Option<Duration>,
1070        #[case] recursive_mode: DiskWatcherRecursiveMode,
1071    ) {
1072        let tt = TurboTasks::new(TurboTasksBackend::new(
1073            BackendOptions::default(),
1074            noop_backing_storage(),
1075        ));
1076        tt.run_once(async move {
1077            let fs = MockFileSystem::new(DiskWatcherConfig {
1078                recursive_mode: Some(recursive_mode),
1079                poll_interval,
1080                report_invalidation_reason: true,
1081            });
1082            let sub_dir = fs.root_path.join("sub");
1083            let file_path = sub_dir.join("file.txt");
1084            fs::create_dir(&sub_dir).unwrap();
1085            fs::write(&file_path, "initial").unwrap();
1086            backdate(&file_path);
1087
1088            DiskWatcher::start_watching(fs.clone()).await?;
1089
1090            // the initial reads register the invalidators that the watcher will later fire
1091            assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 1);
1092            assert_eq!(fs.tracked_read_strongly_consistent(&sub_dir).await, 1);
1093
1094            // reading again without touching the filesystem must not re-run anything
1095            assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 1);
1096            assert_eq!(fs.tracked_read_strongly_consistent(&sub_dir).await, 1);
1097
1098            // modifying a file invalidates the task that read that file
1099            fs::write(&file_path, "updated")?;
1100            wait_for_rerun(&fs, &file_path, 1).await;
1101
1102            // creating a file invalidates the task that listed the containing directory
1103            let dir_runs = fs.tracked_read_strongly_consistent(&sub_dir).await;
1104            fs::write(sub_dir.join("new.txt"), "new")?;
1105            wait_for_rerun(&fs, &sub_dir, dir_runs).await;
1106
1107            fs.watcher.stop_watching().await;
1108            anyhow::Ok(())
1109        })
1110        .await
1111        .unwrap();
1112    }
1113}