Skip to main content

turbo_tasks_fuzz/
fs_watcher.rs

1#![allow(clippy::needless_return)]
2
3use std::{
4    fs::OpenOptions,
5    io::Write,
6    iter,
7    path::{Path, PathBuf},
8    sync::{Arc, Mutex},
9    time::Duration,
10};
11
12use clap::{Args, ValueEnum};
13use rand::{Rng, RngExt, SeedableRng};
14use rustc_hash::FxHashSet;
15use tokio::time::sleep;
16use turbo_rcstr::{RcStr, rcstr};
17use turbo_tasks::{
18    Effects, NonLocalValue, OperationVc, ResolvedVc, TransientInstance, Vc,
19    read_strongly_consistent_and_apply_effects, take_effects, trace::TraceRawVcs,
20};
21use turbo_tasks_backend::{BackendOptions, TurboTasksBackend, noop_backing_storage};
22use turbo_tasks_fs::{
23    DiskFileSystem, File, FileContent, FileSystem, FileSystemPath, LinkContent, LinkType,
24};
25
26// `read_or_write_all_paths_operation` always writes the sentinel values to files/symlinks. We can
27// check for these sentinel values to see if `write`/`write_link` was re-run.
28const FILE_SENTINEL_CONTENT: &[u8] = b"sentinel_value";
29const SYMLINK_SENTINEL_TARGET: &str = "../0";
30
31#[derive(Args)]
32pub struct FsWatcher {
33    #[arg(long)]
34    fs_root: PathBuf,
35    #[arg(long, default_value_t = 4)]
36    depth: usize,
37    #[arg(long, default_value_t = 6)]
38    width: usize,
39    #[arg(long, default_value_t = 100)]
40    notify_timeout_ms: u64,
41    #[arg(long, default_value_t = 200)]
42    file_modifications: u32,
43    #[arg(long, default_value_t = 2)]
44    directory_modifications: u32,
45    #[arg(long)]
46    print_missing_invalidations: bool,
47    /// Call `start_watching` after the initial read of files instead of before (the default).
48    #[arg(long)]
49    start_watching_late: bool,
50    /// Enable symlink testing. The mode controls what kind of targets the symlinks point to.
51    #[arg(long, value_enum)]
52    symlinks: Option<SymlinkMode>,
53    /// Total number of symlinks to create.
54    #[arg(long, default_value_t = 80, requires = "symlinks")]
55    symlink_count: u32,
56    /// Number of symlink modifications per iteration (only used when --symlinks is set).
57    #[arg(long, default_value_t = 20, requires = "symlinks")]
58    symlink_modifications: u32,
59    /// Track file writes instead of reads. When enabled, the fuzzer writes files via
60    /// turbo-tasks and verifies that external modifications trigger invalidations.
61    #[arg(long)]
62    track_writes: bool,
63}
64
65#[derive(Clone, Copy, Debug, ValueEnum)]
66enum SymlinkMode {
67    /// Test file symlinks
68    #[cfg_attr(windows, doc = "(requires developer mode or admin)")]
69    File,
70    /// Test directory symlinks
71    #[cfg_attr(windows, doc = "(requires developer mode or admin)")]
72    Directory,
73    /// Test junction points (Windows-only)
74    #[cfg(windows)]
75    Junction,
76}
77
78impl SymlinkMode {
79    fn to_link_type(self) -> LinkType {
80        match self {
81            SymlinkMode::File => LinkType::empty(),
82            SymlinkMode::Directory => LinkType::DIRECTORY,
83            #[cfg(windows)]
84            SymlinkMode::Junction => LinkType::DIRECTORY,
85        }
86    }
87}
88
89#[derive(Default, NonLocalValue, TraceRawVcs)]
90struct PathInvalidations(#[turbo_tasks(trace_ignore)] Arc<Mutex<FxHashSet<RcStr>>>);
91
92#[turbo_tasks::function(operation, root)]
93async fn extract_effects_operation(op: OperationVc<()>) -> anyhow::Result<Vc<Effects>> {
94    let _ = op.resolve().strongly_consistent().await?;
95    Ok(take_effects(op).await?.cell())
96}
97
98pub async fn run(args: FsWatcher) -> anyhow::Result<()> {
99    std::fs::create_dir(&args.fs_root)?;
100    let fs_root = args.fs_root.canonicalize()?;
101    let _guard = FsCleanup {
102        path: &fs_root.clone(),
103    };
104
105    let tt = turbo_tasks::TurboTasks::new(TurboTasksBackend::new(
106        BackendOptions::default(),
107        noop_backing_storage(),
108    ));
109
110    tt.run_once(async move {
111        let invalidations = TransientInstance::new(PathInvalidations::default());
112        let project_fs = disk_file_system_operation(RcStr::from(fs_root.to_str().unwrap()))
113            .resolve()
114            .strongly_consistent()
115            .await?;
116        let project_root = disk_file_system_root_operation(project_fs)
117            .resolve()
118            .strongly_consistent()
119            .await?
120            .owned()
121            .await?;
122
123        create_directory_tree(&mut FxHashSet::default(), &fs_root, args.depth, args.width)?;
124
125        let mut symlink_targets = if let Some(mode) = args.symlinks {
126            create_initial_symlinks(&fs_root, mode, args.symlink_count, args.depth)?
127        } else {
128            Vec::new()
129        };
130
131        if !args.start_watching_late {
132            project_fs.await?.start_watching(None).await?;
133        }
134
135        let symlink_count = if args.symlinks.is_some() {
136            args.symlink_count
137        } else {
138            0
139        };
140        let track_writes = args.track_writes;
141        let symlink_mode = args.symlinks;
142        let symlink_is_directory =
143            symlink_mode.map(|m| m.to_link_type().contains(LinkType::DIRECTORY));
144
145        let effects_op = extract_effects_operation(read_or_write_all_paths_operation(
146            invalidations.clone(),
147            project_root.clone(),
148            args.depth,
149            args.width,
150            symlink_count,
151            symlink_is_directory,
152            track_writes,
153        ));
154        if track_writes {
155            read_strongly_consistent_and_apply_effects(effects_op, |e| e).await?;
156            let (total, mismatched) = verify_written_files(
157                &fs_root,
158                args.depth,
159                args.width,
160                symlink_count,
161                symlink_mode,
162            );
163            println!("wrote all {} paths, {} mismatches", total, mismatched.len());
164            if args.print_missing_invalidations && !mismatched.is_empty() {
165                for path in &mismatched {
166                    println!("  mismatch {path:?}");
167                }
168            }
169        } else {
170            // Still drive the computation (and propagate errors) without applying effects.
171            effects_op.read_strongly_consistent().await?;
172            let invalidations = invalidations.0.lock().unwrap();
173            println!("read all {} files", invalidations.len());
174        }
175        invalidations.0.lock().unwrap().clear();
176
177        if args.start_watching_late {
178            project_fs.await?.start_watching(None).await?;
179        }
180
181        let mut rand_buf = [0; 16];
182        let mut rng = rand::rngs::SmallRng::from_rng(&mut rand::rng());
183        loop {
184            let mut modified_file_paths = FxHashSet::default();
185            for _ in 0..args.file_modifications {
186                let path = fs_root.join(pick_random_file(args.depth, args.width));
187                let mut f = OpenOptions::new().write(true).truncate(true).open(&path)?;
188                rng.fill_bytes(&mut rand_buf);
189                f.write_all(&rand_buf)?;
190                f.flush()?;
191                modified_file_paths.insert(path);
192            }
193            for _ in 0..args.directory_modifications {
194                let dir = pick_random_directory(args.depth, args.width);
195                let path = fs_root.join(dir.path);
196                std::fs::remove_dir_all(&path)?;
197                std::fs::create_dir(&path)?;
198                create_directory_tree(
199                    &mut modified_file_paths,
200                    &path,
201                    args.depth - dir.depth,
202                    args.width,
203                )?;
204            }
205
206            if let Some(mode) = args.symlinks
207                && !symlink_targets.is_empty()
208            {
209                for _ in 0..args.symlink_modifications {
210                    let symlink_idx = rng.random_range(0..symlink_targets.len());
211                    let old_target = &symlink_targets[symlink_idx];
212
213                    let new_target_relative = pick_random_link_target(args.depth, args.width, mode);
214
215                    if new_target_relative != *old_target {
216                        let symlink_path = fs_root.join("_symlinks").join(symlink_idx.to_string());
217                        let relative_target = Path::new("..").join(&new_target_relative);
218
219                        remove_symlink(&symlink_path, mode)?;
220                        create_symlink(&symlink_path, &relative_target, mode)?;
221
222                        modified_file_paths.insert(symlink_path);
223                        symlink_targets[symlink_idx] = new_target_relative;
224                    }
225                }
226            }
227
228            // there's no way to know when we've received all the pending events from the operating
229            // system, so just sleep and pray
230            sleep(Duration::from_millis(args.notify_timeout_ms)).await;
231            let effects_op = extract_effects_operation(read_or_write_all_paths_operation(
232                invalidations.clone(),
233                project_root.clone(),
234                args.depth,
235                args.width,
236                symlink_count,
237                symlink_is_directory,
238                track_writes,
239            ));
240            let symlink_info = if args.symlinks.is_some() {
241                " and symlinks"
242            } else {
243                ""
244            };
245            if track_writes {
246                read_strongly_consistent_and_apply_effects(effects_op, |e| e).await?;
247                let (total, mismatched) = verify_written_files(
248                    &fs_root,
249                    args.depth,
250                    args.width,
251                    symlink_count,
252                    symlink_mode,
253                );
254                println!(
255                    "modified {} files{}. verified {} paths, {} mismatches",
256                    modified_file_paths.len(),
257                    symlink_info,
258                    total,
259                    mismatched.len()
260                );
261                if args.print_missing_invalidations && !mismatched.is_empty() {
262                    let mut sorted = mismatched;
263                    sorted.sort_unstable();
264                    for path in &sorted {
265                        println!("  mismatch {path:?}");
266                    }
267                }
268            } else {
269                // Still drive the computation (and propagate errors) without applying effects.
270                effects_op.read_strongly_consistent().await?;
271                let mut invalidations = invalidations.0.lock().unwrap();
272                println!(
273                    "modified {} files{}. found {} invalidations",
274                    modified_file_paths.len(),
275                    symlink_info,
276                    invalidations.len()
277                );
278                if args.print_missing_invalidations {
279                    let absolute_path_invalidations = invalidations
280                        .iter()
281                        .map(|relative_path| fs_root.join(relative_path))
282                        .collect::<FxHashSet<PathBuf>>();
283                    let mut missing = modified_file_paths
284                        .difference(&absolute_path_invalidations)
285                        .collect::<Vec<_>>();
286                    missing.sort_unstable();
287                    for path in &missing {
288                        println!("  missing {path:?}");
289                    }
290                }
291                invalidations.clear();
292            }
293        }
294    })
295    .await
296}
297
298#[turbo_tasks::function(operation)]
299fn disk_file_system_operation(fs_root: RcStr) -> Vc<DiskFileSystem> {
300    DiskFileSystem::new(rcstr!("project"), Vc::cell(fs_root))
301}
302
303#[turbo_tasks::function(operation)]
304fn disk_file_system_root_operation(fs: ResolvedVc<DiskFileSystem>) -> Vc<FileSystemPath> {
305    fs.root()
306}
307
308#[turbo_tasks::function]
309async fn read_path(
310    invalidations: TransientInstance<PathInvalidations>,
311    path: FileSystemPath,
312) -> anyhow::Result<()> {
313    let path_str = path.path.clone();
314    invalidations.0.lock().unwrap().insert(path_str);
315    let _ = path.read().await?;
316    Ok(())
317}
318
319#[turbo_tasks::function]
320async fn read_link(
321    invalidations: TransientInstance<PathInvalidations>,
322    path: FileSystemPath,
323) -> anyhow::Result<()> {
324    let path_str = path.path.clone();
325    invalidations.0.lock().unwrap().insert(path_str);
326    let _ = path.read_link().await?;
327    Ok(())
328}
329
330#[turbo_tasks::function]
331async fn write_path(
332    invalidations: TransientInstance<PathInvalidations>,
333    path: FileSystemPath,
334) -> anyhow::Result<()> {
335    let path_str = path.path.clone();
336    invalidations.0.lock().unwrap().insert(path_str);
337    let content = FileContent::Content(File::from(FILE_SENTINEL_CONTENT));
338    let _ = path.write(content.cell()).await?;
339    Ok(())
340}
341
342#[turbo_tasks::function]
343async fn write_link(
344    invalidations: TransientInstance<PathInvalidations>,
345    path: FileSystemPath,
346    target: RcStr,
347    is_directory: bool,
348) -> anyhow::Result<()> {
349    let path_str = path.path.clone();
350    invalidations.0.lock().unwrap().insert(path_str);
351    let link_type = if is_directory {
352        LinkType::DIRECTORY
353    } else {
354        LinkType::empty()
355    };
356    let link_content = LinkContent::Link { target, link_type };
357    let _ = path
358        .fs()
359        .write_link(path.clone(), link_content.cell())
360        .await?;
361    Ok(())
362}
363
364#[turbo_tasks::function(operation)]
365async fn read_or_write_all_paths_operation(
366    invalidations: TransientInstance<PathInvalidations>,
367    root: FileSystemPath,
368    depth: usize,
369    width: usize,
370    symlink_count: u32,
371    symlink_is_directory: Option<bool>,
372    write: bool,
373) -> anyhow::Result<()> {
374    async fn process_paths_inner(
375        invalidations: TransientInstance<PathInvalidations>,
376        parent: FileSystemPath,
377        depth: usize,
378        width: usize,
379        write: bool,
380    ) -> anyhow::Result<()> {
381        for child_id in 0..width {
382            let child_name = child_id.to_string();
383            let child_path = parent.join(&child_name)?;
384            if depth == 1 {
385                if write {
386                    write_path(invalidations.clone(), child_path).await?;
387                } else {
388                    read_path(invalidations.clone(), child_path).await?;
389                }
390            } else {
391                Box::pin(process_paths_inner(
392                    invalidations.clone(),
393                    child_path,
394                    depth - 1,
395                    width,
396                    write,
397                ))
398                .await?;
399            }
400        }
401        Ok(())
402    }
403    process_paths_inner(invalidations.clone(), root.clone(), depth, width, write).await?;
404
405    if symlink_count > 0 {
406        let symlinks_dir = root.join("_symlinks")?;
407        for i in 0..symlink_count {
408            let symlink_path = symlinks_dir.join(&i.to_string())?;
409            if write {
410                write_link(
411                    invalidations.clone(),
412                    symlink_path,
413                    RcStr::from(SYMLINK_SENTINEL_TARGET),
414                    symlink_is_directory.unwrap_or(false),
415                )
416                .await?;
417            } else {
418                read_link(invalidations.clone(), symlink_path).await?;
419            }
420        }
421    }
422
423    Ok(())
424}
425
426/// Verifies that all files and symlinks have the expected sentinel content. Returns (total_checked,
427/// mismatched_paths).
428///
429/// We use this when using `--track-writes`/`track_writes`. We can't use the same trick that reads
430/// do, because `write`/`write_link` will never invalidate their caller (their return value is
431/// `Vc<()>`).
432fn verify_written_files(
433    fs_root: &Path,
434    depth: usize,
435    width: usize,
436    symlink_count: u32,
437    symlink_mode: Option<SymlinkMode>,
438) -> (usize, Vec<PathBuf>) {
439    fn check_files_inner(
440        parent: &Path,
441        depth: usize,
442        width: usize,
443        total: &mut usize,
444        mismatched: &mut Vec<PathBuf>,
445    ) {
446        for child_id in 0..width {
447            let child_path = parent.join(child_id.to_string());
448            if depth == 1 {
449                *total += 1;
450                match std::fs::read(&child_path) {
451                    Ok(content) if content == FILE_SENTINEL_CONTENT => {}
452                    _ => mismatched.push(child_path),
453                }
454            } else {
455                check_files_inner(&child_path, depth - 1, width, total, mismatched);
456            }
457        }
458    }
459
460    let mut total = 0;
461    let mut mismatched = Vec::new();
462
463    check_files_inner(fs_root, depth, width, &mut total, &mut mismatched);
464
465    if symlink_count > 0 {
466        let symlinks_dir = fs_root.join("_symlinks");
467
468        // Compute expected target based on mode. On Windows, junctions are stored with absolute
469        // paths by DiskFileSystem::write_link. We also need to canonicalize because read_link
470        // returns paths with the \\?\ extended-length prefix.
471        #[cfg(windows)]
472        let expected_target_canonicalized: Option<PathBuf> = match symlink_mode {
473            Some(SymlinkMode::Junction) => {
474                // Absolute path: fs_root/_symlinks/../0 resolves to fs_root/0
475                // Canonicalize to get the \\?\ prefixed form that read_link returns
476                std::fs::canonicalize(fs_root.join("0")).ok()
477            }
478            _ => None,
479        };
480
481        for i in 0..symlink_count {
482            total += 1;
483            let symlink_path = symlinks_dir.join(i.to_string());
484            let matches = match std::fs::read_link(&symlink_path) {
485                Ok(target) => {
486                    #[cfg(windows)]
487                    {
488                        if let Some(ref expected) = expected_target_canonicalized {
489                            // Canonicalize the target we read back for consistent comparison
490                            std::fs::canonicalize(&target).ok().as_ref() == Some(expected)
491                        } else {
492                            target == Path::new(SYMLINK_SENTINEL_TARGET)
493                        }
494                    }
495                    #[cfg(not(windows))]
496                    {
497                        let _ = symlink_mode;
498                        target == Path::new(SYMLINK_SENTINEL_TARGET)
499                    }
500                }
501                Err(_) => false,
502            };
503            if !matches {
504                mismatched.push(symlink_path);
505            }
506        }
507    }
508
509    (total, mismatched)
510}
511
512fn create_directory_tree(
513    modified_file_paths: &mut FxHashSet<PathBuf>,
514    parent: &Path,
515    depth: usize,
516    width: usize,
517) -> anyhow::Result<()> {
518    let mut rng = rand::rng();
519    let mut rand_buf = [0; 16];
520    for child_id in 0..width {
521        let child_name = child_id.to_string();
522        let child_path = parent.join(&child_name);
523        if depth == 1 {
524            let mut f = std::fs::File::create(&child_path)?;
525            rng.fill_bytes(&mut rand_buf);
526            f.write_all(&rand_buf)?;
527            f.flush()?;
528            modified_file_paths.insert(child_path);
529        } else {
530            std::fs::create_dir(&child_path)?;
531            create_directory_tree(modified_file_paths, &child_path, depth - 1, width)?;
532        }
533    }
534    Ok(())
535}
536
537fn create_initial_symlinks(
538    fs_root: &Path,
539    symlink_mode: SymlinkMode,
540    symlink_count: u32,
541    depth: usize,
542) -> anyhow::Result<Vec<PathBuf>> {
543    // Use a dedicated "symlinks" directory to avoid conflicts
544    let symlinks_dir = fs_root.join("_symlinks");
545    std::fs::create_dir_all(&symlinks_dir)?;
546
547    let initial_target_relative = match symlink_mode {
548        SymlinkMode::File => {
549            // Point to a file at depth: 0/0/0/.../0
550            let mut path = PathBuf::new();
551            for _ in 0..depth {
552                path.push("0");
553            }
554            path
555        }
556        SymlinkMode::Directory => PathBuf::from("0"),
557        #[cfg(windows)]
558        SymlinkMode::Junction => PathBuf::from("0"),
559    };
560
561    let relative_target = Path::new("..").join(&initial_target_relative);
562
563    let mut symlink_targets = Vec::new();
564    for i in 0..symlink_count {
565        let symlink_path = symlinks_dir.join(i.to_string());
566        create_symlink(&symlink_path, &relative_target, symlink_mode)?;
567        symlink_targets.push(initial_target_relative.clone());
568    }
569
570    Ok(symlink_targets)
571}
572
573fn create_symlink(link_path: &Path, target: &Path, mode: SymlinkMode) -> anyhow::Result<()> {
574    #[cfg(unix)]
575    {
576        let _ = mode;
577        std::os::unix::fs::symlink(target, link_path)?;
578    }
579    #[cfg(windows)]
580    {
581        match mode {
582            SymlinkMode::File => {
583                std::os::windows::fs::symlink_file(target, link_path)?;
584            }
585            SymlinkMode::Directory => {
586                std::os::windows::fs::symlink_dir(target, link_path)?;
587            }
588            SymlinkMode::Junction => {
589                // Junction points require absolute paths
590                let absolute_target = link_path.parent().unwrap_or(link_path).join(target);
591                std::os::windows::fs::junction_point(&absolute_target, link_path)?;
592            }
593        }
594    }
595    Ok(())
596}
597
598fn remove_symlink(link_path: &Path, mode: SymlinkMode) -> anyhow::Result<()> {
599    #[cfg(unix)]
600    {
601        let _ = mode;
602        std::fs::remove_file(link_path)?;
603    }
604    #[cfg(windows)]
605    {
606        match mode {
607            SymlinkMode::File | SymlinkMode::Directory => {
608                std::fs::remove_file(link_path)?;
609            }
610            SymlinkMode::Junction => {
611                std::fs::remove_dir(link_path)?;
612            }
613        }
614    }
615    Ok(())
616}
617
618fn pick_random_file(depth: usize, width: usize) -> PathBuf {
619    let mut rng = rand::rng();
620    iter::repeat_with(|| rng.random_range(0..width).to_string())
621        .take(depth)
622        .collect()
623}
624
625struct RandomDirectory {
626    depth: usize,
627    path: PathBuf,
628}
629
630fn pick_random_directory(max_depth: usize, width: usize) -> RandomDirectory {
631    let mut rng = rand::rng();
632    // never use a depth of 0 because that would be the root directory
633    let depth = rng.random_range(1..(max_depth - 1));
634    let path = iter::repeat_with(|| rng.random_range(0..width).to_string())
635        .take(depth)
636        .collect();
637    RandomDirectory { depth, path }
638}
639
640fn pick_random_link_target(depth: usize, width: usize, mode: SymlinkMode) -> PathBuf {
641    match mode {
642        SymlinkMode::File => pick_random_file(depth, width),
643        SymlinkMode::Directory => pick_random_directory(depth, width).path,
644        #[cfg(windows)]
645        SymlinkMode::Junction => pick_random_directory(depth, width).path,
646    }
647}
648
649struct FsCleanup<'a> {
650    path: &'a Path,
651}
652
653impl Drop for FsCleanup<'_> {
654    fn drop(&mut self) {
655        std::fs::remove_dir_all(self.path).unwrap();
656    }
657}