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