From 60007ddcc5611f6f7bf65f14a5e6b67e7fd1eb49 Mon Sep 17 00:00:00 2001 From: Benjamin Woodruff Date: Tue, 6 Jan 2026 22:37:57 -0800 Subject: [PATCH] Turbopack: Extend filesystem watcher fuzzing to cover symlinks and junction points --- turbopack/crates/turbo-tasks-fuzz/src/main.rs | 190 +++++++++++++++++- 1 file changed, 185 insertions(+), 5 deletions(-) diff --git a/turbopack/crates/turbo-tasks-fuzz/src/main.rs b/turbopack/crates/turbo-tasks-fuzz/src/main.rs index 83ad927858739..31a28ddf16d55 100644 --- a/turbopack/crates/turbo-tasks-fuzz/src/main.rs +++ b/turbopack/crates/turbo-tasks-fuzz/src/main.rs @@ -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, + /// 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, + 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, root: FileSystemPath, depth: usize, width: usize, + symlink_count: u32, ) -> anyhow::Result<()> { async fn read_all_paths_inner( invalidations: TransientInstance, @@ -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> { + // 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, }