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
25const 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 #[arg(long)]
48 start_watching_late: bool,
49 #[arg(long, value_enum)]
51 symlinks: Option<SymlinkMode>,
52 #[arg(long, default_value_t = 80, requires = "symlinks")]
54 symlink_count: u32,
55 #[arg(long, default_value_t = 20, requires = "symlinks")]
57 symlink_modifications: u32,
58 #[arg(long)]
61 track_writes: bool,
62}
63
64#[derive(Clone, Copy, Debug, ValueEnum)]
65enum SymlinkMode {
66 #[cfg_attr(windows, doc = "(requires developer mode or admin)")]
68 File,
69 #[cfg_attr(windows, doc = "(requires developer mode or admin)")]
71 Directory,
72 #[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 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
415fn 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 #[cfg(windows)]
461 let expected_target_canonicalized: Option<PathBuf> = match symlink_mode {
462 Some(SymlinkMode::Junction) => {
463 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 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 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 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 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 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}