Skip to main content

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