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
26const 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 #[arg(long)]
49 start_watching_late: bool,
50 #[arg(long, value_enum)]
52 symlinks: Option<SymlinkMode>,
53 #[arg(long, default_value_t = 80, requires = "symlinks")]
55 symlink_count: u32,
56 #[arg(long, default_value_t = 20, requires = "symlinks")]
58 symlink_modifications: u32,
59 #[arg(long)]
62 track_writes: bool,
63}
64
65#[derive(Clone, Copy, Debug, ValueEnum)]
66enum SymlinkMode {
67 #[cfg_attr(windows, doc = "(requires developer mode or admin)")]
69 File,
70 #[cfg_attr(windows, doc = "(requires developer mode or admin)")]
72 Directory,
73 #[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 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 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 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
426fn 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 #[cfg(windows)]
472 let expected_target_canonicalized: Option<PathBuf> = match symlink_mode {
473 Some(SymlinkMode::Junction) => {
474 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 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 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 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 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 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}