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