1#![deny(unsafe_code)]
24#![deny(missing_docs)]
25
26use crossbeam::{
27 deque::{Injector, Steal, Stealer, Worker},
28 sync::{Parker, Unparker},
29};
30use std::{
31 collections::HashMap,
32 io,
33 num::NonZeroU32,
34 path::{Path, PathBuf},
35 sync::{
36 Arc,
37 atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering},
38 mpsc::{Receiver, SyncSender, sync_channel},
39 },
40 thread,
41};
42
43#[cfg(any(not(any(windows, target_os = "macos")), test))]
44use std::{ffi::OsString, fs};
45
46#[cfg(not(any(windows, target_os = "macos")))]
47pub use std::fs::{FileType, Metadata};
48
49#[cfg(target_os = "macos")]
50#[allow(unsafe_code)]
51mod macos;
52
53#[cfg(windows)]
54#[allow(unsafe_code)]
55mod windows;
56
57#[cfg(target_os = "macos")]
58pub use macos::{Entry, FileType, Metadata};
59
60#[cfg(target_os = "macos")]
61use macos::ReadDir as NativeReadDir;
62
63#[cfg(windows)]
64pub use windows::{Entry, FileType, Metadata};
65
66#[cfg(windows)]
67use windows::ReadDir as NativeReadDir;
68
69type Descend = dyn Fn(usize, &Entry) -> bool + Send + Sync;
72type Batch = io::Result<Vec<io::Result<Entry>>>;
76const ENTRY_CHUNK_SIZE: usize = 4;
79
80#[derive(Clone, Copy)]
82pub enum Order {
83 Completion,
85 ParentFirst,
87}
88
89#[derive(Clone, Copy, Debug, Default)]
91pub struct Options {
92 #[cfg(target_os = "macos")]
94 pub apfs_clone_metadata: bool,
95}
96
97#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
99pub struct DirectoryId(NonZeroU32);
100
101impl DirectoryId {
102 fn new(index: usize) -> Self {
103 let stored = u32::try_from(index)
104 .ok()
105 .and_then(|index| index.checked_add(1))
106 .and_then(NonZeroU32::new)
107 .expect("directory identifier overflow");
108 Self(stored)
109 }
110
111 #[must_use]
113 pub fn index(self) -> usize {
114 (self.0.get() - 1) as usize
115 }
116}
117
118#[cfg(not(any(windows, target_os = "macos")))]
120pub struct Entry {
121 pub depth: usize,
123 pub file_name: OsString,
125 pub file_type: FileType,
127 pub metadata: io::Result<Metadata>,
129 pub parent_path: Arc<Path>,
131 pub directory_id: Option<DirectoryId>,
133 pub parent_directory_id: Option<DirectoryId>,
135}
136
137enum Job {
138 ReadDir {
140 root_idx: usize,
141 path: Arc<Path>,
142 directory_id: usize,
144 entry_depth: usize,
147 },
148 #[cfg(not(any(windows, target_os = "macos")))]
150 StatCompletion {
151 root_idx: usize,
152 path: Arc<Path>,
153 directory_id: usize,
155 entry_depth: usize,
157 entries: Vec<fs::DirEntry>,
158 },
159}
160
161impl Job {
162 fn root_idx(&self) -> usize {
164 match self {
165 Job::ReadDir { root_idx, .. } => *root_idx,
166 #[cfg(not(any(windows, target_os = "macos")))]
167 Job::StatCompletion { root_idx, .. } => *root_idx,
168 }
169 }
170}
171
172enum Event {
174 Batch {
175 root_idx: usize,
176 batch: Batch,
177 },
178 RootFinished {
181 root_idx: usize,
182 },
183 Finished,
185}
186
187pub enum RootEvent {
193 Entry(io::Result<Entry>),
195 Finished,
197}
198
199struct PoolShared {
200 injector: Injector<Job>,
202 stealers: Vec<Stealer<Job>>,
203 stop: AtomicBool,
204 descend: Arc<Descend>,
205 events: SyncSender<Event>,
206 active_roots: AtomicUsize,
208 jobs_per_root: HashMap<usize, AtomicUsize>,
211 order: Order,
212 #[cfg(any(windows, target_os = "macos"))]
213 options: Options,
214 unparkers: Vec<Unparker>,
216 idle: Vec<AtomicBool>,
219 next_wake: AtomicUsize,
221 next_directory_id: AtomicUsize,
223}
224
225struct Pool {
226 shared: Arc<PoolShared>,
227 events: Receiver<Event>,
228 handles: Vec<thread::JoinHandle<()>>,
229}
230
231pub struct RootWalk {
234 next: Vec<(usize, RootEvent)>,
236 pool: Option<Pool>,
238}
239
240pub struct Walk {
243 next: Vec<io::Result<Entry>>,
250 pool: Option<Pool>,
254 root: PathBuf,
255 options: Options,
256 finished: bool,
258}
259
260#[cfg(any(windows, target_os = "macos"))]
266pub fn read_dir(
267 path: &Path,
268 options: Options,
269) -> io::Result<impl Iterator<Item = io::Result<Entry>>> {
270 NativeReadDir::open(Arc::from(path), 0, options)
271}
272
273pub fn walk(
277 root: &Path,
278 threads: usize,
279 order: Order,
280 options: Options,
281 descend: impl Fn(&Entry) -> bool + Send + Sync + 'static,
282) -> Walk {
283 let root_path = root.to_owned();
284 let mut root = Entry::from_path(root, options);
285 if let Ok(entry) = &mut root
286 && entry.file_type.is_dir()
287 {
288 entry.directory_id = Some(DirectoryId::new(0));
289 }
290 let pool = match &root {
291 Ok(entry) if entry.file_type.is_dir() && descend(entry) => {
292 let path = Arc::from(entry.path());
293 let pool = start_pool(
294 threads.max(1),
295 HashMap::from([(0, AtomicUsize::new(0))]),
296 order,
297 Arc::new(move |_, entry| descend(entry)),
298 options,
299 1,
300 );
301 start_jobs(
302 &pool,
303 vec![Job::ReadDir {
304 root_idx: 0,
305 path,
306 directory_id: 0,
307 entry_depth: 1,
308 }],
309 );
310 Some(pool)
311 }
312 _ => None,
313 };
314 Walk {
315 next: vec![root],
316 pool,
317 root: root_path,
318 options,
319 finished: false,
320 }
321}
322
323impl Walk {
324 #[must_use]
328 pub fn restart(&mut self) -> bool {
329 if !self.finished || !self.next.is_empty() {
330 return false;
331 }
332 let Some(pool) = self.pool.as_ref() else {
333 return false;
334 };
335 let mut root = Entry::from_path(&self.root, self.options);
336 if let Ok(entry) = &mut root
337 && entry.file_type.is_dir()
338 {
339 entry.directory_id = Some(DirectoryId::new(0));
340 }
341 let job = match &root {
342 Ok(entry) if entry.file_type.is_dir() && (pool.shared.descend)(0, entry) => {
343 Some(Job::ReadDir {
344 root_idx: 0,
345 path: Arc::from(entry.path()),
346 directory_id: 0,
347 entry_depth: 1,
348 })
349 }
350 _ => None,
351 };
352 self.next.push(root);
353 if let Some(job) = job {
354 pool.shared
355 .next_directory_id
356 .store(1, AtomicOrdering::Relaxed);
357 self.finished = false;
358 start_jobs(pool, vec![job]);
359 }
360 true
361 }
362}
363
364impl Iterator for Walk {
365 type Item = io::Result<Entry>;
366
367 fn next(&mut self) -> Option<Self::Item> {
368 loop {
369 if let Some(entry) = self.next.pop() {
370 return Some(entry);
371 }
372 if self.finished {
373 return None;
374 }
375
376 match self.pool.as_ref()?.events.recv() {
377 Ok(Event::Batch {
378 batch: Ok(entries), ..
379 }) => {
380 self.next.extend(entries.into_iter().rev());
381 }
382 Ok(Event::Batch {
383 batch: Err(err), ..
384 }) => return Some(Err(err)),
385 Ok(Event::RootFinished { .. }) => {}
386 Ok(Event::Finished) => {
387 self.finished = true;
388 return None;
389 }
390 Err(_) => return Some(Err(io::Error::other("directory worker stopped"))),
391 }
392 }
393 }
394}
395
396pub fn walk_roots(
406 roots: impl IntoIterator<Item = (usize, PathBuf)>,
407 threads: usize,
408 order: Order,
409 options: Options,
410 descend: impl Fn(usize, &Entry) -> bool + Send + Sync + 'static,
411) -> RootWalk {
412 start_root_walk(
413 roots.into_iter().collect(),
414 threads,
415 order,
416 descend,
417 |path: PathBuf| Entry::from_path(&path, options),
418 options,
419 )
420}
421
422pub fn walk_root_entries(
433 roots: impl IntoIterator<Item = (usize, io::Result<Entry>)>,
434 threads: usize,
435 order: Order,
436 options: Options,
437 descend: impl Fn(usize, &Entry) -> bool + Send + Sync + 'static,
438) -> RootWalk {
439 start_root_walk(
440 roots.into_iter().collect(),
441 threads,
442 order,
443 descend,
444 std::convert::identity,
445 options,
446 )
447}
448
449fn start_root_walk<Root>(
450 roots: Vec<(usize, Root)>,
451 threads: usize,
452 order: Order,
453 descend: impl Fn(usize, &Entry) -> bool + Send + Sync + 'static,
454 prepare: impl Fn(Root) -> io::Result<Entry>,
455 options: Options,
456) -> RootWalk {
457 let jobs_per_root = roots
458 .iter()
459 .map(|(root_idx, _)| (*root_idx, AtomicUsize::new(0)))
460 .collect::<HashMap<_, _>>();
461 assert_eq!(
462 jobs_per_root.len(),
463 roots.len(),
464 "root indices must be unique"
465 );
466 let descend = Arc::new(descend);
467 let (next, root_jobs, next_directory_id) = begin_walks(
468 roots
469 .into_iter()
470 .map(|(root_idx, root)| (root_idx, prepare(root))),
471 descend.as_ref(),
472 );
473 let pool = if root_jobs.is_empty() {
474 None
475 } else {
476 let pool = start_pool(
477 threads.max(1),
478 jobs_per_root,
479 order,
480 descend,
481 options,
482 next_directory_id,
483 );
484 start_jobs(&pool, root_jobs);
485 Some(pool)
486 };
487 RootWalk { next, pool }
488}
489
490impl Iterator for RootWalk {
491 type Item = (usize, RootEvent);
492
493 fn next(&mut self) -> Option<Self::Item> {
494 loop {
495 if let Some(entry) = self.next.pop() {
496 return Some(entry);
497 }
498 match self.pool.as_ref()?.events.recv() {
499 Ok(Event::Batch {
500 root_idx,
501 batch: Ok(entries),
502 }) => self.next.extend(
503 entries
504 .into_iter()
505 .rev()
506 .map(|entry| (root_idx, RootEvent::Entry(entry))),
507 ),
508 Ok(Event::Batch {
509 root_idx,
510 batch: Err(err),
511 }) => return Some((root_idx, RootEvent::Entry(Err(err)))),
512 Ok(Event::RootFinished { root_idx }) => {
513 return Some((root_idx, RootEvent::Finished));
514 }
515 Ok(Event::Finished) => {
516 self.pool = None;
517 return None;
518 }
519 Err(_) => {
520 return Some((
521 0,
522 RootEvent::Entry(Err(io::Error::other("directory worker stopped"))),
523 ));
524 }
525 }
526 }
527 }
528}
529
530impl PoolShared {
531 fn wake_worker(&self) {
533 let len = self.idle.len();
534 let start = self.next_wake.fetch_add(1, AtomicOrdering::Relaxed) % len;
537 for offset in 0..len {
538 let idx = (start + offset) % len;
539 if self.idle[idx]
540 .compare_exchange(true, false, AtomicOrdering::AcqRel, AtomicOrdering::Relaxed)
541 .is_ok()
542 {
543 self.unparkers[idx].unpark();
544 break;
545 }
546 }
547 }
548
549 fn wake_workers(&self) {
551 for unparker in &self.unparkers {
552 unparker.unpark();
553 }
554 }
555}
556
557#[cfg(not(any(windows, target_os = "macos")))]
558impl Entry {
559 #[must_use]
561 pub fn path(&self) -> PathBuf {
562 self.parent_path.join(&self.file_name)
563 }
564
565 pub fn from_path(path: &Path, _options: Options) -> io::Result<Self> {
567 let metadata = fs::symlink_metadata(path)?;
568 Ok(Self {
569 depth: 0,
570 file_name: path.file_name().unwrap_or(path.as_os_str()).to_owned(),
571 file_type: metadata.file_type(),
572 metadata: Ok(metadata),
573 parent_path: Arc::from(path.parent().unwrap_or(Path::new(""))),
574 directory_id: None,
575 parent_directory_id: None,
576 })
577 }
578
579 fn from_dir_entry(
580 depth: usize,
581 parent_path: Arc<Path>,
582 entry: fs::DirEntry,
583 ) -> io::Result<Self> {
584 Ok(Self {
585 depth,
586 file_name: entry.file_name(),
587 file_type: entry.file_type()?,
588 metadata: entry.metadata(),
589 parent_path,
590 directory_id: None,
591 parent_directory_id: None,
592 })
593 }
594}
595
596fn start_pool(
597 threads: usize,
598 jobs_per_root: HashMap<usize, AtomicUsize>,
599 order: Order,
600 descend: Arc<Descend>,
601 options: Options,
602 next_directory_id: usize,
603) -> Pool {
604 #[cfg(not(any(windows, target_os = "macos")))]
605 let _ = options;
606 let workers: Vec<_> = (0..threads).map(|_| Worker::new_lifo()).collect();
607 let parkers: Vec<_> = (0..threads).map(|_| Parker::new()).collect();
608 let (event_tx, event_rx) = sync_channel(threads * 2);
609 let shared = Arc::new(PoolShared {
610 injector: Injector::new(),
611 stealers: workers.iter().map(Worker::stealer).collect(),
612 stop: AtomicBool::new(false),
613 descend,
614 events: event_tx,
615 active_roots: AtomicUsize::new(0),
616 jobs_per_root,
617 order,
618 #[cfg(any(windows, target_os = "macos"))]
619 options,
620 unparkers: parkers
621 .iter()
622 .map(|parker| parker.unparker().clone())
623 .collect(),
624 idle: (0..threads).map(|_| AtomicBool::new(false)).collect(),
625 next_wake: AtomicUsize::new(0),
626 next_directory_id: AtomicUsize::new(next_directory_id),
627 });
628 let handles: Vec<_> = workers
629 .into_iter()
630 .zip(parkers)
631 .enumerate()
632 .map(|(idx, (worker, parker))| {
633 let shared = Arc::clone(&shared);
634 thread::Builder::new()
635 .name(format!("dua-fs-walk-{idx}"))
636 .spawn(move || worker_loop(idx, worker, parker, shared))
637 .expect("filesystem worker thread can be spawned")
638 })
639 .collect();
640
641 Pool {
642 shared,
643 events: event_rx,
644 handles,
645 }
646}
647
648fn begin_walks(
651 roots: impl IntoIterator<Item = (usize, io::Result<Entry>)>,
652 descend: &Descend,
653) -> (Vec<(usize, RootEvent)>, Vec<Job>, usize) {
654 let mut next = Vec::new();
655 let mut jobs = Vec::new();
656 let mut next_directory_id = 0;
657 for (root_idx, mut entry) in roots {
658 if let Ok(entry) = &mut entry {
659 entry.depth = 0;
660 entry.parent_directory_id = None;
661 if entry.file_type.is_dir() {
662 entry.directory_id = Some(DirectoryId::new(next_directory_id));
663 next_directory_id += 1;
664 } else {
665 entry.directory_id = None;
666 }
667 }
668 let has_job = if let Ok(entry) = &entry
669 && entry.metadata.is_ok()
670 && entry.file_type.is_dir()
671 && descend(root_idx, entry)
672 {
673 let directory_id = entry
674 .directory_id
675 .expect("directory roots receive an identifier");
676 jobs.push(Job::ReadDir {
677 root_idx,
678 path: Arc::from(entry.path()),
679 directory_id: directory_id.index(),
680 entry_depth: 1,
681 });
682 true
683 } else {
684 false
685 };
686 next.push((root_idx, RootEvent::Entry(entry)));
687 if !has_job {
688 next.push((root_idx, RootEvent::Finished));
689 }
690 }
691 next.reverse();
692 (next, jobs, next_directory_id)
693}
694
695fn start_jobs(pool: &Pool, root_jobs: Vec<Job>) {
698 let wake_all = root_jobs.len() > 1;
699 debug_assert_eq!(
700 pool.shared.active_roots.load(AtomicOrdering::Relaxed),
701 0,
702 "initial jobs must be started on an idle pool"
703 );
704 debug_assert!(
705 root_jobs.iter().all(|j| match j {
706 Job::ReadDir { entry_depth, .. } => *entry_depth,
707 #[cfg(not(any(windows, target_os = "macos")))]
708 Job::StatCompletion { entry_depth, .. } => *entry_depth,
709 } == 1),
710 "the first jobs should be root jobs, so active_root counts match"
711 );
712 pool.shared
713 .active_roots
714 .store(root_jobs.len(), AtomicOrdering::Relaxed);
715 for job in &root_jobs {
716 add_pending(job.root_idx(), 1, &pool.shared);
717 }
718 for job in root_jobs {
719 pool.shared.injector.push(job);
720 }
721 if wake_all {
722 pool.shared.wake_workers();
723 } else {
724 pool.shared.wake_worker();
725 }
726}
727
728fn worker_loop(idx: usize, worker: Worker<Job>, parker: Parker, shared: Arc<PoolShared>) {
729 while !shared.stop.load(AtomicOrdering::Relaxed) {
730 let found = if let Some(found) = find_job(&worker, &shared) {
731 found
732 } else {
733 shared.idle[idx].store(true, AtomicOrdering::Release);
734 let Some(found) = find_job(&worker, &shared) else {
735 parker.park();
736 shared.idle[idx].store(false, AtomicOrdering::Release);
737 continue;
738 };
739 shared.idle[idx].store(false, AtomicOrdering::Release);
740 found
741 };
742 let (job, stolen) = found;
743 if stolen {
744 shared.wake_worker();
747 }
748 run_job(job, &worker, &shared);
749 }
750}
751
752impl Drop for Pool {
753 fn drop(&mut self) {
754 self.shared.stop.store(true, AtomicOrdering::Relaxed);
755 self.shared.wake_workers();
756 for handle in self.handles.drain(..) {
757 handle.join().ok();
758 }
759 }
760}
761
762fn find_job(worker: &Worker<Job>, shared: &PoolShared) -> Option<(Job, bool)> {
773 loop {
774 if let Some(job) = worker.pop() {
775 return Some((job, false));
776 }
777
778 match shared.injector.steal_batch_and_pop(worker) {
779 Steal::Success(job) => return Some((job, false)),
780 Steal::Retry => continue,
781 Steal::Empty => {}
782 }
783
784 let mut retry = false;
785 for stealer in &shared.stealers {
786 match stealer.steal() {
787 Steal::Success(job) => return Some((job, true)),
788 Steal::Retry => retry = true,
789 Steal::Empty => {}
790 }
791 }
792 if !retry {
793 return None;
794 }
795 }
796}
797
798fn run_job(job: Job, worker: &Worker<Job>, shared: &PoolShared) {
799 match job {
800 Job::ReadDir {
801 root_idx: root,
802 path,
803 directory_id,
804 entry_depth,
805 } => {
806 if matches!(shared.order, Order::Completion) {
807 read_dir_completion(root, path, directory_id, entry_depth, worker, shared);
808 } else {
809 read_dir_parent_first(root, path, directory_id, entry_depth, worker, shared);
810 }
811 }
812 #[cfg(not(any(windows, target_os = "macos")))]
813 Job::StatCompletion {
814 root_idx: root,
815 path,
816 directory_id,
817 entry_depth,
818 entries,
819 } => stat_entries_completion(
820 root,
821 path,
822 directory_id,
823 entry_depth,
824 entries,
825 worker,
826 shared,
827 ),
828 }
829}
830
831#[cfg(not(any(windows, target_os = "macos")))]
837fn read_dir_completion(
838 root_idx: usize,
839 path: Arc<Path>,
840 directory_id: usize,
841 entry_depth: usize,
842 worker: &Worker<Job>,
843 shared: &PoolShared,
844) {
845 let dir_entries = match fs::read_dir(&path) {
846 Ok(entries) => entries,
847 Err(err) => {
848 if shared
849 .events
850 .send(Event::Batch {
851 root_idx,
852 batch: Err(err),
853 })
854 .is_err()
855 {
856 shared.stop.store(true, AtomicOrdering::Relaxed);
857 }
858 finish_pending(root_idx, shared);
859 return;
860 }
861 };
862 let mut chunk = Vec::with_capacity(ENTRY_CHUNK_SIZE);
863 let mut errors = Vec::new();
864 let mut has_jobs = false;
865 for entry in dir_entries {
866 match entry {
867 Ok(entry) => {
868 chunk.push(entry);
869 if chunk.len() == ENTRY_CHUNK_SIZE {
870 add_pending(root_idx, 1, shared);
871 worker.push(Job::StatCompletion {
872 root_idx,
873 path: Arc::clone(&path),
874 directory_id,
875 entry_depth,
876 entries: std::mem::replace(
877 &mut chunk,
878 Vec::with_capacity(ENTRY_CHUNK_SIZE),
879 ),
880 });
881 has_jobs = true;
882 }
883 }
884 Err(err) => errors.push(Err(err)),
885 }
886 }
887 if !chunk.is_empty() {
888 add_pending(root_idx, 1, shared);
889 worker.push(Job::StatCompletion {
890 root_idx,
891 path,
892 directory_id,
893 entry_depth,
894 entries: chunk,
895 });
896 has_jobs = true;
897 }
898 if has_jobs {
899 shared.wake_worker();
900 }
901 if !errors.is_empty()
902 && shared
903 .events
904 .send(Event::Batch {
905 root_idx,
906 batch: Ok(errors),
907 })
908 .is_err()
909 {
910 shared.stop.store(true, AtomicOrdering::Relaxed);
911 }
912 finish_pending(root_idx, shared);
913}
914
915#[cfg(any(windows, target_os = "macos"))]
917fn native_read_dir(
918 path: Arc<Path>,
919 depth: usize,
920 shared: &PoolShared,
921) -> io::Result<NativeReadDir> {
922 NativeReadDir::open(path, depth, shared.options)
923}
924
925#[cfg(any(windows, target_os = "macos"))]
931fn read_dir_completion(
932 root_idx: usize,
933 path: Arc<Path>,
934 directory_id: usize,
935 depth: usize,
936 worker: &Worker<Job>,
937 shared: &PoolShared,
938) {
939 let dir_entries = match native_read_dir(path, depth, shared) {
940 Ok(entries) => entries,
941 Err(err) => {
942 if shared
943 .events
944 .send(Event::Batch {
945 root_idx,
946 batch: Err(err),
947 })
948 .is_err()
949 {
950 shared.stop.store(true, AtomicOrdering::Relaxed);
951 }
952 finish_pending(root_idx, shared);
953 return;
954 }
955 };
956 let mut entries = Vec::with_capacity(ENTRY_CHUNK_SIZE);
957 let mut jobs = Vec::new();
958 for mut entry in dir_entries {
959 if let Ok(entry) = &mut entry {
960 assign_directory_ids(entry, directory_id, shared);
961 }
962 if let Ok(entry) = &entry
963 && entry.file_type.is_dir()
964 && (shared.descend)(root_idx, entry)
965 {
966 jobs.push(Job::ReadDir {
967 root_idx,
968 path: Arc::from(entry.path()),
969 directory_id: entry
970 .directory_id
971 .expect("directories receive an identifier")
972 .index(),
973 entry_depth: depth + 1,
974 });
975 }
976 entries.push(entry);
977 if entries.len() == ENTRY_CHUNK_SIZE
978 && !publish_completion_batch(root_idx, &mut entries, &mut jobs, worker, shared)
979 {
980 finish_pending(root_idx, shared);
981 return;
982 }
983 }
984 if !entries.is_empty() {
985 publish_completion_batch(root_idx, &mut entries, &mut jobs, worker, shared);
986 }
987 finish_pending(root_idx, shared);
988}
989
990#[cfg(any(windows, target_os = "macos"))]
991fn publish_completion_batch(
992 root_idx: usize,
993 entries: &mut Vec<io::Result<Entry>>,
994 jobs: &mut Vec<Job>,
995 worker: &Worker<Job>,
996 shared: &PoolShared,
997) -> bool {
998 add_pending(root_idx, jobs.len(), shared);
999 schedule_jobs(std::mem::take(jobs), worker, shared);
1000 if shared
1001 .events
1002 .send(Event::Batch {
1003 root_idx,
1004 batch: Ok(std::mem::replace(
1005 entries,
1006 Vec::with_capacity(ENTRY_CHUNK_SIZE),
1007 )),
1008 })
1009 .is_err()
1010 {
1011 shared.stop.store(true, AtomicOrdering::Relaxed);
1012 false
1013 } else {
1014 true
1015 }
1016}
1017
1018#[cfg(any(windows, target_os = "macos"))]
1019fn read_dir_parent_first(
1020 root_idx: usize,
1021 path: Arc<Path>,
1022 directory_id: usize,
1023 depth: usize,
1024 worker: &Worker<Job>,
1025 shared: &PoolShared,
1026) {
1027 let dir_entries = match native_read_dir(path, depth, shared) {
1028 Ok(entries) => entries,
1029 Err(err) => {
1030 finish_directory(root_idx, Err(err), Vec::new(), worker, shared);
1031 return;
1032 }
1033 };
1034 let mut jobs = Vec::new();
1035 let entries = dir_entries
1036 .map(|entry| {
1037 entry.map(|mut entry| {
1038 assign_directory_ids(&mut entry, directory_id, shared);
1039 if entry.file_type.is_dir() && (shared.descend)(root_idx, &entry) {
1040 jobs.push(Job::ReadDir {
1041 root_idx,
1042 path: Arc::from(entry.path()),
1043 directory_id: entry
1044 .directory_id
1045 .expect("directories receive an identifier")
1046 .index(),
1047 entry_depth: depth + 1,
1048 });
1049 }
1050 entry
1051 })
1052 })
1053 .collect();
1054 finish_directory(root_idx, Ok(entries), jobs, worker, shared);
1055}
1056
1057#[cfg(not(any(windows, target_os = "macos")))]
1058fn stat_entries_completion(
1059 root_idx: usize,
1060 path: Arc<Path>,
1061 directory_id: usize,
1062 depth: usize,
1063 entries: Vec<fs::DirEntry>,
1064 worker: &Worker<Job>,
1065 shared: &PoolShared,
1066) {
1067 let mut jobs = Vec::new();
1068 let entries = entries
1069 .into_iter()
1070 .map(|entry| {
1071 Entry::from_dir_entry(depth, Arc::clone(&path), entry).map(|mut entry| {
1072 assign_directory_ids(&mut entry, directory_id, shared);
1073 if entry.file_type.is_dir() && (shared.descend)(root_idx, &entry) {
1074 jobs.push(Job::ReadDir {
1075 root_idx,
1076 path: Arc::from(entry.path()),
1077 directory_id: entry
1078 .directory_id
1079 .expect("directories receive an identifier")
1080 .index(),
1081 entry_depth: entry.depth + 1,
1082 });
1083 }
1084 entry
1085 })
1086 })
1087 .collect();
1088 add_pending(root_idx, jobs.len(), shared);
1089 schedule_jobs(jobs, worker, shared);
1090 if shared
1091 .events
1092 .send(Event::Batch {
1093 root_idx,
1094 batch: Ok(entries),
1095 })
1096 .is_err()
1097 {
1098 shared.stop.store(true, AtomicOrdering::Relaxed);
1099 }
1100 finish_pending(root_idx, shared);
1101}
1102
1103#[cfg(not(any(windows, target_os = "macos")))]
1111fn read_dir_parent_first(
1112 root_idx: usize,
1113 path: Arc<Path>,
1114 directory_id: usize,
1115 depth: usize,
1116 worker: &Worker<Job>,
1117 shared: &PoolShared,
1118) {
1119 read_dir_inline(root_idx, path, directory_id, depth, worker, shared);
1120}
1121
1122#[cfg(not(any(windows, target_os = "macos")))]
1126fn read_dir_inline(
1127 root_idx: usize,
1128 path: Arc<Path>,
1129 directory_id: usize,
1130 depth: usize,
1131 worker: &Worker<Job>,
1132 shared: &PoolShared,
1133) {
1134 let dir_entries = match fs::read_dir(&path) {
1135 Ok(entries) => entries,
1136 Err(err) => {
1137 finish_directory(root_idx, Err(err), Vec::new(), worker, shared);
1138 return;
1139 }
1140 };
1141 let mut jobs = Vec::new();
1142 let entries = dir_entries
1143 .map(|entry| {
1144 entry
1145 .and_then(|entry| Entry::from_dir_entry(depth, Arc::clone(&path), entry))
1146 .map(|mut entry| {
1147 assign_directory_ids(&mut entry, directory_id, shared);
1148 if entry.file_type.is_dir() && (shared.descend)(root_idx, &entry) {
1149 jobs.push(Job::ReadDir {
1150 root_idx,
1151 path: Arc::from(entry.path()),
1152 directory_id: entry
1153 .directory_id
1154 .expect("directories receive an identifier")
1155 .index(),
1156 entry_depth: depth + 1,
1157 });
1158 }
1159 entry
1160 })
1161 })
1162 .collect();
1163 finish_directory(root_idx, Ok(entries), jobs, worker, shared);
1164}
1165
1166fn assign_directory_ids(entry: &mut Entry, parent_directory_id: usize, shared: &PoolShared) {
1167 entry.parent_directory_id = Some(DirectoryId::new(parent_directory_id));
1168 entry.directory_id = entry.file_type.is_dir().then(|| {
1169 DirectoryId::new(
1170 shared
1171 .next_directory_id
1172 .fetch_update(AtomicOrdering::Relaxed, AtomicOrdering::Relaxed, |id| {
1173 id.checked_add(1)
1174 })
1175 .expect("directory identifier overflow"),
1176 )
1177 });
1178}
1179
1180fn finish_directory(
1184 root_idx: usize,
1185 batch: Batch,
1186 jobs: Vec<Job>,
1187 worker: &Worker<Job>,
1188 shared: &PoolShared,
1189) {
1190 add_pending(root_idx, jobs.len(), shared);
1191
1192 match shared.order {
1193 Order::ParentFirst => {
1194 if shared
1195 .events
1196 .send(Event::Batch { root_idx, batch })
1197 .is_err()
1198 {
1199 shared.stop.store(true, AtomicOrdering::Relaxed);
1200 return;
1201 }
1202 schedule_jobs(jobs, worker, shared);
1203 }
1204 Order::Completion => {
1205 schedule_jobs(jobs, worker, shared);
1206 if shared
1207 .events
1208 .send(Event::Batch { root_idx, batch })
1209 .is_err()
1210 {
1211 shared.stop.store(true, AtomicOrdering::Relaxed);
1212 return;
1213 }
1214 }
1215 }
1216
1217 finish_pending(root_idx, shared);
1218}
1219
1220fn add_pending(root: usize, count: usize, shared: &PoolShared) {
1221 shared.jobs_per_root[&root].fetch_add(count, AtomicOrdering::Relaxed);
1222}
1223
1224fn finish_pending(root_idx: usize, shared: &PoolShared) {
1227 if shared.jobs_per_root[&root_idx].fetch_sub(1, AtomicOrdering::Relaxed) == 1 {
1228 shared.events.send(Event::RootFinished { root_idx }).ok();
1229 if shared.active_roots.fetch_sub(1, AtomicOrdering::Relaxed) == 1 {
1230 shared.events.send(Event::Finished).ok();
1231 }
1232 }
1233}
1234
1235fn schedule_jobs(jobs: Vec<Job>, worker: &Worker<Job>, shared: &PoolShared) {
1236 let has_jobs = !jobs.is_empty();
1237 for job in jobs {
1238 worker.push(job);
1239 }
1240 if has_jobs {
1241 shared.wake_worker();
1242 }
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247 use super::*;
1248
1249 #[test]
1250 fn directory_ids_are_compact_dense_and_match_parents() {
1251 assert_eq!(size_of::<Option<DirectoryId>>(), 4);
1252
1253 let dir = tempfile::tempdir().unwrap();
1254 fs::create_dir_all(dir.path().join("a/child")).unwrap();
1255 fs::create_dir(dir.path().join("b")).unwrap();
1256 fs::write(dir.path().join("a/file"), b"x").unwrap();
1257 fs::write(dir.path().join("file"), b"x").unwrap();
1258
1259 let entries = walk(
1260 dir.path(),
1261 4,
1262 Order::ParentFirst,
1263 Options::default(),
1264 |_| true,
1265 )
1266 .map(Result::unwrap)
1267 .collect::<Vec<_>>();
1268 let directories = entries
1269 .iter()
1270 .filter_map(|entry| entry.directory_id.map(|id| (entry.path(), id)))
1271 .collect::<HashMap<_, _>>();
1272 let mut ids = directories
1273 .values()
1274 .map(|id| id.index())
1275 .collect::<Vec<_>>();
1276 ids.sort_unstable();
1277 assert_eq!(ids, (0..directories.len()).collect::<Vec<_>>());
1278
1279 for entry in entries {
1280 assert_eq!(entry.directory_id.is_some(), entry.file_type.is_dir());
1281 if entry.depth == 0 {
1282 assert_eq!(entry.parent_directory_id, None);
1283 } else {
1284 assert_eq!(
1285 entry.parent_directory_id,
1286 directories.get(entry.path().parent().unwrap()).copied()
1287 );
1288 }
1289 }
1290 }
1291
1292 #[test]
1293 fn parallel_walk_is_parent_first_and_does_not_follow_symlinks() {
1294 let dir = tempfile::tempdir().unwrap();
1295 fs::create_dir_all(dir.path().join("b/child")).unwrap();
1296 fs::create_dir(dir.path().join("a")).unwrap();
1297 fs::write(dir.path().join("b/child/file"), b"x").unwrap();
1298
1299 #[cfg(unix)]
1300 std::os::unix::fs::symlink(dir.path().join("b"), dir.path().join("link")).unwrap();
1301
1302 #[cfg(unix)]
1303 let expected = ["", "a", "b", "b/child", "b/child/file", "link"];
1304 #[cfg(not(unix))]
1305 let expected = ["", "a", "b", "b/child", "b/child/file"];
1306 let expected = expected.into_iter().map(PathBuf::from).collect::<Vec<_>>();
1307
1308 for threads in [1, 4] {
1309 let paths = walk(
1310 dir.path(),
1311 threads,
1312 Order::ParentFirst,
1313 Options::default(),
1314 |_| true,
1315 )
1316 .map(|entry| {
1317 entry
1318 .unwrap()
1319 .path()
1320 .strip_prefix(dir.path())
1321 .unwrap()
1322 .to_owned()
1323 })
1324 .collect::<Vec<_>>();
1325 let mut sorted_paths = paths.clone();
1326 sorted_paths.sort();
1327 assert_eq!(
1328 sorted_paths, expected,
1329 "walk with {threads} threads should visit every expected path exactly once"
1330 );
1331
1332 for path in paths.iter().filter(|path| path.components().count() > 1) {
1333 let parent = path.parent().unwrap();
1334 assert!(
1335 paths.iter().position(|path| path == parent)
1336 < paths.iter().position(|candidate| candidate == path),
1337 "parent {parent:?} should precede child {path:?} with {threads} threads; \
1338 traversal order: {paths:?}"
1339 );
1340 }
1341 }
1342 }
1343
1344 #[test]
1345 fn a_completed_walk_can_reuse_its_workers() {
1346 let dir = tempfile::tempdir().unwrap();
1347 fs::create_dir_all(dir.path().join("first/child")).unwrap();
1348
1349 let mut walk = walk(dir.path(), 2, Order::Completion, Options::default(), |_| {
1350 true
1351 });
1352 assert!(!walk.restart(), "an active walk cannot be restarted");
1353 let worker_ids = walk
1354 .pool
1355 .as_ref()
1356 .unwrap()
1357 .handles
1358 .iter()
1359 .map(|handle| handle.thread().id())
1360 .collect::<Vec<_>>();
1361 walk.by_ref().for_each(drop);
1362 assert!(walk.next().is_none(), "a completed walk stays exhausted");
1363
1364 fs::create_dir_all(dir.path().join("second/child")).unwrap();
1365 assert!(walk.restart());
1366 let paths = walk
1367 .by_ref()
1368 .map(|entry| {
1369 entry
1370 .unwrap()
1371 .path()
1372 .strip_prefix(dir.path())
1373 .unwrap()
1374 .into()
1375 })
1376 .collect::<Vec<PathBuf>>();
1377
1378 assert!(paths.contains(&PathBuf::from("second/child")));
1379 assert_eq!(
1380 walk.pool
1381 .as_ref()
1382 .unwrap()
1383 .handles
1384 .iter()
1385 .map(|handle| handle.thread().id())
1386 .collect::<Vec<_>>(),
1387 worker_ids,
1388 "a restarted walk keeps its original worker threads"
1389 );
1390 }
1391
1392 #[test]
1393 fn pruning_keeps_the_directory_and_missing_roots_are_errors() {
1394 let dir = tempfile::tempdir().unwrap();
1395 fs::create_dir_all(dir.path().join("skip/child")).unwrap();
1396
1397 let paths = walk(
1398 dir.path(),
1399 2,
1400 Order::Completion,
1401 Options::default(),
1402 |entry| entry.file_name != "skip",
1403 )
1404 .map(|entry| entry.unwrap().file_name)
1405 .collect::<Vec<_>>();
1406 assert_eq!(
1407 paths,
1408 vec![
1409 dir.path().file_name().unwrap().to_owned(),
1410 OsString::from("skip")
1411 ],
1412 "a pruned directory should be yielded without traversing its children"
1413 );
1414
1415 assert!(
1416 walk(
1417 &dir.path().join("missing"),
1418 2,
1419 Order::Completion,
1420 Options::default(),
1421 |_| true,
1422 )
1423 .next()
1424 .unwrap()
1425 .is_err(),
1426 "a missing root should be yielded as an I/O error"
1427 );
1428 }
1429
1430 #[test]
1431 fn concurrent_roots_keep_their_identity() {
1432 let dir = tempfile::tempdir().unwrap();
1433 let roots = [dir.path().join("a"), dir.path().join("b")];
1434 for root in &roots {
1435 fs::create_dir_all(root.join("child")).unwrap();
1436 }
1437
1438 let events = walk_roots(
1439 roots.iter().cloned().enumerate(),
1440 2,
1441 Order::Completion,
1442 Options::default(),
1443 |_, _| true,
1444 )
1445 .collect::<Vec<_>>();
1446 let mut paths = Vec::new();
1447 let mut last_entry = [0; 2];
1448 let mut finished = [None; 2];
1449 for (position, (root_idx, event)) in events.into_iter().enumerate() {
1450 match event {
1451 RootEvent::Entry(entry) => {
1452 last_entry[root_idx] = position;
1453 paths.push((
1454 root_idx,
1455 entry
1456 .unwrap()
1457 .path()
1458 .strip_prefix(&roots[root_idx])
1459 .unwrap()
1460 .to_owned(),
1461 ));
1462 }
1463 RootEvent::Finished => finished[root_idx] = Some(position),
1464 }
1465 }
1466 paths.sort();
1467 assert_eq!(
1468 paths,
1469 [
1470 (0, PathBuf::new()),
1471 (0, PathBuf::from("child")),
1472 (1, PathBuf::new()),
1473 (1, PathBuf::from("child")),
1474 ]
1475 );
1476 for root_idx in 0..roots.len() {
1477 assert!(
1478 last_entry[root_idx] < finished[root_idx].unwrap(),
1479 "root {root_idx} must finish after its last entry",
1480 );
1481 }
1482 }
1483
1484 #[test]
1485 fn prepared_roots_rebase_existing_descendants() {
1486 let directory = tempfile::tempdir().unwrap();
1487 let descendant = directory.path().join("descendant");
1488 fs::create_dir(&descendant).unwrap();
1489 let child = descendant.join("child");
1490 fs::write(&child, b"nested file").unwrap();
1491
1492 let descendant_entry = walk(
1493 directory.path(),
1494 2,
1495 Order::ParentFirst,
1496 Options::default(),
1497 |_| true,
1498 )
1499 .find_map(|entry| {
1500 let entry = entry.unwrap();
1501 (entry.path() == descendant).then_some(entry)
1502 })
1503 .expect("the initial walk should yield the descendant directory");
1504 assert_eq!(descendant_entry.depth, 1);
1505
1506 let mut events = walk_root_entries(
1507 [(7, Ok(descendant_entry))],
1508 2,
1509 Order::ParentFirst,
1510 Options::default(),
1511 |root_idx, entry| {
1512 assert_eq!(root_idx, 7);
1513 assert_eq!(entry.depth, 0, "the predicate should see a re-rooted entry");
1514 true
1515 },
1516 );
1517
1518 let Some((7, RootEvent::Entry(Ok(mut root)))) = events.next() else {
1519 panic!("the prepared descendant should be emitted as the new root");
1520 };
1521 assert_eq!(root.path(), descendant);
1522 assert_eq!(
1523 root.depth, 0,
1524 "the entry originally found at depth 1 must become the new traversal root"
1525 );
1526
1527 let Some((7, RootEvent::Entry(Ok(entry)))) = events.next() else {
1528 panic!("the re-rooted directory should emit its child");
1529 };
1530 assert_eq!(entry.path(), child);
1531 assert_eq!(
1532 entry.depth, 1,
1533 "the child depth must be relative to the prepared entry used as the new root"
1534 );
1535 assert_eq!(
1536 events
1537 .next()
1538 .map(|(root_idx, event)| (root_idx, matches!(event, RootEvent::Finished))),
1539 Some((7, true))
1540 );
1541 assert_eq!(
1542 events.next().map(|(root_idx, _)| root_idx),
1543 None,
1544 "nothing left after the Finished event"
1545 );
1546
1547 root.metadata = Err(io::Error::from(io::ErrorKind::PermissionDenied));
1548 let mut events = walk_root_entries(
1549 [(7, Ok(root))],
1550 2,
1551 Order::ParentFirst,
1552 Options::default(),
1553 |_, _| panic!("a directory with inaccessible metadata must not be descended"),
1554 );
1555 let Some((7, RootEvent::Entry(Ok(root)))) = events.next() else {
1556 panic!("the prepared directory must retain its metadata error");
1557 };
1558 let error = root
1559 .metadata
1560 .err()
1561 .expect("the inaccessible root must retain its metadata error");
1562 assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
1563 assert_eq!(
1564 events
1565 .next()
1566 .map(|(root_idx, event)| (root_idx, matches!(event, RootEvent::Finished))),
1567 Some((7, true))
1568 );
1569 assert_eq!(events.next().map(|(root_idx, _)| root_idx), None);
1570 }
1571
1572 #[cfg(any(windows, target_os = "macos"))]
1573 #[test]
1574 fn prepared_roots_reuse_native_directory_metadata() {
1575 let directory = tempfile::tempdir().unwrap();
1576 let path = directory.path().join("file");
1577 fs::write(&path, b"cached metadata").unwrap();
1578 let expected_len = fs::metadata(&path).unwrap().len();
1579
1580 let entry = read_dir(directory.path(), Options::default())
1581 .unwrap()
1582 .next()
1583 .unwrap()
1584 .unwrap();
1585 assert_eq!(entry.depth, 0);
1586 assert_eq!(entry.path(), path);
1587 fs::remove_file(&path).unwrap();
1588
1589 let mut events = walk_root_entries(
1590 [(7, Ok(entry))],
1591 1,
1592 Order::Completion,
1593 Options::default(),
1594 |_, _| true,
1595 );
1596 let Some((7, RootEvent::Entry(Ok(entry)))) = events.next() else {
1597 panic!(
1598 "prepared root must be yielded without querying its removed path which would fail"
1599 );
1600 };
1601 assert_eq!(entry.path(), path);
1602 assert_eq!(entry.metadata.unwrap().len(), expected_len);
1603 assert_eq!(
1604 events
1605 .next()
1606 .map(|(root_idx, event)| (root_idx, matches!(event, RootEvent::Finished))),
1607 Some((7, true))
1608 );
1609 assert_eq!(events.next().map(|(root_idx, _)| root_idx), None);
1610 }
1611
1612 #[test]
1613 fn wide_walk_wakes_multiple_idle_workers() {
1614 let dir = tempfile::tempdir().unwrap();
1615 for idx in 0..32 {
1616 fs::create_dir_all(dir.path().join(format!("{idx}/child"))).unwrap();
1617 }
1618
1619 let worker_threads = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
1620 let seen_threads = Arc::clone(&worker_threads);
1621 walk(
1622 dir.path(),
1623 8,
1624 Order::Completion,
1625 Options::default(),
1626 move |entry| {
1627 if entry.depth == 1 {
1628 thread::sleep(std::time::Duration::from_millis(1));
1629 } else if entry.depth == 2 {
1630 seen_threads.lock().unwrap().insert(thread::current().id());
1631 thread::sleep(std::time::Duration::from_millis(10));
1632 }
1633 true
1634 },
1635 )
1636 .for_each(drop);
1637
1638 assert!(
1639 worker_threads.lock().unwrap().len() >= 4,
1640 "a wide directory should engage more than the producer and one thief"
1641 );
1642 }
1643
1644 #[cfg(any(windows, target_os = "macos"))]
1645 #[test]
1646 fn native_metadata_is_collected_by_the_directory_worker() {
1647 let dir = tempfile::tempdir().unwrap();
1648 for idx in 0..32 {
1649 fs::create_dir(dir.path().join(idx.to_string())).unwrap();
1650 }
1651
1652 let worker_threads = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
1653 let seen_threads = Arc::clone(&worker_threads);
1654 walk(
1655 dir.path(),
1656 8,
1657 Order::Completion,
1658 Options::default(),
1659 move |entry| {
1660 if entry.depth == 1 {
1661 seen_threads.lock().unwrap().insert(thread::current().id());
1662 thread::sleep(std::time::Duration::from_millis(2));
1663 }
1664 true
1665 },
1666 )
1667 .for_each(drop);
1668
1669 assert_eq!(
1670 worker_threads.lock().unwrap().len(),
1671 1,
1672 "native directory-entry metadata should stay on the enumerating worker"
1673 );
1674 }
1675
1676 #[cfg(any(windows, target_os = "macos"))]
1677 #[test]
1678 fn native_completion_streams_metadata_before_enumeration_finishes() {
1679 let dir = tempfile::tempdir().unwrap();
1680 for idx in 0..=ENTRY_CHUNK_SIZE {
1681 fs::create_dir(dir.path().join(idx.to_string())).unwrap();
1682 }
1683
1684 let (continue_tx, continue_rx) = std::sync::mpsc::sync_channel(0);
1685 let continue_rx = Arc::new(std::sync::Mutex::new(continue_rx));
1686 let seen = Arc::new(AtomicUsize::new(0));
1687 let seen_in_worker = Arc::clone(&seen);
1688 let mut entries =
1689 walk(
1690 dir.path(),
1691 2,
1692 Order::Completion,
1693 Options::default(),
1694 move |entry| {
1695 if entry.depth == 1
1696 && seen_in_worker.fetch_add(1, AtomicOrdering::Relaxed) == ENTRY_CHUNK_SIZE
1697 {
1698 continue_rx
1699 .lock()
1700 .unwrap()
1701 .recv_timeout(std::time::Duration::from_secs(2))
1702 .expect("the first metadata batch should arrive before enumeration finishes");
1703 }
1704 true
1705 },
1706 );
1707
1708 assert_eq!(
1709 entries.next().unwrap().unwrap().depth,
1710 0,
1711 "the root entry should be yielded first"
1712 );
1713 assert_eq!(
1714 entries.next().unwrap().unwrap().depth,
1715 1,
1716 "the first metadata batch should be yielded before enumeration resumes"
1717 );
1718 continue_tx.send(()).unwrap();
1719 entries.for_each(drop);
1720 assert_eq!(
1721 seen.load(AtomicOrdering::Relaxed),
1722 ENTRY_CHUNK_SIZE + 1,
1723 "all directory entries should be inspected"
1724 );
1725 }
1726}