Skip to main content

common/
walk_driver.rs

1//! Generic single-tree safe-walk driver.
2//!
3//! This module owns the recursive directory-walk *skeleton* that copy, chmod, and
4//! rm previously each hand-coded. A tool supplies a [`WalkVisitor`]; the driver
5//! drives the traversal:
6//!
7//! 1. gated `read_entries` on the open hardened directory,
8//! 2. per child: derive explicit admission from its type hint,
9//! 3. apply only a reliable-hint terminal exclusion the visitor explicitly permits; every other
10//!    filter decision remains attached to the scheduled child,
11//! 4. admit each remaining possible leaf immediately before spawn. Join/fold summaries, errors,
12//!    and exact processed names together (NOT batched: see [`walk_dir_contents`] for why admission
13//!    and spawn share one loop step),
14//! 5. in each task: classify once, apply any pending exact filter, then either skip or dispatch as
15//!    checked
16//!    [`WalkVisitor::visit_leaf`] (holding the permit) or — for a directory — **drop the permit and
17//!    end its inner scope**, [`WalkVisitor::dir_pre`], recurse, [`WalkVisitor::dir_post`].
18//!
19//! ## The single invariant home
20//!
21//! The "drop the leaf permit before recursing into a directory" invariant — the
22//! root cause of the hold-and-wait deadlock class (see [`walk::LeafPermit`]) —
23//! lives in **exactly one place**: the directory branch shared by [`process_entry`] and
24//! `process_admitted_entry`.
25//! Leaves hold their permit across [`WalkVisitor::visit_leaf`]; the directory
26//! branch `drop`s it before any further work. No visitor ever hand-drops a leaf
27//! permit, so the invariant cannot silently migrate back to N parallel sites.
28//!
29//! ## Cancellation safety
30//!
31//! Spawned tasks must be `'static`, and an already-started `spawn_blocking` job is not cancellable,
32//! so every per-entry context is **owned**: [`EntryCx`] clones `Arc<Dir>` plus
33//! owned `OsString`/`PathBuf` rather than borrowing, exactly as the existing
34//! per-tool walks do. A dropped surrounding future (timeout or Ctrl-C) therefore
35//! can never leave a spawned task holding a dangling borrow. Fail-early traversal
36//! aborts spawned siblings and its task scope waits for recursively owned async work. A blocking job
37//! that has already started remains non-cancellable; its fd-admission lease outlives the cancelled
38//! waiter without delaying the runtime's bounded shutdown policy.
39//!
40//! ## How copy maps onto this trait
41//!
42//! `copy` is the reference single-tree visitor (`rcp::copy::CopyVisitor`); its
43//! mapping shaped this trait.
44//! `CopyVisitor` holds the run-constant state (`dst_root`, `filter_base`,
45//! `Settings`, `preserve::Settings`, the opened top-level destination parent — the
46//! *source* root needs no field, since each entry's source path is its
47//! [`EntryCx::real_path`]) and:
48//!
49//! - **`type Summary`** = `copy::Summary`.
50//! - **`type DirContext`** = the *destination* parent for one level:
51//!   `{ dst_dir: Option<Arc<Dir>>, is_fresh: bool }` (`None` dst = dry-run). This
52//!   is how the single-tree driver carries copy's second tree — each child reads
53//!   its destination parent from `parent_ctx` rather than the driver modeling two
54//!   trees. [`WalkVisitor::root_dir_context`] returns the opened top-level
55//!   destination (`Some(dst)`/`None`) with the initial `is_fresh`.
56//! - **`type DirState`** = `{ dst_dir, dst_parent, dst_name, we_created, src_meta,
57//!   is_root, base }` — what `dir_post` needs to apply directory metadata
58//!   (`src_meta` is read from the opened enumeration `Dir`, pairing it with the copied contents),
59//!   run empty-dir cleanup (`dst_parent.rmdir_at(dst_name)`), and `--delete`-prune,
60//!   plus the `base` create/unchanged contribution it folds with the children.
61//! - **`visit_leaf`** dispatches on `kind`: `File` → `copy_file_fd`, `Symlink` →
62//!   `copy_symlink_fd`, `Special` → skip-or-error. The admitted `permit` remains held across every
63//!   fd-bearing leaf-dispatch path, including recursive destination removal under overwrite.
64//!   `--dereference` of a symlink-to-dir stays inside `visit_leaf`: it transfers
65//!   the permit into the path-based target root walk (the one deliberately
66//!   non-fd-relative path). That walk releases the permit after authoritative
67//!   directory classification and before descent.
68//! - **`dir_pre`** runs `resolve_dst_dir`: `DirResolution::Skip` →
69//!   [`DirAction::Skip`] (`--ignore-existing` hit a non-dir); `Proceed{dir,..}` →
70//!   [`DirAction::Descend`] whose `dir` is the *source* dir (opened via
71//!   `src_parent.open_dir(name)`), `child_ctx` carries the resolved `dst_dir` +
72//!   child `is_fresh`, and `state` carries the `DirState`.
73//! - **`dir_post`** receives the children's folded `Result`: on `Ok` it runs the
74//!   `--delete` prune (keep-set = `processed.names()`), empty-dir cleanup, and
75//!   `set_dir_metadata_fd` (post-order); on `Err` (a non-fail-early child failure)
76//!   it skips the destructive prune, still applies directory metadata, and returns
77//!   the combined error — exactly as `copy_dir_contents`'s tail did.
78//! - **`on_skip`** mirrors copy's inline filter-skip: `report_skip` in dry-run +
79//!   `skipped_summary_for(kind)`.
80//! - **`permit_kind`** = `OpenFile`; every entry not positively hinted as a
81//!   directory is admitted before spawn. Roots and delegated entries acquire
82//!   before authoritative classification. A positive directory hint may classify
83//!   without admission; if stale, that first handle is closed before admission is
84//!   awaited and classification is repeated. An authoritative directory releases
85//!   any provisional permit before descent.
86//!
87//! The delegated-subtree case (rlink handing copy an update-only/type-changed
88//! subtree rooted below the original filter root) is carried by seeding the root
89//! [`EntryCx::filter_path`] with the subtree's logical base, so the filter still
90//! matches at the entry's true path while `rel_path`/`real_path` stay relative to
91//! the delegated root.
92//!
93//! The dry-run "directory" path (no destination dir, contents still traversed for
94//! reporting) is just `DirContext.dst_dir == None` threaded through — the same
95//! branch copy already has. No part of copy needs a trait shape this module does
96//! not provide, which is why the trait stops here (no second-tree concept leaks
97//! into the driver — that asymmetry is what keeps rlink on the substrate, not the
98//! visitor; see docs/tocttou.md, "One shared traversal driver").
99
100use std::ffi::{OsStr, OsString};
101use std::path::PathBuf;
102use std::sync::Arc;
103
104use async_recursion::async_recursion;
105
106use crate::error::OperationError;
107
108#[derive(Clone)]
109pub(crate) struct TaskTracker {
110    state: Arc<TaskTrackerState>,
111}
112
113struct TaskTrackerState {
114    active: std::sync::atomic::AtomicUsize,
115    idle: tokio::sync::Notify,
116}
117
118struct TrackedTask(TaskTracker);
119
120pin_project_lite::pin_project! {
121    /// One child future paired with the task-scope guard that accounts for it.
122    ///
123    /// Field order is load-bearing: cancelling even an unpolled Tokio task must destroy the child
124    /// future and all of its captures before [`TrackedTask`] decrements the scope's active count.
125    struct TrackedFuture<F> {
126        #[pin]
127        future: F,
128        _tracked: TrackedTask,
129    }
130}
131
132impl<F> std::future::Future for TrackedFuture<F>
133where
134    F: std::future::Future,
135{
136    type Output = F::Output;
137
138    fn poll(
139        self: std::pin::Pin<&mut Self>,
140        cx: &mut std::task::Context<'_>,
141    ) -> std::task::Poll<Self::Output> {
142        self.project().future.poll(cx)
143    }
144}
145
146impl TaskTracker {
147    fn new() -> Self {
148        Self {
149            state: Arc::new(TaskTrackerState {
150                active: std::sync::atomic::AtomicUsize::new(0),
151                idle: tokio::sync::Notify::new(),
152            }),
153        }
154    }
155
156    fn enter(&self) -> TrackedTask {
157        self.state
158            .active
159            .fetch_add(1, std::sync::atomic::Ordering::AcqRel);
160        TrackedTask(self.clone())
161    }
162
163    async fn wait_idle(&self) {
164        loop {
165            let idle = self.state.idle.notified();
166            if self.state.active.load(std::sync::atomic::Ordering::Acquire) == 0 {
167                return;
168            }
169            idle.await;
170        }
171    }
172}
173
174impl Drop for TrackedTask {
175    fn drop(&mut self) {
176        if self
177            .0
178            .state
179            .active
180            .fetch_sub(1, std::sync::atomic::Ordering::AcqRel)
181            == 1
182        {
183            self.0.state.idle.notify_one();
184        }
185    }
186}
187
188tokio::task_local! {
189    static TASK_TRACKER: TaskTracker;
190}
191
192/// Runs an operation in a task scope and waits for cancelled descendants to finish dropping.
193pub(crate) async fn scope_tasks<F>(future: F) -> F::Output
194where
195    F: std::future::Future,
196{
197    if TASK_TRACKER.try_with(Clone::clone).is_ok() {
198        return future.await;
199    }
200    let tracker = TaskTracker::new();
201    let output = TASK_TRACKER.scope(tracker.clone(), future).await;
202    tracker.wait_idle().await;
203    output
204}
205
206/// Spawns work tracked by the current task scope.
207pub(crate) fn spawn_tracked<T, F>(join_set: &mut tokio::task::JoinSet<T>, future: F)
208where
209    T: Send + 'static,
210    F: std::future::Future<Output = T> + Send + 'static,
211{
212    let tracker = TASK_TRACKER
213        .try_with(Clone::clone)
214        .expect("tracked work must run inside a task scope");
215    let tracked = tracker.enter();
216    join_set.spawn(TASK_TRACKER.scope(
217        tracker,
218        TrackedFuture {
219            future,
220            _tracked: tracked,
221        },
222    ));
223}
224
225use crate::progress::Progress;
226use crate::safedir::{Dir, Handle};
227use crate::walk::{self, AdmittedEntry, AdmittedLeaf, EntryAdmission, EntryKind, PermitKind};
228
229/// A per-run summary accumulated by the walk.
230///
231/// Every tool's `Summary` (copy/chmod/rm/link) already satisfies these bounds
232/// (`Default + Add + Send + 'static`); requiring exactly them keeps the driver
233/// generic over the tool without depending on any tool's concrete counters.
234pub trait WalkSummary: Default + std::ops::Add<Output = Self> + Send + Sized + 'static {}
235
236impl<T> WalkSummary for T where T: Default + std::ops::Add<Output = T> + Send + Sized + 'static {}
237
238/// Owned per-entry context handed to every [`WalkVisitor`] method.
239///
240/// All fields are owned so the whole context can move into a spawned task (tasks
241/// are `'static`). It carries the hardened parent [`Dir`] (an `Arc`, cloned, never
242/// borrowed) and the entry's accumulated paths.
243#[derive(Clone)]
244pub struct EntryCx {
245    /// The hardened directory that contains this entry. Cloned into each child
246    /// task; every open below it is `O_NOFOLLOW`.
247    pub parent: Arc<Dir>,
248    /// This entry's name within `parent`.
249    pub name: OsString,
250    /// Path accumulated from the walk root to this entry (empty for the root
251    /// entry). Joined onto the tool's root it reconstructs the real path; used for
252    /// diagnostics and path reconstruction.
253    pub rel_path: PathBuf,
254    /// The path the driver feeds to the include/exclude filter for this entry: the
255    /// entry's **logical** path relative to the filter root. Usually equals
256    /// `rel_path`, but a tool processing a *delegated subtree* (rlink handing an
257    /// update-only or type-changed subtree to copy, which is rooted below the
258    /// original filter root) seeds the root entry's `filter_path` with that subtree's
259    /// logical base so the filter still matches at the entry's true path (e.g.
260    /// `cache/keep.txt`, not the bare `keep.txt` relative to the delegated root). The
261    /// driver extends it by one component per level alongside `rel_path`.
262    pub filter_path: PathBuf,
263    /// `root.join(rel_path)` — the reconstructed real filesystem path, for
264    /// diagnostics and the deliberately-path-based features (`-L`/`--delete`).
265    pub real_path: PathBuf,
266    /// Whether this is a dry run (no filesystem mutation).
267    pub dry_run: bool,
268    /// The process-global progress tracker.
269    pub prog_track: &'static Progress,
270}
271
272impl EntryCx {
273    /// Build the child context for `child_name` within `child_dir`, extending the
274    /// accumulated `rel_path`/`filter_path`/`real_path` by one component. `child_dir`
275    /// is the hardened directory the child lives in (for a directory entry's
276    /// contents, the opened directory itself; for the root, the root directory).
277    #[must_use]
278    pub fn child(&self, child_dir: Arc<Dir>, child_name: &OsStr) -> EntryCx {
279        EntryCx {
280            parent: child_dir,
281            name: child_name.to_owned(),
282            rel_path: self.rel_path.join(child_name),
283            filter_path: self.filter_path.join(child_name),
284            real_path: self.real_path.join(child_name),
285            dry_run: self.dry_run,
286            prog_track: self.prog_track,
287        }
288    }
289}
290
291/// What a [`WalkVisitor::dir_pre`] decided to do with a directory entry.
292///
293/// Generic over the tool's summary `Sum` (carried by [`Self::Skip`]), the
294/// per-directory inherited `Ctx` (carried by [`Self::Descend`] to this
295/// directory's children), and the per-directory `State` (carried by
296/// [`Self::Descend`] to [`WalkVisitor::dir_post`]).
297pub enum DirAction<Sum, Ctx, State> {
298    /// Do not descend; the whole subtree contributes `Sum` and nothing else
299    /// (e.g. `--ignore-existing` hit a non-directory destination, or a
300    /// filtered-out directory).
301    Skip(Sum),
302    /// Descend into `dir`; `child_ctx` is the inherited context handed to *this
303    /// directory's children* (copy's destination dir + freshness; chmod/rm: `()`),
304    /// and `state` is carried, in the same task, to [`WalkVisitor::dir_post`].
305    Descend {
306        /// The hardened directory whose contents to walk. For copy this is the
307        /// *source* directory being read; the destination travels in `child_ctx`.
308        dir: Arc<Dir>,
309        /// The inherited context the driver clones into each child task and hands
310        /// to the child's [`WalkVisitor::visit_leaf`] / [`WalkVisitor::dir_pre`].
311        /// This is how copy threads the destination parent directory down one
312        /// level without the driver knowing a second tree exists.
313        child_ctx: Ctx,
314        /// Tool state threaded from `dir_pre` to `dir_post` in the same task
315        /// (copy's `we_created` + dst handle for metadata; rm's relaxed-directory state + snapshot;
316        /// chmod's `()`).
317        state: State,
318    },
319}
320
321/// The names of the children whose final exact decision included them and whose work succeeded, in
322/// enumeration order.
323///
324/// Handed to [`WalkVisitor::dir_post`] so a visitor can build a `--delete`
325/// keep-set (the set of destination names with a source counterpart) without
326/// re-reading the directory.
327#[derive(Debug, Default)]
328pub struct ProcessedChildren {
329    names: Vec<OsString>,
330}
331
332impl ProcessedChildren {
333    /// The successfully processed children's names, in enumeration order.
334    #[must_use]
335    pub fn names(&self) -> &[OsString] {
336        &self.names
337    }
338
339    /// Move the names out (e.g. straight into a `--delete` keep-set).
340    #[must_use]
341    pub fn into_names(self) -> Vec<OsString> {
342        self.names
343    }
344}
345
346/// The `Result` a [`WalkVisitor::dir_pre`] produces: a [`DirAction`] over the
347/// visitor's three associated types, or its [`OperationError`]. A named alias so
348/// the trait method's signature stays readable.
349pub type DirPreResult<V> = Result<
350    DirAction<
351        <V as WalkVisitor>::Summary,
352        <V as WalkVisitor>::DirContext,
353        <V as WalkVisitor>::DirState,
354    >,
355    OperationError<<V as WalkVisitor>::Summary>,
356>;
357
358/// A tool's policy for a single-tree safe walk.
359///
360/// The driver calls these to make the per-entry decisions it cannot know itself;
361/// it owns everything else (enumeration, permit lifecycle, spawning, the
362/// drop-before-recurse invariant, error fold). All futures are `+ Send` (RPITIT)
363/// so the driver can spawn them; the visitor is shared as `Arc<V>`.
364pub trait WalkVisitor: Send + Sync + 'static {
365    /// Per-run summary type (the tool's `Summary`).
366    type Summary: WalkSummary;
367    /// Inherited per-directory context: what a directory's children need *from
368    /// that directory* (an "inherited attribute" of the tree walk). The driver
369    /// clones it into each child task and hands it to the child's
370    /// [`Self::visit_leaf`] / [`Self::dir_pre`].
371    ///
372    /// This is the single-tree driver's bridge to copy's second (destination)
373    /// tree: copy puts the open destination directory handle (plus its freshness)
374    /// here, so each child can create/overwrite its own destination entry without
375    /// the driver ever modeling a second tree. chmod and rm — which only ever
376    /// need the source parent the driver already provides — use `()`.
377    type DirContext: Clone + Send + Sync + 'static;
378    /// State threaded from [`Self::dir_pre`] to [`Self::dir_post`] within one
379    /// task (so it need not be `Send` across the per-child spawn boundary —
380    /// `dir_pre`/recurse/`dir_post` all run in the same task).
381    type DirState: Send;
382
383    /// The inherited context for the walk *root's* children — the seed of the
384    /// `DirContext` chain. copy returns its top-level destination directory here;
385    /// chmod/rm return `()`. (The root entry itself is processed with this same
386    /// context as its "parent" context.)
387    fn root_dir_context(&self) -> Self::DirContext;
388
389    /// Which backpressure pool a leaf permit comes from for this tool.
390    fn permit_kind(&self) -> PermitKind;
391
392    /// Whether the walk stops at the first error (`--fail-early`).
393    fn fail_early(&self) -> bool;
394
395    /// The active filter, if any (applied to terminal reliable hints and, when required, the
396    /// worker's exact classification through [`walk::should_skip_entry_ref`]).
397    fn filter(&self) -> Option<&crate::filter::FilterSettings>;
398
399    /// Whether a filter decision must retain its authoritative entry until dispatch.
400    ///
401    /// The default keeps best-effort `d_type` filtering cheap for non-destructive walks. A visitor
402    /// whose included filter result authorizes type-sensitive work overrides this so a stale hint
403    /// cannot make that decision for one type and then act on a separately classified entry. A
404    /// reliable hint may still reject cheaply when [`Self::filter_allows_hint_only_skip`] confirms
405    /// that omitting the child cannot authorize later work.
406    fn filter_requires_admitted_entry(&self) -> bool {
407        false
408    }
409
410    /// Whether a reliable-hint exclusion may terminate before authoritative admission.
411    ///
412    /// The default preserves the cheap exclusion path: no entry action follows a skip. A visitor
413    /// whose downstream bookkeeping can turn an omitted child into an action may disable that path
414    /// for the current directory context, forcing the exact admitted decision instead.
415    fn filter_allows_hint_only_skip(&self, _dir_ctx: &Self::DirContext) -> bool {
416        true
417    }
418
419    /// Account for an entry the filter excluded, returning its summary contribution. Called by the
420    /// driver for each filtered-out child *instead of* dispatching it, so the tool's `*_skipped`
421    /// counters and dry-run skip reporting stay tool-owned (the driver is generic over the summary
422    /// and dry-run mode).
423    ///
424    /// For ordinary visitors, `kind` comes from the cheap `getdents` hint when available and
425    /// `DT_UNKNOWN` is resolved authoritatively as directory/non-directory. An authoritative-filter
426    /// visitor receives either the reliable kind that rejected cheaply or the exact classified kind
427    /// from its final recheck. `skip_result` is the `FilterResult` that caused the exclusion. The
428    /// driver still increments the shared progress counter via [`EntryKind::inc_skipped`] — override
429    /// only to add the summary counters and the `--dry-run` "skip …" line.
430    ///
431    /// The default does nothing (returns `Default`), which suits metadata-only
432    /// walks and the smoke tests; copy/chmod/rm override it to mirror their
433    /// existing `skipped_summary_for` + `report_skip` behavior.
434    fn on_skip(
435        &self,
436        _cx: &EntryCx,
437        _kind: EntryKind,
438        _skip_result: &crate::filter::FilterResult,
439    ) -> Self::Summary {
440        Self::Summary::default()
441    }
442
443    /// Process a non-directory entry (file / symlink / special). `parent_ctx` is the inherited
444    /// context of the directory containing this entry (copy's destination parent + freshness).
445    /// `leaf` keeps its classification handle structurally ahead of its admission permit for the
446    /// full visit.
447    fn visit_leaf(
448        &self,
449        cx: &EntryCx,
450        parent_ctx: &Self::DirContext,
451        leaf: AdmittedLeaf,
452    ) -> impl std::future::Future<Output = Result<Self::Summary, OperationError<Self::Summary>>> + Send;
453
454    /// Pre-order step for a directory entry, run *after* the leaf permit has been
455    /// dropped and *before* the contents are walked. `parent_ctx` is the inherited
456    /// context of the *containing* directory. Returns [`DirAction::Skip`] to prune
457    /// the subtree or [`DirAction::Descend`] to walk it (supplying the child
458    /// context for this directory's own children, and the `dir_post` state).
459    ///
460    /// chmod applies the pre-order mode change here (unless deferred) and opens
461    /// the dir; copy resolves the destination directory (mkdir/overwrite/skip) and
462    /// puts it in the child context; rm snapshots metadata and records any temporary
463    /// permission relaxation for filter-retention handling.
464    fn dir_pre(
465        &self,
466        cx: &EntryCx,
467        parent_ctx: &Self::DirContext,
468        handle: &Handle,
469    ) -> impl std::future::Future<Output = DirPreResult<Self>> + Send;
470
471    /// Post-order step for a directory entry, run *after* its contents are walked,
472    /// in the same task as `dir_pre`. `state` is the [`DirAction::Descend`] state;
473    /// `processed` lists the successfully included children; `child_result` is the contents' folded
474    /// outcome — `Ok(summary)` when every child succeeded, or `Err` carrying the
475    /// combined child error and the partial summary when one or more children failed
476    /// **without** `fail_early`. (Neither has `dir_pre`'s own contribution folded in
477    /// — the visitor carries that in `state` and folds it here.)
478    ///
479    /// `dir_post` is **not** called when `fail_early` is set and a child failed: that
480    /// case aborts the subtree's already-spawned siblings and returns the error after
481    /// the enclosing async task scope has quiesced, without any post-order work — so a
482    /// fail-early return never applies post-order finalization (copy's directory metadata /
483    /// `--delete` prune). When `fail_early` is unset, `dir_post` IS called
484    /// with `Err(..)` so the visitor can still apply safe post-order finalization
485    /// (copy applies directory metadata even after a partial failure, but skips the
486    /// destructive `--delete` prune) and then return the combined error.
487    ///
488    /// copy applies directory metadata, empty-dir cleanup, and `--delete` prune;
489    /// chmod applies the deferred post-order change; rm runs the time filter, restores a
490    /// temporary mode only when filtering retains the directory, or performs `rmdir`. A visitor
491    /// that wants the historical "finalize only on full success" behavior simply propagates the
492    /// `Err`.
493    fn dir_post(
494        &self,
495        cx: &EntryCx,
496        state: Self::DirState,
497        processed: &ProcessedChildren,
498        child_result: Result<Self::Summary, OperationError<Self::Summary>>,
499    ) -> impl std::future::Future<Output = Result<Self::Summary, OperationError<Self::Summary>>> + Send;
500}
501
502/// Process one already-located entry: classify it authoritatively via
503/// [`Dir::child`], then dispatch.
504///
505/// - **Non-directory:** call [`WalkVisitor::visit_leaf`] holding admission.
506/// - **Directory:** drop admission — **the one and only drop-before-recurse
507///   site** — then [`WalkVisitor::dir_pre`]; on [`DirAction::Descend`], walk the
508///   contents via [`walk_dir_contents`] (threading the child context) and finish
509///   with [`WalkVisitor::dir_post`].
510///
511/// `parent_ctx` is the inherited context of the directory that contains this
512/// entry. `cx.parent` must be that (hardened) directory. On a classification
513/// error the entry's own error is surfaced — the same fail-closed behavior the
514/// per-tool walks have.
515pub async fn process_entry<V>(
516    visitor: Arc<V>,
517    cx: EntryCx,
518    parent_ctx: V::DirContext,
519    admission: impl Into<EntryAdmission> + Send,
520) -> Result<V::Summary, OperationError<V::Summary>>
521where
522    V: WalkVisitor,
523{
524    scope_tasks(process_entry_tracked(
525        visitor,
526        cx,
527        parent_ctx,
528        EntrySource::Unclassified(admission.into()),
529    ))
530    .await
531}
532
533/// Process an entry whose authoritative classification already informed its scheduling decision.
534///
535/// Destructive filters use this boundary so the shared driver consumes the exact handle they
536/// classified instead of resolving the name again and potentially acting on a different type.
537pub(crate) async fn process_admitted_entry<V>(
538    visitor: Arc<V>,
539    cx: EntryCx,
540    parent_ctx: V::DirContext,
541    entry: AdmittedEntry,
542) -> Result<V::Summary, OperationError<V::Summary>>
543where
544    V: WalkVisitor,
545{
546    scope_tasks(process_entry_tracked(
547        visitor,
548        cx,
549        parent_ctx,
550        EntrySource::Admitted(entry),
551    ))
552    .await
553}
554
555enum EntrySource {
556    Unclassified(EntryAdmission),
557    Admitted(AdmittedEntry),
558}
559
560enum ClassifiedEntry {
561    Directory(Handle),
562    Admitted(AdmittedEntry),
563}
564
565impl ClassifiedEntry {
566    fn kind(&self) -> EntryKind {
567        match self {
568            Self::Directory(handle) => handle.kind(),
569            Self::Admitted(entry) => entry.kind(),
570        }
571    }
572}
573
574async fn classify_for_dispatch<V>(
575    visitor: &V,
576    cx: &EntryCx,
577    source: EntrySource,
578) -> Result<ClassifiedEntry, OperationError<V::Summary>>
579where
580    V: WalkVisitor,
581{
582    if let EntrySource::Admitted(entry) = source {
583        return Ok(ClassifiedEntry::Admitted(entry));
584    }
585    let EntrySource::Unclassified(mut admission) = source else {
586        unreachable!("admitted entries return above")
587    };
588    if matches!(admission, EntryAdmission::HintedDirectory) {
589        match walk::classify_entry(&cx.parent, &cx.name).await {
590            Ok(handle) if handle.kind() == EntryKind::Dir => {
591                return Ok(ClassifiedEntry::Directory(handle));
592            }
593            Ok(handle) => {
594                drop(handle);
595                admission = EntryAdmission::RootOrDelegated;
596            }
597            Err(err) => {
598                let err = anyhow::Error::new(err)
599                    .context(format!("failed reading metadata from {:?}", &cx.real_path));
600                return Err(OperationError::new(err, Default::default()));
601            }
602        }
603    }
604    let admission = walk::ensure_entry_admission(visitor.permit_kind(), admission).await;
605    let entry = walk::classify_admitted_entry(&cx.parent, &cx.name, admission.into_permit())
606        .await
607        .map_err(|err| {
608            let err = anyhow::Error::new(err)
609                .context(format!("failed reading metadata from {:?}", &cx.real_path));
610            OperationError::new(err, Default::default())
611        })?;
612    Ok(ClassifiedEntry::Admitted(entry))
613}
614
615#[async_recursion]
616async fn process_entry_tracked<V>(
617    visitor: Arc<V>,
618    cx: EntryCx,
619    parent_ctx: V::DirContext,
620    source: EntrySource,
621) -> Result<V::Summary, OperationError<V::Summary>>
622where
623    V: WalkVisitor,
624{
625    let _ops_guard = cx.prog_track.ops.guard();
626    let classified = classify_for_dispatch(visitor.as_ref(), &cx, source).await?;
627    dispatch_classified_entry(visitor, cx, parent_ctx, classified).await
628}
629
630#[async_recursion]
631async fn dispatch_classified_entry<V>(
632    visitor: Arc<V>,
633    cx: EntryCx,
634    parent_ctx: V::DirContext,
635    classified: ClassifiedEntry,
636) -> Result<V::Summary, OperationError<V::Summary>>
637where
638    V: WalkVisitor,
639{
640    enum AdmittedDispatch<S> {
641        Leaf(Result<S, OperationError<S>>),
642        Directory(Handle),
643    }
644    let handle = match classified {
645        ClassifiedEntry::Directory(handle) => handle,
646        ClassifiedEntry::Admitted(entry) => {
647            let weak_admission = entry.admission();
648            let dispatch = crate::safedir::with_optional_fd_admission(weak_admission, async {
649                match entry.into_leaf() {
650                    Ok(leaf) => {
651                        return AdmittedDispatch::Leaf(
652                            visitor.visit_leaf(&cx, &parent_ctx, leaf).await,
653                        );
654                    }
655                    Err(handle) => {
656                        // ── the single drop-before-recurse site ──────────────────────────────
657                        // release provisional admission inside its scope, then return only the
658                        // directory handle. ending the scope restores any outer pool before
659                        // dir_pre can recurse.
660                        AdmittedDispatch::Directory(handle)
661                    }
662                }
663            })
664            .await;
665            match dispatch {
666                AdmittedDispatch::Leaf(result) => return result,
667                AdmittedDispatch::Directory(handle) => handle,
668            }
669        }
670    };
671    let action = visitor.dir_pre(&cx, &parent_ctx, &handle).await?;
672    // dir_pre has copied everything its state needs and opened the directory used for descent.
673    // the classification handle is redundant from here on and must not inflate the per-depth fd
674    // baseline alongside those deliberately unbudgeted recursive directory handles.
675    drop(handle);
676    match action {
677        DirAction::Skip(summary) => Ok(summary),
678        DirAction::Descend {
679            dir,
680            child_ctx,
681            state,
682        } => {
683            match walk_dir_contents(Arc::clone(&visitor), dir, &cx, &child_ctx).await {
684                Ok((child_summary, processed)) => {
685                    visitor
686                        .dir_post(&cx, state, &processed, Ok(child_summary))
687                        .await
688                }
689                // a child failed. with `fail_early` the subtree's already-spawned siblings have
690                // been aborted and NO post-order work runs — the error propagates as-is after the
691                // enclosing async task scope quiesces. without `fail_early`, `dir_post` IS still
692                // invoked,
693                // with the combined error, so the visitor can apply safe post-order finalization
694                // (copy's directory metadata) while skipping destructive work (copy's `--delete`
695                // prune) and then return the combined error. `processed` is not recoverable on the
696                // error path, so an empty list is passed — the only consumer (a `--delete` keep-set)
697                // is skipped on error anyway.
698                Err(walk_err) => {
699                    if visitor.fail_early() {
700                        Err(walk_err)
701                    } else {
702                        visitor
703                            .dir_post(&cx, state, &ProcessedChildren::default(), Err(walk_err))
704                            .await
705                    }
706                }
707            }
708        }
709    }
710}
711
712/// Walk the contents of an open hardened directory.
713///
714/// Enumerates `dir` (gated `read_entries`) and applies only reliable-hint terminal exclusions the
715/// visitor permits. Every remaining possible leaf is admitted immediately before spawn. A child
716/// worker classifies once, applies any pending authoritative filter to that exact entry, and either
717/// skips it or transfers the same entry into dispatch. The result fold applies fail-early or
718/// keep-going policy and derives processed names from successful exact outcomes.
719///
720/// Returns the folded child summary (filter-skip contributions included) and the
721/// [`ProcessedChildren`] list of successfully included names. `parent_cx`
722/// describes the directory entry itself (its `rel_path`/`real_path` are the base
723/// the children extend). `dir_ctx` is the inherited context of `dir` (the context
724/// its children receive) — for the root walk this is
725/// [`WalkVisitor::root_dir_context`].
726///
727/// ## Acquire-then-spawn ordering
728///
729/// Each leaf permit is acquired and the child task is spawned **in the same loop
730/// iteration**, before the next child's permit is acquired. This is load-bearing
731/// for backpressure correctness: a directory may have more permit-taking leaf
732/// children than the pool has permits. If permits for *every* child were acquired
733/// before *any* task was spawned (batch-acquire-then-spawn), the acquire loop would
734/// block on permit `N+1` while the first `N` permits are held by not-yet-running
735/// tasks — a self-deadlock against a saturated pool. Spawning each task as soon as
736/// its permit is taken lets running tasks release permits the loop is waiting on.
737pub async fn walk_dir_contents<V>(
738    visitor: Arc<V>,
739    dir: Arc<Dir>,
740    parent_cx: &EntryCx,
741    dir_ctx: &V::DirContext,
742) -> Result<(V::Summary, ProcessedChildren), OperationError<V::Summary>>
743where
744    V: WalkVisitor,
745{
746    scope_tasks(walk_dir_contents_tracked(visitor, dir, parent_cx, dir_ctx)).await
747}
748
749#[async_recursion]
750async fn walk_dir_contents_tracked<V>(
751    visitor: Arc<V>,
752    dir: Arc<Dir>,
753    parent_cx: &EntryCx,
754    dir_ctx: &V::DirContext,
755) -> Result<(V::Summary, ProcessedChildren), OperationError<V::Summary>>
756where
757    V: WalkVisitor,
758{
759    let entries = match dir.read_entries().await {
760        Ok(entries) => entries,
761        Err(err) => {
762            let err = anyhow::Error::new(err)
763                .context(format!("cannot read directory {:?}", &parent_cx.real_path));
764            return Err(OperationError::new(err, Default::default()));
765        }
766    };
767    walk_dir_entries(visitor, dir, parent_cx, dir_ctx, entries).await
768}
769
770/// Produces one filtered exclusion's shared progress and visitor-owned summary.
771fn filter_skip_summary<V: WalkVisitor>(
772    visitor: &V,
773    child_cx: &EntryCx,
774    kind: EntryKind,
775    skip_result: &crate::filter::FilterResult,
776) -> V::Summary {
777    tracing::debug!("skipping {:?} due to filter", &child_cx.real_path);
778    kind.inc_skipped(child_cx.prog_track);
779    visitor.on_skip(child_cx, kind, skip_result)
780}
781
782enum ScheduledEntry {
783    Unclassified(EntryAdmission),
784    Authoritative(EntryAdmission),
785}
786
787impl ScheduledEntry {
788    async fn ensure_admission(self, permit_kind: PermitKind) -> Self {
789        match self {
790            Self::Unclassified(admission) => {
791                Self::Unclassified(walk::ensure_entry_admission(permit_kind, admission).await)
792            }
793            Self::Authoritative(admission) => {
794                Self::Authoritative(walk::ensure_entry_admission(permit_kind, admission).await)
795            }
796        }
797    }
798}
799
800struct WalkEntryResult<S> {
801    summary: S,
802    processed: Option<(usize, OsString)>,
803}
804
805struct WalkEntryFold<S> {
806    summary: S,
807    processed: Vec<(usize, OsString)>,
808    errors: crate::error_collector::ErrorCollector,
809}
810
811impl<S: WalkSummary> WalkEntryFold<S> {
812    fn new(summary: S) -> Self {
813        Self {
814            summary,
815            processed: Vec::new(),
816            errors: crate::error_collector::ErrorCollector::default(),
817        }
818    }
819
820    fn add_summary(&mut self, summary: S) {
821        self.summary = std::mem::take(&mut self.summary) + summary;
822    }
823
824    fn push(
825        &mut self,
826        result: Result<Result<WalkEntryResult<S>, OperationError<S>>, tokio::task::JoinError>,
827        fail_early: bool,
828    ) -> Result<(), anyhow::Error> {
829        match result {
830            Ok(Ok(child)) => {
831                self.add_summary(child.summary);
832                self.processed.extend(child.processed);
833            }
834            Ok(Err(error)) => {
835                tracing::error!("walk child failed with: {:#}", &error);
836                self.add_summary(error.summary);
837                if fail_early {
838                    return Err(error.source);
839                }
840                self.errors.push(error.source);
841            }
842            Err(join_error) => {
843                if fail_early {
844                    return Err(join_error.into());
845                }
846                self.errors.push(join_error.into());
847            }
848        }
849        Ok(())
850    }
851
852    fn fail(self, error: anyhow::Error) -> OperationError<S> {
853        OperationError::new(error, self.summary)
854    }
855
856    fn finish(self) -> Result<(S, ProcessedChildren), OperationError<S>> {
857        let Self {
858            summary,
859            mut processed,
860            errors,
861        } = self;
862        if let Some(error) = errors.into_error() {
863            return Err(OperationError::new(error, summary));
864        }
865        processed.sort_unstable_by_key(|(ordinal, _)| *ordinal);
866        Ok((
867            summary,
868            ProcessedChildren {
869                names: processed.into_iter().map(|(_, name)| name).collect(),
870            },
871        ))
872    }
873}
874
875async fn process_scheduled_entry<V>(
876    visitor: Arc<V>,
877    cx: EntryCx,
878    parent_ctx: V::DirContext,
879    ordinal: usize,
880    name: OsString,
881    scheduled: ScheduledEntry,
882) -> Result<WalkEntryResult<V::Summary>, OperationError<V::Summary>>
883where
884    V: WalkVisitor,
885{
886    let _ops_guard = cx.prog_track.ops.guard();
887    let (admission, authoritative) = match scheduled {
888        ScheduledEntry::Unclassified(admission) => (admission, false),
889        ScheduledEntry::Authoritative(admission) => (admission, true),
890    };
891    let classified =
892        classify_for_dispatch(visitor.as_ref(), &cx, EntrySource::Unclassified(admission)).await?;
893    if authoritative
894        && let Some(skip_result) = walk::should_skip_entry_ref(
895            visitor.filter(),
896            &cx.filter_path,
897            classified.kind() == EntryKind::Dir,
898        )
899    {
900        let summary = filter_skip_summary(visitor.as_ref(), &cx, classified.kind(), &skip_result);
901        drop(classified);
902        return Ok(WalkEntryResult {
903            summary,
904            processed: None,
905        });
906    }
907    let summary = dispatch_classified_entry(visitor, cx, parent_ctx, classified).await?;
908    Ok(WalkEntryResult {
909        summary,
910        processed: Some((ordinal, name)),
911    })
912}
913
914async fn ensure_scheduled_admission<S>(
915    scheduled: ScheduledEntry,
916    permit_kind: PermitKind,
917    join_set: &mut tokio::task::JoinSet<Result<WalkEntryResult<S>, OperationError<S>>>,
918    fail_early: bool,
919    fold: &mut WalkEntryFold<S>,
920) -> Result<ScheduledEntry, anyhow::Error>
921where
922    S: WalkSummary,
923{
924    let admission = scheduled.ensure_admission(permit_kind);
925    tokio::pin!(admission);
926    loop {
927        let has_tasks = !join_set.is_empty();
928        tokio::select! {
929            // a completed fail-early error must win over newly available capacity; otherwise later
930            // permanently pending siblings can consume that capacity and strand the unread error.
931            biased;
932            result = join_set.join_next(), if has_tasks => {
933                fold.push(
934                    result.expect("a non-empty walk JoinSet must yield one task result"),
935                    fail_early,
936                )?;
937            }
938            scheduled = &mut admission => return Ok(scheduled),
939        }
940    }
941}
942
943/// Process an already-enumerated directory listing.
944///
945/// Keeping enumeration separate from admission/scheduling gives tests a deterministic way to
946/// supply unavailable or stale hints without requiring a particular filesystem race. Production
947/// callers always arrive through [`walk_dir_contents`].
948pub(crate) async fn walk_dir_entries<V>(
949    visitor: Arc<V>,
950    dir: Arc<Dir>,
951    parent_cx: &EntryCx,
952    dir_ctx: &V::DirContext,
953    entries: Vec<(OsString, Option<EntryKind>)>,
954) -> Result<(V::Summary, ProcessedChildren), OperationError<V::Summary>>
955where
956    V: WalkVisitor,
957{
958    scope_tasks(walk_dir_entries_tracked(
959        visitor, parent_cx, dir_ctx, dir, entries,
960    ))
961    .await
962}
963
964async fn walk_dir_entries_tracked<V>(
965    visitor: Arc<V>,
966    parent_cx: &EntryCx,
967    dir_ctx: &V::DirContext,
968    dir: Arc<Dir>,
969    entries: Vec<(OsString, Option<EntryKind>)>,
970) -> Result<(V::Summary, ProcessedChildren), OperationError<V::Summary>>
971where
972    V: WalkVisitor,
973{
974    let fail_early = visitor.fail_early();
975    let mut fold = WalkEntryFold::new(V::Summary::default());
976    let mut join_set = tokio::task::JoinSet::new();
977    for (ordinal, (entry_name, hint)) in entries.into_iter().enumerate() {
978        // build the child's owned context once; reused whether it is skipped or spawned, and gives
979        // an authoritative probe failure its operation-path context.
980        let child_cx = parent_cx.child(Arc::clone(&dir), &entry_name);
981        let authoritative_filter =
982            visitor.filter().is_some() && visitor.filter_requires_admitted_entry();
983        if visitor.filter_allows_hint_only_skip(dir_ctx)
984            && let Some(hinted_kind) = hint
985            && let Some(skip_result) = walk::should_skip_entry_ref(
986                visitor.filter(),
987                &child_cx.filter_path,
988                hinted_kind == EntryKind::Dir,
989            )
990        {
991            // the visitor confirmed that omitting this child cannot authorize downstream work, so
992            // a reliable hint may retain this cheap path. every other outcome is scheduled; an
993            // authoritative visitor rechecks it against the worker's exact classification.
994            fold.add_summary(filter_skip_summary(
995                visitor.as_ref(),
996                &child_cx,
997                hinted_kind,
998                &skip_result,
999            ));
1000            continue;
1001        }
1002        let admission = EntryAdmission::from_hint(hint);
1003        let scheduled = if authoritative_filter || visitor.filter().is_some() && hint.is_none() {
1004            ScheduledEntry::Authoritative(admission)
1005        } else {
1006            ScheduledEntry::Unclassified(admission)
1007        };
1008        // own everything moved into the task (cancellation safety): the child context
1009        // (source parent Arc + owned name/paths), the visitor handle, and a clone of
1010        // the inherited context (copy's destination parent dir).
1011        let task_visitor = Arc::clone(&visitor);
1012        let task_ctx = dir_ctx.clone();
1013        let scheduled = match ensure_scheduled_admission(
1014            scheduled,
1015            visitor.permit_kind(),
1016            &mut join_set,
1017            fail_early,
1018            &mut fold,
1019        )
1020        .await
1021        {
1022            Ok(scheduled) => scheduled,
1023            Err(error) => {
1024                abort_and_join(&mut join_set).await;
1025                return Err(fold.fail(error));
1026            }
1027        };
1028        spawn_tracked(
1029            &mut join_set,
1030            process_scheduled_entry(
1031                task_visitor,
1032                child_cx,
1033                task_ctx,
1034                ordinal,
1035                entry_name,
1036                scheduled,
1037            ),
1038        );
1039    }
1040    join_walk_entries(join_set, fail_early, fold).await
1041}
1042
1043/// Cancels every remaining task and waits for their direct futures to finish dropping.
1044pub(crate) async fn abort_and_join<T: 'static>(join_set: &mut tokio::task::JoinSet<T>) {
1045    join_set.abort_all();
1046    while join_set.join_next().await.is_some() {}
1047}
1048
1049async fn join_walk_entries<S>(
1050    mut join_set: tokio::task::JoinSet<Result<WalkEntryResult<S>, OperationError<S>>>,
1051    fail_early: bool,
1052    mut fold: WalkEntryFold<S>,
1053) -> Result<(S, ProcessedChildren), OperationError<S>>
1054where
1055    S: WalkSummary,
1056{
1057    while let Some(result) = join_set.join_next().await {
1058        if let Err(error) = fold.push(result, fail_early) {
1059            abort_and_join(&mut join_set).await;
1060            return Err(fold.fail(error));
1061        }
1062    }
1063    fold.finish()
1064}
1065
1066/// Join an already-populated `JoinSet` of per-child tasks and fold their summaries with fail-early /
1067/// error-collection semantics.
1068///
1069/// Callers spawn into the `JoinSet` incrementally (acquire-then-spawn, see
1070/// [`walk_dir_contents`]) so held leaf permits are released by running tasks rather than all held
1071/// before the first task runs. `base` seeds the fold. On
1072/// `fail_early`, the first task error aborts the remaining tasks. The enclosing
1073/// task scope waits for recursively owned async tasks to finish dropping before the operation
1074/// returns. Already-started blocking jobs are not cancellable and do not delay this return; their
1075/// separate fd-admission leases remain live until their work and abandoned outputs finish.
1076/// Otherwise all errors are collected and deduplicated, and the single
1077/// combined error (if any) is returned with the full folded summary.
1078pub async fn join_and_fold<S>(
1079    mut join_set: tokio::task::JoinSet<Result<S, OperationError<S>>>,
1080    fail_early: bool,
1081    base: S,
1082) -> Result<S, OperationError<S>>
1083where
1084    S: WalkSummary,
1085{
1086    let mut summary = base;
1087    let errors = crate::error_collector::ErrorCollector::default();
1088    while let Some(res) = join_set.join_next().await {
1089        match res {
1090            Ok(Ok(child_summary)) => summary = summary + child_summary,
1091            Ok(Err(error)) => {
1092                tracing::error!("walk child failed with: {:#}", &error);
1093                summary = summary + error.summary;
1094                if fail_early {
1095                    abort_and_join(&mut join_set).await;
1096                    return Err(OperationError::new(error.source, summary));
1097                }
1098                errors.push(error.source);
1099            }
1100            Err(join_error) => {
1101                if fail_early {
1102                    abort_and_join(&mut join_set).await;
1103                    return Err(OperationError::new(join_error.into(), summary));
1104                }
1105                errors.push(join_error.into());
1106            }
1107        }
1108    }
1109    if let Some(error) = errors.into_error() {
1110        return Err(OperationError::new(error, summary));
1111    }
1112    Ok(summary)
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118    use crate::filter::FilterSettings;
1119    use crate::progress::Progress;
1120    use std::sync::atomic::{AtomicUsize, Ordering};
1121    use std::time::Duration;
1122
1123    static PROGRESS: std::sync::LazyLock<Progress> = std::sync::LazyLock::new(Progress::new);
1124
1125    /// A minimal `Summary` for the driver tests: counts files, dirs, and symlinks.
1126    #[derive(Clone, Copy, Default, Debug, PartialEq, Eq)]
1127    struct CountSummary {
1128        files: usize,
1129        dirs: usize,
1130        symlinks: usize,
1131    }
1132
1133    impl std::ops::Add for CountSummary {
1134        type Output = Self;
1135        fn add(self, other: Self) -> Self {
1136            Self {
1137                files: self.files + other.files,
1138                dirs: self.dirs + other.dirs,
1139                symlinks: self.symlinks + other.symlinks,
1140            }
1141        }
1142    }
1143
1144    /// A trivial visitor that just counts entries by kind. Exercises RPITIT +
1145    /// `Send` + recursion (compile and run). `DirState = ()`; leaf permit comes
1146    /// from the pending-meta pool for every possible leaf.
1147    struct CountingVisitor {
1148        /// counts every spawned leaf, to prove `visit_leaf` ran under backpressure.
1149        leaves_seen: Arc<AtomicUsize>,
1150        /// optional filter used by ordering tests.
1151        filter: Option<FilterSettings>,
1152        /// whether the filter authorizes destructive work and needs an exact entry transfer.
1153        authoritative_filter: bool,
1154    }
1155
1156    impl WalkVisitor for CountingVisitor {
1157        type Summary = CountSummary;
1158        type DirContext = ();
1159        type DirState = ();
1160
1161        fn root_dir_context(&self) {}
1162
1163        fn permit_kind(&self) -> PermitKind {
1164            PermitKind::PendingMeta
1165        }
1166        fn fail_early(&self) -> bool {
1167            false
1168        }
1169        fn filter(&self) -> Option<&FilterSettings> {
1170            self.filter.as_ref()
1171        }
1172        fn filter_requires_admitted_entry(&self) -> bool {
1173            self.authoritative_filter
1174        }
1175
1176        async fn visit_leaf(
1177            &self,
1178            _cx: &EntryCx,
1179            _parent_ctx: &(),
1180            leaf: AdmittedLeaf,
1181        ) -> Result<CountSummary, OperationError<CountSummary>> {
1182            self.leaves_seen.fetch_add(1, Ordering::SeqCst);
1183            Ok(match leaf.kind() {
1184                EntryKind::Symlink => CountSummary {
1185                    symlinks: 1,
1186                    ..Default::default()
1187                },
1188                _ => CountSummary {
1189                    files: 1,
1190                    ..Default::default()
1191                },
1192            })
1193        }
1194
1195        async fn dir_pre(
1196            &self,
1197            cx: &EntryCx,
1198            _parent_ctx: &(),
1199            _handle: &Handle,
1200        ) -> Result<DirAction<CountSummary, (), ()>, OperationError<CountSummary>> {
1201            // open the directory's contents fd (O_NOFOLLOW) and descend.
1202            let dir = cx.parent.open_dir(&cx.name).await.map_err(|err| {
1203                OperationError::new(
1204                    anyhow::Error::new(err)
1205                        .context(format!("cannot open directory {:?}", &cx.real_path)),
1206                    Default::default(),
1207                )
1208            })?;
1209            Ok(DirAction::Descend {
1210                dir: Arc::new(dir),
1211                child_ctx: (),
1212                state: (),
1213            })
1214        }
1215
1216        async fn dir_post(
1217            &self,
1218            _cx: &EntryCx,
1219            _state: (),
1220            _processed: &ProcessedChildren,
1221            child_result: Result<CountSummary, OperationError<CountSummary>>,
1222        ) -> Result<CountSummary, OperationError<CountSummary>> {
1223            // count this directory itself, post-order. a child error propagates (this test visitor
1224            // has `fail_early == false` but never errors, so the `Ok` arm is what runs).
1225            let child_summary = child_result?;
1226            Ok(child_summary
1227                + CountSummary {
1228                    dirs: 1,
1229                    ..Default::default()
1230                })
1231        }
1232    }
1233
1234    /// Build an `EntryCx` for the root directory `name` under `parent`.
1235    fn root_cx(parent: Arc<Dir>, name: &OsStr, real_path: PathBuf) -> EntryCx {
1236        EntryCx {
1237            parent,
1238            name: name.to_owned(),
1239            rel_path: PathBuf::new(),
1240            filter_path: PathBuf::new(),
1241            real_path,
1242            dry_run: false,
1243            prog_track: &PROGRESS,
1244        }
1245    }
1246
1247    struct DropNotice(Option<tokio::sync::oneshot::Sender<()>>);
1248
1249    impl Drop for DropNotice {
1250        fn drop(&mut self) {
1251            if let Some(sender) = self.0.take() {
1252                let _ = sender.send(());
1253            }
1254        }
1255    }
1256
1257    struct BlockingDropNotice {
1258        started: Option<tokio::sync::oneshot::Sender<()>>,
1259        release: std::sync::mpsc::Receiver<()>,
1260    }
1261
1262    impl Drop for BlockingDropNotice {
1263        fn drop(&mut self) {
1264            if let Some(started) = self.started.take() {
1265                let _ = started.send(());
1266            }
1267            let _ = self.release.recv();
1268        }
1269    }
1270
1271    #[tokio::test(flavor = "current_thread")]
1272    async fn task_scope_waits_for_an_unpolled_spawn_to_be_cancelled() {
1273        let (dropped_tx, mut dropped_rx) = tokio::sync::oneshot::channel();
1274        scope_tasks(async move {
1275            let mut join_set = tokio::task::JoinSet::new();
1276            let drop_notice = DropNotice(Some(dropped_tx));
1277            spawn_tracked(&mut join_set, async move {
1278                let _drop_notice = drop_notice;
1279                std::future::pending::<()>().await;
1280            });
1281        })
1282        .await;
1283        assert_eq!(
1284            dropped_rx.try_recv(),
1285            Ok(()),
1286            "task scope returned before its unpolled spawned child was destroyed"
1287        );
1288    }
1289
1290    #[test]
1291    fn task_scope_waits_for_an_unpolled_child_to_finish_dropping() -> anyhow::Result<()> {
1292        let runtime = tokio::runtime::Builder::new_multi_thread()
1293            .worker_threads(1)
1294            .enable_all()
1295            .build()?;
1296        runtime.block_on(async {
1297            // occupy the sole runtime worker so `spawn_tracked` can publish, then abort, a child
1298            // that Tokio has not polled. `block_on` itself continues driving the scope on this
1299            // calling thread.
1300            let (worker_started_tx, worker_started_rx) = tokio::sync::oneshot::channel();
1301            let (release_worker_tx, release_worker_rx) = std::sync::mpsc::channel();
1302            let worker = tokio::spawn(async move {
1303                let _ = worker_started_tx.send(());
1304                let _ = release_worker_rx.recv();
1305            });
1306            worker_started_rx
1307                .await
1308                .expect("the sole runtime worker did not start its blocker");
1309
1310            let (drop_started_tx, drop_started_rx) = tokio::sync::oneshot::channel();
1311            let (release_drop_tx, release_drop_rx) = std::sync::mpsc::channel();
1312            let child_drop = BlockingDropNotice {
1313                started: Some(drop_started_tx),
1314                release: release_drop_rx,
1315            };
1316            let mut scoped = Box::pin(scope_tasks(async move {
1317                let mut join_set = tokio::task::JoinSet::new();
1318                spawn_tracked(&mut join_set, async move {
1319                    let _child_drop = child_drop;
1320                    std::future::pending::<()>().await;
1321                });
1322                // tokio guarantees spawn does not poll synchronously. With its sole worker still
1323                // occupied, dropping this set aborts a deterministically unpolled child.
1324                drop(join_set);
1325            }));
1326            assert!(
1327                futures::poll!(scoped.as_mut()).is_pending(),
1328                "the task scope must wait for its registered child"
1329            );
1330
1331            release_worker_tx
1332                .send(())
1333                .expect("the worker blocker ended before release");
1334            drop_started_rx
1335                .await
1336                .expect("the aborted child did not start dropping");
1337            let scope_poll = futures::poll!(scoped.as_mut());
1338            let waited_for_child_drop = scope_poll.is_pending();
1339
1340            let release_result = release_drop_tx.send(());
1341            if scope_poll.is_pending() {
1342                scoped.await;
1343            }
1344            let worker_result = worker.await;
1345            release_result.expect("the child finished dropping before release");
1346            worker_result.expect("the worker blocker panicked");
1347            assert!(
1348                waited_for_child_drop,
1349                "task scope returned while an unpolled child's captures were still dropping"
1350            );
1351            anyhow::Ok(())
1352        })
1353    }
1354
1355    #[tokio::test(flavor = "current_thread")]
1356    async fn fail_early_cancels_and_quiesces_nested_async_work() {
1357        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1358        let (dropped_tx, mut dropped_rx) = tokio::sync::oneshot::channel();
1359        let nested_completed = Arc::new(std::sync::atomic::AtomicBool::new(false));
1360        let task_completed = Arc::clone(&nested_completed);
1361        let result = scope_tasks(async move {
1362            let mut join_set = tokio::task::JoinSet::new();
1363            spawn_tracked(&mut join_set, async move {
1364                let mut nested = tokio::task::JoinSet::new();
1365                spawn_tracked(&mut nested, async move {
1366                    let _drop_notice = DropNotice(Some(dropped_tx));
1367                    let _ = started_tx.send(());
1368                    std::future::pending::<()>().await;
1369                    task_completed.store(true, std::sync::atomic::Ordering::SeqCst);
1370                });
1371                while nested.join_next().await.is_some() {}
1372                Ok(CountSummary::default())
1373            });
1374            started_rx.await.expect("sibling task did not start");
1375            spawn_tracked(&mut join_set, async {
1376                Err(OperationError::new(
1377                    anyhow::anyhow!("stop the walk"),
1378                    CountSummary::default(),
1379                ))
1380            });
1381            join_and_fold(join_set, true, CountSummary::default()).await
1382        })
1383        .await;
1384
1385        assert!(result.is_err());
1386        assert!(
1387            dropped_rx.try_recv().is_ok(),
1388            "fail-early returned before nested sibling work was dropped"
1389        );
1390        assert!(!nested_completed.load(std::sync::atomic::Ordering::SeqCst));
1391    }
1392
1393    #[tokio::test(flavor = "current_thread")]
1394    async fn fail_early_returns_while_non_cancellable_work_retains_admission() {
1395        let admission = crate::testutils::AdmissionLimit::new().await;
1396        admission.set_files_in_flight(1);
1397        let open_file_guard = throttle::open_file_permit().await;
1398        let fd_admission = open_file_guard.admission();
1399        let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1400        let (release_tx, release_rx) = std::sync::mpsc::channel();
1401        let (completed_tx, completed_rx) = tokio::sync::oneshot::channel();
1402        let (inner_done_tx, inner_done_rx) = tokio::sync::oneshot::channel();
1403
1404        let operation = tokio::spawn(scope_tasks(async move {
1405            let mut join_set = tokio::task::JoinSet::new();
1406            spawn_tracked(&mut join_set, async move {
1407                let _open_file_guard = open_file_guard;
1408                let result = crate::safedir::with_fd_admission(fd_admission, async move {
1409                    crate::safedir::run_fd_admitted_blocking(move || {
1410                        let _ = started_tx.send(());
1411                        let _ = release_rx.recv();
1412                        let _ = completed_tx.send(());
1413                        Ok(())
1414                    })
1415                    .await
1416                })
1417                .await;
1418                result
1419                    .map(|()| CountSummary::default())
1420                    .map_err(|error| OperationError::new(error.into(), CountSummary::default()))
1421            });
1422            started_rx.await.expect("blocking sibling did not start");
1423            spawn_tracked(&mut join_set, async {
1424                Err(OperationError::new(
1425                    anyhow::anyhow!("stop the walk"),
1426                    CountSummary::default(),
1427                ))
1428            });
1429            let result = join_and_fold(join_set, true, CountSummary::default()).await;
1430            let _ = inner_done_tx.send(());
1431            result
1432        }));
1433
1434        inner_done_rx
1435            .await
1436            .expect("fail-early join did not cancel its async siblings");
1437        tokio::task::yield_now().await;
1438        let returned_before_blocking_release = operation.is_finished();
1439        let mut second_permit = Box::pin(throttle::open_file_permit());
1440        let admission_retained = futures::poll!(second_permit.as_mut()).is_pending();
1441
1442        let release_result = release_tx.send(());
1443        let cleanup = admission
1444            .run_with_timeout(Duration::from_secs(20), async {
1445                let completion = completed_rx.await;
1446                let permit = second_permit.await;
1447                drop(permit);
1448                let operation_result = operation.await;
1449                (completion, operation_result)
1450            })
1451            .await;
1452
1453        release_result.expect("blocking sibling ended before release");
1454        let (completion, operation_result) = cleanup.expect("blocking sibling did not quiesce");
1455        completion.expect("blocking sibling did not report completion");
1456        assert!(
1457            operation_result
1458                .expect("scoped fail-early operation panicked")
1459                .is_err()
1460        );
1461        assert!(
1462            returned_before_blocking_release,
1463            "fail-early waited for non-cancellable blocking work"
1464        );
1465        assert!(
1466            admission_retained,
1467            "cancelled blocking work released admission before finishing"
1468        );
1469    }
1470
1471    // The driver compiles and runs end-to-end (RPITIT + Send + recursion) and counts
1472    // a real tree correctly. `setup_test_dir` builds foo/{0.txt, bar/{1,2,3.txt},
1473    // baz/{4.txt, 5.txt->sym, 6.txt->sym}}: under `foo` there are 5 files, 2
1474    // subdirectories, and 2 symlinks.
1475    #[tokio::test]
1476    async fn counts_entries_in_a_tree() -> anyhow::Result<()> {
1477        let tmp = crate::testutils::setup_test_dir().await?;
1478        let foo = tmp.join("foo");
1479        // open the foo directory as the hardened root to walk its contents.
1480        let root = Arc::new(Dir::open_root_dir(&foo, false, congestion::Side::Source).await?);
1481        let leaves_seen = Arc::new(AtomicUsize::new(0));
1482        let visitor = Arc::new(CountingVisitor {
1483            leaves_seen: Arc::clone(&leaves_seen),
1484            filter: None,
1485            authoritative_filter: false,
1486        });
1487        let cx = root_cx(Arc::clone(&root), std::ffi::OsStr::new("foo"), foo.clone());
1488        let (summary, processed) = walk_dir_contents(visitor, root, &cx, &()).await?;
1489        assert_eq!(
1490            summary,
1491            CountSummary {
1492                files: 5,
1493                dirs: 2,
1494                symlinks: 2,
1495            },
1496            "the walk must count every entry once, by kind"
1497        );
1498        // the top-level processed list is foo's direct children: 0.txt, bar, baz.
1499        assert_eq!(processed.names().len(), 3, "foo has three direct children");
1500        assert_eq!(
1501            leaves_seen.load(Ordering::SeqCst),
1502            7,
1503            "every non-directory leaf (5 files + 2 symlinks) was visited"
1504        );
1505        Ok(())
1506    }
1507
1508    /// Driver-level deadlock regressions. Each test scopes process-wide admission changes with an
1509    /// `AdmissionLimit` guard.
1510    mod max_files_in_flight_tests {
1511        use super::*;
1512        use anyhow::Context;
1513        use std::os::fd::AsRawFd;
1514        use std::os::unix::fs::MetadataExt;
1515
1516        struct TaskDropContext {
1517            id: Option<usize>,
1518            next_id: Arc<AtomicUsize>,
1519            dropped: tokio::sync::mpsc::UnboundedSender<usize>,
1520        }
1521
1522        impl TaskDropContext {
1523            fn root(dropped: tokio::sync::mpsc::UnboundedSender<usize>) -> Self {
1524                Self {
1525                    id: None,
1526                    next_id: Arc::new(AtomicUsize::new(0)),
1527                    dropped,
1528                }
1529            }
1530        }
1531
1532        impl Clone for TaskDropContext {
1533            fn clone(&self) -> Self {
1534                Self {
1535                    id: Some(self.next_id.fetch_add(1, Ordering::SeqCst)),
1536                    next_id: Arc::clone(&self.next_id),
1537                    dropped: self.dropped.clone(),
1538                }
1539            }
1540        }
1541
1542        impl Drop for TaskDropContext {
1543            fn drop(&mut self) {
1544                if let Some(id) = self.id {
1545                    let _ = self.dropped.send(id);
1546                }
1547            }
1548        }
1549
1550        struct FailEarlyClassificationVisitor {
1551            filter: FilterSettings,
1552            leaf_started: Arc<std::sync::atomic::AtomicBool>,
1553        }
1554
1555        impl WalkVisitor for FailEarlyClassificationVisitor {
1556            type Summary = CountSummary;
1557            type DirContext = TaskDropContext;
1558            type DirState = ();
1559
1560            fn root_dir_context(&self) -> TaskDropContext {
1561                let (dropped, _) = tokio::sync::mpsc::unbounded_channel();
1562                TaskDropContext::root(dropped)
1563            }
1564
1565            fn permit_kind(&self) -> PermitKind {
1566                PermitKind::PendingMeta
1567            }
1568
1569            fn fail_early(&self) -> bool {
1570                true
1571            }
1572
1573            fn filter(&self) -> Option<&FilterSettings> {
1574                Some(&self.filter)
1575            }
1576
1577            fn filter_requires_admitted_entry(&self) -> bool {
1578                true
1579            }
1580
1581            async fn visit_leaf(
1582                &self,
1583                _cx: &EntryCx,
1584                _parent_ctx: &TaskDropContext,
1585                _leaf: AdmittedLeaf,
1586            ) -> Result<CountSummary, OperationError<CountSummary>> {
1587                self.leaf_started.store(true, Ordering::SeqCst);
1588                std::future::pending().await
1589            }
1590
1591            async fn dir_pre(
1592                &self,
1593                _cx: &EntryCx,
1594                _parent_ctx: &TaskDropContext,
1595                _handle: &Handle,
1596            ) -> DirPreResult<Self> {
1597                unreachable!("fail-early classification visitor must not see a directory")
1598            }
1599
1600            async fn dir_post(
1601                &self,
1602                _cx: &EntryCx,
1603                _state: (),
1604                _processed: &ProcessedChildren,
1605                _child_result: Result<CountSummary, OperationError<CountSummary>>,
1606            ) -> Result<CountSummary, OperationError<CountSummary>> {
1607                unreachable!("fail-early classification visitor must not recurse")
1608            }
1609        }
1610
1611        #[derive(Debug, PartialEq, Eq)]
1612        enum CompletionEvent {
1613            Started(OsString),
1614            Completed(OsString),
1615        }
1616
1617        struct OrderedFilterVisitor {
1618            filter: FilterSettings,
1619            leaf_releases: std::sync::Mutex<
1620                std::collections::HashMap<OsString, tokio::sync::oneshot::Receiver<()>>,
1621            >,
1622            skip_release: std::sync::Mutex<Option<std::sync::mpsc::Receiver<()>>>,
1623            events: tokio::sync::mpsc::UnboundedSender<CompletionEvent>,
1624        }
1625
1626        impl WalkVisitor for OrderedFilterVisitor {
1627            type Summary = CountSummary;
1628            type DirContext = ();
1629            type DirState = ();
1630
1631            fn root_dir_context(&self) {}
1632
1633            fn permit_kind(&self) -> PermitKind {
1634                PermitKind::PendingMeta
1635            }
1636
1637            fn fail_early(&self) -> bool {
1638                false
1639            }
1640
1641            fn filter(&self) -> Option<&FilterSettings> {
1642                Some(&self.filter)
1643            }
1644
1645            fn filter_requires_admitted_entry(&self) -> bool {
1646                true
1647            }
1648
1649            fn on_skip(
1650                &self,
1651                cx: &EntryCx,
1652                _kind: EntryKind,
1653                _skip_result: &crate::filter::FilterResult,
1654            ) -> CountSummary {
1655                let _ = self.events.send(CompletionEvent::Started(cx.name.clone()));
1656                self.skip_release
1657                    .lock()
1658                    .expect("skip release lock poisoned")
1659                    .take()
1660                    .expect("the exact exclusion runs once")
1661                    .recv()
1662                    .expect("the exact exclusion gate was dropped before release");
1663                let _ = self
1664                    .events
1665                    .send(CompletionEvent::Completed(cx.name.clone()));
1666                CountSummary::default()
1667            }
1668
1669            async fn visit_leaf(
1670                &self,
1671                cx: &EntryCx,
1672                _parent_ctx: &(),
1673                _leaf: AdmittedLeaf,
1674            ) -> Result<CountSummary, OperationError<CountSummary>> {
1675                let release = self
1676                    .leaf_releases
1677                    .lock()
1678                    .expect("leaf release lock poisoned")
1679                    .remove(&cx.name)
1680                    .expect("included leaf has one completion gate");
1681                let _ = self.events.send(CompletionEvent::Started(cx.name.clone()));
1682                release
1683                    .await
1684                    .expect("included leaf gate was dropped before release");
1685                let _ = self
1686                    .events
1687                    .send(CompletionEvent::Completed(cx.name.clone()));
1688                Ok(CountSummary {
1689                    files: 1,
1690                    ..Default::default()
1691                })
1692            }
1693
1694            async fn dir_pre(
1695                &self,
1696                _cx: &EntryCx,
1697                _parent_ctx: &(),
1698                _handle: &Handle,
1699            ) -> DirPreResult<Self> {
1700                unreachable!("the exact directory exclusion must run before dispatch")
1701            }
1702
1703            async fn dir_post(
1704                &self,
1705                _cx: &EntryCx,
1706                _state: (),
1707                _processed: &ProcessedChildren,
1708                _child_result: Result<CountSummary, OperationError<CountSummary>>,
1709            ) -> Result<CountSummary, OperationError<CountSummary>> {
1710                unreachable!("ordered filter visitor must not recurse")
1711            }
1712        }
1713
1714        #[derive(Clone, Copy)]
1715        enum LeafOutcome {
1716            Success,
1717            Error,
1718        }
1719
1720        struct LeafLifetimeVisitor {
1721            started: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<std::os::fd::RawFd>>>,
1722            release: Arc<tokio::sync::Notify>,
1723            outcome: LeafOutcome,
1724            dropped: Option<crate::testutils::DropEvents>,
1725        }
1726
1727        struct DirPreLifetimeVisitor {
1728            file_path: std::path::PathBuf,
1729            started: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
1730            release: std::sync::Mutex<Option<std::sync::mpsc::Receiver<()>>>,
1731            completed: std::sync::Mutex<Option<tokio::sync::oneshot::Sender<()>>>,
1732        }
1733
1734        impl WalkVisitor for DirPreLifetimeVisitor {
1735            type Summary = CountSummary;
1736            type DirContext = ();
1737            type DirState = ();
1738
1739            fn root_dir_context(&self) {}
1740
1741            fn permit_kind(&self) -> PermitKind {
1742                PermitKind::PendingMeta
1743            }
1744
1745            fn fail_early(&self) -> bool {
1746                false
1747            }
1748
1749            fn filter(&self) -> Option<&FilterSettings> {
1750                None
1751            }
1752
1753            async fn visit_leaf(
1754                &self,
1755                _cx: &EntryCx,
1756                _parent_ctx: &(),
1757                _leaf: AdmittedLeaf,
1758            ) -> Result<CountSummary, OperationError<CountSummary>> {
1759                unreachable!("dir-pre lifetime visitor must not see a leaf")
1760            }
1761
1762            async fn dir_pre(
1763                &self,
1764                _cx: &EntryCx,
1765                _parent_ctx: &(),
1766                _handle: &Handle,
1767            ) -> DirPreResult<Self> {
1768                let file_path = self.file_path.clone();
1769                let started = self
1770                    .started
1771                    .lock()
1772                    .expect("started lock poisoned")
1773                    .take()
1774                    .expect("dir_pre runs once");
1775                let release = self
1776                    .release
1777                    .lock()
1778                    .expect("release lock poisoned")
1779                    .take()
1780                    .expect("dir_pre runs once");
1781                let completed = self
1782                    .completed
1783                    .lock()
1784                    .expect("completed lock poisoned")
1785                    .take()
1786                    .expect("dir_pre runs once");
1787                crate::safedir::run_fd_admitted_blocking(move || {
1788                    let file = std::fs::File::open(file_path)?;
1789                    let _ = started.send(());
1790                    release.recv().map_err(std::io::Error::other)?;
1791                    drop(file);
1792                    let _ = completed.send(());
1793                    Ok(())
1794                })
1795                .await
1796                .map_err(|err| {
1797                    OperationError::new(anyhow::Error::new(err), CountSummary::default())
1798                })?;
1799                Ok(DirAction::Skip(CountSummary::default()))
1800            }
1801
1802            async fn dir_post(
1803                &self,
1804                _cx: &EntryCx,
1805                _state: (),
1806                _processed: &ProcessedChildren,
1807                _child_result: Result<CountSummary, OperationError<CountSummary>>,
1808            ) -> Result<CountSummary, OperationError<CountSummary>> {
1809                unreachable!("dir-pre lifetime visitor does not descend")
1810            }
1811        }
1812
1813        impl WalkVisitor for LeafLifetimeVisitor {
1814            type Summary = CountSummary;
1815            type DirContext = ();
1816            type DirState = ();
1817
1818            fn root_dir_context(&self) {}
1819
1820            fn permit_kind(&self) -> PermitKind {
1821                PermitKind::PendingMeta
1822            }
1823
1824            fn fail_early(&self) -> bool {
1825                false
1826            }
1827
1828            fn filter(&self) -> Option<&FilterSettings> {
1829                None
1830            }
1831
1832            async fn visit_leaf(
1833                &self,
1834                _cx: &EntryCx,
1835                _parent_ctx: &(),
1836                leaf: AdmittedLeaf,
1837            ) -> Result<CountSummary, OperationError<CountSummary>> {
1838                let raw_fd = leaf.handle().as_fd().as_raw_fd();
1839                let _dropped = self.dropped.as_ref().map(|events| events.probe("leaf"));
1840                if let Some(started) = self.started.lock().expect("started lock poisoned").take() {
1841                    let _ = started.send(raw_fd);
1842                }
1843                self.release.notified().await;
1844                match self.outcome {
1845                    LeafOutcome::Success => Ok(CountSummary {
1846                        files: 1,
1847                        ..Default::default()
1848                    }),
1849                    LeafOutcome::Error => Err(OperationError::new(
1850                        anyhow::anyhow!("leaf operation failed"),
1851                        CountSummary::default(),
1852                    )),
1853                }
1854            }
1855
1856            async fn dir_pre(
1857                &self,
1858                _cx: &EntryCx,
1859                _parent_ctx: &(),
1860                _handle: &Handle,
1861            ) -> DirPreResult<Self> {
1862                unreachable!("leaf lifetime visitor must not see a directory")
1863            }
1864
1865            async fn dir_post(
1866                &self,
1867                _cx: &EntryCx,
1868                _state: (),
1869                _processed: &ProcessedChildren,
1870                _child_result: Result<CountSummary, OperationError<CountSummary>>,
1871            ) -> Result<CountSummary, OperationError<CountSummary>> {
1872                unreachable!("leaf lifetime visitor must not recurse")
1873            }
1874        }
1875
1876        async fn quiesce_failed_leaf_lifetime_operation(
1877            admission: crate::testutils::AdmissionLimit,
1878            task: tokio::task::JoinHandle<Result<CountSummary, OperationError<CountSummary>>>,
1879            release: Arc<tokio::sync::Notify>,
1880            root: std::path::PathBuf,
1881        ) -> anyhow::Result<()> {
1882            // wake the blocked visitor before aborting so either exit path can make progress.
1883            release.notify_waiters();
1884            task.abort();
1885            let task_result = task.await;
1886            let capacity_error =
1887                match tokio::time::timeout(Duration::from_secs(1), throttle::pending_meta_permit())
1888                    .await
1889                {
1890                    Ok(permit) => {
1891                        drop(permit);
1892                        None
1893                    }
1894                    Err(error) => Some(error),
1895                };
1896            drop(admission);
1897            let cleanup_result = tokio::fs::remove_dir_all(root).await;
1898
1899            if let Err(error) = task_result
1900                && !error.is_cancelled()
1901            {
1902                return Err(anyhow::Error::new(error)
1903                    .context("failed leaf task did not join after probe setup failed"));
1904            }
1905            if let Some(error) = capacity_error {
1906                return Err(anyhow::Error::new(error)
1907                    .context("failed leaf task did not return pending-meta admission"));
1908            }
1909            cleanup_result.context("failed leaf task fixture did not clean up")?;
1910            Ok(())
1911        }
1912
1913        async fn start_leaf_lifetime_operation(
1914            outcome: LeafOutcome,
1915            dropped: Option<crate::testutils::DropEvents>,
1916        ) -> anyhow::Result<(
1917            crate::testutils::AdmissionLimit,
1918            tokio::task::JoinHandle<Result<CountSummary, OperationError<CountSummary>>>,
1919            crate::testutils::FdIdentityProbe,
1920            Arc<tokio::sync::Notify>,
1921            std::path::PathBuf,
1922        )> {
1923            let root = crate::testutils::create_temp_dir().await?;
1924            let leaf_path = root.join("leaf");
1925            tokio::fs::write(&leaf_path, b"x").await?;
1926            let admission = crate::testutils::AdmissionLimit::new().await;
1927            admission.set_files_in_flight(1);
1928            let parent = Arc::new(
1929                Dir::open_parent_dir(&root, congestion::Side::Source)
1930                    .await?
1931                    .into_tree(),
1932            );
1933            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
1934            let release = Arc::new(tokio::sync::Notify::new());
1935            let visitor = Arc::new(LeafLifetimeVisitor {
1936                started: std::sync::Mutex::new(Some(started_tx)),
1937                release: Arc::clone(&release),
1938                outcome,
1939                dropped,
1940            });
1941            let cx = root_cx(parent, OsStr::new("leaf"), leaf_path);
1942            let permit =
1943                walk::preacquire_leaf_permit(PermitKind::PendingMeta, Some(EntryKind::File)).await;
1944            let task = tokio::spawn(process_entry(visitor, cx, (), permit));
1945            let raw_fd = match started_rx.await {
1946                Ok(raw_fd) => raw_fd,
1947                Err(error) => {
1948                    let failure = anyhow::Error::new(error).context("leaf visitor did not start");
1949                    let cleanup =
1950                        quiesce_failed_leaf_lifetime_operation(admission, task, release, root)
1951                            .await;
1952                    return match cleanup {
1953                        Ok(()) => Err(failure),
1954                        Err(cleanup_error) => Err(failure.context(format!(
1955                            "failed to quiesce the leaf task after probe setup failed: {cleanup_error:#}"
1956                        ))),
1957                    };
1958                }
1959            };
1960            let probe = match crate::testutils::FdIdentityProbe::capture(raw_fd) {
1961                Ok(probe) => probe,
1962                Err(error) => {
1963                    let failure = anyhow::Error::new(error);
1964                    let cleanup =
1965                        quiesce_failed_leaf_lifetime_operation(admission, task, release, root)
1966                            .await;
1967                    return match cleanup {
1968                        Ok(()) => Err(failure),
1969                        Err(cleanup_error) => Err(failure.context(format!(
1970                            "failed to quiesce the leaf task after probe setup failed: {cleanup_error:#}"
1971                        ))),
1972                    };
1973                }
1974            };
1975            Ok((admission, task, probe, release, root))
1976        }
1977
1978        #[tokio::test(flavor = "current_thread")]
1979        async fn failed_leaf_probe_capture_quiesces_before_returning_error() -> anyhow::Result<()> {
1980            let events = crate::testutils::DropEvents::default();
1981            let _failure = crate::testutils::inject_fd_identity_probe_failure(
1982                crate::testutils::FdIdentityProbeOperation::Capture,
1983                1,
1984            );
1985            let result =
1986                start_leaf_lifetime_operation(LeafOutcome::Success, Some(events.clone())).await;
1987
1988            let error = match result {
1989                Ok(_) => anyhow::bail!("the injected leaf probe capture error was not returned"),
1990                Err(error) => error,
1991            };
1992            assert!(
1993                format!("{error:#}").contains("injected fd identity probe failure"),
1994                "the leaf-start helper returned an unexpected error: {error:#}"
1995            );
1996            assert_eq!(
1997                events.snapshot(),
1998                ["leaf"],
1999                "the leaf task remained live when probe capture returned its error"
2000            );
2001            Ok(())
2002        }
2003
2004        #[tokio::test(flavor = "current_thread")]
2005        async fn successful_leaf_holds_admission_through_visitor() -> anyhow::Result<()> {
2006            let (admission, task, probe, release, root) =
2007                start_leaf_lifetime_operation(LeafOutcome::Success, None).await?;
2008            let mut next_permit = Box::pin(throttle::pending_meta_permit());
2009            assert!(
2010                futures::poll!(next_permit.as_mut()).is_pending(),
2011                "leaf admission returned while its classification handle was live"
2012            );
2013            release.notify_one();
2014            let summary_result = match task.await {
2015                Ok(Ok(summary)) => Ok(summary),
2016                Ok(Err(error)) => Err(error.source),
2017                Err(error) => Err(anyhow::Error::new(error)),
2018            };
2019            let fd_was_closed = probe.original_is_closed();
2020            let capacity_error = match tokio::time::timeout(Duration::from_secs(1), next_permit)
2021                .await
2022            {
2023                Ok(permit) => {
2024                    drop(permit);
2025                    None
2026                }
2027                Err(error) => Some(
2028                    anyhow::Error::new(error).context("successful leaf did not return admission"),
2029                ),
2030            };
2031            drop(admission);
2032            let cleanup_result = tokio::fs::remove_dir_all(root).await;
2033
2034            let summary = summary_result?;
2035            let fd_was_closed = fd_was_closed?;
2036            if let Some(error) = capacity_error {
2037                return Err(error);
2038            }
2039            cleanup_result?;
2040            assert_eq!(summary.files, 1);
2041            assert!(
2042                fd_was_closed,
2043                "successful leaf returned with its classification handle open"
2044            );
2045            Ok(())
2046        }
2047
2048        #[tokio::test(flavor = "current_thread")]
2049        async fn failed_leaf_holds_admission_through_visitor() -> anyhow::Result<()> {
2050            let (admission, task, probe, release, root) =
2051                start_leaf_lifetime_operation(LeafOutcome::Error, None).await?;
2052            let mut next_permit = Box::pin(throttle::pending_meta_permit());
2053            assert!(
2054                futures::poll!(next_permit.as_mut()).is_pending(),
2055                "leaf admission returned while its classification handle was live"
2056            );
2057            release.notify_one();
2058            let task_result = task.await;
2059            let fd_was_closed = probe.original_is_closed();
2060            let capacity_error =
2061                match tokio::time::timeout(Duration::from_secs(1), next_permit).await {
2062                    Ok(permit) => {
2063                        drop(permit);
2064                        None
2065                    }
2066                    Err(error) => Some(
2067                        anyhow::Error::new(error).context("failed leaf did not return admission"),
2068                    ),
2069                };
2070            drop(admission);
2071            let cleanup_result = tokio::fs::remove_dir_all(root).await;
2072
2073            let error = task_result?.expect_err("leaf operation must fail");
2074            let fd_was_closed = fd_was_closed?;
2075            if let Some(error) = capacity_error {
2076                return Err(error);
2077            }
2078            cleanup_result?;
2079            assert!(format!("{:#}", error.source).contains("leaf operation failed"));
2080            assert!(
2081                fd_was_closed,
2082                "failed leaf returned with its classification handle open"
2083            );
2084            Ok(())
2085        }
2086
2087        #[tokio::test(flavor = "current_thread")]
2088        async fn cancelled_leaf_drops_handle_and_returns_admission() -> anyhow::Result<()> {
2089            let (admission, task, probe, _release, root) =
2090                start_leaf_lifetime_operation(LeafOutcome::Success, None).await?;
2091            let mut next_permit = Box::pin(throttle::pending_meta_permit());
2092            assert!(
2093                futures::poll!(next_permit.as_mut()).is_pending(),
2094                "leaf admission returned while its classification handle was live"
2095            );
2096            task.abort();
2097            let task_result = task.await;
2098            let fd_was_closed = probe.original_is_closed();
2099            let capacity_error = match tokio::time::timeout(Duration::from_secs(1), next_permit)
2100                .await
2101            {
2102                Ok(permit) => {
2103                    drop(permit);
2104                    None
2105                }
2106                Err(error) => Some(
2107                    anyhow::Error::new(error).context("cancelled leaf did not return admission"),
2108                ),
2109            };
2110            drop(admission);
2111            let cleanup_result = tokio::fs::remove_dir_all(root).await;
2112
2113            let task_error = task_result.expect_err("leaf task must be cancelled");
2114            let fd_was_closed = fd_was_closed?;
2115            if let Some(error) = capacity_error {
2116                return Err(error);
2117            }
2118            cleanup_result?;
2119            assert!(task_error.is_cancelled());
2120            assert!(
2121                fd_was_closed,
2122                "cancelled leaf retained its classification handle"
2123            );
2124            Ok(())
2125        }
2126
2127        #[tokio::test(flavor = "current_thread")]
2128        async fn directory_ends_inner_scope_before_dir_pre_blocking_work() -> anyhow::Result<()> {
2129            let root = crate::testutils::create_temp_dir().await?;
2130            let directory_path = root.join("directory");
2131            let file_path = root.join("owned-by-dir-pre");
2132            tokio::fs::create_dir(&directory_path).await?;
2133            tokio::fs::write(&file_path, b"x").await?;
2134            let admission = crate::testutils::AdmissionLimit::new().await;
2135            admission.set_files_in_flight(1);
2136            let parent = Arc::new(
2137                Dir::open_parent_dir(&root, congestion::Side::Source)
2138                    .await?
2139                    .into_tree(),
2140            );
2141            let (started_tx, started_rx) = tokio::sync::oneshot::channel();
2142            let (release_tx, release_rx) = std::sync::mpsc::channel();
2143            let (completed_tx, completed_rx) = tokio::sync::oneshot::channel();
2144            let visitor = Arc::new(DirPreLifetimeVisitor {
2145                file_path,
2146                started: std::sync::Mutex::new(Some(started_tx)),
2147                release: std::sync::Mutex::new(Some(release_rx)),
2148                completed: std::sync::Mutex::new(Some(completed_tx)),
2149            });
2150            let cx = root_cx(parent, OsStr::new("directory"), directory_path);
2151            let outer_guard = throttle::open_file_permit().await;
2152            let outer_admission = outer_guard.admission();
2153            let task = tokio::spawn(crate::safedir::with_fd_admission(
2154                outer_admission,
2155                async move {
2156                    let _outer_guard = outer_guard;
2157                    process_entry(visitor, cx, (), EntryAdmission::RootOrDelegated).await
2158                },
2159            ));
2160            started_rx
2161                .await
2162                .context("dir_pre blocking work did not start")?;
2163            task.abort();
2164            let task_error = task.await.expect_err("directory task must be cancelled");
2165            assert!(task_error.is_cancelled());
2166            let mut next_permit = Box::pin(throttle::open_file_permit());
2167            let admission_was_retained = futures::poll!(next_permit.as_mut()).is_pending();
2168            let release_result = release_tx.send(());
2169            completed_rx
2170                .await
2171                .context("dir_pre blocking work did not complete")?;
2172            release_result.context("dir_pre blocking work ended before release")?;
2173            assert!(
2174                admission_was_retained,
2175                "the expired inner directory admission masked the live outer leaf admission"
2176            );
2177            drop(
2178                tokio::time::timeout(Duration::from_secs(1), next_permit)
2179                    .await
2180                    .context("dir_pre blocking work did not return outer admission")?,
2181            );
2182            tokio::fs::remove_dir_all(root).await?;
2183            Ok(())
2184        }
2185
2186        fn inode_is_open(path: &std::path::Path) -> anyhow::Result<bool> {
2187            let metadata = std::fs::symlink_metadata(path)?;
2188            for entry in std::fs::read_dir("/proc/self/fd")? {
2189                let entry = entry?;
2190                let Ok(open_metadata) = std::fs::metadata(entry.path()) else {
2191                    continue;
2192                };
2193                if open_metadata.dev() == metadata.dev() && open_metadata.ino() == metadata.ino() {
2194                    return Ok(true);
2195                }
2196            }
2197            Ok(false)
2198        }
2199
2200        /// A caller that did not pre-acquire must reserve capacity before the driver's fd-based
2201        /// authoritative classification, rather than opening a `Handle` first.
2202        #[tokio::test]
2203        async fn entry_acquires_capacity_before_classification() -> anyhow::Result<()> {
2204            let root = crate::testutils::create_temp_dir().await?;
2205            let leaf_path = root.join("leaf");
2206            tokio::fs::write(&leaf_path, b"x").await?;
2207            let admission = crate::testutils::AdmissionLimit::new().await;
2208            admission.set_files_in_flight(1);
2209            let parent = Arc::new(
2210                Dir::open_parent_dir(&root, congestion::Side::Source)
2211                    .await?
2212                    .into_tree(),
2213            );
2214            let leaves_seen = Arc::new(AtomicUsize::new(0));
2215            let visitor = Arc::new(CountingVisitor {
2216                leaves_seen: Arc::clone(&leaves_seen),
2217                filter: None,
2218                authoritative_filter: false,
2219            });
2220            let stat_resource =
2221                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
2222            admission.set_max_ops_in_flight(stat_resource, 1);
2223            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
2224            let cx = root_cx(parent, std::ffi::OsStr::new("leaf"), leaf_path);
2225            let operation = process_entry(visitor, cx, (), None);
2226            tokio::pin!(operation);
2227            let stopped_at_stat_gate = futures::poll!(operation.as_mut()).is_pending();
2228            let mut second_permit = Box::pin(throttle::pending_meta_permit());
2229            let classification_bypassed_admission =
2230                futures::poll!(second_permit.as_mut()).is_ready();
2231            drop(second_permit);
2232            drop(held_stat);
2233            let result = admission
2234                .run_with_timeout(Duration::from_secs(20), operation.as_mut())
2235                .await;
2236            assert!(
2237                stopped_at_stat_gate,
2238                "entry did not reach the held stat gate"
2239            );
2240            assert!(
2241                !classification_bypassed_admission,
2242                "entry reached fd-based classification before pending-metadata admission"
2243            );
2244            let summary = result
2245                .map_err(|_| anyhow::anyhow!("entry did not resume after capacity was released"))?
2246                .map_err(|error| error.source)?;
2247            assert_eq!(summary.files, 1);
2248            assert_eq!(leaves_seen.load(Ordering::SeqCst), 1);
2249            Ok(())
2250        }
2251
2252        /// A `DT_UNKNOWN` entry that needs fd-based classification for filtering must
2253        /// obtain admission before that probe, even when the filter ultimately skips it.
2254        #[tokio::test]
2255        async fn filtered_unknown_entry_waits_before_authoritative_filter_probe()
2256        -> anyhow::Result<()> {
2257            let root = crate::testutils::create_temp_dir().await?;
2258            tokio::fs::write(root.join("leaf"), b"x").await?;
2259            let admission = crate::testutils::AdmissionLimit::new().await;
2260            let dir = Arc::new(
2261                Dir::open_parent_dir(&root, congestion::Side::Source)
2262                    .await?
2263                    .into_tree(),
2264            );
2265            let mut filter = FilterSettings::default();
2266            filter.add_exclude("leaf")?;
2267            let visitor = Arc::new(CountingVisitor {
2268                leaves_seen: Arc::new(AtomicUsize::new(0)),
2269                filter: Some(filter),
2270                authoritative_filter: false,
2271            });
2272            let parent_cx = root_cx(Arc::clone(&dir), std::ffi::OsStr::new("root"), root.clone());
2273            admission.set_files_in_flight(1);
2274            let stat_resource =
2275                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
2276            admission.set_max_ops_in_flight(stat_resource, 1);
2277            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
2278            let walk = scope_tasks(walk_dir_entries(
2279                visitor,
2280                dir,
2281                &parent_cx,
2282                &(),
2283                vec![(std::ffi::OsString::from("leaf"), None)],
2284            ));
2285            tokio::pin!(walk);
2286            let stopped_at_stat_gate = futures::poll!(walk.as_mut()).is_pending();
2287            let mut second_permit = Box::pin(throttle::pending_meta_permit());
2288            let classification_bypassed_admission =
2289                futures::poll!(second_permit.as_mut()).is_ready();
2290            drop(second_permit);
2291            drop(held_stat);
2292            let result = admission
2293                .run_with_timeout(Duration::from_secs(20), walk.as_mut())
2294                .await;
2295            assert!(
2296                stopped_at_stat_gate,
2297                "DT_UNKNOWN filter probe did not reach the held stat gate"
2298            );
2299            assert!(
2300                !classification_bypassed_admission,
2301                "DT_UNKNOWN filter classification bypassed admission"
2302            );
2303            let (summary, processed) = result
2304                .map_err(|_| {
2305                    anyhow::anyhow!(
2306                        "filtered entry did not resume after stat capacity was released"
2307                    )
2308                })?
2309                .map_err(|error| error.source)?;
2310            assert_eq!(summary, CountSummary::default());
2311            assert!(
2312                processed.names().is_empty(),
2313                "filtered entry must not spawn"
2314            );
2315            Ok(())
2316        }
2317
2318        /// Authoritative classifications belong to independently scheduled workers.
2319        #[tokio::test(flavor = "current_thread")]
2320        async fn authoritative_filter_schedules_classifications_concurrently() -> anyhow::Result<()>
2321        {
2322            let root = crate::testutils::create_temp_dir().await?;
2323            tokio::fs::write(root.join("first"), b"x").await?;
2324            tokio::fs::write(root.join("second"), b"x").await?;
2325            let admission = crate::testutils::AdmissionLimit::new().await;
2326            admission.set_files_in_flight(2);
2327            let dir = Arc::new(
2328                Dir::open_parent_dir(&root, congestion::Side::Source)
2329                    .await?
2330                    .into_tree(),
2331            );
2332            let stat_resource =
2333                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
2334            admission.set_max_ops_in_flight(stat_resource, 1);
2335            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
2336            let mut filter = FilterSettings::default();
2337            filter.add_exclude("protected/")?;
2338            let visitor = Arc::new(CountingVisitor {
2339                leaves_seen: Arc::new(AtomicUsize::new(0)),
2340                filter: Some(filter),
2341                authoritative_filter: true,
2342            });
2343            let parent_cx = root_cx(Arc::clone(&dir), OsStr::new("root"), root);
2344            let walk = scope_tasks(walk_dir_entries(
2345                visitor,
2346                dir,
2347                &parent_cx,
2348                &(),
2349                vec![
2350                    (OsString::from("first"), Some(EntryKind::File)),
2351                    (OsString::from("second"), Some(EntryKind::File)),
2352                ],
2353            ));
2354            tokio::pin!(walk);
2355            assert!(
2356                futures::poll!(walk.as_mut()).is_pending(),
2357                "authoritative classifications did not reach the held stat gate"
2358            );
2359            let mut next_permit = Box::pin(throttle::pending_meta_permit());
2360            let both_workers_owned_admission = futures::poll!(next_permit.as_mut()).is_pending();
2361            drop(next_permit);
2362            drop(held_stat);
2363            assert!(
2364                both_workers_owned_admission,
2365                "the first authoritative classification serialized scheduling of its sibling"
2366            );
2367            let (summary, processed) = admission
2368                .run_with_timeout(Duration::from_secs(20), walk.as_mut())
2369                .await
2370                .context("authoritative classifications did not resume after stat release")?
2371                .map_err(|error| error.source)?;
2372            assert_eq!(summary.files, 2);
2373            assert_eq!(
2374                processed.names(),
2375                &[OsString::from("first"), OsString::from("second")]
2376            );
2377            Ok(())
2378        }
2379
2380        /// A worker classification failure is folded like any other child error.
2381        #[tokio::test]
2382        async fn classification_error_keeps_going_and_folds_siblings() -> anyhow::Result<()> {
2383            let root = crate::testutils::create_temp_dir().await?;
2384            tokio::fs::write(root.join("before"), b"x").await?;
2385            tokio::fs::write(root.join("after"), b"x").await?;
2386            let admission = crate::testutils::AdmissionLimit::new().await;
2387            let dir = Arc::new(
2388                Dir::open_parent_dir(&root, congestion::Side::Source)
2389                    .await?
2390                    .into_tree(),
2391            );
2392            let mut filter = FilterSettings::default();
2393            filter.add_exclude("protected/")?;
2394            let leaves_seen = Arc::new(AtomicUsize::new(0));
2395            let visitor = Arc::new(CountingVisitor {
2396                leaves_seen: Arc::clone(&leaves_seen),
2397                filter: Some(filter),
2398                authoritative_filter: true,
2399            });
2400            let parent_cx = root_cx(Arc::clone(&dir), OsStr::new("root"), root);
2401            let error = admission
2402                .run_with_timeout(
2403                    Duration::from_secs(20),
2404                    scope_tasks(walk_dir_entries(
2405                        visitor,
2406                        dir,
2407                        &parent_cx,
2408                        &(),
2409                        vec![
2410                            (OsString::from("before"), Some(EntryKind::File)),
2411                            (OsString::from("gone"), Some(EntryKind::File)),
2412                            (OsString::from("after"), Some(EntryKind::File)),
2413                        ],
2414                    )),
2415                )
2416                .await
2417                .context("keep-going authoritative walk did not terminate")?
2418                .expect_err("the vanished child classification must fail");
2419            assert_eq!(
2420                (error.summary.files, leaves_seen.load(Ordering::SeqCst)),
2421                (2, 2),
2422                "the error summary and visits must include both successful siblings"
2423            );
2424            Ok(())
2425        }
2426
2427        /// Fail-early classification errors cancel and fully drop every sibling task capture,
2428        /// whether the runtime had polled that sibling yet or not.
2429        #[tokio::test(flavor = "current_thread")]
2430        async fn fail_early_classification_error_aborts_and_quiesces_siblings() -> anyhow::Result<()>
2431        {
2432            let root = crate::testutils::create_temp_dir().await?;
2433            tokio::fs::write(root.join("gated"), b"x").await?;
2434            let admission = crate::testutils::AdmissionLimit::new().await;
2435            admission.set_files_in_flight(2);
2436            let dir = Arc::new(
2437                Dir::open_parent_dir(&root, congestion::Side::Source)
2438                    .await?
2439                    .into_tree(),
2440            );
2441            let mut filter = FilterSettings::default();
2442            filter.add_exclude("protected/")?;
2443            let leaf_started = Arc::new(std::sync::atomic::AtomicBool::new(false));
2444            let visitor = Arc::new(FailEarlyClassificationVisitor {
2445                filter,
2446                leaf_started: Arc::clone(&leaf_started),
2447            });
2448            let (dropped_tx, mut dropped_rx) = tokio::sync::mpsc::unbounded_channel();
2449            let dir_ctx = TaskDropContext::root(dropped_tx);
2450            let parent_cx = root_cx(Arc::clone(&dir), OsStr::new("root"), root);
2451            let error = admission
2452                .run_with_timeout(
2453                    Duration::from_secs(20),
2454                    scope_tasks(walk_dir_entries(
2455                        visitor,
2456                        dir,
2457                        &parent_cx,
2458                        &dir_ctx,
2459                        vec![
2460                            (OsString::from("gated"), Some(EntryKind::File)),
2461                            (OsString::from("gone"), Some(EntryKind::File)),
2462                        ],
2463                    )),
2464                )
2465                .await
2466                .context("fail-early authoritative walk did not terminate")?
2467                .expect_err("the vanished child classification must fail");
2468            let mut dropped = Vec::new();
2469            while let Ok(id) = dropped_rx.try_recv() {
2470                dropped.push(id);
2471            }
2472            dropped.sort_unstable();
2473            assert!(format!("{:#}", error.source).contains("gone"));
2474            assert!(
2475                leaf_started.load(Ordering::SeqCst),
2476                "the gated sibling did not start before the classification error"
2477            );
2478            assert_eq!(
2479                dropped,
2480                vec![0, 1],
2481                "fail-early returned before all scheduled child captures were dropped"
2482            );
2483            Ok(())
2484        }
2485
2486        /// A completed fail-early error is reaped while later scheduling waits for admission.
2487        #[tokio::test(flavor = "current_thread")]
2488        async fn fail_early_reaps_classification_error_while_next_admission_is_saturated()
2489        -> anyhow::Result<()> {
2490            let root = crate::testutils::create_temp_dir().await?;
2491            tokio::fs::write(root.join("first-gated"), b"x").await?;
2492            tokio::fs::write(root.join("second-gated"), b"x").await?;
2493            tokio::fs::write(root.join("fourth"), b"x").await?;
2494            let admission = crate::testutils::AdmissionLimit::new().await;
2495            admission.set_files_in_flight(2);
2496            let dir = Arc::new(
2497                Dir::open_parent_dir(&root, congestion::Side::Source)
2498                    .await?
2499                    .into_tree(),
2500            );
2501            let mut filter = FilterSettings::default();
2502            filter.add_exclude("protected/")?;
2503            let leaf_started = Arc::new(std::sync::atomic::AtomicBool::new(false));
2504            let visitor = Arc::new(FailEarlyClassificationVisitor {
2505                filter,
2506                leaf_started: Arc::clone(&leaf_started),
2507            });
2508            let (dropped_tx, mut dropped_rx) = tokio::sync::mpsc::unbounded_channel();
2509            let dir_ctx = TaskDropContext::root(dropped_tx);
2510            let parent_cx = root_cx(Arc::clone(&dir), OsStr::new("root"), root);
2511
2512            // the first two entries reserve both permits before their tasks need to be polled, so
2513            // constructing the third child reaches a saturated admission wait. Only reaping
2514            // `gone` and cancelling its siblings can make this return; consuming the released
2515            // permit instead would advance far enough to construct the fourth child.
2516            let error = admission
2517                .run_with_timeout(
2518                    Duration::from_secs(3),
2519                    scope_tasks(walk_dir_entries(
2520                        visitor,
2521                        dir,
2522                        &parent_cx,
2523                        &dir_ctx,
2524                        vec![
2525                            (OsString::from("first-gated"), Some(EntryKind::File)),
2526                            (OsString::from("gone"), Some(EntryKind::File)),
2527                            (OsString::from("second-gated"), Some(EntryKind::File)),
2528                            (OsString::from("fourth"), Some(EntryKind::File)),
2529                        ],
2530                    )),
2531                )
2532                .await
2533                .context(
2534                    "fail-early left a completed classification error unread while admission was \
2535                     saturated",
2536                )?
2537                .expect_err("the vanished child classification must fail");
2538
2539            let created = dir_ctx.next_id.load(Ordering::SeqCst);
2540            let mut dropped = Vec::new();
2541            while let Ok(id) = dropped_rx.try_recv() {
2542                dropped.push(id);
2543            }
2544            dropped.sort_unstable();
2545            assert!(format!("{:#}", error.source).contains("gone"));
2546            assert!(
2547                leaf_started.load(Ordering::SeqCst),
2548                "the permanently gated sibling did not start"
2549            );
2550            assert_eq!(
2551                created, 3,
2552                "the completed error was not reaped directly from the saturated admission wait"
2553            );
2554            assert_eq!(
2555                dropped,
2556                (0..created).collect::<Vec<_>>(),
2557                "fail-early returned before every created task capture was dropped"
2558            );
2559            let returned = admission
2560                .run_with_timeout(Duration::from_secs(1), throttle::pending_meta_permit())
2561                .await
2562                .context("fail-early cleanup retained PendingMeta admission")?;
2563            drop(returned);
2564            Ok(())
2565        }
2566
2567        /// Exact worker outcomes, not spawn or completion order, define processed children.
2568        #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
2569        async fn processed_children_uses_exact_decisions_in_enumeration_order() -> anyhow::Result<()>
2570        {
2571            let root = crate::testutils::create_temp_dir().await?;
2572            tokio::fs::write(root.join("A"), b"x").await?;
2573            tokio::fs::create_dir(root.join("B")).await?;
2574            tokio::fs::write(root.join("C"), b"x").await?;
2575            let admission = crate::testutils::AdmissionLimit::new().await;
2576            admission.set_files_in_flight(3);
2577            let dir = Arc::new(
2578                Dir::open_parent_dir(&root, congestion::Side::Source)
2579                    .await?
2580                    .into_tree(),
2581            );
2582            let mut filter = FilterSettings::default();
2583            filter.add_exclude("B/")?;
2584            let (a_release_tx, a_release_rx) = tokio::sync::oneshot::channel();
2585            let (c_release_tx, c_release_rx) = tokio::sync::oneshot::channel();
2586            let (b_release_tx, b_release_rx) = std::sync::mpsc::channel();
2587            let (events_tx, mut events_rx) = tokio::sync::mpsc::unbounded_channel();
2588            let visitor = Arc::new(OrderedFilterVisitor {
2589                filter,
2590                leaf_releases: std::sync::Mutex::new(std::collections::HashMap::from([
2591                    (OsString::from("A"), a_release_rx),
2592                    (OsString::from("C"), c_release_rx),
2593                ])),
2594                skip_release: std::sync::Mutex::new(Some(b_release_rx)),
2595                events: events_tx,
2596            });
2597            let parent_cx = root_cx(Arc::clone(&dir), OsStr::new("root"), root);
2598            let operation = tokio::spawn(async move {
2599                scope_tasks(walk_dir_entries(
2600                    visitor,
2601                    dir,
2602                    &parent_cx,
2603                    &(),
2604                    vec![
2605                        (OsString::from("A"), Some(EntryKind::File)),
2606                        (OsString::from("B"), Some(EntryKind::File)),
2607                        (OsString::from("C"), Some(EntryKind::File)),
2608                    ],
2609                ))
2610                .await
2611            });
2612            let all_started = tokio::time::timeout(Duration::from_secs(3), async {
2613                let mut started = std::collections::HashSet::new();
2614                while started.len() < 3 {
2615                    match events_rx.recv().await {
2616                        Some(CompletionEvent::Started(name)) => {
2617                            started.insert(name);
2618                        }
2619                        Some(CompletionEvent::Completed(name)) => {
2620                            panic!("{name:?} completed before every exact decision was gated")
2621                        }
2622                        None => panic!("completion event channel closed before every task started"),
2623                    }
2624                }
2625                started
2626            })
2627            .await;
2628            if all_started.is_err() {
2629                let _ = c_release_tx.send(());
2630                let _ = a_release_tx.send(());
2631                let _ = b_release_tx.send(());
2632                let _ = operation.await;
2633                anyhow::bail!("exact filter decisions were not scheduled concurrently");
2634            }
2635            assert_eq!(
2636                all_started?,
2637                std::collections::HashSet::from([
2638                    OsString::from("A"),
2639                    OsString::from("B"),
2640                    OsString::from("C"),
2641                ])
2642            );
2643            c_release_tx
2644                .send(())
2645                .expect("C completion gate owner disappeared");
2646            let c_completed = events_rx.recv().await;
2647            a_release_tx
2648                .send(())
2649                .expect("A completion gate owner disappeared");
2650            let a_completed = events_rx.recv().await;
2651            b_release_tx
2652                .send(())
2653                .expect("B completion gate owner disappeared");
2654            let b_completed = events_rx.recv().await;
2655            assert_eq!(
2656                [c_completed, a_completed, b_completed],
2657                [
2658                    Some(CompletionEvent::Completed(OsString::from("C"))),
2659                    Some(CompletionEvent::Completed(OsString::from("A"))),
2660                    Some(CompletionEvent::Completed(OsString::from("B"))),
2661                ],
2662                "the fixture must force C/A/B completion"
2663            );
2664            let (summary, processed) = admission
2665                .run_with_timeout(Duration::from_secs(20), operation)
2666                .await
2667                .context("ordered authoritative walk did not terminate")?
2668                .context("ordered authoritative walk task panicked")?
2669                .map_err(|error| error.source)?;
2670            assert_eq!(summary.files, 2);
2671            assert_eq!(
2672                processed.names(),
2673                &[OsString::from("A"), OsString::from("C")]
2674            );
2675            Ok(())
2676        }
2677
2678        /// A non-authoritative visitor retains a reliable-hint terminal exclusion.
2679        #[tokio::test]
2680        async fn filtered_hinted_file_does_not_wait_for_admission() -> anyhow::Result<()> {
2681            let root = crate::testutils::create_temp_dir().await?;
2682            tokio::fs::write(root.join("leaf"), b"x").await?;
2683            let admission = crate::testutils::AdmissionLimit::new().await;
2684            admission.set_files_in_flight(1);
2685            let held = throttle::pending_meta_permit().await;
2686            let dir = Arc::new(
2687                Dir::open_parent_dir(&root, congestion::Side::Source)
2688                    .await?
2689                    .into_tree(),
2690            );
2691            let mut filter = FilterSettings::default();
2692            filter.add_exclude("leaf")?;
2693            let visitor = Arc::new(CountingVisitor {
2694                leaves_seen: Arc::new(AtomicUsize::new(0)),
2695                filter: Some(filter),
2696                authoritative_filter: false,
2697            });
2698            let parent_cx = root_cx(Arc::clone(&dir), OsStr::new("root"), root);
2699            let walk = scope_tasks(walk_dir_entries(
2700                visitor,
2701                dir,
2702                &parent_cx,
2703                &(),
2704                vec![(OsString::from("leaf"), Some(EntryKind::File))],
2705            ));
2706            tokio::pin!(walk);
2707            let result = futures::poll!(walk.as_mut());
2708            let completed_while_saturated = result.is_ready();
2709            drop(held);
2710            let (summary, processed) = match result {
2711                std::task::Poll::Ready(result) => result.map_err(|error| error.source)?,
2712                std::task::Poll::Pending => admission
2713                    .run_with_timeout(Duration::from_secs(20), walk.as_mut())
2714                    .await
2715                    .context("reliable-hint exclusion waited for admission")?
2716                    .map_err(|error| error.source)?,
2717            };
2718            assert!(
2719                completed_while_saturated,
2720                "a non-authoritative reliable-hint exclusion consumed admission"
2721            );
2722            assert_eq!(summary, CountSummary::default());
2723            assert!(processed.names().is_empty());
2724            Ok(())
2725        }
2726
2727        /// An authoritative-filter visitor may reject a reliable hint without consuming capacity;
2728        /// only a provisionally included entry needs the admitted final decision.
2729        #[tokio::test]
2730        async fn authoritative_filter_rejects_reliable_hint_without_admission() -> anyhow::Result<()>
2731        {
2732            let root = crate::testutils::create_temp_dir().await?;
2733            tokio::fs::write(root.join("leaf"), b"x").await?;
2734            let admission = crate::testutils::AdmissionLimit::new().await;
2735            admission.set_files_in_flight(1);
2736            let held = throttle::pending_meta_permit().await;
2737            let dir = Arc::new(
2738                Dir::open_parent_dir(&root, congestion::Side::Source)
2739                    .await?
2740                    .into_tree(),
2741            );
2742            let mut filter = FilterSettings::default();
2743            filter.add_exclude("leaf")?;
2744            let visitor = Arc::new(CountingVisitor {
2745                leaves_seen: Arc::new(AtomicUsize::new(0)),
2746                filter: Some(filter),
2747                authoritative_filter: true,
2748            });
2749            let parent_cx = root_cx(Arc::clone(&dir), OsStr::new("root"), root);
2750            let walk = scope_tasks(walk_dir_entries(
2751                visitor,
2752                dir,
2753                &parent_cx,
2754                &(),
2755                vec![(OsString::from("leaf"), Some(EntryKind::File))],
2756            ));
2757            tokio::pin!(walk);
2758            let first_poll = futures::poll!(walk.as_mut());
2759            let completed_while_saturated = first_poll.is_ready();
2760            drop(held);
2761            let result = match first_poll {
2762                std::task::Poll::Ready(result) => result,
2763                std::task::Poll::Pending => admission
2764                    .run_with_timeout(Duration::from_secs(20), walk.as_mut())
2765                    .await
2766                    .context("filtered hinted file did not resume after capacity was released")?,
2767            };
2768            let (summary, processed) = result.map_err(|error| error.source)?;
2769            assert!(
2770                completed_while_saturated,
2771                "an authoritative visitor's cheap filter skip waited for admission"
2772            );
2773            assert_eq!(summary.files, 0);
2774            assert!(processed.names().is_empty());
2775            Ok(())
2776        }
2777
2778        /// A destructive filter must ignore a stale `d_type` before deciding to act.
2779        #[tokio::test]
2780        async fn destructive_filter_uses_transferred_authoritative_kind() -> anyhow::Result<()> {
2781            let root = crate::testutils::create_temp_dir().await?;
2782            tokio::fs::create_dir(root.join("cache")).await?;
2783            tokio::fs::write(root.join("cache").join("child"), b"protected").await?;
2784            let admission = crate::testutils::AdmissionLimit::new().await;
2785            admission.set_files_in_flight(1);
2786            let dir = Arc::new(
2787                Dir::open_parent_dir(&root, congestion::Side::Source)
2788                    .await?
2789                    .into_tree(),
2790            );
2791            let mut filter = FilterSettings::default();
2792            filter.add_exclude("cache/")?;
2793            let leaves_seen = Arc::new(AtomicUsize::new(0));
2794            let visitor = Arc::new(CountingVisitor {
2795                leaves_seen: Arc::clone(&leaves_seen),
2796                filter: Some(filter),
2797                authoritative_filter: true,
2798            });
2799            let parent_cx = root_cx(Arc::clone(&dir), OsStr::new("root"), root);
2800            let (summary, processed) = admission
2801                .run_with_timeout(
2802                    Duration::from_secs(20),
2803                    scope_tasks(walk_dir_entries(
2804                        visitor,
2805                        dir,
2806                        &parent_cx,
2807                        &(),
2808                        vec![(OsString::from("cache"), Some(EntryKind::File))],
2809                    )),
2810                )
2811                .await
2812                .context("destructive stale-hint filter did not terminate")?
2813                .map_err(|error| error.source)?;
2814            assert_eq!(summary, CountSummary::default());
2815            assert!(processed.names().is_empty());
2816            assert_eq!(leaves_seen.load(Ordering::SeqCst), 0);
2817            Ok(())
2818        }
2819
2820        /// A provisionally included directory hint cannot bypass admission before exact dispatch.
2821        #[tokio::test]
2822        async fn authoritative_filter_included_directory_hint_waits_for_admission()
2823        -> anyhow::Result<()> {
2824            let root = crate::testutils::create_temp_dir().await?;
2825            tokio::fs::write(root.join("leaf"), b"x").await?;
2826            let admission = crate::testutils::AdmissionLimit::new().await;
2827            admission.set_files_in_flight(1);
2828            let dir = Arc::new(
2829                Dir::open_parent_dir(&root, congestion::Side::Source)
2830                    .await?
2831                    .into_tree(),
2832            );
2833            let stat_resource =
2834                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
2835            admission.set_max_ops_in_flight(stat_resource, 1);
2836            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
2837            let mut filter = FilterSettings::default();
2838            filter.add_exclude("protected/")?;
2839            let leaves_seen = Arc::new(AtomicUsize::new(0));
2840            let visitor = Arc::new(CountingVisitor {
2841                leaves_seen: Arc::clone(&leaves_seen),
2842                filter: Some(filter),
2843                authoritative_filter: true,
2844            });
2845            let parent_cx = root_cx(Arc::clone(&dir), OsStr::new("root"), root);
2846            let walk = scope_tasks(walk_dir_entries(
2847                visitor,
2848                dir,
2849                &parent_cx,
2850                &(),
2851                vec![(OsString::from("leaf"), Some(EntryKind::Dir))],
2852            ));
2853            tokio::pin!(walk);
2854            let stopped_at_first_stat_gate = futures::poll!(walk.as_mut()).is_pending();
2855            tokio::task::yield_now().await;
2856            let mut first_capacity_probe = Box::pin(throttle::pending_meta_permit());
2857            let hinted_classification_was_unadmitted =
2858                futures::poll!(first_capacity_probe.as_mut()).is_ready();
2859            drop(first_capacity_probe);
2860            drop(held_stat);
2861            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
2862            let mut admitted_reclassification_owned_capacity = false;
2863            for _ in 0..100 {
2864                let mut second_capacity_probe = Box::pin(throttle::pending_meta_permit());
2865                if futures::poll!(second_capacity_probe.as_mut()).is_pending() {
2866                    admitted_reclassification_owned_capacity = true;
2867                    break;
2868                }
2869                drop(second_capacity_probe);
2870                tokio::task::yield_now().await;
2871            }
2872            drop(held_stat);
2873            let (summary, processed) = admission
2874                .run_with_timeout(Duration::from_secs(20), walk.as_mut())
2875                .await
2876                .context(
2877                    "destructive stale-directory classification did not resume after stat release",
2878                )?
2879                .map_err(|error| error.source)?;
2880            assert!(
2881                stopped_at_first_stat_gate,
2882                "authoritative filtering did not reach the first held source Stat gate"
2883            );
2884            assert!(
2885                hinted_classification_was_unadmitted,
2886                "a positive directory hint consumed admission before it proved stale"
2887            );
2888            assert!(
2889                admitted_reclassification_owned_capacity,
2890                "the stale hinted directory reclassified its leaf without owning admission"
2891            );
2892            assert_eq!(summary.files, 1);
2893            assert_eq!(summary.dirs, 0);
2894            assert_eq!(processed.names(), &[OsString::from("leaf")]);
2895            assert_eq!(leaves_seen.load(Ordering::SeqCst), 1);
2896            Ok(())
2897        }
2898
2899        /// A transferred directory classification must release cap-one admission before descent.
2900        #[tokio::test]
2901        async fn destructive_filter_transfer_does_not_hold_admission_across_deep_tree()
2902        -> anyhow::Result<()> {
2903            let root = crate::testutils::create_temp_dir().await?;
2904            let depth = 12;
2905            let mut current = root.join("extra");
2906            tokio::fs::create_dir(&current).await?;
2907            for level in 0..depth {
2908                tokio::fs::write(current.join(format!("leaf-{level}")), b"x").await?;
2909                if level + 1 < depth {
2910                    current = current.join(format!("dir-{level}"));
2911                    tokio::fs::create_dir(&current).await?;
2912                }
2913            }
2914            let admission = crate::testutils::AdmissionLimit::new().await;
2915            admission.set_files_in_flight(1);
2916            let dir = Arc::new(
2917                Dir::open_parent_dir(&root, congestion::Side::Source)
2918                    .await?
2919                    .into_tree(),
2920            );
2921            let mut filter = FilterSettings::default();
2922            filter.add_exclude("protected/")?;
2923            let leaves_seen = Arc::new(AtomicUsize::new(0));
2924            let visitor = Arc::new(CountingVisitor {
2925                leaves_seen: Arc::clone(&leaves_seen),
2926                filter: Some(filter),
2927                authoritative_filter: true,
2928            });
2929            let parent_cx = root_cx(Arc::clone(&dir), OsStr::new("root"), root);
2930            let (summary, processed) = admission
2931                .run_with_timeout(
2932                    Duration::from_secs(20),
2933                    scope_tasks(walk_dir_entries(
2934                        visitor,
2935                        dir,
2936                        &parent_cx,
2937                        &(),
2938                        vec![(OsString::from("extra"), Some(EntryKind::File))],
2939                    )),
2940                )
2941                .await
2942                .context("destructive classified-directory transfer deadlocked at cap one")?
2943                .map_err(|error| error.source)?;
2944            assert_eq!(summary.files, depth);
2945            assert_eq!(summary.dirs, depth);
2946            assert_eq!(leaves_seen.load(Ordering::SeqCst), depth);
2947            assert_eq!(processed.names(), &[OsString::from("extra")]);
2948            Ok(())
2949        }
2950
2951        /// A positive directory hint is the one classification allowed outside leaf admission.
2952        #[tokio::test]
2953        async fn hinted_directory_classifies_while_pool_is_saturated() -> anyhow::Result<()> {
2954            let root = crate::testutils::create_temp_dir().await?;
2955            tokio::fs::create_dir(root.join("dir")).await?;
2956            let admission = crate::testutils::AdmissionLimit::new().await;
2957            admission.set_files_in_flight(1);
2958            let held = throttle::pending_meta_permit().await;
2959            let parent = Arc::new(
2960                Dir::open_parent_dir(&root, congestion::Side::Source)
2961                    .await?
2962                    .into_tree(),
2963            );
2964            let visitor = Arc::new(CountingVisitor {
2965                leaves_seen: Arc::new(AtomicUsize::new(0)),
2966                filter: None,
2967                authoritative_filter: false,
2968            });
2969            let parent_cx = root_cx(Arc::clone(&parent), OsStr::new("root"), root);
2970            let result = admission
2971                .run_with_timeout(
2972                    Duration::from_secs(1),
2973                    scope_tasks(walk_dir_entries(
2974                        visitor,
2975                        parent,
2976                        &parent_cx,
2977                        &(),
2978                        vec![(OsString::from("dir"), Some(EntryKind::Dir))],
2979                    )),
2980                )
2981                .await;
2982            drop(held);
2983            let (summary, processed) = result
2984                .context("hinted directory waited for pending-metadata admission")?
2985                .map_err(|error| error.source)?;
2986            assert_eq!(summary.dirs, 1);
2987            assert_eq!(processed.names(), &[OsString::from("dir")]);
2988            Ok(())
2989        }
2990
2991        /// A stale directory hint must close its first handle, admit, and classify the name again.
2992        #[tokio::test(flavor = "current_thread")]
2993        async fn stale_directory_hint_closes_admits_and_reclassifies() -> anyhow::Result<()> {
2994            let root = crate::testutils::create_temp_dir().await?;
2995            let entry = root.join("entry");
2996            tokio::fs::write(&entry, b"old").await?;
2997            let admission = crate::testutils::AdmissionLimit::new().await;
2998            admission.set_files_in_flight(1);
2999            let held_leaf = throttle::pending_meta_permit().await;
3000            let parent = Arc::new(
3001                Dir::open_parent_dir(&root, congestion::Side::Source)
3002                    .await?
3003                    .into_tree(),
3004            );
3005            let stat_resource =
3006                throttle::Resource::meta(throttle::Side::Source, throttle::MetadataOp::Stat);
3007            admission.set_max_ops_in_flight(stat_resource, 1);
3008            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
3009            let visitor = Arc::new(CountingVisitor {
3010                leaves_seen: Arc::new(AtomicUsize::new(0)),
3011                filter: None,
3012                authoritative_filter: false,
3013            });
3014            let cx = root_cx(parent, OsStr::new("entry"), entry.clone());
3015            let mut task = tokio::spawn(process_entry(
3016                visitor,
3017                cx,
3018                (),
3019                walk::EntryAdmission::HintedDirectory,
3020            ));
3021            tokio::task::yield_now().await;
3022            drop(held_stat);
3023            let held_stat = throttle::ops_in_flight_permit(stat_resource).await;
3024            let mut first_handle_closed = false;
3025            for _ in 0..100 {
3026                if !inode_is_open(&entry)? {
3027                    first_handle_closed = true;
3028                    break;
3029                }
3030                tokio::task::yield_now().await;
3031            }
3032            tokio::fs::remove_file(&entry).await?;
3033            tokio::fs::create_dir(&entry).await?;
3034            drop(held_leaf);
3035            tokio::task::yield_now().await;
3036            drop(held_stat);
3037            let task_result = admission
3038                .run_with_timeout(Duration::from_secs(20), &mut task)
3039                .await
3040                .context("stale hinted entry did not resume after admission was released")?;
3041            let summary = task_result?.map_err(|error| error.source)?;
3042            assert!(
3043                first_handle_closed,
3044                "stale hinted-directory classification retained its first leaf handle while waiting"
3045            );
3046            assert_eq!(
3047                summary,
3048                CountSummary {
3049                    dirs: 1,
3050                    ..Default::default()
3051                },
3052                "the admitted reclassification must observe the replacement directory"
3053            );
3054            Ok(())
3055        }
3056
3057        /// A failed authoritative filter probe is an operation error, not a cheap non-directory.
3058        #[tokio::test]
3059        async fn failed_authoritative_filter_probe_propagates() -> anyhow::Result<()> {
3060            let root = crate::testutils::create_temp_dir().await?;
3061            let admission = crate::testutils::AdmissionLimit::new().await;
3062            let dir = Arc::new(
3063                Dir::open_parent_dir(&root, congestion::Side::Source)
3064                    .await?
3065                    .into_tree(),
3066            );
3067            let mut filter = FilterSettings::default();
3068            filter.add_exclude("vanished")?;
3069            let visitor = Arc::new(CountingVisitor {
3070                leaves_seen: Arc::new(AtomicUsize::new(0)),
3071                filter: Some(filter),
3072                authoritative_filter: false,
3073            });
3074            let parent_cx = root_cx(Arc::clone(&dir), OsStr::new("root"), root.clone());
3075            let result = admission
3076                .run_with_timeout(
3077                    Duration::from_secs(20),
3078                    scope_tasks(walk_dir_entries(
3079                        visitor,
3080                        dir,
3081                        &parent_cx,
3082                        &(),
3083                        vec![(OsString::from("vanished"), None)],
3084                    )),
3085                )
3086                .await
3087                .context("failed filter probe did not terminate")?;
3088            let error = result.expect_err("a missing authoritative probe target must fail");
3089            let rendered = format!("{:#}", error.source);
3090            assert!(rendered.contains("failed reading metadata from"));
3091            assert!(rendered.contains("vanished"));
3092            Ok(())
3093        }
3094
3095        /// Driver-level regression for the drop-before-recurse invariant.
3096        ///
3097        /// With the pending-meta pool sized to a single permit, we pre-acquire that
3098        /// one permit and hand it to `process_entry` for an entry that is
3099        /// AUTHORITATIVELY a directory (mirroring the spawn loop's hinted-leaf
3100        /// pre-acquire when getdents mis-hints, or a swap between getdents and
3101        /// `child()`). The directory's child file then needs its own pending-meta
3102        /// permit to be visited.
3103        ///
3104        /// WITHOUT the fix, `process_entry` would hold that one permit across the
3105        /// recursion and the child's `preacquire_leaf_permit` would block forever
3106        /// (pool size 1, already held) — the timeout fires. WITH the fix, the
3107        /// directory branch drops the permit before recursing, the child acquires it,
3108        /// and the walk completes well within the timeout.
3109        #[tokio::test]
3110        async fn hinted_leaf_that_is_dir_drops_permit_before_recursion() -> anyhow::Result<()> {
3111            let root = crate::testutils::create_temp_dir().await?;
3112            // `d` is a real directory holding one child file `c`.
3113            let dir_path = root.join("d");
3114            tokio::fs::create_dir(&dir_path).await?;
3115            tokio::fs::write(dir_path.join("c"), b"x").await?;
3116            // size the pending-meta pool to a single permit (the `set_files_in_flight`
3117            // knob sizes both pools).
3118            let admission = crate::testutils::AdmissionLimit::new().await;
3119            admission.set_files_in_flight(1);
3120            // open the container of `d` and classify `d`: an authoritative directory.
3121            let parent = Arc::new(
3122                Dir::open_parent_dir(&root, congestion::Side::Source)
3123                    .await?
3124                    .into_tree(),
3125            );
3126            let name = std::ffi::OsStr::new("d");
3127            let handle = parent.child(name).await?;
3128            assert_eq!(
3129                handle.kind(),
3130                EntryKind::Dir,
3131                "fixture `d` must be a directory"
3132            );
3133            drop(handle);
3134            let leaves_seen = Arc::new(AtomicUsize::new(0));
3135            let visitor = Arc::new(CountingVisitor {
3136                leaves_seen: Arc::clone(&leaves_seen),
3137                filter: None,
3138                authoritative_filter: false,
3139            });
3140            let cx = root_cx(Arc::clone(&parent), name, dir_path.clone());
3141            // pre-acquire the single permit exactly as the spawn loop does for a
3142            // hinted leaf, and hand it to `process_entry`. the fix drops it before
3143            // recursing.
3144            let permit =
3145                walk::preacquire_leaf_permit(PermitKind::PendingMeta, Some(EntryKind::File)).await;
3146            assert!(permit.is_some(), "the pre-acquire must take the one permit");
3147            let result = admission
3148                .run_with_timeout(
3149                    Duration::from_secs(20),
3150                    process_entry(visitor, cx, (), permit),
3151                )
3152                .await;
3153            let summary = result
3154                .map_err(|_| {
3155                    anyhow::anyhow!(
3156                        "process_entry hung — leaf permit held across directory recursion (deadlock)"
3157                    )
3158                })?
3159                .map_err(|e| e.source)?;
3160            assert_eq!(
3161                summary,
3162                CountSummary {
3163                    files: 1,
3164                    dirs: 1,
3165                    symlinks: 0,
3166                },
3167                "the directory and its one child file are both counted"
3168            );
3169            assert_eq!(
3170                leaves_seen.load(Ordering::SeqCst),
3171                1,
3172                "the child file was visited (its permit was acquired after the drop)"
3173            );
3174            Ok(())
3175        }
3176    }
3177}