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