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