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    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
69/// Decides whether to traverse an entry's children for a given root index.
70/// Returning `false` prunes descendants but still emits the entry itself.
71type Descend = dyn Fn(usize, &Entry) -> bool + Send + Sync;
72/// Entries obtained from one directory read.
73/// An outer error means the directory could not be opened; inner errors come from reading or
74/// converting individual directory entries.
75type Batch = io::Result<Vec<io::Result<Entry>>>;
76/// Number of directory entries grouped into each metadata job or result batch.
77/// Small chunks expose parallel work and stream wide directories while amortizing queue overhead.
78const ENTRY_CHUNK_SIZE: usize = 4;
79
80/// Controls when entries are yielded relative to their descendants.
81#[derive(Clone, Copy)]
82pub enum Order {
83    /// Yield entries as their parent-directory reads complete.
84    Completion,
85    /// Yield every parent before its descendants.
86    ParentFirst,
87}
88
89/// Platform-specific filesystem metadata requested during traversal.
90#[derive(Clone, Copy, Debug, Default)]
91pub struct Options {
92    /// Collect APFS clone identity and data-fork allocation metadata.
93    #[cfg(target_os = "macos")]
94    pub apfs_clone_metadata: bool,
95}
96
97/// Dense identifier of a directory within one filesystem walk.
98#[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    /// Return the zero-based identifier for indexing compact side tables.
112    #[must_use]
113    pub fn index(self) -> usize {
114        (self.0.get() - 1) as usize
115    }
116}
117
118/// A filesystem entry produced by [`walk`].
119#[cfg(not(any(windows, target_os = "macos")))]
120pub struct Entry {
121    /// Distance from the walk root: `0` for the root, `1` for its children, and so on.
122    pub depth: usize,
123    /// File name relative to `parent_path`.
124    pub file_name: OsString,
125    /// Filesystem entry type without following symbolic links.
126    pub file_type: FileType,
127    /// Entry metadata, or the error encountered while reading it.
128    pub metadata: io::Result<Metadata>,
129    /// Path containing this entry.
130    pub parent_path: Arc<Path>,
131    /// Dense identifier of this directory within the current walk, or `None` for non-directories.
132    pub directory_id: Option<DirectoryId>,
133    /// Dense identifier of the directory containing this entry, or `None` for a walk root.
134    pub parent_directory_id: Option<DirectoryId>,
135}
136
137enum Job {
138    /// Read a directory and schedule processing of its entries.
139    ReadDir {
140        root_idx: usize,
141        path: Arc<Path>,
142        /// Dense identifier of the directory being read.
143        directory_id: usize,
144        /// Depth to be assigned to entries read from `path`; always at least `1`.
145        /// The directory at `path` is one level shallower.
146        entry_depth: usize,
147    },
148    /// Fetch metadata for a chunk of entries from a completed directory read.
149    #[cfg(not(any(windows, target_os = "macos")))]
150    StatCompletion {
151        root_idx: usize,
152        path: Arc<Path>,
153        /// Dense identifier of the directory containing these entries.
154        directory_id: usize,
155        /// Depth assigned to every entry in this chunk; always at least `1`, i.e. a file in a directory.
156        entry_depth: usize,
157        entries: Vec<fs::DirEntry>,
158    },
159}
160
161impl Job {
162    /// Return the index of the root path that this job belongs to.
163    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
172/// Internal worker-channel events, including batches, per-root completion, and pool completion.
173enum Event {
174    Batch {
175        root_idx: usize,
176        batch: Batch,
177    },
178    /// All work for this root is complete; emitted after all of its batches.
179    /// Completion events for different roots may occur in any order.
180    RootFinished {
181        root_idx: usize,
182    },
183    /// Emitted once after all roots have emitted `RootFinished`; this is the final event.
184    Finished,
185}
186
187/// Per-root events exposed by [`RootWalk`].
188/// Unlike [`Event`], batches are flattened into entries and pool-wide completion ends the iterator
189/// instead of being yielded; `Finished` therefore means only that the associated root completed.
190/// [`RootWalk`] yields `(root_idx, event)`, separating root routing from event meaning. [`Event`]
191/// cannot do this uniformly because its `Finished` variant is pool-wide and has no root index.
192pub enum RootEvent {
193    /// An entry or filesystem error produced while walking the root.
194    Entry(io::Result<Entry>),
195    /// All entries for the root have been emitted.
196    Finished,
197}
198
199struct PoolShared {
200    /// Global queue that makes the initial root job available to whichever worker starts first.
201    injector: Injector<Job>,
202    stealers: Vec<Stealer<Job>>,
203    stop: AtomicBool,
204    descend: Arc<Descend>,
205    events: SyncSender<Event>,
206    /// Number of roots with queued or running jobs.
207    active_roots: AtomicUsize,
208    /// Number of queued or running jobs for each root index.
209    /// A counter reaching zero emits that root's [`Event::RootFinished`].
210    jobs_per_root: HashMap<usize, AtomicUsize>,
211    order: Order,
212    #[cfg(any(windows, target_os = "macos"))]
213    options: Options,
214    /// Handles used to wake workers, indexed by worker number.
215    unparkers: Vec<Unparker>,
216    /// Whether each worker has announced that it is idle, indexed like `unparkers`.
217    /// `wake_worker` atomically claims one idle worker before unparking it.
218    idle: Vec<AtomicBool>,
219    /// A round-robin cursor for the first idle worker to inspect.
220    next_wake: AtomicUsize,
221    /// Allocates dense identifiers to directories as they are discovered.
222    next_directory_id: AtomicUsize,
223}
224
225struct Pool {
226    shared: Arc<PoolShared>,
227    events: Receiver<Event>,
228    handles: Vec<thread::JoinHandle<()>>,
229}
230
231/// A multi-root iterator yielding each root index with entry and per-root completion events.
232/// Unlike [`Walk`], it preserves root identity and exposes when each root finishes.
233pub struct RootWalk {
234    /// Entries buffered for delivery, by root index.
235    next: Vec<(usize, RootEvent)>,
236    /// See [`Walk::pool`].
237    pool: Option<Pool>,
238}
239
240/// A single-root directory iterator whose directory reads happen in parallel.
241/// Unlike `RootWalk`, it yields entries directly and hides root identity and completion events.
242pub struct Walk {
243    /// Entries buffered for delivery.
244    ///
245    /// This vector is used as a stack: it starts with the root, and received batches are inserted
246    /// in reverse so popping preserves their original order.
247    ///
248    /// If consumption isn't as fast as its production, threads will block.
249    next: Vec<io::Result<Entry>>,
250    /// Owns the worker threads for as long as traversal is active.
251    ///
252    /// Clearing or dropping it requests shutdown, unparks every worker, and joins their threads.
253    pool: Option<Pool>,
254    root: PathBuf,
255    options: Options,
256    /// Whether the worker pool has finished the current traversal.
257    finished: bool,
258}
259
260/// Read a directory using native bulk enumeration and return entries with metadata already collected.
261///
262/// Entries have depth zero so they can be passed directly to [`walk_root_entries`] without
263/// querying their paths again. Directory-open errors are returned immediately; later enumeration
264/// errors are yielded by the iterator.
265#[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
273/// Walk `root` without following symlinks.
274/// Unlike `walk_roots`, this yields entries directly for a single root and hides
275/// completion events.
276pub 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    /// Restart an exhausted directory walk while retaining its worker threads.
325    ///
326    /// Returns `false` if the walk is still active or did not start a worker pool.
327    #[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
396/// Walk multiple indexed roots without following symlinks.
397/// Unlike [`walk`], this preserves each root index and yields its completion as a [`RootEvent`].
398///
399/// Each item in `roots` is `(root_index, path)`. `root_index` is a caller-chosen identifier passed
400/// to `descend` and returned with every [`RootEvent`] for that root, unique per root path.
401///
402/// # Panics
403///
404/// Panics if two roots have the same index.
405pub 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
422/// Walk multiple indexed roots whose entries and metadata have already been collected.
423///
424/// Unlike [`walk_roots`], this reuses each supplied entry without querying its path again. Entry
425/// errors are yielded for their corresponding root, and each root retains its index and completion
426/// event just as it does with [`walk_roots`]. Supplied entries are re-rooted at depth zero before
427/// the predicate runs, and their descendants start at depth one.
428///
429/// # Panics
430///
431/// Panics if two roots have the same index.
432pub 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    /// Wake one worker that has announced it is idle.
532    fn wake_worker(&self) {
533        let len = self.idle.len();
534        // This cursor only distributes scan starting points, so relaxed races affect fairness, not
535        // correctness; the compare-exchange below exclusively claims the worker to wake.
536        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    /// Wake all threads unconditionally.
550    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    /// Return the full path to this entry.
560    #[must_use]
561    pub fn path(&self) -> PathBuf {
562        self.parent_path.join(&self.file_name)
563    }
564
565    /// Create an entry from a filesystem path.
566    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
648/// Prepare initial root events and directory jobs.
649/// Returns events in stack order for [`RootWalk::next`] to pop, plus jobs requiring a worker pool.
650fn 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
695/// Seed an idle pool with one initial job per active root.
696/// Initializes per-root completion accounting, queues the jobs, and wakes workers to process them.
697fn 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            // A successful steal proves peer work is available; wake one more worker so
745            // concurrency ramps up only while work remains stealable.
746            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
762/// Find work in order of increasing synchronization cost.
763///
764/// The worker checks its own LIFO queue first, favoring locality and avoiding
765/// shared-queue contention. It next takes a batch from the injector, keeping one job and moving
766/// the rest into its local queue. Only then does it inspect other workers, because stealing from a
767/// peer is the most contentious path. Consequently, a worker with local jobs keeps processing
768/// them before helping elsewhere, and injector jobs take priority over peer jobs.
769///
770/// Returns the selected job and whether it was stolen from another worker; the caller uses a
771/// successful steal to wake another idle worker. Returns `None` when a full scan finds no work.
772fn 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/// Read a directory for completion-order traversal.
832/// Successful directory entries are split into stealable metadata jobs, while enumeration errors
833/// are emitted directly; the directory-read job completes after all chunks are queued.
834/// This adds parallelism within wide directories when metadata calls dominate. Both traversal
835/// orders already process separate directories concurrently, so typical trees may see no speedup.
836#[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/// Open the platform-native reader with any traversal-specific metadata enabled.
916#[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/// Read a directory for completion-order traversal.
926///
927/// Unlike the generic implementation, native readers collect metadata while enumerating,
928/// so complete entries are published directly in chunks instead of being split into stealable
929/// metadata jobs. This streams wide directories but keeps their metadata work on one worker.
930#[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/// Read a directory for parent-first traversal.
1104/// Entries are converted inline rather than scheduled as `StatCompletion` jobs, producing the
1105/// complete parent batch and its child-directory jobs together. This lets `finish_directory` send
1106/// the parent batch before making any child job available, preserving parent-before-descendant
1107/// order. Metadata within one directory is serial, although separate directories still run in
1108/// parallel; this often matches completion-order performance unless wide-directory metadata is the
1109/// bottleneck.
1110#[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/// Convert a directory's entries on the worker that enumerates it, then schedule its children.
1123///
1124/// Parent-first traversal converts each entry inline to preserve ordering.
1125#[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
1180/// Publish a completed directory read and schedule its accepted child-directory jobs.
1181/// `ParentFirst` sends the batch before exposing child jobs; `Completion` exposes child jobs first.
1182/// Child jobs are counted before either action, and the current job is marked complete afterward.
1183fn 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
1224/// Mark one job complete for `root`.
1225/// The last job emits `RootFinished`; if this was also the last active root, `Finished` follows.
1226fn 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}