Skip to main content

dua_core/
lib.rs

1//! Parallel filesystem traversal backed by a work-stealing worker pool.
2//!
3//! [`walk`] yields the root first, then workers read directories and distribute newly discovered
4//! subdirectories among themselves. [`Order::ParentFirst`] publishes each directory's entries
5//! before scheduling its children, while [`Order::Completion`] allows descendant batches to arrive
6//! first when their reads finish sooner. Sibling order is unspecified in both modes.
7//!
8//! The `descend` predicate controls which directories are traversed; rejected directories are
9//! still yielded (but not traversed).
10//! Symbolic links are reported but never followed, and filesystem errors are
11//! returned as iterator items. Dropping the iterator stops and joins its workers.
12//!
13//! # Scheduling
14//!
15//! The root directory starts in a shared injector queue. On platforms where directory-entry
16//! metadata may require another syscall, directory reads enqueue small metadata batches, and
17//! metadata batches enqueue accepted child directories. Windows and macOS workers instead consume
18//! native metadata returned by directory enumeration and enqueue child directories immediately.
19//! Every worker can run available jobs from its local LIFO queue or steal from a peer. Each
20//! successful thief wakes another idle worker, ramping up only while work remains stealable. A
21//! worker parks when no queue has work and is unparked when new work arrives or the walk stops. The
22//! last completed job emits the finished event; dropping the iterator stops and joins all workers.
23#![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    path::{Path, PathBuf},
34    sync::{
35        Arc,
36        atomic::{AtomicBool, AtomicUsize, Ordering as AtomicOrdering},
37        mpsc::{Receiver, SyncSender, sync_channel},
38    },
39    thread,
40};
41
42#[cfg(any(not(any(windows, target_os = "macos")), test))]
43use std::{ffi::OsString, fs};
44
45#[cfg(not(any(windows, target_os = "macos")))]
46pub use std::fs::{FileType, Metadata};
47
48#[cfg(target_os = "macos")]
49#[allow(unsafe_code)]
50mod macos;
51
52#[cfg(windows)]
53#[allow(unsafe_code)]
54mod windows;
55
56#[cfg(target_os = "macos")]
57pub use macos::{Entry, FileType, Metadata};
58
59#[cfg(target_os = "macos")]
60use macos::ReadDir as NativeReadDir;
61
62#[cfg(windows)]
63pub use windows::{Entry, FileType, Metadata};
64
65#[cfg(windows)]
66use windows::ReadDir as NativeReadDir;
67
68/// Decides whether to traverse an entry's children for a given root index.
69/// Returning `false` prunes descendants but still emits the entry itself.
70type Descend = dyn Fn(usize, &Entry) -> bool + Send + Sync;
71/// Entries obtained from one directory read.
72/// An outer error means the directory could not be opened; inner errors come from reading or
73/// converting individual directory entries.
74type Batch = io::Result<Vec<io::Result<Entry>>>;
75/// Number of directory entries grouped into each metadata job or result batch.
76/// Small chunks expose parallel work and stream wide directories while amortizing queue overhead.
77const ENTRY_CHUNK_SIZE: usize = 4;
78
79/// Controls when entries are yielded relative to their descendants.
80#[derive(Clone, Copy)]
81pub enum Order {
82    /// Yield entries as their parent-directory reads complete.
83    Completion,
84    /// Yield every parent before its descendants.
85    ParentFirst,
86}
87
88/// Platform-specific filesystem metadata requested during traversal.
89#[derive(Clone, Copy, Debug, Default)]
90pub struct Options {
91    /// Collect APFS clone identity and data-fork allocation metadata.
92    #[cfg(target_os = "macos")]
93    pub apfs_clone_metadata: bool,
94}
95
96/// A filesystem entry produced by [`walk`].
97#[cfg(not(any(windows, target_os = "macos")))]
98pub struct Entry {
99    /// Distance from the walk root: `0` for the root, `1` for its children, and so on.
100    pub depth: usize,
101    /// File name relative to `parent_path`.
102    pub file_name: OsString,
103    /// Filesystem entry type without following symbolic links.
104    pub file_type: FileType,
105    /// Entry metadata, or the error encountered while reading it.
106    pub metadata: io::Result<Metadata>,
107    /// Path containing this entry.
108    pub parent_path: Arc<Path>,
109}
110
111enum Job {
112    /// Read a directory and schedule processing of its entries.
113    ReadDir {
114        root_idx: usize,
115        path: Arc<Path>,
116        /// Depth to be assigned to entries read from `path`; always at least `1`.
117        /// The directory at `path` is one level shallower.
118        entry_depth: usize,
119    },
120    /// Fetch metadata for a chunk of entries from a completed directory read.
121    #[cfg(not(any(windows, target_os = "macos")))]
122    StatCompletion {
123        root_idx: usize,
124        path: Arc<Path>,
125        /// Depth assigned to every entry in this chunk; always at least `1`, i.e. a file in a directory.
126        entry_depth: usize,
127        entries: Vec<fs::DirEntry>,
128    },
129}
130
131impl Job {
132    /// Return the index of the root path that this job belongs to.
133    fn root_idx(&self) -> usize {
134        match self {
135            Job::ReadDir { root_idx, .. } => *root_idx,
136            #[cfg(not(any(windows, target_os = "macos")))]
137            Job::StatCompletion { root_idx, .. } => *root_idx,
138        }
139    }
140}
141
142/// Internal worker-channel events, including batches, per-root completion, and pool completion.
143enum Event {
144    Batch {
145        root_idx: usize,
146        batch: Batch,
147    },
148    /// All work for this root is complete; emitted after all of its batches.
149    /// Completion events for different roots may occur in any order.
150    RootFinished {
151        root_idx: usize,
152    },
153    /// Emitted once after all roots have emitted `RootFinished`; this is the final event.
154    Finished,
155}
156
157/// Per-root events exposed by [`RootWalk`].
158/// Unlike [`Event`], batches are flattened into entries and pool-wide completion ends the iterator
159/// instead of being yielded; `Finished` therefore means only that the associated root completed.
160/// [`RootWalk`] yields `(root_idx, event)`, separating root routing from event meaning. [`Event`]
161/// cannot do this uniformly because its `Finished` variant is pool-wide and has no root index.
162pub enum RootEvent {
163    /// An entry or filesystem error produced while walking the root.
164    Entry(io::Result<Entry>),
165    /// All entries for the root have been emitted.
166    Finished,
167}
168
169struct PoolShared {
170    /// Global queue that makes the initial root job available to whichever worker starts first.
171    injector: Injector<Job>,
172    stealers: Vec<Stealer<Job>>,
173    stop: AtomicBool,
174    descend: Arc<Descend>,
175    events: SyncSender<Event>,
176    /// Number of roots with queued or running jobs.
177    active_roots: AtomicUsize,
178    /// Number of queued or running jobs for each root index.
179    /// A counter reaching zero emits that root's [`Event::RootFinished`].
180    jobs_per_root: HashMap<usize, AtomicUsize>,
181    order: Order,
182    #[cfg(any(windows, target_os = "macos"))]
183    options: Options,
184    /// Handles used to wake workers, indexed by worker number.
185    unparkers: Vec<Unparker>,
186    /// Whether each worker has announced that it is idle, indexed like `unparkers`.
187    /// `wake_worker` atomically claims one idle worker before unparking it.
188    idle: Vec<AtomicBool>,
189    /// A round-robin cursor for the first idle worker to inspect.
190    next_wake: AtomicUsize,
191}
192
193struct Pool {
194    shared: Arc<PoolShared>,
195    events: Receiver<Event>,
196    handles: Vec<thread::JoinHandle<()>>,
197}
198
199/// A multi-root iterator yielding each root index with entry and per-root completion events.
200/// Unlike [`Walk`], it preserves root identity and exposes when each root finishes.
201pub struct RootWalk {
202    /// Entries buffered for delivery, by root index.
203    next: Vec<(usize, RootEvent)>,
204    /// See [`Walk::pool`].
205    pool: Option<Pool>,
206}
207
208/// A single-root directory iterator whose directory reads happen in parallel.
209/// Unlike `RootWalk`, it yields entries directly and hides root identity and completion events.
210pub struct Walk {
211    /// Entries buffered for delivery.
212    ///
213    /// This vector is used as a stack: it starts with the root, and received batches are inserted
214    /// in reverse so popping preserves their original order.
215    ///
216    /// If consumption isn't as fast as its production, threads will block.
217    next: Vec<io::Result<Entry>>,
218    /// Owns the worker threads for as long as traversal is active.
219    ///
220    /// Clearing or dropping it requests shutdown, unparks every worker, and joins their threads.
221    pool: Option<Pool>,
222    root: PathBuf,
223    options: Options,
224    /// Whether the worker pool has finished the current traversal.
225    finished: bool,
226}
227
228/// Read a directory using native bulk enumeration and return entries with metadata already collected.
229///
230/// Entries have depth zero so they can be passed directly to [`walk_root_entries`] without
231/// querying their paths again. Directory-open errors are returned immediately; later enumeration
232/// errors are yielded by the iterator.
233#[cfg(any(windows, target_os = "macos"))]
234pub fn read_dir(
235    path: &Path,
236    options: Options,
237) -> io::Result<impl Iterator<Item = io::Result<Entry>>> {
238    NativeReadDir::open(Arc::from(path), 0, options)
239}
240
241/// Walk `root` without following symlinks.
242/// Unlike `walk_roots`, this yields entries directly for a single root and hides
243/// completion events.
244pub fn walk(
245    root: &Path,
246    threads: usize,
247    order: Order,
248    options: Options,
249    descend: impl Fn(&Entry) -> bool + Send + Sync + 'static,
250) -> Walk {
251    let root_path = root.to_owned();
252    let root = Entry::from_path(root, options);
253    let pool = match &root {
254        Ok(entry) if entry.file_type.is_dir() && descend(entry) => {
255            let path = Arc::from(entry.path());
256            let pool = start_pool(
257                threads.max(1),
258                HashMap::from([(0, AtomicUsize::new(0))]),
259                order,
260                Arc::new(move |_, entry| descend(entry)),
261                options,
262            );
263            start_jobs(
264                &pool,
265                vec![Job::ReadDir {
266                    root_idx: 0,
267                    path,
268                    entry_depth: 1,
269                }],
270            );
271            Some(pool)
272        }
273        _ => None,
274    };
275    Walk {
276        next: vec![root],
277        pool,
278        root: root_path,
279        options,
280        finished: false,
281    }
282}
283
284impl Walk {
285    /// Restart an exhausted directory walk while retaining its worker threads.
286    ///
287    /// Returns `false` if the walk is still active or did not start a worker pool.
288    #[must_use]
289    pub fn restart(&mut self) -> bool {
290        if !self.finished || !self.next.is_empty() {
291            return false;
292        }
293        let Some(pool) = self.pool.as_ref() else {
294            return false;
295        };
296        let root = Entry::from_path(&self.root, self.options);
297        let job = match &root {
298            Ok(entry) if entry.file_type.is_dir() && (pool.shared.descend)(0, entry) => {
299                Some(Job::ReadDir {
300                    root_idx: 0,
301                    path: Arc::from(entry.path()),
302                    entry_depth: 1,
303                })
304            }
305            _ => None,
306        };
307        self.next.push(root);
308        if let Some(job) = job {
309            self.finished = false;
310            start_jobs(pool, vec![job]);
311        }
312        true
313    }
314}
315
316impl Iterator for Walk {
317    type Item = io::Result<Entry>;
318
319    fn next(&mut self) -> Option<Self::Item> {
320        loop {
321            if let Some(entry) = self.next.pop() {
322                return Some(entry);
323            }
324            if self.finished {
325                return None;
326            }
327
328            match self.pool.as_ref()?.events.recv() {
329                Ok(Event::Batch {
330                    batch: Ok(entries), ..
331                }) => {
332                    self.next.extend(entries.into_iter().rev());
333                }
334                Ok(Event::Batch {
335                    batch: Err(err), ..
336                }) => return Some(Err(err)),
337                Ok(Event::RootFinished { .. }) => {}
338                Ok(Event::Finished) => {
339                    self.finished = true;
340                    return None;
341                }
342                Err(_) => return Some(Err(io::Error::other("directory worker stopped"))),
343            }
344        }
345    }
346}
347
348/// Walk multiple indexed roots without following symlinks.
349/// Unlike [`walk`], this preserves each root index and yields its completion as a [`RootEvent`].
350///
351/// Each item in `roots` is `(root_index, path)`. `root_index` is a caller-chosen identifier passed
352/// to `descend` and returned with every [`RootEvent`] for that root, unique per root path.
353///
354/// # Panics
355///
356/// Panics if two roots have the same index.
357pub fn walk_roots(
358    roots: impl IntoIterator<Item = (usize, PathBuf)>,
359    threads: usize,
360    order: Order,
361    options: Options,
362    descend: impl Fn(usize, &Entry) -> bool + Send + Sync + 'static,
363) -> RootWalk {
364    start_root_walk(
365        roots.into_iter().collect(),
366        threads,
367        order,
368        descend,
369        |path: PathBuf| Entry::from_path(&path, options),
370        options,
371    )
372}
373
374/// Walk multiple indexed roots whose entries and metadata have already been collected.
375///
376/// Unlike [`walk_roots`], this reuses each supplied entry without querying its path again. Entry
377/// errors are yielded for their corresponding root, and each root retains its index and completion
378/// event just as it does with [`walk_roots`]. Supplied entries are re-rooted at depth zero before
379/// the predicate runs, and their descendants start at depth one.
380///
381/// # Panics
382///
383/// Panics if two roots have the same index.
384pub fn walk_root_entries(
385    roots: impl IntoIterator<Item = (usize, io::Result<Entry>)>,
386    threads: usize,
387    order: Order,
388    options: Options,
389    descend: impl Fn(usize, &Entry) -> bool + Send + Sync + 'static,
390) -> RootWalk {
391    start_root_walk(
392        roots.into_iter().collect(),
393        threads,
394        order,
395        descend,
396        std::convert::identity,
397        options,
398    )
399}
400
401fn start_root_walk<Root>(
402    roots: Vec<(usize, Root)>,
403    threads: usize,
404    order: Order,
405    descend: impl Fn(usize, &Entry) -> bool + Send + Sync + 'static,
406    prepare: impl Fn(Root) -> io::Result<Entry>,
407    options: Options,
408) -> RootWalk {
409    let jobs_per_root = roots
410        .iter()
411        .map(|(root_idx, _)| (*root_idx, AtomicUsize::new(0)))
412        .collect::<HashMap<_, _>>();
413    assert_eq!(
414        jobs_per_root.len(),
415        roots.len(),
416        "root indices must be unique"
417    );
418    let descend = Arc::new(descend);
419    let (next, root_jobs) = begin_walks(
420        roots
421            .into_iter()
422            .map(|(root_idx, root)| (root_idx, prepare(root))),
423        descend.as_ref(),
424    );
425    let pool = if root_jobs.is_empty() {
426        None
427    } else {
428        let pool = start_pool(threads.max(1), jobs_per_root, order, descend, options);
429        start_jobs(&pool, root_jobs);
430        Some(pool)
431    };
432    RootWalk { next, pool }
433}
434
435impl Iterator for RootWalk {
436    type Item = (usize, RootEvent);
437
438    fn next(&mut self) -> Option<Self::Item> {
439        loop {
440            if let Some(entry) = self.next.pop() {
441                return Some(entry);
442            }
443            match self.pool.as_ref()?.events.recv() {
444                Ok(Event::Batch {
445                    root_idx,
446                    batch: Ok(entries),
447                }) => self.next.extend(
448                    entries
449                        .into_iter()
450                        .rev()
451                        .map(|entry| (root_idx, RootEvent::Entry(entry))),
452                ),
453                Ok(Event::Batch {
454                    root_idx,
455                    batch: Err(err),
456                }) => return Some((root_idx, RootEvent::Entry(Err(err)))),
457                Ok(Event::RootFinished { root_idx }) => {
458                    return Some((root_idx, RootEvent::Finished));
459                }
460                Ok(Event::Finished) => {
461                    self.pool = None;
462                    return None;
463                }
464                Err(_) => {
465                    return Some((
466                        0,
467                        RootEvent::Entry(Err(io::Error::other("directory worker stopped"))),
468                    ));
469                }
470            }
471        }
472    }
473}
474
475impl PoolShared {
476    /// Wake one worker that has announced it is idle.
477    fn wake_worker(&self) {
478        let len = self.idle.len();
479        // This cursor only distributes scan starting points, so relaxed races affect fairness, not
480        // correctness; the compare-exchange below exclusively claims the worker to wake.
481        let start = self.next_wake.fetch_add(1, AtomicOrdering::Relaxed) % len;
482        for offset in 0..len {
483            let idx = (start + offset) % len;
484            if self.idle[idx]
485                .compare_exchange(true, false, AtomicOrdering::AcqRel, AtomicOrdering::Relaxed)
486                .is_ok()
487            {
488                self.unparkers[idx].unpark();
489                break;
490            }
491        }
492    }
493
494    /// Wake all threads unconditionally.
495    fn wake_workers(&self) {
496        for unparker in &self.unparkers {
497            unparker.unpark();
498        }
499    }
500}
501
502#[cfg(not(any(windows, target_os = "macos")))]
503impl Entry {
504    /// Return the full path to this entry.
505    #[must_use]
506    pub fn path(&self) -> PathBuf {
507        self.parent_path.join(&self.file_name)
508    }
509
510    /// Create an entry from a filesystem path.
511    pub fn from_path(path: &Path, _options: Options) -> io::Result<Self> {
512        let metadata = fs::symlink_metadata(path)?;
513        Ok(Self {
514            depth: 0,
515            file_name: path.file_name().unwrap_or(path.as_os_str()).to_owned(),
516            file_type: metadata.file_type(),
517            metadata: Ok(metadata),
518            parent_path: Arc::from(path.parent().unwrap_or(Path::new(""))),
519        })
520    }
521
522    fn from_dir_entry(
523        depth: usize,
524        parent_path: Arc<Path>,
525        entry: fs::DirEntry,
526    ) -> io::Result<Self> {
527        Ok(Self {
528            depth,
529            file_name: entry.file_name(),
530            file_type: entry.file_type()?,
531            metadata: entry.metadata(),
532            parent_path,
533        })
534    }
535}
536
537fn start_pool(
538    threads: usize,
539    jobs_per_root: HashMap<usize, AtomicUsize>,
540    order: Order,
541    descend: Arc<Descend>,
542    options: Options,
543) -> Pool {
544    #[cfg(not(any(windows, target_os = "macos")))]
545    let _ = options;
546    let workers: Vec<_> = (0..threads).map(|_| Worker::new_lifo()).collect();
547    let parkers: Vec<_> = (0..threads).map(|_| Parker::new()).collect();
548    let (event_tx, event_rx) = sync_channel(threads * 2);
549    let shared = Arc::new(PoolShared {
550        injector: Injector::new(),
551        stealers: workers.iter().map(Worker::stealer).collect(),
552        stop: AtomicBool::new(false),
553        descend,
554        events: event_tx,
555        active_roots: AtomicUsize::new(0),
556        jobs_per_root,
557        order,
558        #[cfg(any(windows, target_os = "macos"))]
559        options,
560        unparkers: parkers
561            .iter()
562            .map(|parker| parker.unparker().clone())
563            .collect(),
564        idle: (0..threads).map(|_| AtomicBool::new(false)).collect(),
565        next_wake: AtomicUsize::new(0),
566    });
567    let handles: Vec<_> = workers
568        .into_iter()
569        .zip(parkers)
570        .enumerate()
571        .map(|(idx, (worker, parker))| {
572            let shared = Arc::clone(&shared);
573            thread::Builder::new()
574                .name(format!("dua-fs-walk-{idx}"))
575                .spawn(move || worker_loop(idx, worker, parker, shared))
576                .expect("filesystem worker thread can be spawned")
577        })
578        .collect();
579
580    Pool {
581        shared,
582        events: event_rx,
583        handles,
584    }
585}
586
587/// Prepare initial root events and directory jobs.
588/// Returns events in stack order for [`RootWalk::next`] to pop, plus jobs requiring a worker pool.
589fn begin_walks(
590    roots: impl IntoIterator<Item = (usize, io::Result<Entry>)>,
591    descend: &Descend,
592) -> (Vec<(usize, RootEvent)>, Vec<Job>) {
593    let mut next = Vec::new();
594    let mut jobs = Vec::new();
595    for (root_idx, mut entry) in roots {
596        if let Ok(entry) = &mut entry {
597            entry.depth = 0;
598        }
599        let has_job = if let Ok(entry) = &entry
600            && entry.metadata.is_ok()
601            && entry.file_type.is_dir()
602            && descend(root_idx, entry)
603        {
604            jobs.push(Job::ReadDir {
605                root_idx,
606                path: Arc::from(entry.path()),
607                entry_depth: 1,
608            });
609            true
610        } else {
611            false
612        };
613        next.push((root_idx, RootEvent::Entry(entry)));
614        if !has_job {
615            next.push((root_idx, RootEvent::Finished));
616        }
617    }
618    next.reverse();
619    (next, jobs)
620}
621
622/// Seed an idle pool with one initial job per active root.
623/// Initializes per-root completion accounting, queues the jobs, and wakes workers to process them.
624fn start_jobs(pool: &Pool, root_jobs: Vec<Job>) {
625    let wake_all = root_jobs.len() > 1;
626    debug_assert_eq!(
627        pool.shared.active_roots.load(AtomicOrdering::Relaxed),
628        0,
629        "initial jobs must be started on an idle pool"
630    );
631    debug_assert!(
632        root_jobs.iter().all(|j| match j {
633            Job::ReadDir { entry_depth, .. } => *entry_depth,
634            #[cfg(not(any(windows, target_os = "macos")))]
635            Job::StatCompletion { entry_depth, .. } => *entry_depth,
636        } == 1),
637        "the first jobs should be root jobs, so active_root counts match"
638    );
639    pool.shared
640        .active_roots
641        .store(root_jobs.len(), AtomicOrdering::Relaxed);
642    for job in &root_jobs {
643        add_pending(job.root_idx(), 1, &pool.shared);
644    }
645    for job in root_jobs {
646        pool.shared.injector.push(job);
647    }
648    if wake_all {
649        pool.shared.wake_workers();
650    } else {
651        pool.shared.wake_worker();
652    }
653}
654
655fn worker_loop(idx: usize, worker: Worker<Job>, parker: Parker, shared: Arc<PoolShared>) {
656    while !shared.stop.load(AtomicOrdering::Relaxed) {
657        let found = if let Some(found) = find_job(&worker, &shared) {
658            found
659        } else {
660            shared.idle[idx].store(true, AtomicOrdering::Release);
661            let Some(found) = find_job(&worker, &shared) else {
662                parker.park();
663                shared.idle[idx].store(false, AtomicOrdering::Release);
664                continue;
665            };
666            shared.idle[idx].store(false, AtomicOrdering::Release);
667            found
668        };
669        let (job, stolen) = found;
670        if stolen {
671            // A successful steal proves peer work is available; wake one more worker so
672            // concurrency ramps up only while work remains stealable.
673            shared.wake_worker();
674        }
675        run_job(job, &worker, &shared);
676    }
677}
678
679impl Drop for Pool {
680    fn drop(&mut self) {
681        self.shared.stop.store(true, AtomicOrdering::Relaxed);
682        self.shared.wake_workers();
683        for handle in self.handles.drain(..) {
684            handle.join().ok();
685        }
686    }
687}
688
689/// Find work in order of increasing synchronization cost.
690///
691/// The worker checks its own LIFO queue first, favoring locality and avoiding
692/// shared-queue contention. It next takes a batch from the injector, keeping one job and moving
693/// the rest into its local queue. Only then does it inspect other workers, because stealing from a
694/// peer is the most contentious path. Consequently, a worker with local jobs keeps processing
695/// them before helping elsewhere, and injector jobs take priority over peer jobs.
696///
697/// Returns the selected job and whether it was stolen from another worker; the caller uses a
698/// successful steal to wake another idle worker. Returns `None` when a full scan finds no work.
699fn find_job(worker: &Worker<Job>, shared: &PoolShared) -> Option<(Job, bool)> {
700    loop {
701        if let Some(job) = worker.pop() {
702            return Some((job, false));
703        }
704
705        match shared.injector.steal_batch_and_pop(worker) {
706            Steal::Success(job) => return Some((job, false)),
707            Steal::Retry => continue,
708            Steal::Empty => {}
709        }
710
711        let mut retry = false;
712        for stealer in &shared.stealers {
713            match stealer.steal() {
714                Steal::Success(job) => return Some((job, true)),
715                Steal::Retry => retry = true,
716                Steal::Empty => {}
717            }
718        }
719        if !retry {
720            return None;
721        }
722    }
723}
724
725fn run_job(job: Job, worker: &Worker<Job>, shared: &PoolShared) {
726    match job {
727        Job::ReadDir {
728            root_idx: root,
729            path,
730            entry_depth,
731        } => {
732            if matches!(shared.order, Order::Completion) {
733                read_dir_completion(root, path, entry_depth, worker, shared);
734            } else {
735                read_dir_parent_first(root, path, entry_depth, worker, shared);
736            }
737        }
738        #[cfg(not(any(windows, target_os = "macos")))]
739        Job::StatCompletion {
740            root_idx: root,
741            path,
742            entry_depth,
743            entries,
744        } => stat_entries_completion(root, path, entry_depth, entries, worker, shared),
745    }
746}
747
748/// Read a directory for completion-order traversal.
749/// Successful directory entries are split into stealable metadata jobs, while enumeration errors
750/// are emitted directly; the directory-read job completes after all chunks are queued.
751/// This adds parallelism within wide directories when metadata calls dominate. Both traversal
752/// orders already process separate directories concurrently, so typical trees may see no speedup.
753#[cfg(not(any(windows, target_os = "macos")))]
754fn read_dir_completion(
755    root_idx: usize,
756    path: Arc<Path>,
757    entry_depth: usize,
758    worker: &Worker<Job>,
759    shared: &PoolShared,
760) {
761    let dir_entries = match fs::read_dir(&path) {
762        Ok(entries) => entries,
763        Err(err) => {
764            if shared
765                .events
766                .send(Event::Batch {
767                    root_idx,
768                    batch: Err(err),
769                })
770                .is_err()
771            {
772                shared.stop.store(true, AtomicOrdering::Relaxed);
773            }
774            finish_pending(root_idx, shared);
775            return;
776        }
777    };
778    let mut chunk = Vec::with_capacity(ENTRY_CHUNK_SIZE);
779    let mut errors = Vec::new();
780    let mut has_jobs = false;
781    for entry in dir_entries {
782        match entry {
783            Ok(entry) => {
784                chunk.push(entry);
785                if chunk.len() == ENTRY_CHUNK_SIZE {
786                    add_pending(root_idx, 1, shared);
787                    worker.push(Job::StatCompletion {
788                        root_idx,
789                        path: Arc::clone(&path),
790                        entry_depth,
791                        entries: std::mem::replace(
792                            &mut chunk,
793                            Vec::with_capacity(ENTRY_CHUNK_SIZE),
794                        ),
795                    });
796                    has_jobs = true;
797                }
798            }
799            Err(err) => errors.push(Err(err)),
800        }
801    }
802    if !chunk.is_empty() {
803        add_pending(root_idx, 1, shared);
804        worker.push(Job::StatCompletion {
805            root_idx,
806            path,
807            entry_depth,
808            entries: chunk,
809        });
810        has_jobs = true;
811    }
812    if has_jobs {
813        shared.wake_worker();
814    }
815    if !errors.is_empty()
816        && shared
817            .events
818            .send(Event::Batch {
819                root_idx,
820                batch: Ok(errors),
821            })
822            .is_err()
823    {
824        shared.stop.store(true, AtomicOrdering::Relaxed);
825    }
826    finish_pending(root_idx, shared);
827}
828
829/// Open the platform-native reader with any traversal-specific metadata enabled.
830#[cfg(any(windows, target_os = "macos"))]
831fn native_read_dir(
832    path: Arc<Path>,
833    depth: usize,
834    shared: &PoolShared,
835) -> io::Result<NativeReadDir> {
836    NativeReadDir::open(path, depth, shared.options)
837}
838
839/// Read a directory for completion-order traversal.
840///
841/// Unlike the generic implementation, native readers collect metadata while enumerating,
842/// so complete entries are published directly in chunks instead of being split into stealable
843/// metadata jobs. This streams wide directories but keeps their metadata work on one worker.
844#[cfg(any(windows, target_os = "macos"))]
845fn read_dir_completion(
846    root_idx: usize,
847    path: Arc<Path>,
848    depth: usize,
849    worker: &Worker<Job>,
850    shared: &PoolShared,
851) {
852    let dir_entries = match native_read_dir(path, depth, shared) {
853        Ok(entries) => entries,
854        Err(err) => {
855            if shared
856                .events
857                .send(Event::Batch {
858                    root_idx,
859                    batch: Err(err),
860                })
861                .is_err()
862            {
863                shared.stop.store(true, AtomicOrdering::Relaxed);
864            }
865            finish_pending(root_idx, shared);
866            return;
867        }
868    };
869    let mut entries = Vec::with_capacity(ENTRY_CHUNK_SIZE);
870    let mut jobs = Vec::new();
871    for entry in dir_entries {
872        if let Ok(entry) = &entry
873            && entry.file_type.is_dir()
874            && (shared.descend)(root_idx, entry)
875        {
876            jobs.push(Job::ReadDir {
877                root_idx,
878                path: Arc::from(entry.path()),
879                entry_depth: depth + 1,
880            });
881        }
882        entries.push(entry);
883        if entries.len() == ENTRY_CHUNK_SIZE
884            && !publish_completion_batch(root_idx, &mut entries, &mut jobs, worker, shared)
885        {
886            finish_pending(root_idx, shared);
887            return;
888        }
889    }
890    if !entries.is_empty() {
891        publish_completion_batch(root_idx, &mut entries, &mut jobs, worker, shared);
892    }
893    finish_pending(root_idx, shared);
894}
895
896#[cfg(any(windows, target_os = "macos"))]
897fn publish_completion_batch(
898    root_idx: usize,
899    entries: &mut Vec<io::Result<Entry>>,
900    jobs: &mut Vec<Job>,
901    worker: &Worker<Job>,
902    shared: &PoolShared,
903) -> bool {
904    add_pending(root_idx, jobs.len(), shared);
905    schedule_jobs(std::mem::take(jobs), worker, shared);
906    if shared
907        .events
908        .send(Event::Batch {
909            root_idx,
910            batch: Ok(std::mem::replace(
911                entries,
912                Vec::with_capacity(ENTRY_CHUNK_SIZE),
913            )),
914        })
915        .is_err()
916    {
917        shared.stop.store(true, AtomicOrdering::Relaxed);
918        false
919    } else {
920        true
921    }
922}
923
924#[cfg(any(windows, target_os = "macos"))]
925fn read_dir_parent_first(
926    root_idx: usize,
927    path: Arc<Path>,
928    depth: usize,
929    worker: &Worker<Job>,
930    shared: &PoolShared,
931) {
932    let dir_entries = match native_read_dir(path, depth, shared) {
933        Ok(entries) => entries,
934        Err(err) => {
935            finish_directory(root_idx, Err(err), Vec::new(), worker, shared);
936            return;
937        }
938    };
939    let mut jobs = Vec::new();
940    let entries = dir_entries
941        .map(|entry| {
942            entry.inspect(|entry| {
943                if entry.file_type.is_dir() && (shared.descend)(root_idx, entry) {
944                    jobs.push(Job::ReadDir {
945                        root_idx,
946                        path: Arc::from(entry.path()),
947                        entry_depth: depth + 1,
948                    });
949                }
950            })
951        })
952        .collect();
953    finish_directory(root_idx, Ok(entries), jobs, worker, shared);
954}
955
956#[cfg(not(any(windows, target_os = "macos")))]
957fn stat_entries_completion(
958    root_idx: usize,
959    path: Arc<Path>,
960    depth: usize,
961    entries: Vec<fs::DirEntry>,
962    worker: &Worker<Job>,
963    shared: &PoolShared,
964) {
965    let mut jobs = Vec::new();
966    let entries = entries
967        .into_iter()
968        .map(|entry| {
969            Entry::from_dir_entry(depth, Arc::clone(&path), entry).inspect(|entry| {
970                if entry.file_type.is_dir() && (shared.descend)(root_idx, entry) {
971                    jobs.push(Job::ReadDir {
972                        root_idx,
973                        path: Arc::from(entry.path()),
974                        entry_depth: entry.depth + 1,
975                    });
976                }
977            })
978        })
979        .collect();
980    add_pending(root_idx, jobs.len(), shared);
981    schedule_jobs(jobs, worker, shared);
982    if shared
983        .events
984        .send(Event::Batch {
985            root_idx,
986            batch: Ok(entries),
987        })
988        .is_err()
989    {
990        shared.stop.store(true, AtomicOrdering::Relaxed);
991    }
992    finish_pending(root_idx, shared);
993}
994
995/// Read a directory for parent-first traversal.
996/// Entries are converted inline rather than scheduled as `StatCompletion` jobs, producing the
997/// complete parent batch and its child-directory jobs together. This lets `finish_directory` send
998/// the parent batch before making any child job available, preserving parent-before-descendant
999/// order. Metadata within one directory is serial, although separate directories still run in
1000/// parallel; this often matches completion-order performance unless wide-directory metadata is the
1001/// bottleneck.
1002#[cfg(not(any(windows, target_os = "macos")))]
1003fn read_dir_parent_first(
1004    root_idx: usize,
1005    path: Arc<Path>,
1006    depth: usize,
1007    worker: &Worker<Job>,
1008    shared: &PoolShared,
1009) {
1010    read_dir_inline(root_idx, path, depth, worker, shared);
1011}
1012
1013/// Convert a directory's entries on the worker that enumerates it, then schedule its children.
1014///
1015/// Parent-first traversal converts each entry inline to preserve ordering.
1016#[cfg(not(any(windows, target_os = "macos")))]
1017fn read_dir_inline(
1018    root_idx: usize,
1019    path: Arc<Path>,
1020    depth: usize,
1021    worker: &Worker<Job>,
1022    shared: &PoolShared,
1023) {
1024    let dir_entries = match fs::read_dir(&path) {
1025        Ok(entries) => entries,
1026        Err(err) => {
1027            finish_directory(root_idx, Err(err), Vec::new(), worker, shared);
1028            return;
1029        }
1030    };
1031    let mut jobs = Vec::new();
1032    let entries = dir_entries
1033        .map(|entry| {
1034            entry
1035                .and_then(|entry| Entry::from_dir_entry(depth, Arc::clone(&path), entry))
1036                .inspect(|entry| {
1037                    if entry.file_type.is_dir() && (shared.descend)(root_idx, entry) {
1038                        jobs.push(Job::ReadDir {
1039                            root_idx,
1040                            path: Arc::from(entry.path()),
1041                            entry_depth: depth + 1,
1042                        });
1043                    }
1044                })
1045        })
1046        .collect();
1047    finish_directory(root_idx, Ok(entries), jobs, worker, shared);
1048}
1049
1050/// Publish a completed directory read and schedule its accepted child-directory jobs.
1051/// `ParentFirst` sends the batch before exposing child jobs; `Completion` exposes child jobs first.
1052/// Child jobs are counted before either action, and the current job is marked complete afterward.
1053fn finish_directory(
1054    root_idx: usize,
1055    batch: Batch,
1056    jobs: Vec<Job>,
1057    worker: &Worker<Job>,
1058    shared: &PoolShared,
1059) {
1060    add_pending(root_idx, jobs.len(), shared);
1061
1062    match shared.order {
1063        Order::ParentFirst => {
1064            if shared
1065                .events
1066                .send(Event::Batch { root_idx, batch })
1067                .is_err()
1068            {
1069                shared.stop.store(true, AtomicOrdering::Relaxed);
1070                return;
1071            }
1072            schedule_jobs(jobs, worker, shared);
1073        }
1074        Order::Completion => {
1075            schedule_jobs(jobs, worker, shared);
1076            if shared
1077                .events
1078                .send(Event::Batch { root_idx, batch })
1079                .is_err()
1080            {
1081                shared.stop.store(true, AtomicOrdering::Relaxed);
1082                return;
1083            }
1084        }
1085    }
1086
1087    finish_pending(root_idx, shared);
1088}
1089
1090fn add_pending(root: usize, count: usize, shared: &PoolShared) {
1091    shared.jobs_per_root[&root].fetch_add(count, AtomicOrdering::Relaxed);
1092}
1093
1094/// Mark one job complete for `root`.
1095/// The last job emits `RootFinished`; if this was also the last active root, `Finished` follows.
1096fn finish_pending(root_idx: usize, shared: &PoolShared) {
1097    if shared.jobs_per_root[&root_idx].fetch_sub(1, AtomicOrdering::Relaxed) == 1 {
1098        shared.events.send(Event::RootFinished { root_idx }).ok();
1099        if shared.active_roots.fetch_sub(1, AtomicOrdering::Relaxed) == 1 {
1100            shared.events.send(Event::Finished).ok();
1101        }
1102    }
1103}
1104
1105fn schedule_jobs(jobs: Vec<Job>, worker: &Worker<Job>, shared: &PoolShared) {
1106    let has_jobs = !jobs.is_empty();
1107    for job in jobs {
1108        worker.push(job);
1109    }
1110    if has_jobs {
1111        shared.wake_worker();
1112    }
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118
1119    #[test]
1120    fn parallel_walk_is_parent_first_and_does_not_follow_symlinks() {
1121        let dir = tempfile::tempdir().unwrap();
1122        fs::create_dir_all(dir.path().join("b/child")).unwrap();
1123        fs::create_dir(dir.path().join("a")).unwrap();
1124        fs::write(dir.path().join("b/child/file"), b"x").unwrap();
1125
1126        #[cfg(unix)]
1127        std::os::unix::fs::symlink(dir.path().join("b"), dir.path().join("link")).unwrap();
1128
1129        #[cfg(unix)]
1130        let expected = ["", "a", "b", "b/child", "b/child/file", "link"];
1131        #[cfg(not(unix))]
1132        let expected = ["", "a", "b", "b/child", "b/child/file"];
1133        let expected = expected.into_iter().map(PathBuf::from).collect::<Vec<_>>();
1134
1135        for threads in [1, 4] {
1136            let paths = walk(
1137                dir.path(),
1138                threads,
1139                Order::ParentFirst,
1140                Options::default(),
1141                |_| true,
1142            )
1143            .map(|entry| {
1144                entry
1145                    .unwrap()
1146                    .path()
1147                    .strip_prefix(dir.path())
1148                    .unwrap()
1149                    .to_owned()
1150            })
1151            .collect::<Vec<_>>();
1152            let mut sorted_paths = paths.clone();
1153            sorted_paths.sort();
1154            assert_eq!(
1155                sorted_paths, expected,
1156                "walk with {threads} threads should visit every expected path exactly once"
1157            );
1158
1159            for path in paths.iter().filter(|path| path.components().count() > 1) {
1160                let parent = path.parent().unwrap();
1161                assert!(
1162                    paths.iter().position(|path| path == parent)
1163                        < paths.iter().position(|candidate| candidate == path),
1164                    "parent {parent:?} should precede child {path:?} with {threads} threads; \
1165                     traversal order: {paths:?}"
1166                );
1167            }
1168        }
1169    }
1170
1171    #[test]
1172    fn a_completed_walk_can_reuse_its_workers() {
1173        let dir = tempfile::tempdir().unwrap();
1174        fs::create_dir_all(dir.path().join("first/child")).unwrap();
1175
1176        let mut walk = walk(dir.path(), 2, Order::Completion, Options::default(), |_| {
1177            true
1178        });
1179        assert!(!walk.restart(), "an active walk cannot be restarted");
1180        let worker_ids = walk
1181            .pool
1182            .as_ref()
1183            .unwrap()
1184            .handles
1185            .iter()
1186            .map(|handle| handle.thread().id())
1187            .collect::<Vec<_>>();
1188        walk.by_ref().for_each(drop);
1189        assert!(walk.next().is_none(), "a completed walk stays exhausted");
1190
1191        fs::create_dir_all(dir.path().join("second/child")).unwrap();
1192        assert!(walk.restart());
1193        let paths = walk
1194            .by_ref()
1195            .map(|entry| {
1196                entry
1197                    .unwrap()
1198                    .path()
1199                    .strip_prefix(dir.path())
1200                    .unwrap()
1201                    .into()
1202            })
1203            .collect::<Vec<PathBuf>>();
1204
1205        assert!(paths.contains(&PathBuf::from("second/child")));
1206        assert_eq!(
1207            walk.pool
1208                .as_ref()
1209                .unwrap()
1210                .handles
1211                .iter()
1212                .map(|handle| handle.thread().id())
1213                .collect::<Vec<_>>(),
1214            worker_ids,
1215            "a restarted walk keeps its original worker threads"
1216        );
1217    }
1218
1219    #[test]
1220    fn pruning_keeps_the_directory_and_missing_roots_are_errors() {
1221        let dir = tempfile::tempdir().unwrap();
1222        fs::create_dir_all(dir.path().join("skip/child")).unwrap();
1223
1224        let paths = walk(
1225            dir.path(),
1226            2,
1227            Order::Completion,
1228            Options::default(),
1229            |entry| entry.file_name != "skip",
1230        )
1231        .map(|entry| entry.unwrap().file_name)
1232        .collect::<Vec<_>>();
1233        assert_eq!(
1234            paths,
1235            vec![
1236                dir.path().file_name().unwrap().to_owned(),
1237                OsString::from("skip")
1238            ],
1239            "a pruned directory should be yielded without traversing its children"
1240        );
1241
1242        assert!(
1243            walk(
1244                &dir.path().join("missing"),
1245                2,
1246                Order::Completion,
1247                Options::default(),
1248                |_| true,
1249            )
1250            .next()
1251            .unwrap()
1252            .is_err(),
1253            "a missing root should be yielded as an I/O error"
1254        );
1255    }
1256
1257    #[test]
1258    fn concurrent_roots_keep_their_identity() {
1259        let dir = tempfile::tempdir().unwrap();
1260        let roots = [dir.path().join("a"), dir.path().join("b")];
1261        for root in &roots {
1262            fs::create_dir_all(root.join("child")).unwrap();
1263        }
1264
1265        let events = walk_roots(
1266            roots.iter().cloned().enumerate(),
1267            2,
1268            Order::Completion,
1269            Options::default(),
1270            |_, _| true,
1271        )
1272        .collect::<Vec<_>>();
1273        let mut paths = Vec::new();
1274        let mut last_entry = [0; 2];
1275        let mut finished = [None; 2];
1276        for (position, (root_idx, event)) in events.into_iter().enumerate() {
1277            match event {
1278                RootEvent::Entry(entry) => {
1279                    last_entry[root_idx] = position;
1280                    paths.push((
1281                        root_idx,
1282                        entry
1283                            .unwrap()
1284                            .path()
1285                            .strip_prefix(&roots[root_idx])
1286                            .unwrap()
1287                            .to_owned(),
1288                    ));
1289                }
1290                RootEvent::Finished => finished[root_idx] = Some(position),
1291            }
1292        }
1293        paths.sort();
1294        assert_eq!(
1295            paths,
1296            [
1297                (0, PathBuf::new()),
1298                (0, PathBuf::from("child")),
1299                (1, PathBuf::new()),
1300                (1, PathBuf::from("child")),
1301            ]
1302        );
1303        for root_idx in 0..roots.len() {
1304            assert!(
1305                last_entry[root_idx] < finished[root_idx].unwrap(),
1306                "root {root_idx} must finish after its last entry",
1307            );
1308        }
1309    }
1310
1311    #[test]
1312    fn prepared_roots_rebase_existing_descendants() {
1313        let directory = tempfile::tempdir().unwrap();
1314        let descendant = directory.path().join("descendant");
1315        fs::create_dir(&descendant).unwrap();
1316        let child = descendant.join("child");
1317        fs::write(&child, b"nested file").unwrap();
1318
1319        let descendant_entry = walk(
1320            directory.path(),
1321            2,
1322            Order::ParentFirst,
1323            Options::default(),
1324            |_| true,
1325        )
1326        .find_map(|entry| {
1327            let entry = entry.unwrap();
1328            (entry.path() == descendant).then_some(entry)
1329        })
1330        .expect("the initial walk should yield the descendant directory");
1331        assert_eq!(descendant_entry.depth, 1);
1332
1333        let mut events = walk_root_entries(
1334            [(7, Ok(descendant_entry))],
1335            2,
1336            Order::ParentFirst,
1337            Options::default(),
1338            |root_idx, entry| {
1339                assert_eq!(root_idx, 7);
1340                assert_eq!(entry.depth, 0, "the predicate should see a re-rooted entry");
1341                true
1342            },
1343        );
1344
1345        let Some((7, RootEvent::Entry(Ok(mut root)))) = events.next() else {
1346            panic!("the prepared descendant should be emitted as the new root");
1347        };
1348        assert_eq!(root.path(), descendant);
1349        assert_eq!(
1350            root.depth, 0,
1351            "the entry originally found at depth 1 must become the new traversal root"
1352        );
1353
1354        let Some((7, RootEvent::Entry(Ok(entry)))) = events.next() else {
1355            panic!("the re-rooted directory should emit its child");
1356        };
1357        assert_eq!(entry.path(), child);
1358        assert_eq!(
1359            entry.depth, 1,
1360            "the child depth must be relative to the prepared entry used as the new root"
1361        );
1362        assert_eq!(
1363            events
1364                .next()
1365                .map(|(root_idx, event)| (root_idx, matches!(event, RootEvent::Finished))),
1366            Some((7, true))
1367        );
1368        assert_eq!(
1369            events.next().map(|(root_idx, _)| root_idx),
1370            None,
1371            "nothing left after the Finished event"
1372        );
1373
1374        root.metadata = Err(io::Error::from(io::ErrorKind::PermissionDenied));
1375        let mut events = walk_root_entries(
1376            [(7, Ok(root))],
1377            2,
1378            Order::ParentFirst,
1379            Options::default(),
1380            |_, _| panic!("a directory with inaccessible metadata must not be descended"),
1381        );
1382        let Some((7, RootEvent::Entry(Ok(root)))) = events.next() else {
1383            panic!("the prepared directory must retain its metadata error");
1384        };
1385        let error = root
1386            .metadata
1387            .err()
1388            .expect("the inaccessible root must retain its metadata error");
1389        assert_eq!(error.kind(), io::ErrorKind::PermissionDenied);
1390        assert_eq!(
1391            events
1392                .next()
1393                .map(|(root_idx, event)| (root_idx, matches!(event, RootEvent::Finished))),
1394            Some((7, true))
1395        );
1396        assert_eq!(events.next().map(|(root_idx, _)| root_idx), None);
1397    }
1398
1399    #[cfg(any(windows, target_os = "macos"))]
1400    #[test]
1401    fn prepared_roots_reuse_native_directory_metadata() {
1402        let directory = tempfile::tempdir().unwrap();
1403        let path = directory.path().join("file");
1404        fs::write(&path, b"cached metadata").unwrap();
1405        let expected_len = fs::metadata(&path).unwrap().len();
1406
1407        let entry = read_dir(directory.path(), Options::default())
1408            .unwrap()
1409            .next()
1410            .unwrap()
1411            .unwrap();
1412        assert_eq!(entry.depth, 0);
1413        assert_eq!(entry.path(), path);
1414        fs::remove_file(&path).unwrap();
1415
1416        let mut events = walk_root_entries(
1417            [(7, Ok(entry))],
1418            1,
1419            Order::Completion,
1420            Options::default(),
1421            |_, _| true,
1422        );
1423        let Some((7, RootEvent::Entry(Ok(entry)))) = events.next() else {
1424            panic!(
1425                "prepared root must be yielded without querying its removed path which would fail"
1426            );
1427        };
1428        assert_eq!(entry.path(), path);
1429        assert_eq!(entry.metadata.unwrap().len(), expected_len);
1430        assert_eq!(
1431            events
1432                .next()
1433                .map(|(root_idx, event)| (root_idx, matches!(event, RootEvent::Finished))),
1434            Some((7, true))
1435        );
1436        assert_eq!(events.next().map(|(root_idx, _)| root_idx), None);
1437    }
1438
1439    #[test]
1440    fn wide_walk_wakes_multiple_idle_workers() {
1441        let dir = tempfile::tempdir().unwrap();
1442        for idx in 0..32 {
1443            fs::create_dir_all(dir.path().join(format!("{idx}/child"))).unwrap();
1444        }
1445
1446        let worker_threads = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
1447        let seen_threads = Arc::clone(&worker_threads);
1448        walk(
1449            dir.path(),
1450            8,
1451            Order::Completion,
1452            Options::default(),
1453            move |entry| {
1454                if entry.depth == 1 {
1455                    thread::sleep(std::time::Duration::from_millis(1));
1456                } else if entry.depth == 2 {
1457                    seen_threads.lock().unwrap().insert(thread::current().id());
1458                    thread::sleep(std::time::Duration::from_millis(10));
1459                }
1460                true
1461            },
1462        )
1463        .for_each(drop);
1464
1465        assert!(
1466            worker_threads.lock().unwrap().len() >= 4,
1467            "a wide directory should engage more than the producer and one thief"
1468        );
1469    }
1470
1471    #[cfg(any(windows, target_os = "macos"))]
1472    #[test]
1473    fn native_metadata_is_collected_by_the_directory_worker() {
1474        let dir = tempfile::tempdir().unwrap();
1475        for idx in 0..32 {
1476            fs::create_dir(dir.path().join(idx.to_string())).unwrap();
1477        }
1478
1479        let worker_threads = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
1480        let seen_threads = Arc::clone(&worker_threads);
1481        walk(
1482            dir.path(),
1483            8,
1484            Order::Completion,
1485            Options::default(),
1486            move |entry| {
1487                if entry.depth == 1 {
1488                    seen_threads.lock().unwrap().insert(thread::current().id());
1489                    thread::sleep(std::time::Duration::from_millis(2));
1490                }
1491                true
1492            },
1493        )
1494        .for_each(drop);
1495
1496        assert_eq!(
1497            worker_threads.lock().unwrap().len(),
1498            1,
1499            "native directory-entry metadata should stay on the enumerating worker"
1500        );
1501    }
1502
1503    #[cfg(any(windows, target_os = "macos"))]
1504    #[test]
1505    fn native_completion_streams_metadata_before_enumeration_finishes() {
1506        let dir = tempfile::tempdir().unwrap();
1507        for idx in 0..=ENTRY_CHUNK_SIZE {
1508            fs::create_dir(dir.path().join(idx.to_string())).unwrap();
1509        }
1510
1511        let (continue_tx, continue_rx) = std::sync::mpsc::sync_channel(0);
1512        let continue_rx = Arc::new(std::sync::Mutex::new(continue_rx));
1513        let seen = Arc::new(AtomicUsize::new(0));
1514        let seen_in_worker = Arc::clone(&seen);
1515        let mut entries =
1516            walk(
1517                dir.path(),
1518                2,
1519                Order::Completion,
1520                Options::default(),
1521                move |entry| {
1522                    if entry.depth == 1
1523                        && seen_in_worker.fetch_add(1, AtomicOrdering::Relaxed) == ENTRY_CHUNK_SIZE
1524                    {
1525                        continue_rx
1526                    .lock()
1527                    .unwrap()
1528                    .recv_timeout(std::time::Duration::from_secs(2))
1529                    .expect("the first metadata batch should arrive before enumeration finishes");
1530                    }
1531                    true
1532                },
1533            );
1534
1535        assert_eq!(
1536            entries.next().unwrap().unwrap().depth,
1537            0,
1538            "the root entry should be yielded first"
1539        );
1540        assert_eq!(
1541            entries.next().unwrap().unwrap().depth,
1542            1,
1543            "the first metadata batch should be yielded before enumeration resumes"
1544        );
1545        continue_tx.send(()).unwrap();
1546        entries.for_each(drop);
1547        assert_eq!(
1548            seen.load(AtomicOrdering::Relaxed),
1549            ENTRY_CHUNK_SIZE + 1,
1550            "all directory entries should be inspected"
1551        );
1552    }
1553}