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/// Walk `root` without following symlinks.
215/// Unlike `walk_roots`, this yields entries directly for a single root and hides
216/// completion events.
217pub fn walk(
218    root: &Path,
219    threads: usize,
220    order: Order,
221    descend: impl Fn(&Entry) -> bool + Send + Sync + 'static,
222) -> Walk {
223    let root = Entry::from_path(root);
224    let pool = match &root {
225        Ok(entry) if entry.file_type.is_dir() && descend(entry) => {
226            let path = Arc::from(entry.path());
227            let pool = start_pool(
228                threads.max(1),
229                HashMap::from([(0, AtomicUsize::new(0))]),
230                order,
231                Arc::new(move |_, entry| descend(entry)),
232            );
233            start_jobs(
234                &pool,
235                vec![Job::ReadDir {
236                    root_idx: 0,
237                    path,
238                    entry_depth: 1,
239                }],
240            );
241            Some(pool)
242        }
243        _ => None,
244    };
245    Walk {
246        next: vec![root],
247        pool,
248    }
249}
250
251impl Iterator for Walk {
252    type Item = io::Result<Entry>;
253
254    fn next(&mut self) -> Option<Self::Item> {
255        loop {
256            if let Some(entry) = self.next.pop() {
257                return Some(entry);
258            }
259
260            match self.pool.as_ref()?.events.recv() {
261                Ok(Event::Batch {
262                    batch: Ok(entries), ..
263                }) => {
264                    self.next.extend(entries.into_iter().rev());
265                }
266                Ok(Event::Batch {
267                    batch: Err(err), ..
268                }) => return Some(Err(err)),
269                Ok(Event::RootFinished { .. }) => {}
270                Ok(Event::Finished) => {
271                    self.pool = None;
272                    return None;
273                }
274                Err(_) => return Some(Err(io::Error::other("directory worker stopped"))),
275            }
276        }
277    }
278}
279
280/// Walk multiple indexed roots without following symlinks.
281/// Unlike [`walk`], this preserves each root index and yields its completion as a [`RootEvent`].
282///
283/// # Panics
284///
285/// Panics if two roots have the same index.
286pub fn walk_roots(
287    roots: impl IntoIterator<Item = (usize, PathBuf)>,
288    threads: usize,
289    order: Order,
290    descend: impl Fn(usize, &Entry) -> bool + Send + Sync + 'static,
291) -> RootWalk {
292    let roots = roots.into_iter().collect::<Vec<_>>();
293    let jobs_per_root = roots
294        .iter()
295        .map(|(root_idx, _)| (*root_idx, AtomicUsize::new(0)))
296        .collect::<HashMap<_, _>>();
297    assert_eq!(
298        jobs_per_root.len(),
299        roots.len(),
300        "root indices must be unique"
301    );
302    let descend = Arc::new(descend);
303    let (next, root_jobs) = begin_walks(roots, descend.as_ref());
304    let pool = if root_jobs.is_empty() {
305        None
306    } else {
307        let pool = start_pool(threads.max(1), jobs_per_root, order, descend);
308        start_jobs(&pool, root_jobs);
309        Some(pool)
310    };
311    RootWalk { next, pool }
312}
313
314impl Iterator for RootWalk {
315    type Item = (usize, RootEvent);
316
317    fn next(&mut self) -> Option<Self::Item> {
318        loop {
319            if let Some(entry) = self.next.pop() {
320                return Some(entry);
321            }
322            match self.pool.as_ref()?.events.recv() {
323                Ok(Event::Batch {
324                    root_idx,
325                    batch: Ok(entries),
326                }) => self.next.extend(
327                    entries
328                        .into_iter()
329                        .rev()
330                        .map(|entry| (root_idx, RootEvent::Entry(entry))),
331                ),
332                Ok(Event::Batch {
333                    root_idx,
334                    batch: Err(err),
335                }) => return Some((root_idx, RootEvent::Entry(Err(err)))),
336                Ok(Event::RootFinished { root_idx }) => {
337                    return Some((root_idx, RootEvent::Finished));
338                }
339                Ok(Event::Finished) => {
340                    self.pool = None;
341                    return None;
342                }
343                Err(_) => {
344                    return Some((
345                        0,
346                        RootEvent::Entry(Err(io::Error::other("directory worker stopped"))),
347                    ));
348                }
349            }
350        }
351    }
352}
353
354impl PoolShared {
355    /// Wake one worker that has announced it is idle.
356    fn wake_worker(&self) {
357        let len = self.idle.len();
358        // This cursor only distributes scan starting points, so relaxed races affect fairness, not
359        // correctness; the compare-exchange below exclusively claims the worker to wake.
360        let start = self.next_wake.fetch_add(1, AtomicOrdering::Relaxed) % len;
361        for offset in 0..len {
362            let idx = (start + offset) % len;
363            if self.idle[idx]
364                .compare_exchange(true, false, AtomicOrdering::AcqRel, AtomicOrdering::Relaxed)
365                .is_ok()
366            {
367                self.unparkers[idx].unpark();
368                break;
369            }
370        }
371    }
372
373    /// Wake all threads unconditionally.
374    fn wake_workers(&self) {
375        for unparker in &self.unparkers {
376            unparker.unpark();
377        }
378    }
379}
380
381#[cfg(not(any(windows, target_os = "macos")))]
382impl Entry {
383    /// Return the full path to this entry.
384    #[must_use]
385    pub fn path(&self) -> PathBuf {
386        self.parent_path.join(&self.file_name)
387    }
388
389    /// Create an entry from a filesystem path.
390    pub fn from_path(path: &Path) -> io::Result<Self> {
391        let metadata = fs::symlink_metadata(path)?;
392        Ok(Self {
393            depth: 0,
394            file_name: path.file_name().unwrap_or(path.as_os_str()).to_owned(),
395            file_type: metadata.file_type(),
396            metadata: Ok(metadata),
397            parent_path: Arc::from(path.parent().unwrap_or(Path::new(""))),
398        })
399    }
400
401    fn from_dir_entry(
402        depth: usize,
403        parent_path: Arc<Path>,
404        entry: fs::DirEntry,
405    ) -> io::Result<Self> {
406        Ok(Self {
407            depth,
408            file_name: entry.file_name(),
409            file_type: entry.file_type()?,
410            metadata: entry.metadata(),
411            parent_path,
412        })
413    }
414}
415
416fn start_pool(
417    threads: usize,
418    jobs_per_root: HashMap<usize, AtomicUsize>,
419    order: Order,
420    descend: Arc<Descend>,
421) -> Pool {
422    let workers: Vec<_> = (0..threads).map(|_| Worker::new_lifo()).collect();
423    let parkers: Vec<_> = (0..threads).map(|_| Parker::new()).collect();
424    let (event_tx, event_rx) = sync_channel(threads * 2);
425    let shared = Arc::new(PoolShared {
426        injector: Injector::new(),
427        stealers: workers.iter().map(Worker::stealer).collect(),
428        stop: AtomicBool::new(false),
429        descend,
430        events: event_tx,
431        active_roots: AtomicUsize::new(0),
432        jobs_per_root,
433        order,
434        unparkers: parkers
435            .iter()
436            .map(|parker| parker.unparker().clone())
437            .collect(),
438        idle: (0..threads).map(|_| AtomicBool::new(false)).collect(),
439        next_wake: AtomicUsize::new(0),
440    });
441    let handles: Vec<_> = workers
442        .into_iter()
443        .zip(parkers)
444        .enumerate()
445        .map(|(idx, (worker, parker))| {
446            let shared = Arc::clone(&shared);
447            thread::Builder::new()
448                .name(format!("dua-fs-walk-{idx}"))
449                .spawn(move || worker_loop(idx, worker, parker, shared))
450                .expect("filesystem worker thread can be spawned")
451        })
452        .collect();
453
454    Pool {
455        shared,
456        events: event_rx,
457        handles,
458    }
459}
460
461/// Prepare initial root events and directory jobs.
462/// Returns events in stack order for [`RootWalk::next`] to pop, plus jobs requiring a worker pool.
463fn begin_walks(
464    roots: impl IntoIterator<Item = (usize, PathBuf)>,
465    descend: &Descend,
466) -> (Vec<(usize, RootEvent)>, Vec<Job>) {
467    let mut next = Vec::new();
468    let mut jobs = Vec::new();
469    for (root_idx, path) in roots {
470        let entry = Entry::from_path(&path);
471        let has_job = if let Ok(entry) = &entry
472            && entry.file_type.is_dir()
473            && descend(root_idx, entry)
474        {
475            jobs.push(Job::ReadDir {
476                root_idx,
477                path: Arc::from(entry.path()),
478                entry_depth: 1,
479            });
480            true
481        } else {
482            false
483        };
484        next.push((root_idx, RootEvent::Entry(entry)));
485        if !has_job {
486            next.push((root_idx, RootEvent::Finished));
487        }
488    }
489    next.reverse();
490    (next, jobs)
491}
492
493/// Seed an idle pool with one initial job per active root.
494/// Initializes per-root completion accounting, queues the jobs, and wakes workers to process them.
495fn start_jobs(pool: &Pool, root_jobs: Vec<Job>) {
496    let wake_all = root_jobs.len() > 1;
497    debug_assert_eq!(
498        pool.shared.active_roots.load(AtomicOrdering::Relaxed),
499        0,
500        "initial jobs must be started on an idle pool"
501    );
502    debug_assert!(
503        root_jobs.iter().all(|j| match j {
504            Job::ReadDir { entry_depth, .. } => *entry_depth,
505            #[cfg(not(any(windows, target_os = "macos")))]
506            Job::StatCompletion { entry_depth, .. } => *entry_depth,
507        } == 1),
508        "the first jobs should be root jobs, so active_root counts match"
509    );
510    pool.shared
511        .active_roots
512        .store(root_jobs.len(), AtomicOrdering::Relaxed);
513    for job in &root_jobs {
514        add_pending(job.root_idx(), 1, &pool.shared);
515    }
516    for job in root_jobs {
517        pool.shared.injector.push(job);
518    }
519    if wake_all {
520        pool.shared.wake_workers();
521    } else {
522        pool.shared.wake_worker();
523    }
524}
525
526fn worker_loop(idx: usize, worker: Worker<Job>, parker: Parker, shared: Arc<PoolShared>) {
527    while !shared.stop.load(AtomicOrdering::Relaxed) {
528        let found = if let Some(found) = find_job(&worker, &shared) {
529            found
530        } else {
531            shared.idle[idx].store(true, AtomicOrdering::Release);
532            let Some(found) = find_job(&worker, &shared) else {
533                parker.park();
534                shared.idle[idx].store(false, AtomicOrdering::Release);
535                continue;
536            };
537            shared.idle[idx].store(false, AtomicOrdering::Release);
538            found
539        };
540        let (job, stolen) = found;
541        if stolen {
542            // A successful steal proves peer work is available; wake one more worker so
543            // concurrency ramps up only while work remains stealable.
544            shared.wake_worker();
545        }
546        run_job(job, &worker, &shared);
547    }
548}
549
550impl Drop for Pool {
551    fn drop(&mut self) {
552        self.shared.stop.store(true, AtomicOrdering::Relaxed);
553        self.shared.wake_workers();
554        for handle in self.handles.drain(..) {
555            handle.join().ok();
556        }
557    }
558}
559
560/// Find work in order of increasing synchronization cost.
561///
562/// The worker checks its own LIFO queue first, favoring locality and avoiding
563/// shared-queue contention. It next takes a batch from the injector, keeping one job and moving
564/// the rest into its local queue. Only then does it inspect other workers, because stealing from a
565/// peer is the most contentious path. Consequently, a worker with local jobs keeps processing
566/// them before helping elsewhere, and injector jobs take priority over peer jobs.
567///
568/// Returns the selected job and whether it was stolen from another worker; the caller uses a
569/// successful steal to wake another idle worker. Returns `None` when a full scan finds no work.
570fn find_job(worker: &Worker<Job>, shared: &PoolShared) -> Option<(Job, bool)> {
571    loop {
572        if let Some(job) = worker.pop() {
573            return Some((job, false));
574        }
575
576        match shared.injector.steal_batch_and_pop(worker) {
577            Steal::Success(job) => return Some((job, false)),
578            Steal::Retry => continue,
579            Steal::Empty => {}
580        }
581
582        let mut retry = false;
583        for stealer in &shared.stealers {
584            match stealer.steal() {
585                Steal::Success(job) => return Some((job, true)),
586                Steal::Retry => retry = true,
587                Steal::Empty => {}
588            }
589        }
590        if !retry {
591            return None;
592        }
593    }
594}
595
596fn run_job(job: Job, worker: &Worker<Job>, shared: &PoolShared) {
597    match job {
598        Job::ReadDir {
599            root_idx: root,
600            path,
601            entry_depth,
602        } => {
603            if matches!(shared.order, Order::Completion) {
604                read_dir_completion(root, path, entry_depth, worker, shared);
605            } else {
606                read_dir_parent_first(root, path, entry_depth, worker, shared);
607            }
608        }
609        #[cfg(not(any(windows, target_os = "macos")))]
610        Job::StatCompletion {
611            root_idx: root,
612            path,
613            entry_depth,
614            entries,
615        } => stat_entries_completion(root, path, entry_depth, entries, worker, shared),
616    }
617}
618
619/// Read a directory for completion-order traversal.
620/// Successful directory entries are split into stealable metadata jobs, while enumeration errors
621/// are emitted directly; the directory-read job completes after all chunks are queued.
622/// This adds parallelism within wide directories when metadata calls dominate. Both traversal
623/// orders already process separate directories concurrently, so typical trees may see no speedup.
624#[cfg(not(any(windows, target_os = "macos")))]
625fn read_dir_completion(
626    root_idx: usize,
627    path: Arc<Path>,
628    entry_depth: usize,
629    worker: &Worker<Job>,
630    shared: &PoolShared,
631) {
632    let dir_entries = match fs::read_dir(&path) {
633        Ok(entries) => entries,
634        Err(err) => {
635            if shared
636                .events
637                .send(Event::Batch {
638                    root_idx,
639                    batch: Err(err),
640                })
641                .is_err()
642            {
643                shared.stop.store(true, AtomicOrdering::Relaxed);
644            }
645            finish_pending(root_idx, shared);
646            return;
647        }
648    };
649    let mut chunk = Vec::with_capacity(ENTRY_CHUNK_SIZE);
650    let mut errors = Vec::new();
651    let mut has_jobs = false;
652    for entry in dir_entries {
653        match entry {
654            Ok(entry) => {
655                chunk.push(entry);
656                if chunk.len() == ENTRY_CHUNK_SIZE {
657                    add_pending(root_idx, 1, shared);
658                    worker.push(Job::StatCompletion {
659                        root_idx,
660                        path: Arc::clone(&path),
661                        entry_depth,
662                        entries: std::mem::replace(
663                            &mut chunk,
664                            Vec::with_capacity(ENTRY_CHUNK_SIZE),
665                        ),
666                    });
667                    has_jobs = true;
668                }
669            }
670            Err(err) => errors.push(Err(err)),
671        }
672    }
673    if !chunk.is_empty() {
674        add_pending(root_idx, 1, shared);
675        worker.push(Job::StatCompletion {
676            root_idx,
677            path,
678            entry_depth,
679            entries: chunk,
680        });
681        has_jobs = true;
682    }
683    if has_jobs {
684        shared.wake_worker();
685    }
686    if !errors.is_empty()
687        && shared
688            .events
689            .send(Event::Batch {
690                root_idx,
691                batch: Ok(errors),
692            })
693            .is_err()
694    {
695        shared.stop.store(true, AtomicOrdering::Relaxed);
696    }
697    finish_pending(root_idx, shared);
698}
699
700/// Read a directory for completion-order traversal.
701///
702/// Unlike the generic implementation, native readers collect metadata while enumerating,
703/// so complete entries are published directly in chunks instead of being split into stealable
704/// metadata jobs. This streams wide directories but keeps their metadata work on one worker.
705#[cfg(any(windows, target_os = "macos"))]
706fn read_dir_completion(
707    root_idx: usize,
708    path: Arc<Path>,
709    depth: usize,
710    worker: &Worker<Job>,
711    shared: &PoolShared,
712) {
713    let dir_entries = match NativeReadDir::open(path, depth) {
714        Ok(entries) => entries,
715        Err(err) => {
716            if shared
717                .events
718                .send(Event::Batch {
719                    root_idx,
720                    batch: Err(err),
721                })
722                .is_err()
723            {
724                shared.stop.store(true, AtomicOrdering::Relaxed);
725            }
726            finish_pending(root_idx, shared);
727            return;
728        }
729    };
730    let mut entries = Vec::with_capacity(ENTRY_CHUNK_SIZE);
731    let mut jobs = Vec::new();
732    for entry in dir_entries {
733        if let Ok(entry) = &entry
734            && entry.file_type.is_dir()
735            && (shared.descend)(root_idx, entry)
736        {
737            jobs.push(Job::ReadDir {
738                root_idx,
739                path: Arc::from(entry.path()),
740                entry_depth: depth + 1,
741            });
742        }
743        entries.push(entry);
744        if entries.len() == ENTRY_CHUNK_SIZE
745            && !publish_completion_batch(root_idx, &mut entries, &mut jobs, worker, shared)
746        {
747            finish_pending(root_idx, shared);
748            return;
749        }
750    }
751    if !entries.is_empty() {
752        publish_completion_batch(root_idx, &mut entries, &mut jobs, worker, shared);
753    }
754    finish_pending(root_idx, shared);
755}
756
757#[cfg(any(windows, target_os = "macos"))]
758fn publish_completion_batch(
759    root_idx: usize,
760    entries: &mut Vec<io::Result<Entry>>,
761    jobs: &mut Vec<Job>,
762    worker: &Worker<Job>,
763    shared: &PoolShared,
764) -> bool {
765    add_pending(root_idx, jobs.len(), shared);
766    schedule_jobs(std::mem::take(jobs), worker, shared);
767    if shared
768        .events
769        .send(Event::Batch {
770            root_idx,
771            batch: Ok(std::mem::replace(
772                entries,
773                Vec::with_capacity(ENTRY_CHUNK_SIZE),
774            )),
775        })
776        .is_err()
777    {
778        shared.stop.store(true, AtomicOrdering::Relaxed);
779        false
780    } else {
781        true
782    }
783}
784
785#[cfg(any(windows, target_os = "macos"))]
786fn read_dir_parent_first(
787    root_idx: usize,
788    path: Arc<Path>,
789    depth: usize,
790    worker: &Worker<Job>,
791    shared: &PoolShared,
792) {
793    let dir_entries = match NativeReadDir::open(path, depth) {
794        Ok(entries) => entries,
795        Err(err) => {
796            finish_directory(root_idx, Err(err), Vec::new(), worker, shared);
797            return;
798        }
799    };
800    let mut jobs = Vec::new();
801    let entries = dir_entries
802        .map(|entry| {
803            entry.inspect(|entry| {
804                if entry.file_type.is_dir() && (shared.descend)(root_idx, entry) {
805                    jobs.push(Job::ReadDir {
806                        root_idx,
807                        path: Arc::from(entry.path()),
808                        entry_depth: depth + 1,
809                    });
810                }
811            })
812        })
813        .collect();
814    finish_directory(root_idx, Ok(entries), jobs, worker, shared);
815}
816
817#[cfg(not(any(windows, target_os = "macos")))]
818fn stat_entries_completion(
819    root_idx: usize,
820    path: Arc<Path>,
821    depth: usize,
822    entries: Vec<fs::DirEntry>,
823    worker: &Worker<Job>,
824    shared: &PoolShared,
825) {
826    let mut jobs = Vec::new();
827    let entries = entries
828        .into_iter()
829        .map(|entry| {
830            Entry::from_dir_entry(depth, Arc::clone(&path), entry).inspect(|entry| {
831                if entry.file_type.is_dir() && (shared.descend)(root_idx, entry) {
832                    jobs.push(Job::ReadDir {
833                        root_idx,
834                        path: Arc::from(entry.path()),
835                        entry_depth: entry.depth + 1,
836                    });
837                }
838            })
839        })
840        .collect();
841    add_pending(root_idx, jobs.len(), shared);
842    schedule_jobs(jobs, worker, shared);
843    if shared
844        .events
845        .send(Event::Batch {
846            root_idx,
847            batch: Ok(entries),
848        })
849        .is_err()
850    {
851        shared.stop.store(true, AtomicOrdering::Relaxed);
852    }
853    finish_pending(root_idx, shared);
854}
855
856/// Read a directory for parent-first traversal.
857/// Entries are converted inline rather than scheduled as `StatCompletion` jobs, producing the
858/// complete parent batch and its child-directory jobs together. This lets `finish_directory` send
859/// the parent batch before making any child job available, preserving parent-before-descendant
860/// order. Metadata within one directory is serial, although separate directories still run in
861/// parallel; this often matches completion-order performance unless wide-directory metadata is the
862/// bottleneck.
863#[cfg(not(any(windows, target_os = "macos")))]
864fn read_dir_parent_first(
865    root_idx: usize,
866    path: Arc<Path>,
867    depth: usize,
868    worker: &Worker<Job>,
869    shared: &PoolShared,
870) {
871    read_dir_inline(root_idx, path, depth, worker, shared);
872}
873
874/// Convert a directory's entries on the worker that enumerates it, then schedule its children.
875///
876/// Parent-first traversal converts each entry inline to preserve ordering.
877#[cfg(not(any(windows, target_os = "macos")))]
878fn read_dir_inline(
879    root_idx: usize,
880    path: Arc<Path>,
881    depth: usize,
882    worker: &Worker<Job>,
883    shared: &PoolShared,
884) {
885    let dir_entries = match fs::read_dir(&path) {
886        Ok(entries) => entries,
887        Err(err) => {
888            finish_directory(root_idx, Err(err), Vec::new(), worker, shared);
889            return;
890        }
891    };
892    let mut jobs = Vec::new();
893    let entries = dir_entries
894        .map(|entry| {
895            entry
896                .and_then(|entry| Entry::from_dir_entry(depth, Arc::clone(&path), entry))
897                .inspect(|entry| {
898                    if entry.file_type.is_dir() && (shared.descend)(root_idx, entry) {
899                        jobs.push(Job::ReadDir {
900                            root_idx,
901                            path: Arc::from(entry.path()),
902                            entry_depth: depth + 1,
903                        });
904                    }
905                })
906        })
907        .collect();
908    finish_directory(root_idx, Ok(entries), jobs, worker, shared);
909}
910
911/// Publish a completed directory read and schedule its accepted child-directory jobs.
912/// `ParentFirst` sends the batch before exposing child jobs; `Completion` exposes child jobs first.
913/// Child jobs are counted before either action, and the current job is marked complete afterward.
914fn finish_directory(
915    root_idx: usize,
916    batch: Batch,
917    jobs: Vec<Job>,
918    worker: &Worker<Job>,
919    shared: &PoolShared,
920) {
921    add_pending(root_idx, jobs.len(), shared);
922
923    match shared.order {
924        Order::ParentFirst => {
925            if shared
926                .events
927                .send(Event::Batch { root_idx, batch })
928                .is_err()
929            {
930                shared.stop.store(true, AtomicOrdering::Relaxed);
931                return;
932            }
933            schedule_jobs(jobs, worker, shared);
934        }
935        Order::Completion => {
936            schedule_jobs(jobs, worker, shared);
937            if shared
938                .events
939                .send(Event::Batch { root_idx, batch })
940                .is_err()
941            {
942                shared.stop.store(true, AtomicOrdering::Relaxed);
943                return;
944            }
945        }
946    }
947
948    finish_pending(root_idx, shared);
949}
950
951fn add_pending(root: usize, count: usize, shared: &PoolShared) {
952    shared.jobs_per_root[&root].fetch_add(count, AtomicOrdering::Relaxed);
953}
954
955/// Mark one job complete for `root`.
956/// The last job emits `RootFinished`; if this was also the last active root, `Finished` follows.
957fn finish_pending(root_idx: usize, shared: &PoolShared) {
958    if shared.jobs_per_root[&root_idx].fetch_sub(1, AtomicOrdering::Relaxed) == 1 {
959        shared.events.send(Event::RootFinished { root_idx }).ok();
960        if shared.active_roots.fetch_sub(1, AtomicOrdering::Relaxed) == 1 {
961            shared.events.send(Event::Finished).ok();
962        }
963    }
964}
965
966fn schedule_jobs(jobs: Vec<Job>, worker: &Worker<Job>, shared: &PoolShared) {
967    let has_jobs = !jobs.is_empty();
968    for job in jobs {
969        worker.push(job);
970    }
971    if has_jobs {
972        shared.wake_worker();
973    }
974}
975
976#[cfg(test)]
977mod tests {
978    use super::*;
979
980    #[test]
981    fn parallel_walk_is_parent_first_and_does_not_follow_symlinks() {
982        let dir = tempfile::tempdir().unwrap();
983        fs::create_dir_all(dir.path().join("b/child")).unwrap();
984        fs::create_dir(dir.path().join("a")).unwrap();
985        fs::write(dir.path().join("b/child/file"), b"x").unwrap();
986
987        #[cfg(unix)]
988        std::os::unix::fs::symlink(dir.path().join("b"), dir.path().join("link")).unwrap();
989
990        #[cfg(unix)]
991        let expected = ["", "a", "b", "b/child", "b/child/file", "link"];
992        #[cfg(not(unix))]
993        let expected = ["", "a", "b", "b/child", "b/child/file"];
994        let expected = expected.into_iter().map(PathBuf::from).collect::<Vec<_>>();
995
996        for threads in [1, 4] {
997            let paths = walk(dir.path(), threads, Order::ParentFirst, |_| true)
998                .map(|entry| {
999                    entry
1000                        .unwrap()
1001                        .path()
1002                        .strip_prefix(dir.path())
1003                        .unwrap()
1004                        .to_owned()
1005                })
1006                .collect::<Vec<_>>();
1007            let mut sorted_paths = paths.clone();
1008            sorted_paths.sort();
1009            assert_eq!(
1010                sorted_paths, expected,
1011                "walk with {threads} threads should visit every expected path exactly once"
1012            );
1013
1014            for path in paths.iter().filter(|path| path.components().count() > 1) {
1015                let parent = path.parent().unwrap();
1016                assert!(
1017                    paths.iter().position(|path| path == parent)
1018                        < paths.iter().position(|candidate| candidate == path),
1019                    "parent {parent:?} should precede child {path:?} with {threads} threads; \
1020                     traversal order: {paths:?}"
1021                );
1022            }
1023        }
1024    }
1025
1026    #[test]
1027    fn pruning_keeps_the_directory_and_missing_roots_are_errors() {
1028        let dir = tempfile::tempdir().unwrap();
1029        fs::create_dir_all(dir.path().join("skip/child")).unwrap();
1030
1031        let paths = walk(dir.path(), 2, Order::Completion, |entry| {
1032            entry.file_name != "skip"
1033        })
1034        .map(|entry| entry.unwrap().file_name)
1035        .collect::<Vec<_>>();
1036        assert_eq!(
1037            paths,
1038            vec![
1039                dir.path().file_name().unwrap().to_owned(),
1040                OsString::from("skip")
1041            ],
1042            "a pruned directory should be yielded without traversing its children"
1043        );
1044
1045        assert!(
1046            walk(&dir.path().join("missing"), 2, Order::Completion, |_| true)
1047                .next()
1048                .unwrap()
1049                .is_err(),
1050            "a missing root should be yielded as an I/O error"
1051        );
1052    }
1053
1054    #[test]
1055    fn concurrent_roots_keep_their_identity() {
1056        let dir = tempfile::tempdir().unwrap();
1057        let roots = [dir.path().join("a"), dir.path().join("b")];
1058        for root in &roots {
1059            fs::create_dir_all(root.join("child")).unwrap();
1060        }
1061
1062        let events = walk_roots(
1063            roots.iter().cloned().enumerate(),
1064            2,
1065            Order::Completion,
1066            |_, _| true,
1067        )
1068        .collect::<Vec<_>>();
1069        let mut paths = Vec::new();
1070        let mut last_entry = [0; 2];
1071        let mut finished = [None; 2];
1072        for (position, (root_idx, event)) in events.into_iter().enumerate() {
1073            match event {
1074                RootEvent::Entry(entry) => {
1075                    last_entry[root_idx] = position;
1076                    paths.push((
1077                        root_idx,
1078                        entry
1079                            .unwrap()
1080                            .path()
1081                            .strip_prefix(&roots[root_idx])
1082                            .unwrap()
1083                            .to_owned(),
1084                    ));
1085                }
1086                RootEvent::Finished => finished[root_idx] = Some(position),
1087            }
1088        }
1089        paths.sort();
1090        assert_eq!(
1091            paths,
1092            [
1093                (0, PathBuf::new()),
1094                (0, PathBuf::from("child")),
1095                (1, PathBuf::new()),
1096                (1, PathBuf::from("child")),
1097            ]
1098        );
1099        for root_idx in 0..roots.len() {
1100            assert!(
1101                last_entry[root_idx] < finished[root_idx].unwrap(),
1102                "root {root_idx} must finish after its last entry",
1103            );
1104        }
1105    }
1106
1107    #[test]
1108    fn wide_walk_wakes_multiple_idle_workers() {
1109        let dir = tempfile::tempdir().unwrap();
1110        for idx in 0..32 {
1111            fs::create_dir_all(dir.path().join(format!("{idx}/child"))).unwrap();
1112        }
1113
1114        let worker_threads = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
1115        let seen_threads = Arc::clone(&worker_threads);
1116        walk(dir.path(), 8, Order::Completion, move |entry| {
1117            if entry.depth == 1 {
1118                thread::sleep(std::time::Duration::from_millis(1));
1119            } else if entry.depth == 2 {
1120                seen_threads.lock().unwrap().insert(thread::current().id());
1121                thread::sleep(std::time::Duration::from_millis(10));
1122            }
1123            true
1124        })
1125        .for_each(drop);
1126
1127        assert!(
1128            worker_threads.lock().unwrap().len() >= 4,
1129            "a wide directory should engage more than the producer and one thief"
1130        );
1131    }
1132
1133    #[cfg(any(windows, target_os = "macos"))]
1134    #[test]
1135    fn native_metadata_is_collected_by_the_directory_worker() {
1136        let dir = tempfile::tempdir().unwrap();
1137        for idx in 0..32 {
1138            fs::create_dir(dir.path().join(idx.to_string())).unwrap();
1139        }
1140
1141        let worker_threads = Arc::new(std::sync::Mutex::new(std::collections::HashSet::new()));
1142        let seen_threads = Arc::clone(&worker_threads);
1143        walk(dir.path(), 8, Order::Completion, move |entry| {
1144            if entry.depth == 1 {
1145                seen_threads.lock().unwrap().insert(thread::current().id());
1146                thread::sleep(std::time::Duration::from_millis(2));
1147            }
1148            true
1149        })
1150        .for_each(drop);
1151
1152        assert_eq!(
1153            worker_threads.lock().unwrap().len(),
1154            1,
1155            "native directory-entry metadata should stay on the enumerating worker"
1156        );
1157    }
1158
1159    #[cfg(any(windows, target_os = "macos"))]
1160    #[test]
1161    fn native_completion_streams_metadata_before_enumeration_finishes() {
1162        let dir = tempfile::tempdir().unwrap();
1163        for idx in 0..=ENTRY_CHUNK_SIZE {
1164            fs::create_dir(dir.path().join(idx.to_string())).unwrap();
1165        }
1166
1167        let (continue_tx, continue_rx) = std::sync::mpsc::sync_channel(0);
1168        let continue_rx = Arc::new(std::sync::Mutex::new(continue_rx));
1169        let seen = Arc::new(AtomicUsize::new(0));
1170        let seen_in_worker = Arc::clone(&seen);
1171        let mut entries = walk(dir.path(), 2, Order::Completion, move |entry| {
1172            if entry.depth == 1
1173                && seen_in_worker.fetch_add(1, AtomicOrdering::Relaxed) == ENTRY_CHUNK_SIZE
1174            {
1175                continue_rx
1176                    .lock()
1177                    .unwrap()
1178                    .recv_timeout(std::time::Duration::from_secs(2))
1179                    .expect("the first metadata batch should arrive before enumeration finishes");
1180            }
1181            true
1182        });
1183
1184        assert_eq!(
1185            entries.next().unwrap().unwrap().depth,
1186            0,
1187            "the root entry should be yielded first"
1188        );
1189        assert_eq!(
1190            entries.next().unwrap().unwrap().depth,
1191            1,
1192            "the first metadata batch should be yielded before enumeration resumes"
1193        );
1194        continue_tx.send(()).unwrap();
1195        entries.for_each(drop);
1196        assert_eq!(
1197            seen.load(AtomicOrdering::Relaxed),
1198            ENTRY_CHUNK_SIZE + 1,
1199            "all directory entries should be inspected"
1200        );
1201    }
1202}