Repository: vercel/next.js
Pull request: Turbopack: Extend filesystem watcher fuzzing to cover symlinks and junction points

This is a crappy fuzzer I have to make sure that the filesystem watcher picks up changes to files. This didn't have coverage for links, so this tests that.

I tested this on Windows and Linux.

I don't care about the code quality here, so this was LLM-generated with very lightweight review.

Changed files:
filename: turbopack/crates/turbo-tasks-fuzz/src/main.rs
patch: @@ -1,3 +1,5 @@
+#![cfg_attr(windows, feature(junction_point))]
+
 use std::{
     fs::OpenOptions,
     io::Write,
@@ -7,7 +9,7 @@ use std::{
     time::Duration,
 };
 
-use clap::{Args, Parser, Subcommand};
+use clap::{Args, Parser, Subcommand, ValueEnum};
 use rand::{Rng, RngCore, SeedableRng};
 use rustc_hash::FxHashSet;
 use tokio::time::sleep;
@@ -55,6 +57,28 @@ struct FsWatcher {
     /// Call `start_watching` after the initial read of files instead of before (the default).
     #[arg(long)]
     start_watching_late: bool,
+    /// Enable symlink testing. The mode controls what kind of targets the symlinks point to.
+    #[arg(long, value_enum)]
+    symlinks: Option<SymlinkMode>,
+    /// Total number of symlinks to create.
+    #[arg(long, default_value_t = 80, requires = "symlinks")]
+    symlink_count: u32,
+    /// Number of symlink modifications per iteration (only used when --symlinks is set).
+    #[arg(long, default_value_t = 20, requires = "symlinks")]
+    symlink_modifications: u32,
+}
+
+#[derive(Clone, Copy, Debug, ValueEnum)]
+enum SymlinkMode {
+    /// Test file symlinks
+    #[cfg_attr(windows, doc = "(requires developer mode or admin)")]
+    File,
+    /// Test directory symlinks
+    #[cfg_attr(windows, doc = "(requires developer mode or admin)")]
+    Directory,
+    /// Test junction points (Windows-only)
+    #[cfg(windows)]
+    Junction,
 }
 
 #[tokio::main]
@@ -80,6 +104,7 @@ async fn fuzz_fs_watcher(args: FsWatcher) -> anyhow::Result<()> {
         BackendOptions::default(),
         noop_backing_storage(),
     ));
+
     tt.run_once(async move {
         let invalidations = TransientInstance::new(PathInvalidations::default());
         let fs_root_rcstr = RcStr::from(fs_root.to_str().unwrap());
@@ -91,14 +116,30 @@ async fn fuzz_fs_watcher(args: FsWatcher) -> anyhow::Result<()> {
             .await?
             .owned()
             .await?;
+
         create_directory_tree(&mut FxHashSet::default(), &fs_root, args.depth, args.width)?;
 
+        let mut symlink_targets = if let Some(mode) = args.symlinks {
+            create_initial_symlinks(&fs_root, mode, args.symlink_count, args.depth)?
+        } else {
+            Vec::new()
+        };
+
         if !args.start_watching_late {
             project_fs.await?.start_watching(None).await?;
         }
 
-        let read_all_paths_op =
-            read_all_paths_operation(invalidations.clone(), project_root, args.depth, args.width);
+        let read_all_paths_op = read_all_paths_operation(
+            invalidations.clone(),
+            project_root.clone(),
+            args.depth,
+            args.width,
+            if args.symlinks.is_some() {
+                args.symlink_count
+            } else {
+                0
+            },
+        );
         read_all_paths_op.read_strongly_consistent().await?;
         {
             let mut invalidations = invalidations.0.lock().unwrap();
@@ -134,15 +175,44 @@ async fn fuzz_fs_watcher(args: FsWatcher) -> anyhow::Result<()> {
                     args.width,
                 )?;
             }
+
+            if let Some(mode) = args.symlinks
+                && !symlink_targets.is_empty()
+            {
+                for _ in 0..args.symlink_modifications {
+                    let symlink_idx = rng.random_range(0..symlink_targets.len());
+                    let old_target = &symlink_targets[symlink_idx];
+
+                    let new_target_relative = pick_random_link_target(args.depth, args.width, mode);
+
+                    if new_target_relative != *old_target {
+                        let symlink_path = fs_root.join("_symlinks").join(symlink_idx.to_string());
+                        let relative_target = Path::new("..").join(&new_target_relative);
+
+                        remove_symlink(&symlink_path, mode)?;
+                        create_symlink(&symlink_path, &relative_target, mode)?;
+
+                        modified_file_paths.insert(symlink_path);
+                        symlink_targets[symlink_idx] = new_target_relative;
+                    }
+                }
+            }
+
             // there's no way to know when we've received all the pending events from the operating
             // system, so just sleep and pray
             sleep(Duration::from_millis(args.notify_timeout_ms)).await;
             read_all_paths_op.read_strongly_consistent().await?;
             {
                 let mut invalidations = invalidations.0.lock().unwrap();
+                let symlink_info = if args.symlinks.is_some() {
+                    " and symlinks"
+                } else {
+                    ""
+                };
                 println!(
-                    "modified {} files and found {} invalidations",
+                    "modified {} files{}. found {} invalidations",
                     modified_file_paths.len(),
+                    symlink_info,
                     invalidations.len()
                 );
                 if args.print_missing_invalidations {
@@ -186,12 +256,24 @@ async fn read_path(
     Ok(())
 }
 
+#[turbo_tasks::function]
+async fn read_link(
+    invalidations: TransientInstance<PathInvalidations>,
+    path: FileSystemPath,
+) -> anyhow::Result<()> {
+    let path_str = path.path.clone();
+    invalidations.0.lock().unwrap().insert(path_str);
+    let _ = path.read_link().await?;
+    Ok(())
+}
+
 #[turbo_tasks::function(operation)]
 async fn read_all_paths_operation(
     invalidations: TransientInstance<PathInvalidations>,
     root: FileSystemPath,
     depth: usize,
     width: usize,
+    symlink_count: u32,
 ) -> anyhow::Result<()> {
     async fn read_all_paths_inner(
         invalidations: TransientInstance<PathInvalidations>,
@@ -216,7 +298,15 @@ async fn read_all_paths_operation(
         }
         Ok(())
     }
-    read_all_paths_inner(invalidations, root, depth, width).await
+    read_all_paths_inner(invalidations.clone(), root.clone(), depth, width).await?;
+
+    let symlinks_dir = root.join("_symlinks")?;
+    for i in 0..symlink_count {
+        let symlink_path = symlinks_dir.join(&i.to_string())?;
+        read_link(invalidations.clone(), symlink_path).await?;
+    }
+
+    Ok(())
 }
 
 fn create_directory_tree(
@@ -244,6 +334,87 @@ fn create_directory_tree(
     Ok(())
 }
 
+fn create_initial_symlinks(
+    fs_root: &Path,
+    symlink_mode: SymlinkMode,
+    symlink_count: u32,
+    depth: usize,
+) -> anyhow::Result<Vec<PathBuf>> {
+    // Use a dedicated "symlinks" directory to avoid conflicts
+    let symlinks_dir = fs_root.join("_symlinks");
+    std::fs::create_dir_all(&symlinks_dir)?;
+
+    let initial_target_relative = match symlink_mode {
+        SymlinkMode::File => {
+            // Point to a file at depth: 0/0/0/.../0
+            let mut path = PathBuf::new();
+            for _ in 0..depth {
+                path.push("0");
+            }
+            path
+        }
+        SymlinkMode::Directory => PathBuf::from("0"),
+        #[cfg(windows)]
+        SymlinkMode::Junction => PathBuf::from("0"),
+    };
+
+    let relative_target = Path::new("..").join(&initial_target_relative);
+
+    let mut symlink_targets = Vec::new();
+    for i in 0..symlink_count {
+        let symlink_path = symlinks_dir.join(i.to_string());
+        create_symlink(&symlink_path, &relative_target, symlink_mode)?;
+        symlink_targets.push(initial_target_relative.clone());
+    }
+
+    Ok(symlink_targets)
+}
+
+fn create_symlink(link_path: &Path, target: &Path, mode: SymlinkMode) -> anyhow::Result<()> {
+    #[cfg(unix)]
+    {
+        let _ = mode;
+        std::os::unix::fs::symlink(target, link_path)?;
+    }
+    #[cfg(windows)]
+    {
+        match mode {
+            SymlinkMode::File => {
+                std::os::windows::fs::symlink_file(target, link_path)?;
+            }
+            SymlinkMode::Directory => {
+                std::os::windows::fs::symlink_dir(target, link_path)?;
+            }
+            SymlinkMode::Junction => {
+                // Junction points require absolute paths
+                let absolute_target = link_path.parent().unwrap_or(link_path).join(target);
+                std::os::windows::fs::junction_point(&absolute_target, link_path)?;
+            }
+        }
+    }
+    Ok(())
+}
+
+fn remove_symlink(link_path: &Path, mode: SymlinkMode) -> anyhow::Result<()> {
+    #[cfg(unix)]
+    {
+        let _ = mode;
+        std::fs::remove_file(link_path)?;
+    }
+    #[cfg(windows)]
+    {
+        match mode {
+            SymlinkMode::File | SymlinkMode::Directory => {
+                std::fs::remove_file(link_path)?;
+            }
+            SymlinkMode::Junction => {
+                std::fs::remove_dir(link_path)?;
+            }
+        }
+    }
+    Ok(())
+}
+
 fn pick_random_file(depth: usize, width: usize) -> PathBuf {
     let mut rng = rand::rng();
     iter::repeat_with(|| rng.random_range(0..width).to_string())
@@ -266,6 +437,15 @@ fn pick_random_directory(max_depth: usize, width: usize) -> RandomDirectory {
     RandomDirectory { depth, path }
 }
 
+fn pick_random_link_target(depth: usize, width: usize, mode: SymlinkMode) -> PathBuf {
+    match mode {
+        SymlinkMode::File => pick_random_file(depth, width),
+        SymlinkMode::Directory => pick_random_directory(depth, width).path,
+        #[cfg(windows)]
+        SymlinkMode::Junction => pick_random_directory(depth, width).path,
+    }
+}
+
 struct FsCleanup<'a> {
     path: &'a Path,
 }