Skip to main content

turbo_tasks_fs/watcher/
mod.rs

1mod batch_schedule;
2mod fs_api;
3#[cfg(test)]
4mod mock_fs_api;
5
6use std::{
7    any::Any,
8    borrow::Cow,
9    cell::LazyCell,
10    collections::BTreeSet,
11    env, fmt,
12    path::{Path, PathBuf},
13    sync::{
14        Arc, LazyLock,
15        mpsc::{Receiver, RecvTimeoutError, channel},
16    },
17    time::Duration,
18};
19
20use anyhow::{Context, Result};
21use bincode::{
22    Decode, Encode,
23    de::Decoder,
24    enc::Encoder,
25    error::{DecodeError, EncodeError},
26};
27use bitflags::bitflags;
28use indexmap::map::{RawEntryApiV1, raw_entry_v1::RawEntryMut};
29use notify::{
30    Config, EventKind, PollWatcher, RecommendedWatcher, Watcher,
31    event::{MetadataKind, ModifyKind, RenameMode},
32};
33use tokio::sync::{RwLock, RwLockWriteGuard};
34use tracing::instrument;
35use turbo_rcstr::RcStr;
36use turbo_tasks::{
37    FxIndexMap, FxIndexSet, InvalidationReason, InvalidationReasonKind, Invalidator, ResolvedVc,
38    TraitRef, TurboTasksApi, spawn_thread, util::StaticOrArc,
39};
40
41use crate::{
42    format_absolute_fs_path,
43    invalidation::{WatchChange, WatchStart},
44    invalidator_map::InvalidatorMap,
45    path_map::OrderedPathMapExt,
46    watcher::{batch_schedule::BatchSchedule, fs_api::DiskFileSystemWatcherApi},
47};
48
49/// Overrides [`DiskWatcherConfig::recursive_mode`]. Users shouldn't need to set this, this is
50/// intended only for debugging purposes.
51static FORCED_WATCH_RECURSIVE_MODE: LazyLock<Option<DiskWatcherRecursiveMode>> = LazyLock::new(
52    || match env::var("TURBO_TASKS_FORCE_WATCH_MODE").as_deref() {
53        Ok("recursive") => Some(DiskWatcherRecursiveMode::Recursive),
54        Ok("nonrecursive") => Some(DiskWatcherRecursiveMode::NonRecursive),
55        Ok(_) => {
56            eprintln!(
57                "unsupported `TURBO_TASKS_FORCE_WATCH_MODE`, must be `recursive` or `nonrecursive`"
58            );
59            None
60        }
61        _ => None,
62    },
63);
64
65#[turbo_tasks::task_input]
66#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Encode, Decode)]
67pub struct DiskWatcherConfig {
68    /// Whether to let the [`notify::Watcher`] recurse into subdirectories itself, or to track and
69    /// watch each directory we care about ourselves.
70    ///
71    /// [`None`] picks a default based on the platform and [`Self::poll_interval`], which is
72    /// normally what you want.
73    ///
74    /// The `TURBO_TASKS_FORCE_WATCH_MODE` environment variable will override this configuration
75    /// value (intended for debugging purposes).
76    pub recursive_mode: Option<DiskWatcherRecursiveMode>,
77    /// Poll the filesystem at this interval instead of using the platform's native file watching,
78    /// for cases where native watching doesn't work (e.g. some Docker setups). This is slow and
79    /// inefficient, it should only be used as a last resort.
80    ///
81    /// [`None`] disables polling, using the platform's native watcher ([`RecommendedWatcher`])
82    /// instead of [`PollWatcher`].
83    pub poll_interval: Option<Duration>,
84    /// Attach an [`InvalidationReason`] to every invalidation the watcher causes (`WatchStart`,
85    /// `WatchChange`, or `InvalidateRescan`), so that it can be reported to the user.
86    ///
87    /// This costs an extra allocation per invalidated path, so it's only worth enabling when
88    /// something actually consumes the reasons.
89    pub report_invalidation_reason: bool,
90
91    /// How long to keep a batch of filesystem events open, waiting for more events, before
92    /// flushing invalidations. Batching coalesces bursts (e.g. a `git checkout`) into a single
93    /// invalidation pass and avoids reading half-written files.
94    ///
95    /// If set too low (<10ms), this is known to cause partial file reads on Linux where `inotify`
96    /// has very low latency.
97    pub batch_delay: Duration,
98    /// When [`DiskWatcherPathMatcher::match_path`] returns `true`, we will extend the batch by
99    /// [`Self::extended_batch_delay_duration`].
100    pub extended_batch_delay_matcher: Option<ResolvedVc<Box<dyn DiskWatcherPathMatcher>>>,
101    /// The idle period required to close a batch once [`Self::extended_batch_delay_matcher`] has
102    /// matched. Unused when there is no matcher.
103    pub extended_batch_delay_duration: Duration,
104
105    /// If a single batch stays open at least this long, emit a `FilesystemSettlingEvent`
106    /// compilation event so the user knows why work has stalled. Repeated events within the same
107    /// batch back off exponentially, up to [`Self::settling_event_max_delay`].
108    pub settling_event_initial_delay: Duration,
109    /// Upper bound for the exponentially increasing interval between repeated
110    /// `FilesystemSettlingEvent`s within a single batch.
111    pub settling_event_max_delay: Duration,
112}
113
114impl Default for DiskWatcherConfig {
115    fn default() -> Self {
116        Self {
117            recursive_mode: None,
118            poll_interval: None,
119            report_invalidation_reason: false,
120            batch_delay: Duration::from_millis(10),
121            extended_batch_delay_matcher: None,
122            extended_batch_delay_duration: Duration::from_millis(200),
123            settling_event_initial_delay: Duration::from_millis(500),
124            settling_event_max_delay: Duration::from_secs(60),
125        }
126    }
127}
128
129/// Matches absolute paths reported by the filesystem watcher. See
130/// [`DiskWatcherConfig::extended_batch_delay_matcher`].
131#[turbo_tasks::value_trait]
132pub trait DiskWatcherPathMatcher {
133    /// Called on the watcher thread once per path of every incoming event, so this should be
134    /// cheap and must not block.
135    fn match_path(&self, path: &Path) -> bool;
136}
137
138/// Equivalent to [`notify::RecursiveMode`], but implements traits needed by [`turbo_tasks`].
139///
140/// When using [`Self::Recursive`], [`notify::Watcher`] will recursively track all contents
141/// of the filesystem root. This should only be used on platforms with efficient recursive watcher
142/// implementations (i.e. macOS and Windows).
143///
144/// When using [`Self::NonRecursive`], we only track previously read files and their parent
145/// directories.
146#[turbo_tasks::task_input]
147#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash, Encode, Decode)]
148pub enum DiskWatcherRecursiveMode {
149    Recursive,
150    NonRecursive,
151}
152
153impl From<DiskWatcherRecursiveMode> for notify::RecursiveMode {
154    fn from(value: DiskWatcherRecursiveMode) -> Self {
155        match value {
156            DiskWatcherRecursiveMode::Recursive => notify::RecursiveMode::Recursive,
157            DiskWatcherRecursiveMode::NonRecursive => notify::RecursiveMode::NonRecursive,
158        }
159    }
160}
161
162impl DiskWatcherConfig {
163    /// Resolves [`Self::recursive_mode`], falling back to a default based on
164    /// [`Self::poll_interval`] and the platform.
165    fn resolve_recursive_mode(&self) -> DiskWatcherRecursiveMode {
166        // macOS and Windows have efficient recursive watchers, so it's best to track the entire
167        // directory and filter events to the files we care about. inotify on Linux is
168        // non-recursive, so notify-rs's implementation is inefficient; better for us to track it
169        // ourselves and only watch the directories we know we care about.
170        //
171        // See: <https://github.com/vercel/turborepo/pull/4100>
172        let platform_has_efficient_recursive_watcher =
173            cfg!(any(target_os = "macos", target_os = "windows"));
174
175        // the env var is a debugging escape hatch, so it wins over everything else
176        if let Some(forced) = *FORCED_WATCH_RECURSIVE_MODE {
177            forced
178        } else if let Some(recursive_mode) = self.recursive_mode {
179            recursive_mode
180        } else if self.poll_interval.is_some() {
181            // `PollWatcher` implements recursive watching by walking the entire subtree on every
182            // poll, so watching the fs root recursively would stat every file in the project each
183            // interval. Watching non-recursively keeps each poll to the directories we've read.
184            DiskWatcherRecursiveMode::NonRecursive
185        } else if platform_has_efficient_recursive_watcher {
186            DiskWatcherRecursiveMode::Recursive
187        } else {
188            DiskWatcherRecursiveMode::NonRecursive
189        }
190    }
191}
192
193pub(crate) struct DiskWatcher {
194    state: State,
195    config: DiskWatcherConfig,
196}
197
198/// Only [`Self::config`] is serialized: a decoded [`DiskWatcher`] is always stopped.
199impl Encode for DiskWatcher {
200    fn encode<E: Encoder>(&self, encoder: &mut E) -> Result<(), EncodeError> {
201        self.config.encode(encoder)
202    }
203}
204
205impl<Ctx> Decode<Ctx> for DiskWatcher {
206    fn decode<D: Decoder<Context = Ctx>>(decoder: &mut D) -> Result<Self, DecodeError> {
207        Ok(Self::new(DiskWatcherConfig::decode(decoder)?))
208    }
209}
210bincode::impl_borrow_decode!(DiskWatcher);
211
212enum State {
213    // Note: Information about if we're a recursive or non-recursive watcher must live outside the
214    // `RwLock` to allow us to quickly bail out before calling functions in
215    // `non_recursive_helpers`.
216    Recursive(RwLock<RecursiveState>),
217    NonRecursive(RwLock<NonRecursiveState>),
218}
219
220enum StateWriteGuard<'a> {
221    Recursive(RwLockWriteGuard<'a, RecursiveState>),
222    NonRecursive(RwLockWriteGuard<'a, NonRecursiveState>),
223}
224
225impl State {
226    fn new_stopped(recursive_mode: DiskWatcherRecursiveMode) -> Self {
227        match recursive_mode {
228            DiskWatcherRecursiveMode::Recursive => {
229                Self::Recursive(RwLock::new(RecursiveState::Stopped))
230            }
231            DiskWatcherRecursiveMode::NonRecursive => {
232                Self::NonRecursive(RwLock::new(NonRecursiveState::Stopped))
233            }
234        }
235    }
236
237    async fn write(&self) -> StateWriteGuard<'_> {
238        match self {
239            Self::Recursive(state) => StateWriteGuard::Recursive(state.write().await),
240            Self::NonRecursive(state) => StateWriteGuard::NonRecursive(state.write().await),
241        }
242    }
243
244    fn recursive_mode(&self) -> DiskWatcherRecursiveMode {
245        match self {
246            Self::Recursive(_) => DiskWatcherRecursiveMode::Recursive,
247            Self::NonRecursive(_) => DiskWatcherRecursiveMode::NonRecursive,
248        }
249    }
250}
251
252/// Used when [`DiskWatcherConfig::recursive_mode`] returns [`RecursiveMode::Recursive`] (default on
253/// macOS and Windows when not polling).
254enum RecursiveState {
255    /// Used when [`DiskWatcher::start_watching`] hasn't been called yet or after
256    /// [`DiskWatcher::stop_watching`] is called.
257    Stopped,
258    Watching {
259        /// Hold onto the watcher: When this is dropped, it will cause the channel to disconnect
260        _notify_watcher: NotifyWatcher,
261    },
262}
263
264/// Used when [`DiskWatcherConfig::recursive_mode`] returns [`RecursiveMode::NonRecursive`] (default
265/// on Linux, and everywhere when polling).
266enum NonRecursiveState {
267    /// Used when [`DiskWatcher::start_watching`] hasn't been called yet or after
268    /// [`DiskWatcher::stop_watching`] is called.
269    Stopped,
270    Watching(NonRecursiveWatchingState),
271}
272
273// split out from the `NonRecursiveState` enum because we want to pass this value around
274struct NonRecursiveWatchingState {
275    notify_watcher: NotifyWatcher,
276    /// Keeps track of which directories are currently or were previously watched by
277    /// [`Self::notify_watcher`].
278    ///
279    /// Invariants:
280    /// - Never contains `root_path`. A watcher for `root_path` is implicitly set up during
281    ///   [`DiskWatcher::start_watching`].
282    /// - Contains all parent directories up to `root_path` for every entry.
283    watched: BTreeSet<PathBuf>,
284}
285
286/// A thin wrapper around [`RecommendedWatcher`] and [`PollWatcher`].
287enum NotifyWatcher {
288    Recommended(RecommendedWatcher),
289    Polling(PollWatcher),
290}
291
292impl NotifyWatcher {
293    fn watch(&mut self, path: &Path, recursive_mode: notify::RecursiveMode) -> notify::Result<()> {
294        match self {
295            Self::Recommended(watcher) => watcher.watch(path, recursive_mode),
296            Self::Polling(watcher) => watcher.watch(path, recursive_mode),
297        }
298    }
299}
300
301mod non_recursive_helpers {
302    use super::*;
303    use crate::path_map::OrderedPathSetExt;
304
305    /// Called after a rescan in case a previously watched-but-deleted directory was recreated.
306    #[instrument(skip_all, level = "trace")]
307    pub async fn restore_all_watched_ignore_errors(
308        state: &RwLock<NonRecursiveState>,
309        root_path: &Path,
310    ) {
311        let mut guard = state.write().await;
312        let NonRecursiveState::Watching(watching_state) = &mut *guard else {
313            return;
314        };
315        for dir_path in watching_state.watched.iter() {
316            // TODO: Report diagnostics if this error happens
317            //
318            // Don't watch the parents, because those are already included in `self.watched` (so
319            // it'd be redundant), but also because this could deadlock, since we'd try to modify
320            // `self.watched` while iterating over it (write lock overlapping with a read lock).
321            let _ = start_watching_dir(&mut watching_state.notify_watcher, dir_path, root_path);
322        }
323    }
324
325    /// Called with directories newly found in parent directories we're watching. Restores the
326    /// watchers of those we were previously watching (i.e. that are in `watched`), and calls
327    /// `on_restored` with each of them. `on_restored` is called while holding the write lock on
328    /// `state`, so it must not access `state`.
329    #[instrument(skip_all, level = "trace")]
330    pub async fn restore_all_if_watched(
331        state: &RwLock<NonRecursiveState>,
332        dir_paths: FxIndexSet<Box<Path>>,
333        root_path: &Path,
334        mut on_restored: impl FnMut(Box<Path>),
335    ) {
336        let needs_restore = |watched: &BTreeSet<PathBuf>, dir_path: &Path| {
337            // The root directory is always implicitly watched during `DiskWatcher::start_watching`,
338            // we assume it is never deleted and never needs to be restored.
339            dir_path != root_path && watched.contains(dir_path)
340        };
341
342        // fast path: none of the directories are in `watched`, only take a read lock and bail out
343        // early
344        {
345            let guard = state.read().await;
346            let NonRecursiveState::Watching(watching_state) = &*guard else {
347                return;
348            };
349            if !dir_paths
350                .iter()
351                .any(|dir_path| needs_restore(&watching_state.watched, dir_path))
352            {
353                return;
354            }
355        }
356
357        // slow path: re-watch the paths
358        let mut guard = state.write().await;
359        let NonRecursiveState::Watching(watching_state) = &mut *guard else {
360            return;
361        };
362        for dir_path in dir_paths {
363            if needs_restore(&watching_state.watched, &dir_path) {
364                // TODO: Report diagnostics if this error happens. Report the path as restored even
365                // on errors, in case the watch was partially restored.
366                let _ = restore_watched_dir(watching_state, &dir_path, root_path);
367                on_restored(dir_path);
368            }
369        }
370    }
371
372    /// Re-watches `dir_path` and any of its children in `watched`.
373    fn restore_watched_dir(
374        watching_state: &mut NonRecursiveWatchingState,
375        dir_path: &Path,
376        root_path: &Path,
377    ) -> Result<()> {
378        // watch the new directory
379        start_watching_dir(&mut watching_state.notify_watcher, dir_path, root_path)?;
380
381        // Also try to restore any watchers for children of this directory
382        for child_path in watching_state.watched.iter_path_children(dir_path) {
383            // Don't watch the parents -- see the comment on `restore_all_watched_ignore_errors`
384            start_watching_dir(&mut watching_state.notify_watcher, child_path, root_path)?;
385        }
386        Ok(())
387    }
388
389    /// Called when a file in `dir_path` or `dir_path` itself is read or written. Adds a new watcher
390    /// if we're not already watching the directory.
391    ///
392    /// This should be called *before* reading a file to avoid a race condition.
393    #[instrument(skip_all, level = "trace")]
394    pub async fn ensure_watched(
395        state: &RwLock<NonRecursiveState>,
396        dir_path: &Path,
397        root_path: &Path,
398    ) -> Result<()> {
399        // fast path: The root directory is always implicitly watched during
400        // `DiskWatcher::start_watching`.
401        if dir_path == root_path {
402            return Ok(());
403        }
404
405        // fast path: the directory is already in `watched`, only take a read lock and bail out
406        // early
407        {
408            let guard = state.read().await;
409            let NonRecursiveState::Watching(watching_state) = &*guard else {
410                return Ok(());
411            };
412            if watching_state.watched.contains(dir_path) {
413                return Ok(());
414            }
415        }
416
417        // slow path: watch the path
418        let mut guard = state.write().await;
419        let NonRecursiveState::Watching(watching_state) = &mut *guard else {
420            return Ok(());
421        };
422        if watching_state.watched.insert(dir_path.to_path_buf()) {
423            start_watching_dir_and_parents(watching_state, dir_path, root_path)?;
424        }
425        Ok(())
426    }
427
428    /// Private helper, assumes that `dir_path` has already been added to
429    /// [`NonRecursiveWatchingState::watched`].
430    ///
431    /// This does not watch any of the parent directories. For that, use
432    /// [`start_watching_dir_and_parents`]. Use this method when iterating over previously-watched
433    /// values in `self.watching`.
434    fn start_watching_dir(
435        notify_watcher: &mut NotifyWatcher,
436        dir_path: &Path,
437        root_path: &Path,
438    ) -> Result<()> {
439        debug_assert_ne!(dir_path, root_path);
440
441        match notify_watcher.watch(dir_path, notify::RecursiveMode::NonRecursive) {
442            Ok(())
443            | Err(notify::Error {
444                // The path was probably deleted before we could process the event, but the parent
445                // should still be watched. The codepaths that care about this either call
446                // `start_watching_dir_and_parents` or handle the parents themselves.
447                kind: notify::ErrorKind::PathNotFound,
448                ..
449            }) => Ok(()),
450            Err(err) => {
451                // ast-grep-ignore: no-context-format
452                return Err(err).context(format!("Unable to watch {}", dir_path.display(),));
453            }
454        }
455    }
456
457    /// Private helper, assumes that `dir_path` has already been added to
458    /// [`NonRecursiveWatchingState::watched`].
459    ///
460    /// Watches the given `dir_path` and every parent up to `root_path`. Parents must be recursively
461    /// watched in case any of them change:
462    /// https://docs.rs/notify/latest/notify/#parent-folder-deletion
463    fn start_watching_dir_and_parents(
464        state: &mut NonRecursiveWatchingState,
465        dir_path: &Path,
466        root_path: &Path,
467    ) -> Result<()> {
468        let mut found_watched_ancestor = false;
469
470        // NOTE: `Path::ancestors` yields ancestors from longest to shortest path.
471        let dir_and_ancestor_paths: Vec<_> = [dir_path]
472            .into_iter()
473            .chain(
474                dir_path
475                    .ancestors()
476                    // skip: `ancestors` includes `dir_path` itself, as well as the ancestors, but
477                    // we only want to apply the `take_while` check to parents
478                    .skip(1)
479                    .take_while(|p| {
480                        found_watched_ancestor = *p == root_path || state.watched.contains(*p);
481                        !found_watched_ancestor
482                    }),
483            )
484            .collect();
485
486        if !found_watched_ancestor {
487            // this should never happen, as we should eventually hit the `root_path`
488            anyhow::bail!(
489                "failed to find the fs root of {root_path:?} while watching {dir_path:?}"
490            );
491        }
492
493        // Reverse the iterator: We want to start closest to the root and work towards `dir_path`
494        // (opposite of `Path::ancestors`), to avoid a potential race condition if directories are
495        // removed and re-added before we've watched their parent.
496        for path in dir_and_ancestor_paths.into_iter().rev() {
497            // this will silently ignore if the path is not found, expecting that we've watched the
498            // parent directory
499            start_watching_dir(&mut state.notify_watcher, path, root_path)?;
500            state.watched.insert(path.to_owned());
501        }
502
503        Ok(())
504    }
505}
506
507impl DiskWatcher {
508    pub fn new(config: DiskWatcherConfig) -> Self {
509        assert!(
510            config.extended_batch_delay_duration >= config.batch_delay,
511            "extended_batch_delay_duration must be at least batch_delay"
512        );
513        Self {
514            state: State::new_stopped(config.resolve_recursive_mode()),
515            config,
516        }
517    }
518
519    pub async fn start_watching<FsApi: DiskFileSystemWatcherApi>(fs: Arc<FsApi>) -> Result<()> {
520        let watcher: &Self = fs.watcher();
521
522        // read in the turbo-task context and before acquiring the lock
523        let extended_batch_delay_matcher = match watcher.config.extended_batch_delay_matcher {
524            Some(matcher) => Some(matcher.into_trait_ref().await?),
525            None => None,
526        };
527
528        let state_guard = watcher.state.write().await;
529
530        // bail out if we're already watching
531        if let StateWriteGuard::Recursive(guard) = &state_guard
532            && matches!(**guard, RecursiveState::Watching { .. })
533        {
534            return Ok(());
535        } else if let StateWriteGuard::NonRecursive(guard) = &state_guard
536            && matches!(**guard, NonRecursiveState::Watching(..))
537        {
538            return Ok(());
539        }
540
541        // Create a channel to receive the events.
542        let (tx, rx) = channel();
543        // Create a watcher object, delivering debounced events.
544        // The notification back-end is selected based on the platform.
545        let config = Config::default();
546        // we should track and invalidate each part of a symlink chain ourselves in
547        // turbo-tasks-fs
548        let config = config.with_follow_symlinks(false);
549
550        let mut notify_watcher = if let Some(poll_interval) = watcher.config.poll_interval {
551            let config = config.with_poll_interval(poll_interval);
552            NotifyWatcher::Polling(PollWatcher::new(tx, config)?)
553        } else {
554            NotifyWatcher::Recommended(RecommendedWatcher::new(tx, config)?)
555        };
556
557        // TOCTOU: we must watch `root_path` before calling any invalidators and setting up the
558        // watchers in their associated functions
559        let root_path = fs.root_path();
560        notify_watcher.watch(
561            root_path,
562            notify::RecursiveMode::from(watcher.state.recursive_mode()),
563        )?;
564
565        // We need to invalidate all reads or writes that happened before watching. As a
566        // side-effect, this will call `ensure_watched` again, setting up any watchers needed.
567        //
568        // Best is to start_watching before starting to read
569        if watcher.config.report_invalidation_reason {
570            let name = fs.name().clone();
571            fs.invalidate_all_with_reason(|path| WatchStart {
572                name: name.clone(),
573                // this path is just used for display purposes
574                path: RcStr::from(path.to_string_lossy()),
575            });
576        } else {
577            fs.invalidate_all();
578        }
579
580        spawn_thread({
581            let fs = fs.clone();
582            move || Self::watch_thread(fs, rx, extended_batch_delay_matcher)
583        });
584
585        // Updating `self.state` is done last. If we panic while setting up the watcher, it'll
586        // stay in the `Stopped` state.
587        match state_guard {
588            StateWriteGuard::Recursive(mut recursive) => {
589                *recursive = RecursiveState::Watching {
590                    _notify_watcher: notify_watcher,
591                }
592            }
593            StateWriteGuard::NonRecursive(mut non_recursive) => {
594                *non_recursive = NonRecursiveState::Watching(NonRecursiveWatchingState {
595                    notify_watcher,
596                    watched: BTreeSet::new(),
597                })
598            }
599        };
600
601        Ok(())
602    }
603
604    pub async fn stop_watching(&self) {
605        match &self.state {
606            State::Recursive(state) => *state.write().await = RecursiveState::Stopped,
607            State::NonRecursive(state) => *state.write().await = NonRecursiveState::Stopped,
608        }
609        // thread will detect the stop because the channel is disconnected when `NotifyWatcher` is
610        // dropped
611    }
612
613    /// Internal thread that processes the events from the watcher
614    /// and invalidates the cache.
615    ///
616    /// Should only be called once from `start_watching`.
617    fn watch_thread<FsApi: DiskFileSystemWatcherApi>(
618        fs: Arc<FsApi>,
619        rx: Receiver<notify::Result<notify::Event>>,
620        extended_batch_delay_matcher: Option<TraitRef<Box<dyn DiskWatcherPathMatcher>>>,
621    ) {
622        let watcher: &Self = fs.watcher();
623        let config = &watcher.config;
624        let report_invalidation_reason = config.report_invalidation_reason;
625        let mut batch = BatchedInvalidations::new(
626            watcher.state.recursive_mode(),
627            config.poll_interval.is_some(),
628        );
629        let mut schedule = BatchSchedule::new(config);
630
631        'outer: loop {
632            loop {
633                match schedule.recv_event(&rx, &*fs, &batch) {
634                    Ok(Ok(event)) => {
635                        // TODO: We might benefit from some user-facing diagnostics if it rescans
636                        // occur frequently (i.e. more than X times in Y minutes)
637                        //
638                        // You can test rescans on Linux by reducing the inotify queue to something
639                        // really small:
640                        //
641                        // ```
642                        // echo 3 | sudo tee /proc/sys/fs/inotify/max_queued_events
643                        // ```
644                        if event.need_rescan() {
645                            let _lock = fs.invalidation_lock().blocking_write();
646
647                            // flush the whole mpsc queue, we're about to rescan, we don't need to
648                            // process any other update events that have already happened
649                            while rx.try_recv().is_ok() {}
650
651                            if let State::NonRecursive(non_recursive) = &watcher.state {
652                                // we can't narrow this down to a smaller set of paths: Rescan
653                                // events (at least when tested on
654                                // Linux) come with no `paths`, and we use
655                                // only one global `notify::Watcher` instance.
656                                //
657                                // TODO: Report diagnostics if an error happens
658                                fs.tokio_handle().block_on(
659                                    non_recursive_helpers::restore_all_watched_ignore_errors(
660                                        non_recursive,
661                                        fs.root_path(),
662                                    ),
663                                );
664                            }
665
666                            if report_invalidation_reason {
667                                fs.invalidate_all_with_reason(|path| InvalidateRescan {
668                                    // this path is just used for display purposes
669                                    path: RcStr::from(path.to_string_lossy()),
670                                });
671                            } else {
672                                fs.invalidate_all();
673                            }
674
675                            // no need to process the rest of the batch as we just
676                            // invalidated everything
677                            batch.clear();
678                            schedule.reset();
679                            break;
680                        }
681
682                        // Any event that contributes to the batch keeps it open for another
683                        // `batch_delay`. A path matching `extended_batch_delay_matcher` (e.g. a
684                        // package-manager install target) keeps it open for
685                        // `extended_batch_delay_duration` instead.
686                        let mut delay = config.batch_delay;
687                        if let Some(matcher) = &extended_batch_delay_matcher
688                            && event.paths.iter().any(|path| matcher.match_path(path))
689                        {
690                            delay = delay.max(config.extended_batch_delay_duration);
691                        }
692
693                        if batch.add_event(event, fs.invalidator_map(), fs.dir_invalidator_map()) {
694                            schedule.extend(delay);
695                        } else if !batch.has_pending_invalidations() && batch.has_new_paths() {
696                            // No batch is pending, so `recv_event` would wait for the next event,
697                            // but newly created directories may still need their watches restored.
698                            // Flush right away, as a dependent task could re-read them at any time.
699                            break;
700                        }
701                    }
702                    // Error raised by notify watcher itself
703                    Ok(Err(notify::Error { kind, paths })) => {
704                        // A path removed while the watcher was scanning it is expected (e.g.
705                        // `PollWatcher` walking a tree that is being deleted), so don't report
706                        // it. The invalidation below still covers it.
707                        if !is_not_found_error(&kind) {
708                            eprintln!("watch error ({paths:?}): {kind:?} ");
709                        }
710
711                        batch.add_error(paths, fs.root_path());
712                        schedule.extend(config.batch_delay);
713                    }
714                    Err(RecvTimeoutError::Timeout) => {
715                        // the batch is complete: break out to invalidate the collected paths.
716                        break;
717                    }
718                    Err(RecvTimeoutError::Disconnected) => {
719                        // Sender has been disconnected, which means DiskFileSystem has been dropped
720                        // exit thread
721                        break 'outer;
722                    }
723                }
724            }
725
726            // We need to start watching first before invalidating the changed paths...
727            // This is only needed on platforms we don't do recursive watching on.
728            if let State::NonRecursive(non_recursive) = &watcher.state
729                && batch.has_new_paths()
730            {
731                let new_paths = batch.take_new_paths();
732                // A read registered after a directory was created, but before its watch was
733                // restored, took `ensure_watched`'s fast path without a live watch, and could have
734                // missed changes made in the meantime. Now that the watch is live, invalidate
735                // everything under the restored paths, even if they didn't affect any tracked read
736                // when the event arrived.
737                fs.tokio_handle()
738                    .block_on(non_recursive_helpers::restore_all_if_watched(
739                        non_recursive,
740                        new_paths,
741                        fs.root_path(),
742                        |path| batch.mark_restored_path(path),
743                    ));
744            }
745            if !batch.has_pending_invalidations() {
746                batch.clear();
747                continue;
748            }
749
750            let Some(turbo_tasks) = fs.turbo_tasks() else {
751                // TurboTasks was dropped, stop watching
752                break 'outer;
753            };
754            let _guard = fs.tokio_handle().enter();
755
756            let _lock = fs.invalidation_lock().blocking_write();
757            // All invalidators of the batch are invalidated together, with one reason per path.
758            let mut invalidators = Vec::new();
759            // The changed paths, for the span and (when enabled) the invalidation reasons.
760            let mut changed_paths: Vec<PathBuf> = Vec::new();
761            batch.execute(
762                fs.invalidator_map(),
763                fs.dir_invalidator_map(),
764                |invalidation_reason_path, invalidator| {
765                    invalidators.push(invalidator);
766                    if changed_paths.last().map(|p| p.as_path()) != Some(invalidation_reason_path) {
767                        changed_paths.push(invalidation_reason_path.to_path_buf());
768                    }
769                },
770            );
771            invalidate(
772                &*fs,
773                &*turbo_tasks,
774                report_invalidation_reason,
775                &changed_paths,
776                invalidators,
777            );
778        }
779    }
780
781    pub async fn ensure_watched_file(&self, path: &Path, root_path: &Path) -> Result<()> {
782        // Watch the parent directory instead of the specified file, since directories also track
783        // their immediate children (even in non-recursive mode), and we need to watch all the
784        // parents anyways.
785        if let State::NonRecursive(non_recursive) = &self.state
786            && let Some(dir_path) = path.parent()
787        {
788            non_recursive_helpers::ensure_watched(non_recursive, dir_path, root_path).await?;
789        }
790        Ok(())
791    }
792
793    pub async fn ensure_watched_dir(&self, dir_path: &Path, root_path: &Path) -> Result<()> {
794        if let State::NonRecursive(non_recursive) = &self.state {
795            non_recursive_helpers::ensure_watched(non_recursive, dir_path, root_path).await?;
796        }
797        Ok(())
798    }
799}
800
801bitflags! {
802    /// Describes how a single path in a [`BatchedInvalidations`] should be invalidated. A path may
803    /// carry any combination of these (accumulated across the events in a batch).
804    struct InvalidationFlags: u8 {
805        /// Invalidate exactly this path in the file-content invalidator map.
806        const PATH = 1 << 0;
807        /// Invalidate exactly this path in the directory-listing invalidator map.
808        const PATH_DIR = 1 << 1;
809        /// Invalidate this path and all of its children in the file-content invalidator map.
810        const PATH_AND_CHILDREN = 1 << 2;
811        /// Invalidate this path and all of its children in the directory-listing invalidator map.
812        const PATH_AND_CHILDREN_DIR = 1 << 3;
813    }
814}
815
816/// A set of deferred invalidations. Because one or more files may be updated many times in quick
817/// succession, we don't want to perform invalidations until we think the filesystem has settled.
818///
819/// This avoids reading partially-written files which might generate transient errors, and reduces
820/// CPU and memory usage by producing less wasted work.
821///
822/// Paths to invalidate are stored once in a flag-keyed map, with a set of [`InvalidationFlags`]
823/// describing what needs to happen for each. Newly created paths are tracked separately because
824/// their watches must be restored even when they don't affect any tracked read.
825struct BatchedInvalidations {
826    paths: FxIndexMap<Box<Path>, InvalidationFlags>,
827    /// The most recently updated entry in [`Self::paths`].
828    last_updated_index: Option<usize>,
829    /// Newly-created paths in this batch, including ones that don't affect any tracked read. In
830    /// non-recursive watching mode, these must have their watches (re-)established before
831    /// [`Self::execute`] is called (see the note there). Stored as [`None`] in recursive mode.
832    ///
833    /// Kept separately from [`Self::paths`], because it also includes paths that don't affect any
834    /// tracked read: a recreated directory must get its watch back even if nothing currently
835    /// depends on it, as a dependent task may re-read it later (and then wouldn't watch it again).
836    new_paths: Option<FxIndexSet<Box<Path>>>,
837    /// Whether events are coming from [`PollWatcher`] instead of [`RecommendedWatcher`], which
838    /// changes how a file content change is reported. See [`Self::is_content_change`].
839    polling: bool,
840}
841
842impl BatchedInvalidations {
843    fn new(recursive_mode: DiskWatcherRecursiveMode, polling: bool) -> Self {
844        Self {
845            paths: FxIndexMap::default(),
846            last_updated_index: None,
847            new_paths: match recursive_mode {
848                DiskWatcherRecursiveMode::NonRecursive => Some(FxIndexSet::default()),
849                DiskWatcherRecursiveMode::Recursive => None,
850            },
851            polling,
852        }
853    }
854
855    /// Whether a [`ModifyKind::Metadata`] event means the file's *contents* changed.
856    ///
857    /// Some backends don't report content changes as [`ModifyKind::Data`] at all, so we have to
858    /// treat one specific metadata change per backend as a content change. Accepting these
859    /// unconditionally would mean invalidating on every `chmod`/`touch` on the backends that do
860    /// report `Data` properly.
861    fn is_content_change(&self, kind: MetadataKind) -> bool {
862        match kind {
863            // `PollWatcher` detects changes by comparing mtimes, so a content change surfaces as a
864            // write-time change. It only emits `Data` when `Config::with_compare_contents` is
865            // enabled, which we don't do because hashing every watched file is too expensive.
866            MetadataKind::WriteTime => self.polling,
867            // fsevents does not always emit `kFSEventStreamEventFlagItemModified` for a content
868            // change; sometimes it only emits `kFSEventStreamEventFlagItemInodeMetaMod`, which
869            // notify maps to `MetadataKind::Any`. This causes redundant invalidations, but it's the
870            // only way to reliably detect content changes there. Fix for PACK-2437.
871            //
872            // libuv does the same thing to trigger `UV_CHANGES`:
873            // https://github.com/libuv/libuv/commit/73cf3600d75a5884b890a1a94048b8f3f9c66876
874            //
875            // inotify and ReadDirectoryChangesW both report content changes on their own, so
876            // `MetadataKind::Any` there only ever means an actual attribute change.
877            MetadataKind::Any => cfg!(target_os = "macos"),
878            _ => false,
879        }
880    }
881
882    fn clear(&mut self) {
883        self.paths.clear();
884        self.last_updated_index = None;
885        if let Some(new_paths) = &mut self.new_paths {
886            new_paths.clear();
887        }
888    }
889
890    fn has_new_paths(&self) -> bool {
891        self.new_paths.as_ref().is_some_and(|p| !p.is_empty())
892    }
893
894    /// Takes the newly-created paths out of the batch, leaving it without any. Always empty in
895    /// recursive mode.
896    fn take_new_paths(&mut self) -> FxIndexSet<Box<Path>> {
897        self.new_paths
898            .as_mut()
899            .map(std::mem::take)
900            .unwrap_or_default()
901    }
902
903    /// Adds a recursive invalidation for a path whose watch was just restored. See
904    /// [`DiskWatcher::watch_thread`].
905    fn mark_restored_path(&mut self, path: Box<Path>) {
906        self.mark(
907            Cow::Owned(path.into_path_buf()),
908            InvalidationFlags::PATH_AND_CHILDREN | InvalidationFlags::PATH_AND_CHILDREN_DIR,
909        );
910    }
911
912    /// Whether the batch contains any paths to invalidate, i.e. whether a flush is pending.
913    fn has_pending_invalidations(&self) -> bool {
914        !self.paths.is_empty()
915    }
916
917    /// Records `path` as newly-created so its watch can be (re-)established, regardless of whether
918    /// it affects any tracked read. No-op in recursive watching mode.
919    fn mark_new_path(&mut self, path: &Path) {
920        if let Some(new_paths) = &mut self.new_paths
921            && !new_paths.contains(path)
922        {
923            new_paths.insert(Box::from(path));
924        }
925    }
926
927    /// Sets the `flags` for `path`. Returns the index that was modified.
928    fn mark(&mut self, path: Cow<'_, Path>, flags: InvalidationFlags) -> usize {
929        match self.paths.raw_entry_mut_v1().from_key(path.as_ref()) {
930            RawEntryMut::Occupied(mut entry) => {
931                *entry.get_mut() |= flags;
932                entry.index()
933            }
934            RawEntryMut::Vacant(entry) => {
935                let index = entry.index();
936                entry.insert(path.into_owned().into_boxed_path(), flags);
937                index
938            }
939        }
940    }
941
942    fn last_updated_path(&self) -> Option<&Path> {
943        self.last_updated_index
944            .and_then(|index| self.paths.get_index(index))
945            .map(|(path, _)| &**path)
946    }
947
948    fn mark_parent_dir(&mut self, path: &Path) {
949        if let Some(parent) = path.parent() {
950            self.mark(Cow::Borrowed(parent), InvalidationFlags::PATH_DIR);
951        }
952    }
953
954    /// Updates the batch to contain updated paths from the given event. Does not perform any
955    /// invalidations.
956    ///
957    /// Returns whether the event contributed any paths affecting tracked reads. Newly created paths
958    /// are recorded in [`Self::new_paths`] either way.
959    #[must_use]
960    fn add_event(
961        &mut self,
962        event: notify::Event,
963        invalidator_map: &InvalidatorMap,
964        dir_invalidator_map: &InvalidatorMap,
965    ) -> bool {
966        // if we need to lock an invalidator map, hold and cache the guard for the whole event
967        let invalidator_map_guard = LazyCell::new(|| invalidator_map.lock().unwrap());
968        let dir_invalidator_map_guard = LazyCell::new(|| dir_invalidator_map.lock().unwrap());
969
970        // If this path has no reader, we should not add it to the batch, and we should not extend
971        // the batch schedule. This function is best-effort and marks some events as relevant that
972        // will no-op.
973        let is_relevant = |batch: &Self, path: &Path, parent: Option<&Path>, recursive: bool| {
974            // fast-path: The path is already in the batch, assume it is relevant.
975            if batch.paths.contains_key(path) {
976                return true;
977            }
978            // slow-path: Lock the invalidator maps, and see if they contain the path or the parent
979            // (some events invalidate the parent directory)
980            invalidator_map_guard.contains_key(path)
981                || dir_invalidator_map_guard.contains_key(path)
982                || parent.is_some_and(|parent| dir_invalidator_map_guard.contains_key(parent))
983                || (recursive
984                    && (invalidator_map_guard.contains_path_or_children(path)
985                        || dir_invalidator_map_guard.contains_path_or_children(path)))
986        };
987
988        let paths = event.paths;
989        let mut last_updated_index = None;
990        match event.kind {
991            EventKind::Modify(ModifyKind::Data(_)) => {
992                for path in paths {
993                    if is_relevant(self, &path, None, false) {
994                        last_updated_index =
995                            Some(self.mark(Cow::Owned(path), InvalidationFlags::PATH));
996                    }
997                }
998            }
999            // Some backends (fsevents, polling) can report metadata events for file content changes
1000            EventKind::Modify(ModifyKind::Metadata(kind)) if self.is_content_change(kind) => {
1001                for path in paths {
1002                    if is_relevant(self, &path, None, false) {
1003                        last_updated_index =
1004                            Some(self.mark(Cow::Owned(path), InvalidationFlags::PATH));
1005                    }
1006                }
1007            }
1008            EventKind::Create(_) => {
1009                for path in paths {
1010                    self.mark_new_path(&path);
1011                    if !is_relevant(self, &path, path.parent(), true) {
1012                        continue;
1013                    }
1014                    self.mark_parent_dir(&path);
1015                    last_updated_index = Some(self.mark(
1016                        Cow::Owned(path),
1017                        InvalidationFlags::PATH_AND_CHILDREN
1018                            | InvalidationFlags::PATH_AND_CHILDREN_DIR,
1019                    ));
1020                }
1021            }
1022            EventKind::Remove(_) => {
1023                for path in paths {
1024                    if !is_relevant(self, &path, path.parent(), true) {
1025                        continue;
1026                    }
1027                    self.mark_parent_dir(&path);
1028                    last_updated_index = Some(self.mark(
1029                        Cow::Owned(path),
1030                        InvalidationFlags::PATH_AND_CHILDREN
1031                            | InvalidationFlags::PATH_AND_CHILDREN_DIR,
1032                    ));
1033                }
1034            }
1035            // A single event emitted with both the `From` and `To` paths.
1036            EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
1037                let [source, destination] = <[PathBuf; 2]>::try_from(paths)
1038                    .expect("RenameMode::Both event must contain exactly two paths");
1039
1040                if is_relevant(self, &source, source.parent(), true) {
1041                    self.mark_parent_dir(&source);
1042                    last_updated_index =
1043                        Some(self.mark(Cow::Owned(source), InvalidationFlags::PATH_AND_CHILDREN));
1044                }
1045
1046                self.mark_new_path(&destination);
1047                if is_relevant(self, &destination, destination.parent(), true) {
1048                    self.mark_parent_dir(&destination);
1049                    last_updated_index = Some(self.mark(
1050                        Cow::Owned(destination),
1051                        InvalidationFlags::PATH_AND_CHILDREN,
1052                    ));
1053                }
1054            }
1055            // We expect `RenameMode::Both` to cover most of the cases we need to invalidate,
1056            // but we also check other RenameModes to cover cases where notify couldn't match the
1057            // two rename events.
1058            EventKind::Any | EventKind::Modify(ModifyKind::Any | ModifyKind::Name(..)) => {
1059                for path in paths {
1060                    if !is_relevant(self, &path, path.parent(), true) {
1061                        continue;
1062                    }
1063                    self.mark_parent_dir(&path);
1064                    last_updated_index = Some(self.mark(
1065                        Cow::Owned(path),
1066                        InvalidationFlags::PATH_AND_CHILDREN
1067                            | InvalidationFlags::PATH_AND_CHILDREN_DIR,
1068                    ));
1069                }
1070            }
1071            EventKind::Modify(ModifyKind::Metadata(..) | ModifyKind::Other)
1072            | EventKind::Access(_)
1073            | EventKind::Other => {}
1074        }
1075        if let Some(index) = last_updated_index {
1076            self.last_updated_index = Some(index);
1077            true
1078        } else {
1079            false
1080        }
1081    }
1082
1083    /// Updates the batch to invalidate paths associated with a watcher error.
1084    fn add_error(&mut self, paths: Vec<PathBuf>, root_path: &Path) {
1085        let flags = InvalidationFlags::PATH_AND_CHILDREN | InvalidationFlags::PATH_AND_CHILDREN_DIR;
1086        if paths.is_empty() {
1087            self.last_updated_index = Some(self.mark(Cow::Borrowed(root_path), flags));
1088        } else {
1089            for path in paths {
1090                self.last_updated_index = Some(self.mark(Cow::Owned(path), flags));
1091            }
1092        }
1093    }
1094
1095    /// Performs all batched invalidations, calling `invalidate` once for each `(path, invalidator)`
1096    /// pair that needs to be invalidated, then clears the batch.
1097    ///
1098    /// In non-recursive watching mode, [`Self::new_paths`] must be processed (to (re-)establish
1099    /// watches) *before* calling this.
1100    ///
1101    /// For each path, a recursive invalidation subsumes an exact one, as
1102    /// [`extract_path_with_children`][OrderedPathMapExt::extract_path_with_children] removes the
1103    /// path itself in addition to its children.
1104    fn execute(
1105        &mut self,
1106        invalidator_map: &InvalidatorMap,
1107        dir_invalidator_map: &InvalidatorMap,
1108        mut invalidate: impl FnMut(&Path, Invalidator),
1109    ) {
1110        for (map, exact_flag, recursive_flag) in [
1111            (
1112                invalidator_map,
1113                InvalidationFlags::PATH,
1114                InvalidationFlags::PATH_AND_CHILDREN,
1115            ),
1116            (
1117                dir_invalidator_map,
1118                InvalidationFlags::PATH_DIR,
1119                InvalidationFlags::PATH_AND_CHILDREN_DIR,
1120            ),
1121        ] {
1122            let mut map = map.lock().unwrap();
1123            for (path, flags) in &self.paths {
1124                if flags.contains(recursive_flag) {
1125                    for (_, invalidators) in map.extract_path_with_children(path) {
1126                        for invalidator in invalidators {
1127                            invalidate(path, invalidator);
1128                        }
1129                    }
1130                } else if flags.contains(exact_flag)
1131                    && let Some(invalidators) = map.remove(&**path)
1132                {
1133                    for invalidator in invalidators {
1134                        invalidate(path, invalidator);
1135                    }
1136                }
1137            }
1138        }
1139        self.clear();
1140    }
1141}
1142
1143/// The maximum number of paths listed in the `file change` span.
1144const MAX_SPAN_PATHS: usize = 20;
1145
1146/// Formats paths for the `file change` span: comma separated, at most [`MAX_SPAN_PATHS`], with
1147/// `, ... (N more paths)` appended when there are more.
1148struct SpanPaths<'a>(&'a [PathBuf]);
1149
1150impl std::fmt::Display for SpanPaths<'_> {
1151    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1152        for (index, path) in self.0.iter().take(MAX_SPAN_PATHS).enumerate() {
1153            if index > 0 {
1154                f.write_str(", ")?;
1155            }
1156            write!(f, "{}", path.display())?;
1157        }
1158        if self.0.len() > MAX_SPAN_PATHS {
1159            write!(f, ", ... ({} more paths)", self.0.len() - MAX_SPAN_PATHS)?;
1160        }
1161        Ok(())
1162    }
1163}
1164
1165#[instrument(
1166    parent = None,
1167    level = "info",
1168    name = "file change",
1169    skip_all,
1170    fields(
1171        paths = %SpanPaths(invalidation_reason_paths),
1172        invalidators = invalidators.len(),
1173    )
1174)]
1175fn invalidate(
1176    inner: &impl DiskFileSystemWatcherApi,
1177    turbo_tasks: &dyn TurboTasksApi,
1178    report_invalidation_reason: bool,
1179    invalidation_reason_paths: &[PathBuf],
1180    invalidators: Vec<Invalidator>,
1181) {
1182    if invalidators.is_empty() {
1183        return;
1184    }
1185    if report_invalidation_reason {
1186        let reasons = invalidation_reason_paths
1187            .iter()
1188            .filter_map(|path| format_absolute_fs_path(path, inner.name(), inner.root_path()))
1189            .map(|path| WatchChange { path });
1190        Invalidator::invalidate_all_with_reasons(invalidators, turbo_tasks, reasons);
1191    } else {
1192        Invalidator::invalidate_all(invalidators, turbo_tasks);
1193    }
1194}
1195
1196/// Invalidation was caused by a watcher rescan event. This will likely invalidate *every* watched
1197/// file.
1198#[derive(Clone, PartialEq, Eq, Hash)]
1199pub struct InvalidateRescan {
1200    path: RcStr,
1201}
1202
1203impl InvalidationReason for InvalidateRescan {
1204    fn kind(&self) -> Option<StaticOrArc<dyn InvalidationReasonKind>> {
1205        Some(StaticOrArc::Static(&INVALIDATE_RESCAN_KIND))
1206    }
1207}
1208
1209impl fmt::Display for InvalidateRescan {
1210    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1211        write!(f, "{} in filesystem invalidated", self.path)
1212    }
1213}
1214
1215/// [Invalidation kind][InvalidationReasonKind] for [`InvalidateRescan`].
1216#[derive(PartialEq, Eq, Hash)]
1217struct InvalidateRescanKind;
1218
1219static INVALIDATE_RESCAN_KIND: InvalidateRescanKind = InvalidateRescanKind;
1220
1221impl InvalidationReasonKind for InvalidateRescanKind {
1222    fn fmt(
1223        &self,
1224        reasons: &FxIndexSet<StaticOrArc<dyn InvalidationReason>>,
1225        f: &mut fmt::Formatter<'_>,
1226    ) -> fmt::Result {
1227        let first_reason: &dyn InvalidationReason = &*reasons[0];
1228        write!(
1229            f,
1230            "{} items in filesystem invalidated due to notify::Watcher rescan event ({}, ...)",
1231            reasons.len(),
1232            (first_reason as &dyn Any)
1233                .downcast_ref::<InvalidateRescan>()
1234                .unwrap()
1235                .path
1236        )
1237    }
1238}
1239
1240/// Whether a [`notify::Error`] was caused by a path that no longer exists, e.g. a directory
1241/// removed between being listed and being read by [`PollWatcher`].
1242fn is_not_found_error(kind: &notify::ErrorKind) -> bool {
1243    matches!(kind, notify::ErrorKind::Io(err) if err.kind() == std::io::ErrorKind::NotFound)
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248    use std::{
1249        fs,
1250        time::{Instant, SystemTime},
1251    };
1252
1253    use notify::event::{CreateKind, DataChange, RemoveKind};
1254    use rstest::rstest;
1255    use turbo_tasks::TurboTasks;
1256    use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
1257
1258    use super::*;
1259    use crate::watcher::mock_fs_api::MockFileSystem;
1260
1261    #[test]
1262    fn span_paths_are_truncated() {
1263        let paths = |n: usize| {
1264            (0..n)
1265                .map(|i| PathBuf::from(format!("p{i}")))
1266                .collect::<Vec<_>>()
1267        };
1268        assert_eq!(SpanPaths(&paths(0)).to_string(), "");
1269        assert_eq!(SpanPaths(&paths(2)).to_string(), "p0, p1");
1270        let twenty = (0..20)
1271            .map(|i| format!("p{i}"))
1272            .collect::<Vec<_>>()
1273            .join(", ");
1274        assert_eq!(SpanPaths(&paths(20)).to_string(), twenty);
1275        assert_eq!(
1276            SpanPaths(&paths(21)).to_string(),
1277            format!("{twenty}, ... (1 more paths)")
1278        );
1279        assert_eq!(
1280            SpanPaths(&paths(25)).to_string(),
1281            format!("{twenty}, ... (5 more paths)")
1282        );
1283    }
1284
1285    #[cfg(not(miri))]
1286    #[tokio::test]
1287    async fn batches_only_tracked_paths() {
1288        let tt = TurboTasks::new(TurboTasksBackend::new(
1289            BackendOptions::default(),
1290            noop_backing_storage(),
1291        ));
1292        tt.run_once(async move {
1293            let invalidator_map = InvalidatorMap::new();
1294            let dir_invalidator_map = InvalidatorMap::new();
1295            let invalidator = turbo_tasks::get_invalidator().unwrap();
1296            invalidator_map.insert(Arc::new(PathBuf::from("root/file")), invalidator);
1297            dir_invalidator_map.insert(Arc::new(PathBuf::from("root/listed")), invalidator);
1298            dir_invalidator_map.insert(Arc::new(PathBuf::from("dirs/sub")), invalidator);
1299
1300            let content = EventKind::Modify(ModifyKind::Data(DataChange::Any));
1301            let create = EventKind::Create(CreateKind::Any);
1302            let remove = EventKind::Remove(RemoveKind::Any);
1303            let rename = EventKind::Modify(ModifyKind::Name(RenameMode::From));
1304            for (kind, path, relevant) in [
1305                (content, "root", false),
1306                (content, "root/file", true),
1307                (content, "root/listed", true),
1308                (create, "root/listed/new", true),
1309                (create, "root", true),
1310                (remove, "root", true),
1311                (rename, "root", true),
1312                (remove, "dirs", true),
1313            ] {
1314                let mut batch =
1315                    BatchedInvalidations::new(DiskWatcherRecursiveMode::NonRecursive, false);
1316                let event = notify::Event::new(kind).add_path(PathBuf::from(path));
1317                assert_eq!(
1318                    batch.add_event(event, &invalidator_map, &dir_invalidator_map),
1319                    relevant,
1320                    "{kind:?} at {path}",
1321                );
1322                assert_eq!(batch.last_updated_path().is_some(), relevant);
1323            }
1324            anyhow::Ok(())
1325        })
1326        .await
1327        .unwrap();
1328    }
1329
1330    /// Polls [`tracked_read`] until it has executed more than `previous_runs` times, i.e. until the
1331    /// watcher has invalidated it.
1332    async fn wait_for_rerun(fs: &Arc<MockFileSystem>, path: &Path, previous_runs: u64) {
1333        const WATCH_TIMEOUT: Duration = Duration::from_secs(5);
1334        let deadline = Instant::now() + WATCH_TIMEOUT;
1335        loop {
1336            if fs.tracked_read_strongly_consistent(path).await > previous_runs {
1337                return;
1338            }
1339            assert!(
1340                Instant::now() < deadline,
1341                "the watcher did not invalidate {path:?} within {WATCH_TIMEOUT:?}",
1342            );
1343            tokio::time::sleep(Duration::from_millis(10)).await;
1344        }
1345    }
1346
1347    /// Backdates `path`'s mtime so that a [`PollWatcher`] can see the write that follows it to
1348    /// avoid mtime truncation issues.
1349    fn backdate(path: &Path) {
1350        fs::File::options()
1351            .write(true)
1352            .open(path)
1353            .unwrap()
1354            .set_modified(SystemTime::now() - Duration::from_secs(10))
1355            .unwrap();
1356    }
1357
1358    /// `recursive_mode` is set explicitly rather than left to the platform default so that both
1359    /// watching strategies are covered on every host. `TURBO_TASKS_FORCE_WATCH_MODE` still
1360    /// overrides it, collapsing these into two cases.
1361    // Miri cannot run the native cases because inotify is unsupported, while the polling cases
1362    // require Turbo Tasks' link-section registry, which is unavailable under Miri.
1363    #[cfg(not(miri))]
1364    #[rstest]
1365    #[case::native_recursive(None, DiskWatcherRecursiveMode::Recursive)]
1366    #[case::native_non_recursive(None, DiskWatcherRecursiveMode::NonRecursive)]
1367    #[case::polling_recursive(Some(Duration::from_millis(20)), DiskWatcherRecursiveMode::Recursive)]
1368    #[case::polling_non_recursive(
1369        Some(Duration::from_millis(20)),
1370        DiskWatcherRecursiveMode::NonRecursive
1371    )]
1372    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1373    async fn watches_file_and_directory_changes(
1374        #[case] poll_interval: Option<Duration>,
1375        #[case] recursive_mode: DiskWatcherRecursiveMode,
1376    ) {
1377        let tt = TurboTasks::new(TurboTasksBackend::new(
1378            BackendOptions::default(),
1379            noop_backing_storage(),
1380        ));
1381        tt.run_once(async move {
1382            let fs = MockFileSystem::new(DiskWatcherConfig {
1383                recursive_mode: Some(recursive_mode),
1384                poll_interval,
1385                report_invalidation_reason: true,
1386                ..Default::default()
1387            });
1388            let sub_dir = fs.root_path.join("sub");
1389            let file_path = sub_dir.join("file.txt");
1390            fs::create_dir(&sub_dir).unwrap();
1391            fs::write(&file_path, "initial").unwrap();
1392            backdate(&file_path);
1393
1394            DiskWatcher::start_watching(fs.clone()).await?;
1395
1396            // the initial reads register the invalidators that the watcher will later fire
1397            assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 1);
1398            assert_eq!(fs.tracked_read_strongly_consistent(&sub_dir).await, 1);
1399
1400            // reading again without touching the filesystem must not re-run anything
1401            assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 1);
1402            assert_eq!(fs.tracked_read_strongly_consistent(&sub_dir).await, 1);
1403
1404            // modifying a file invalidates the task that read that file
1405            fs::write(&file_path, "updated")?;
1406            wait_for_rerun(&fs, &file_path, 1).await;
1407
1408            // creating a file invalidates the task that listed the containing directory
1409            let dir_runs = fs.tracked_read_strongly_consistent(&sub_dir).await;
1410            fs::write(sub_dir.join("new.txt"), "new")?;
1411            wait_for_rerun(&fs, &sub_dir, dir_runs).await;
1412
1413            fs.watcher.stop_watching().await;
1414            anyhow::Ok(())
1415        })
1416        .await
1417        .unwrap();
1418    }
1419
1420    /// Events for paths that nobody has read must not extend the batch: a stream of writes to an
1421    /// untracked file should not delay the invalidation of a tracked one.
1422    #[cfg(not(miri))]
1423    #[rstest]
1424    #[case::native_recursive(None, DiskWatcherRecursiveMode::Recursive)]
1425    #[case::native_non_recursive(None, DiskWatcherRecursiveMode::NonRecursive)]
1426    #[case::polling_recursive(Some(Duration::from_millis(20)), DiskWatcherRecursiveMode::Recursive)]
1427    #[case::polling_non_recursive(
1428        Some(Duration::from_millis(20)),
1429        DiskWatcherRecursiveMode::NonRecursive
1430    )]
1431    #[cfg_attr(feature = "ci", ignore = "timing-sensitive, flaky in CI")]
1432    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1433    async fn irrelevant_events_do_not_delay_invalidation(
1434        #[case] poll_interval: Option<Duration>,
1435        #[case] recursive_mode: DiskWatcherRecursiveMode,
1436    ) {
1437        const BATCH_DELAY: Duration = Duration::from_millis(300);
1438        const CHURN: Duration = Duration::from_secs(3);
1439        const CHURN_INTERVAL: Duration = Duration::from_millis(50);
1440
1441        let tt = TurboTasks::new(TurboTasksBackend::new(
1442            BackendOptions::default(),
1443            noop_backing_storage(),
1444        ));
1445        tt.run_once(async move {
1446            let fs = MockFileSystem::new(DiskWatcherConfig {
1447                recursive_mode: Some(recursive_mode),
1448                poll_interval,
1449                batch_delay: BATCH_DELAY,
1450                extended_batch_delay_duration: BATCH_DELAY,
1451                ..Default::default()
1452            });
1453            let sub_dir = fs.root_path.join("sub");
1454            let file_path = sub_dir.join("file.txt");
1455            let other_path = sub_dir.join("other.txt");
1456            fs::create_dir(&sub_dir).unwrap();
1457            fs::write(&file_path, "initial").unwrap();
1458            fs::write(&other_path, "0").unwrap();
1459            backdate(&file_path);
1460            backdate(&other_path);
1461
1462            DiskWatcher::start_watching(fs.clone()).await?;
1463            assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 1);
1464
1465            let churn_start = Instant::now();
1466            let churn = std::thread::spawn({
1467                let other_path = other_path.clone();
1468                move || {
1469                    let mut i = 0u64;
1470                    while churn_start.elapsed() < CHURN {
1471                        i += 1;
1472                        fs::write(&other_path, i.to_string()).unwrap();
1473                        std::thread::sleep(CHURN_INTERVAL);
1474                    }
1475                }
1476            });
1477
1478            std::thread::sleep(CHURN_INTERVAL * 2);
1479            fs::write(&file_path, "updated")?;
1480            wait_for_rerun(&fs, &file_path, 1).await;
1481            let elapsed = churn_start.elapsed();
1482            assert!(
1483                elapsed < CHURN - Duration::from_millis(500),
1484                "invalidation was delayed by unrelated events: took {elapsed:?}"
1485            );
1486
1487            churn.join().unwrap();
1488            fs.watcher.stop_watching().await;
1489            anyhow::Ok(())
1490        })
1491        .await
1492        .unwrap();
1493    }
1494
1495    /// Repeated writes to a tracked file must keep the batch open even though the file's
1496    /// invalidator was already extracted into the batch by the first write.
1497    #[cfg(not(miri))]
1498    #[rstest]
1499    #[case::native_recursive(None, DiskWatcherRecursiveMode::Recursive)]
1500    #[case::native_non_recursive(None, DiskWatcherRecursiveMode::NonRecursive)]
1501    #[case::polling_recursive(Some(Duration::from_millis(20)), DiskWatcherRecursiveMode::Recursive)]
1502    #[case::polling_non_recursive(
1503        Some(Duration::from_millis(20)),
1504        DiskWatcherRecursiveMode::NonRecursive
1505    )]
1506    #[cfg_attr(feature = "ci", ignore = "timing-sensitive, flaky in CI")]
1507    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1508    async fn repeated_writes_to_tracked_file_are_debounced(
1509        #[case] poll_interval: Option<Duration>,
1510        #[case] recursive_mode: DiskWatcherRecursiveMode,
1511    ) {
1512        const BATCH_DELAY: Duration = Duration::from_millis(300);
1513        const CHURN: Duration = Duration::from_millis(1500);
1514        const CHURN_INTERVAL: Duration = Duration::from_millis(50);
1515
1516        let tt = TurboTasks::new(TurboTasksBackend::new(
1517            BackendOptions::default(),
1518            noop_backing_storage(),
1519        ));
1520        tt.run_once(async move {
1521            let fs = MockFileSystem::new(DiskWatcherConfig {
1522                recursive_mode: Some(recursive_mode),
1523                poll_interval,
1524                batch_delay: BATCH_DELAY,
1525                extended_batch_delay_duration: BATCH_DELAY,
1526                ..Default::default()
1527            });
1528            let sub_dir = fs.root_path.join("sub");
1529            let file_path = sub_dir.join("file.txt");
1530            fs::create_dir(&sub_dir).unwrap();
1531            fs::write(&file_path, "initial").unwrap();
1532            backdate(&file_path);
1533
1534            DiskWatcher::start_watching(fs.clone()).await?;
1535            assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 1);
1536
1537            let churn_start = Instant::now();
1538            let churn = std::thread::spawn({
1539                let file_path = file_path.clone();
1540                move || {
1541                    let mut i = 0u64;
1542                    while churn_start.elapsed() < CHURN {
1543                        i += 1;
1544                        fs::write(&file_path, i.to_string()).unwrap();
1545                        std::thread::sleep(CHURN_INTERVAL);
1546                    }
1547                }
1548            });
1549
1550            // well past `BATCH_DELAY`, but while the writes are still going on
1551            tokio::time::sleep(Duration::from_secs(1)).await;
1552            assert!(churn_start.elapsed() < CHURN);
1553            assert_eq!(
1554                fs.tracked_read_strongly_consistent(&file_path).await,
1555                1,
1556                "the batch was flushed while the tracked file was still being written"
1557            );
1558
1559            churn.join().unwrap();
1560            wait_for_rerun(&fs, &file_path, 1).await;
1561            assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 2);
1562
1563            fs.watcher.stop_watching().await;
1564            anyhow::Ok(())
1565        })
1566        .await
1567        .unwrap();
1568    }
1569
1570    /// In non-recursive mode, deleting a watched directory drops its OS watches, but the path
1571    /// stays in `NonRecursiveWatchingState::watched`. The watch is only re-established by
1572    /// `restore_all_if_watched` when the directory's creation is processed. Re-reading a file in it
1573    /// takes `ensure_watched`'s fast path and doesn't watch it again.
1574    ///
1575    /// So the creation must restore the watch even when nothing currently depends on the
1576    /// directory. That is the case here: the deletion's batch consumed the only invalidator, and
1577    /// the invalidated read is held back (via `MockFileSystem::read_gate`) until after the
1578    /// directory has been recreated in a later batch.
1579    ///
1580    /// The directory is either recreated in place, or built elsewhere and renamed into place. Runs
1581    /// in both watching modes, though only non-recursive mode needs watches to be restored.
1582    #[cfg(not(miri))]
1583    #[rstest]
1584    #[case::non_recursive_create(DiskWatcherRecursiveMode::NonRecursive, false)]
1585    #[case::non_recursive_rename(DiskWatcherRecursiveMode::NonRecursive, true)]
1586    #[case::recursive_create(DiskWatcherRecursiveMode::Recursive, false)]
1587    #[case::recursive_rename(DiskWatcherRecursiveMode::Recursive, true)]
1588    #[cfg_attr(feature = "ci", ignore = "timing-sensitive, flaky in CI")]
1589    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1590    async fn recreated_directory_is_watched_again(
1591        #[case] recursive_mode: DiskWatcherRecursiveMode,
1592        #[case] rename_into_place: bool,
1593    ) {
1594        const BATCH_DELAY: Duration = Duration::from_millis(50);
1595        // well past `BATCH_DELAY`, so that the removal and the recreation are flushed as separate
1596        // batches
1597        const SETTLE: Duration = Duration::from_millis(500);
1598
1599        let tt = TurboTasks::new(TurboTasksBackend::new(
1600            BackendOptions::default(),
1601            noop_backing_storage(),
1602        ));
1603        tt.run_once(async move {
1604            let fs = MockFileSystem::new(DiskWatcherConfig {
1605                recursive_mode: Some(recursive_mode),
1606                batch_delay: BATCH_DELAY,
1607                extended_batch_delay_duration: BATCH_DELAY,
1608                ..Default::default()
1609            });
1610            let dir_a = fs.root_path.join("a");
1611            let dir_b = dir_a.join("b");
1612            let file_path = dir_b.join("c.txt");
1613            fs::create_dir_all(&dir_b)?;
1614            fs::write(&file_path, "initial")?;
1615            backdate(&file_path);
1616
1617            DiskWatcher::start_watching(fs.clone()).await?;
1618            // watches `a` and `a/b`. Nothing reads or lists the root or `a`.
1619            assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 1);
1620
1621            // The removal invalidates the read and consumes its invalidator. Hold back the
1622            // re-execution, so no invalidator is registered under `a` while it is recreated.
1623            let gate = fs.read_gate.write().await;
1624            fs::remove_dir_all(&dir_a)?;
1625            tokio::time::sleep(SETTLE).await;
1626
1627            if rename_into_place {
1628                let staging = fs.root_path.join("staging");
1629                fs::create_dir_all(staging.join("b"))?;
1630                fs::write(staging.join("b").join("c.txt"), "recreated")?;
1631                fs::rename(&staging, &dir_a)?;
1632            } else {
1633                fs::create_dir_all(&dir_b)?;
1634                fs::write(&file_path, "recreated")?;
1635            }
1636            tokio::time::sleep(SETTLE).await;
1637
1638            // re-runs because of the removal, and registers the read again
1639            drop(gate);
1640            assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 2);
1641
1642            fs::write(&file_path, "modified")?;
1643            wait_for_rerun(&fs, &file_path, 2).await;
1644
1645            fs.watcher.stop_watching().await;
1646            anyhow::Ok(())
1647        })
1648        .await
1649        .unwrap();
1650    }
1651
1652    /// A read that re-registers after a directory was recreated, but before its watch was restored,
1653    /// takes `ensure_watched`'s fast path without a live watch. Changes in that window produce no
1654    /// events, so the reader must be invalidated once the watch is restored.
1655    ///
1656    /// The window is kept open deterministically: writes to an unrelated tracked file keep a batch
1657    /// pending, which defers restoring the recreated directory's watch until that batch flushes.
1658    #[cfg(not(miri))]
1659    #[rstest]
1660    #[case::non_recursive(DiskWatcherRecursiveMode::NonRecursive)]
1661    #[case::recursive(DiskWatcherRecursiveMode::Recursive)]
1662    #[cfg_attr(feature = "ci", ignore = "timing-sensitive, flaky in CI")]
1663    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1664    async fn read_registered_before_watch_is_restored_is_invalidated(
1665        #[case] recursive_mode: DiskWatcherRecursiveMode,
1666    ) {
1667        const BATCH_DELAY: Duration = Duration::from_millis(300);
1668        const SETTLE: Duration = Duration::from_millis(800);
1669        const CHURN_INTERVAL: Duration = Duration::from_millis(50);
1670        // fallback, in case the test fails before stopping the writes explicitly
1671        const MAX_CHURN: Duration = Duration::from_secs(10);
1672
1673        let tt = TurboTasks::new(TurboTasksBackend::new(
1674            BackendOptions::default(),
1675            noop_backing_storage(),
1676        ));
1677        tt.run_once(async move {
1678            let fs = MockFileSystem::new(DiskWatcherConfig {
1679                recursive_mode: Some(recursive_mode),
1680                batch_delay: BATCH_DELAY,
1681                extended_batch_delay_duration: BATCH_DELAY,
1682                ..Default::default()
1683            });
1684            let dir_a = fs.root_path.join("a");
1685            let dir_b = dir_a.join("b");
1686            let file_path = dir_b.join("c.txt");
1687            let other_dir = fs.root_path.join("other");
1688            let other_path = other_dir.join("x.txt");
1689            fs::create_dir_all(&dir_b)?;
1690            fs::create_dir_all(&other_dir)?;
1691            fs::write(&file_path, "initial")?;
1692            fs::write(&other_path, "0")?;
1693            backdate(&file_path);
1694            backdate(&other_path);
1695
1696            DiskWatcher::start_watching(fs.clone()).await?;
1697            assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 1);
1698            assert_eq!(fs.tracked_read_strongly_consistent(&other_path).await, 1);
1699
1700            // The removal invalidates the read and consumes its invalidator. Hold back the
1701            // re-execution until the directory has been recreated.
1702            let gate = fs.read_gate.write().await;
1703            fs::remove_dir_all(&dir_a)?;
1704            tokio::time::sleep(SETTLE).await;
1705
1706            // Keep a batch pending with writes to an unrelated tracked file, so that restoring the
1707            // recreated directory's watch is deferred until that batch flushes.
1708            let churn_done = Arc::new(std::sync::atomic::AtomicBool::new(false));
1709            let churn = std::thread::spawn({
1710                let other_path = other_path.clone();
1711                let churn_done = churn_done.clone();
1712                move || {
1713                    let churn_start = Instant::now();
1714                    let mut i = 0u64;
1715                    while !churn_done.load(std::sync::atomic::Ordering::Relaxed)
1716                        && churn_start.elapsed() < MAX_CHURN
1717                    {
1718                        i += 1;
1719                        fs::write(&other_path, i.to_string()).unwrap();
1720                        std::thread::sleep(CHURN_INTERVAL);
1721                    }
1722                }
1723            });
1724            std::thread::sleep(CHURN_INTERVAL * 2);
1725
1726            fs::create_dir_all(&dir_b)?;
1727            fs::write(&file_path, "recreated")?;
1728            std::thread::sleep(CHURN_INTERVAL * 2);
1729
1730            // Re-register the read while the batch (and the watch restoration) is still pending.
1731            drop(gate);
1732            assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 2);
1733
1734            // In non-recursive mode, there is no watch on `a/b` yet, so this produces no event.
1735            fs::write(&file_path, "modified")?;
1736
1737            churn_done.store(true, std::sync::atomic::Ordering::Relaxed);
1738            churn.join().unwrap();
1739            wait_for_rerun(&fs, &file_path, 2).await;
1740
1741            fs.watcher.stop_watching().await;
1742            anyhow::Ok(())
1743        })
1744        .await
1745        .unwrap();
1746    }
1747}