1use 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 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
192pub(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
206pub(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
229pub 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#[derive(Clone)]
244pub struct EntryCx {
245 pub parent: Arc<Dir>,
248 pub name: OsString,
250 pub rel_path: PathBuf,
254 pub filter_path: PathBuf,
263 pub real_path: PathBuf,
266 pub dry_run: bool,
268 pub prog_track: &'static Progress,
270}
271
272impl EntryCx {
273 #[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
291pub enum DirAction<Sum, Ctx, State> {
298 Skip(Sum),
302 Descend {
306 dir: Arc<Dir>,
309 child_ctx: Ctx,
314 state: State,
318 },
319}
320
321#[derive(Debug, Default)]
328pub struct ProcessedChildren {
329 names: Vec<OsString>,
330}
331
332impl ProcessedChildren {
333 #[must_use]
335 pub fn names(&self) -> &[OsString] {
336 &self.names
337 }
338
339 #[must_use]
341 pub fn into_names(self) -> Vec<OsString> {
342 self.names
343 }
344}
345
346pub 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
358pub trait WalkVisitor: Send + Sync + 'static {
365 type Summary: WalkSummary;
367 type DirContext: Clone + Send + Sync + 'static;
378 type DirState: Send;
382
383 fn root_dir_context(&self) -> Self::DirContext;
388
389 fn permit_kind(&self) -> PermitKind;
391
392 fn fail_early(&self) -> bool;
394
395 fn filter(&self) -> Option<&crate::filter::FilterSettings>;
398
399 fn filter_requires_admitted_entry(&self) -> bool {
407 false
408 }
409
410 fn filter_allows_hint_only_skip(&self, _dir_ctx: &Self::DirContext) -> bool {
416 true
417 }
418
419 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 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 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 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
502pub 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
533pub(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 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 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 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
712pub 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
770fn 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 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
943pub(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 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 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 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
1043pub(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
1066pub 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 #[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 struct CountingVisitor {
1148 leaves_seen: Arc<AtomicUsize>,
1150 filter: Option<FilterSettings>,
1152 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 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 let child_summary = child_result?;
1226 Ok(child_summary
1227 + CountSummary {
1228 dirs: 1,
1229 ..Default::default()
1230 })
1231 }
1232 }
1233
1234 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 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 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 #[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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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(¤t).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(¤t).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 #[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 #[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 #[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 #[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 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 let admission = crate::testutils::AdmissionLimit::new().await;
3119 admission.set_files_in_flight(1);
3120 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 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}