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