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 collections::BTreeSet,
10 env, fmt,
11 path::{Path, PathBuf},
12 sync::{
13 Arc, LazyLock,
14 mpsc::{Receiver, RecvTimeoutError, channel},
15 },
16 time::Duration,
17};
18
19use anyhow::{Context, Result};
20use bincode::{
21 Decode, Encode,
22 de::Decoder,
23 enc::Encoder,
24 error::{DecodeError, EncodeError},
25};
26use bitflags::bitflags;
27use indexmap::map::{RawEntryApiV1, raw_entry_v1::RawEntryMut};
28use notify::{
29 Config, EventKind, PollWatcher, RecommendedWatcher, Watcher,
30 event::{MetadataKind, ModifyKind, RenameMode},
31};
32use rustc_hash::FxHashSet;
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 when a new directory is found in a parent directory we're watching. Restores the
326 /// watcher if we were previously watching it.
327 #[instrument(skip_all, level = "trace")]
328 pub async fn restore_if_watched(
329 state: &RwLock<NonRecursiveState>,
330 dir_path: &Path,
331 root_path: &Path,
332 ) -> Result<()> {
333 // fast path: The root directory is always implicitly watched during
334 // `DiskWatcher::start_watching`, we assume it is never deleted and never needs to be
335 // restored.
336 if dir_path == root_path {
337 return Ok(());
338 }
339
340 // fast path: the directory isn't in `watched`, only take a read lock and bail out early
341 {
342 let guard = state.read().await;
343 let NonRecursiveState::Watching(watching_state) = &*guard else {
344 return Ok(());
345 };
346 if !watching_state.watched.contains(dir_path) {
347 return Ok(());
348 }
349 }
350
351 // slow path: re-watch the path
352 let mut guard = state.write().await;
353 let NonRecursiveState::Watching(watching_state) = &mut *guard else {
354 return Ok(());
355 };
356
357 // watch the new directory
358 start_watching_dir(&mut watching_state.notify_watcher, dir_path, root_path)?;
359
360 // Also try to restore any watchers for children of this directory
361 for child_path in watching_state.watched.iter_path_children(dir_path) {
362 // Don't watch the parents -- see the comment on `restore_all_watched`
363 start_watching_dir(&mut watching_state.notify_watcher, child_path, root_path)?;
364 }
365 Ok(())
366 }
367
368 /// Called when a file in `dir_path` or `dir_path` itself is read or written. Adds a new watcher
369 /// if we're not already watching the directory.
370 ///
371 /// This should be called *before* reading a file to avoid a race condition.
372 #[instrument(skip_all, level = "trace")]
373 pub async fn ensure_watched(
374 state: &RwLock<NonRecursiveState>,
375 dir_path: &Path,
376 root_path: &Path,
377 ) -> Result<()> {
378 // fast path: The root directory is always implicitly watched during
379 // `DiskWatcher::start_watching`.
380 if dir_path == root_path {
381 return Ok(());
382 }
383
384 // fast path: the directory is already in `watched`, only take a read lock and bail out
385 // early
386 {
387 let guard = state.read().await;
388 let NonRecursiveState::Watching(watching_state) = &*guard else {
389 return Ok(());
390 };
391 if watching_state.watched.contains(dir_path) {
392 return Ok(());
393 }
394 }
395
396 // slow path: watch the path
397 let mut guard = state.write().await;
398 let NonRecursiveState::Watching(watching_state) = &mut *guard else {
399 return Ok(());
400 };
401 if watching_state.watched.insert(dir_path.to_path_buf()) {
402 start_watching_dir_and_parents(watching_state, dir_path, root_path)?;
403 }
404 Ok(())
405 }
406
407 /// Private helper, assumes that `dir_path` has already been added to
408 /// [`NonRecursiveWatchingState::watched`].
409 ///
410 /// This does not watch any of the parent directories. For that, use
411 /// [`start_watching_dir_and_parents`]. Use this method when iterating over previously-watched
412 /// values in `self.watching`.
413 fn start_watching_dir(
414 notify_watcher: &mut NotifyWatcher,
415 dir_path: &Path,
416 root_path: &Path,
417 ) -> Result<()> {
418 debug_assert_ne!(dir_path, root_path);
419
420 match notify_watcher.watch(dir_path, notify::RecursiveMode::NonRecursive) {
421 Ok(())
422 | Err(notify::Error {
423 // The path was probably deleted before we could process the event, but the parent
424 // should still be watched. The codepaths that care about this either call
425 // `start_watching_dir_and_parents` or handle the parents themselves.
426 kind: notify::ErrorKind::PathNotFound,
427 ..
428 }) => Ok(()),
429 Err(err) => {
430 // ast-grep-ignore: no-context-format
431 return Err(err).context(format!("Unable to watch {}", dir_path.display(),));
432 }
433 }
434 }
435
436 /// Private helper, assumes that `dir_path` has already been added to
437 /// [`NonRecursiveWatchingState::watched`].
438 ///
439 /// Watches the given `dir_path` and every parent up to `root_path`. Parents must be recursively
440 /// watched in case any of them change:
441 /// https://docs.rs/notify/latest/notify/#parent-folder-deletion
442 fn start_watching_dir_and_parents(
443 state: &mut NonRecursiveWatchingState,
444 dir_path: &Path,
445 root_path: &Path,
446 ) -> Result<()> {
447 let mut found_watched_ancestor = false;
448
449 // NOTE: `Path::ancestors` yields ancestors from longest to shortest path.
450 let dir_and_ancestor_paths: Vec<_> = [dir_path]
451 .into_iter()
452 .chain(
453 dir_path
454 .ancestors()
455 // skip: `ancestors` includes `dir_path` itself, as well as the ancestors, but
456 // we only want to apply the `take_while` check to parents
457 .skip(1)
458 .take_while(|p| {
459 found_watched_ancestor = *p == root_path || state.watched.contains(*p);
460 !found_watched_ancestor
461 }),
462 )
463 .collect();
464
465 if !found_watched_ancestor {
466 // this should never happen, as we should eventually hit the `root_path`
467 anyhow::bail!(
468 "failed to find the fs root of {root_path:?} while watching {dir_path:?}"
469 );
470 }
471
472 // Reverse the iterator: We want to start closest to the root and work towards `dir_path`
473 // (opposite of `Path::ancestors`), to avoid a potential race condition if directories are
474 // removed and re-added before we've watched their parent.
475 for path in dir_and_ancestor_paths.into_iter().rev() {
476 // this will silently ignore if the path is not found, expecting that we've watched the
477 // parent directory
478 start_watching_dir(&mut state.notify_watcher, path, root_path)?;
479 state.watched.insert(path.to_owned());
480 }
481
482 Ok(())
483 }
484}
485
486impl DiskWatcher {
487 pub fn new(config: DiskWatcherConfig) -> Self {
488 assert!(
489 config.extended_batch_delay_duration >= config.batch_delay,
490 "extended_batch_delay_duration must be at least batch_delay"
491 );
492 Self {
493 state: State::new_stopped(config.resolve_recursive_mode()),
494 config,
495 }
496 }
497
498 pub async fn start_watching<FsApi: DiskFileSystemWatcherApi>(fs: Arc<FsApi>) -> Result<()> {
499 let watcher: &Self = fs.watcher();
500
501 // read in the turbo-task context and before acquiring the lock
502 let extended_batch_delay_matcher = match watcher.config.extended_batch_delay_matcher {
503 Some(matcher) => Some(matcher.into_trait_ref().await?),
504 None => None,
505 };
506
507 let state_guard = watcher.state.write().await;
508
509 // bail out if we're already watching
510 if let StateWriteGuard::Recursive(guard) = &state_guard
511 && matches!(**guard, RecursiveState::Watching { .. })
512 {
513 return Ok(());
514 } else if let StateWriteGuard::NonRecursive(guard) = &state_guard
515 && matches!(**guard, NonRecursiveState::Watching(..))
516 {
517 return Ok(());
518 }
519
520 // Create a channel to receive the events.
521 let (tx, rx) = channel();
522 // Create a watcher object, delivering debounced events.
523 // The notification back-end is selected based on the platform.
524 let config = Config::default();
525 // we should track and invalidate each part of a symlink chain ourselves in
526 // turbo-tasks-fs
527 let config = config.with_follow_symlinks(false);
528
529 let mut notify_watcher = if let Some(poll_interval) = watcher.config.poll_interval {
530 let config = config.with_poll_interval(poll_interval);
531 NotifyWatcher::Polling(PollWatcher::new(tx, config)?)
532 } else {
533 NotifyWatcher::Recommended(RecommendedWatcher::new(tx, config)?)
534 };
535
536 // TOCTOU: we must watch `root_path` before calling any invalidators and setting up the
537 // watchers in their associated functions
538 let root_path = fs.root_path();
539 notify_watcher.watch(
540 root_path,
541 notify::RecursiveMode::from(watcher.state.recursive_mode()),
542 )?;
543
544 // We need to invalidate all reads or writes that happened before watching. As a
545 // side-effect, this will call `ensure_watched` again, setting up any watchers needed.
546 //
547 // Best is to start_watching before starting to read
548 if watcher.config.report_invalidation_reason {
549 let name = fs.name().clone();
550 fs.invalidate_all_with_reason(|path| WatchStart {
551 name: name.clone(),
552 // this path is just used for display purposes
553 path: RcStr::from(path.to_string_lossy()),
554 });
555 } else {
556 fs.invalidate_all();
557 }
558
559 spawn_thread({
560 let fs = fs.clone();
561 move || Self::watch_thread(fs, rx, extended_batch_delay_matcher)
562 });
563
564 // Updating `self.state` is done last. If we panic while setting up the watcher, it'll
565 // stay in the `Stopped` state.
566 match state_guard {
567 StateWriteGuard::Recursive(mut recursive) => {
568 *recursive = RecursiveState::Watching {
569 _notify_watcher: notify_watcher,
570 }
571 }
572 StateWriteGuard::NonRecursive(mut non_recursive) => {
573 *non_recursive = NonRecursiveState::Watching(NonRecursiveWatchingState {
574 notify_watcher,
575 watched: BTreeSet::new(),
576 })
577 }
578 };
579
580 Ok(())
581 }
582
583 pub async fn stop_watching(&self) {
584 match &self.state {
585 State::Recursive(state) => *state.write().await = RecursiveState::Stopped,
586 State::NonRecursive(state) => *state.write().await = NonRecursiveState::Stopped,
587 }
588 // thread will detect the stop because the channel is disconnected when `NotifyWatcher` is
589 // dropped
590 }
591
592 /// Internal thread that processes the events from the watcher
593 /// and invalidates the cache.
594 ///
595 /// Should only be called once from `start_watching`.
596 fn watch_thread<FsApi: DiskFileSystemWatcherApi>(
597 fs: Arc<FsApi>,
598 rx: Receiver<notify::Result<notify::Event>>,
599 extended_batch_delay_matcher: Option<TraitRef<Box<dyn DiskWatcherPathMatcher>>>,
600 ) {
601 let watcher: &Self = fs.watcher();
602 let config = &watcher.config;
603 let report_invalidation_reason = config.report_invalidation_reason;
604 let mut batch = BatchedInvalidations::new(
605 watcher.state.recursive_mode(),
606 config.poll_interval.is_some(),
607 );
608 let mut schedule = BatchSchedule::new(config);
609
610 'outer: loop {
611 loop {
612 match schedule.recv_event(&rx, &*fs, &batch) {
613 Ok(Ok(event)) => {
614 // TODO: We might benefit from some user-facing diagnostics if it rescans
615 // occur frequently (i.e. more than X times in Y minutes)
616 //
617 // You can test rescans on Linux by reducing the inotify queue to something
618 // really small:
619 //
620 // ```
621 // echo 3 | sudo tee /proc/sys/fs/inotify/max_queued_events
622 // ```
623 if event.need_rescan() {
624 let _lock = fs.invalidation_lock().blocking_write();
625
626 // flush the whole mpsc queue, we're about to rescan, we don't need to
627 // process any other update events that have already happened
628 while rx.try_recv().is_ok() {}
629
630 if let State::NonRecursive(non_recursive) = &watcher.state {
631 // we can't narrow this down to a smaller set of paths: Rescan
632 // events (at least when tested on
633 // Linux) come with no `paths`, and we use
634 // only one global `notify::Watcher` instance.
635 //
636 // TODO: Report diagnostics if an error happens
637 fs.tokio_handle().block_on(
638 non_recursive_helpers::restore_all_watched_ignore_errors(
639 non_recursive,
640 fs.root_path(),
641 ),
642 );
643 }
644
645 if report_invalidation_reason {
646 fs.invalidate_all_with_reason(|path| InvalidateRescan {
647 // this path is just used for display purposes
648 path: RcStr::from(path.to_string_lossy()),
649 });
650 } else {
651 fs.invalidate_all();
652 }
653
654 // no need to process the rest of the batch as we just
655 // invalidated everything
656 batch.clear();
657 schedule.reset();
658 break;
659 }
660
661 // Any event that contributes to the batch keeps it open for another
662 // `batch_delay`. A path matching `extended_batch_delay_matcher` (e.g. a
663 // package-manager install target) keeps it open for
664 // `extended_batch_delay_duration` instead.
665 let mut delay = config.batch_delay;
666 if let Some(matcher) = &extended_batch_delay_matcher
667 && event.paths.iter().any(|path| matcher.match_path(path))
668 {
669 delay = delay.max(config.extended_batch_delay_duration);
670 }
671
672 if batch.add_event(event) {
673 schedule.extend(delay);
674 }
675 }
676 // Error raised by notify watcher itself
677 Ok(Err(notify::Error { kind, paths })) => {
678 println!("watch error ({paths:?}): {kind:?} ");
679
680 batch.add_error(paths, fs.root_path());
681 schedule.extend(config.batch_delay);
682 }
683 Err(RecvTimeoutError::Timeout) => {
684 // the batch is complete: break out to invalidate the collected paths.
685 break;
686 }
687 Err(RecvTimeoutError::Disconnected) => {
688 // Sender has been disconnected, which means DiskFileSystem has been dropped
689 // exit thread
690 break 'outer;
691 }
692 }
693 }
694
695 // We need to start watching first before invalidating the changed paths...
696 // This is only needed on platforms we don't do recursive watching on.
697 if let State::NonRecursive(non_recursive) = &watcher.state {
698 for path in batch.new_paths() {
699 // TODO: Report diagnostics if this error happens
700 let _ = fs
701 .tokio_handle()
702 .block_on(non_recursive_helpers::restore_if_watched(
703 non_recursive,
704 path,
705 fs.root_path(),
706 ));
707 }
708 }
709
710 let Some(turbo_tasks) = fs.turbo_tasks() else {
711 // TurboTasks was dropped, stop watching
712 break 'outer;
713 };
714 let _guard = fs.tokio_handle().enter();
715
716 let _lock = fs.invalidation_lock().blocking_write();
717 batch.execute(
718 fs.invalidator_map(),
719 fs.dir_invalidator_map(),
720 |invalidation_reason_path, invalidator| {
721 invalidate(
722 &*fs,
723 &*turbo_tasks,
724 report_invalidation_reason,
725 invalidation_reason_path,
726 invalidator,
727 )
728 },
729 );
730 }
731 }
732
733 pub async fn ensure_watched_file(&self, path: &Path, root_path: &Path) -> Result<()> {
734 // Watch the parent directory instead of the specified file, since directories also track
735 // their immediate children (even in non-recursive mode), and we need to watch all the
736 // parents anyways.
737 if let State::NonRecursive(non_recursive) = &self.state
738 && let Some(dir_path) = path.parent()
739 {
740 non_recursive_helpers::ensure_watched(non_recursive, dir_path, root_path).await?;
741 }
742 Ok(())
743 }
744
745 pub async fn ensure_watched_dir(&self, dir_path: &Path, root_path: &Path) -> Result<()> {
746 if let State::NonRecursive(non_recursive) = &self.state {
747 non_recursive_helpers::ensure_watched(non_recursive, dir_path, root_path).await?;
748 }
749 Ok(())
750 }
751}
752
753bitflags! {
754 /// Describes how a single path in a [`BatchedInvalidations`] should be invalidated. A path may
755 /// carry any combination of these (accumulated across the events in a batch).
756 struct InvalidationFlags: u8 {
757 /// Invalidate exactly this path in the file-content invalidator map.
758 const PATH = 1 << 0;
759 /// Invalidate exactly this path in the directory-listing invalidator map.
760 const PATH_DIR = 1 << 1;
761 /// Invalidate this path and all of its children in the file-content invalidator map.
762 const PATH_AND_CHILDREN = 1 << 2;
763 /// Invalidate this path and all of its children in the directory-listing invalidator map.
764 const PATH_AND_CHILDREN_DIR = 1 << 3;
765 }
766}
767
768/// A set of deferred invalidations. Because one or more files may be updated many times in quick
769/// succession, we don't want to perform invalidations until we think the filesystem has settled.
770///
771/// This avoids reading partially-written files which might generate transient errors, and reduces
772/// CPU and memory usage by producing less wasted work.
773///
774/// Paths are stored once in a flag-keyed map, with a set of [`InvalidationFlags`] describing what
775/// needs to happen for each, rather than in several separate sets. This avoids cloning each
776/// `PathBuf` into multiple collections.
777struct BatchedInvalidations {
778 paths: FxIndexMap<Box<Path>, InvalidationFlags>,
779 /// The most recently updated entry in [`Self::paths`].
780 last_updated_index: Option<usize>,
781 /// See [`Self::new_paths`]. Stored as [`None`] in recursive mode.
782 new_paths: Option<FxHashSet<usize>>,
783 /// Whether events are coming from [`PollWatcher`] instead of [`RecommendedWatcher`], which
784 /// changes how a file content change is reported. See [`Self::is_content_change`].
785 polling: bool,
786}
787
788impl BatchedInvalidations {
789 fn new(recursive_mode: DiskWatcherRecursiveMode, polling: bool) -> Self {
790 Self {
791 paths: FxIndexMap::default(),
792 last_updated_index: None,
793 new_paths: match recursive_mode {
794 DiskWatcherRecursiveMode::NonRecursive => Some(FxHashSet::default()),
795 DiskWatcherRecursiveMode::Recursive => None,
796 },
797 polling,
798 }
799 }
800
801 /// Whether a [`ModifyKind::Metadata`] event means the file's *contents* changed.
802 ///
803 /// Some backends don't report content changes as [`ModifyKind::Data`] at all, so we have to
804 /// treat one specific metadata change per backend as a content change. Accepting these
805 /// unconditionally would mean invalidating on every `chmod`/`touch` on the backends that do
806 /// report `Data` properly.
807 fn is_content_change(&self, kind: MetadataKind) -> bool {
808 match kind {
809 // `PollWatcher` detects changes by comparing mtimes, so a content change surfaces as a
810 // write-time change. It only emits `Data` when `Config::with_compare_contents` is
811 // enabled, which we don't do because hashing every watched file is too expensive.
812 MetadataKind::WriteTime => self.polling,
813 // fsevents does not always emit `kFSEventStreamEventFlagItemModified` for a content
814 // change; sometimes it only emits `kFSEventStreamEventFlagItemInodeMetaMod`, which
815 // notify maps to `MetadataKind::Any`. This causes redundant invalidations, but it's the
816 // only way to reliably detect content changes there. Fix for PACK-2437.
817 //
818 // libuv does the same thing to trigger `UV_CHANGES`:
819 // https://github.com/libuv/libuv/commit/73cf3600d75a5884b890a1a94048b8f3f9c66876
820 //
821 // inotify and ReadDirectoryChangesW both report content changes on their own, so
822 // `MetadataKind::Any` there only ever means an actual attribute change.
823 MetadataKind::Any => cfg!(target_os = "macos"),
824 _ => false,
825 }
826 }
827
828 fn clear(&mut self) {
829 self.paths.clear();
830 self.last_updated_index = None;
831 if let Some(new_paths) = &mut self.new_paths {
832 new_paths.clear();
833 }
834 }
835
836 /// Records `index` as newly-created so its watch can be (re-)established. No-op in recursive
837 /// watching mode.
838 fn mark_new_path(&mut self, index: usize) {
839 if let Some(new_paths) = &mut self.new_paths {
840 new_paths.insert(index);
841 }
842 }
843
844 /// Sets the `flags` for `path`. Returns the index that was modified.
845 fn mark(&mut self, path: Cow<'_, Path>, flags: InvalidationFlags) -> usize {
846 match self.paths.raw_entry_mut_v1().from_key(path.as_ref()) {
847 RawEntryMut::Occupied(mut entry) => {
848 *entry.get_mut() |= flags;
849 entry.index()
850 }
851 RawEntryMut::Vacant(entry) => {
852 let index = entry.index();
853 entry.insert(path.into_owned().into_boxed_path(), flags);
854 index
855 }
856 }
857 }
858
859 fn last_updated_path(&self) -> Option<&Path> {
860 self.last_updated_index
861 .and_then(|index| self.paths.get_index(index))
862 .map(|(path, _)| &**path)
863 }
864
865 fn mark_parent_dir(&mut self, path: &Path) {
866 if let Some(parent) = path.parent() {
867 self.mark(Cow::Borrowed(parent), InvalidationFlags::PATH_DIR);
868 }
869 }
870
871 /// Iterates over the newly-created paths in this batch. In non-recursive watching mode, these
872 /// must have their watches (re-)established before [`Self::execute`] is called (see the note
873 /// there). Always empty in recursive mode.
874 fn new_paths(&self) -> impl Iterator<Item = &Path> {
875 self.new_paths
876 .iter()
877 .flatten()
878 .map(|&index| self.paths.get_index(index).unwrap().0.as_ref())
879 }
880
881 /// Updates the batch to contain updated paths from the given event. Does not perform any
882 /// invalidations.
883 ///
884 /// Returns whether the event contained relevant events.
885 #[must_use]
886 fn add_event(&mut self, event: notify::Event) -> bool {
887 let paths: Vec<PathBuf> = event.paths;
888 let mut last_updated_index = None;
889 match event.kind {
890 EventKind::Modify(ModifyKind::Data(_)) => {
891 for path in paths {
892 last_updated_index = Some(self.mark(Cow::Owned(path), InvalidationFlags::PATH));
893 }
894 }
895 // Some backends (fsevents, polling) can report metadata events for file content changes
896 EventKind::Modify(ModifyKind::Metadata(kind)) if self.is_content_change(kind) => {
897 for path in paths {
898 last_updated_index = Some(self.mark(Cow::Owned(path), InvalidationFlags::PATH));
899 }
900 }
901 EventKind::Create(_) => {
902 for path in paths {
903 self.mark_parent_dir(&path);
904 let index = self.mark(
905 Cow::Owned(path),
906 InvalidationFlags::PATH_AND_CHILDREN
907 | InvalidationFlags::PATH_AND_CHILDREN_DIR,
908 );
909 self.mark_new_path(index);
910 last_updated_index = Some(index);
911 }
912 }
913 EventKind::Remove(_) => {
914 for path in paths {
915 self.mark_parent_dir(&path);
916 last_updated_index = Some(self.mark(
917 Cow::Owned(path),
918 InvalidationFlags::PATH_AND_CHILDREN
919 | InvalidationFlags::PATH_AND_CHILDREN_DIR,
920 ));
921 }
922 }
923 // A single event emitted with both the `From` and `To` paths.
924 EventKind::Modify(ModifyKind::Name(RenameMode::Both)) => {
925 let [source, destination] = <[PathBuf; 2]>::try_from(paths)
926 .expect("RenameMode::Both event must contain exactly two paths");
927
928 self.mark_parent_dir(&source);
929 self.mark(Cow::Owned(source), InvalidationFlags::PATH_AND_CHILDREN);
930
931 self.mark_parent_dir(&destination);
932 let index = self.mark(
933 Cow::Owned(destination),
934 InvalidationFlags::PATH_AND_CHILDREN,
935 );
936 self.mark_new_path(index);
937 last_updated_index = Some(index);
938 }
939 // We expect `RenameMode::Both` to cover most of the cases we need to invalidate,
940 // but we also check other RenameModes to cover cases where notify couldn't match the
941 // two rename events.
942 EventKind::Any | EventKind::Modify(ModifyKind::Any | ModifyKind::Name(..)) => {
943 for path in paths {
944 self.mark_parent_dir(&path);
945 last_updated_index = Some(self.mark(
946 Cow::Owned(path),
947 InvalidationFlags::PATH_AND_CHILDREN
948 | InvalidationFlags::PATH_AND_CHILDREN_DIR,
949 ));
950 }
951 }
952 EventKind::Modify(ModifyKind::Metadata(..) | ModifyKind::Other)
953 | EventKind::Access(_)
954 | EventKind::Other => {}
955 }
956 if let Some(index) = last_updated_index {
957 self.last_updated_index = Some(index);
958 true
959 } else {
960 false
961 }
962 }
963
964 /// Updates the batch to invalidate paths associated with a watcher error.
965 fn add_error(&mut self, paths: Vec<PathBuf>, root_path: &Path) {
966 let flags = InvalidationFlags::PATH_AND_CHILDREN | InvalidationFlags::PATH_AND_CHILDREN_DIR;
967 if paths.is_empty() {
968 self.last_updated_index = Some(self.mark(Cow::Borrowed(root_path), flags));
969 } else {
970 for path in paths {
971 self.last_updated_index = Some(self.mark(Cow::Owned(path), flags));
972 }
973 }
974 }
975
976 /// Performs all batched invalidations, calling `invalidate` once for each `(path, invalidator)`
977 /// pair that needs to be invalidated, then clears the batch.
978 ///
979 /// In non-recursive watching mode, [`Self::new_paths`] must be processed (to (re-)establish
980 /// watches) *before* calling this.
981 ///
982 /// For each path, a recursive invalidation subsumes an exact one, as
983 /// [`extract_path_with_children`][OrderedPathMapExt::extract_path_with_children] removes the
984 /// path itself in addition to its children.
985 fn execute(
986 &mut self,
987 invalidator_map: &InvalidatorMap,
988 dir_invalidator_map: &InvalidatorMap,
989 invalidate: impl Fn(&Path, Invalidator),
990 ) {
991 for (map, exact_flag, recursive_flag) in [
992 (
993 invalidator_map,
994 InvalidationFlags::PATH,
995 InvalidationFlags::PATH_AND_CHILDREN,
996 ),
997 (
998 dir_invalidator_map,
999 InvalidationFlags::PATH_DIR,
1000 InvalidationFlags::PATH_AND_CHILDREN_DIR,
1001 ),
1002 ] {
1003 let mut map = map.lock().unwrap();
1004 for (path, flags) in &self.paths {
1005 if flags.contains(recursive_flag) {
1006 for (_, invalidators) in map.extract_path_with_children(path) {
1007 for invalidator in invalidators {
1008 invalidate(path, invalidator);
1009 }
1010 }
1011 } else if flags.contains(exact_flag)
1012 && let Some(invalidators) = map.remove(&**path)
1013 {
1014 for invalidator in invalidators {
1015 invalidate(path, invalidator);
1016 }
1017 }
1018 }
1019 }
1020 self.clear();
1021 }
1022}
1023
1024#[instrument(
1025 parent = None,
1026 level = "info",
1027 name = "file change",
1028 skip_all,
1029 fields(name = %invalidation_reason_path.display())
1030)]
1031fn invalidate(
1032 inner: &impl DiskFileSystemWatcherApi,
1033 turbo_tasks: &dyn TurboTasksApi,
1034 report_invalidation_reason: bool,
1035 invalidation_reason_path: &Path,
1036 invalidator: Invalidator,
1037) {
1038 if report_invalidation_reason
1039 && let Some(path) =
1040 format_absolute_fs_path(invalidation_reason_path, inner.name(), inner.root_path())
1041 {
1042 invalidator.invalidate_with_reason(turbo_tasks, WatchChange { path });
1043 return;
1044 }
1045 invalidator.invalidate(turbo_tasks);
1046}
1047
1048/// Invalidation was caused by a watcher rescan event. This will likely invalidate *every* watched
1049/// file.
1050#[derive(Clone, PartialEq, Eq, Hash)]
1051pub struct InvalidateRescan {
1052 path: RcStr,
1053}
1054
1055impl InvalidationReason for InvalidateRescan {
1056 fn kind(&self) -> Option<StaticOrArc<dyn InvalidationReasonKind>> {
1057 Some(StaticOrArc::Static(&INVALIDATE_RESCAN_KIND))
1058 }
1059}
1060
1061impl fmt::Display for InvalidateRescan {
1062 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1063 write!(f, "{} in filesystem invalidated", self.path)
1064 }
1065}
1066
1067/// [Invalidation kind][InvalidationReasonKind] for [`InvalidateRescan`].
1068#[derive(PartialEq, Eq, Hash)]
1069struct InvalidateRescanKind;
1070
1071static INVALIDATE_RESCAN_KIND: InvalidateRescanKind = InvalidateRescanKind;
1072
1073impl InvalidationReasonKind for InvalidateRescanKind {
1074 fn fmt(
1075 &self,
1076 reasons: &FxIndexSet<StaticOrArc<dyn InvalidationReason>>,
1077 f: &mut fmt::Formatter<'_>,
1078 ) -> fmt::Result {
1079 let first_reason: &dyn InvalidationReason = &*reasons[0];
1080 write!(
1081 f,
1082 "{} items in filesystem invalidated due to notify::Watcher rescan event ({}, ...)",
1083 reasons.len(),
1084 (first_reason as &dyn Any)
1085 .downcast_ref::<InvalidateRescan>()
1086 .unwrap()
1087 .path
1088 )
1089 }
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use std::{
1095 fs,
1096 time::{Instant, SystemTime},
1097 };
1098
1099 use rstest::rstest;
1100 use turbo_tasks::TurboTasks;
1101 use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
1102
1103 use super::*;
1104 use crate::watcher::mock_fs_api::MockFileSystem;
1105
1106 /// Polls [`tracked_read`] until it has executed more than `previous_runs` times, i.e. until the
1107 /// watcher has invalidated it.
1108 async fn wait_for_rerun(fs: &Arc<MockFileSystem>, path: &Path, previous_runs: u64) {
1109 const WATCH_TIMEOUT: Duration = Duration::from_secs(5);
1110 let deadline = Instant::now() + WATCH_TIMEOUT;
1111 loop {
1112 if fs.tracked_read_strongly_consistent(path).await > previous_runs {
1113 return;
1114 }
1115 assert!(
1116 Instant::now() < deadline,
1117 "the watcher did not invalidate {path:?} within {WATCH_TIMEOUT:?}",
1118 );
1119 tokio::time::sleep(Duration::from_millis(10)).await;
1120 }
1121 }
1122
1123 /// Backdates `path`'s mtime so that a [`PollWatcher`] can see the write that follows it to
1124 /// avoid mtime truncation issues.
1125 fn backdate(path: &Path) {
1126 fs::File::options()
1127 .write(true)
1128 .open(path)
1129 .unwrap()
1130 .set_modified(SystemTime::now() - Duration::from_secs(10))
1131 .unwrap();
1132 }
1133
1134 /// `recursive_mode` is set explicitly rather than left to the platform default so that both
1135 /// watching strategies are covered on every host. `TURBO_TASKS_FORCE_WATCH_MODE` still
1136 /// overrides it, collapsing these into two cases.
1137 // Miri cannot run the native cases because inotify is unsupported, while the polling cases
1138 // require Turbo Tasks' link-section registry, which is unavailable under Miri.
1139 #[cfg(not(miri))]
1140 #[rstest]
1141 #[case::native_recursive(None, DiskWatcherRecursiveMode::Recursive)]
1142 #[case::native_non_recursive(None, DiskWatcherRecursiveMode::NonRecursive)]
1143 #[case::polling_recursive(Some(Duration::from_millis(20)), DiskWatcherRecursiveMode::Recursive)]
1144 #[case::polling_non_recursive(
1145 Some(Duration::from_millis(20)),
1146 DiskWatcherRecursiveMode::NonRecursive
1147 )]
1148 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1149 async fn watches_file_and_directory_changes(
1150 #[case] poll_interval: Option<Duration>,
1151 #[case] recursive_mode: DiskWatcherRecursiveMode,
1152 ) {
1153 let tt = TurboTasks::new(TurboTasksBackend::new(
1154 BackendOptions::default(),
1155 noop_backing_storage(),
1156 ));
1157 tt.run_once(async move {
1158 let fs = MockFileSystem::new(DiskWatcherConfig {
1159 recursive_mode: Some(recursive_mode),
1160 poll_interval,
1161 report_invalidation_reason: true,
1162 ..Default::default()
1163 });
1164 let sub_dir = fs.root_path.join("sub");
1165 let file_path = sub_dir.join("file.txt");
1166 fs::create_dir(&sub_dir).unwrap();
1167 fs::write(&file_path, "initial").unwrap();
1168 backdate(&file_path);
1169
1170 DiskWatcher::start_watching(fs.clone()).await?;
1171
1172 // the initial reads register the invalidators that the watcher will later fire
1173 assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 1);
1174 assert_eq!(fs.tracked_read_strongly_consistent(&sub_dir).await, 1);
1175
1176 // reading again without touching the filesystem must not re-run anything
1177 assert_eq!(fs.tracked_read_strongly_consistent(&file_path).await, 1);
1178 assert_eq!(fs.tracked_read_strongly_consistent(&sub_dir).await, 1);
1179
1180 // modifying a file invalidates the task that read that file
1181 fs::write(&file_path, "updated")?;
1182 wait_for_rerun(&fs, &file_path, 1).await;
1183
1184 // creating a file invalidates the task that listed the containing directory
1185 let dir_runs = fs.tracked_read_strongly_consistent(&sub_dir).await;
1186 fs::write(sub_dir.join("new.txt"), "new")?;
1187 wait_for_rerun(&fs, &sub_dir, dir_runs).await;
1188
1189 fs.watcher.stop_watching().await;
1190 anyhow::Ok(())
1191 })
1192 .await
1193 .unwrap();
1194 }
1195}