use std::collections::{BTreeMap, BTreeSet, VecDeque};
use std::ffi::{OsStr, OsString};
use std::fmt::Write as _;
use std::fs;
use std::io::Read as _;
use std::path::{Component, Path, PathBuf};
use crate::ApplyStats;
use crate::engine_contract::{
Attrs, Commit, EntryKind, Error, Observation, ObservationOp, Op, PathExpectation, PathState,
Result, ScanScope,
};
use crate::execution::TreeRetention;
use crate::index::{
DetachedIndexBuilder, Index, IndexHandle, ReconcileErrors, ReconcileFinish,
collect_child_expectations,
};
use crate::query::ScopeAxis;
use crate::stored_state::{ControlTierIdentity, EntryScope, EntryTierIdentity, SnapshotIdentity};
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod macos_bulk;
#[cfg(all(target_os = "linux", target_env = "gnu"))]
#[allow(unsafe_code)]
mod linux_dents;
#[cfg(windows)]
#[allow(unsafe_code)]
mod windows_metadata;
const DEFAULT_BATCH_SIZE: usize = crate::platform_tuning::tuning().batch_size.get();
pub const MAX_SCAN_BATCH_SIZE: usize = 64 * 1024;
const MAX_DEFERRED_RECONCILE_OPS: usize = MAX_SCAN_BATCH_SIZE;
const RECONCILE_WAVE_DIRECTORIES: usize =
crate::platform_tuning::tuning().reconcile_wave_directories.get();
const REDUCERS_FINGERPRINT: u64 = 1;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum ScanOrder {
#[default]
BreadthFirst,
DepthFirst,
}
#[derive(Clone, Debug)]
#[allow(clippy::struct_excessive_bools)]
pub struct ScanConfig {
pub max_depth: Option<usize>,
pub batch_size: usize,
pub follow_symlinks: bool,
pub one_filesystem: bool,
pub hidden: Option<std::sync::Arc<crate::admission::HiddenPolicy>>,
pub exclude_special: bool,
pub threads: Option<usize>,
pub order: ScanOrder,
pub types: Option<std::sync::Arc<crate::classify::TypeRegistry>>,
pub read_controls: bool,
pub population: crate::query::IgnoredEntries,
pub control_limits: crate::control::ControlLimits,
pub progress: Option<crate::Progress>,
}
impl Default for ScanConfig {
fn default() -> Self {
Self {
max_depth: None,
batch_size: DEFAULT_BATCH_SIZE,
follow_symlinks: false,
one_filesystem: false,
hidden: None,
exclude_special: false,
threads: None,
order: ScanOrder::default(),
types: None,
read_controls: crate::query::Request::DEFAULTS.read_controls,
population: crate::query::IgnoredEntries::Include,
control_limits: crate::query::Request::DEFAULTS.control_limits,
progress: None,
}
}
}
pub const WATCH_SCOPE_GUIDANCE: &str = concat!(
"watching requires full scope and cannot be combined with max_depth or one_filesystem: ",
"a watcher cannot filter backend events against a narrowed boundary. Selection such as ",
"depth, include, and modified_since does work while watching, because it filters the ",
"retained index rather than narrowing the scan"
);
impl ScanConfig {
#[must_use]
pub fn with_types(mut self, types: std::sync::Arc<crate::classify::TypeRegistry>) -> Self {
self.types = Some(types);
self
}
pub fn types(&self) -> &crate::classify::TypeRegistry {
match &self.types {
Some(types) => types,
None => crate::classify::TypeRegistry::compiled(),
}
}
pub(crate) fn types_shared(&self) -> std::sync::Arc<crate::classify::TypeRegistry> {
self.types
.as_ref()
.map_or_else(crate::classify::TypeRegistry::compiled_shared, std::sync::Arc::clone)
}
pub fn hidden(&self) -> &crate::admission::HiddenPolicy {
self.hidden.as_deref().unwrap_or_else(|| crate::admission::HiddenPolicy::keep_all())
}
pub fn scope(&self) -> ScanScope {
self.snapshot_identity().scan_scope()
}
pub fn entry_scope(&self) -> EntryScope {
EntryScope {
max_depth: self.max_depth,
follow_symlinks: self.follow_symlinks,
one_filesystem: self.one_filesystem,
hidden_fingerprint: self.hidden().fingerprint(),
exclude_special: self.exclude_special,
population: self.population,
control_fingerprint: if self.population == crate::query::IgnoredEntries::Include {
0
} else {
self.control_identity().ignore_rules_fingerprint()
},
}
}
pub fn control_identity(&self) -> ControlTierIdentity {
if self.read_controls {
ControlTierIdentity::Observed { limits: self.control_limits }
} else {
ControlTierIdentity::NotObserved
}
}
pub fn snapshot_identity(&self) -> SnapshotIdentity {
SnapshotIdentity {
entries: EntryTierIdentity {
engine: crate::snapshot::engine_fingerprint(),
scope: self.entry_scope(),
type_rules_fingerprint: self.types().fingerprint(),
reducers_fingerprint: REDUCERS_FINGERPRINT,
},
controls: self.control_identity(),
}
}
#[cfg(any(target_os = "macos", test))]
fn worker_threads(&self) -> usize {
self.worker_pool().initial
}
fn reconciliation_worker_threads(&self) -> usize {
match self.threads {
Some(threads) => threads.clamp(1, MAX_SCAN_THREADS),
None => std::thread::available_parallelism()
.map_or(1, std::num::NonZero::get)
.clamp(1, DEFAULT_RECONCILE_THREADS_CAP),
}
}
#[cfg(any(target_os = "macos", test))]
fn worker_pool(&self) -> WorkerPool {
self.worker_pool_for(std::thread::available_parallelism().map_or(1, std::num::NonZero::get))
}
fn worker_pool_for(&self, available_parallelism: usize) -> WorkerPool {
match self.threads {
Some(threads) => WorkerPool::fixed(threads.clamp(1, MAX_SCAN_THREADS)),
None => automatic_worker_pool(available_parallelism),
}
}
pub(crate) const fn unsupported_axis(&self) -> Option<ScopeAxis> {
if self.follow_symlinks {
return Some(ScopeAxis::FollowSymlinks);
}
#[cfg(not(unix))]
if self.one_filesystem {
return Some(ScopeAxis::OneFilesystem);
}
None
}
pub(crate) fn validate(&self) -> Result<()> {
if !self.read_controls && self.population != crate::query::IgnoredEntries::Include {
return Err(Error::UnsupportedScanConfig(
"ignored population requires .gitignore observation",
));
}
if self.batch_size == 0 || self.batch_size > MAX_SCAN_BATCH_SIZE {
return Err(Error::UnsupportedScanConfig(
"batch_size must be nonzero and no greater than MAX_SCAN_BATCH_SIZE",
));
}
if let Some(axis) = self.unsupported_axis() {
return Err(Error::UnsupportedScanConfig(axis.reason()));
}
Ok(())
}
pub(crate) fn validate_for_scope(&self, indexed: ScanScope) -> Result<()> {
self.validate()?;
let requested = self.scope();
if indexed != requested {
return Err(Error::ScanScopeMismatch { indexed, requested });
}
Ok(())
}
#[cfg(feature = "watch")]
pub(crate) fn validate_for_watch_scope(&self, indexed: ScanScope) -> Result<()> {
self.validate_for_scope(indexed)?;
if self.max_depth.is_some() || self.one_filesystem {
return Err(Error::UnsupportedScanConfig(WATCH_SCOPE_GUIDANCE));
}
Ok(())
}
}
impl Default for ScanScope {
fn default() -> Self {
ScanConfig::default().scope()
}
}
#[derive(Debug, Default)]
pub struct ScanReport {
pub dirs_read: u64,
pub entries: u64,
pub files_walked: u64,
pub bytes_walked: u64,
pub allocated_walked: u64,
pub errors: Vec<Error>,
pub attribution: WalkAttribution,
}
impl ScanReport {
pub fn is_complete(&self) -> bool {
self.errors.is_empty()
}
fn absorb(&mut self, other: Self) {
self.dirs_read += other.dirs_read;
self.entries += other.entries;
self.files_walked += other.files_walked;
self.bytes_walked = self.bytes_walked.saturating_add(other.bytes_walked);
self.allocated_walked = self.allocated_walked.saturating_add(other.allocated_walked);
self.errors.extend(other.errors);
self.attribution.absorb(other.attribution);
}
fn observe(&mut self, kind: EntryKind, attrs: Attrs) {
self.entries += 1;
if kind == EntryKind::File {
self.files_walked += 1;
self.bytes_walked = self.bytes_walked.saturating_add(attrs.size);
self.allocated_walked = self.allocated_walked.saturating_add(attrs.allocated);
}
}
}
struct ProgressTally<'a> {
progress: Option<&'a crate::Progress>,
directories: u64,
files: u64,
bytes: u64,
allocated: u64,
}
impl<'a> ProgressTally<'a> {
const fn new(progress: Option<&'a crate::Progress>) -> Self {
Self { progress, directories: 0, files: 0, bytes: 0, allocated: 0 }
}
fn flush(&mut self, report: &ScanReport) {
let Some(progress) = self.progress else { return };
let directories = report.dirs_read - self.directories;
let files = report.files_walked - self.files;
let bytes = report.bytes_walked - self.bytes;
let allocated = report.allocated_walked - self.allocated;
if directories != 0 || files != 0 || bytes != 0 || allocated != 0 {
progress.add_walked(directories, files, bytes, allocated);
self.directories = report.dirs_read;
self.files = report.files_walked;
self.bytes = report.bytes_walked;
self.allocated = report.allocated_walked;
}
}
fn skip_to(&mut self, report: &ScanReport) {
self.directories = report.dirs_read;
self.files = report.files_walked;
self.bytes = report.bytes_walked;
self.allocated = report.allocated_walked;
}
}
pub(crate) fn normalize_walk_errors(root: &Path, errors: &mut Vec<Error>) {
errors.sort_by(|left, right| match (left, right) {
(
Error::Io { path: left_path, source: left_source },
Error::Io { path: right_path, source: right_source },
) => left_path
.strip_prefix(root)
.unwrap_or(left_path)
.cmp(right_path.strip_prefix(root).unwrap_or(right_path))
.then_with(|| {
walk_issue_kind_rank(left_source).cmp(&walk_issue_kind_rank(right_source))
}),
(Error::Io { .. }, _) => std::cmp::Ordering::Less,
(_, Error::Io { .. }) => std::cmp::Ordering::Greater,
_ => std::cmp::Ordering::Equal,
});
errors.dedup_by(|right, left| match (left, right) {
(
Error::Io { path: left_path, source: left_source },
Error::Io { path: right_path, source: right_source },
) => {
left_path.strip_prefix(root).unwrap_or(left_path)
== right_path.strip_prefix(root).unwrap_or(right_path)
&& walk_issue_kind_rank(left_source) == walk_issue_kind_rank(right_source)
}
_ => false,
});
}
fn walk_issue_kind_rank(error: &std::io::Error) -> u8 {
match error.kind() {
std::io::ErrorKind::PermissionDenied => 0,
std::io::ErrorKind::NotFound => 1,
std::io::ErrorKind::InvalidData | std::io::ErrorKind::InvalidInput => 2,
_ => 5,
}
}
pub const SCAN_DIAGNOSTICS_SCHEMA: &str = "fdu-scan-diagnostics-v1";
const MAX_POLICY_TRACE_EVENTS: usize = 256;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ScanDiagnostics {
pub schema: &'static str,
pub worker_policy: WorkerPolicyDiagnostics,
pub backend: ScanBackendDiagnostics,
}
#[doc(hidden)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum WorkerPolicyExperiment {
#[default]
ShippedOneShot,
RepeatedWindows,
StagedGatedWindows,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WorkerPolicyOutcome {
NotRun,
Fixed,
Undecided,
Held,
ScaledUp,
HeldNoUsefulWork,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkerPolicyWindow {
pub sequence: u64,
pub start_entry_ordinal: u64,
pub end_entry_ordinal: u64,
pub observed_entries: u64,
pub observed_chunks: u64,
pub observed_work_ns: u64,
pub work_ns_per_entry: Option<u64>,
pub work_ns_per_entry_unavailable_reason: Option<&'static str>,
pub ready_directories: usize,
pub in_flight_directories: usize,
pub active_workers: usize,
pub handoff_backlog: usize,
pub requested_workers: Option<usize>,
pub decision: WorkerPolicyDecision,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WorkerPolicyDecision {
Undecided,
Hold,
ScaleUp,
HoldNoUsefulWork,
HoldInsufficientFrontier,
HoldHandoffBacklog,
ObserveFast,
ObserveSlow,
Incomplete,
ObserveIncomplete,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkerPolicyDiagnostics {
pub controller: &'static str,
pub available_parallelism: usize,
pub initial_workers: usize,
pub maximum_workers: usize,
pub calibration_window_entries: Option<u64>,
pub slow_threshold_ns_per_entry: Option<u64>,
pub calibration_chunks: u64,
pub calibration_entries: u64,
pub calibration_work_ns: u64,
pub worker_expansions: u64,
pub outcome: WorkerPolicyOutcome,
pub outcome_reason: Option<&'static str>,
pub workers_spawned: usize,
pub peak_active_workers: usize,
pub ready_directories_at_finish: usize,
pub in_flight_directories_at_finish: usize,
pub handoff_backlog_at_finish: usize,
pub handoff_backlog_high_water: usize,
pub windows: Vec<WorkerPolicyWindow>,
pub events_truncated: bool,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct ScanBackendDiagnostics {
pub portable_attempts: u64,
pub portable_directory_reads: u64,
pub macos_bulk_attempts: Option<u64>,
pub macos_bulk_successes: Option<u64>,
pub macos_bulk_fallbacks: Option<u64>,
pub unavailable_reason: Option<&'static str>,
pub linux_dents_attempts: Option<u64>,
pub linux_dents_successes: Option<u64>,
pub linux_dents_fallbacks: Option<u64>,
}
impl ScanDiagnostics {
pub fn to_json(&self) -> String {
let policy = &self.worker_policy;
let backend = &self.backend;
let mut windows = String::from("[");
for (index, window) in policy.windows.iter().enumerate() {
if index > 0 {
windows.push(',');
}
let _ = write!(
windows,
concat!(
"{{\"active_workers\":{},\"decision\":\"{}\",",
"\"end_entry_ordinal\":{},\"handoff_backlog\":{},",
"\"in_flight_directories\":{},\"observed_chunks\":{},",
"\"observed_entries\":{},",
"\"observed_work_ns\":{},\"ready_directories\":{},",
"\"requested_workers\":{},\"sequence\":{},\"start_entry_ordinal\":{},",
"\"work_ns_per_entry\":{},",
"\"work_ns_per_entry_unavailable_reason\":{}}}"
),
window.active_workers,
worker_policy_decision_name(window.decision),
window.end_entry_ordinal,
window.handoff_backlog,
window.in_flight_directories,
window.observed_chunks,
window.observed_entries,
window.observed_work_ns,
window.ready_directories,
json_optional_usize(window.requested_workers),
window.sequence,
window.start_entry_ordinal,
json_optional_u64(window.work_ns_per_entry),
json_optional_string(window.work_ns_per_entry_unavailable_reason),
);
}
windows.push(']');
format!(
concat!(
"{{\"backend\":{{\"linux_dents_attempts\":{},",
"\"linux_dents_fallbacks\":{},\"linux_dents_successes\":{},",
"\"macos_bulk_attempts\":{},",
"\"macos_bulk_fallbacks\":{},\"macos_bulk_successes\":{},",
"\"portable_attempts\":{},\"portable_directory_reads\":{},",
"\"unavailable_reason\":{}}},",
"\"schema\":\"{}\",\"worker_policy\":{{",
"\"available_parallelism\":{},\"calibration_chunks\":{},",
"\"calibration_entries\":{},\"calibration_window_entries\":{},",
"\"calibration_work_ns\":{},",
"\"controller\":\"{}\",",
"\"events_truncated\":{},\"handoff_backlog_at_finish\":{},",
"\"handoff_backlog_high_water\":{},\"in_flight_directories_at_finish\":{},",
"\"initial_workers\":{},\"maximum_workers\":{},\"outcome\":\"{}\",",
"\"outcome_reason\":{},\"peak_active_workers\":{},",
"\"ready_directories_at_finish\":{},\"slow_threshold_ns_per_entry\":{},",
"\"windows\":{},\"worker_expansions\":{},\"workers_spawned\":{}}}}}"
),
json_optional_u64(backend.linux_dents_attempts),
json_optional_u64(backend.linux_dents_fallbacks),
json_optional_u64(backend.linux_dents_successes),
json_optional_u64(backend.macos_bulk_attempts),
json_optional_u64(backend.macos_bulk_fallbacks),
json_optional_u64(backend.macos_bulk_successes),
backend.portable_attempts,
backend.portable_directory_reads,
json_optional_string(backend.unavailable_reason),
self.schema,
policy.available_parallelism,
policy.calibration_chunks,
policy.calibration_entries,
json_optional_u64(policy.calibration_window_entries),
policy.calibration_work_ns,
policy.controller,
policy.events_truncated,
policy.handoff_backlog_at_finish,
policy.handoff_backlog_high_water,
policy.in_flight_directories_at_finish,
policy.initial_workers,
policy.maximum_workers,
worker_policy_outcome_name(policy.outcome),
json_optional_string(policy.outcome_reason),
policy.peak_active_workers,
policy.ready_directories_at_finish,
json_optional_u64(policy.slow_threshold_ns_per_entry),
windows,
policy.worker_expansions,
policy.workers_spawned,
)
}
}
const fn worker_policy_outcome_name(value: WorkerPolicyOutcome) -> &'static str {
match value {
WorkerPolicyOutcome::NotRun => "not_run",
WorkerPolicyOutcome::Fixed => "fixed",
WorkerPolicyOutcome::Undecided => "undecided",
WorkerPolicyOutcome::Held => "held",
WorkerPolicyOutcome::ScaledUp => "scaled_up",
WorkerPolicyOutcome::HeldNoUsefulWork => "held_no_useful_work",
}
}
const fn worker_policy_decision_name(value: WorkerPolicyDecision) -> &'static str {
match value {
WorkerPolicyDecision::Undecided => "undecided",
WorkerPolicyDecision::Hold => "hold",
WorkerPolicyDecision::ScaleUp => "scale_up",
WorkerPolicyDecision::HoldNoUsefulWork => "hold_no_useful_work",
WorkerPolicyDecision::HoldInsufficientFrontier => "hold_insufficient_frontier",
WorkerPolicyDecision::HoldHandoffBacklog => "hold_handoff_backlog",
WorkerPolicyDecision::ObserveFast => "observe_fast",
WorkerPolicyDecision::ObserveSlow => "observe_slow",
WorkerPolicyDecision::Incomplete => "incomplete",
WorkerPolicyDecision::ObserveIncomplete => "observe_incomplete",
}
}
fn json_optional_string(value: Option<&str>) -> String {
value.map_or_else(|| "null".into(), |value| format!("\"{}\"", json_escape(value)))
}
fn json_optional_u64(value: Option<u64>) -> String {
value.map_or_else(|| "null".into(), |value| value.to_string())
}
fn json_optional_usize(value: Option<usize>) -> String {
value.map_or_else(|| "null".into(), |value| value.to_string())
}
fn json_escape(value: &str) -> String {
let mut escaped = String::new();
for character in value.chars() {
match character {
'"' => escaped.push_str("\\\""),
'\\' => escaped.push_str("\\\\"),
'\u{08}' => escaped.push_str("\\b"),
'\u{0c}' => escaped.push_str("\\f"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
character if character <= '\u{1f}' => {
let _ = write!(escaped, "\\u{:04x}", u32::from(character));
}
character => escaped.push(character),
}
}
escaped
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct WalkAttribution {
pub wall_ns: u64,
pub work_ns: u64,
pub starved_ns: u64,
pub lock_wait_ns: u64,
pub send_ns: u64,
pub claims: u64,
pub lock_ops: u64,
pub lock_contended: u64,
}
impl WalkAttribution {
fn absorb(&mut self, other: Self) {
self.wall_ns += other.wall_ns;
self.work_ns += other.work_ns;
self.starved_ns += other.starved_ns;
self.lock_wait_ns += other.lock_wait_ns;
self.send_ns += other.send_ns;
self.claims += other.claims;
self.lock_ops += other.lock_ops;
self.lock_contended += other.lock_contended;
}
pub fn accounted_ns(&self) -> u64 {
self.work_ns + self.starved_ns + self.lock_wait_ns + self.send_ns
}
}
#[derive(Debug, Default)]
pub struct ReconcileReport {
pub scan: ScanReport,
pub apply: ApplyStats,
pub(crate) observations: u64,
pub(crate) listed_incomplete: Vec<PathBuf>,
reconcile_epoch: Option<u64>,
retry_required: bool,
}
impl ReconcileReport {
pub fn is_complete(&self) -> bool {
self.scan.is_complete()
&& self.apply.stale == 0
&& self.apply.resource_refused == 0
&& !self.retry_required
}
pub(crate) const fn retry_required(&self) -> bool {
self.retry_required
}
pub(crate) fn take_recordable_completeness(&mut self) -> Vec<PathBuf> {
let listed = std::mem::take(&mut self.listed_incomplete);
if self.apply.stale > 0 || self.apply.resource_refused > 0 { Vec::new() } else { listed }
}
}
enum ReconcileTarget<'a> {
Direct(&'a mut Index),
Shared(&'a IndexHandle),
Controlled { handle: &'a IndexHandle, control: &'a dyn ReconcileControl },
}
pub(crate) trait ReconcileControl {
fn check_active(&self) -> Result<()>;
fn before_conditional_commit(&self) -> Result<()>;
fn max_files(&self) -> Option<u64>;
}
impl ReconcileTarget<'_> {
fn scope(&self) -> Result<ScanScope> {
match self {
Self::Direct(index) => Ok(index.scope()),
Self::Shared(handle) | Self::Controlled { handle, .. } => handle.scope(),
}
}
fn root_path(&self) -> Result<PathBuf> {
match self {
Self::Direct(index) => Ok(index.root_path().to_path_buf()),
Self::Shared(handle) | Self::Controlled { handle, .. } => handle.root_path(),
}
}
fn expectation(&self, path: &Path) -> Result<PathExpectation> {
match self {
Self::Direct(index) => Ok(index.expectation(path)),
Self::Shared(handle) | Self::Controlled { handle, .. } => handle.expectation(path),
}
}
fn child_states(&self, path: &Path) -> Result<BTreeMap<OsString, PathExpectation>> {
match self {
Self::Direct(index) => Ok(collect_child_expectations(index, path)),
Self::Shared(handle) | Self::Controlled { handle, .. } => handle.child_states(path),
}
}
fn listing_baseline(&self, path: &Path) -> Result<(BTreeMap<OsString, PathExpectation>, bool)> {
match self {
Self::Direct(index) => Ok((
collect_child_expectations(index, path),
index.directory_complete(path) != Some(true),
)),
Self::Shared(handle) | Self::Controlled { handle, .. } => handle.listing_baseline(path),
}
}
fn has_control(&self, path: &Path) -> Result<bool> {
match self {
Self::Direct(index) => Ok(index.control_table().contains(path)),
Self::Shared(handle) | Self::Controlled { handle, .. } => handle.has_control(path),
}
}
fn control_table(&self) -> Result<crate::control::ControlTable> {
match self {
Self::Direct(index) => Ok(index.control_table().clone()),
Self::Shared(handle) | Self::Controlled { handle, .. } => {
handle.read_with(|index| index.control_table().clone())
}
}
}
fn control_classification_known(&self, path: &Path) -> Result<bool> {
match self {
Self::Direct(index) => Ok(index.control_classification_known(path)),
Self::Shared(handle) | Self::Controlled { handle, .. } => {
handle.read_with(|index| index.control_classification_known(path))
}
}
}
fn apply(&mut self, started_at: u64, observation: &Observation) -> Result<crate::ApplyOutcome> {
match self {
Self::Direct(index) => index.apply(observation),
Self::Shared(handle) => handle.apply_reconcile(started_at, observation),
Self::Controlled { handle, control } => {
control.before_conditional_commit()?;
handle.apply_opened_reconcile(started_at, observation, control.max_files())
}
}
}
fn direct_upsert_is_unchanged(
&self,
baseline: PathExpectation,
kind: EntryKind,
attrs: Attrs,
) -> bool {
matches!(self, Self::Direct(_)) && baseline.state == (PathState::Present { kind, attrs })
}
fn take_pending_invalidations(&mut self) -> Result<Vec<(PathBuf, crate::InvalidateReason)>> {
match self {
Self::Direct(index) => Ok(index.take_pending_invalidations()),
Self::Shared(handle) | Self::Controlled { handle, .. } => {
handle.take_pending_invalidations()
}
}
}
fn restore_pending_invalidations(
&mut self,
invalidations: Vec<(PathBuf, crate::InvalidateReason)>,
) -> Result<()> {
match self {
Self::Direct(index) => index.restore_pending_invalidations(invalidations),
Self::Shared(handle) | Self::Controlled { handle, .. } => {
handle.restore_pending_invalidations(invalidations)?;
}
}
Ok(())
}
fn retries_incomplete(&self, report: &ReconcileReport) -> bool {
match self {
Self::Direct(_) => !report.is_complete(),
Self::Shared(_) | Self::Controlled { .. } => {
report.apply.stale > 0 || report.apply.resource_refused > 0 || report.retry_required
}
}
}
fn begin_reconcile(&mut self, path: &Path) -> Result<(u64, Option<Commit>)> {
match self {
Self::Direct(index) => index.begin_reconcile(path),
Self::Shared(handle) => handle.begin_reconcile(path),
Self::Controlled { handle, control } => {
control.check_active()?;
handle.begin_reconcile(path)
}
}
}
fn finish_reconcile(
&mut self,
path: &Path,
started_at: u64,
complete: bool,
listed_incomplete: &[PathBuf],
failed_paths: &[PathBuf],
errors: ReconcileErrors<'_>,
) -> Result<ReconcileFinish> {
match self {
Self::Direct(index) => index.finish_reconcile(
path,
started_at,
complete,
listed_incomplete,
failed_paths,
errors,
),
Self::Shared(handle) => handle.finish_reconcile(
path,
started_at,
complete,
listed_incomplete,
failed_paths,
errors,
),
Self::Controlled { handle, control } => {
control.check_active()?;
handle.finish_reconcile(
path,
started_at,
complete,
listed_incomplete,
failed_paths,
errors,
)
}
}
}
}
#[cfg(unix)]
pub(crate) fn metadata_for_fingerprint(entry: &fs::DirEntry) -> std::io::Result<fs::Metadata> {
crate::counters::bump(|c| c.stats += 1);
entry.metadata()
}
#[cfg(any(all(windows, test), not(any(unix, windows))))]
pub(crate) fn metadata_for_fingerprint(entry: &fs::DirEntry) -> std::io::Result<fs::Metadata> {
crate::counters::bump(|c| c.stats += 1);
fs::symlink_metadata(entry.path())
}
#[cfg(test)]
type WalkHook = std::sync::Arc<dyn Fn(WalkHookPoint<'_>) -> Option<std::io::Error> + Send + Sync>;
#[cfg(test)]
#[derive(Clone, Copy, Debug)]
pub(crate) enum WalkHookPoint<'a> {
ChildMetadata(&'a Path),
ListingEnd,
ControlLookup(&'a Path),
}
#[cfg(test)]
static WALK_HOOKS: std::sync::RwLock<Vec<(Vec<PathBuf>, WalkHook)>> =
std::sync::RwLock::new(Vec::new());
#[cfg(test)]
#[must_use = "the hook is removed as soon as the guard is dropped"]
pub(crate) struct WalkHookGuard(WalkHook);
#[cfg(test)]
impl Drop for WalkHookGuard {
fn drop(&mut self) {
WALK_HOOKS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain(|(_, hook)| !std::sync::Arc::ptr_eq(hook, &self.0));
}
}
#[cfg(test)]
pub(crate) fn install_walk_hook(
root: &Path,
hook: impl Fn(WalkHookPoint<'_>) -> Option<std::io::Error> + Send + Sync + 'static,
) -> WalkHookGuard {
let mut roots = vec![root.to_path_buf()];
if let Ok(canonical) = root.canonicalize() {
roots.push(canonical);
}
let hook: WalkHook = std::sync::Arc::new(hook);
WALK_HOOKS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push((roots, std::sync::Arc::clone(&hook)));
WalkHookGuard(hook)
}
#[cfg(test)]
pub(crate) fn install_child_metadata_hook(
root: &Path,
hook: impl Fn(&Path) -> Option<std::io::Error> + Send + Sync + 'static,
) -> WalkHookGuard {
install_walk_hook(root, move |point| match point {
WalkHookPoint::ChildMetadata(path) => hook(path),
WalkHookPoint::ListingEnd | WalkHookPoint::ControlLookup(_) => None,
})
}
#[cfg(test)]
fn walk_hook(path: &Path) -> Option<WalkHook> {
WALK_HOOKS
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.find(|(roots, _)| roots.iter().any(|root| path.starts_with(root)))
.map(|(_, hook)| std::sync::Arc::clone(hook))
}
#[cfg(test)]
fn reconcile_listing<'r>(listing: Listing<'r>, dir: &Path) -> impl Iterator<Item = Listed<'r>> {
let injected = walk_hook(dir).and_then(|hook| hook(WalkHookPoint::ListingEnd));
listing.chain(injected.map(Listed::Failed))
}
#[cfg(not(test))]
fn reconcile_listing<'r>(listing: Listing<'r>, _dir: &Path) -> Listing<'r> {
listing
}
#[cfg(all(test, not(windows)))]
fn child_metadata_hook(path: &Path) -> Option<std::io::Error> {
walk_hook(path).and_then(|hook| hook(WalkHookPoint::ChildMetadata(path)))
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
fn std_child_stat_never_automounts() -> bool {
linux_dents::statx_unavailable()
}
#[cfg(not(any(windows, all(target_os = "linux", target_env = "gnu"))))]
const fn std_child_stat_never_automounts() -> bool {
true
}
fn listed_child_kind_and_attrs(
entry: &fs::DirEntry,
policy: ListingPolicy,
searchability: &mut Searchability,
) -> std::io::Result<Option<(EntryKind, Attrs)>> {
#[cfg(not(windows))]
{
let listing_kind_suffices = policy.skip_dir_symlink_stat
&& *searchability == Searchability::Proven
&& std_child_stat_never_automounts();
if listing_kind_suffices {
if let Ok(file_type) = entry.file_type() {
if file_type.is_dir() && !policy.one_filesystem {
return Ok(Some((EntryKind::Dir, Attrs::default())));
}
if file_type.is_symlink() {
return Ok(Some((EntryKind::Symlink, Attrs::default())));
}
}
}
let observed = observe_dir_entry(entry)?;
if observed.is_some() {
*searchability = Searchability::Proven;
}
if policy.skip_dir_symlink_stat && !listing_kind_suffices {
return Ok(observed.map(|(kind, attrs)| match kind {
EntryKind::Dir if !policy.one_filesystem => (kind, Attrs::default()),
EntryKind::Symlink => (kind, Attrs::default()),
EntryKind::Dir | EntryKind::File | EntryKind::Other => (kind, attrs),
}));
}
Ok(observed)
}
#[cfg(windows)]
{
let _ = (policy.skip_dir_symlink_stat, policy.one_filesystem);
let observed = observe_dir_entry(entry)?;
if observed.is_some() {
*searchability = Searchability::Proven;
}
Ok(observed)
}
}
#[derive(Clone, Copy, Debug)]
pub(crate) struct ListingPolicy {
pub(crate) skip_dir_symlink_stat: bool,
pub(crate) one_filesystem: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum Searchability {
#[default]
Unproven,
Proven,
}
impl ListingPolicy {
pub(crate) const fn every_child_stated(one_filesystem: bool) -> Self {
Self { skip_dir_symlink_stat: false, one_filesystem }
}
}
pub(crate) struct Readers {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
dents: linux_dents::Reader,
}
impl Readers {
pub(crate) fn new() -> Self {
Self {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
dents: linux_dents::Reader::new(),
}
}
}
pub(crate) enum Listed<'a> {
Failed(std::io::Error),
Child {
name: std::borrow::Cow<'a, OsStr>,
observed: std::io::Result<Option<(EntryKind, Attrs)>>,
},
}
pub(crate) enum Listing<'r> {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
Native(linux_dents::Listing<'r>),
Portable {
entries: fs::ReadDir,
policy: ListingPolicy,
searchability: Searchability,
readers: std::marker::PhantomData<&'r mut Readers>,
},
}
impl<'r> Iterator for Listing<'r> {
type Item = Listed<'r>;
fn next(&mut self) -> Option<Listed<'r>> {
match self {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
Self::Native(listing) => {
let entry = listing.next()?;
let observed = match entry.outcome {
linux_dents::Outcome::Observed { kind, attrs } => Ok(Some((kind, attrs))),
linux_dents::Outcome::Failed(error) => Err(error),
};
Some(Listed::Child { name: std::borrow::Cow::Borrowed(entry.name), observed })
}
Self::Portable { entries, policy, searchability, .. } => {
let item = match entries.next()? {
Ok(item) => item,
Err(error) => return Some(Listed::Failed(error)),
};
crate::counters::bump(|c| c.dir_entries += 1);
let observed = listed_child_kind_and_attrs(&item, *policy, searchability);
Some(Listed::Child { name: std::borrow::Cow::Owned(item.file_name()), observed })
}
}
}
}
pub(crate) fn list_directory<'r>(
readers: &'r mut Readers,
abs_dir: &Path,
policy: ListingPolicy,
diagnostics: Option<&ScanDiagnosticsRecorder>,
) -> std::io::Result<Listing<'r>> {
#[cfg(all(target_os = "linux", target_env = "gnu"))]
{
if !walk_hook_covers(abs_dir) {
if let Some(diagnostics) = diagnostics {
diagnostics.linux_dents_attempted();
}
let native = linux_dents::StatPolicy {
skip_dir_symlink_stat: policy.skip_dir_symlink_stat,
one_filesystem: policy.one_filesystem,
};
if let Some(listing) = readers.dents.read(abs_dir, native) {
if let Some(diagnostics) = diagnostics {
diagnostics.linux_dents_succeeded();
}
return Ok(Listing::Native(listing));
}
if let Some(diagnostics) = diagnostics {
diagnostics.linux_dents_fell_back();
}
}
}
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
let _ = &readers;
crate::counters::bump(|c| c.dir_opens += 1);
if let Some(diagnostics) = diagnostics {
diagnostics.portable_attempted();
}
let entries = fs::read_dir(abs_dir)?;
if let Some(diagnostics) = diagnostics {
diagnostics.portable_succeeded();
}
Ok(Listing::Portable {
entries,
policy,
searchability: Searchability::Unproven,
readers: std::marker::PhantomData,
})
}
fn missing_as_none<T>(lookup: std::io::Result<T>) -> std::io::Result<Option<T>> {
match lookup {
Ok(metadata) => Ok(Some(metadata)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
#[cfg(all(test, any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))]
fn walk_hook_covers(path: &Path) -> bool {
walk_hook(path).is_some()
}
#[cfg(all(not(test), any(target_os = "macos", all(target_os = "linux", target_env = "gnu"))))]
const fn walk_hook_covers(_path: &Path) -> bool {
false
}
#[derive(Debug)]
pub(crate) struct ScannerBatch {
ops: Vec<ObservationOp>,
recycle: Option<std::sync::mpsc::Sender<Vec<ObservationOp>>>,
}
impl ScannerBatch {
pub(crate) const fn new(ops: Vec<ObservationOp>) -> Self {
Self { ops, recycle: None }
}
fn with_recycle(self, recycle: std::sync::mpsc::Sender<Vec<ObservationOp>>) -> Self {
Self { recycle: Some(recycle), ..self }
}
#[cfg(test)]
pub(crate) fn from_ops(ops: Vec<Op>) -> Self {
Self { ops: ops.into_iter().map(ObservationOp::unconditional).collect(), recycle: None }
}
pub(crate) fn len(&self) -> usize {
self.ops.len()
}
pub(crate) fn ops(&self) -> &[ObservationOp] {
&self.ops
}
pub(crate) fn into_ops(self) -> Vec<ObservationOp> {
self.ops
}
fn into_observation(self) -> Observation {
Observation::from_ops(self.ops)
}
fn recycle(self) {
if let Some(recycle) = self.recycle {
let _ = recycle.send(self.ops);
}
}
}
#[derive(Debug)]
pub(crate) struct DetachedChild {
pub(crate) name: OsString,
pub(crate) kind: EntryKind,
pub(crate) attrs: Attrs,
pub(crate) position: u32,
}
#[derive(Debug)]
pub(crate) struct DetachedDirectory {
pub(crate) path: PathBuf,
pub(crate) children: Vec<DetachedChild>,
pub(crate) control: Option<Op>,
}
pub fn scan(
root: &Path,
config: &ScanConfig,
sink: &mut dyn FnMut(Observation),
) -> Result<ScanReport> {
let mut public_sink = |batch: ScannerBatch| sink(batch.into_observation());
let (mut report, _diagnostics) = scan_internal(
root,
config,
&mut public_sink,
false,
WorkerPolicyExperiment::ShippedOneShot,
SinkMode::Retained,
)?;
normalize_walk_errors(root, &mut report.errors);
Ok(report)
}
pub(crate) fn scan_summary_fold(
root: &Path,
config: &ScanConfig,
fold: &mut dyn FnMut(&ObservationOp),
) -> Result<ScanReport> {
let mut sink = |batch: ScannerBatch| {
for op in batch.ops() {
fold(op);
}
batch.recycle();
};
let (mut report, _diagnostics) = scan_internal(
root,
config,
&mut sink,
false,
WorkerPolicyExperiment::ShippedOneShot,
SinkMode::TransientFold,
)?;
normalize_walk_errors(root, &mut report.errors);
Ok(report)
}
pub(crate) fn scan_summary_fold_with_diagnostics(
root: &Path,
config: &ScanConfig,
fold: &mut dyn FnMut(&ObservationOp),
) -> Result<(ScanReport, ScanDiagnostics)> {
let mut sink = |batch: ScannerBatch| {
for op in batch.ops() {
fold(op);
}
batch.recycle();
};
let (mut report, diagnostics) = scan_internal(
root,
config,
&mut sink,
true,
WorkerPolicyExperiment::ShippedOneShot,
SinkMode::TransientFold,
)?;
normalize_walk_errors(root, &mut report.errors);
Ok((report, diagnostics.expect("diagnostic scan creates a recorder")))
}
pub fn scan_with_diagnostics(
root: &Path,
config: &ScanConfig,
sink: &mut dyn FnMut(Observation),
) -> Result<(ScanReport, ScanDiagnostics)> {
scan_with_policy_diagnostics(root, config, sink, WorkerPolicyExperiment::ShippedOneShot)
}
#[doc(hidden)]
pub fn scan_with_policy_diagnostics(
root: &Path,
config: &ScanConfig,
sink: &mut dyn FnMut(Observation),
policy: WorkerPolicyExperiment,
) -> Result<(ScanReport, ScanDiagnostics)> {
let mut public_sink = |batch: ScannerBatch| sink(batch.into_observation());
let (mut report, diagnostics) =
scan_internal(root, config, &mut public_sink, true, policy, SinkMode::Retained)?;
normalize_walk_errors(root, &mut report.errors);
Ok((report, diagnostics.expect("diagnostic scan creates a recorder")))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SinkMode {
Retained,
TransientFold,
}
impl SinkMode {
fn recycles_batches(self) -> bool {
self == Self::TransientFold
}
fn skips_dir_symlink_stat(self) -> bool {
self == Self::TransientFold
}
fn groups_directories(self, config: &ScanConfig) -> bool {
self == Self::TransientFold && config.read_controls
}
}
fn scan_internal(
root: &Path,
config: &ScanConfig,
sink: &mut dyn FnMut(ScannerBatch),
collect_diagnostics: bool,
policy: WorkerPolicyExperiment,
sink_mode: SinkMode,
) -> Result<(ScanReport, Option<ScanDiagnostics>)> {
config.validate()?;
if let Some(progress) = &config.progress {
progress.enter(crate::ProgressPhase::Scanning);
}
let root_meta = {
crate::counters::bump(|c| c.stats += 1);
fs::symlink_metadata(root)
}
.map_err(|e| Error::io(root, e))?;
if !root_meta.is_dir() {
return Err(Error::io(
root,
std::io::Error::new(std::io::ErrorKind::NotADirectory, "scan root is not a directory"),
));
}
let root_dev = root_device(root, &root_meta).map_err(|error| Error::io(root, error))?;
let available_parallelism =
std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
let pool = if config.population == crate::query::IgnoredEntries::Include {
config.worker_pool_for(available_parallelism)
} else {
WorkerPool::fixed(1)
};
let diagnostics = collect_diagnostics
.then(|| ScanDiagnosticsRecorder::new(pool, available_parallelism, policy));
let groups = sink_mode.groups_directories(config)
&& config.population == crate::query::IgnoredEntries::Include;
if config.max_depth != Some(0) && (pool.initial > 1 || groups) {
let report = scan_concurrent(
root,
config,
root_dev,
sink,
pool,
diagnostics.as_ref(),
policy,
sink_mode,
);
return Ok((report, diagnostics.as_ref().map(|value| value.finish())));
}
let mut report = ScanReport::default();
if config.max_depth == Some(0) {
if let Some(diagnostics) = &diagnostics {
diagnostics.mark_not_run();
diagnostics.record_queue_finish(0, 0);
}
return Ok((report, diagnostics.as_ref().map(|value| value.finish())));
}
let worker_guard = diagnostics.as_ref().map(ScanDiagnosticsRecorder::worker_guard);
let walk_started = std::time::Instant::now();
let mut batch: Vec<ObservationOp> = Vec::with_capacity(config.batch_size);
let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::from(vec![(PathBuf::new(), 0)]);
let mut controls = (config.population != crate::query::IgnoredEntries::Include)
.then(|| crate::control::ControlTable::with_limits(config.control_limits));
let mut unreadable_controls = std::collections::BTreeSet::new();
let mut tally = ProgressTally::new(config.progress.as_ref());
let mut readers = Readers::new();
let policy = ListingPolicy {
skip_dir_symlink_stat: sink_mode.skips_dir_symlink_stat(),
one_filesystem: config.one_filesystem,
};
let mut emit = |ops: Vec<ObservationOp>, report: &mut ScanReport| {
let send_started = std::time::Instant::now();
sink(ScannerBatch::new(ops));
report.attribution.send_ns += elapsed_ns(send_started);
tally.flush(report);
};
while let Some((rel_dir, depth)) = take_next(&mut queue, config.order) {
let abs_dir = root.join(&rel_dir);
if let Some(controls) = controls.as_mut() {
let control_path = rel_dir.join(crate::control::CONTROL_FILE_NAME);
match read_directory_control(config, root, &control_path) {
Ok(Some(op)) => {
apply_discovery_control(controls, &op)?;
batch.push(ObservationOp::unconditional(op));
}
Ok(None) => {}
Err(error) => {
unreadable_controls.insert(rel_dir.clone());
report.errors.push(error);
}
}
}
let listing = match list_directory(&mut readers, &abs_dir, policy, diagnostics.as_deref()) {
Ok(listing) => listing,
Err(e) => {
report.errors.push(Error::io(abs_dir, e));
continue;
}
};
report.dirs_read += 1;
for item in listing {
let (name, observed) = match item {
Listed::Child { name, observed } => (name, observed),
Listed::Failed(e) => {
report.errors.push(Error::io(&abs_dir, e));
continue;
}
};
let rel_path = rel_dir.join(&name);
let (kind, attrs) = match observed {
Ok(Some(observed)) => observed,
Ok(None) => continue,
Err(error) => {
report.errors.push(Error::io(abs_dir.join(&name), error));
continue;
}
};
let disposition =
crate::admission::decide(&name, kind, config.hidden(), config.exclude_special);
if disposition == crate::admission::Disposition::Reject {
continue;
}
if population_prunes(
config.population,
&rel_path,
kind,
disposition,
controls.as_ref(),
&unreadable_controls,
) {
continue;
}
let control =
match if controls.is_some() && crate::control::control_spelling(&name).is_some() {
Ok(None)
} else {
read_control_op(config, root, &rel_path, kind)
} {
Ok(control) => control,
Err(error) => {
report.errors.push(error);
None
}
};
if disposition == crate::admission::Disposition::ControlOnly {
if let Some(control) = control {
batch.push(ObservationOp::unconditional(control));
if batch.len() >= config.batch_size {
emit(std::mem::take(&mut batch), &mut report);
batch.reserve(config.batch_size);
}
}
continue;
}
report.observe(kind, attrs);
batch.push(ObservationOp::unconditional(Op::Upsert {
path: rel_path.clone(),
kind,
attrs,
}));
if batch.len() >= config.batch_size {
emit(std::mem::take(&mut batch), &mut report);
batch.reserve(config.batch_size);
}
if let Some(control) = control {
batch.push(ObservationOp::unconditional(control));
if batch.len() >= config.batch_size {
emit(std::mem::take(&mut batch), &mut report);
batch.reserve(config.batch_size);
}
}
if should_descend(kind, attrs, depth, root_dev, config) {
queue.push_back((rel_path, depth + 1));
}
}
}
if !batch.is_empty() {
emit(batch, &mut report);
}
tally.flush(&report);
report.attribution.wall_ns = elapsed_ns(walk_started);
report.attribution.work_ns =
report.attribution.wall_ns.saturating_sub(report.attribution.send_ns);
drop(worker_guard);
if let Some(diagnostics) = &diagnostics {
diagnostics.record_queue_finish(0, 0);
}
Ok((report, diagnostics.as_ref().map(|value| value.finish())))
}
fn take_next(queue: &mut VecDeque<(PathBuf, usize)>, order: ScanOrder) -> Option<(PathBuf, usize)> {
match order {
ScanOrder::BreadthFirst => queue.pop_front(),
ScanOrder::DepthFirst => queue.pop_back(),
}
}
const MAX_SCAN_THREADS: usize = 32;
const DEFAULT_SCAN_THREADS_CAP: usize = crate::platform_tuning::tuning().scan_threads_cap.get();
const DEFAULT_RECONCILE_THREADS_CAP: usize =
crate::platform_tuning::tuning().reconcile_threads_cap.get();
const ADAPTIVE_SCAN_THREADS_CAP: usize =
crate::platform_tuning::tuning().adaptive_scan_threads_cap.get();
const ADAPTIVE_SCAN_PARALLELISM_MULTIPLIER: usize =
crate::platform_tuning::tuning().adaptive_scan_parallelism_multiplier.get();
const ADAPTIVE_SCAN_CALIBRATION_ENTRIES: u64 =
crate::platform_tuning::tuning().adaptive_scan_calibration_entries.get();
const ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY: u64 =
crate::platform_tuning::tuning().adaptive_scan_slow_work_ns_per_entry.get();
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct WorkerPool {
initial: usize,
maximum: usize,
calibration: Option<WorkerCalibration>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct WorkerCalibration {
minimum_entries: u64,
slow_work_ns_per_entry: u64,
entries: u64,
work_ns: u64,
chunks: u64,
}
#[derive(Clone, Copy, Debug)]
struct PolicyWindowSnapshot {
sequence: u64,
start_entry_ordinal: u64,
end_entry_ordinal: u64,
observed_entries: u64,
observed_chunks: u64,
observed_work_ns: u64,
ready_directories: usize,
in_flight_directories: usize,
active_workers: usize,
handoff_backlog: usize,
requested_workers: Option<usize>,
decision: WorkerPolicyDecision,
}
struct PolicyTraceState {
outcome: WorkerPolicyOutcome,
outcome_reason: Option<&'static str>,
outcome_sequence: Option<u64>,
windows: Vec<WorkerPolicyWindow>,
events_truncated: bool,
ready_directories_at_finish: usize,
in_flight_directories_at_finish: usize,
}
pub(crate) struct ScanDiagnosticsRecorder {
available_parallelism: usize,
pool: WorkerPool,
policy: WorkerPolicyExperiment,
trace: std::sync::Mutex<PolicyTraceState>,
workers_spawned: std::sync::atomic::AtomicUsize,
active_workers: std::sync::atomic::AtomicUsize,
peak_active_workers: std::sync::atomic::AtomicUsize,
handoff_backlog: std::sync::atomic::AtomicUsize,
handoff_backlog_high_water: std::sync::atomic::AtomicUsize,
calibration_chunks: std::sync::atomic::AtomicU64,
calibration_entries: std::sync::atomic::AtomicU64,
calibration_work_ns: std::sync::atomic::AtomicU64,
worker_expansions: std::sync::atomic::AtomicU64,
portable_attempts: std::sync::atomic::AtomicU64,
portable_successes: std::sync::atomic::AtomicU64,
#[cfg(target_os = "macos")]
macos_bulk_attempts: std::sync::atomic::AtomicU64,
#[cfg(target_os = "macos")]
macos_bulk_successes: std::sync::atomic::AtomicU64,
#[cfg(target_os = "macos")]
macos_bulk_fallbacks: std::sync::atomic::AtomicU64,
#[cfg(all(target_os = "linux", target_env = "gnu"))]
linux_dents_attempts: std::sync::atomic::AtomicU64,
#[cfg(all(target_os = "linux", target_env = "gnu"))]
linux_dents_successes: std::sync::atomic::AtomicU64,
#[cfg(all(target_os = "linux", target_env = "gnu"))]
linux_dents_fallbacks: std::sync::atomic::AtomicU64,
}
impl ScanDiagnosticsRecorder {
fn new(
pool: WorkerPool,
available_parallelism: usize,
policy: WorkerPolicyExperiment,
) -> std::sync::Arc<Self> {
let (outcome, outcome_reason) = if pool.calibration.is_some() {
(
WorkerPolicyOutcome::Undecided,
Some("the adaptive calibration window has not completed"),
)
} else {
(WorkerPolicyOutcome::Fixed, Some("this worker pool has no adaptive reserve"))
};
std::sync::Arc::new(Self {
available_parallelism,
pool,
policy,
trace: std::sync::Mutex::new(PolicyTraceState {
outcome,
outcome_reason,
outcome_sequence: None,
windows: Vec::new(),
events_truncated: false,
ready_directories_at_finish: 0,
in_flight_directories_at_finish: 0,
}),
workers_spawned: std::sync::atomic::AtomicUsize::new(0),
active_workers: std::sync::atomic::AtomicUsize::new(0),
peak_active_workers: std::sync::atomic::AtomicUsize::new(0),
handoff_backlog: std::sync::atomic::AtomicUsize::new(0),
handoff_backlog_high_water: std::sync::atomic::AtomicUsize::new(0),
calibration_chunks: std::sync::atomic::AtomicU64::new(0),
calibration_entries: std::sync::atomic::AtomicU64::new(0),
calibration_work_ns: std::sync::atomic::AtomicU64::new(0),
worker_expansions: std::sync::atomic::AtomicU64::new(0),
portable_attempts: std::sync::atomic::AtomicU64::new(0),
portable_successes: std::sync::atomic::AtomicU64::new(0),
#[cfg(target_os = "macos")]
macos_bulk_attempts: std::sync::atomic::AtomicU64::new(0),
#[cfg(target_os = "macos")]
macos_bulk_successes: std::sync::atomic::AtomicU64::new(0),
#[cfg(target_os = "macos")]
macos_bulk_fallbacks: std::sync::atomic::AtomicU64::new(0),
#[cfg(all(target_os = "linux", target_env = "gnu"))]
linux_dents_attempts: std::sync::atomic::AtomicU64::new(0),
#[cfg(all(target_os = "linux", target_env = "gnu"))]
linux_dents_successes: std::sync::atomic::AtomicU64::new(0),
#[cfg(all(target_os = "linux", target_env = "gnu"))]
linux_dents_fallbacks: std::sync::atomic::AtomicU64::new(0),
})
}
fn worker_guard(self: &std::sync::Arc<Self>) -> ScanWorkerGuard {
let active = self
.active_workers
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
.saturating_add(1);
self.workers_spawned.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
atomic_update_max(&self.peak_active_workers, active);
ScanWorkerGuard { recorder: self.clone() }
}
fn record_policy_window(&self, snapshot: PolicyWindowSnapshot) {
let mut trace = self.trace.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let sequence = snapshot.sequence;
let supersedes = trace.outcome_sequence.is_none_or(|current| sequence >= current);
match snapshot.decision {
WorkerPolicyDecision::Undecided if supersedes => {
trace.outcome = WorkerPolicyOutcome::Undecided;
trace.outcome_reason =
Some("the walk ended before the adaptive calibration window completed");
trace.outcome_sequence = Some(sequence);
}
WorkerPolicyDecision::Hold
if trace.outcome != WorkerPolicyOutcome::ScaledUp && supersedes =>
{
trace.outcome = WorkerPolicyOutcome::Held;
trace.outcome_reason = None;
trace.outcome_sequence = Some(sequence);
}
WorkerPolicyDecision::ScaleUp => {
trace.outcome = WorkerPolicyOutcome::ScaledUp;
trace.outcome_reason = None;
trace.outcome_sequence = Some(sequence);
}
WorkerPolicyDecision::HoldNoUsefulWork
if trace.outcome != WorkerPolicyOutcome::ScaledUp && supersedes =>
{
trace.outcome = WorkerPolicyOutcome::HeldNoUsefulWork;
trace.outcome_reason =
Some("the slow trigger fired only after ready and in-flight work had drained");
trace.outcome_sequence = Some(sequence);
}
WorkerPolicyDecision::HoldInsufficientFrontier
if trace.outcome != WorkerPolicyOutcome::ScaledUp && supersedes =>
{
trace.outcome = WorkerPolicyOutcome::Held;
trace.outcome_reason =
Some("the observed frontier could not use additional workers");
trace.outcome_sequence = Some(sequence);
}
WorkerPolicyDecision::HoldHandoffBacklog
if trace.outcome != WorkerPolicyOutcome::ScaledUp && supersedes =>
{
trace.outcome = WorkerPolicyOutcome::Held;
trace.outcome_reason =
Some("the observation handoff backlog was already at the controller limit");
trace.outcome_sequence = Some(sequence);
}
WorkerPolicyDecision::Incomplete
if supersedes && trace.outcome == WorkerPolicyOutcome::Undecided =>
{
trace.outcome_reason =
Some("the walk ended before any adaptive calibration window completed");
trace.outcome_sequence = Some(sequence);
}
_ => {}
}
if sequence >= MAX_POLICY_TRACE_EVENTS as u64 {
trace.events_truncated = true;
return;
}
let work_ns_per_entry = (snapshot.observed_entries > 0)
.then(|| snapshot.observed_work_ns / snapshot.observed_entries);
trace.windows.push(WorkerPolicyWindow {
sequence,
start_entry_ordinal: snapshot.start_entry_ordinal,
end_entry_ordinal: snapshot.end_entry_ordinal,
observed_entries: snapshot.observed_entries,
observed_chunks: snapshot.observed_chunks,
observed_work_ns: snapshot.observed_work_ns,
work_ns_per_entry,
work_ns_per_entry_unavailable_reason: work_ns_per_entry
.is_none()
.then_some("the window observed no entries"),
ready_directories: snapshot.ready_directories,
in_flight_directories: snapshot.in_flight_directories,
active_workers: snapshot.active_workers,
handoff_backlog: snapshot.handoff_backlog,
requested_workers: snapshot.requested_workers,
decision: snapshot.decision,
});
}
fn mark_not_run(&self) {
let mut trace = self.trace.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
trace.outcome = WorkerPolicyOutcome::NotRun;
trace.outcome_reason = Some("max_depth zero requested no directory walk");
}
fn record_queue_finish(&self, ready_directories: usize, in_flight_directories: usize) {
let mut trace = self.trace.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
trace.ready_directories_at_finish = ready_directories;
trace.in_flight_directories_at_finish = in_flight_directories;
}
fn handoff_sent(&self) {
let backlog = self
.handoff_backlog
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
.saturating_add(1);
atomic_update_max(&self.handoff_backlog_high_water, backlog);
}
fn handoff_received(&self) {
let previous = self.handoff_backlog.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
debug_assert!(previous > 0, "received handoff must have been sent");
}
fn calibration_chunk(&self, entries: u64, work_ns: u64) {
self.calibration_chunks.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
self.calibration_entries.fetch_add(entries, std::sync::atomic::Ordering::Relaxed);
self.calibration_work_ns.fetch_add(work_ns, std::sync::atomic::Ordering::Relaxed);
}
fn worker_expanded(&self) {
self.worker_expansions.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
fn portable_attempted(&self) {
self.portable_attempts.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
fn portable_succeeded(&self) {
self.portable_successes.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
#[cfg(target_os = "macos")]
fn macos_bulk_attempted(&self) {
self.macos_bulk_attempts.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
#[cfg(target_os = "macos")]
fn macos_bulk_succeeded(&self) {
self.macos_bulk_successes.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
#[cfg(target_os = "macos")]
fn macos_bulk_fell_back(&self) {
self.macos_bulk_fallbacks.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
fn linux_dents_attempted(&self) {
self.linux_dents_attempts.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
fn linux_dents_succeeded(&self) {
self.linux_dents_successes.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
fn linux_dents_fell_back(&self) {
self.linux_dents_fallbacks.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
}
fn finish(&self) -> ScanDiagnostics {
let trace = self.trace.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let calibration = self.pool.calibration;
let backend = ScanBackendDiagnostics {
portable_attempts: self.portable_attempts.load(std::sync::atomic::Ordering::Relaxed),
portable_directory_reads: self
.portable_successes
.load(std::sync::atomic::Ordering::Relaxed),
#[cfg(target_os = "macos")]
macos_bulk_attempts: Some(
self.macos_bulk_attempts.load(std::sync::atomic::Ordering::Relaxed),
),
#[cfg(not(target_os = "macos"))]
macos_bulk_attempts: None,
#[cfg(target_os = "macos")]
macos_bulk_successes: Some(
self.macos_bulk_successes.load(std::sync::atomic::Ordering::Relaxed),
),
#[cfg(not(target_os = "macos"))]
macos_bulk_successes: None,
#[cfg(target_os = "macos")]
macos_bulk_fallbacks: Some(
self.macos_bulk_fallbacks.load(std::sync::atomic::Ordering::Relaxed),
),
#[cfg(not(target_os = "macos"))]
macos_bulk_fallbacks: None,
#[cfg(target_os = "macos")]
unavailable_reason: None,
#[cfg(not(target_os = "macos"))]
unavailable_reason: Some(
"macOS bulk directory enumeration is unavailable on this platform",
),
#[cfg(all(target_os = "linux", target_env = "gnu"))]
linux_dents_attempts: Some(
self.linux_dents_attempts.load(std::sync::atomic::Ordering::Relaxed),
),
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
linux_dents_attempts: None,
#[cfg(all(target_os = "linux", target_env = "gnu"))]
linux_dents_successes: Some(
self.linux_dents_successes.load(std::sync::atomic::Ordering::Relaxed),
),
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
linux_dents_successes: None,
#[cfg(all(target_os = "linux", target_env = "gnu"))]
linux_dents_fallbacks: Some(
self.linux_dents_fallbacks.load(std::sync::atomic::Ordering::Relaxed),
),
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
linux_dents_fallbacks: None,
};
ScanDiagnostics {
schema: SCAN_DIAGNOSTICS_SCHEMA,
worker_policy: WorkerPolicyDiagnostics {
controller: worker_policy_experiment_name(self.policy),
available_parallelism: self.available_parallelism,
initial_workers: self.pool.initial,
maximum_workers: self.pool.maximum,
calibration_window_entries: calibration.map(|value| value.minimum_entries),
slow_threshold_ns_per_entry: calibration.map(|value| value.slow_work_ns_per_entry),
calibration_chunks: self
.calibration_chunks
.load(std::sync::atomic::Ordering::Relaxed),
calibration_entries: self
.calibration_entries
.load(std::sync::atomic::Ordering::Relaxed),
calibration_work_ns: self
.calibration_work_ns
.load(std::sync::atomic::Ordering::Relaxed),
worker_expansions: self
.worker_expansions
.load(std::sync::atomic::Ordering::Relaxed),
outcome: trace.outcome,
outcome_reason: trace.outcome_reason,
workers_spawned: self.workers_spawned.load(std::sync::atomic::Ordering::Relaxed),
peak_active_workers: self
.peak_active_workers
.load(std::sync::atomic::Ordering::Relaxed),
ready_directories_at_finish: trace.ready_directories_at_finish,
in_flight_directories_at_finish: trace.in_flight_directories_at_finish,
handoff_backlog_at_finish: self
.handoff_backlog
.load(std::sync::atomic::Ordering::Relaxed),
handoff_backlog_high_water: self
.handoff_backlog_high_water
.load(std::sync::atomic::Ordering::Relaxed),
windows: {
let mut windows = trace.windows.clone();
windows.sort_by_key(|window| window.sequence);
windows
},
events_truncated: trace.events_truncated,
},
backend,
}
}
}
const fn worker_policy_experiment_name(value: WorkerPolicyExperiment) -> &'static str {
match value {
WorkerPolicyExperiment::ShippedOneShot => "shipped_one_shot",
WorkerPolicyExperiment::RepeatedWindows => "repeated_windows",
WorkerPolicyExperiment::StagedGatedWindows => "staged_gated_windows",
}
}
struct ScanWorkerGuard {
recorder: std::sync::Arc<ScanDiagnosticsRecorder>,
}
impl Drop for ScanWorkerGuard {
fn drop(&mut self) {
let previous =
self.recorder.active_workers.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
debug_assert!(previous > 0, "worker guard must balance worker start");
}
}
fn atomic_update_max(target: &std::sync::atomic::AtomicUsize, value: usize) {
let mut observed = target.load(std::sync::atomic::Ordering::Relaxed);
while value > observed {
match target.compare_exchange_weak(
observed,
value,
std::sync::atomic::Ordering::Relaxed,
std::sync::atomic::Ordering::Relaxed,
) {
Ok(_) => break,
Err(actual) => observed = actual,
}
}
}
enum WalkMessage {
Batch(ScannerBatch),
DetachedDirectories {
directories: Vec<DetachedDirectory>,
recycle: std::sync::mpsc::Sender<Vec<DetachedDirectory>>,
},
ScaleUp {
sender: std::sync::mpsc::Sender<Self>,
target_workers: usize,
},
}
impl WorkerPool {
const fn fixed(workers: usize) -> Self {
Self { initial: workers, maximum: workers, calibration: None }
}
}
impl WorkerCalibration {
const fn new(minimum_entries: u64, slow_work_ns_per_entry: u64) -> Self {
Self { minimum_entries, slow_work_ns_per_entry, entries: 0, work_ns: 0, chunks: 0 }
}
fn observe(&mut self, entries: u64, work_ns: u64) -> Option<bool> {
self.chunks = self.chunks.saturating_add(1);
self.entries = self.entries.saturating_add(entries);
self.work_ns = self.work_ns.saturating_add(work_ns);
(self.entries >= self.minimum_entries)
.then(|| self.work_ns / self.entries >= self.slow_work_ns_per_entry)
}
}
#[derive(Clone, Copy, Debug)]
struct CalibrationWindow {
start_entry_ordinal: u64,
end_entry_ordinal: u64,
entries: u64,
chunks: u64,
work_ns: u64,
slow: bool,
}
#[derive(Debug)]
struct RepeatedCalibration {
minimum_entries: u64,
slow_work_ns_per_entry: u64,
window_start: u64,
entries: u64,
chunks: u64,
work_ns: u64,
completed_windows: u64,
}
impl RepeatedCalibration {
const fn new(calibration: WorkerCalibration) -> Self {
Self {
minimum_entries: calibration.minimum_entries,
slow_work_ns_per_entry: calibration.slow_work_ns_per_entry,
window_start: 0,
entries: 0,
chunks: 0,
work_ns: 0,
completed_windows: 0,
}
}
const fn starting_at(calibration: WorkerCalibration, window_start: u64) -> Self {
let mut repeated = Self::new(calibration);
repeated.window_start = window_start;
repeated
}
fn observe(&mut self, entries: u64, work_ns: u64) -> Option<CalibrationWindow> {
self.chunks = self.chunks.saturating_add(1);
self.entries = self.entries.saturating_add(entries);
self.work_ns = self.work_ns.saturating_add(work_ns);
if self.entries < self.minimum_entries {
return None;
}
let end_entry_ordinal = self.window_start.saturating_add(self.entries);
let window = CalibrationWindow {
start_entry_ordinal: self.window_start,
end_entry_ordinal,
entries: self.entries,
chunks: self.chunks,
work_ns: self.work_ns,
slow: self.work_ns / self.entries >= self.slow_work_ns_per_entry,
};
self.window_start = end_entry_ordinal;
self.entries = 0;
self.chunks = 0;
self.work_ns = 0;
self.completed_windows = self.completed_windows.saturating_add(1);
Some(window)
}
}
#[derive(Debug)]
enum WorkerController {
OneShot(WorkerCalibration),
Repeated { calibration: RepeatedCalibration, staged_gated: bool },
}
impl WorkerController {
fn new(calibration: WorkerCalibration, policy: WorkerPolicyExperiment) -> Self {
match policy {
WorkerPolicyExperiment::ShippedOneShot => Self::OneShot(calibration),
WorkerPolicyExperiment::RepeatedWindows => Self::Repeated {
calibration: RepeatedCalibration::new(calibration),
staged_gated: false,
},
WorkerPolicyExperiment::StagedGatedWindows => Self::Repeated {
calibration: RepeatedCalibration::new(calibration),
staged_gated: true,
},
}
}
fn observe(&mut self, entries: u64, work_ns: u64) -> Option<CalibrationWindow> {
match self {
Self::OneShot(calibration) => {
let slow = calibration.observe(entries, work_ns)?;
Some(CalibrationWindow {
start_entry_ordinal: 0,
end_entry_ordinal: calibration.entries,
entries: calibration.entries,
chunks: calibration.chunks,
work_ns: calibration.work_ns,
slow,
})
}
Self::Repeated { calibration, .. } => calibration.observe(entries, work_ns),
}
}
fn partial_window(&self) -> (CalibrationWindow, WorkerPolicyDecision) {
match self {
Self::OneShot(calibration) => (
CalibrationWindow {
start_entry_ordinal: 0,
end_entry_ordinal: calibration.entries,
entries: calibration.entries,
chunks: calibration.chunks,
work_ns: calibration.work_ns,
slow: false,
},
WorkerPolicyDecision::Undecided,
),
Self::Repeated { calibration, .. } => (
CalibrationWindow {
start_entry_ordinal: calibration.window_start,
end_entry_ordinal: calibration.window_start.saturating_add(calibration.entries),
entries: calibration.entries,
chunks: calibration.chunks,
work_ns: calibration.work_ns,
slow: false,
},
if calibration.completed_windows == 0 {
WorkerPolicyDecision::Undecided
} else {
WorkerPolicyDecision::Incomplete
},
),
}
}
const fn is_staged_gated(&self) -> bool {
matches!(self, Self::Repeated { staged_gated: true, .. })
}
const fn is_one_shot(&self) -> bool {
matches!(self, Self::OneShot(_))
}
const fn calibration_spec(&self) -> WorkerCalibration {
match self {
Self::OneShot(calibration) => WorkerCalibration::new(
calibration.minimum_entries,
calibration.slow_work_ns_per_entry,
),
Self::Repeated { calibration, .. } => WorkerCalibration::new(
calibration.minimum_entries,
calibration.slow_work_ns_per_entry,
),
}
}
}
fn automatic_worker_pool(available: usize) -> WorkerPool {
let initial = available.clamp(1, DEFAULT_SCAN_THREADS_CAP);
if initial == 1 {
return WorkerPool::fixed(1);
}
let maximum = available
.saturating_mul(ADAPTIVE_SCAN_PARALLELISM_MULTIPLIER)
.clamp(initial, ADAPTIVE_SCAN_THREADS_CAP);
WorkerPool {
initial,
maximum,
calibration: (maximum > initial).then_some(WorkerCalibration::new(
ADAPTIVE_SCAN_CALIBRATION_ENTRIES,
ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY,
)),
}
}
const DIR_CLAIM: usize = 4;
#[allow(clippy::too_many_arguments)]
fn scan_concurrent(
root: &Path,
config: &ScanConfig,
root_dev: u64,
sink: &mut dyn FnMut(ScannerBatch),
pool: WorkerPool,
diagnostics: Option<&std::sync::Arc<ScanDiagnosticsRecorder>>,
policy: WorkerPolicyExperiment,
sink_mode: SinkMode,
) -> ScanReport {
let mut consume = |message| match message {
WalkMessage::Batch(batch) => {
if let Some(diagnostics) = diagnostics {
diagnostics.handoff_received();
}
sink(batch);
}
WalkMessage::DetachedDirectories { .. } => {
unreachable!("the streaming walker never publishes detached directories")
}
WalkMessage::ScaleUp { .. } => {
unreachable!("the shared runner consumes scale-up messages")
}
};
run_concurrent_walk(
root,
config,
root_dev,
pool,
diagnostics,
policy,
match sink_mode {
SinkMode::Retained => walk_worker,
SinkMode::TransientFold => walk_worker_transient_fold,
},
&mut consume,
)
}
fn scan_concurrent_detached(
root: &Path,
config: &ScanConfig,
root_dev: u64,
pool: WorkerPool,
diagnostics: Option<&std::sync::Arc<ScanDiagnosticsRecorder>>,
policy: WorkerPolicyExperiment,
retention: Option<TreeRetention>,
) -> Result<(ScanReport, DetachedIndexBuilder)> {
let mut builder = detached_builder(root, config, retention);
let mut build_error = None;
let output = {
let mut consume = |message| match message {
WalkMessage::Batch(_) => {
unreachable!("the detached walker never publishes scanner batches")
}
WalkMessage::DetachedDirectories { mut directories, recycle } => {
if let Some(diagnostics) = diagnostics {
diagnostics.handoff_received();
}
if build_error.is_none() {
for directory in &mut directories {
if let Err(error) = builder.push_directory(directory) {
build_error = Some(error);
break;
}
}
}
let _ = recycle.send(directories);
}
WalkMessage::ScaleUp { .. } => {
unreachable!("the shared runner consumes scale-up messages")
}
};
let worker: WalkWorker =
if retention.is_some() { walk_detached_folding_worker } else { walk_detached_worker };
run_concurrent_walk(root, config, root_dev, pool, diagnostics, policy, worker, &mut consume)
};
if let Some(error) = build_error {
return Err(error);
}
Ok((output, builder))
}
type WalkWorker = fn(
&Path,
&ScanConfig,
u64,
&DirectoryQueue,
&std::sync::mpsc::Sender<WalkMessage>,
Option<&std::sync::Arc<ScanDiagnosticsRecorder>>,
) -> ScanReport;
#[allow(clippy::too_many_arguments)]
fn run_concurrent_walk<C>(
root: &Path,
config: &ScanConfig,
root_dev: u64,
pool: WorkerPool,
diagnostics: Option<&std::sync::Arc<ScanDiagnosticsRecorder>>,
policy: WorkerPolicyExperiment,
worker: WalkWorker,
consume: &mut C,
) -> ScanReport
where
C: FnMut(WalkMessage),
{
let diagnostics = diagnostics.cloned();
let queue = DirectoryQueue::new_with_policy(
(PathBuf::new(), 0),
config.order,
pool.calibration,
diagnostics.clone(),
pool.initial,
pool.maximum,
policy,
);
let (sender, receiver) = std::sync::mpsc::channel::<WalkMessage>();
let mut report = std::thread::scope(|scope| {
let mut handles: Vec<_> = (0..pool.initial)
.map(|_| {
let sender = sender.clone();
let queue = &queue;
let diagnostics = diagnostics.clone();
scope.spawn(move || {
worker(root, config, root_dev, queue, &sender, diagnostics.as_ref())
})
})
.collect();
drop(sender);
let mut spawned_workers = pool.initial;
for message in receiver {
match message {
WalkMessage::ScaleUp { sender, target_workers }
if target_workers > spawned_workers =>
{
let target_workers = target_workers.min(pool.maximum);
record_adaptive_worker_expansion(diagnostics.as_ref());
for _ in spawned_workers..target_workers {
let sender = sender.clone();
let queue = &queue;
let diagnostics = diagnostics.clone();
handles.push(scope.spawn(move || {
worker(root, config, root_dev, queue, &sender, diagnostics.as_ref())
}));
}
spawned_workers = target_workers;
}
WalkMessage::ScaleUp { .. } => {}
output => consume(output),
}
}
let queue_finish = {
let mut state = queue.lock();
let mut trailing_window = None;
if let Some(controller) = &state.controller {
let (window, decision) = controller.partial_window();
if decision == WorkerPolicyDecision::Undecided {
crate::counters::bump(|counts| {
counts.adaptive_policy_undecided =
counts.adaptive_policy_undecided.saturating_add(1);
});
}
if diagnostics.is_some() {
let sequence = state.allocate_policy_sequence();
trailing_window = Some(PolicyWindowSnapshot {
sequence,
start_entry_ordinal: window.start_entry_ordinal,
end_entry_ordinal: window.end_entry_ordinal,
observed_entries: window.entries,
observed_chunks: window.chunks,
observed_work_ns: window.work_ns,
ready_directories: state.ready_directories,
in_flight_directories: state.in_flight_directories,
active_workers: diagnostics.as_ref().map_or(0, |diagnostics| {
diagnostics.active_workers.load(std::sync::atomic::Ordering::Relaxed)
}),
handoff_backlog: diagnostics.as_ref().map_or(0, |diagnostics| {
diagnostics.handoff_backlog.load(std::sync::atomic::Ordering::Relaxed)
}),
requested_workers: None,
decision,
});
}
} else if diagnostics.is_some() {
let shadow = state.shadow_calibration.as_ref().and_then(|shadow| {
(shadow.entries > 0).then_some((
shadow.window_start,
shadow.entries,
shadow.chunks,
shadow.work_ns,
))
});
if let Some((window_start, entries, chunks, work_ns)) = shadow {
let sequence = state.allocate_policy_sequence();
trailing_window = Some(PolicyWindowSnapshot {
sequence,
start_entry_ordinal: window_start,
end_entry_ordinal: window_start.saturating_add(entries),
observed_entries: entries,
observed_chunks: chunks,
observed_work_ns: work_ns,
ready_directories: state.ready_directories,
in_flight_directories: state.in_flight_directories,
active_workers: diagnostics.as_ref().map_or(0, |diagnostics| {
diagnostics.active_workers.load(std::sync::atomic::Ordering::Relaxed)
}),
handoff_backlog: diagnostics.as_ref().map_or(0, |diagnostics| {
diagnostics.handoff_backlog.load(std::sync::atomic::Ordering::Relaxed)
}),
requested_workers: None,
decision: WorkerPolicyDecision::ObserveIncomplete,
});
}
}
(state.ready_directories, state.in_flight_directories, trailing_window)
};
if let Some(diagnostics) = &diagnostics {
diagnostics.record_queue_finish(queue_finish.0, queue_finish.1);
if let Some(window) = queue_finish.2 {
diagnostics.record_policy_window(window);
}
}
let mut report = ScanReport::default();
for handle in handles {
match handle.join() {
Ok(worker) => report.absorb(worker),
Err(_) => {
report.errors.push(Error::io(
root,
std::io::Error::other("a scan worker thread panicked"),
));
}
}
}
report
});
report.errors.sort_by_cached_key(ToString::to_string);
report
}
trait WalkEmission {
type Directory;
fn begin_directory(&mut self, path: &Path) -> Self::Directory;
#[allow(clippy::too_many_arguments)]
fn record_entry(
&mut self,
root: &Path,
rel_dir: &Path,
depth: usize,
region: RegionId,
name: &OsStr,
kind: EntryKind,
attrs: Attrs,
root_dev: u64,
config: &ScanConfig,
directory: &mut Self::Directory,
discovered: &mut Vec<(PathBuf, usize, RegionId)>,
report: &mut ScanReport,
sender: &std::sync::mpsc::Sender<WalkMessage>,
chunk_send_ns: &mut u64,
diagnostics: Option<&ScanDiagnosticsRecorder>,
) -> bool;
fn finish_directory(&mut self, directory: Self::Directory);
fn publish_before_discovery(
&mut self,
has_discovered: bool,
sender: &std::sync::mpsc::Sender<WalkMessage>,
chunk_send_ns: &mut u64,
diagnostics: Option<&ScanDiagnosticsRecorder>,
) -> bool;
fn finish(
&mut self,
sender: &std::sync::mpsc::Sender<WalkMessage>,
report: &mut ScanReport,
diagnostics: Option<&ScanDiagnosticsRecorder>,
);
fn skip_dir_symlink_stat(&self) -> bool {
false
}
}
struct StreamingEmission {
batch: Vec<ObservationOp>,
batch_size: usize,
recycle_tx: Option<std::sync::mpsc::Sender<Vec<ObservationOp>>>,
recycle_rx: Option<std::sync::mpsc::Receiver<Vec<ObservationOp>>>,
skip_dir_symlink_stat: bool,
group_directories: bool,
directory_start: usize,
listing_settled: bool,
listing_control: ListingControl,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum ListingControl {
Unread,
Listed,
Probed,
}
impl StreamingEmission {
fn for_sink(config: &ScanConfig, mode: SinkMode) -> Self {
let batch_size = config.batch_size;
let (recycle_tx, recycle_rx) = if mode.recycles_batches() {
let (tx, rx) = std::sync::mpsc::channel();
(Some(tx), Some(rx))
} else {
(None, None)
};
Self {
batch: Vec::with_capacity(batch_size),
batch_size,
recycle_tx,
recycle_rx,
skip_dir_symlink_stat: mode.skips_dir_symlink_stat(),
group_directories: mode.groups_directories(config),
directory_start: 0,
listing_settled: false,
listing_control: ListingControl::Unread,
}
}
#[allow(clippy::too_many_arguments)]
fn send_if_full(
&mut self,
root: &Path,
rel_dir: &Path,
config: &ScanConfig,
report: &mut ScanReport,
sender: &std::sync::mpsc::Sender<WalkMessage>,
chunk_send_ns: &mut u64,
diagnostics: Option<&ScanDiagnosticsRecorder>,
) -> bool {
if self.batch.len() < self.batch_size {
return true;
}
if self.group_directories && !self.listing_settled {
self.settle_listing(root, rel_dir, config, report);
}
let send_started = std::time::Instant::now();
let sent = self.send_full(sender, diagnostics);
*chunk_send_ns += elapsed_ns(send_started);
sent
}
fn settle_listing(
&mut self,
root: &Path,
rel_dir: &Path,
config: &ScanConfig,
report: &mut ScanReport,
) {
self.listing_settled = true;
if self.listing_control != ListingControl::Unread {
controls_first(&mut self.batch[self.directory_start..]);
return;
}
self.listing_control = ListingControl::Probed;
let control = rel_dir.join(crate::control::CONTROL_FILE_NAME);
match read_directory_control(config, root, &control) {
Ok(Some(op)) => {
self.batch.insert(self.directory_start, ObservationOp::unconditional(op));
}
Ok(None) => {}
Err(error) => report.errors.push(error),
}
}
fn control_probed(&self) -> bool {
self.group_directories && self.listing_control == ListingControl::Probed
}
fn note_listed_control(&mut self, control: Option<&Op>, control_error: Option<&Error>) {
if control.is_some() || control_error.is_some() {
self.listing_control = ListingControl::Listed;
}
}
fn wrap(&self, ops: Vec<ObservationOp>) -> ScannerBatch {
match &self.recycle_tx {
Some(recycle) => ScannerBatch::new(ops).with_recycle(recycle.clone()),
None => ScannerBatch::new(ops),
}
}
fn next_vec(&self) -> Vec<ObservationOp> {
let Some(recycle_rx) = &self.recycle_rx else {
return Vec::new();
};
let mut kept = None;
while let Ok(mut recycled) = recycle_rx.try_recv() {
recycled.clear();
kept = Some(recycled);
}
kept.unwrap_or_else(|| Vec::with_capacity(self.batch_size))
}
fn send_full(
&mut self,
sender: &std::sync::mpsc::Sender<WalkMessage>,
diagnostics: Option<&ScanDiagnosticsRecorder>,
) -> bool {
let ops = std::mem::take(&mut self.batch);
let sent = send_scanner_batch(sender, self.wrap(ops), diagnostics);
self.batch = self.next_vec();
self.directory_start = 0;
sent
}
}
impl WalkEmission for StreamingEmission {
type Directory = ();
fn begin_directory(&mut self, _path: &Path) {
self.directory_start = self.batch.len();
self.listing_settled = false;
self.listing_control = ListingControl::Unread;
}
#[allow(clippy::too_many_arguments)]
fn record_entry(
&mut self,
root: &Path,
rel_dir: &Path,
depth: usize,
region: RegionId,
name: &OsStr,
kind: EntryKind,
attrs: Attrs,
root_dev: u64,
config: &ScanConfig,
_directory: &mut Self::Directory,
discovered: &mut Vec<(PathBuf, usize, RegionId)>,
report: &mut ScanReport,
sender: &std::sync::mpsc::Sender<WalkMessage>,
chunk_send_ns: &mut u64,
diagnostics: Option<&ScanDiagnosticsRecorder>,
) -> bool {
record_walk_entry(
root,
rel_dir,
depth,
region,
name,
kind,
attrs,
root_dev,
config,
self,
discovered,
report,
sender,
chunk_send_ns,
diagnostics,
)
}
fn finish_directory(&mut self, _directory: Self::Directory) {
if self.group_directories && !self.listing_settled {
controls_first(&mut self.batch[self.directory_start..]);
}
}
fn publish_before_discovery(
&mut self,
has_discovered: bool,
sender: &std::sync::mpsc::Sender<WalkMessage>,
chunk_send_ns: &mut u64,
diagnostics: Option<&ScanDiagnosticsRecorder>,
) -> bool {
if self.batch.is_empty() || !has_discovered {
return true;
}
let send_started = std::time::Instant::now();
let sent = self.send_full(sender, diagnostics);
*chunk_send_ns += elapsed_ns(send_started);
sent
}
fn finish(
&mut self,
sender: &std::sync::mpsc::Sender<WalkMessage>,
report: &mut ScanReport,
diagnostics: Option<&ScanDiagnosticsRecorder>,
) {
if self.batch.is_empty() {
return;
}
let send_started = std::time::Instant::now();
let ops = std::mem::take(&mut self.batch);
let _ = send_scanner_batch(sender, self.wrap(ops), diagnostics);
self.batch = Vec::new();
report.attribution.send_ns += elapsed_ns(send_started);
}
fn skip_dir_symlink_stat(&self) -> bool {
self.skip_dir_symlink_stat
}
}
const DETACHED_SPARE_LISTINGS: usize = DIR_CLAIM;
const DETACHED_SPARE_CHILD_CAPACITY: usize = 64;
struct DetachedEmission {
skip_dir_symlink_stat: bool,
directories: Vec<DetachedDirectory>,
spare: Vec<DetachedDirectory>,
spare_list: Vec<DetachedDirectory>,
recycle_tx: std::sync::mpsc::Sender<Vec<DetachedDirectory>>,
recycle_rx: std::sync::mpsc::Receiver<Vec<DetachedDirectory>>,
}
impl DetachedEmission {
fn new(skip_dir_symlink_stat: bool) -> Self {
let (recycle_tx, recycle_rx) = std::sync::mpsc::channel();
Self {
skip_dir_symlink_stat,
directories: Vec::new(),
spare: Vec::new(),
spare_list: Vec::new(),
recycle_tx,
recycle_rx,
}
}
fn collect_returned(&mut self) {
while let Ok(mut returned) = self.recycle_rx.try_recv() {
for mut directory in returned.drain(..) {
directory.children.clear();
directory.control = None;
if self.spare.len() < DETACHED_SPARE_LISTINGS
&& directory.children.capacity() <= DETACHED_SPARE_CHILD_CAPACITY
{
self.spare.push(directory);
}
}
if self.spare_list.capacity() == 0 {
self.spare_list = returned;
}
}
}
}
impl WalkEmission for DetachedEmission {
type Directory = DetachedDirectory;
fn skip_dir_symlink_stat(&self) -> bool {
self.skip_dir_symlink_stat
}
fn begin_directory(&mut self, path: &Path) -> Self::Directory {
let Some(mut directory) = self.spare.pop() else {
return DetachedDirectory {
path: path.to_path_buf(),
children: Vec::new(),
control: None,
};
};
let buffer = directory.path.as_mut_os_string();
buffer.clear();
buffer.push(path);
debug_assert!(directory.children.is_empty() && directory.control.is_none());
directory
}
#[allow(clippy::too_many_arguments)]
fn record_entry(
&mut self,
root: &Path,
rel_dir: &Path,
depth: usize,
region: RegionId,
name: &OsStr,
kind: EntryKind,
attrs: Attrs,
root_dev: u64,
config: &ScanConfig,
directory: &mut Self::Directory,
discovered: &mut Vec<(PathBuf, usize, RegionId)>,
report: &mut ScanReport,
_sender: &std::sync::mpsc::Sender<WalkMessage>,
_chunk_send_ns: &mut u64,
_diagnostics: Option<&ScanDiagnosticsRecorder>,
) -> bool {
record_detached_entry(
root,
rel_dir,
depth,
region,
name,
kind,
attrs,
root_dev,
config,
&mut directory.children,
&mut directory.control,
discovered,
report,
);
true
}
fn finish_directory(&mut self, directory: Self::Directory) {
self.directories.push(directory);
}
fn publish_before_discovery(
&mut self,
_has_discovered: bool,
sender: &std::sync::mpsc::Sender<WalkMessage>,
chunk_send_ns: &mut u64,
diagnostics: Option<&ScanDiagnosticsRecorder>,
) -> bool {
if self.directories.is_empty() {
return true;
}
let send_started = std::time::Instant::now();
self.collect_returned();
let next = std::mem::take(&mut self.spare_list);
let directories = std::mem::replace(&mut self.directories, next);
let sent =
send_detached_directories(sender, directories, self.recycle_tx.clone(), diagnostics);
*chunk_send_ns += elapsed_ns(send_started);
sent
}
fn finish(
&mut self,
_sender: &std::sync::mpsc::Sender<WalkMessage>,
_report: &mut ScanReport,
_diagnostics: Option<&ScanDiagnosticsRecorder>,
) {
}
}
fn walk_detached_worker(
root: &Path,
config: &ScanConfig,
root_dev: u64,
queue: &DirectoryQueue,
sender: &std::sync::mpsc::Sender<WalkMessage>,
diagnostics: Option<&std::sync::Arc<ScanDiagnosticsRecorder>>,
) -> ScanReport {
walk_detached_with(root, config, root_dev, queue, sender, diagnostics, false)
}
fn walk_detached_folding_worker(
root: &Path,
config: &ScanConfig,
root_dev: u64,
queue: &DirectoryQueue,
sender: &std::sync::mpsc::Sender<WalkMessage>,
diagnostics: Option<&std::sync::Arc<ScanDiagnosticsRecorder>>,
) -> ScanReport {
walk_detached_with(root, config, root_dev, queue, sender, diagnostics, true)
}
fn walk_detached_with(
root: &Path,
config: &ScanConfig,
root_dev: u64,
queue: &DirectoryQueue,
sender: &std::sync::mpsc::Sender<WalkMessage>,
diagnostics: Option<&std::sync::Arc<ScanDiagnosticsRecorder>>,
skip_dir_symlink_stat: bool,
) -> ScanReport {
let report = walk_worker_with(
root,
config,
root_dev,
queue,
sender,
diagnostics,
DetachedEmission::new(skip_dir_symlink_stat),
);
if let Some(progress) = &config.progress {
progress.enter(crate::ProgressPhase::Indexing);
}
report
}
fn walk_worker(
root: &Path,
config: &ScanConfig,
root_dev: u64,
queue: &DirectoryQueue,
sender: &std::sync::mpsc::Sender<WalkMessage>,
diagnostics: Option<&std::sync::Arc<ScanDiagnosticsRecorder>>,
) -> ScanReport {
walk_worker_with(
root,
config,
root_dev,
queue,
sender,
diagnostics,
StreamingEmission::for_sink(config, SinkMode::Retained),
)
}
fn walk_worker_transient_fold(
root: &Path,
config: &ScanConfig,
root_dev: u64,
queue: &DirectoryQueue,
sender: &std::sync::mpsc::Sender<WalkMessage>,
diagnostics: Option<&std::sync::Arc<ScanDiagnosticsRecorder>>,
) -> ScanReport {
walk_worker_with(
root,
config,
root_dev,
queue,
sender,
diagnostics,
StreamingEmission::for_sink(config, SinkMode::TransientFold),
)
}
fn walk_worker_with<E: WalkEmission>(
root: &Path,
config: &ScanConfig,
root_dev: u64,
queue: &DirectoryQueue,
sender: &std::sync::mpsc::Sender<WalkMessage>,
diagnostics: Option<&std::sync::Arc<ScanDiagnosticsRecorder>>,
mut emission: E,
) -> ScanReport {
let _counter_guard = crate::counters::thread_flush_guard();
let _worker_guard = diagnostics.map(ScanDiagnosticsRecorder::worker_guard);
let worker_started = std::time::Instant::now();
let mut report = ScanReport::default();
let mut tally = ProgressTally::new(config.progress.as_ref());
let mut claimed: Vec<(PathBuf, usize, RegionId)> = Vec::with_capacity(DIR_CLAIM);
let mut discovered: Vec<(PathBuf, usize, RegionId)> = Vec::new();
let mut consumer_gone = false;
#[cfg(target_os = "macos")]
let mut bulk_reader = macos_bulk::Reader::new();
#[cfg(all(target_os = "linux", target_env = "gnu"))]
let mut dents_reader = linux_dents::Reader::new();
'walk: while let Some(claim) = queue.claim(&mut claimed, &mut report.attribution) {
let chunk_started = std::time::Instant::now();
let mut chunk_send_ns: u64 = 0;
let entries_before = report.entries;
for (rel_dir, depth, region) in claimed.drain(..) {
let abs_dir = root.join(&rel_dir);
let mut directory = emission.begin_directory(&rel_dir);
#[cfg(target_os = "macos")]
{
if let Some(diagnostics) = diagnostics {
diagnostics.macos_bulk_attempted();
}
if let Some(entries) =
(!walk_hook_covers(&abs_dir)).then(|| bulk_reader.read(&abs_dir)).flatten()
{
if let Some(diagnostics) = diagnostics {
diagnostics.macos_bulk_succeeded();
}
report.dirs_read += 1;
for entry in entries {
if !emission.record_entry(
root,
&rel_dir,
depth,
region,
&entry.name,
entry.kind,
entry.attrs,
root_dev,
config,
&mut directory,
&mut discovered,
&mut report,
sender,
&mut chunk_send_ns,
diagnostics.map(AsRef::as_ref),
) {
consumer_gone = true;
break 'walk;
}
}
emission.finish_directory(directory);
continue;
}
if let Some(diagnostics) = diagnostics {
diagnostics.macos_bulk_fell_back();
}
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
{
if let Some(diagnostics) = diagnostics {
diagnostics.linux_dents_attempted();
}
let policy = linux_dents::StatPolicy {
skip_dir_symlink_stat: emission.skip_dir_symlink_stat(),
one_filesystem: config.one_filesystem,
};
let listing = if walk_hook_covers(&abs_dir) {
None
} else {
dents_reader.read(&abs_dir, policy)
};
if let Some(listing) = listing {
if let Some(diagnostics) = diagnostics {
diagnostics.linux_dents_succeeded();
}
report.dirs_read += 1;
for entry in listing {
let (kind, attrs) = match entry.outcome {
linux_dents::Outcome::Observed { kind, attrs } => (kind, attrs),
linux_dents::Outcome::Failed(error) => {
report.errors.push(Error::io(abs_dir.join(entry.name), error));
continue;
}
};
if !emission.record_entry(
root,
&rel_dir,
depth,
region,
entry.name,
kind,
attrs,
root_dev,
config,
&mut directory,
&mut discovered,
&mut report,
sender,
&mut chunk_send_ns,
diagnostics.map(AsRef::as_ref),
) {
consumer_gone = true;
break 'walk;
}
}
emission.finish_directory(directory);
continue;
}
if let Some(diagnostics) = diagnostics {
diagnostics.linux_dents_fell_back();
}
}
crate::counters::bump(|c| c.dir_opens += 1);
if let Some(diagnostics) = diagnostics {
diagnostics.portable_attempted();
}
let listing = match fs::read_dir(&abs_dir) {
Ok(listing) => {
if let Some(diagnostics) = diagnostics {
diagnostics.portable_succeeded();
}
listing
}
Err(e) => {
report.errors.push(Error::io(abs_dir, e));
continue;
}
};
report.dirs_read += 1;
let policy = ListingPolicy {
skip_dir_symlink_stat: emission.skip_dir_symlink_stat(),
one_filesystem: config.one_filesystem,
};
let mut searchability = Searchability::Unproven;
for item in listing {
let item = match item {
Ok(item) => item,
Err(e) => {
report.errors.push(Error::io(&abs_dir, e));
continue;
}
};
crate::counters::bump(|c| c.dir_entries += 1);
let name = item.file_name();
let (kind, attrs) =
match listed_child_kind_and_attrs(&item, policy, &mut searchability) {
Ok(Some(observed)) => observed,
Ok(None) => continue,
Err(error) => {
report.errors.push(Error::io(item.path(), error));
continue;
}
};
if !emission.record_entry(
root,
&rel_dir,
depth,
region,
&name,
kind,
attrs,
root_dev,
config,
&mut directory,
&mut discovered,
&mut report,
sender,
&mut chunk_send_ns,
diagnostics.map(AsRef::as_ref),
) {
consumer_gone = true;
break 'walk;
}
}
emission.finish_directory(directory);
}
if !emission.publish_before_discovery(
!discovered.is_empty(),
sender,
&mut chunk_send_ns,
diagnostics.map(AsRef::as_ref),
) {
report.attribution.send_ns += chunk_send_ns;
report.attribution.work_ns += elapsed_ns(chunk_started).saturating_sub(chunk_send_ns);
consumer_gone = true;
break 'walk;
}
report.attribution.send_ns += chunk_send_ns;
let chunk_work_ns = elapsed_ns(chunk_started).saturating_sub(chunk_send_ns);
report.attribution.work_ns += chunk_work_ns;
tally.flush(&report);
if !discovered.is_empty() {
queue.extend(discovered.drain(..), &mut report.attribution);
}
if let Some(target_workers) = claim.release(
report.entries.saturating_sub(entries_before),
chunk_work_ns,
&mut report.attribution,
) {
let _ = sender.send(WalkMessage::ScaleUp { sender: sender.clone(), target_workers });
}
}
if !consumer_gone {
emission.finish(sender, &mut report, diagnostics.map(AsRef::as_ref));
}
tally.flush(&report);
report.attribution.wall_ns = elapsed_ns(worker_started);
report
}
#[allow(clippy::too_many_arguments)]
fn record_detached_entry(
root: &Path,
rel_dir: &Path,
depth: usize,
region: RegionId,
name: &OsStr,
kind: EntryKind,
attrs: Attrs,
root_dev: u64,
config: &ScanConfig,
children: &mut Vec<DetachedChild>,
control: &mut Option<Op>,
discovered: &mut Vec<(PathBuf, usize, RegionId)>,
report: &mut ScanReport,
) {
let disposition = crate::admission::decide(name, kind, config.hidden(), config.exclude_special);
if disposition == crate::admission::Disposition::Reject {
return;
}
if config.read_controls && crate::control::control_spelling(name).is_some() {
let path = rel_dir.join(name);
match read_control_op(config, root, &path, kind) {
Ok(observed) => *control = observed,
Err(error) => report.errors.push(error),
}
}
if disposition != crate::admission::Disposition::Retain {
return;
}
report.observe(kind, attrs);
let position = u32::try_from(children.len()).unwrap_or(u32::MAX);
children.push(DetachedChild { name: name.to_os_string(), kind, attrs, position });
if should_descend(kind, attrs, depth, root_dev, config) {
let child_region = if depth == 0 { RegionId::UNASSIGNED } else { region };
discovered.push((rel_dir.join(name), depth + 1, child_region));
}
}
pub(crate) struct PreparedWalkEntry {
pub(crate) path: PathBuf,
pub(crate) kind: EntryKind,
pub(crate) attrs: Attrs,
pub(crate) retained: bool,
pub(crate) control: Option<Op>,
pub(crate) descend: bool,
pub(crate) control_error: Option<Error>,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn prepare_walk_entry(
root: &Path,
rel_dir: &Path,
depth: usize,
name: &OsStr,
kind: EntryKind,
attrs: Attrs,
root_dev: u64,
config: &ScanConfig,
) -> Option<PreparedWalkEntry> {
prepare_walk_entry_reading(root, rel_dir, depth, name, kind, attrs, root_dev, config, true)
}
#[allow(clippy::too_many_arguments)]
fn prepare_walk_entry_reading(
root: &Path,
rel_dir: &Path,
depth: usize,
name: &OsStr,
kind: EntryKind,
attrs: Attrs,
root_dev: u64,
config: &ScanConfig,
read_control: bool,
) -> Option<PreparedWalkEntry> {
let disposition = crate::admission::decide(name, kind, config.hidden(), config.exclude_special);
if disposition == crate::admission::Disposition::Reject {
return None;
}
let path = join_listed_name(rel_dir, name);
let (control, control_error) = if read_control {
match read_named_control_op(config, root, &path, name, kind) {
Ok(control) => (control, None),
Err(error) => (None, Some(error)),
}
} else {
(None, None)
};
Some(PreparedWalkEntry {
path,
kind,
attrs,
retained: disposition == crate::admission::Disposition::Retain,
control,
descend: should_descend(kind, attrs, depth, root_dev, config),
control_error,
})
}
fn join_listed_name(rel_dir: &Path, name: &OsStr) -> PathBuf {
let mut path = PathBuf::with_capacity(rel_dir.as_os_str().len() + 1 + name.len());
path.as_mut_os_string().push(rel_dir);
path.push(name);
path
}
#[allow(clippy::too_many_arguments)]
fn record_walk_entry(
root: &Path,
rel_dir: &Path,
depth: usize,
region: RegionId,
name: &OsStr,
kind: EntryKind,
attrs: Attrs,
root_dev: u64,
config: &ScanConfig,
emission: &mut StreamingEmission,
discovered: &mut Vec<(PathBuf, usize, RegionId)>,
report: &mut ScanReport,
sender: &std::sync::mpsc::Sender<WalkMessage>,
chunk_send_ns: &mut u64,
diagnostics: Option<&ScanDiagnosticsRecorder>,
) -> bool {
let read_control = !emission.control_probed();
let Some(prepared) = prepare_walk_entry_reading(
root,
rel_dir,
depth,
name,
kind,
attrs,
root_dev,
config,
read_control,
) else {
return true;
};
emission.note_listed_control(prepared.control.as_ref(), prepared.control_error.as_ref());
let (mut control, control_error) = (prepared.control, prepared.control_error);
if let Some(error) = control_error {
report.errors.push(error);
}
if !prepared.retained || emission.group_directories {
if let Some(control) = control.take() {
emission.batch.push(ObservationOp::unconditional(control));
if !emission.send_if_full(
root,
rel_dir,
config,
report,
sender,
chunk_send_ns,
diagnostics,
) {
return false;
}
}
if !prepared.retained {
return true;
}
}
report.observe(kind, attrs);
let (path, descend_path) = if prepared.descend {
(prepared.path.clone(), Some(prepared.path))
} else {
(prepared.path, None)
};
emission.batch.push(ObservationOp::unconditional(Op::Upsert { path, kind, attrs }));
if !emission.send_if_full(root, rel_dir, config, report, sender, chunk_send_ns, diagnostics) {
return false;
}
if let Some(control) = control {
emission.batch.push(ObservationOp::unconditional(control));
if !emission.send_if_full(root, rel_dir, config, report, sender, chunk_send_ns, diagnostics)
{
return false;
}
}
if let Some(path) = descend_path {
let child_region = if depth == 0 { RegionId::UNASSIGNED } else { region };
discovered.push((path, depth + 1, child_region));
}
true
}
fn controls_first(listing: &mut [ObservationOp]) {
let mut placed = 0;
for position in 0..listing.len() {
if matches!(listing[position].op, Op::ControlUpsert { .. } | Op::ControlRemove { .. }) {
listing[placed..=position].rotate_right(1);
placed += 1;
}
}
}
pub(crate) fn read_control_op(
config: &ScanConfig,
root: &Path,
path: &Path,
kind: EntryKind,
) -> Result<Option<Op>> {
read_spelled_control_op(config, root, path, kind, crate::control::path_control_spelling)
}
fn read_named_control_op(
config: &ScanConfig,
root: &Path,
path: &Path,
name: &OsStr,
kind: EntryKind,
) -> Result<Option<Op>> {
debug_assert_eq!(path.file_name(), Some(name), "a listed name ends its path");
read_spelled_control_op(config, root, path, kind, |_| crate::control::control_spelling(name))
}
fn read_spelled_control_op(
config: &ScanConfig,
root: &Path,
path: &Path,
kind: EntryKind,
spelling: impl FnOnce(&Path) -> Option<crate::control::ControlSpelling>,
) -> Result<Option<Op>> {
if !config.read_controls {
return Ok(None);
}
match spelling(path) {
Some(crate::control::ControlSpelling::Exact) => {
read_control_op_unconditional(root, path, kind, config.control_limits.budget)
}
Some(crate::control::ControlSpelling::Variant) => {
read_directory_control(config, root, &crate::control::sibling_control_path(path))
}
None => Ok(None),
}
}
fn read_directory_control(
config: &ScanConfig,
root: &Path,
control_path: &Path,
) -> Result<Option<Op>> {
if !config.read_controls {
return Ok(None);
}
let absolute = control_lookup_path(root, control_path);
let found = look_up_control(&absolute, control_path, config.control_limits.budget);
#[cfg(test)]
{
if let Some(error) =
walk_hook(&absolute).and_then(|hook| hook(WalkHookPoint::ControlLookup(&absolute)))
{
return Err(Error::io(&absolute, error));
}
}
found
}
fn look_up_control(
absolute: &Path,
control_path: &Path,
budget: Option<usize>,
) -> Result<Option<Op>> {
let kind = match observe_path(absolute) {
Ok((EntryKind::File, _)) => EntryKind::File,
Ok(_) => EntryKind::Other,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(Error::io(absolute, error)),
};
read_control_source(absolute, control_path, kind, budget)
}
pub(crate) fn read_directory_control_or_removal(
config: &ScanConfig,
root: &Path,
control_path: &Path,
) -> Result<Option<Op>> {
if !config.read_controls {
return Ok(None);
}
Ok(Some(
read_directory_control(config, root, control_path)?
.unwrap_or_else(|| Op::ControlRemove { path: control_path.to_path_buf() }),
))
}
#[cfg(not(test))]
fn control_lookup_path(root: &Path, control_path: &Path) -> PathBuf {
root.join(control_path)
}
#[cfg(test)]
fn control_lookup_path(root: &Path, control_path: &Path) -> PathBuf {
let absolute = root.join(control_path);
let folds = CASE_FOLDING_LOOKUPS
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.iter()
.any(|folded| absolute.starts_with(folded));
let Some(directory) = absolute.parent().filter(|_| folds) else {
return absolute;
};
if fs::symlink_metadata(&absolute).is_ok() {
return absolute;
}
let Ok(mut names) = fs::read_dir(directory) else {
return absolute;
};
names
.find_map(|item| {
let name = item.ok()?.file_name();
crate::control::control_spelling(&name).map(|_| directory.join(name))
})
.unwrap_or(absolute)
}
#[cfg(test)]
static CASE_FOLDING_LOOKUPS: std::sync::RwLock<Vec<PathBuf>> = std::sync::RwLock::new(Vec::new());
#[cfg(test)]
#[must_use = "the lookups fold only until the guard is dropped"]
pub(crate) struct CaseFoldingGuard(Vec<PathBuf>);
#[cfg(test)]
impl Drop for CaseFoldingGuard {
fn drop(&mut self) {
CASE_FOLDING_LOOKUPS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.retain(|root| !self.0.contains(root));
}
}
#[cfg(test)]
pub(crate) fn install_case_folding_control_lookup(root: &Path) -> CaseFoldingGuard {
let mut roots = vec![root.to_path_buf()];
if let Ok(canonical) = root.canonicalize() {
if canonical != root {
roots.push(canonical);
}
}
CASE_FOLDING_LOOKUPS
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.extend(roots.iter().cloned());
CaseFoldingGuard(roots)
}
fn read_listed_control_op(
config: &ScanConfig,
root: &Path,
path: &Path,
kind: EntryKind,
) -> Result<Option<Op>> {
if config.population != crate::query::IgnoredEntries::Include
&& crate::control::path_control_spelling(path).is_some()
{
Ok(None)
} else {
read_control_op(config, root, path, kind)
}
}
fn population_prunes(
population: crate::query::IgnoredEntries,
path: &Path,
kind: EntryKind,
disposition: crate::admission::Disposition,
controls: Option<&crate::control::ControlTable>,
unreadable_controls: &std::collections::BTreeSet<PathBuf>,
) -> bool {
if crate::control::path_control_spelling(path).is_some() {
return false;
}
let Some(table) = controls else { return false };
if !table.classification_known(path)
|| path.ancestors().skip(1).any(|ancestor| unreadable_controls.contains(ancestor))
{
return false;
}
let ignored = table.is_ignored(path, kind.is_dir());
match population {
crate::query::IgnoredEntries::Include => false,
crate::query::IgnoredEntries::Exclude => ignored,
crate::query::IgnoredEntries::Only => {
!ignored && !kind.is_dir() && disposition != crate::admission::Disposition::ControlOnly
}
}
}
fn apply_discovery_control(table: &mut crate::control::ControlTable, op: &Op) -> Result<()> {
match op {
Op::ControlUpsert { path, source } => {
table.upsert(path, source.clone())?;
}
Op::ControlRemove { path } => {
table.remove(path)?;
}
_ => unreachable!("directory control probe emits only control operations"),
}
Ok(())
}
fn read_control_op_unconditional(
root: &Path,
path: &Path,
kind: EntryKind,
budget: Option<usize>,
) -> Result<Option<Op>> {
if !crate::control::is_control_file(path) {
return Ok(None);
}
read_control_source(&root.join(path), path, kind, budget)
}
const CONTROL_READ_RESERVE_BYTES: usize = 64 * 1024;
fn read_control_source(
absolute: &Path,
path: &Path,
kind: EntryKind,
budget: Option<usize>,
) -> Result<Option<Op>> {
if kind != EntryKind::File {
return Ok(Some(Op::ControlRemove { path: path.to_path_buf() }));
}
let file = open_control_file(absolute).map_err(|error| Error::io(absolute, error))?;
let metadata = file.metadata().map_err(|error| Error::io(absolute, error))?;
if !metadata.file_type().is_file() {
return Ok(Some(Op::ControlRemove { path: path.to_path_buf() }));
}
let read_limit = budget
.map_or(u64::MAX, |budget| u64::try_from(budget).unwrap_or(u64::MAX).saturating_add(1));
let reserve = usize::try_from(metadata.len().min(read_limit))
.unwrap_or(usize::MAX)
.min(CONTROL_READ_RESERVE_BYTES)
.saturating_add(1);
let mut source = Vec::with_capacity(reserve);
file.take(read_limit).read_to_end(&mut source).map_err(|error| Error::io(absolute, error))?;
crate::counters::bump(|counts| counts.control_reads = counts.control_reads.saturating_add(1));
Ok(Some(Op::ControlUpsert { path: path.to_path_buf(), source }))
}
#[cfg(unix)]
fn open_control_file(path: &Path) -> std::io::Result<fs::File> {
use std::os::unix::fs::OpenOptionsExt as _;
fs::OpenOptions::new().read(true).custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW).open(path)
}
#[cfg(not(unix))]
fn open_control_file(path: &Path) -> std::io::Result<fs::File> {
fs::File::open(path)
}
fn send_scanner_batch(
sender: &std::sync::mpsc::Sender<WalkMessage>,
batch: ScannerBatch,
diagnostics: Option<&ScanDiagnosticsRecorder>,
) -> bool {
if let Some(diagnostics) = diagnostics {
diagnostics.handoff_sent();
}
let sent = sender.send(WalkMessage::Batch(batch)).is_ok();
if !sent {
if let Some(diagnostics) = diagnostics {
diagnostics.handoff_received();
}
}
sent
}
fn send_detached_directories(
sender: &std::sync::mpsc::Sender<WalkMessage>,
directories: Vec<DetachedDirectory>,
recycle: std::sync::mpsc::Sender<Vec<DetachedDirectory>>,
diagnostics: Option<&ScanDiagnosticsRecorder>,
) -> bool {
if let Some(diagnostics) = diagnostics {
diagnostics.handoff_sent();
}
let sent = sender.send(WalkMessage::DetachedDirectories { directories, recycle }).is_ok();
if !sent {
if let Some(diagnostics) = diagnostics {
diagnostics.handoff_received();
}
}
sent
}
fn elapsed_ns(started: std::time::Instant) -> u64 {
u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct RegionId(usize);
impl RegionId {
const ROOT: Self = Self(0);
const UNASSIGNED: Self = Self(usize::MAX);
}
struct DirectoryQueue {
state: std::sync::Mutex<DirectoryQueueState>,
ready: std::sync::Condvar,
order: ScanOrder,
diagnostics: Option<std::sync::Arc<ScanDiagnosticsRecorder>>,
}
struct DirectoryClaim<'a> {
queue: &'a DirectoryQueue,
directories: usize,
released: bool,
}
impl DirectoryClaim<'_> {
fn release(
mut self,
entries: u64,
work_ns: u64,
timing: &mut WalkAttribution,
) -> Option<usize> {
self.released = true;
self.queue.release(self.directories, entries, work_ns, timing)
}
}
impl Drop for DirectoryClaim<'_> {
fn drop(&mut self) {
if self.released {
return;
}
self.queue.abandon(self.directories);
}
}
struct DirectoryQueueState {
pending: VecDeque<(PathBuf, usize, RegionId)>,
regions: Vec<Vec<(PathBuf, usize, RegionId)>>,
ready_ring: VecDeque<RegionId>,
enqueued: Vec<bool>,
ready_directories: usize,
in_flight_directories: usize,
outstanding: usize,
finished: bool,
controller: Option<WorkerController>,
shadow_calibration: Option<RepeatedCalibration>,
next_policy_sequence: u64,
worker_target: usize,
maximum_workers: usize,
}
impl DirectoryQueueState {
fn seeded(
root: (PathBuf, usize),
order: ScanOrder,
calibration: Option<WorkerCalibration>,
initial_workers: usize,
maximum_workers: usize,
policy: WorkerPolicyExperiment,
) -> Self {
let mut state = Self {
pending: VecDeque::new(),
regions: Vec::new(),
ready_ring: VecDeque::new(),
enqueued: Vec::new(),
ready_directories: 0,
in_flight_directories: 0,
outstanding: 0,
finished: false,
controller: calibration.map(|value| WorkerController::new(value, policy)),
shadow_calibration: None,
next_policy_sequence: 0,
worker_target: initial_workers,
maximum_workers,
};
state.push((root.0, root.1, RegionId::ROOT), order);
state
}
fn push(&mut self, item: (PathBuf, usize, RegionId), order: ScanOrder) {
self.ready_directories = self.ready_directories.saturating_add(1);
match order {
ScanOrder::DepthFirst => self.pending.push_back(item),
ScanOrder::BreadthFirst => {
let region = if item.2 == RegionId::UNASSIGNED {
self.regions.len().max(1)
} else {
item.2.0
};
if region >= self.regions.len() {
self.regions.resize_with(region + 1, Vec::new);
self.enqueued.resize(region + 1, false);
}
let mut item = item;
item.2 = RegionId(region);
self.regions[region].push(item);
if !self.enqueued[region] {
self.enqueued[region] = true;
self.ready_ring.push_back(RegionId(region));
}
}
}
}
fn is_empty(&self, order: ScanOrder) -> bool {
match order {
ScanOrder::DepthFirst => self.pending.is_empty(),
ScanOrder::BreadthFirst => self.ready_ring.is_empty(),
}
}
fn take(
&mut self,
limit: usize,
order: ScanOrder,
into: &mut Vec<(PathBuf, usize, RegionId)>,
) -> usize {
let before = into.len();
match order {
ScanOrder::DepthFirst => {
let take = self.pending.len().min(limit);
let start = self.pending.len() - take;
into.extend(self.pending.drain(start..));
}
ScanOrder::BreadthFirst => {
let Some(region) = self.ready_ring.pop_front() else { return 0 };
self.enqueued[region.0] = false;
let bucket = &mut self.regions[region.0];
let take = bucket.len().min(limit);
let start = bucket.len() - take;
into.extend(bucket.drain(start..));
if !bucket.is_empty() && !self.enqueued[region.0] {
self.enqueued[region.0] = true;
self.ready_ring.push_back(region);
}
}
}
into.len().saturating_sub(before)
}
fn allocate_policy_sequence(&mut self) -> u64 {
let sequence = self.next_policy_sequence;
self.next_policy_sequence = self.next_policy_sequence.saturating_add(1);
sequence
}
}
impl DirectoryQueue {
#[cfg(test)]
fn new(
root: (PathBuf, usize),
order: ScanOrder,
calibration: Option<WorkerCalibration>,
diagnostics: Option<std::sync::Arc<ScanDiagnosticsRecorder>>,
) -> Self {
Self::new_with_policy(
root,
order,
calibration,
diagnostics,
1,
2,
WorkerPolicyExperiment::ShippedOneShot,
)
}
#[allow(clippy::too_many_arguments)]
fn new_with_policy(
root: (PathBuf, usize),
order: ScanOrder,
calibration: Option<WorkerCalibration>,
diagnostics: Option<std::sync::Arc<ScanDiagnosticsRecorder>>,
initial_workers: usize,
maximum_workers: usize,
policy: WorkerPolicyExperiment,
) -> Self {
let state = DirectoryQueueState::seeded(
root,
order,
calibration,
initial_workers,
maximum_workers,
policy,
);
Self {
state: std::sync::Mutex::new(state),
ready: std::sync::Condvar::new(),
order,
diagnostics,
}
}
fn claim<'a>(
&'a self,
into: &mut Vec<(PathBuf, usize, RegionId)>,
timing: &mut WalkAttribution,
) -> Option<DirectoryClaim<'a>> {
let mut state = self.lock_timed(timing);
loop {
if !state.is_empty(self.order) {
let directories = state.take(DIR_CLAIM, self.order, into);
state.ready_directories = state.ready_directories.saturating_sub(directories);
state.in_flight_directories =
state.in_flight_directories.saturating_add(directories);
state.outstanding += 1;
timing.claims += 1;
return Some(DirectoryClaim { queue: self, directories, released: false });
}
if state.finished {
return None;
}
if state.outstanding == 0 {
state.finished = true;
self.ready.notify_all();
return None;
}
let started = std::time::Instant::now();
state = self.ready.wait(state).unwrap_or_else(std::sync::PoisonError::into_inner);
timing.starved_ns += elapsed_ns(started);
}
}
fn extend(
&self,
directories: impl Iterator<Item = (PathBuf, usize, RegionId)>,
timing: &mut WalkAttribution,
) {
let mut state = self.lock_timed(timing);
for item in directories {
state.push(item, self.order);
}
drop(state);
self.ready.notify_all();
}
fn abandon(&self, directories: usize) {
let mut state = self.lock();
state.outstanding -= 1;
state.in_flight_directories = state.in_flight_directories.saturating_sub(directories);
if state.outstanding == 0 && state.is_empty(self.order) {
state.finished = true;
drop(state);
self.ready.notify_all();
}
}
fn release(
&self,
directories: usize,
observed_entries: u64,
observed_work_ns: u64,
timing: &mut WalkAttribution,
) -> Option<usize> {
let mut state = self.lock_timed(timing);
let calibrating = state.controller.is_some();
let one_shot = state.controller.as_ref().is_some_and(WorkerController::is_one_shot);
let controller_spec = state.controller.as_ref().map(WorkerController::calibration_spec);
let staged_gated = state.controller.as_ref().is_some_and(WorkerController::is_staged_gated);
let completed_window = state
.controller
.as_mut()
.and_then(|value| value.observe(observed_entries, observed_work_ns));
let observed_window = state
.shadow_calibration
.as_mut()
.and_then(|value| value.observe(observed_entries, observed_work_ns));
let window_completed = completed_window.is_some();
state.outstanding -= 1;
state.in_flight_directories = state.in_flight_directories.saturating_sub(directories);
let finished = state.outstanding == 0 && state.is_empty(self.order);
if finished {
state.finished = true;
}
let handoff_backlog = self.diagnostics.as_ref().map_or(0, |diagnostics| {
diagnostics.handoff_backlog.load(std::sync::atomic::Ordering::Relaxed)
});
let mut requested_workers = None;
let policy_window = completed_window.map(|window| {
let useful_frontier =
state.ready_directories.saturating_add(state.in_flight_directories);
let decision = if !window.slow {
WorkerPolicyDecision::Hold
} else if finished {
WorkerPolicyDecision::HoldNoUsefulWork
} else if staged_gated && useful_frontier <= state.worker_target {
WorkerPolicyDecision::HoldInsufficientFrontier
} else if staged_gated && handoff_backlog >= state.worker_target {
WorkerPolicyDecision::HoldHandoffBacklog
} else {
let target = if staged_gated {
state.worker_target.saturating_mul(2).min(state.maximum_workers)
} else {
state.maximum_workers
};
if target > state.worker_target {
state.worker_target = target;
requested_workers = Some(target);
WorkerPolicyDecision::ScaleUp
} else {
WorkerPolicyDecision::HoldInsufficientFrontier
}
};
let sequence = state.allocate_policy_sequence();
PolicyWindowSnapshot {
sequence,
start_entry_ordinal: window.start_entry_ordinal,
end_entry_ordinal: window.end_entry_ordinal,
observed_entries: window.entries,
observed_chunks: window.chunks,
observed_work_ns: window.work_ns,
ready_directories: state.ready_directories,
in_flight_directories: state.in_flight_directories,
active_workers: self.diagnostics.as_ref().map_or(0, |diagnostics| {
diagnostics.active_workers.load(std::sync::atomic::Ordering::Relaxed)
}),
handoff_backlog,
requested_workers,
decision,
}
});
let shadow_window = observed_window.map(|window| {
let sequence = state.allocate_policy_sequence();
PolicyWindowSnapshot {
sequence,
start_entry_ordinal: window.start_entry_ordinal,
end_entry_ordinal: window.end_entry_ordinal,
observed_entries: window.entries,
observed_chunks: window.chunks,
observed_work_ns: window.work_ns,
ready_directories: state.ready_directories,
in_flight_directories: state.in_flight_directories,
active_workers: self.diagnostics.as_ref().map_or(0, |diagnostics| {
diagnostics.active_workers.load(std::sync::atomic::Ordering::Relaxed)
}),
handoff_backlog,
requested_workers: None,
decision: if window.slow {
WorkerPolicyDecision::ObserveSlow
} else {
WorkerPolicyDecision::ObserveFast
},
}
});
let controller_terminates = (one_shot || finished) && window_completed
|| requested_workers.is_some_and(|target| target == state.maximum_workers);
if controller_terminates {
if let (Some(spec), Some(window)) = (controller_spec, completed_window) {
if self.diagnostics.is_some() && !finished {
state.shadow_calibration =
Some(RepeatedCalibration::starting_at(spec, window.end_entry_ordinal));
}
}
state.controller = None;
}
drop(state);
if let (Some(diagnostics), Some(window)) = (&self.diagnostics, policy_window) {
diagnostics.record_policy_window(window);
}
if let (Some(diagnostics), Some(window)) = (&self.diagnostics, shadow_window) {
diagnostics.record_policy_window(window);
}
if calibrating {
record_adaptive_calibration_chunk(
self.diagnostics.as_ref(),
observed_entries,
observed_work_ns,
);
}
if finished {
self.ready.notify_all();
}
requested_workers
}
fn lock_timed(
&self,
timing: &mut WalkAttribution,
) -> std::sync::MutexGuard<'_, DirectoryQueueState> {
timing.lock_ops += 1;
match self.state.try_lock() {
Ok(guard) => guard,
Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
Err(std::sync::TryLockError::WouldBlock) => {
timing.lock_contended += 1;
let started = std::time::Instant::now();
let guard = self.lock();
timing.lock_wait_ns += elapsed_ns(started);
guard
}
}
}
fn lock(&self) -> std::sync::MutexGuard<'_, DirectoryQueueState> {
self.state.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
fn record_adaptive_calibration_chunk(
diagnostics: Option<&std::sync::Arc<ScanDiagnosticsRecorder>>,
entries: u64,
work_ns: u64,
) {
if let Some(diagnostics) = diagnostics {
diagnostics.calibration_chunk(entries, work_ns);
}
crate::counters::bump(|counts| {
counts.adaptive_calibration_chunks = counts.adaptive_calibration_chunks.saturating_add(1);
counts.adaptive_calibration_entries =
counts.adaptive_calibration_entries.saturating_add(entries);
counts.adaptive_calibration_work_us =
counts.adaptive_calibration_work_us.saturating_add(work_ns / 1_000);
});
}
fn record_adaptive_worker_expansion(diagnostics: Option<&std::sync::Arc<ScanDiagnosticsRecorder>>) {
if let Some(diagnostics) = diagnostics {
diagnostics.worker_expanded();
}
crate::counters::bump(|counts| {
counts.adaptive_scale_ups = counts.adaptive_scale_ups.saturating_add(1);
});
}
fn detached_builder(
root: &Path,
config: &ScanConfig,
retention: Option<TreeRetention>,
) -> DetachedIndexBuilder {
let builder = DetachedIndexBuilder::new(root, config.scope(), config.types_shared())
.with_control_limits(config.control_limits);
match retention {
Some(retention) => builder.folding(retention),
None => builder,
}
}
fn scan_detached_directories(
root: &Path,
config: &ScanConfig,
collect_diagnostics: bool,
policy: WorkerPolicyExperiment,
retention: Option<TreeRetention>,
) -> Result<(ScanReport, DetachedIndexBuilder, Option<ScanDiagnostics>)> {
if let Some(progress) = &config.progress {
progress.enter(crate::ProgressPhase::Scanning);
}
let root_metadata = {
crate::counters::bump(|counts| counts.stats += 1);
fs::symlink_metadata(root)
}
.map_err(|error| Error::io(root, error))?;
if !root_metadata.is_dir() {
return Err(Error::io(
root,
std::io::Error::new(std::io::ErrorKind::NotADirectory, "scan root is not a directory"),
));
}
let root_dev = root_device(root, &root_metadata).map_err(|error| Error::io(root, error))?;
let available_parallelism =
std::thread::available_parallelism().map_or(1, std::num::NonZero::get);
let pool = config.worker_pool_for(available_parallelism);
let diagnostics = collect_diagnostics
.then(|| ScanDiagnosticsRecorder::new(pool, available_parallelism, policy));
if config.max_depth == Some(0) {
if let Some(diagnostics) = &diagnostics {
diagnostics.mark_not_run();
diagnostics.record_queue_finish(0, 0);
}
return Ok((
ScanReport::default(),
detached_builder(root, config, retention),
diagnostics.as_ref().map(|value| value.finish()),
));
}
let walk_started = crate::counters::enabled().then(std::time::Instant::now);
let (output, builder) = scan_concurrent_detached(
root,
config,
root_dev,
pool,
diagnostics.as_ref(),
policy,
retention,
)?;
if let Some(progress) = &config.progress {
progress.enter(crate::ProgressPhase::Indexing);
}
if let Some(started) = walk_started {
let elapsed = elapsed_ns(started) / 1_000;
crate::counters::bump(|counts| {
counts.detached_walk_us = counts.detached_walk_us.saturating_add(elapsed);
});
}
Ok((output, builder, diagnostics.as_ref().map(|value| value.finish())))
}
fn consolidate_detached_index(
mut output: ScanReport,
builder: DetachedIndexBuilder,
) -> (Index, ScanReport) {
let entries = output.entries;
let consolidate_started = crate::counters::enabled().then(std::time::Instant::now);
let mut index = builder.finish();
if let Some(started) = consolidate_started {
let elapsed = elapsed_ns(started) / 1_000;
crate::counters::bump(|counts| {
counts.detached_builds = counts.detached_builds.saturating_add(1);
counts.detached_entries = counts.detached_entries.saturating_add(entries);
counts.detached_finish_us = counts.detached_finish_us.saturating_add(elapsed);
});
}
index.record_walk_errors(&mut output.errors);
index.set_initial_detached_scan_freshness(&output.errors);
(index, output)
}
pub fn scan_into_index(root: &Path, config: &ScanConfig) -> Result<(Index, ScanReport)> {
config.validate()?;
let root = root.canonicalize().map_err(|error| Error::io(root, error))?;
if config.population != crate::query::IgnoredEntries::Include {
let (index, report, _) = scan_into_index_with_scanner(
&root,
config,
false,
WorkerPolicyExperiment::ShippedOneShot,
)?;
return Ok((index, report));
}
let (output, builder, _diagnostics) = scan_detached_directories(
&root,
config,
false,
WorkerPolicyExperiment::ShippedOneShot,
None,
)?;
Ok(consolidate_detached_index(output, builder))
}
pub(crate) fn scan_into_folded_index(
root: &Path,
config: &ScanConfig,
retention: TreeRetention,
collect_diagnostics: bool,
) -> Result<(Index, ScanReport, Option<ScanDiagnostics>)> {
config.validate()?;
debug_assert_eq!(
config.population,
crate::query::IgnoredEntries::Include,
"a folded index keeps the whole population; a narrowed one takes the scanner"
);
let (output, builder, diagnostics) = scan_detached_directories(
root,
config,
collect_diagnostics,
WorkerPolicyExperiment::ShippedOneShot,
Some(retention),
)?;
let (index, report) = consolidate_detached_index(output, builder);
Ok((index, report, diagnostics))
}
fn scan_into_index_with_scanner(
root: &Path,
config: &ScanConfig,
collect_diagnostics: bool,
policy: WorkerPolicyExperiment,
) -> Result<(Index, ScanReport, Option<ScanDiagnostics>)> {
let mut index = Index::new_with_scope_and_types(root, config.scope(), config.types_shared());
index.set_control_limits(config.control_limits);
let mut apply_error: Option<Error> = None;
let (mut report, diagnostics) = scan_internal(
root,
config,
&mut |batch| {
if apply_error.is_none() {
if let Err(error) = index.apply_scanner_baseline(batch) {
apply_error = Some(error);
}
}
},
collect_diagnostics,
policy,
SinkMode::Retained,
)?;
if let Some(error) = apply_error {
return Err(error);
}
index.record_walk_errors(&mut report.errors);
index.set_initial_scan_freshness(&report.errors);
Ok((index, report, diagnostics))
}
#[cfg(test)]
fn scan_into_index_via_scanner(root: &Path, config: &ScanConfig) -> Result<(Index, ScanReport)> {
let (index, report, _) =
scan_into_index_with_scanner(root, config, false, WorkerPolicyExperiment::ShippedOneShot)?;
Ok((index, report))
}
pub fn scan_into_index_with_diagnostics(
root: &Path,
config: &ScanConfig,
) -> Result<(Index, ScanReport, ScanDiagnostics)> {
scan_into_index_with_policy_diagnostics(root, config, WorkerPolicyExperiment::ShippedOneShot)
}
#[doc(hidden)]
pub fn scan_into_index_with_policy_diagnostics(
root: &Path,
config: &ScanConfig,
policy: WorkerPolicyExperiment,
) -> Result<(Index, ScanReport, ScanDiagnostics)> {
config.validate()?;
let root = root.canonicalize().map_err(|error| Error::io(root, error))?;
if config.population != crate::query::IgnoredEntries::Include {
let (index, report, diagnostics) =
scan_into_index_with_scanner(&root, config, true, policy)?;
return Ok((index, report, diagnostics.expect("diagnostic scanner creates a recorder")));
}
let (output, builder, diagnostics) =
scan_detached_directories(&root, config, true, policy, None)?;
let (index, report) = consolidate_detached_index(output, builder);
Ok((index, report, diagnostics.expect("diagnostic detached scan creates a recorder")))
}
pub fn revalidate(
index: &Index,
config: &ScanConfig,
sink: &mut dyn FnMut(Observation),
) -> Result<ScanReport> {
config.validate_for_scope(index.scope())?;
let root = index.root_path().to_path_buf();
let root_meta = {
crate::counters::bump(|c| c.stats += 1);
fs::symlink_metadata(&root)
}
.map_err(|error| Error::io(&root, error))?;
if !root_meta.is_dir() {
return Err(Error::io(
&root,
std::io::Error::new(
std::io::ErrorKind::NotADirectory,
"revalidation root is not a directory",
),
));
}
let root_dev = root_device(&root, &root_meta).map_err(|error| Error::io(&root, error))?;
if let Some(progress) = &config.progress {
progress.enter(crate::ProgressPhase::Revalidating);
}
let mut report = ScanReport::default();
let mut tally = ProgressTally::new(config.progress.as_ref());
let batch_limit = config.batch_size.max(1);
let mut batch: Vec<ObservationOp> = Vec::with_capacity(batch_limit);
if config.max_depth == Some(0) {
if let Some(children) = index.children(Path::new("")) {
for (name, _) in children {
let path = PathBuf::from(name);
batch.push(ObservationOp::if_state(
Op::Remove { path: path.clone() },
index.relaxed_expectation(&path),
));
if batch.len() >= batch_limit {
sink(Observation::from_ops(std::mem::take(&mut batch)));
batch.reserve(batch_limit);
}
}
}
if !batch.is_empty() {
sink(Observation::from_ops(batch));
}
return Ok(report);
}
let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::from(vec![(PathBuf::new(), 0)]);
let mut controls = (config.population != crate::query::IgnoredEntries::Include)
.then(|| index.control_table().clone());
let mut unreadable_controls = std::collections::BTreeSet::new();
let mut readers = Readers::new();
let policy = ListingPolicy::every_child_stated(config.one_filesystem);
while let Some((rel_dir, depth)) = take_next(&mut queue, config.order) {
let abs_dir = root.join(&rel_dir);
let control_path = rel_dir.join(crate::control::CONTROL_FILE_NAME);
let mut had_control = index.control_table().contains(&control_path);
let mut listed_control = ListedControl::default();
if let Some(table) = controls.as_mut() {
let baseline = index.relaxed_expectation(&control_path);
let lookup = read_directory_control(config, &root, &control_path);
listed_control.lookup(&lookup);
match lookup {
Ok(Some(op)) => {
apply_discovery_control(table, &op)?;
batch.push(ObservationOp::if_state(op, baseline));
}
Ok(None) => {
table.remove(&control_path)?;
if had_control {
batch.push(ObservationOp::if_state(
Op::ControlRemove { path: control_path.clone() },
baseline,
));
had_control = false;
}
}
Err(error) => {
unreadable_controls.insert(rel_dir.clone());
report.errors.push(error);
}
}
}
let listing = match list_directory(&mut readers, &abs_dir, policy, None) {
Ok(listing) => listing,
Err(e) => {
report.errors.push(Error::io(abs_dir, e));
continue;
}
};
report.dirs_read += 1;
let mut seen: BTreeSet<OsString> = BTreeSet::new();
let mut listing_complete = true;
let listing = reconcile_listing(listing, &abs_dir);
for item in listing {
let (name, observed) = match item {
Listed::Child { name, observed } => (name, observed),
Listed::Failed(e) => {
listing_complete = false;
report.errors.push(Error::io(&abs_dir, e));
continue;
}
};
seen.insert(name.to_os_string());
let rel_path = rel_dir.join(&name);
let baseline = index.relaxed_expectation(&rel_path);
let (kind, attrs) = match observed {
Ok(Some(observed)) => observed,
Ok(None) => {
let entry_held = baseline.state != PathState::Absent;
for removal in
vanished_child_removals(&rel_dir, &name, entry_held, &mut had_control)
.into_iter()
.flatten()
{
batch.push(ObservationOp::if_state(removal, baseline));
}
if batch.len() >= batch_limit {
sink(Observation::from_ops(std::mem::take(&mut batch)));
batch.reserve(batch_limit);
}
continue;
}
Err(e) => {
listed_control.listed(&name);
report.errors.push(Error::io(abs_dir.join(&name), e));
continue;
}
};
listed_control.listed(&name);
let disposition =
crate::admission::decide(&name, kind, config.hidden(), config.exclude_special);
if population_prunes(
config.population,
&rel_path,
kind,
disposition,
controls.as_ref(),
&unreadable_controls,
) {
if baseline.state != PathState::Absent {
batch.push(ObservationOp::if_state(Op::Remove { path: rel_path }, baseline));
}
continue;
}
let read = read_listed_control_op(config, &root, &rel_path, kind);
listed_control.read(&name, &read);
let control = match read {
Ok(control) => control,
Err(error) => {
report.errors.push(error);
None
}
};
if disposition != crate::admission::Disposition::Retain {
if baseline.state != PathState::Absent {
batch.push(ObservationOp::if_state(
Op::Remove { path: rel_path.clone() },
baseline,
));
}
if disposition == crate::admission::Disposition::ControlOnly {
if let Some(control) = control {
let guard = control_guard(&rel_path, baseline, |path| {
index.relaxed_expectation(path)
});
batch.push(ObservationOp::if_state(control, guard));
}
}
if batch.len() >= batch_limit {
sink(Observation::from_ops(std::mem::take(&mut batch)));
batch.reserve(batch_limit);
}
continue;
}
report.observe(kind, attrs);
batch.push(ObservationOp::if_state(
Op::Upsert { path: rel_path.clone(), kind, attrs },
baseline,
));
if batch.len() >= batch_limit {
sink(Observation::from_ops(std::mem::take(&mut batch)));
batch.reserve(batch_limit);
}
if let Some(control) = control {
let guard =
control_guard(&rel_path, baseline, |path| index.relaxed_expectation(path));
batch.push(ObservationOp::if_state(control, guard));
if batch.len() >= batch_limit {
sink(Observation::from_ops(std::mem::take(&mut batch)));
batch.reserve(batch_limit);
}
}
if should_descend(kind, attrs, depth, root_dev, config) {
queue.push_back((rel_path, depth + 1));
} else if kind.is_dir() {
if let Some(children) = index.children(&rel_path) {
for (child_name, _) in children {
let child_path = rel_path.join(child_name);
batch.push(ObservationOp::if_state(
Op::Remove { path: child_path.clone() },
index.relaxed_expectation(&child_path),
));
if batch.len() >= batch_limit {
sink(Observation::from_ops(std::mem::take(&mut batch)));
batch.reserve(batch_limit);
}
}
}
}
}
if listing_complete {
if let Some(known) = index.children(&rel_dir) {
for (name, _) in known {
if !seen.contains(name) {
let path = rel_dir.join(name);
batch.push(ObservationOp::if_state(
Op::Remove { path: path.clone() },
index.relaxed_expectation(&path),
));
if let Some(control) = listed_control.restatement_after_removing(name) {
batch.push(ObservationOp::if_state(
control,
index.relaxed_expectation(&control_path),
));
}
}
}
}
if had_control && !listed_control.seen {
batch.push(ObservationOp::if_state(
Op::ControlRemove { path: control_path.clone() },
index.relaxed_expectation(&control_path),
));
}
}
tally.flush(&report);
}
if !batch.is_empty() {
sink(Observation::from_ops(batch));
}
tally.flush(&report);
Ok(report)
}
pub fn reconcile(
index: &mut Index,
config: &ScanConfig,
sink: &mut dyn FnMut(&Commit),
) -> Result<ReconcileReport> {
reconcile_subtree(index, Path::new(""), config, sink)
}
pub fn reconcile_subtree(
index: &mut Index,
subtree: &Path,
config: &ScanConfig,
sink: &mut dyn FnMut(&Commit),
) -> Result<ReconcileReport> {
reconcile_target(&mut ReconcileTarget::Direct(index), subtree, config, sink)
}
pub fn reconcile_handle(
handle: &IndexHandle,
config: &ScanConfig,
sink: &mut dyn FnMut(&Commit),
) -> Result<ReconcileReport> {
reconcile_subtree_handle(handle, Path::new(""), config, sink)
}
pub fn reconcile_subtree_handle(
handle: &IndexHandle,
subtree: &Path,
config: &ScanConfig,
sink: &mut dyn FnMut(&Commit),
) -> Result<ReconcileReport> {
reconcile_target(&mut ReconcileTarget::Shared(handle), subtree, config, sink)
}
#[derive(Debug, Default)]
pub(crate) struct ReconcilePathsReport {
pub(crate) reconciliation: ReconcileReport,
pub(crate) accepted: Vec<PathBuf>,
pub(crate) rejected: Vec<crate::RejectedRefreshPath>,
}
pub(crate) fn reconcile_paths_handle_controlled(
handle: &IndexHandle,
paths: &[PathBuf],
config: &ScanConfig,
forbid_expansion: bool,
control: &dyn ReconcileControl,
sink: &mut dyn FnMut(&Commit),
) -> Result<ReconcilePathsReport> {
let mut target = ReconcileTarget::Controlled { handle, control };
reconcile_paths_target(&mut target, paths, config, forbid_expansion, sink)
}
fn reconcile_paths_target(
target: &mut ReconcileTarget<'_>,
paths: &[PathBuf],
config: &ScanConfig,
forbid_expansion: bool,
sink: &mut dyn FnMut(&Commit),
) -> Result<ReconcilePathsReport> {
config.validate_for_scope(target.scope()?)?;
let mut report = ReconcilePathsReport::default();
let mut accepted = BTreeSet::new();
for requested in paths {
let reject = |reason| crate::RejectedRefreshPath { path: requested.clone(), reason };
let Ok(path) = normalize_subtree(requested) else {
report.rejected.push(reject(crate::RefreshRejection::OutsideRoot));
continue;
};
if config.max_depth.is_some_and(|maximum| path.components().count() > maximum) {
report.rejected.push(reject(crate::RefreshRejection::BeyondDepth));
continue;
}
if crate::admission::decide_path(&path, EntryKind::File, config.hidden(), false)
== crate::admission::Disposition::Reject
{
report.rejected.push(reject(crate::RefreshRejection::NotAdmitted));
continue;
}
if forbid_expansion && refresh_may_expand(target, &path, &mut report.reconciliation.scan)? {
report.rejected.push(reject(crate::RefreshRejection::ResourceBudget));
continue;
}
accepted.insert(path);
}
report.accepted = accepted.into_iter().collect();
let mut resolved = Vec::new();
let mut unsafe_roots = Vec::new();
for requested_root in covering_roots(report.accepted.clone()) {
match resolve_subtree_root(target, &requested_root, config) {
Ok(root) => resolved.push(root),
Err(Error::SubtreeOutsideScanScope { .. }) => unsafe_roots.push(requested_root),
Err(error) => return Err(error),
}
}
if !unsafe_roots.is_empty() {
let mut retained = Vec::with_capacity(report.accepted.len());
for path in std::mem::take(&mut report.accepted) {
if unsafe_roots.iter().any(|root| path.starts_with(root)) {
report.rejected.push(crate::RejectedRefreshPath {
path,
reason: crate::RefreshRejection::UnsafeAncestry,
});
} else {
retained.push(path);
}
}
report.accepted = retained;
}
let walked = covering_roots(resolved);
if walked.is_empty() {
return Ok(report);
}
let mut opened = Vec::with_capacity(walked.len());
for subtree in walked {
let (started_at, commit) = target.begin_reconcile(&subtree)?;
if let Some(commit) = commit.as_ref() {
sink(commit);
}
opened.push((subtree, started_at));
}
let mut failure = None;
let mut outcomes = Vec::with_capacity(opened.len());
for (subtree, started_at) in &opened {
if failure.is_some() {
outcomes.push((false, false));
continue;
}
match reconcile_target_inner(
target,
subtree,
*started_at,
config,
MAX_DEFERRED_RECONCILE_OPS,
sink,
) {
Ok(mut reconciliation) => {
outcomes.push((
reconciliation.is_complete(),
reconciliation.apply.stale == 0 && reconciliation.apply.resource_refused == 0,
));
reconciliation.listed_incomplete = reconciliation.take_recordable_completeness();
merge_reconcile_report(&mut report.reconciliation, reconciliation);
}
Err(error) => {
outcomes.push((false, false));
failure = Some(error);
}
}
}
let listed_incomplete = std::mem::take(&mut report.reconciliation.listed_incomplete);
let root = target.root_path()?;
normalize_walk_errors(&root, &mut report.reconciliation.scan.errors);
let failed_paths = failure_paths(target, &report.reconciliation.scan.errors)?;
for ((subtree, started_at), (complete, disproves_old)) in opened.into_iter().zip(outcomes) {
let commit = target.finish_reconcile(
&subtree,
started_at,
complete,
&listed_incomplete,
&failed_paths,
ReconcileErrors {
errors: &report.reconciliation.scan.errors,
terminal: failure.as_ref(),
disproves_old,
},
)?;
if let Some(commit) = commit.commit.as_ref() {
sink(commit);
}
report.reconciliation.retry_required |= commit.retry;
}
match failure {
Some(error) => Err(error),
None => Ok(report),
}
}
fn failure_paths(target: &ReconcileTarget<'_>, errors: &[Error]) -> Result<Vec<PathBuf>> {
if errors.is_empty() {
return Ok(Vec::new());
}
let root = target.root_path()?;
let mut paths = Vec::with_capacity(errors.len());
for error in errors {
let Some(path) = crate::Issue::from_error_under(&root, error).path else {
return Ok(Vec::new());
};
if path.is_absolute() {
return Ok(Vec::new());
}
paths.push(path);
}
paths.sort();
paths.dedup();
Ok(paths)
}
fn refresh_may_expand(
target: &ReconcileTarget<'_>,
path: &Path,
work: &mut ScanReport,
) -> Result<bool> {
let current = target.expectation(path)?.state;
let absolute = target.root_path()?.join(path);
let observed = match observe_path(&absolute) {
Ok((kind, attrs)) => {
work.observe(kind, attrs);
Some(kind)
}
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
None
}
Err(_) => return Ok(true),
};
Ok(!matches!(
(current, observed),
(PathState::Present { kind: EntryKind::File, .. }, Some(EntryKind::File)) | (_, None)
))
}
fn covering_roots(mut paths: Vec<PathBuf>) -> Vec<PathBuf> {
paths.sort();
paths.dedup();
if paths.first().is_some_and(|first| first.as_os_str().is_empty()) {
return vec![PathBuf::new()];
}
let mut roots: Vec<PathBuf> = Vec::with_capacity(paths.len());
for path in paths {
if roots.last().is_some_and(|kept| path.starts_with(kept)) {
continue;
}
roots.push(path);
}
roots
}
fn reconcile_target(
target: &mut ReconcileTarget<'_>,
subtree: &Path,
config: &ScanConfig,
sink: &mut dyn FnMut(&Commit),
) -> Result<ReconcileReport> {
config.validate_for_scope(target.scope()?)?;
if let Some(progress) = &config.progress {
progress.enter(crate::ProgressPhase::Revalidating);
}
let subtree = normalize_subtree(subtree)?;
if config.max_depth.is_some_and(|maximum| subtree.components().count() > maximum) {
return Err(Error::SubtreeOutsideScanScope { path: subtree, scope: config.scope() });
}
let subtree = if config.population != crate::query::IgnoredEntries::Include
&& !subtree.as_os_str().is_empty()
{
let mut parent = subtree.parent().map_or_else(PathBuf::new, Path::to_path_buf);
while !parent.as_os_str().is_empty()
&& (target.expectation(&parent)?.state == PathState::Absent
|| !target.control_classification_known(&parent)?)
{
parent = parent.parent().map_or_else(PathBuf::new, Path::to_path_buf);
}
parent
} else {
subtree
};
let subtree = resolve_subtree_root(target, &subtree, config)?;
let (started_at, started) = target.begin_reconcile(&subtree)?;
if let Some(commit) = started.as_ref() {
sink(commit);
}
match reconcile_target_inner(
target,
&subtree,
started_at,
config,
MAX_DEFERRED_RECONCILE_OPS,
sink,
) {
Ok(mut report) => {
let root = target.root_path()?;
normalize_walk_errors(&root, &mut report.scan.errors);
let listed_incomplete = report.take_recordable_completeness();
let failed_paths = failure_paths(target, &report.scan.errors)?;
let finished = target.finish_reconcile(
&subtree,
started_at,
report.is_complete(),
&listed_incomplete,
&failed_paths,
ReconcileErrors {
errors: &report.scan.errors,
terminal: None,
disproves_old: report.apply.stale == 0 && report.apply.resource_refused == 0,
},
)?;
if let Some(commit) = finished.commit.as_ref() {
sink(commit);
}
report.retry_required |= finished.retry;
Ok(report)
}
Err(error) => {
let finished = target.finish_reconcile(
&subtree,
started_at,
false,
&[],
&[],
ReconcileErrors { errors: &[], terminal: Some(&error), disproves_old: false },
)?;
if let Some(commit) = finished.commit.as_ref() {
sink(commit);
}
Err(error)
}
}
}
fn reconcile_target_inner(
target: &mut ReconcileTarget<'_>,
subtree: &Path,
started_at: u64,
config: &ScanConfig,
max_deferred_ops: usize,
sink: &mut dyn FnMut(&Commit),
) -> Result<ReconcileReport> {
let root = target.root_path()?;
let root_meta = {
crate::counters::bump(|c| c.stats += 1);
fs::symlink_metadata(&root)
}
.map_err(|error| Error::io(&root, error))?;
if !root_meta.is_dir() {
return Err(Error::io(
&root,
std::io::Error::new(
std::io::ErrorKind::NotADirectory,
"reconciliation root is not a directory",
),
));
}
let root_dev = root_device(&root, &root_meta).map_err(|error| Error::io(&root, error))?;
let start_depth = subtree.components().count();
let mut report =
ReconcileReport { reconcile_epoch: Some(started_at), ..ReconcileReport::default() };
let mut tally = ProgressTally::new(config.progress.as_ref());
let mut retry_frontier = None;
let mut batch: Vec<ObservationOp> = Vec::with_capacity(config.batch_size.max(1));
if config.max_depth == Some(0) {
remove_known_children(target, Path::new(""), config, &mut batch, sink, &mut report)?;
return Ok(report);
}
if !subtree.as_os_str().is_empty() {
let baseline = target.expectation(subtree)?;
let absolute = root.join(subtree);
let (kind, attrs) = match observe_path(&absolute) {
Ok(observed) => observed,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
batch.push(ObservationOp::if_state(
Op::Remove { path: subtree.to_path_buf() },
baseline,
));
push_lost_spelling_control(
target,
&root,
config,
subtree,
baseline,
&mut batch,
&mut report,
)?;
flush_reconcile_batch(target, &mut batch, sink, &mut report)?;
return Ok(report);
}
Err(error) => {
report.scan.errors.push(Error::io(&absolute, error));
if baseline.state != PathState::Absent {
batch.push(ObservationOp::if_state(
Op::Remove { path: subtree.to_path_buf() },
baseline,
));
}
push_lost_spelling_control(
target,
&root,
config,
subtree,
baseline,
&mut batch,
&mut report,
)?;
flush_reconcile_batch(target, &mut batch, sink, &mut report)?;
return Ok(report);
}
};
let disposition =
crate::admission::decide_path(subtree, kind, config.hidden(), config.exclude_special);
if disposition != crate::admission::Disposition::Retain {
if baseline.state != PathState::Absent {
batch.push(ObservationOp::if_state(
Op::Remove { path: subtree.to_path_buf() },
baseline,
));
}
if disposition == crate::admission::Disposition::ControlOnly {
let read = read_control_op(config, &root, subtree, kind);
push_read_control(target, subtree, baseline, read, &mut batch, &mut report)?;
}
flush_reconcile_batch(target, &mut batch, sink, &mut report)?;
return Ok(report);
}
report.scan.observe(kind, attrs);
push_reconcile_upsert(target, subtree, kind, attrs, baseline, &mut batch, &mut report);
let read = read_control_op(config, &root, subtree, kind);
push_read_control(target, subtree, baseline, read, &mut batch, &mut report)?;
flush_reconcile_batch(target, &mut batch, sink, &mut report)?;
if !should_descend(kind, attrs, start_depth.saturating_sub(1), root_dev, config) {
if kind.is_dir() {
remove_known_children(target, subtree, config, &mut batch, sink, &mut report)?;
}
tally.flush(&report.scan);
return Ok(report);
}
}
if subtree.as_os_str().is_empty()
&& config.population == crate::query::IgnoredEntries::Include
&& config.reconciliation_worker_threads() > 1
{
if let ReconcileTarget::Direct(index) = target {
match reconcile_direct_parallel(index, &root, root_dev, config, max_deferred_ops, sink)?
{
DirectParallelOutcome::Complete(parallel) => return Ok(parallel),
DirectParallelOutcome::RetrySerial { prefix, remaining } => {
report = prefix;
report.reconcile_epoch = Some(started_at);
retry_frontier = Some(remaining);
tally.skip_to(&report.scan);
}
}
}
}
let mut queue: VecDeque<(PathBuf, usize)> = retry_frontier
.unwrap_or_else(|| VecDeque::from(vec![(subtree.to_path_buf(), start_depth)]));
let mut controls = if config.population == crate::query::IgnoredEntries::Include {
None
} else {
Some(target.control_table()?)
};
let mut unreadable_controls = std::collections::BTreeSet::new();
#[cfg(target_os = "macos")]
let mut bulk_reader = (config.worker_threads() > 1).then(macos_bulk::Reader::new);
let mut readers = Readers::new();
let policy = ListingPolicy::every_child_stated(config.one_filesystem);
while let Some((rel_dir, depth)) = take_next(&mut queue, config.order) {
let errors_before = report.scan.errors.len();
let abs_dir = root.join(&rel_dir);
let control_path = rel_dir.join(crate::control::CONTROL_FILE_NAME);
let mut had_control = target.has_control(&control_path)?;
let mut listed_control = ListedControl::default();
if let Some(table) = controls.as_mut() {
let lookup = read_directory_control(config, &root, &control_path);
listed_control.lookup(&lookup);
match lookup {
Ok(Some(op)) => {
let baseline = target.expectation(&control_path)?;
apply_discovery_control(table, &op)?;
batch.push(ObservationOp::if_state(op, baseline));
}
Ok(None) => {
table.remove(&control_path)?;
if had_control {
let baseline = target.expectation(&control_path)?;
batch.push(ObservationOp::if_state(
Op::ControlRemove { path: control_path.clone() },
baseline,
));
had_control = false;
}
}
Err(error) => {
unreadable_controls.insert(rel_dir.clone());
report.scan.errors.push(error);
}
}
flush_reconcile_batch(target, &mut batch, sink, &mut report)?;
if report.apply.stale > 0 || report.apply.resource_refused > 0 {
report.retry_required = true;
return Ok(report);
}
}
let (mut known, records_completeness) = target.listing_baseline(&rel_dir)?;
let mut listing_complete = true;
let process_entry = |name: OsString,
kind: EntryKind,
attrs: Attrs,
baseline: PathExpectation,
listed_control: &mut ListedControl,
target: &mut ReconcileTarget<'_>,
queue: &mut VecDeque<(PathBuf, usize)>,
batch: &mut Vec<ObservationOp>,
sink: &mut dyn FnMut(&Commit),
report: &mut ReconcileReport|
-> Result<()> {
let rel_path = rel_dir.join(&name);
listed_control.listed(&name);
let disposition =
crate::admission::decide(&name, kind, config.hidden(), config.exclude_special);
if population_prunes(
config.population,
&rel_path,
kind,
disposition,
controls.as_ref(),
&unreadable_controls,
) {
if baseline.state != PathState::Absent {
batch.push(ObservationOp::if_state(Op::Remove { path: rel_path }, baseline));
}
return Ok(());
}
if disposition != crate::admission::Disposition::Retain {
if baseline.state != PathState::Absent {
batch.push(ObservationOp::if_state(
Op::Remove { path: rel_path.clone() },
baseline,
));
}
if disposition == crate::admission::Disposition::ControlOnly {
let read = read_listed_control_op(config, &root, &rel_path, kind);
listed_control.read(&name, &read);
push_read_control(target, &rel_path, baseline, read, batch, report)?;
}
if batch.len() >= config.batch_size.max(1) {
flush_reconcile_batch(target, batch, sink, report)?;
}
return Ok(());
}
report.scan.observe(kind, attrs);
push_reconcile_upsert(target, &rel_path, kind, attrs, baseline, batch, report);
if batch.len() >= config.batch_size.max(1) {
flush_reconcile_batch(target, batch, sink, report)?;
}
let read = read_listed_control_op(config, &root, &rel_path, kind);
listed_control.read(&name, &read);
push_read_control(target, &rel_path, baseline, read, batch, report)?;
if batch.len() >= config.batch_size.max(1) {
flush_reconcile_batch(target, batch, sink, report)?;
}
if should_descend(kind, attrs, depth, root_dev, config) {
queue.push_back((rel_path, depth + 1));
} else if kind.is_dir() {
remove_known_children(target, &rel_path, config, batch, sink, report)?;
}
Ok(())
};
#[cfg(target_os = "macos")]
let used_bulk = if walk_hook_covers(&abs_dir) {
false
} else if let Some(entries) = bulk_reader.as_mut().and_then(|reader| reader.read(&abs_dir))
{
report.scan.dirs_read += 1;
for entry in entries {
let baseline = match known.remove(&entry.name) {
Some(baseline) => baseline,
None => target.expectation(&rel_dir.join(&entry.name))?,
};
process_entry(
entry.name,
entry.kind,
entry.attrs,
baseline,
&mut listed_control,
target,
&mut queue,
&mut batch,
sink,
&mut report,
)?;
}
true
} else {
false
};
#[cfg(not(target_os = "macos"))]
let used_bulk = false;
if !used_bulk {
let listing = match list_directory(&mut readers, &abs_dir, policy, None) {
Ok(listing) => listing,
Err(error) => {
report.scan.errors.push(Error::io(&abs_dir, error));
remove_known_children(target, &rel_dir, config, &mut batch, sink, &mut report)?;
if had_control {
let baseline = target.expectation(&control_path)?;
batch.push(ObservationOp::if_state(
Op::ControlRemove { path: control_path },
baseline,
));
flush_reconcile_batch(target, &mut batch, sink, &mut report)?;
}
continue;
}
};
report.scan.dirs_read += 1;
let listing = reconcile_listing(listing, &abs_dir);
for item in listing {
let (name, observed) = match item {
Listed::Child { name, observed } => (name.into_owned(), observed),
Listed::Failed(error) => {
listing_complete = false;
report.scan.errors.push(Error::io(&abs_dir, error));
continue;
}
};
let baseline = match known.remove(&name) {
Some(baseline) => baseline,
None => target.expectation(&rel_dir.join(&name))?,
};
let (kind, attrs) = match observed {
Ok(Some(observed)) => observed,
Ok(None) => {
let entry_held = baseline.state != PathState::Absent;
for removal in
vanished_child_removals(&rel_dir, &name, entry_held, &mut had_control)
.into_iter()
.flatten()
{
batch.push(ObservationOp::if_state(removal, baseline));
}
if batch.len() >= config.batch_size.max(1) {
flush_reconcile_batch(target, &mut batch, sink, &mut report)?;
}
continue;
}
Err(error) => {
listed_control.listed(&name);
report.scan.errors.push(Error::io(abs_dir.join(&name), error));
if baseline.state != PathState::Absent {
batch.push(ObservationOp::if_state(
Op::Remove { path: rel_dir.join(&name) },
baseline,
));
}
if name == crate::control::CONTROL_FILE_NAME && had_control {
batch.push(ObservationOp::if_state(
Op::ControlRemove { path: rel_dir.join(&name) },
baseline,
));
had_control = false;
}
if batch.len() >= config.batch_size.max(1) {
flush_reconcile_batch(target, &mut batch, sink, &mut report)?;
}
continue;
}
};
process_entry(
name,
kind,
attrs,
baseline,
&mut listed_control,
target,
&mut queue,
&mut batch,
sink,
&mut report,
)?;
}
}
for (name, baseline) in known {
let restatement = listed_control.restatement_after_removing(&name);
batch.push(ObservationOp::if_state(Op::Remove { path: rel_dir.join(name) }, baseline));
if let Some(control) = restatement {
let guard = target.expectation(&control_path)?;
batch.push(ObservationOp::if_state(control, guard));
}
if batch.len() >= config.batch_size.max(1) {
flush_reconcile_batch(target, &mut batch, sink, &mut report)?;
}
}
if had_control && !listed_control.seen {
let baseline = target.expectation(&control_path)?;
batch.push(ObservationOp::if_state(Op::ControlRemove { path: control_path }, baseline));
}
if listing_complete {
if records_completeness && report.scan.errors.len() == errors_before {
report.listed_incomplete.push(rel_dir);
}
}
tally.flush(&report.scan);
}
flush_reconcile_batch(target, &mut batch, sink, &mut report)?;
report.scan.errors.sort_by_cached_key(ToString::to_string);
tally.flush(&report.scan);
Ok(report)
}
#[derive(Debug, Default)]
struct DeferredReconcile {
scan: ScanReport,
unchanged: u64,
operations: Vec<Op>,
restated: Vec<Op>,
discovered: Vec<(PathBuf, usize, RegionId)>,
listed_incomplete: Vec<PathBuf>,
}
enum DirectParallelOutcome {
Complete(ReconcileReport),
RetrySerial { prefix: ReconcileReport, remaining: VecDeque<(PathBuf, usize)> },
}
fn reconcile_direct_parallel(
index: &mut Index,
root: &Path,
root_dev: u64,
config: &ScanConfig,
max_deferred_ops: usize,
sink: &mut dyn FnMut(&Commit),
) -> Result<DirectParallelOutcome> {
let mut frontier = DirectoryQueueState::seeded(
(PathBuf::new(), 0),
config.order,
None,
1,
1,
WorkerPolicyExperiment::ShippedOneShot,
);
let mut report = ReconcileReport::default();
while !frontier.is_empty(config.order) {
let mut wave = Vec::with_capacity(RECONCILE_WAVE_DIRECTORIES);
while wave.len() < RECONCILE_WAVE_DIRECTORIES && !frontier.is_empty(config.order) {
let remaining = RECONCILE_WAVE_DIRECTORIES - wave.len();
frontier.take(remaining.min(DIR_CLAIM), config.order, &mut wave);
}
let next = std::sync::atomic::AtomicUsize::new(0);
let deferred_count = std::sync::atomic::AtomicUsize::new(0);
let overflowed = std::sync::atomic::AtomicBool::new(false);
let workers = config.reconciliation_worker_threads().min(wave.len());
let baseline: &Index = index;
let results: Vec<DeferredReconcile> = std::thread::scope(|scope| {
let handles: Vec<_> = (0..workers)
.map(|_| {
scope.spawn(|| {
reconcile_wave_worker(
baseline,
root,
root_dev,
config,
&wave,
&next,
&deferred_count,
&overflowed,
max_deferred_ops,
)
})
})
.collect();
handles
.into_iter()
.map(|handle| {
if let Ok(worker) = handle.join() {
return worker;
}
let mut worker = DeferredReconcile::default();
worker.scan.errors.push(Error::io(
root,
std::io::Error::other("a reconciliation worker thread panicked"),
));
worker
})
.collect()
});
if overflowed.load(std::sync::atomic::Ordering::Relaxed) {
let mut remaining: VecDeque<_> =
wave.into_iter().map(|(path, depth, _region)| (path, depth)).collect();
let mut deferred = Vec::with_capacity(DIR_CLAIM);
while !frontier.is_empty(config.order) {
frontier.take(DIR_CLAIM, config.order, &mut deferred);
remaining.extend(deferred.drain(..).map(|(path, depth, _region)| (path, depth)));
}
return Ok(DirectParallelOutcome::RetrySerial { prefix: report, remaining });
}
let operation_count = deferred_count.load(std::sync::atomic::Ordering::Relaxed);
let mut operations = Vec::with_capacity(operation_count);
let mut restated = Vec::new();
for worker in results {
report.listed_incomplete.extend(worker.listed_incomplete);
report.scan.absorb(worker.scan);
report.apply.unchanged += worker.unchanged;
report.observations = report.observations.saturating_add(worker.unchanged);
operations.extend(worker.operations);
restated.extend(worker.restated);
for directory in worker.discovered {
frontier.push(directory, config.order);
}
}
apply_deferred_reconcile(
index,
&mut operations,
restated,
config,
sink,
&mut report.apply,
&mut report.observations,
)?;
}
report.scan.errors.sort_by_cached_key(ToString::to_string);
Ok(DirectParallelOutcome::Complete(report))
}
fn apply_deferred_reconcile(
index: &mut Index,
operations: &mut Vec<Op>,
restated: Vec<Op>,
config: &ScanConfig,
sink: &mut dyn FnMut(&Commit),
stats: &mut ApplyStats,
observations: &mut u64,
) -> Result<()> {
operations.sort_by(|left, right| {
let left_remove = matches!(left, Op::Remove { .. });
let right_remove = matches!(right, Op::Remove { .. });
left_remove.cmp(&right_remove).then_with(|| {
let left_depth = left.path().components().count();
let right_depth = right.path().components().count();
if left_remove {
right_depth.cmp(&left_depth).then_with(|| left.path().cmp(right.path()))
} else {
left_depth.cmp(&right_depth).then_with(|| left.path().cmp(right.path()))
}
})
});
let batch_limit = config.batch_size.max(1);
let mut batch = Vec::with_capacity(batch_limit.min(operations.len()));
for operation in operations.drain(..) {
batch.push(operation);
if batch.len() >= batch_limit {
flush_direct_reconcile_batch(index, &mut batch, sink, stats, observations)?;
}
}
flush_direct_reconcile_batch(index, &mut batch, sink, stats, observations)?;
let mut restated = restated;
flush_direct_reconcile_batch(index, &mut restated, sink, stats, observations)
}
#[allow(clippy::too_many_arguments)]
fn reconcile_wave_worker(
index: &Index,
root: &Path,
root_dev: u64,
config: &ScanConfig,
wave: &[(PathBuf, usize, RegionId)],
next: &std::sync::atomic::AtomicUsize,
deferred_count: &std::sync::atomic::AtomicUsize,
overflowed: &std::sync::atomic::AtomicBool,
max_deferred_ops: usize,
) -> DeferredReconcile {
let _counter_guard = crate::counters::thread_flush_guard();
let mut result = DeferredReconcile::default();
let mut tally = ProgressTally::new(config.progress.as_ref());
#[cfg(target_os = "macos")]
let mut bulk_reader = macos_bulk::Reader::new();
let mut readers = Readers::new();
let policy = ListingPolicy::every_child_stated(config.one_filesystem);
loop {
let start = next.fetch_add(DIR_CLAIM, std::sync::atomic::Ordering::Relaxed);
if start >= wave.len() {
break;
}
let end = start.saturating_add(DIR_CLAIM).min(wave.len());
for (rel_dir, depth, region) in &wave[start..end] {
let errors_before = result.scan.errors.len();
let mut known = collect_child_expectations(index, rel_dir);
let abs_dir = root.join(rel_dir);
let control_path = rel_dir.join(crate::control::CONTROL_FILE_NAME);
let mut had_control = index.control_table().contains(&control_path);
let mut listed_control = ListedControl::default();
let mut control_errors = Vec::new();
let mut vanished = Vec::new();
let mut unverified = Vec::new();
let mut control_read_failed = false;
let mut listing_open_failed = false;
{
let mut process_entry =
|name: OsString,
kind: EntryKind,
attrs: Attrs,
baseline: PathExpectation,
listed_control: &mut ListedControl| {
let rel_path = rel_dir.join(&name);
listed_control.listed(&name);
let disposition = crate::admission::decide(
&name,
kind,
config.hidden(),
config.exclude_special,
);
if disposition == crate::admission::Disposition::Reject {
if baseline.state != PathState::Absent {
defer_reconcile_op(
Op::Remove { path: rel_path },
&mut result.operations,
deferred_count,
overflowed,
max_deferred_ops,
);
}
return;
}
if disposition == crate::admission::Disposition::ControlOnly {
let read = read_control_op(config, root, &rel_path, kind);
listed_control.read(&name, &read);
match read {
Ok(Some(Op::ControlUpsert { path, source })) => {
if !index.control_table().source_is(&path, &source) {
defer_reconcile_op(
Op::ControlUpsert { path, source },
&mut result.operations,
deferred_count,
overflowed,
max_deferred_ops,
);
}
}
Ok(Some(Op::ControlRemove { path })) => {
if index.control_table().contains(&path) {
defer_reconcile_op(
Op::ControlRemove { path },
&mut result.operations,
deferred_count,
overflowed,
max_deferred_ops,
);
}
}
Ok(Some(_) | None) => {}
Err(error) => {
control_errors.push(error);
control_read_failed = true;
}
}
return;
}
result.scan.entries += 1;
if kind == EntryKind::File {
result.scan.files_walked += 1;
result.scan.bytes_walked =
result.scan.bytes_walked.saturating_add(attrs.size);
result.scan.allocated_walked =
result.scan.allocated_walked.saturating_add(attrs.allocated);
}
if baseline.state == (PathState::Present { kind, attrs }) {
result.unchanged += 1;
} else {
defer_reconcile_op(
Op::Upsert { path: rel_path.clone(), kind, attrs },
&mut result.operations,
deferred_count,
overflowed,
max_deferred_ops,
);
}
let read = read_control_op(config, root, &rel_path, kind);
listed_control.read(&name, &read);
match read {
Ok(Some(Op::ControlUpsert { path, source })) => {
if !index.control_table().source_is(&path, &source) {
defer_reconcile_op(
Op::ControlUpsert { path, source },
&mut result.operations,
deferred_count,
overflowed,
max_deferred_ops,
);
}
}
Ok(Some(Op::ControlRemove { path }))
if crate::control::control_spelling(&name)
== Some(crate::control::ControlSpelling::Variant)
&& index.control_table().contains(&path) =>
{
defer_reconcile_op(
Op::ControlRemove { path },
&mut result.operations,
deferred_count,
overflowed,
max_deferred_ops,
);
}
Ok(Some(_) | None) => {}
Err(error) => {
control_errors.push(error);
control_read_failed = true;
}
}
if should_descend(kind, attrs, *depth, root_dev, config) {
let child_region =
if *depth == 0 { RegionId::UNASSIGNED } else { *region };
result.discovered.push((rel_path, depth + 1, child_region));
} else if kind.is_dir() {
for name in collect_child_expectations(index, &rel_path).into_keys() {
defer_reconcile_op(
Op::Remove { path: rel_path.join(name) },
&mut result.operations,
deferred_count,
overflowed,
max_deferred_ops,
);
}
}
};
#[cfg(target_os = "macos")]
let used_bulk = if let Some(entries) =
(!walk_hook_covers(&abs_dir)).then(|| bulk_reader.read(&abs_dir)).flatten()
{
result.scan.dirs_read += 1;
for entry in entries {
let baseline = known
.remove(&entry.name)
.unwrap_or_else(|| index.expectation(&rel_dir.join(&entry.name)));
process_entry(
entry.name,
entry.kind,
entry.attrs,
baseline,
&mut listed_control,
);
}
true
} else {
false
};
#[cfg(not(target_os = "macos"))]
let used_bulk = false;
if !used_bulk {
let listing = match list_directory(&mut readers, &abs_dir, policy, None) {
Ok(listing) => Some(listing),
Err(error) => {
result.scan.errors.push(Error::io(&abs_dir, error));
listing_open_failed = true;
None
}
};
if let Some(listing) = listing {
result.scan.dirs_read += 1;
let listing = reconcile_listing(listing, &abs_dir);
for item in listing {
let (name, observed) = match item {
Listed::Child { name, observed } => (name.into_owned(), observed),
Listed::Failed(error) => {
result.scan.errors.push(Error::io(&abs_dir, error));
continue;
}
};
let baseline = known
.remove(&name)
.unwrap_or_else(|| index.expectation(&rel_dir.join(&name)));
let (kind, attrs) = match observed {
Ok(Some(observed)) => observed,
Ok(None) => {
vanished.push((name, baseline.state != PathState::Absent));
continue;
}
Err(error) => {
listed_control.listed(&name);
result.scan.errors.push(Error::io(abs_dir.join(&name), error));
unverified.push((name, baseline.state != PathState::Absent));
continue;
}
};
process_entry(name, kind, attrs, baseline, &mut listed_control);
}
}
}
}
control_read_failed |= listing_open_failed && had_control;
result.scan.errors.append(&mut control_errors);
if index.directory_complete(rel_dir) != Some(true)
&& result.scan.errors.len() == errors_before
{
result.listed_incomplete.push(rel_dir.clone());
}
for (name, entry_held) in vanished {
for removal in vanished_child_removals(rel_dir, &name, entry_held, &mut had_control)
.into_iter()
.flatten()
{
defer_reconcile_op(
removal,
&mut result.operations,
deferred_count,
overflowed,
max_deferred_ops,
);
}
}
for (name, entry_held) in unverified {
if entry_held {
defer_reconcile_op(
Op::Remove { path: rel_dir.join(&name) },
&mut result.operations,
deferred_count,
overflowed,
max_deferred_ops,
);
}
if name == crate::control::CONTROL_FILE_NAME {
control_read_failed = true;
}
}
for (name, _) in known {
let restatement = listed_control.restatement_after_removing(&name);
let removal = Op::Remove { path: rel_dir.join(name) };
match restatement {
Some(control) => {
for operation in [removal, control] {
defer_reconcile_op(
operation,
&mut result.restated,
deferred_count,
overflowed,
max_deferred_ops,
);
}
}
None => defer_reconcile_op(
removal,
&mut result.operations,
deferred_count,
overflowed,
max_deferred_ops,
),
}
}
if had_control && (!listed_control.seen || control_read_failed) {
defer_reconcile_op(
Op::ControlRemove { path: control_path },
&mut result.operations,
deferred_count,
overflowed,
max_deferred_ops,
);
}
}
tally.flush(&result.scan);
}
result
}
#[derive(Default)]
struct ListedControl {
seen: bool,
variant_listed: bool,
looked_up: Option<Op>,
}
impl ListedControl {
fn listed(&mut self, name: &OsStr) {
match crate::control::control_spelling(name) {
Some(crate::control::ControlSpelling::Exact) => self.seen = true,
Some(crate::control::ControlSpelling::Variant) => self.variant_listed = true,
None => {}
}
}
fn read(&mut self, name: &OsStr, read: &Result<Option<Op>>) {
if crate::control::control_spelling(name) == Some(crate::control::ControlSpelling::Variant)
{
self.lookup(read);
}
}
fn lookup(&mut self, lookup: &Result<Option<Op>>) {
match lookup {
Ok(Some(observed)) => {
self.seen = true;
self.looked_up = Some(observed.clone());
}
Ok(None) => {}
Err(_) => self.seen = true,
}
}
fn restatement_after_removing(&self, name: &OsStr) -> Option<Op> {
if name == crate::control::CONTROL_FILE_NAME && self.variant_listed {
self.looked_up.clone()
} else {
None
}
}
}
fn control_guard<T>(path: &Path, entry: T, expect: impl FnOnce(&Path) -> T) -> T {
match crate::control::path_control_spelling(path) {
Some(crate::control::ControlSpelling::Variant) => {
expect(&crate::control::sibling_control_path(path))
}
_ => entry,
}
}
fn push_read_control(
target: &ReconcileTarget<'_>,
path: &Path,
baseline: PathExpectation,
read: Result<Option<Op>>,
batch: &mut Vec<ObservationOp>,
report: &mut ReconcileReport,
) -> Result<()> {
match read {
Ok(Some(control)) => {
let guard = control_guard(path, Ok(baseline), |named| target.expectation(named))?;
batch.push(ObservationOp::if_state(control, guard));
}
Ok(None) => {}
Err(error) => {
let control_path = crate::control::sibling_control_path(path);
if target.has_control(&control_path)? {
let guard = control_guard(path, Ok(baseline), |named| target.expectation(named))?;
batch
.push(ObservationOp::if_state(Op::ControlRemove { path: control_path }, guard));
}
report.scan.errors.push(error);
}
}
Ok(())
}
fn push_lost_spelling_control(
target: &ReconcileTarget<'_>,
root: &Path,
config: &ScanConfig,
path: &Path,
baseline: PathExpectation,
batch: &mut Vec<ObservationOp>,
report: &mut ReconcileReport,
) -> Result<()> {
match crate::control::path_control_spelling(path) {
Some(crate::control::ControlSpelling::Exact) => {
if target.has_control(path)? {
batch.push(ObservationOp::if_state(
Op::ControlRemove { path: path.to_path_buf() },
baseline,
));
}
}
Some(crate::control::ControlSpelling::Variant) => {
let control_path = crate::control::sibling_control_path(path);
let read = read_directory_control_or_removal(config, root, &control_path);
push_read_control(target, path, baseline, read, batch, report)?;
}
None => {}
}
Ok(())
}
fn vanished_child_removals(
dir: &Path,
name: &OsStr,
entry_held: bool,
had_control: &mut bool,
) -> [Option<Op>; 2] {
let path = dir.join(name);
let rules = (*had_control && name == crate::control::CONTROL_FILE_NAME).then(|| {
*had_control = false;
Op::ControlRemove { path: path.clone() }
});
[entry_held.then_some(Op::Remove { path }), rules]
}
fn defer_reconcile_op(
operation: Op,
operations: &mut Vec<Op>,
deferred_count: &std::sync::atomic::AtomicUsize,
overflowed: &std::sync::atomic::AtomicBool,
max_deferred_ops: usize,
) {
let position = deferred_count.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
if position < max_deferred_ops {
operations.push(operation);
} else {
overflowed.store(true, std::sync::atomic::Ordering::Relaxed);
}
}
fn flush_direct_reconcile_batch(
index: &mut Index,
batch: &mut Vec<Op>,
sink: &mut dyn FnMut(&Commit),
stats: &mut ApplyStats,
observations: &mut u64,
) -> Result<()> {
if batch.is_empty() {
return Ok(());
}
*observations = observations.saturating_add(u64::try_from(batch.len()).unwrap_or(u64::MAX));
let outcome = index.apply(&Observation::new(std::mem::take(batch)))?;
merge_apply_stats(stats, outcome.stats);
if let Some(commit) = outcome.commit.as_ref() {
sink(commit);
}
Ok(())
}
pub fn reconcile_pending(
index: &mut Index,
config: &ScanConfig,
sink: &mut dyn FnMut(&Commit),
) -> Result<ReconcileReport> {
let mut target = ReconcileTarget::Direct(index);
reconcile_pending_target(&mut target, config, sink)
}
pub fn reconcile_pending_handle(
handle: &IndexHandle,
config: &ScanConfig,
sink: &mut dyn FnMut(&Commit),
) -> Result<ReconcileReport> {
let mut target = ReconcileTarget::Shared(handle);
reconcile_pending_target(&mut target, config, sink)
}
#[cfg(feature = "watch")]
pub(crate) fn reconcile_pending_handle_controlled(
handle: &IndexHandle,
config: &ScanConfig,
control: &dyn ReconcileControl,
sink: &mut dyn FnMut(&Commit),
) -> Result<ReconcileReport> {
let mut target = ReconcileTarget::Controlled { handle, control };
reconcile_pending_target(&mut target, config, sink)
}
fn reconcile_pending_target(
target: &mut ReconcileTarget<'_>,
config: &ScanConfig,
sink: &mut dyn FnMut(&Commit),
) -> Result<ReconcileReport> {
config.validate_for_scope(target.scope()?)?;
let roots = take_invalidation_roots(target)?;
let mut combined = ReconcileReport::default();
for (position, (root, reason)) in roots.iter().enumerate() {
match reconcile_target(target, root, config, sink) {
Ok(report) => {
if target.retries_incomplete(&report) {
target.restore_pending_invalidations(vec![(root.clone(), *reason)])?;
}
merge_reconcile_report(&mut combined, report);
}
Err(error) => {
target.restore_pending_invalidations(roots[position..].to_vec())?;
return Err(error);
}
}
}
Ok(combined)
}
fn take_invalidation_roots(
target: &mut ReconcileTarget<'_>,
) -> Result<Vec<(PathBuf, crate::InvalidateReason)>> {
let mut pending = target.take_pending_invalidations()?;
pending.sort_by(|(left, _), (right, _)| {
left.components().count().cmp(&right.components().count()).then_with(|| left.cmp(right))
});
let mut roots: Vec<(PathBuf, crate::InvalidateReason)> = Vec::new();
for (path, reason) in pending {
if roots.iter().any(|(root, _)| path.starts_with(root)) {
continue;
}
roots.push((path, reason));
}
Ok(roots)
}
fn remove_known_children(
target: &mut ReconcileTarget<'_>,
path: &Path,
config: &ScanConfig,
batch: &mut Vec<ObservationOp>,
sink: &mut dyn FnMut(&Commit),
report: &mut ReconcileReport,
) -> Result<()> {
for (name, baseline) in target.child_states(path)? {
batch.push(ObservationOp::if_state(Op::Remove { path: path.join(name) }, baseline));
if batch.len() >= config.batch_size.max(1) {
flush_reconcile_batch(target, batch, sink, report)?;
}
}
flush_reconcile_batch(target, batch, sink, report)
}
fn push_reconcile_upsert(
target: &ReconcileTarget<'_>,
path: &Path,
kind: EntryKind,
attrs: Attrs,
baseline: PathExpectation,
batch: &mut Vec<ObservationOp>,
report: &mut ReconcileReport,
) {
if target.direct_upsert_is_unchanged(baseline, kind, attrs) {
report.observations = report.observations.saturating_add(1);
report.apply.unchanged = report.apply.unchanged.saturating_add(1);
return;
}
batch.push(ObservationOp::if_state(
Op::Upsert { path: path.to_path_buf(), kind, attrs },
baseline,
));
}
fn flush_reconcile_batch(
target: &mut ReconcileTarget<'_>,
batch: &mut Vec<ObservationOp>,
sink: &mut dyn FnMut(&Commit),
report: &mut ReconcileReport,
) -> Result<()> {
if batch.is_empty() {
return Ok(());
}
report.observations =
report.observations.saturating_add(u64::try_from(batch.len()).unwrap_or(u64::MAX));
let started_at = report.reconcile_epoch.expect("reconciliation report has an owner");
let outcome = target.apply(started_at, &Observation::from_ops(std::mem::take(batch)))?;
merge_apply_stats(&mut report.apply, outcome.stats);
if let Some(commit) = outcome.commit.as_ref() {
sink(commit);
}
Ok(())
}
fn merge_apply_stats(total: &mut ApplyStats, addition: ApplyStats) {
total.inserted += addition.inserted;
total.updated += addition.updated;
total.removed += addition.removed;
total.unchanged += addition.unchanged;
total.invalidated += addition.invalidated;
total.controls += addition.controls;
total.reclassified += addition.reclassified;
total.stale += addition.stale;
total.resource_refused += addition.resource_refused;
}
fn merge_reconcile_report(total: &mut ReconcileReport, addition: ReconcileReport) {
total.retry_required |= addition.retry_required;
total.scan.dirs_read += addition.scan.dirs_read;
total.scan.entries += addition.scan.entries;
total.scan.files_walked += addition.scan.files_walked;
total.scan.bytes_walked = total.scan.bytes_walked.saturating_add(addition.scan.bytes_walked);
total.scan.allocated_walked =
total.scan.allocated_walked.saturating_add(addition.scan.allocated_walked);
total.scan.errors.extend(addition.scan.errors);
total.observations = total.observations.saturating_add(addition.observations);
merge_apply_stats(&mut total.apply, addition.apply);
total.listed_incomplete.extend(addition.listed_incomplete);
}
fn should_descend(
kind: EntryKind,
attrs: Attrs,
parent_depth: usize,
root_dev: u64,
config: &ScanConfig,
) -> bool {
crate::admission::should_descend(
kind,
attrs,
parent_depth,
root_dev,
config.max_depth,
config.one_filesystem,
)
}
pub(crate) fn normalize_subtree(path: &Path) -> Result<PathBuf> {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(part) => normalized.push(part),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
return Err(Error::PathEscapesRoot(path.to_path_buf()));
}
}
}
Ok(normalized)
}
fn resolve_subtree_root(
target: &ReconcileTarget<'_>,
subtree: &Path,
config: &ScanConfig,
) -> Result<PathBuf> {
if subtree.as_os_str().is_empty() {
return Ok(PathBuf::new());
}
let root = target.root_path()?;
crate::counters::bump(|c| c.stats += 1);
let Ok(root_metadata) = fs::symlink_metadata(&root) else {
return Ok(subtree.to_path_buf());
};
if !root_metadata.is_dir() {
return Ok(subtree.to_path_buf());
}
let Ok(root_dev) = root_device(&root, &root_metadata) else {
return Ok(subtree.to_path_buf());
};
let mut prefix = PathBuf::new();
let mut components = subtree.components().peekable();
while let Some(component) = components.next() {
if components.peek().is_none() {
break; }
prefix.push(component.as_os_str());
let (kind, attrs) = match observe_path(&root.join(&prefix)) {
Ok(observed) => observed,
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
return Ok(prefix);
}
Err(_) => break, };
if kind == EntryKind::Symlink {
return Err(Error::SubtreeOutsideScanScope {
path: subtree.to_path_buf(),
scope: config.scope(),
});
}
if kind != EntryKind::Dir {
return Ok(prefix);
}
if config.one_filesystem && root_dev != 0 && attrs.dev != 0 && attrs.dev != root_dev {
return Err(Error::SubtreeOutsideScanScope {
path: subtree.to_path_buf(),
scope: config.scope(),
});
}
}
Ok(subtree.to_path_buf())
}
pub fn observe(path: &Path, meta: &fs::Metadata) -> std::io::Result<(EntryKind, Attrs)> {
#[cfg(windows)]
{
windows_metadata::observe(path, || Ok(meta.clone()))
}
#[cfg(not(windows))]
{
Ok((kind_from(meta), attrs_from(path, meta)?))
}
}
pub(crate) fn observe_dir_entry(
entry: &fs::DirEntry,
) -> std::io::Result<Option<(EntryKind, Attrs)>> {
#[cfg(windows)]
{
crate::counters::bump(|c| c.stats += 1);
#[cfg(test)]
{
let path = entry.path();
if let Some(error) =
walk_hook(&path).and_then(|hook| hook(WalkHookPoint::ChildMetadata(&path)))
{
return missing_as_none(Err(error));
}
}
missing_as_none(windows_metadata::observe(&entry.path(), || entry.metadata()))
}
#[cfg(not(windows))]
{
#[cfg(test)]
{
let path = entry.path();
if let Some(error) = child_metadata_hook(&path) {
return missing_as_none(Err(error));
}
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
{
if !linux_dents::statx_unavailable() {
crate::counters::bump(|c| c.stats += 1);
if let Some(observed) = linux_dents::stat_path(&entry.path()) {
return missing_as_none(observed);
}
}
}
let Some(meta) = missing_as_none(metadata_for_fingerprint(entry))? else {
return Ok(None);
};
Ok(Some((kind_from(&meta), attrs_from(Path::new(""), &meta)?)))
}
}
pub(crate) fn observe_path(path: &Path) -> std::io::Result<(EntryKind, Attrs)> {
crate::counters::bump(|c| c.stats += 1);
#[cfg(all(target_os = "linux", target_env = "gnu"))]
if let Some(observed) = linux_dents::stat_path(path) {
return observed;
}
let metadata = fs::symlink_metadata(path)?;
observe(path, &metadata)
}
#[cfg(not(windows))]
fn kind_from(meta: &fs::Metadata) -> EntryKind {
let file_type = meta.file_type();
if file_type.is_symlink() {
EntryKind::Symlink
} else if file_type.is_dir() {
EntryKind::Dir
} else if file_type.is_file() {
EntryKind::File
} else {
EntryKind::Other
}
}
#[cfg(unix)]
#[allow(clippy::unnecessary_wraps)] pub(crate) fn attrs_from(_path: &Path, meta: &fs::Metadata) -> std::io::Result<Attrs> {
use std::os::unix::fs::MetadataExt;
Ok(Attrs {
size: meta.size(),
allocated: meta.blocks().saturating_mul(512),
mtime_ns: compose_ns(meta.mtime(), meta.mtime_nsec()),
ctime_ns: compose_ns(meta.ctime(), meta.ctime_nsec()),
inode: meta.ino(),
dev: meta.dev(),
})
}
#[cfg(unix)]
fn compose_ns(secs: i64, nanos: i64) -> i64 {
secs.saturating_mul(1_000_000_000).saturating_add(nanos)
}
#[cfg(all(windows, test))]
pub(crate) fn attrs_from(path: &Path, meta: &fs::Metadata) -> std::io::Result<Attrs> {
windows_metadata::observe(path, || Ok(meta.clone())).map(|(_, attrs)| attrs)
}
#[cfg(not(any(unix, windows)))]
#[allow(clippy::unnecessary_wraps)] pub(crate) fn attrs_from(_path: &Path, meta: &fs::Metadata) -> std::io::Result<Attrs> {
let mtime_ns = meta.modified().map_or(0, system_time_ns);
Ok(Attrs {
size: meta.len(),
allocated: meta.len(),
mtime_ns,
ctime_ns: 0,
inode: 0,
dev: 0,
})
}
pub(crate) fn root_device(root: &Path, meta: &fs::Metadata) -> std::io::Result<u64> {
#[cfg(windows)]
{
let _ = meta;
windows_metadata::volume_serial(root)
}
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::OpenOptionsExt as _;
let opened = fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
.open(root)
.and_then(|directory| directory.metadata());
attrs_from(root, opened.as_ref().unwrap_or(meta)).map(|attrs| attrs.dev)
}
#[cfg(not(any(windows, target_os = "linux")))]
{
attrs_from(root, meta).map(|attrs| attrs.dev)
}
}
pub(crate) fn attrs_from_file(file: &fs::File, meta: &fs::Metadata) -> std::io::Result<Attrs> {
#[cfg(windows)]
{
let _ = meta;
windows_metadata::attrs_from_file(file)
}
#[cfg(not(windows))]
{
let _ = file;
attrs_from(Path::new(""), meta)
}
}
#[cfg(any(not(any(unix, windows)), test))]
fn system_time_ns(time: std::time::SystemTime) -> i64 {
match time.duration_since(std::time::UNIX_EPOCH) {
Ok(duration) => i64::try_from(duration.as_nanos()).unwrap_or(i64::MAX),
Err(error) => {
i64::try_from(error.duration().as_nanos()).map_or(i64::MIN, i64::saturating_neg)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
fn write_file(path: &Path, contents: &[u8]) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
let mut f = File::create(path).expect("create file");
f.write_all(contents).expect("write");
}
fn sample_tree() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join("a.txt"), b"hello");
write_file(&dir.path().join("src/main.rs"), b"fn main() {}");
write_file(&dir.path().join("src/deep/nested.rs"), b"// nested");
crate::test_support::settle_allocations(dir.path());
dir
}
#[test]
fn a_walk_moves_every_counter_it_should() {
let _serial = crate::counters::test_serial();
crate::counters::enable(true);
for threads in [Some(1), Some(4)] {
let dir = sample_tree();
let config = ScanConfig { threads, ..ScanConfig::default() };
let before = crate::counters::snapshot();
let report = scan(dir.path(), &config, &mut |_| {}).expect("scan");
crate::counters::flush_thread();
let after = crate::counters::snapshot();
let observed_entries = after.dir_entries - before.dir_entries;
let observed_opens = after.dir_opens - before.dir_opens;
let observed_stats = after.stats - before.stats;
assert!(
observed_entries >= report.entries,
"every enumerated entry is counted at {threads:?}: {observed_entries} < {}",
report.entries
);
assert!(
observed_opens >= report.dirs_read,
"every directory open is counted at {threads:?}: {observed_opens} < {}",
report.dirs_read
);
assert!(
observed_stats >= report.entries,
"every entry is stated at {threads:?}: {observed_stats} < {}",
report.entries
);
#[cfg(any(target_os = "macos", all(target_os = "linux", target_env = "gnu")))]
{
let observed_enum = after.dir_enumeration_calls - before.dir_enumeration_calls;
if threads != Some(1) {
assert!(
observed_enum >= report.dirs_read,
"every successful native directory issues at least one enumeration \
call at {threads:?}: {observed_enum} < {}",
report.dirs_read
);
}
}
}
crate::counters::enable(false);
}
#[test]
fn summary_fold_skips_stat_on_directories_and_symlinks() {
let _serial = crate::counters::test_serial();
crate::counters::enable(true);
let dir = tempfile::tempdir().expect("tempdir");
fs::create_dir(dir.path().join("src")).expect("directory");
write_file(&dir.path().join("a.txt"), b"hi");
#[cfg(unix)]
std::os::unix::fs::symlink("a.txt", dir.path().join("link")).expect("symlink");
let config = ScanConfig { threads: Some(1), read_controls: false, ..ScanConfig::default() };
crate::counters::test_thread_reset();
let scan_report = scan(dir.path(), &config, &mut |_| {}).expect("scan");
let scan_stats = crate::counters::test_thread_snapshot().stats;
crate::counters::test_thread_reset();
let fold_report = scan_summary_fold(dir.path(), &config, &mut |_| {}).expect("fold");
let fold_stats = crate::counters::test_thread_snapshot().stats;
crate::counters::enable(false);
assert_eq!(fold_report.entries, scan_report.entries);
assert_eq!(fold_report.files_walked, scan_report.files_walked);
assert_eq!(fold_report.bytes_walked, scan_report.bytes_walked);
#[cfg(unix)]
assert!(
fold_stats < scan_stats,
"fold {fold_stats} should skip directory/symlink stats versus scan {scan_stats}"
);
#[cfg(not(windows))]
let proof = u64::from(first_listed(dir.path()) != "a.txt");
#[cfg(unix)]
assert_eq!(scan_stats.saturating_sub(fold_stats), 2 - proof);
#[cfg(not(any(unix, windows)))]
assert_eq!(scan_stats.saturating_sub(fold_stats), 1 - proof);
#[cfg(windows)]
assert_eq!(fold_stats, scan_stats);
}
#[cfg(not(windows))]
fn first_listed(dir: &Path) -> std::ffi::OsString {
fs::read_dir(dir).expect("listing").next().expect("an entry").expect("entry").file_name()
}
#[cfg(unix)]
#[test]
fn summary_fold_still_stats_directories_when_bound_to_one_filesystem() {
let _serial = crate::counters::test_serial();
crate::counters::enable(true);
let dir = tempfile::tempdir().expect("tempdir");
fs::create_dir(dir.path().join("src")).expect("directory");
write_file(&dir.path().join("a.txt"), b"hi");
std::os::unix::fs::symlink("a.txt", dir.path().join("link")).expect("symlink");
let config = ScanConfig {
threads: Some(1),
read_controls: false,
one_filesystem: true,
..ScanConfig::default()
};
crate::counters::test_thread_reset();
let scan_report = scan(dir.path(), &config, &mut |_| {}).expect("scan");
let scan_stats = crate::counters::test_thread_snapshot().stats;
crate::counters::test_thread_reset();
let fold_report = scan_summary_fold(dir.path(), &config, &mut |_| {}).expect("fold");
let fold_stats = crate::counters::test_thread_snapshot().stats;
crate::counters::enable(false);
assert_eq!(fold_report.entries, scan_report.entries);
let proof = u64::from(first_listed(dir.path()) == "link");
assert_eq!(scan_stats.saturating_sub(fold_stats), 1 - proof);
}
#[test]
fn summary_fold_reuses_cleared_recycled_batches() {
const DIRS: usize = 16;
const FILES_PER_DIR: usize = 40;
let dir = tempfile::tempdir().expect("tempdir");
let mut expected_bytes = 0u64;
for directory in 0..DIRS {
let child = dir.path().join(format!("d{directory:02}"));
fs::create_dir(&child).expect("directory");
for file in 0..FILES_PER_DIR {
let size = directory * FILES_PER_DIR + file + 1;
expected_bytes += size as u64;
write_file(&child.join(format!("f{file:02}.dat")), &vec![b'x'; size]);
}
}
let expected_files = (DIRS * FILES_PER_DIR) as u64;
let expected_dirs = DIRS as u64;
let expected_entries = expected_files + expected_dirs;
let config = ScanConfig {
threads: Some(4),
batch_size: 3,
read_controls: false,
..ScanConfig::default()
};
let mut files = 0u64;
let mut bytes = 0u64;
let mut dirs = 0u64;
let mut ops = 0u64;
let report = scan_summary_fold(dir.path(), &config, &mut |observed| {
ops += 1;
let Op::Upsert { kind, attrs, .. } = &observed.op else {
return;
};
match kind {
EntryKind::File => {
files += 1;
bytes += attrs.size;
}
EntryKind::Dir => dirs += 1,
EntryKind::Symlink | EntryKind::Other => {}
}
})
.expect("fold");
assert_eq!(files, expected_files);
assert_eq!(bytes, expected_bytes);
assert_eq!(dirs, expected_dirs);
assert_eq!(ops, report.entries);
assert_eq!(report.entries, expected_entries);
assert_eq!(report.files_walked, expected_files);
assert_eq!(report.bytes_walked, expected_bytes);
}
fn publish_detached(
emission: &mut DetachedEmission,
directory: DetachedDirectory,
sender: &std::sync::mpsc::Sender<WalkMessage>,
receiver: &std::sync::mpsc::Receiver<WalkMessage>,
) -> (Vec<DetachedDirectory>, std::sync::mpsc::Sender<Vec<DetachedDirectory>>) {
emission.finish_directory(directory);
let mut send_ns = 0;
assert!(emission.publish_before_discovery(true, sender, &mut send_ns, None));
match receiver.try_recv().expect("a published chunk") {
WalkMessage::DetachedDirectories { directories, recycle } => (directories, recycle),
_ => panic!("the detached walker publishes only listings"),
}
}
#[cfg(all(unix, not(target_os = "macos")))]
#[test]
fn a_folded_index_takes_directory_and_symlink_kinds_from_the_listing() {
let root = tempfile::tempdir().expect("temp root");
write_file(&root.path().join("dir/file.txt"), b"contents");
write_file(&root.path().join("dir/nested/deep.txt"), b"more");
write_file(&root.path().join("top.txt"), b"top");
std::os::unix::fs::symlink("top.txt", root.path().join("link")).expect("symlink");
let canonical = root.path().canonicalize().expect("canonical root");
let retention = crate::execution::TreeRetention {
largest_files: 100,
size: crate::query::SizeMetric::Allocated,
};
let attrs_by_kind = |index: &Index| -> Vec<(EntryKind, bool)> {
let mut seen = Vec::new();
let mut stack = vec![(PathBuf::new(), crate::EntryId::ROOT)];
while let Some((path, id)) = stack.pop() {
let Some(children) = index.children_of(id) else { continue };
for (name, child) in children {
let kind = index.kind_of(child).expect("live child");
let attrs = index.attrs_of(child).expect("live child");
seen.push((kind, *attrs == Attrs::default()));
if kind.is_dir() {
stack.push((path.join(name), child));
}
}
}
seen.sort_by_key(|(kind, defaulted)| (*kind as u8, *defaulted));
seen
};
for threads in [1, 4] {
let config = ScanConfig { threads: Some(threads), ..ScanConfig::default() };
let (folded, _, _) =
scan_into_folded_index(&canonical, &config, retention, false).expect("folded");
let (full, _) = scan_into_index(&canonical, &config).expect("full");
for (kind, defaulted) in attrs_by_kind(&folded) {
assert_eq!(
defaulted,
matches!(kind, EntryKind::Dir | EntryKind::Symlink),
"{threads} workers: a folded {kind:?} takes its kind from the listing"
);
}
assert!(
attrs_by_kind(&full).iter().all(|(_, defaulted)| !defaulted),
"{threads} workers: the full index stats every entry"
);
let bound = ScanConfig { one_filesystem: true, ..config };
let (folded, _, _) =
scan_into_folded_index(&canonical, &bound, retention, false).expect("folded");
for (kind, defaulted) in attrs_by_kind(&folded) {
assert_eq!(
defaulted,
kind == EntryKind::Symlink,
"{threads} workers, one filesystem: directories keep their device"
);
}
}
}
#[test]
fn detached_emission_reuses_returned_listings_as_fresh_ones() {
let (sender, receiver) = std::sync::mpsc::channel();
let mut emission = DetachedEmission::new(false);
let mut skipped = emission.begin_directory(Path::new("a/much/longer/relative/path"));
skipped.children.push(DetachedChild {
name: OsString::from("stale.txt"),
kind: EntryKind::File,
attrs: Attrs::default(),
position: 0,
});
skipped.control = Some(Op::ControlRemove { path: PathBuf::from("a/.gitignore") });
let (directories, recycle) = publish_detached(&mut emission, skipped, &sender, &receiver);
let returned_list = directories.as_ptr();
let returned_children = directories[0].children.as_ptr();
recycle.send(directories).expect("the worker still listens");
let mut large = emission.begin_directory(Path::new("large"));
assert_eq!(large.children.capacity(), 0);
large.children.reserve_exact(DETACHED_SPARE_CHILD_CAPACITY + 1);
let (directories, recycle) = publish_detached(&mut emission, large, &sender, &receiver);
recycle.send(directories).expect("the worker still listens");
let reused = emission.begin_directory(Path::new("b"));
assert_eq!(reused.path.as_os_str(), OsStr::new("b"));
assert!(reused.children.is_empty(), "a returned listing's children are dropped");
assert!(reused.control.is_none(), "a returned listing's control is dropped");
assert_eq!(reused.children.as_ptr(), returned_children, "the child buffer is reused");
let (directories, _recycle) = publish_detached(&mut emission, reused, &sender, &receiver);
assert_eq!(directories.as_ptr(), returned_list, "the published list is reused");
let fresh = emission.begin_directory(Path::new("c"));
assert_eq!(fresh.path.as_os_str(), OsStr::new("c"));
assert_eq!(fresh.children.capacity(), 0);
}
#[test]
fn a_classifying_summary_fold_receives_each_control_ahead_of_its_entries() {
const DIRS: usize = 12;
const FILES_PER_DIR: usize = 30;
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join(".gitignore"), b"*.log\n");
for directory in 0..DIRS {
let child = dir.path().join(format!("d{directory:02}"));
write_file(&child.join(".gitignore"), b"*.tmp\n");
for file in 0..FILES_PER_DIR {
write_file(&child.join(format!("f{file:02}.dat")), b"x");
}
write_file(&child.join("nested/n.dat"), b"n");
}
let default_batch = ScanConfig::default().batch_size;
for (threads, batch_size) in [
(Some(1), 1),
(Some(4), 1),
(Some(4), 3),
(None, 7),
(Some(4), 16),
(None, default_batch),
] {
let config = ScanConfig { batch_size, threads, ..ScanConfig::default() };
assert!(config.read_controls, "observation is the default");
let mut seen: std::collections::BTreeMap<PathBuf, (Vec<usize>, Vec<usize>)> =
std::collections::BTreeMap::new();
let mut position = 0;
scan_summary_fold(dir.path(), &config, &mut |observed| {
let (path, control) = match &observed.op {
Op::Upsert { path, .. } => (path, false),
Op::ControlUpsert { path, .. } | Op::ControlRemove { path } => (path, true),
other => panic!("a cold walk emitted {other:?}"),
};
let directory = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf();
let (controls, entries) = seen.entry(directory).or_default();
if control { controls } else { entries }.push(position);
position += 1;
})
.expect("fold");
assert_eq!(seen.len(), 1 + 2 * DIRS, "the root, each child, and each nested");
for (directory, (controls, entries)) in &seen {
let label = format!("{directory:?} with {threads:?} workers, batch {batch_size}");
let governed = directory.as_os_str().is_empty()
|| directory.file_name().is_some_and(|name| name != "nested");
assert_eq!(controls.len(), usize::from(governed), "{label}: one control read");
if let (Some(last_control), Some(first_entry)) =
(controls.iter().max(), entries.iter().min())
{
assert!(last_control < first_entry, "{label}: its control comes first");
}
}
}
}
#[test]
fn a_directory_control_is_what_a_lookup_of_its_canonical_path_resolves_to() {
use crate::test_support::CaseLookups;
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join("exact/.gitignore"), b"*.log\n");
write_file(&dir.path().join("variant/.GITIGNORE"), b"*.tmp\n");
fs::create_dir_all(dir.path().join("shape/.GitIgnore")).expect("a directory so named");
write_file(&dir.path().join("absent/README"), b"no rules");
let config = ScanConfig::default();
let upsert = |path: &str, source: &[u8]| {
Some(Op::ControlUpsert { path: PathBuf::from(path), source: source.to_vec() })
};
for (lookups, insensitive) in CaseLookups::on_this_host(dir.path()) {
let _lookups = lookups.install(dir.path());
let label = format!("{lookups:?} lookups");
let lookup = |directory: &str| {
read_directory_control(
&config,
dir.path(),
&Path::new(directory).join(".gitignore"),
)
.expect("lookup")
};
let listed = |path: &str, kind| {
let path = Path::new(path);
let read = read_control_op(&config, dir.path(), path, kind).expect("listed read");
let name = path.file_name().expect("a listed name");
assert_eq!(
read_named_control_op(&config, dir.path(), path, name, kind).expect("named"),
read,
"{label}: a walker's read by the listed name decides as the path's (H180)"
);
read
};
assert_eq!(lookup("exact"), upsert("exact/.gitignore", b"*.log\n"), "{label}");
assert_eq!(listed("exact/.gitignore", EntryKind::File), lookup("exact"), "{label}");
assert_eq!(
lookup("variant"),
if insensitive { upsert("variant/.gitignore", b"*.tmp\n") } else { None },
"{label}"
);
assert_eq!(
listed("variant/.GITIGNORE", EntryKind::File),
lookup("variant"),
"{label}: a listed variant reads what the lookup finds"
);
assert_eq!(
listed("shape/.GitIgnore", EntryKind::Dir),
insensitive.then(|| Op::ControlRemove { path: PathBuf::from("shape/.gitignore") }),
"{label}: a directory is resolved to, and holds no rules"
);
assert_eq!(lookup("absent"), None, "{label}");
assert_eq!(listed("absent/README", EntryKind::File), None, "{label}");
let blind = ScanConfig { read_controls: false, ..ScanConfig::default() };
assert_eq!(
read_control_op(
&blind,
dir.path(),
Path::new("variant/.GITIGNORE"),
EntryKind::File
)
.expect("read"),
None,
"{label}: a scan that reads no rules looks nothing up"
);
assert_eq!(
read_named_control_op(
&blind,
dir.path(),
Path::new("exact/.gitignore"),
OsStr::new(".gitignore"),
EntryKind::File
)
.expect("read"),
None,
"{label}: nor does a read by the listed name"
);
}
}
#[test]
fn a_listed_name_joins_as_path_join_does() {
for (rel_dir, name) in [
("", "file"),
("", ".gitignore"),
("a", "b"),
("a/b", ".GITIGNORE"),
("node_modules/x", "a-name-longer-than-twice-its-directory.js"),
] {
let joined = join_listed_name(Path::new(rel_dir), OsStr::new(name));
assert_eq!(
joined.as_os_str(),
Path::new(rel_dir).join(name).as_os_str(),
"{rel_dir:?} and {name:?}"
);
}
}
type Classification = (BTreeMap<PathBuf, (EntryKind, Option<bool>)>, Vec<(PathBuf, Vec<u8>)>);
fn classification(index: &Index) -> Classification {
let mut entries = BTreeMap::new();
let mut pending = vec![PathBuf::new()];
while let Some(directory) = pending.pop() {
let children: Vec<(PathBuf, crate::EntryId)> = index
.children(&directory)
.into_iter()
.flatten()
.map(|(name, id)| (directory.join(name), id))
.collect();
for (path, id) in children {
let kind = index.kind_of(id).expect("a live child");
entries.insert(path.clone(), (kind, index.is_ignored(&path).expect("observed")));
if kind.is_dir() {
pending.push(path);
}
}
}
let sources = index
.controls()
.expect("observed")
.sources()
.map(|(path, source)| (path, source.to_vec()))
.collect();
(entries, sources)
}
fn case_variant_tree() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join(".gitignore"), b"*.log\n");
write_file(&dir.path().join("a.log"), b"root rule");
write_file(&dir.path().join("keep.txt"), b"kept");
write_file(&dir.path().join("up/.GITIGNORE"), b"*.tmp\n!keep.log\nbuild/\n");
write_file(&dir.path().join("up/x.tmp"), b"variant rule");
write_file(&dir.path().join("up/keep.log"), b"negated by the variant");
write_file(&dir.path().join("up/y.log"), b"root rule");
write_file(&dir.path().join("up/build/out.bin"), b"variant rule");
write_file(&dir.path().join("up/deep/z.tmp"), b"variant rule");
for file in 0..12 {
write_file(&dir.path().join(format!("up/f{file:02}.dat")), b"unignored");
}
dir
}
fn assert_case_variant_classification(classified: &Classification, governs: bool, label: &str) {
let (entries, sources) = classified;
let ignored = |path: &str| entries.get(Path::new(path)).map(|(_, ignored)| *ignored);
assert_eq!(ignored("a.log"), Some(Some(true)), "{label}");
assert_eq!(ignored("up/y.log"), Some(Some(true)), "{label}");
assert_eq!(ignored("up/x.tmp"), Some(Some(governs)), "{label}");
assert_eq!(ignored("up/deep/z.tmp"), Some(Some(governs)), "{label}");
assert_eq!(ignored("up/build"), Some(Some(governs)), "{label}");
assert_eq!(ignored("up/build/out.bin"), Some(Some(governs)), "{label}");
assert_eq!(ignored("up/keep.log"), Some(Some(!governs)), "{label}: negation");
assert_eq!(ignored("up/f00.dat"), Some(Some(false)), "{label}");
let governing: Vec<&Path> = sources.iter().map(|(path, _)| path.as_path()).collect();
let expected: Vec<&Path> = if governs {
vec![Path::new(".gitignore"), Path::new("up/.gitignore")]
} else {
vec![Path::new(".gitignore")]
};
assert_eq!(governing, expected, "{label}: rules are recorded by the canonical path");
}
#[test]
fn every_cold_route_classifies_a_case_variant_control_alike() {
use crate::test_support::CaseLookups;
let dir = case_variant_tree();
let root = dir.path();
let pruned = Some(std::sync::Arc::new(crate::HiddenPolicy::prune_hidden(Vec::<
std::ffi::OsString,
>::new())));
for (lookups, governs) in CaseLookups::on_this_host(root) {
let _lookups = lookups.install(root);
let reference = ScanConfig { threads: Some(1), ..ScanConfig::default() };
let (index, report) = scan_into_index(root, &reference).expect("detached scan");
assert!(report.is_complete(), "{:?}", report.errors);
let expected = classification(&index);
assert_case_variant_classification(&expected, governs, &format!("{lookups:?}"));
for threads in [Some(1), Some(4)] {
for batch_size in [ScanConfig::default().batch_size, 1, 3] {
let config = ScanConfig { batch_size, threads, ..ScanConfig::default() };
let label = format!("{lookups:?}, {threads:?} workers, batch {batch_size}");
let (detached, _) = scan_into_index(root, &config).expect("detached");
assert_eq!(classification(&detached), expected, "{label}: detached");
let (streamed, _) = scan_into_index_via_scanner(root, &config).expect("stream");
assert_eq!(classification(&streamed), expected, "{label}: scanner stream");
}
}
let hidden = ScanConfig { hidden: pruned.clone(), ..ScanConfig::default() };
let (without_hidden, _) = scan_into_index(root, &hidden).expect("hidden pruned");
let (entries, sources) = classification(&without_hidden);
assert_eq!(sources, expected.1, "{lookups:?}: hidden pruning keeps the rules");
for (path, fact) in &entries {
assert_eq!(expected.0.get(path), Some(fact), "{lookups:?}: {path:?}");
}
assert!(!entries.contains_key(Path::new("up/.GITIGNORE")), "{lookups:?}");
let spelled = |path: &Path| crate::control::path_control_spelling(path).is_some();
for population in
[crate::query::IgnoredEntries::Exclude, crate::query::IgnoredEntries::Only]
{
let config = ScanConfig { population, ..ScanConfig::default() };
let (narrowed, report) = scan_into_index(root, &config).expect("narrowed");
assert!(report.is_complete(), "{:?}", report.errors);
let (entries, sources) = classification(&narrowed);
let label = format!("{lookups:?}, {population:?}");
assert_eq!(sources, expected.1, "{label}");
let files =
|entries: &BTreeMap<PathBuf, (EntryKind, Option<bool>)>| -> Vec<PathBuf> {
entries
.iter()
.filter(|(_, (kind, _))| *kind == EntryKind::File)
.map(|(path, _)| path.clone())
.collect()
};
let wanted: BTreeMap<PathBuf, (EntryKind, Option<bool>)> = expected
.0
.iter()
.filter(|(path, (_, ignored))| {
spelled(path)
|| match population {
crate::query::IgnoredEntries::Exclude => *ignored == Some(false),
_ => *ignored == Some(true),
}
})
.map(|(path, fact)| (path.clone(), *fact))
.collect();
assert_eq!(files(&entries), files(&wanted), "{label}: retained files");
}
}
}
#[test]
fn reconciliation_follows_a_case_variant_control_through_every_change() {
use crate::test_support::CaseLookups;
let probe = tempfile::tempdir().expect("tempdir");
for (lookups, governs) in CaseLookups::on_this_host(probe.path()) {
for threads in [Some(1), Some(4)] {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().canonicalize().expect("canonical root");
let _lookups = lookups.install(&root);
let config = ScanConfig { threads, batch_size: 3, ..ScanConfig::default() };
write_file(&root.join(".gitignore"), b"*.log\n");
write_file(&root.join("up/.gitignore"), b"*.tmp\n");
write_file(&root.join("up/x.tmp"), b"governed");
write_file(&root.join("up/y.bin"), b"governed after the edit");
for file in 0..8 {
write_file(&root.join(format!("up/f{file}.dat")), b"unignored");
}
let (mut index, _) = scan_into_index(&root, &config).expect("cold scan");
let cold = |label: &str| {
let (cold, report) = scan_into_index(&root, &config).expect("cold");
assert!(report.is_complete(), "{label}: {:?}", report.errors);
classification(&cold)
};
let reconciled = |index: &mut Index, label: &str| {
let report = reconcile(index, &config, &mut |_| {}).expect("reconcile");
assert!(report.is_complete(), "{label}: {:?}", report.scan.errors);
assert_eq!(report.apply.stale, 0, "{label}: no observation lost a race");
};
let revalidated = |snapshot: &Index, label: &str| {
let mut revalidated = snapshot.clone();
let mut observations = Vec::new();
revalidate(&revalidated, &config, &mut |observation| {
observations.push(observation);
})
.expect("revalidate");
for observation in &observations {
let outcome = revalidated.apply(observation).expect("apply");
assert_eq!(outcome.stats.stale, 0, "{label}: revalidation lost a race");
}
classification(&revalidated)
};
let governed = |label: &str| {
let (entries, _) = cold(label);
entries.get(Path::new("up/x.tmp")).map(|(_, ignored)| *ignored)
};
let before_rename = index.clone();
fs::rename(root.join("up/.gitignore"), root.join("up/.GITIGNORE")).expect("recase");
let label = format!("{lookups:?}, {threads:?} workers: case-only rename");
assert_eq!(governed(&label), Some(Some(governs)), "{label}");
assert_eq!(
revalidated(&before_rename, &label),
cold(&label),
"{label}: revalidate"
);
reconciled(&mut index, &label);
assert_eq!(classification(&index), cold(&label), "{label}");
write_file(&root.join("up/.GITIGNORE"), b"*.bin\n");
let label = format!("{lookups:?}, {threads:?} workers: edit");
reconciled(&mut index, &label);
assert_eq!(classification(&index), cold(&label), "{label}");
fs::remove_file(root.join("up/.GITIGNORE")).expect("remove the variant");
let label = format!("{lookups:?}, {threads:?} workers: removal");
reconciled(&mut index, &label);
assert_eq!(classification(&index), cold(&label), "{label}");
write_file(&root.join("up/.GITIGNORE"), b"*.tmp\n");
for (step, label) in [
(None, format!("{lookups:?}, {threads:?} workers: refresh of a new variant")),
(
Some(()),
format!("{lookups:?}, {threads:?} workers: refresh of a removed variant"),
),
] {
if step.is_some() {
fs::remove_file(root.join("up/.GITIGNORE")).expect("remove");
}
let report = reconcile_subtree(
&mut index,
Path::new("up/.GITIGNORE"),
&config,
&mut |_| {},
)
.expect("refresh");
assert!(report.is_complete(), "{label}: {:?}", report.scan.errors);
assert_eq!(classification(&index), cold(&label), "{label}");
}
}
}
}
fn revalidated_copy(index: &Index, config: &ScanConfig, label: &str) -> Index {
let mut revalidated = index.clone();
let mut observations = Vec::new();
let report = revalidate(&revalidated, config, &mut |observation| {
observations.push(observation);
})
.expect("revalidate");
assert!(report.is_complete(), "{label}: {:?}", report.errors);
for observation in &observations {
let outcome = revalidated.apply(observation).expect("apply");
assert_eq!(outcome.stats.stale, 0, "{label}: revalidation lost a race");
}
revalidated
}
#[test]
fn a_narrowed_reconciliation_follows_a_case_only_rename() {
use crate::test_support::CaseLookups;
let probe = tempfile::tempdir().expect("tempdir");
for (lookups, governs) in CaseLookups::on_this_host(probe.path()) {
for population in
[crate::query::IgnoredEntries::Exclude, crate::query::IgnoredEntries::Only]
{
let label = format!("{lookups:?}, {population:?}");
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().canonicalize().expect("canonical root");
let _lookups = lookups.install(&root);
let config = ScanConfig { population, batch_size: 3, ..ScanConfig::default() };
write_file(&root.join("up/.gitignore"), b"*.tmp\n");
write_file(&root.join("up/x.tmp"), b"governed");
for file in 0..4 {
write_file(&root.join(format!("up/f{file}.dat")), b"unignored");
}
let (mut index, _) = scan_into_index(&root, &config).expect("cold scan");
fs::rename(root.join("up/.gitignore"), root.join("up/.GITIGNORE")).expect("recase");
let (cold, report) = scan_into_index(&root, &config).expect("cold");
assert!(report.is_complete(), "{label}: {:?}", report.errors);
let cold = classification(&cold);
assert_eq!(
cold.1.iter().any(|(path, _)| path == Path::new("up/.gitignore")),
governs,
"{label}: the variant governs exactly where the lookup resolves to it"
);
let revalidated = revalidated_copy(&index, &config, &label);
assert_eq!(classification(&revalidated), cold, "{label}: revalidate");
let report = reconcile(&mut index, &config, &mut |_| {}).expect("reconcile");
assert!(report.is_complete(), "{label}: {:?}", report.scan.errors);
assert_eq!(report.apply.stale, 0, "{label}: no observation lost a race");
assert_eq!(classification(&index), cold, "{label}: reconcile");
}
}
}
#[test]
fn a_control_deleted_between_its_lookup_and_the_listing_leaves_no_rules() {
for population in
[crate::query::IgnoredEntries::Exclude, crate::query::IgnoredEntries::Only]
{
for revalidating in [true, false] {
let label = format!("{population:?}, revalidating: {revalidating}");
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().canonicalize().expect("canonical root");
let config = ScanConfig { population, ..ScanConfig::default() };
write_file(&root.join("up/.gitignore"), b"*.tmp\n");
write_file(&root.join("up/x.tmp"), b"governed");
write_file(&root.join("up/y.dat"), b"unignored");
let control_path = Path::new("up/.gitignore");
let (mut index, _) = scan_into_index(&root, &config).expect("cold scan");
assert!(index.control_table().contains(control_path), "{label}");
let control = root.join(control_path);
let deleted = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let hook_deleted = std::sync::Arc::clone(&deleted);
let race = install_walk_hook(&root, move |point| {
if let WalkHookPoint::ControlLookup(looked_up) = point {
if looked_up == control && fs::remove_file(looked_up).is_ok() {
hook_deleted.store(true, std::sync::atomic::Ordering::SeqCst);
}
}
None
});
if revalidating {
index = revalidated_copy(&index, &config, &label);
} else {
let report = reconcile(&mut index, &config, &mut |_| {}).expect("reconcile");
assert!(report.is_complete(), "{label}: {:?}", report.scan.errors);
}
drop(race);
assert!(
deleted.load(std::sync::atomic::Ordering::SeqCst),
"{label}: the lookup found the control before it was deleted"
);
assert_eq!(index.path_state(control_path), PathState::Absent, "{label}");
assert!(
!index.control_table().contains(control_path),
"{label}: no rules remain for a file that is gone"
);
let report = reconcile(&mut index, &config, &mut |_| {}).expect("reconcile");
assert!(report.is_complete(), "{label}: {:?}", report.scan.errors);
let (cold, _) = scan_into_index(&root, &config).expect("cold");
assert_eq!(classification(&index), classification(&cold), "{label}");
}
}
}
#[test]
fn a_listing_restates_looked_up_rules_only_after_showing_a_case_variant() {
let found =
Op::ControlUpsert { path: PathBuf::from("up/.gitignore"), source: b"*.tmp\n".to_vec() };
let exact = OsStr::new(".gitignore");
let mut deleted = ListedControl::default();
deleted.lookup(&Ok(Some(found.clone())));
deleted.listed(OsStr::new("x.tmp"));
assert_eq!(deleted.restatement_after_removing(exact), None, "the exact file is gone");
let mut recased = ListedControl::default();
recased.lookup(&Ok(Some(found.clone())));
recased.listed(OsStr::new(".GITIGNORE"));
assert_eq!(recased.restatement_after_removing(exact), Some(found.clone()));
assert_eq!(
recased.restatement_after_removing(OsStr::new(".GitIgnore")),
None,
"only removing the canonical entry drops rules"
);
assert_eq!(recased.restatement_after_removing(OsStr::new("x.tmp")), None);
let mut read = ListedControl::default();
read.listed(OsStr::new(".GitIgnore"));
read.read(OsStr::new(".GitIgnore"), &Ok(Some(found.clone())));
assert_eq!(read.restatement_after_removing(exact), Some(found));
let mut missed = ListedControl::default();
missed.lookup(&Ok(None));
missed.listed(OsStr::new(".GITIGNORE"));
assert_eq!(missed.restatement_after_removing(exact), None, "nothing was found");
}
#[test]
fn a_case_sensitive_directory_listing_both_spellings_takes_only_the_exact_name() {
let dir = tempfile::tempdir().expect("tempdir");
let root = dir.path().canonicalize().expect("canonical root");
write_file(&root.join(".gitignore"), b"*.log\n");
write_file(&root.join(".GITIGNORE"), b"*.tmp\n");
let listed = fs::read_dir(&root).expect("list").count();
if listed != 2 {
eprintln!(
"skipped: the temporary directory is case-insensitive, so it cannot hold both \
spellings"
);
return;
}
write_file(&root.join("x.log"), b"exact rule");
write_file(&root.join("y.tmp"), b"variant, not a rule here");
let ignored =
|index: &Index, path: &str| index.is_ignored(Path::new(path)).expect("observed");
for threads in [Some(1), Some(4)] {
let config = ScanConfig { threads, batch_size: 1, ..ScanConfig::default() };
let (detached, _) = scan_into_index(&root, &config).expect("detached");
let (streamed, _) = scan_into_index_via_scanner(&root, &config).expect("stream");
for index in [&detached, &streamed] {
assert_eq!(ignored(index, "x.log"), Some(true), "{threads:?}");
assert_eq!(ignored(index, "y.tmp"), Some(false), "{threads:?}");
assert_eq!(classification(index), classification(&detached), "{threads:?}");
}
}
let config = ScanConfig::default();
let (mut index, _) = scan_into_index(&root, &config).expect("cold");
fs::remove_file(root.join(".GITIGNORE")).expect("remove the variant");
reconcile(&mut index, &config, &mut |_| {}).expect("reconcile");
assert_eq!(ignored(&index, "x.log"), Some(true), "the exact name still governs");
write_file(&root.join(".GITIGNORE"), b"*.tmp\n");
fs::remove_file(root.join(".gitignore")).expect("remove the exact name");
reconcile(&mut index, &config, &mut |_| {}).expect("reconcile");
assert_eq!(ignored(&index, "x.log"), Some(false), "no rules remain");
assert_eq!(ignored(&index, "y.tmp"), Some(false), "the variant never governs here");
let (cold, _) = scan_into_index(&root, &config).expect("cold");
assert_eq!(classification(&index), classification(&cold));
}
#[test]
#[cfg(unix)]
fn a_probed_listing_prepares_its_control_entry_without_reading_it() {
use std::os::unix::fs::PermissionsExt;
if !crate::test_support::require_permission_bits() {
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let control = dir.path().join(".gitignore");
write_file(&control, b"*.log\n");
fs::set_permissions(&control, fs::Permissions::from_mode(0o000)).expect("deny");
let config = ScanConfig::default();
let prepare = |read_control| {
prepare_walk_entry_reading(
dir.path(),
Path::new(""),
0,
OsStr::new(".gitignore"),
EntryKind::File,
Attrs::default(),
0,
&config,
read_control,
)
.expect("admitted")
};
let read = prepare(true);
let skipped = prepare(false);
fs::set_permissions(&control, fs::Permissions::from_mode(0o600)).expect("restore");
assert!(read.control.is_none() && read.control_error.is_some(), "the read was made");
assert!(skipped.control.is_none() && skipped.control_error.is_none(), "no read");
assert!(skipped.retained, "the entry is still a row");
}
#[test]
fn a_short_automatic_walk_records_an_undecided_policy() {
let _serial = crate::counters::test_serial();
let available = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
if automatic_worker_pool(available).calibration.is_none() {
return;
}
crate::counters::enable(true);
let dir = sample_tree();
let config = ScanConfig { threads: None, ..ScanConfig::default() };
let before = crate::counters::snapshot();
scan(dir.path(), &config, &mut |_| {}).expect("scan");
crate::counters::flush_thread();
let after = crate::counters::snapshot();
crate::counters::enable(false);
assert!(
after.adaptive_policy_undecided > before.adaptive_policy_undecided,
"a three-file tree cannot fill a {ADAPTIVE_SCAN_CALIBRATION_ENTRIES}-entry window"
);
}
#[test]
fn diagnostics_make_a_fixed_pool_and_backend_choice_explicit() {
let dir = sample_tree();
let config = ScanConfig { threads: Some(1), ..ScanConfig::default() };
let (report, diagnostics) =
scan_with_diagnostics(dir.path(), &config, &mut |_| {}).expect("diagnostic scan");
assert_eq!(diagnostics.schema, SCAN_DIAGNOSTICS_SCHEMA);
assert_eq!(diagnostics.worker_policy.outcome, WorkerPolicyOutcome::Fixed);
assert_eq!(diagnostics.worker_policy.initial_workers, 1);
assert_eq!(diagnostics.worker_policy.maximum_workers, 1);
assert_eq!(diagnostics.worker_policy.peak_active_workers, 1);
assert!(diagnostics.worker_policy.windows.is_empty());
assert!(!diagnostics.worker_policy.events_truncated);
assert_eq!(diagnostics.worker_policy.ready_directories_at_finish, 0);
assert_eq!(diagnostics.worker_policy.in_flight_directories_at_finish, 0);
#[cfg(all(target_os = "linux", target_env = "gnu"))]
{
let backend = &diagnostics.backend;
assert_eq!(backend.portable_attempts, backend.portable_directory_reads);
assert!(
backend.portable_directory_reads < report.dirs_read,
"native listings are not portable reads: {backend:?}, {} read",
report.dirs_read
);
let (Some(attempts), Some(successes), Some(fallbacks)) = (
backend.linux_dents_attempts,
backend.linux_dents_successes,
backend.linux_dents_fallbacks,
) else {
panic!("Linux native counts are present: {backend:?}");
};
assert_eq!(attempts, successes + fallbacks);
assert!(successes > 0, "the serial walk lists natively: {backend:?}");
assert_eq!(successes + backend.portable_directory_reads, report.dirs_read);
}
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
assert_eq!(diagnostics.backend.portable_directory_reads, report.dirs_read);
#[cfg(target_os = "macos")]
{
assert_eq!(diagnostics.backend.macos_bulk_attempts, Some(0));
assert_eq!(diagnostics.backend.macos_bulk_successes, Some(0));
assert_eq!(diagnostics.backend.macos_bulk_fallbacks, Some(0));
assert!(diagnostics.backend.unavailable_reason.is_none());
}
#[cfg(not(target_os = "macos"))]
{
assert_eq!(diagnostics.backend.macos_bulk_attempts, None);
assert_eq!(diagnostics.backend.macos_bulk_successes, None);
assert_eq!(diagnostics.backend.macos_bulk_fallbacks, None);
assert_eq!(
diagnostics.backend.unavailable_reason,
Some("macOS bulk directory enumeration is unavailable on this platform")
);
}
#[cfg(all(target_os = "linux", target_env = "gnu"))]
{
let config = ScanConfig { threads: Some(4), ..ScanConfig::default() };
let (report, diagnostics) =
scan_with_diagnostics(dir.path(), &config, &mut |_| {}).expect("diagnostic scan");
let backend = &diagnostics.backend;
assert!(report.is_complete(), "{:?}", report.errors);
assert_eq!(backend.portable_attempts, backend.portable_directory_reads);
assert!(
backend.portable_directory_reads < report.dirs_read,
"native listings are not portable reads: {backend:?}, {} read",
report.dirs_read
);
assert_eq!(
backend.unavailable_reason,
Some("macOS bulk directory enumeration is unavailable on this platform")
);
let (Some(attempts), Some(successes), Some(fallbacks)) = (
backend.linux_dents_attempts,
backend.linux_dents_successes,
backend.linux_dents_fallbacks,
) else {
panic!("Linux native counts are present: {backend:?}");
};
assert_eq!(attempts, successes + fallbacks);
assert!(successes > 0, "a parallel walk lists natively: {backend:?}");
assert_eq!(successes + backend.portable_directory_reads, report.dirs_read);
}
#[cfg(not(all(target_os = "linux", target_env = "gnu")))]
{
assert_eq!(diagnostics.backend.linux_dents_attempts, None);
assert_eq!(diagnostics.backend.linux_dents_successes, None);
assert_eq!(diagnostics.backend.linux_dents_fallbacks, None);
}
}
#[test]
fn diagnostics_fail_closed_when_an_automatic_window_is_incomplete() {
let available = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get);
let pool = automatic_worker_pool(available);
if pool.calibration.is_none() {
return;
}
let dir = sample_tree();
let config = ScanConfig { threads: None, ..ScanConfig::default() };
let (report, diagnostics) =
scan_with_diagnostics(dir.path(), &config, &mut |_| {}).expect("diagnostic scan");
assert_eq!(diagnostics.worker_policy.outcome, WorkerPolicyOutcome::Undecided);
assert_eq!(diagnostics.worker_policy.available_parallelism, available);
assert_eq!(diagnostics.worker_policy.initial_workers, pool.initial);
assert_eq!(diagnostics.worker_policy.maximum_workers, pool.maximum);
assert_eq!(
diagnostics.worker_policy.calibration_window_entries,
Some(ADAPTIVE_SCAN_CALIBRATION_ENTRIES)
);
assert_eq!(
diagnostics.worker_policy.slow_threshold_ns_per_entry,
Some(ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY)
);
assert_eq!(diagnostics.worker_policy.windows.len(), 1);
let window = &diagnostics.worker_policy.windows[0];
assert_eq!(window.sequence, 0);
assert_eq!(window.start_entry_ordinal, 0);
assert_eq!(window.end_entry_ordinal, report.entries);
assert_eq!(window.observed_entries, report.entries);
assert_eq!(window.decision, WorkerPolicyDecision::Undecided);
assert!(window.end_entry_ordinal < ADAPTIVE_SCAN_CALIBRATION_ENTRIES);
assert!(window.active_workers <= diagnostics.worker_policy.peak_active_workers);
assert_eq!(diagnostics.worker_policy.ready_directories_at_finish, 0);
assert_eq!(diagnostics.worker_policy.in_flight_directories_at_finish, 0);
assert!(diagnostics.worker_policy.handoff_backlog_high_water >= 1);
}
#[test]
fn diagnostic_trace_is_bounded_and_marks_truncation() {
let recorder = ScanDiagnosticsRecorder::new(
WorkerPool::fixed(2),
2,
WorkerPolicyExperiment::ShippedOneShot,
);
for sequence in 0..=MAX_POLICY_TRACE_EVENTS {
recorder.record_policy_window(PolicyWindowSnapshot {
sequence: sequence as u64,
start_entry_ordinal: sequence as u64,
end_entry_ordinal: sequence as u64 + 1,
observed_entries: 1,
observed_chunks: 1,
observed_work_ns: 10,
ready_directories: 1,
in_flight_directories: 1,
active_workers: 1,
handoff_backlog: 0,
requested_workers: None,
decision: WorkerPolicyDecision::Hold,
});
}
let diagnostics = recorder.finish();
assert_eq!(diagnostics.worker_policy.windows.len(), MAX_POLICY_TRACE_EVENTS);
assert!(diagnostics.worker_policy.events_truncated);
}
#[test]
fn diagnostic_trace_preserves_queue_order_when_recorders_arrive_out_of_order() {
let pool =
WorkerPool { initial: 2, maximum: 4, calibration: Some(WorkerCalibration::new(1, 1)) };
let recorder =
ScanDiagnosticsRecorder::new(pool, 2, WorkerPolicyExperiment::RepeatedWindows);
let snapshot = |sequence, decision| PolicyWindowSnapshot {
sequence,
start_entry_ordinal: sequence,
end_entry_ordinal: sequence + 1,
observed_entries: 1,
observed_chunks: 1,
observed_work_ns: 1,
ready_directories: 0,
in_flight_directories: 0,
active_workers: 1,
handoff_backlog: 0,
requested_workers: None,
decision,
};
recorder.record_policy_window(snapshot(1, WorkerPolicyDecision::HoldNoUsefulWork));
recorder.record_policy_window(snapshot(0, WorkerPolicyDecision::Hold));
let diagnostics = recorder.finish();
assert_eq!(
diagnostics
.worker_policy
.windows
.iter()
.map(|window| window.sequence)
.collect::<Vec<_>>(),
vec![0, 1]
);
assert_eq!(diagnostics.worker_policy.outcome, WorkerPolicyOutcome::HeldNoUsefulWork);
}
#[test]
fn diagnostic_policy_aggregates_cross_check_runtime_counters() {
let _serial = crate::counters::test_serial();
let pool = WorkerPool {
initial: 2,
maximum: 4,
calibration: Some(WorkerCalibration::new(17, 100)),
};
let recorder =
ScanDiagnosticsRecorder::new(pool, 2, WorkerPolicyExperiment::ShippedOneShot);
crate::counters::enable(true);
let before = crate::counters::snapshot();
record_adaptive_calibration_chunk(Some(&recorder), 17, 2_100);
record_adaptive_worker_expansion(Some(&recorder));
crate::counters::flush_thread();
let after = crate::counters::snapshot();
crate::counters::enable(false);
recorder.record_policy_window(PolicyWindowSnapshot {
sequence: 0,
start_entry_ordinal: 0,
end_entry_ordinal: 17,
observed_entries: 17,
observed_chunks: 1,
observed_work_ns: 2_100,
ready_directories: 2,
in_flight_directories: 2,
active_workers: 2,
handoff_backlog: 0,
requested_workers: Some(4),
decision: WorkerPolicyDecision::ScaleUp,
});
let diagnostics = recorder.finish();
let policy = diagnostics.worker_policy;
assert_eq!(policy.calibration_chunks, 1);
assert_eq!(policy.calibration_entries, 17);
assert_eq!(policy.calibration_work_ns, 2_100);
assert_eq!(policy.worker_expansions, 1);
assert_eq!(policy.windows[0].observed_chunks, policy.calibration_chunks);
assert_eq!(policy.windows[0].observed_entries, policy.calibration_entries);
assert_eq!(policy.windows[0].observed_work_ns, policy.calibration_work_ns);
assert!(
after.adaptive_calibration_chunks - before.adaptive_calibration_chunks
>= policy.calibration_chunks
);
assert!(
after.adaptive_calibration_entries - before.adaptive_calibration_entries
>= policy.calibration_entries
);
assert!(
after.adaptive_calibration_work_us - before.adaptive_calibration_work_us
>= policy.calibration_work_ns / 1_000
);
assert!(after.adaptive_scale_ups - before.adaptive_scale_ups >= policy.worker_expansions);
}
#[test]
fn diagnostic_index_scan_preserves_the_regular_result() {
let dir = branching_tree();
let config = ScanConfig { threads: Some(4), ..ScanConfig::default() };
let (plain, plain_report) = scan_into_index(dir.path(), &config).expect("plain scan");
let (diagnostic, diagnostic_report, diagnostics) =
scan_into_index_with_diagnostics(dir.path(), &config).expect("diagnostic scan");
assert_eq!(index_fingerprint(&plain), index_fingerprint(&diagnostic));
assert_eq!(plain_report.entries, diagnostic_report.entries);
assert_eq!(diagnostics.worker_policy.outcome, WorkerPolicyOutcome::Fixed);
}
#[test]
fn detached_bootstrap_matches_the_streaming_reducer_for_each_worker_count() {
let dir = branching_tree();
for threads in 1..=4 {
let config = ScanConfig {
read_controls: false,
threads: Some(threads),
..ScanConfig::default()
};
let _ = detached_and_streaming_indexes(dir.path(), &config);
}
}
#[test]
fn detached_control_bootstrap_matches_the_streaming_reducer_for_each_worker_count() {
let dir = controlled_branching_tree();
for threads in 1..=4 {
let config =
ScanConfig { read_controls: true, threads: Some(threads), ..ScanConfig::default() };
let _ = detached_and_streaming_indexes(dir.path(), &config);
}
}
#[test]
fn detached_bootstrap_preserves_the_exact_first_mutation() {
let dir = branching_tree();
let config = ScanConfig { read_controls: false, threads: Some(4), ..ScanConfig::default() };
let (mut detached, mut streaming) = detached_and_streaming_indexes(dir.path(), &config);
let created = dir.path().join("t3/m2/after-bootstrap.rs");
write_file(&created, b"new fact");
let attrs =
attrs_from(&created, &fs::symlink_metadata(&created).expect("new file metadata"))
.expect("observe new file");
let observation = Observation::new(vec![Op::Upsert {
path: PathBuf::from("t3/m2/after-bootstrap.rs"),
kind: EntryKind::File,
attrs,
}]);
let detached_outcome = detached.apply(&observation).expect("detached mutation");
let streaming_outcome = streaming.apply(&observation).expect("streaming mutation");
assert_eq!(detached_outcome, streaming_outcome);
assert_indexes_equal(&detached, &streaming);
}
#[test]
fn detached_control_bootstrap_preserves_the_exact_first_mutation() {
let dir = controlled_branching_tree();
let config = ScanConfig { read_controls: true, threads: Some(4), ..ScanConfig::default() };
let (mut detached, mut streaming) = detached_and_streaming_indexes(dir.path(), &config);
let observation = Observation::new(vec![Op::ControlUpsert {
path: PathBuf::from(".gitignore"),
source: b"leaf-2.dat\n".to_vec(),
}]);
let detached_outcome = detached.apply(&observation).expect("detached control mutation");
let streaming_outcome = streaming.apply(&observation).expect("streaming control mutation");
assert_eq!(detached_outcome, streaming_outcome);
assert_indexes_equal(&detached, &streaming);
}
fn observed_coverage(index: &Index) -> crate::control::ControlObservation {
match index.control_coverage() {
crate::control::ControlCoverage::Observed(observation) => observation,
crate::control::ControlCoverage::NotObserved => panic!("controls were observed"),
}
}
#[test]
fn both_bootstrap_lanes_refuse_over_bound_controls_without_ending_the_scan() {
let dir = tempfile::tempdir().expect("tempdir");
let mut long_line = b"*.log\n".to_vec();
long_line.extend(std::iter::repeat_n(b'x', crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1));
write_file(&dir.path().join("guarded/.gitignore"), &long_line);
write_file(&dir.path().join("guarded/kept.log"), b"guarded");
write_file(
&dir.path().join("huge/.gitignore"),
&b"x\n".repeat(crate::control::DEFAULT_CONTROL_BUDGET / 2),
);
write_file(&dir.path().join("applied/.gitignore"), b"*.log\n");
write_file(&dir.path().join("applied/dropped.log"), b"applied");
let config = ScanConfig { read_controls: true, threads: Some(4), ..ScanConfig::default() };
let (detached, _) = detached_and_streaming_indexes(dir.path(), &config);
let (_, report) = scan_into_index(dir.path(), &config).expect("scan");
assert!(report.is_complete(), "{:?}", report.errors);
let coverage = observed_coverage(&detached);
assert_eq!((coverage.applied, coverage.refused), (1, 2));
assert_eq!(
coverage.refusals,
vec![
crate::control::RefusedControl {
path: PathBuf::from("guarded/.gitignore"),
reason: crate::control::ControlRefusalReason::LineLimit,
},
crate::control::RefusedControl {
path: PathBuf::from("huge/.gitignore"),
reason: crate::control::ControlRefusalReason::Budget,
},
]
);
assert_eq!(detached.is_ignored(Path::new("guarded/kept.log")).expect("observed"), None);
assert_eq!(
detached.is_ignored(Path::new("applied/dropped.log")).expect("observed"),
Some(true)
);
}
#[test]
fn control_counters_attribute_reads_refusals_and_sharing() {
let _serial = crate::counters::test_serial();
let dir = tempfile::tempdir().expect("tempdir");
let shared = b"*.log\n".to_vec();
write_file(&dir.path().join(".gitignore"), &shared);
write_file(&dir.path().join("twin/.gitignore"), &shared);
let mut long_line = b"*.tmp\n".to_vec();
long_line.extend(std::iter::repeat_n(b'x', crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1));
write_file(&dir.path().join("guarded/.gitignore"), &long_line);
let config = ScanConfig { read_controls: true, threads: Some(1), ..ScanConfig::default() };
crate::counters::enable(true);
let before = crate::counters::snapshot();
let (index, report) = scan_into_index(dir.path(), &config).expect("scan");
crate::counters::flush_thread();
let after = crate::counters::snapshot();
crate::counters::enable(false);
assert!(report.is_complete(), "{:?}", report.errors);
assert_eq!(observed_coverage(&index).refused, 1);
for (label, observed, expected) in [
("one read per .gitignore", after.control_reads - before.control_reads, 3),
("the line over the limit", after.control_refused - before.control_refused, 1),
(
"the twin shares one parsed content",
after.control_sources_shared - before.control_sources_shared,
1,
),
] {
assert!(observed >= expected, "{label}: counted {observed}, expected {expected}");
}
}
#[test]
fn each_control_limit_is_scope_and_lifts_only_its_own_refusals() {
use crate::control::{ControlLimits, ControlRefusalReason, RefusedControl};
let with = |limits| ScanConfig { control_limits: limits, ..ScanConfig::default() };
let defaults = ControlLimits::default();
let default = ScanConfig::default();
let no_budget = with(ControlLimits { budget: None, ..defaults });
let no_line_limit = with(ControlLimits { line_limit: None, ..defaults });
let configs = [
default.clone(),
with(ControlLimits { budget: Some(16 * 1024 * 1024), ..defaults }),
no_budget.clone(),
with(ControlLimits { line_limit: Some(64 * 1024), ..defaults }),
no_line_limit.clone(),
with(ControlLimits { budget: None, line_limit: None }),
with(ControlLimits { budget: defaults.line_limit, line_limit: defaults.budget }),
];
let scopes: Vec<ScanScope> = configs.iter().map(ScanConfig::scope).collect();
for (index, scope) in scopes.iter().enumerate() {
assert!(scope.observes_controls());
assert!(scopes[index + 1..].iter().all(|other| other != scope), "{scopes:?}");
}
for config in &configs {
let blind = ScanConfig { read_controls: false, ..config.clone() };
assert_eq!(blind.scope().ignore_rules_fingerprint, 0, "unobserved has one scope");
}
let dir = tempfile::tempdir().expect("tempdir");
let mut long_line = b"*.log\n".to_vec();
long_line.extend(std::iter::repeat_n(b'x', crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1));
write_file(&dir.path().join("guarded/.gitignore"), &long_line);
write_file(&dir.path().join("guarded/dropped.log"), b"log");
write_file(
&dir.path().join("huge/.gitignore"),
&b"x\n".repeat(crate::control::DEFAULT_CONTROL_BUDGET / 2),
);
let refused = |path: &str, reason| RefusedControl { path: PathBuf::from(path), reason };
let (bounded, _) = scan_into_index(dir.path(), &default).expect("default scan");
assert_eq!(observed_coverage(&bounded).refused, 2);
let (budget_lifted, _) = detached_and_streaming_indexes(dir.path(), &no_budget);
let coverage = observed_coverage(&budget_lifted);
assert_eq!(coverage.limits, no_budget.control_limits);
assert_eq!(
coverage.refusals,
[refused("guarded/.gitignore", ControlRefusalReason::LineLimit)]
);
assert_eq!(
budget_lifted.is_ignored(Path::new("guarded/dropped.log")).expect("observed"),
None
);
let (line_limit_lifted, _) = detached_and_streaming_indexes(dir.path(), &no_line_limit);
let coverage = observed_coverage(&line_limit_lifted);
assert_eq!(coverage.refusals, [refused("huge/.gitignore", ControlRefusalReason::Budget)]);
assert_eq!(
line_limit_lifted.is_ignored(Path::new("guarded/dropped.log")).expect("observed"),
Some(true)
);
assert_eq!(line_limit_lifted.scope(), no_line_limit.scope());
}
#[test]
fn a_tree_past_both_control_bounds_completes_with_exact_sizes() {
const DIRECTORIES: usize = 1_105;
let dir = tempfile::tempdir().expect("tempdir");
for directory in 0..DIRECTORIES {
let mut source = Vec::new();
for line in 0..63 {
source.extend(format!("p{directory:04}{line:02}\n").bytes());
}
source.extend(format!("q{directory:04}\n").bytes());
assert_eq!(source.len(), 510);
let root = dir.path().join(format!("d{directory:04}"));
write_file(&root.join(".gitignore"), &source);
write_file(&root.join("file.txt"), b"contents");
}
write_file(
&dir.path().join("a-guard/.gitignore"),
&vec![b'x'; crate::control::DEFAULT_CONTROL_LINE_LIMIT + 1],
);
crate::test_support::settle_allocations(dir.path());
let observing =
ScanConfig { read_controls: true, threads: Some(4), ..ScanConfig::default() };
let blind = ScanConfig { read_controls: false, ..observing.clone() };
let (unobserved, _) = scan_into_index(dir.path(), &blind).expect("controls-off scan");
let canonical = dir.path().canonicalize().expect("canonical root");
let lanes = [
scan_into_index(dir.path(), &observing).expect("detached scan"),
scan_into_index_via_scanner(&canonical, &observing).expect("streaming scan"),
];
for (index, report) in &lanes {
assert!(report.is_complete(), "{:?}", report.errors);
assert_eq!(index.total(), unobserved.total(), "sizes do not depend on controls");
let coverage = observed_coverage(index);
assert!(coverage.refused > 1, "the budget refused sources: {coverage:?}");
assert_eq!(
coverage.applied + coverage.refused,
u64::try_from(DIRECTORIES + 1).expect("small")
);
assert_eq!(coverage.refusals.len(), crate::MAX_RETAINED_ISSUES);
assert!(!coverage.lists_every_refusal());
assert_eq!(
coverage.refusals[0],
crate::control::RefusedControl {
path: PathBuf::from("a-guard/.gitignore"),
reason: crate::control::ControlRefusalReason::LineLimit,
}
);
assert!(
index.control_table().retained_cost() <= crate::control::DEFAULT_CONTROL_BUDGET
);
}
}
#[test]
fn fingerprint_metadata_observes_mutation_after_directory_enumeration() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("changing.bin");
write_file(&path, b"before");
let entry = fs::read_dir(dir.path())
.expect("read directory")
.next()
.expect("one entry")
.expect("read entry");
write_file(&path, b"after mutation");
let metadata = metadata_for_fingerprint(&entry).expect("fresh metadata");
assert_eq!(metadata.len(), b"after mutation".len() as u64);
}
fn branching_tree() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for top in 0..12 {
for middle in 0..6 {
for leaf in 0..7 {
write_file(
&dir.path().join(format!("t{top}/m{middle}/leaf-{leaf}.dat")),
&vec![b'x'; leaf * 13],
);
}
}
write_file(&dir.path().join(format!("t{top}/a/b/c/d/e/deep.txt")), b"deep");
}
crate::test_support::settle_allocations(dir.path());
dir
}
fn controlled_branching_tree() -> tempfile::TempDir {
let dir = branching_tree();
write_file(&dir.path().join(".gitignore"), b"leaf-1.dat\nt7/\n");
write_file(&dir.path().join("t3/.gitignore"), b"!m2/leaf-1.dat\n*.tmp\n");
write_file(&dir.path().join("t3/m2/generated.tmp"), b"ignored by nested control");
write_file(&dir.path().join("t7/.gitignore"), b"!m0/leaf-1.dat\n");
fs::create_dir_all(dir.path().join("t5/.gitignore")).expect("non-file control directory");
write_file(&dir.path().join("t5/.gitignore/ordinary.txt"), b"ordinary child");
crate::test_support::settle_allocations(dir.path());
dir
}
fn index_fingerprint(index: &Index) -> Vec<(PathBuf, EntryKind, Attrs)> {
let mut entries: Vec<(PathBuf, EntryKind, Attrs)> = Vec::new();
let mut queue = vec![PathBuf::new()];
while let Some(path) = queue.pop() {
let Some(children) = index.children(&path) else {
continue;
};
let names: Vec<PathBuf> = children.map(|(name, _id)| path.join(name)).collect();
for child_path in names {
let kind = index.kind(&child_path).expect("child has a kind");
let attrs = *index.attrs(&child_path).expect("child has attrs");
entries.push((child_path.clone(), kind, attrs));
if kind.is_dir() {
queue.push(child_path);
}
}
}
entries.sort_by(|left, right| left.0.cmp(&right.0));
entries
}
fn detached_and_streaming_indexes(root: &Path, config: &ScanConfig) -> (Index, Index) {
let canonical = root.canonicalize().expect("canonical test root");
let (streaming, streaming_report) =
scan_into_index_via_scanner(&canonical, config).expect("streaming oracle");
let (detached, detached_report) = scan_into_index(root, config).expect("detached scan");
assert_eq!(detached_report.dirs_read, streaming_report.dirs_read);
assert_eq!(detached_report.entries, streaming_report.entries);
assert_eq!(detached_report.files_walked, streaming_report.files_walked);
assert_eq!(detached_report.bytes_walked, streaming_report.bytes_walked);
assert_eq!(
detached_report.errors.iter().map(ToString::to_string).collect::<Vec<_>>(),
streaming_report.errors.iter().map(ToString::to_string).collect::<Vec<_>>()
);
assert_indexes_equal(&detached, &streaming);
(detached, streaming)
}
fn assert_indexes_equal(left: &Index, right: &Index) {
assert_eq!(index_fingerprint(left), index_fingerprint(right));
assert_eq!(left.total(), right.total());
assert_eq!(left.partition_total().ok(), right.partition_total().ok());
assert_eq!(left.scope(), right.scope());
assert_eq!(left.freshness(), right.freshness());
assert_eq!(left.state(), right.state());
assert_eq!(left.clock(), right.clock());
assert_eq!(left.len(), right.len());
assert_eq!(left.issues(), right.issues());
assert_eq!(left.observes_controls(), right.observes_controls());
assert_eq!(left.control_coverage(), right.control_coverage());
assert_eq!(
left.control_table()
.sources()
.map(|(path, source)| (path, source.to_vec()))
.collect::<Vec<_>>(),
right
.control_table()
.sources()
.map(|(path, source)| (path, source.to_vec()))
.collect::<Vec<_>>()
);
for (path, _, _) in index_fingerprint(left) {
assert_eq!(left.is_ignored(&path).ok(), right.is_ignored(&path).ok(), "{path:?}");
}
}
fn reconciliation_transition_tree() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join("changed.txt"), b"before");
write_file(&dir.path().join("removed.txt"), b"remove me");
write_file(&dir.path().join("directory-to-file/old.rs"), b"old child");
write_file(&dir.path().join("file-to-directory"), b"old file");
write_file(&dir.path().join("removed-tree/nested/gone.md"), b"gone");
write_file(&dir.path().join("stable/deep/kept.rs"), b"kept");
crate::test_support::settle_allocations(dir.path());
dir
}
fn mutate_reconciliation_transition_tree(root: &Path) {
write_file(&root.join("changed.txt"), b"after, with a distinct size");
fs::remove_file(root.join("removed.txt")).expect("remove root file");
fs::remove_dir_all(root.join("directory-to-file")).expect("remove old directory");
write_file(&root.join("directory-to-file"), b"replacement file");
fs::remove_file(root.join("file-to-directory")).expect("remove old file");
write_file(&root.join("file-to-directory/new.txt"), b"replacement child");
fs::remove_dir_all(root.join("removed-tree")).expect("remove nested tree");
write_file(&root.join("added-tree/nested/new.md"), b"new nested file");
crate::test_support::settle_allocations(root);
}
fn effective_ops(commits: &[Commit]) -> Vec<Op> {
let mut operations: Vec<_> = commits
.iter()
.flat_map(|commit| commit.changes.iter())
.filter_map(|change| match change {
crate::EffectiveChange::Inserted { path, kind, attrs } => {
Some(Op::Upsert { path: path.clone(), kind: *kind, attrs: *attrs })
}
crate::EffectiveChange::Updated { path, kind, current, .. } => {
Some(Op::Upsert { path: path.clone(), kind: *kind, attrs: *current })
}
crate::EffectiveChange::Removed { path, .. } => {
Some(Op::Remove { path: path.clone() })
}
crate::EffectiveChange::Invalidated { path, reason } => {
Some(Op::InvalidateSubtree { path: path.clone(), reason: *reason })
}
crate::EffectiveChange::ControlUpdated { .. }
| crate::EffectiveChange::ControlRefusalUpdated { .. }
| crate::EffectiveChange::Reclassified { .. } => None,
})
.collect();
operations.sort_by(|left, right| left.path().cmp(right.path()));
operations
}
fn commit_touches(commit: &Commit, path: &Path) -> bool {
commit.changes.iter().any(|change| change.path() == path)
}
#[test]
fn parallel_and_serial_walks_produce_the_same_index() {
let dir = branching_tree();
let serial_config = ScanConfig { threads: Some(1), ..ScanConfig::default() };
let (serial, serial_report) =
scan_into_index(dir.path(), &serial_config).expect("serial scan");
assert!(serial_report.is_complete());
for threads in [2_usize, 3, 8] {
let config = ScanConfig { threads: Some(threads), ..ScanConfig::default() };
let (parallel, report) = scan_into_index(dir.path(), &config).expect("parallel scan");
assert!(report.is_complete(), "{threads} threads reported errors");
assert_eq!(report.entries, serial_report.entries, "{threads} threads");
assert_eq!(report.dirs_read, serial_report.dirs_read, "{threads} threads");
assert_eq!(report.files_walked, serial_report.files_walked, "{threads} threads");
assert_eq!(report.bytes_walked, serial_report.bytes_walked, "{threads} threads");
let (serial_total, parallel_total) = (serial.total(), parallel.total());
assert_eq!(
(
parallel_total.files,
parallel_total.dirs,
parallel_total.bytes,
parallel_total.allocated,
parallel_total.newest_mtime_ns,
),
(
serial_total.files,
serial_total.dirs,
serial_total.bytes,
serial_total.allocated,
serial_total.newest_mtime_ns,
),
"{threads} threads roll-up"
);
assert_eq!(
parallel_total.by_ext, serial_total.by_ext,
"{threads} threads per-extension roll-up"
);
assert_eq!(
index_fingerprint(¶llel),
index_fingerprint(&serial),
"{threads} threads produced a different index"
);
}
}
#[test]
fn parallel_walk_emits_every_entry_exactly_once() {
let dir = branching_tree();
let config = ScanConfig { threads: Some(4), batch_size: 16, ..ScanConfig::default() };
let mut seen: BTreeMap<PathBuf, usize> = BTreeMap::new();
let report = scan(dir.path(), &config, &mut |observation| {
for op in &observation.ops {
if let Op::Upsert { path, .. } = &op.op {
*seen.entry(path.clone()).or_default() += 1;
}
}
})
.expect("parallel scan");
assert!(report.is_complete());
assert_eq!(seen.len() as u64, report.entries, "entry count disagrees with the report");
let duplicated: Vec<_> =
seen.iter().filter(|(_path, count)| **count != 1).map(|(path, _)| path).collect();
assert!(duplicated.is_empty(), "paths emitted more than once: {duplicated:?}");
}
#[test]
fn parallel_walk_honours_max_depth() {
let dir = branching_tree();
for threads in [1_usize, 4] {
let config =
ScanConfig { threads: Some(threads), max_depth: Some(2), ..ScanConfig::default() };
let (index, report) = scan_into_index(dir.path(), &config).expect("scan");
assert!(report.is_complete());
for (path, _kind, _attrs) in index_fingerprint(&index) {
assert!(
path.components().count() <= 2,
"{threads} threads kept {path:?} past the depth limit"
);
}
}
}
#[test]
fn scan_order_never_changes_the_resulting_index() {
let dir = branching_tree();
let depth_first =
ScanConfig { order: ScanOrder::DepthFirst, threads: Some(1), ..ScanConfig::default() };
let (expected, expected_report) =
scan_into_index(dir.path(), &depth_first).expect("depth-first scan");
for (order, threads) in
[(ScanOrder::BreadthFirst, 1), (ScanOrder::BreadthFirst, 4), (ScanOrder::DepthFirst, 4)]
{
let config = ScanConfig { order, threads: Some(threads), ..ScanConfig::default() };
let (index, report) = scan_into_index(dir.path(), &config).expect("scan");
assert_eq!(report.entries, expected_report.entries, "{order:?}/{threads}");
assert_eq!(report.dirs_read, expected_report.dirs_read, "{order:?}/{threads}");
let (totals, expected_totals) = (index.total(), expected.total());
assert_eq!(
(totals.files, totals.dirs, totals.bytes, totals.allocated),
(
expected_totals.files,
expected_totals.dirs,
expected_totals.bytes,
expected_totals.allocated
),
"{order:?}/{threads} roll-up"
);
assert_eq!(
totals.newest_mtime_ns, expected_totals.newest_mtime_ns,
"{order:?}/{threads} newest mtime"
);
assert_eq!(
totals.by_ext, expected_totals.by_ext,
"{order:?}/{threads} extension tallies"
);
assert_eq!(
index_fingerprint(&index),
index_fingerprint(&expected),
"{order:?}/{threads} produced a different index"
);
}
}
#[test]
fn a_single_worker_breadth_first_walk_is_strictly_level_ordered() {
let dir = branching_tree();
let config = ScanConfig {
order: ScanOrder::BreadthFirst,
threads: Some(1),
batch_size: 1,
..ScanConfig::default()
};
let mut depths_in_order: Vec<usize> = Vec::new();
scan(dir.path(), &config, &mut |observation| {
for op in &observation.ops {
if let Op::Upsert { path, kind, .. } = &op.op {
if kind.is_dir() {
depths_in_order.push(path.components().count());
}
}
}
})
.expect("scan");
assert!(depths_in_order.len() > 10, "fixture should have many directories");
assert!(
depths_in_order.windows(2).all(|pair| pair[0] <= pair[1]),
"directory depths were not non-decreasing: {depths_in_order:?}"
);
}
fn subtrees_started_at_halfway(order: ScanOrder, threads: usize, dir: &Path) -> usize {
let config =
ScanConfig { order, batch_size: 1, threads: Some(threads), ..ScanConfig::default() };
let mut files: Vec<PathBuf> = Vec::new();
scan(dir, &config, &mut |observation| {
for op in &observation.ops {
if let Op::Upsert { path, kind, .. } = &op.op {
if !kind.is_dir() {
files.push(path.clone());
}
}
}
})
.expect("scan");
let halfway = files.len() / 2;
let mut started: BTreeSet<PathBuf> = BTreeSet::new();
for path in files.iter().take(halfway) {
if let Some(top) = path.components().next() {
started.insert(PathBuf::from(top.as_os_str()));
}
}
started.len()
}
#[test]
fn a_parallel_walk_accounts_for_where_its_time_went() {
let dir = branching_tree();
let config = ScanConfig { threads: Some(4), batch_size: 64, ..ScanConfig::default() };
let report = scan(dir.path(), &config, &mut |_| {}).expect("scan");
let a = report.attribution;
assert!(a.claims > 0, "a parallel walk claims chunks: {a:?}");
assert!(a.work_ns > 0, "reading directories takes time: {a:?}");
assert!(a.wall_ns > 0);
assert!(a.lock_ops >= a.claims * 2, "lock ops out of step with claims: {a:?}");
assert!(
a.accounted_ns() <= a.wall_ns,
"attributed slices are disjoint intervals inside worker wall: {a:?}"
);
}
#[test]
fn a_serial_walk_has_no_coordination_to_attribute() {
let dir = branching_tree();
let config = ScanConfig { threads: Some(1), batch_size: 64, ..ScanConfig::default() };
let mut observations = 0usize;
let report = scan(dir.path(), &config, &mut |_| observations += 1).expect("scan");
let a = report.attribution;
assert!(observations > 0, "the sink ran, so send_ns measured something real");
assert!(a.work_ns > 0 && a.wall_ns >= a.work_ns);
assert_eq!(
(a.claims, a.lock_ops, a.lock_contended, a.starved_ns, a.lock_wait_ns),
(0, 0, 0, 0, 0),
"no queue, no lock, nothing to wait on: {a:?}"
);
}
fn deep_forest() -> tempfile::TempDir {
let dir = tempfile::tempdir().expect("tempdir");
for top in 0..12 {
let mut level: Vec<PathBuf> = vec![dir.path().join(format!("t{top}"))];
for _ in 0..5 {
let mut next = Vec::new();
for parent in &level {
for child in 0..2 {
let path = parent.join(format!("c{child}"));
for file in 0..3 {
write_file(&path.join(format!("f{file}.dat")), b"xxxxxxxxxx");
}
next.push(path);
}
}
level = next;
}
}
dir
}
fn leanest_subtree_early(order: ScanOrder, threads: usize, dir: &Path) -> usize {
let config =
ScanConfig { order, batch_size: 1, threads: Some(threads), ..ScanConfig::default() };
let mut files: Vec<PathBuf> = Vec::new();
scan(dir, &config, &mut |observation| {
for op in &observation.ops {
if let Op::Upsert { path, kind, .. } = &op.op {
if !kind.is_dir() {
files.push(path.clone());
}
}
}
})
.expect("scan");
let quarter = files.len() / 4;
let mut per_top: BTreeMap<PathBuf, usize> = BTreeMap::new();
for path in files.iter().take(quarter) {
if let Some(top) = path.components().next() {
*per_top.entry(PathBuf::from(top.as_os_str())).or_default() += 1;
}
}
(0..12)
.map(|top| per_top.get(&PathBuf::from(format!("t{top}"))).copied().unwrap_or(0))
.min()
.unwrap_or(0)
}
#[test]
fn deep_subtrees_do_not_delay_their_siblings() {
let dir = deep_forest();
let breadth = leanest_subtree_early(ScanOrder::BreadthFirst, 1, dir.path());
let depth = leanest_subtree_early(ScanOrder::DepthFirst, 1, dir.path());
assert!(
breadth > depth,
"breadth-first should leave its least advanced top-level subtree further \
along: {breadth} files against {depth}"
);
}
#[test]
fn the_region_scheduler_spreads_workers_over_distinct_subtrees() {
let queue = DirectoryQueue::new((PathBuf::new(), 0), ScanOrder::BreadthFirst, None, None);
let mut timing = WalkAttribution::default();
let mut claimed = Vec::new();
let root = queue.claim(&mut claimed, &mut timing).expect("root is claimable");
claimed.clear();
queue.extend(
(0..4).map(|top| (PathBuf::from(format!("t{top}")), 1, RegionId::UNASSIGNED)),
&mut timing,
);
assert!(root.release(0, 0, &mut timing).is_none());
let mut regions = BTreeSet::new();
let mut held = Vec::new();
for _ in 0..4 {
let mut claimed = Vec::new();
held.push(queue.claim(&mut claimed, &mut timing).expect("a region has work"));
regions.insert(claimed[0].2.0);
assert_eq!(claimed.len(), 1, "one directory per region so far");
}
assert_eq!(regions.len(), 4, "each claim took a distinct region: {regions:?}");
}
#[test]
fn breadth_first_spreads_early_work_across_top_level_subtrees() {
let dir = branching_tree();
let breadth = subtrees_started_at_halfway(ScanOrder::BreadthFirst, 1, dir.path());
let depth = subtrees_started_at_halfway(ScanOrder::DepthFirst, 1, dir.path());
assert!(
breadth > depth,
"breadth-first should have more top-level subtrees underway at the halfway \
point, but started {breadth} against depth-first's {depth}"
);
}
#[test]
fn scan_order_does_not_change_the_cache_scope() {
let breadth = ScanConfig { order: ScanOrder::BreadthFirst, ..ScanConfig::default() };
let depth = ScanConfig { order: ScanOrder::DepthFirst, ..ScanConfig::default() };
assert_eq!(breadth.scope(), depth.scope());
}
#[test]
fn worker_threads_are_bounded_and_never_zero() {
let zero = ScanConfig { threads: Some(0), ..ScanConfig::default() };
assert_eq!(zero.worker_threads(), 1, "zero threads must fall back to the serial walk");
let absurd = ScanConfig { threads: Some(usize::MAX), ..ScanConfig::default() };
assert_eq!(absurd.worker_threads(), MAX_SCAN_THREADS);
let automatic = ScanConfig { threads: None, ..ScanConfig::default() };
assert!((1..=DEFAULT_SCAN_THREADS_CAP).contains(&automatic.worker_threads()));
}
#[test]
fn automatic_worker_pool_keeps_a_conservative_start_and_bounded_reserve() {
assert_eq!(automatic_worker_pool(1), WorkerPool::fixed(1));
assert_eq!(
automatic_worker_pool(4),
WorkerPool {
initial: 4,
maximum: 8,
calibration: Some(WorkerCalibration::new(
ADAPTIVE_SCAN_CALIBRATION_ENTRIES,
ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY,
)),
}
);
assert_eq!(
automatic_worker_pool(10),
WorkerPool {
initial: DEFAULT_SCAN_THREADS_CAP,
maximum: ADAPTIVE_SCAN_THREADS_CAP,
calibration: Some(WorkerCalibration::new(
ADAPTIVE_SCAN_CALIBRATION_ENTRIES,
ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY,
)),
}
);
}
#[test]
fn an_abandoned_claim_does_not_strand_the_other_workers() {
let queue = std::sync::Arc::new(DirectoryQueue::new(
(PathBuf::new(), 0),
ScanOrder::BreadthFirst,
None,
None,
));
let mut timing = WalkAttribution::default();
let mut claimed = Vec::new();
drop(queue.claim(&mut claimed, &mut timing).expect("root is claimable"));
let waiter = queue.clone();
let (done, finished) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
let mut timing = WalkAttribution::default();
let mut claimed = Vec::new();
let outcome = waiter.claim(&mut claimed, &mut timing).is_some();
done.send(outcome).expect("publish the claim outcome");
});
assert_eq!(
finished.recv_timeout(std::time::Duration::from_secs(5)),
Ok(false),
"the queue must report the walk finished instead of parking the worker"
);
}
#[test]
fn automatic_queue_activates_its_reserve_only_for_slow_initial_work() {
let slow = WorkerCalibration::new(3, 10);
let queue =
DirectoryQueue::new((PathBuf::new(), 0), ScanOrder::BreadthFirst, Some(slow), None);
let mut timing = WalkAttribution::default();
let mut claimed = Vec::new();
let claim = queue.claim(&mut claimed, &mut timing).expect("root is claimable");
queue.extend([(PathBuf::from("child"), 1, RegionId::UNASSIGNED)].into_iter(), &mut timing);
assert!(claim.release(2, 20, &mut timing).is_none());
claimed.clear();
let claim = queue.claim(&mut claimed, &mut timing).expect("child is claimable");
queue.extend([(PathBuf::from("grandchild"), 2, claimed[0].2)].into_iter(), &mut timing);
assert_eq!(claim.release(1, 10, &mut timing), Some(2));
let fast = WorkerCalibration::new(3, 11);
let queue =
DirectoryQueue::new((PathBuf::new(), 0), ScanOrder::BreadthFirst, Some(fast), None);
let mut timing = WalkAttribution::default();
let mut claimed = Vec::new();
let claim = queue.claim(&mut claimed, &mut timing).expect("root is claimable");
assert!(claim.release(3, 30, &mut timing).is_none());
assert!(queue.lock().controller.is_none(), "calibration decides only once");
}
#[test]
fn repeated_windows_reconsider_a_late_slow_phase_in_entry_order() {
let calibration = WorkerCalibration::new(4, 10);
let mut controller =
WorkerController::new(calibration, WorkerPolicyExperiment::RepeatedWindows);
let fast = controller.observe(4, 20).expect("first complete window");
let slow = controller.observe(4, 80).expect("second complete window");
assert!(!fast.slow);
assert!(slow.slow);
assert_eq!((fast.start_entry_ordinal, fast.end_entry_ordinal), (0, 4));
assert_eq!((slow.start_entry_ordinal, slow.end_entry_ordinal), (4, 8));
}
#[test]
fn shipped_trace_retains_post_decision_windows_without_changing_policy() {
let calibration = WorkerCalibration::new(2, 10);
let pool = WorkerPool { initial: 2, maximum: 4, calibration: Some(calibration) };
let recorder =
ScanDiagnosticsRecorder::new(pool, 2, WorkerPolicyExperiment::ShippedOneShot);
let queue = DirectoryQueue::new_with_policy(
(PathBuf::new(), 0),
ScanOrder::BreadthFirst,
Some(calibration),
Some(recorder.clone()),
pool.initial,
pool.maximum,
WorkerPolicyExperiment::ShippedOneShot,
);
let mut timing = WalkAttribution::default();
let mut claimed = Vec::new();
let first = queue.claim(&mut claimed, &mut timing).expect("first window");
queue.extend([(PathBuf::from("late"), 1, RegionId::UNASSIGNED)].into_iter(), &mut timing);
assert_eq!(first.release(2, 10, &mut timing), None, "fast prefix holds");
claimed.clear();
let late = queue.claim(&mut claimed, &mut timing).expect("late phase");
queue.extend([(PathBuf::from("tail"), 2, RegionId::UNASSIGNED)].into_iter(), &mut timing);
assert_eq!(late.release(2, 40, &mut timing), None, "shadow cannot scale");
let diagnostics = recorder.finish();
assert_eq!(diagnostics.worker_policy.outcome, WorkerPolicyOutcome::Held);
assert_eq!(
diagnostics
.worker_policy
.windows
.iter()
.map(|window| window.decision)
.collect::<Vec<_>>(),
vec![WorkerPolicyDecision::Hold, WorkerPolicyDecision::ObserveSlow]
);
}
#[test]
fn staged_controller_requires_a_useful_frontier_then_stays_bounded() {
let calibration = WorkerCalibration::new(1, 10);
let pool = WorkerPool { initial: 2, maximum: 8, calibration: Some(calibration) };
let recorder =
ScanDiagnosticsRecorder::new(pool, 4, WorkerPolicyExperiment::StagedGatedWindows);
let queue = DirectoryQueue::new_with_policy(
(PathBuf::new(), 0),
ScanOrder::BreadthFirst,
Some(calibration),
Some(recorder.clone()),
pool.initial,
pool.maximum,
WorkerPolicyExperiment::StagedGatedWindows,
);
let mut timing = WalkAttribution::default();
let mut claimed = Vec::new();
let root = queue.claim(&mut claimed, &mut timing).expect("root");
queue.extend([(PathBuf::from("narrow"), 1, RegionId::UNASSIGNED)].into_iter(), &mut timing);
assert_eq!(root.release(1, 20, &mut timing), None);
claimed.clear();
let narrow = queue.claim(&mut claimed, &mut timing).expect("narrow child");
queue.extend(
(0..9).map(|index| (PathBuf::from(format!("wide-{index}")), 2, RegionId::UNASSIGNED)),
&mut timing,
);
assert_eq!(narrow.release(1, 20, &mut timing), Some(4));
claimed.clear();
let wide = queue.claim(&mut claimed, &mut timing).expect("wide claim");
queue.extend(
(0..9).map(|index| (PathBuf::from(format!("wider-{index}")), 3, RegionId::UNASSIGNED)),
&mut timing,
);
assert_eq!(wide.release(1, 20, &mut timing), Some(8));
let diagnostics = recorder.finish();
let decisions: Vec<_> = diagnostics
.worker_policy
.windows
.iter()
.map(|window| (window.decision, window.requested_workers))
.collect();
assert_eq!(
decisions,
vec![
(WorkerPolicyDecision::HoldInsufficientFrontier, None),
(WorkerPolicyDecision::ScaleUp, Some(4)),
(WorkerPolicyDecision::ScaleUp, Some(8)),
]
);
assert!(
diagnostics
.worker_policy
.windows
.windows(2)
.all(|pair| pair[0].end_entry_ordinal <= pair[1].start_entry_ordinal)
);
assert!(diagnostics.worker_policy.windows.iter().all(|window| {
window.requested_workers.is_none_or(|workers| workers <= pool.maximum)
}));
}
#[test]
fn staged_controller_does_not_add_producers_to_a_delayed_handoff() {
let calibration = WorkerCalibration::new(1, 10);
let pool = WorkerPool { initial: 2, maximum: 8, calibration: Some(calibration) };
let recorder =
ScanDiagnosticsRecorder::new(pool, 4, WorkerPolicyExperiment::StagedGatedWindows);
recorder.handoff_sent();
recorder.handoff_sent();
let queue = DirectoryQueue::new_with_policy(
(PathBuf::new(), 0),
ScanOrder::BreadthFirst,
Some(calibration),
Some(recorder.clone()),
pool.initial,
pool.maximum,
WorkerPolicyExperiment::StagedGatedWindows,
);
let mut timing = WalkAttribution::default();
let mut claimed = Vec::new();
let claim = queue.claim(&mut claimed, &mut timing).expect("root");
queue.extend(
(0..9).map(|index| (PathBuf::from(format!("ready-{index}")), 1, RegionId::UNASSIGNED)),
&mut timing,
);
assert_eq!(claim.release(1, 20, &mut timing), None);
recorder.handoff_received();
recorder.handoff_received();
let diagnostics = recorder.finish();
assert_eq!(
diagnostics.worker_policy.windows[0].decision,
WorkerPolicyDecision::HoldHandoffBacklog
);
}
#[test]
fn candidate_retains_post_expansion_shadow_history() {
let calibration = WorkerCalibration::new(1, 10);
let pool = WorkerPool { initial: 2, maximum: 4, calibration: Some(calibration) };
let recorder =
ScanDiagnosticsRecorder::new(pool, 4, WorkerPolicyExperiment::RepeatedWindows);
let queue = DirectoryQueue::new_with_policy(
(PathBuf::new(), 0),
ScanOrder::BreadthFirst,
Some(calibration),
Some(recorder.clone()),
pool.initial,
pool.maximum,
WorkerPolicyExperiment::RepeatedWindows,
);
let mut timing = WalkAttribution::default();
let mut claimed = Vec::new();
let slow = queue.claim(&mut claimed, &mut timing).expect("slow prefix");
queue.extend(
(0..4).map(|index| (PathBuf::from(format!("fast-{index}")), 1, RegionId::UNASSIGNED)),
&mut timing,
);
assert_eq!(slow.release(1, 20, &mut timing), Some(4));
claimed.clear();
let fast = queue.claim(&mut claimed, &mut timing).expect("fast suffix");
queue.extend([(PathBuf::from("tail"), 2, RegionId::UNASSIGNED)].into_iter(), &mut timing);
assert_eq!(fast.release(1, 1, &mut timing), None);
let diagnostics = recorder.finish();
assert_eq!(
diagnostics
.worker_policy
.windows
.iter()
.map(|window| window.decision)
.collect::<Vec<_>>(),
vec![WorkerPolicyDecision::ScaleUp, WorkerPolicyDecision::ObserveFast]
);
}
#[test]
fn every_experimental_controller_preserves_exactness_and_shutdown() {
let dir = branching_tree();
let serial = ScanConfig { threads: Some(1), ..ScanConfig::default() };
let (reference, _) = scan_into_index(dir.path(), &serial).expect("serial reference");
let automatic = ScanConfig { threads: None, ..ScanConfig::default() };
for policy in [
WorkerPolicyExperiment::ShippedOneShot,
WorkerPolicyExperiment::RepeatedWindows,
WorkerPolicyExperiment::StagedGatedWindows,
] {
let (index, report, diagnostics) =
scan_into_index_with_policy_diagnostics(dir.path(), &automatic, policy)
.expect("candidate scan finishes");
assert!(report.is_complete(), "{policy:?}: {:?}", report.errors);
assert_eq!(index_fingerprint(&reference), index_fingerprint(&index), "{policy:?}");
assert_eq!(diagnostics.worker_policy.ready_directories_at_finish, 0);
assert_eq!(diagnostics.worker_policy.in_flight_directories_at_finish, 0);
assert_eq!(diagnostics.worker_policy.handoff_backlog_at_finish, 0);
assert!(
diagnostics.worker_policy.workers_spawned
<= diagnostics.worker_policy.maximum_workers
);
}
}
mod completion_order {
use super::{ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY, WorkerCalibration};
#[derive(Clone, Copy, Debug)]
struct Chunk {
entries: u64,
work_ns: u64,
}
impl Chunk {
const fn at(entries: u64, per_entry_ns: u64) -> Self {
Self { entries, work_ns: entries.saturating_mul(per_entry_ns) }
}
}
#[derive(Debug, PartialEq, Eq)]
enum Outcome {
ScaledUp { after_chunks: usize },
Held { after_chunks: usize },
Undecided,
}
fn whole_trace_ns_per_entry(trace: &[Chunk]) -> u64 {
let entries: u64 = trace.iter().map(|chunk| chunk.entries).sum();
let work_ns: u64 = trace.iter().map(|chunk| chunk.work_ns).sum();
assert!(entries > 0, "a trace must observe entries");
work_ns / entries
}
fn shipped(window: u64, threshold_ns: u64, trace: &[Chunk]) -> Outcome {
let mut calibration = WorkerCalibration::new(window, threshold_ns);
for (index, chunk) in trace.iter().enumerate() {
if let Some(slow) = calibration.observe(chunk.entries, chunk.work_ns) {
let after_chunks = index + 1;
return if slow {
Outcome::ScaledUp { after_chunks }
} else {
Outcome::Held { after_chunks }
};
}
}
Outcome::Undecided
}
const CHUNK: u64 = 4_096;
const FAST: Chunk = Chunk::at(CHUNK, 2_000);
const SLOW: Chunk = Chunk::at(CHUNK, 90_000);
#[test]
fn completion_order_alone_flips_the_shipped_decision() {
let fast_phase_first = [FAST, FAST, FAST, FAST, SLOW, SLOW, SLOW, SLOW];
let interleaved = [SLOW, FAST, SLOW, FAST, SLOW, FAST, SLOW, FAST];
let window = 4 * CHUNK;
let threshold = ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY;
let truth = whole_trace_ns_per_entry(&fast_phase_first);
assert_eq!(truth, whole_trace_ns_per_entry(&interleaved));
assert!(
truth >= threshold,
"both traces are slow walks by the shipped threshold: {truth} < {threshold}"
);
assert_eq!(
shipped(window, threshold, &fast_phase_first),
Outcome::Held { after_chunks: 4 },
"a fast prefix holds the pool for a walk that is slow overall"
);
assert_eq!(
shipped(window, threshold, &interleaved),
Outcome::ScaledUp { after_chunks: 4 },
"the same tree scales up when its slow chunks land in the window"
);
}
#[test]
fn a_slow_phase_after_the_window_is_never_reconsidered() {
let mut trace = vec![FAST; 4];
trace.extend(std::iter::repeat_n(SLOW, 400));
let observed = whole_trace_ns_per_entry(&trace);
assert!(
observed >= 2 * ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY,
"the walk is overwhelmingly latency-bound: {observed} ns per entry"
);
assert_eq!(
shipped(4 * CHUNK, ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY, &trace),
Outcome::Held { after_chunks: 4 },
"1% of the walk decided the worker policy for the other 99%"
);
}
#[test]
fn slow_in_flight_work_is_censored_by_fast_completions() {
let completed_before_slow_returns = [FAST, FAST, FAST, FAST];
assert_eq!(
shipped(
4 * CHUNK,
ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY,
&completed_before_slow_returns,
),
Outcome::Held { after_chunks: 4 }
);
let mut eventual_completions = completed_before_slow_returns.to_vec();
eventual_completions.extend([SLOW, SLOW, SLOW, SLOW]);
assert!(
whole_trace_ns_per_entry(&eventual_completions)
>= ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY
);
assert_eq!(
shipped(4 * CHUNK, ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY, &eventual_completions,),
Outcome::Held { after_chunks: 4 }
);
}
#[test]
fn a_slow_prefix_can_scale_a_walk_that_is_fast_overall() {
let mut trace = vec![SLOW; 4];
trace.extend(std::iter::repeat_n(FAST, 400));
let observed = whole_trace_ns_per_entry(&trace);
assert!(observed < ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY);
assert_eq!(
shipped(4 * CHUNK, ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY, &trace),
Outcome::ScaledUp { after_chunks: 4 }
);
assert_eq!(
sliding(4 * CHUNK, ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY, &trace),
Outcome::ScaledUp { after_chunks: 4 }
);
}
#[test]
fn a_walk_shorter_than_the_window_decides_nothing() {
let trace = [FAST, SLOW];
assert_eq!(
shipped(4 * CHUNK, ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY, &trace),
Outcome::Undecided
);
}
struct SlidingWindow {
window_entries: u64,
threshold_ns: u64,
recent: std::collections::VecDeque<Chunk>,
entries: u64,
work_ns: u64,
}
impl SlidingWindow {
fn new(window_entries: u64, threshold_ns: u64) -> Self {
Self {
window_entries,
threshold_ns,
recent: std::collections::VecDeque::new(),
entries: 0,
work_ns: 0,
}
}
fn observe(&mut self, chunk: Chunk) -> Option<bool> {
self.recent.push_back(chunk);
self.entries = self.entries.saturating_add(chunk.entries);
self.work_ns = self.work_ns.saturating_add(chunk.work_ns);
while let Some(oldest) = self.recent.front().copied() {
if self.entries - oldest.entries < self.window_entries {
break;
}
self.recent.pop_front();
self.entries -= oldest.entries;
self.work_ns -= oldest.work_ns;
}
(self.entries >= self.window_entries)
.then(|| self.work_ns / self.entries >= self.threshold_ns)
}
}
fn sliding(window: u64, threshold_ns: u64, trace: &[Chunk]) -> Outcome {
let mut policy = SlidingWindow::new(window, threshold_ns);
let mut decided = None;
for (index, chunk) in trace.iter().enumerate() {
if let Some(slow) = policy.observe(*chunk) {
let after_chunks = index + 1;
if slow {
return Outcome::ScaledUp { after_chunks };
}
decided.get_or_insert(Outcome::Held { after_chunks });
}
}
decided.unwrap_or(Outcome::Undecided)
}
#[test]
fn screening_a_sliding_window_against_the_order_sensitivity() {
let window = 4 * CHUNK;
let threshold = ADAPTIVE_SCAN_SLOW_WORK_NS_PER_ENTRY;
let fast_phase_first = [FAST, FAST, FAST, FAST, SLOW, SLOW, SLOW, SLOW];
let interleaved = [SLOW, FAST, SLOW, FAST, SLOW, FAST, SLOW, FAST];
assert_eq!(
sliding(window, threshold, &fast_phase_first),
Outcome::ScaledUp { after_chunks: 6 }
);
assert_eq!(
sliding(window, threshold, &interleaved),
Outcome::ScaledUp { after_chunks: 4 }
);
let mut late = vec![FAST; 4];
late.extend(std::iter::repeat_n(SLOW, 400));
assert_eq!(sliding(window, threshold, &late), Outcome::ScaledUp { after_chunks: 6 });
let uniformly_fast = vec![FAST; 40];
assert_eq!(
sliding(window, threshold, &uniformly_fast),
Outcome::Held { after_chunks: 4 }
);
assert_eq!(sliding(window, threshold, &[FAST, SLOW]), Outcome::Undecided);
}
}
#[test]
fn thread_count_does_not_change_the_cache_scope() {
let serial = ScanConfig { threads: Some(1), ..ScanConfig::default() };
let parallel = ScanConfig { threads: Some(8), ..ScanConfig::default() };
assert_eq!(serial.scope(), parallel.scope());
}
#[test]
fn scan_populates_an_index_end_to_end() {
let dir = sample_tree();
let (index, report) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
assert!(report.is_complete(), "unexpected errors: {:?}", report.errors);
let total = index.total();
assert_eq!(total.files, 3);
assert_eq!(total.dirs, 2);
assert_eq!(total.bytes, 5 + 12 + 9);
assert_eq!(total.by_ext[".rs"].files, 2);
assert_eq!(total.by_ext[".txt"].files, 1);
let src = index.rollup(Path::new("src")).expect("src");
assert_eq!(src.files, 2);
assert_eq!(src.dirs, 1);
}
#[test]
fn cold_scan_routes_control_sources_through_both_walkers() {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join(".gitignore"), b"*.log\n");
write_file(&dir.path().join("debug.log"), b"ignored");
write_file(&dir.path().join("keep.rs"), b"visible");
for threads in [1, 4] {
let config =
ScanConfig { read_controls: true, threads: Some(threads), ..ScanConfig::default() };
let (index, report) = scan_into_index(dir.path(), &config).expect("scan");
assert!(report.is_complete(), "unexpected errors: {:?}", report.errors);
assert!(
index
.controls()
.expect("control state observed")
.source_is(Path::new(".gitignore"), b"*.log\n")
);
assert_eq!(
index.is_ignored(Path::new("debug.log")).expect("control state observed"),
Some(true)
);
assert_eq!(
index.is_ignored(Path::new("keep.rs")).expect("control state observed"),
Some(false)
);
let partitions = index.partition_total().expect("control state observed");
assert_eq!(partitions.all.files, 3);
assert_eq!(partitions.unignored.files, 2);
}
}
#[cfg(unix)]
#[test]
fn raced_fifo_control_source_is_rejected_without_blocking() {
let dir = tempfile::tempdir().expect("tempdir");
let control = dir.path().join(".gitignore");
let status = match std::process::Command::new("mkfifo").arg(&control).status() {
Ok(status) => status,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
Err(error) => panic!("create fifo: {error}"),
};
assert!(status.success(), "mkfifo exited with {status}");
let root = dir.path().to_path_buf();
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = read_control_op_unconditional(
&root,
Path::new(".gitignore"),
EntryKind::File,
Some(crate::control::DEFAULT_CONTROL_BUDGET),
);
sender.send(result).ok();
});
let result = receiver
.recv_timeout(std::time::Duration::from_secs(1))
.expect("a raced FIFO must not block the scan worker")
.expect("the non-regular replacement is a normal control removal");
assert!(matches!(result, Some(Op::ControlRemove { .. })));
}
#[test]
fn hidden_admission_keeps_exact_allowlist_and_control_signals_only() {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join(".gitignore"), b"*.log\n");
write_file(&dir.path().join("debug.log"), b"ignored");
write_file(&dir.path().join(".secret/token"), b"hidden");
write_file(&dir.path().join(".github/workflows/check.yml"), b"visible");
let hidden = std::sync::Arc::new(crate::HiddenPolicy::prune_hidden([".github"]));
for threads in [1, 4] {
let config = ScanConfig {
hidden: Some(std::sync::Arc::clone(&hidden)),
threads: Some(threads),
read_controls: true,
..ScanConfig::default()
};
let (mut index, report) = scan_into_index(dir.path(), &config).expect("scan");
assert!(report.is_complete(), "unexpected errors: {:?}", report.errors);
assert!(index.lookup(Path::new(".gitignore")).is_none());
assert!(index.lookup(Path::new(".secret")).is_none());
assert!(index.lookup(Path::new(".secret/token")).is_none());
assert!(index.lookup(Path::new(".github/workflows/check.yml")).is_some());
assert!(
index
.controls()
.expect("control state observed")
.source_is(Path::new(".gitignore"), b"*.log\n")
);
assert_eq!(
index.is_ignored(Path::new("debug.log")).expect("control state observed"),
Some(true)
);
fs::remove_file(dir.path().join(".gitignore")).expect("remove control");
if threads > 1 {
fs::create_dir(dir.path().join(".gitignore")).expect("replace with directory");
}
let reconciled = reconcile(&mut index, &config, &mut |_| {}).expect("reconcile");
assert!(reconciled.is_complete());
assert!(index.controls().expect("control state observed").is_empty());
if threads > 1 {
fs::remove_dir(dir.path().join(".gitignore")).expect("remove directory");
}
write_file(&dir.path().join(".gitignore"), b"*.log\n");
}
}
#[test]
fn excluded_ignored_directory_is_not_enumerated_and_rule_edits_reconcile_it() {
let root = tempfile::tempdir().expect("root");
write_file(&root.path().join(".gitignore"), b"target/\n");
write_file(&root.path().join("target/deep/file.rs"), b"code");
write_file(&root.path().join("keep.rs"), b"kept");
let config = ScanConfig {
population: crate::query::IgnoredEntries::Exclude,
threads: Some(4),
..ScanConfig::default()
};
let (mut index, cold) = scan_into_index(root.path(), &config).expect("cold scan");
assert!(cold.is_complete(), "{:?}", cold.errors);
assert_eq!(cold.dirs_read, 1, "ignored target was not opened");
assert!(index.lookup(Path::new("target")).is_none());
assert!(index.lookup(Path::new("keep.rs")).is_some());
write_file(&root.path().join(".gitignore"), b"");
let exposed = reconcile(&mut index, &config, &mut |_| {}).expect("reconcile exposure");
assert!(exposed.is_complete(), "{:?}", exposed.scan.errors);
assert!(index.lookup(Path::new("target/deep/file.rs")).is_some());
assert!(exposed.scan.dirs_read >= 3);
write_file(&root.path().join(".gitignore"), b"target/\n");
let excluded = reconcile(&mut index, &config, &mut |_| {}).expect("reconcile exclusion");
assert!(excluded.is_complete(), "{:?}", excluded.scan.errors);
assert!(index.lookup(Path::new("target")).is_none());
assert_eq!(excluded.scan.dirs_read, 1, "ignored target was not reopened");
}
#[test]
fn excluded_subtree_refresh_keeps_ignored_file_and_directory_out_of_scope() {
let root = tempfile::tempdir().expect("root");
write_file(&root.path().join(".gitignore"), b"target/\n*.log\n");
write_file(&root.path().join("target/deep/file.rs"), b"code");
write_file(&root.path().join("debug.log"), b"ignored");
let config = ScanConfig {
population: crate::query::IgnoredEntries::Exclude,
..ScanConfig::default()
};
let (mut index, cold) = scan_into_index(root.path(), &config).expect("cold scan");
assert!(cold.is_complete());
assert!(index.lookup(Path::new("target")).is_none());
assert!(index.lookup(Path::new("debug.log")).is_none());
for path in ["target", "debug.log"] {
let refresh = reconcile_subtree(&mut index, Path::new(path), &config, &mut |_| {})
.expect("subtree refresh");
assert!(refresh.is_complete(), "{path}: {:?}", refresh.scan.errors);
assert!(index.lookup(Path::new(path)).is_none(), "{path} is outside scope");
}
assert_eq!(index.total().dirs, 0);
}
#[test]
fn handled_subtree_refresh_recovers_pruned_ancestry_after_control_edit() {
let root = tempfile::tempdir().expect("root");
write_file(&root.path().join(".gitignore"), b"target/\n");
write_file(&root.path().join("target/deep/file.rs"), b"code");
let config = ScanConfig {
population: crate::query::IgnoredEntries::Exclude,
..ScanConfig::default()
};
let (index, cold) = scan_into_index(root.path(), &config).expect("cold scan");
assert!(cold.is_complete());
let handle = IndexHandle::new(index);
let hidden = reconcile_subtree_handle(
&handle,
Path::new("target/deep/file.rs"),
&config,
&mut |_| {},
)
.expect("refresh pruned descendant");
assert!(hidden.is_complete());
assert!(
!handle
.read_with(|index| index.lookup(Path::new("target")).is_some())
.expect("read after hidden refresh")
);
write_file(&root.path().join(".gitignore"), b"");
let exposed = reconcile_subtree_handle(&handle, Path::new("target"), &config, &mut |_| {})
.expect("refresh changed control");
assert!(exposed.is_complete());
assert!(
handle
.read_with(|index| index.lookup(Path::new("target/deep/file.rs")).is_some())
.expect("read after control recovery")
);
write_file(&root.path().join(".gitignore"), b"target/\n");
let hidden_again =
reconcile_subtree_handle(&handle, Path::new("target"), &config, &mut |_| {})
.expect("refresh restored control");
assert!(hidden_again.is_complete());
assert!(
!handle
.read_with(|index| index.lookup(Path::new("target")).is_some())
.expect("read after restored control")
);
}
#[cfg(unix)]
#[test]
fn targeted_refresh_keeps_unknown_population_below_unreadable_ancestor_control() {
use std::os::unix::fs::PermissionsExt;
if !crate::test_support::require_permission_bits() {
return;
}
let root = tempfile::tempdir().expect("root");
let control = root.path().join(".gitignore");
write_file(&control, b"*.log\n");
write_file(&root.path().join("a/keep.rs"), b"unknown membership");
let config =
ScanConfig { population: crate::query::IgnoredEntries::Only, ..ScanConfig::default() };
fs::set_permissions(&control, fs::Permissions::from_mode(0o000)).expect("unreadable");
let (mut index, cold) = scan_into_index(root.path(), &config).expect("cold scan");
assert!(!cold.is_complete());
assert!(index.lookup(Path::new("a/keep.rs")).is_some());
assert_eq!(index.ignored_classification(Path::new("a/keep.rs")), None);
let refreshed = reconcile_subtree(&mut index, Path::new("a/keep.rs"), &config, &mut |_| {});
fs::set_permissions(&control, fs::Permissions::from_mode(0o644)).expect("restore control");
let refreshed = refreshed.expect("targeted refresh");
assert!(!refreshed.is_complete(), "unreadable governing control was not visited");
assert!(index.lookup(Path::new("a/keep.rs")).is_some(), "unknown must stay retained");
assert_eq!(index.ignored_classification(Path::new("a/keep.rs")), None);
}
#[test]
fn exclusion_honors_nested_negation_and_refused_rule_changes() {
let root = tempfile::tempdir().expect("root");
write_file(&root.path().join("nested/.gitignore"), b"*.log\n!keep.log\n");
write_file(&root.path().join("nested/keep.log"), b"negated");
write_file(&root.path().join("nested/drop.log"), b"ignored");
let config = ScanConfig {
population: crate::query::IgnoredEntries::Exclude,
control_limits: crate::control::ControlLimits {
line_limit: Some(20),
..crate::control::ControlLimits::default()
},
..ScanConfig::default()
};
let (mut index, cold) = scan_into_index(root.path(), &config).expect("cold");
assert!(cold.is_complete(), "{:?}", cold.errors);
assert!(index.lookup(Path::new("nested/keep.log")).is_some());
assert!(index.lookup(Path::new("nested/drop.log")).is_none());
write_file(&root.path().join("nested/.gitignore"), b"this-line-is-over-the-limit\n");
let changed =
reconcile(&mut index, &config, &mut |_| {}).expect("reconcile refused source");
assert!(changed.is_complete(), "{:?}", changed.scan.errors);
assert!(index.lookup(Path::new("nested/drop.log")).is_some(), "unknown cannot be pruned");
assert_eq!(index.ignored_classification(Path::new("nested/drop.log")), None);
}
#[test]
fn only_population_skips_nonignored_content_candidates_and_refusals_are_unknown() {
let root = tempfile::tempdir().expect("root");
write_file(&root.path().join(".gitignore"), b"*.log\n");
write_file(&root.path().join("keep.rs"), b"code");
write_file(&root.path().join("debug.log"), b"ignored");
write_file(&root.path().join("nested/keep.rs"), b"kept");
write_file(&root.path().join("nested/debug.log"), b"ignored below nonignored dir");
let only =
ScanConfig { population: crate::query::IgnoredEntries::Only, ..ScanConfig::default() };
let (index, report) = scan_into_index(root.path(), &only).expect("scan");
assert!(report.is_complete());
assert!(index.lookup(Path::new("keep.rs")).is_none());
assert!(index.lookup(Path::new("nested")).is_some());
assert!(index.lookup(Path::new("nested/keep.rs")).is_none());
assert!(index.lookup(Path::new("nested/debug.log")).is_some());
let candidates = index.analysis_candidates(crate::content::AnalysisSet::NONE.with_lines());
assert_eq!(candidates.len(), 2);
assert!(
candidates.iter().any(|candidate| candidate.relative_path == Path::new("debug.log"))
);
assert!(
candidates
.iter()
.any(|candidate| candidate.relative_path == Path::new("nested/debug.log"))
);
let refused = ScanConfig {
population: crate::query::IgnoredEntries::Exclude,
control_limits: crate::control::ControlLimits {
line_limit: Some(1),
..crate::control::ControlLimits::default()
},
..ScanConfig::default()
};
let (index, report) = scan_into_index(root.path(), &refused).expect("refused scan");
assert!(report.is_complete());
assert!(index.lookup(Path::new("debug.log")).is_some(), "unknown is not pruned");
assert_eq!(index.ignored_classification(Path::new("debug.log")), None);
assert!(
index.analysis_candidates(crate::content::AnalysisSet::NONE.with_lines()).is_empty()
);
}
#[test]
fn only_population_content_is_complete_with_retained_control_file() {
let root = tempfile::tempdir().expect("root");
write_file(&root.path().join(".gitignore"), b"vendor/\n");
write_file(&root.path().join("main.rs"), b"fn main() {}\n");
write_file(&root.path().join("vendor/lib.rs"), b"fn lib() {}\n");
let config =
ScanConfig { population: crate::query::IgnoredEntries::Only, ..ScanConfig::default() };
let (mut index, scan) = scan_into_index(root.path(), &config).expect("scan");
assert!(scan.is_complete());
let profile = crate::content::AnalysisSet::NONE.with_lines().with_code();
let analyzed = crate::content::analyze_index(
&mut index,
crate::content::AnalysisRequest {
profile,
..crate::content::AnalysisRequest::default()
},
);
assert!(analyzed.is_complete(), "{analyzed:?}");
assert_eq!(analyzed.candidates, 1);
assert!(!index.content_has_pending(profile));
}
#[test]
fn only_population_reconciles_rule_changes_without_losing_traversal() {
let root = tempfile::tempdir().expect("root");
write_file(&root.path().join("nested/.gitignore"), b"*.log\n");
write_file(&root.path().join("nested/first.log"), b"first");
write_file(&root.path().join("nested/second.txt"), b"second");
let config =
ScanConfig { population: crate::query::IgnoredEntries::Only, ..ScanConfig::default() };
let (mut index, cold) = scan_into_index(root.path(), &config).expect("cold");
assert!(cold.is_complete());
assert!(index.lookup(Path::new("nested")).is_some());
assert!(index.lookup(Path::new("nested/first.log")).is_some());
assert!(index.lookup(Path::new("nested/second.txt")).is_none());
write_file(&root.path().join("nested/.gitignore"), b"*.txt\n");
let changed = reconcile(&mut index, &config, &mut |_| {}).expect("rule change");
assert!(changed.is_complete(), "{:?}", changed.scan.errors);
assert!(index.lookup(Path::new("nested/first.log")).is_none());
assert!(index.lookup(Path::new("nested/second.txt")).is_some());
}
#[cfg(unix)]
#[test]
fn excluded_special_objects_never_enter_cold_or_reconciled_facts() {
use std::os::unix::net::UnixListener;
let dir = tempfile::tempdir().expect("tempdir");
let socket_path = dir.path().join("service.sock");
let _listener = UnixListener::bind(&socket_path).expect("bind socket");
write_file(&dir.path().join("replacement"), b"ordinary");
crate::test_support::settle_allocations(dir.path());
let (kept, kept_report) =
scan_into_index(dir.path(), &ScanConfig::default()).expect("default scan");
assert!(kept_report.is_complete());
assert_eq!(kept.kind(Path::new("service.sock")), Some(EntryKind::Other));
let serial_config =
ScanConfig { exclude_special: true, threads: Some(1), ..ScanConfig::default() };
let parallel_config =
ScanConfig { exclude_special: true, threads: Some(4), ..ScanConfig::default() };
let (mut serial, serial_report) =
scan_into_index(dir.path(), &serial_config).expect("serial scan");
let (mut parallel, parallel_report) =
scan_into_index(dir.path(), ¶llel_config).expect("parallel scan");
for (index, report) in [(&serial, &serial_report), (¶llel, ¶llel_report)] {
assert!(report.is_complete(), "unexpected errors: {:?}", report.errors);
assert!(index.lookup(Path::new("service.sock")).is_none());
assert!(index.lookup(Path::new("replacement")).is_some());
}
fs::remove_file(dir.path().join("replacement")).expect("remove file");
let _replacement =
UnixListener::bind(dir.path().join("replacement")).expect("bind replacement socket");
let serial_reconciled =
reconcile(&mut serial, &serial_config, &mut |_| {}).expect("serial reconcile");
let parallel_reconciled =
reconcile(&mut parallel, ¶llel_config, &mut |_| {}).expect("parallel reconcile");
assert!(serial_reconciled.is_complete());
assert!(parallel_reconciled.is_complete());
assert!(serial.lookup(Path::new("replacement")).is_none());
assert!(parallel.lookup(Path::new("replacement")).is_none());
assert_eq!(index_fingerprint(&serial), index_fingerprint(¶llel));
}
#[test]
fn control_sources_respect_a_single_operation_batch_bound() {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join(".gitignore"), b"*.log\n");
write_file(&dir.path().join("debug.log"), b"ignored");
for threads in [1, 4] {
let config = ScanConfig {
read_controls: true,
threads: Some(threads),
batch_size: 1,
..ScanConfig::default()
};
let mut largest = 0;
let report = scan(dir.path(), &config, &mut |observation| {
largest = largest.max(observation.len());
})
.expect("scan");
assert!(report.is_complete(), "unexpected errors: {:?}", report.errors);
assert_eq!(largest, 1);
}
}
#[test]
fn cold_scan_matches_the_metabrowser_nested_control_fixture() {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join(".gitignore"), b"node_modules/\n*.pyc\n");
write_file(&dir.path().join("src/app.py"), b"x");
write_file(&dir.path().join("src/thing.pyc"), b"x");
write_file(&dir.path().join("src/generated/.gitignore"), b"*.gen\n");
write_file(&dir.path().join("src/generated/out.gen"), b"x");
write_file(&dir.path().join("node_modules/.gitignore"), b"!keep-me.py\n");
write_file(&dir.path().join("node_modules/keep-me.py"), b"x");
let (index, report) = scan_into_index(
dir.path(),
&ScanConfig { read_controls: true, threads: Some(4), ..ScanConfig::default() },
)
.expect("scan fixture");
assert!(report.is_complete(), "unexpected errors: {:?}", report.errors);
assert_eq!(
index.is_ignored(Path::new("src/app.py")).expect("control state observed"),
Some(false)
);
assert_eq!(
index.is_ignored(Path::new("src/thing.pyc")).expect("control state observed"),
Some(true)
);
assert_eq!(
index.is_ignored(Path::new("src/generated")).expect("control state observed"),
Some(false)
);
assert_eq!(
index.is_ignored(Path::new("src/generated/out.gen")).expect("control state observed"),
Some(true)
);
assert_eq!(
index.is_ignored(Path::new("node_modules")).expect("control state observed"),
Some(true)
);
assert_eq!(
index.is_ignored(Path::new("node_modules/keep-me.py")).expect("control state observed"),
Some(true)
);
}
#[test]
fn reconciliation_observes_same_metadata_control_edits_and_last_deletion() {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join(".gitignore"), b"*.log\n");
write_file(&dir.path().join("debug.log"), b"ignored");
let config = ScanConfig { read_controls: true, threads: Some(1), ..ScanConfig::default() };
let (mut index, report) = scan_into_index(dir.path(), &config).expect("scan");
assert!(report.is_complete());
assert_eq!(
index.is_ignored(Path::new("debug.log")).expect("control state observed"),
Some(true)
);
write_file(&dir.path().join(".gitignore"), b"*.tmp\n");
let edited = reconcile(&mut index, &config, &mut |_| {}).expect("edit reconcile");
assert!(edited.is_complete());
assert_eq!(edited.apply.controls, 1);
assert_eq!(edited.apply.reclassified, 1);
assert!(
index
.controls()
.expect("control state observed")
.source_is(Path::new(".gitignore"), b"*.tmp\n")
);
assert_eq!(
index.is_ignored(Path::new("debug.log")).expect("control state observed"),
Some(false)
);
fs::remove_file(dir.path().join(".gitignore")).expect("remove control");
let removed = reconcile(&mut index, &config, &mut |_| {}).expect("remove reconcile");
assert!(removed.is_complete());
assert_eq!(removed.apply.controls, 1);
assert!(index.controls().expect("control state observed").is_empty());
let partitions = index.partition_total().expect("control state observed");
assert_eq!(partitions.all, partitions.unignored);
}
#[cfg(unix)]
#[test]
fn directory_entry_metadata_does_not_follow_symlinks() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().expect("root");
let outside = tempfile::tempdir().expect("outside");
write_file(&outside.path().join("must-not-be-scanned.txt"), b"outside");
symlink(outside.path(), root.path().join("link")).expect("symlink");
let (index, report) = scan_into_index(root.path(), &ScanConfig::default()).expect("scan");
assert!(report.is_complete(), "unexpected errors: {:?}", report.errors);
assert_eq!(index.kind(Path::new("link")), Some(EntryKind::Symlink));
assert!(index.lookup(Path::new("link/must-not-be-scanned.txt")).is_none());
assert_eq!(index.total().files, 0);
}
#[test]
fn cold_scan_establishes_a_baseline_without_change_history() {
let dir = sample_tree();
let (index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
assert_eq!(index.clock(), crate::Clock::ZERO);
assert!(index.since(crate::Clock::ZERO).commits.is_empty());
}
#[test]
fn max_depth_stops_descent() {
let dir = sample_tree();
let config = ScanConfig { max_depth: Some(1), ..ScanConfig::default() };
let (index, _) = scan_into_index(dir.path(), &config).expect("scan");
assert!(index.lookup(Path::new("src")).is_some());
assert!(index.lookup(Path::new("src/main.rs")).is_none());
}
#[test]
fn zero_max_depth_keeps_only_the_index_root() {
let dir = sample_tree();
let config = ScanConfig { max_depth: Some(0), ..ScanConfig::default() };
let (index, report) = scan_into_index(dir.path(), &config).expect("scan");
assert!(index.is_empty());
assert_eq!(report.entries, 0);
assert_eq!(report.dirs_read, 0);
}
#[test]
fn direct_scan_records_the_canonical_root() {
let dir = sample_tree();
let aliased = dir.path().join(".");
let (index, _) = scan_into_index(&aliased, &ScanConfig::default()).expect("scan");
assert_eq!(index.root_path(), dir.path().canonicalize().expect("canonical root"));
}
#[test]
fn unsupported_symlink_following_is_rejected_on_cold_and_warm_paths() {
let dir = sample_tree();
let unsupported = ScanConfig { follow_symlinks: true, ..ScanConfig::default() };
assert!(matches!(
scan_into_index(dir.path(), &unsupported),
Err(Error::UnsupportedScanConfig(_))
));
let (index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
assert!(matches!(
revalidate(&index, &unsupported, &mut |_| {}),
Err(Error::UnsupportedScanConfig(_))
));
}
#[test]
fn revalidation_uses_the_same_depth_boundary_as_cold_scan() {
let dir = sample_tree();
let config = ScanConfig { max_depth: Some(1), ..ScanConfig::default() };
let (mut index, _) = scan_into_index(dir.path(), &config).expect("scan");
write_file(&dir.path().join("src/added-after-scan.txt"), b"new");
let mut observations = Vec::new();
revalidate(&index, &config, &mut |observation| observations.push(observation))
.expect("revalidate");
for observation in &observations {
index.apply_ok(observation);
}
assert!(index.lookup(Path::new("src/added-after-scan.txt")).is_none());
}
#[test]
fn zero_depth_revalidation_prunes_cached_root_children() {
let dir = tempfile::tempdir().expect("tempdir");
let config = ScanConfig { max_depth: Some(0), ..ScanConfig::default() };
let mut index = Index::new_with_scope(dir.path(), config.scope());
index.apply_baseline_ok(&Observation::new(vec![Op::Upsert {
path: PathBuf::from("stale.txt"),
kind: EntryKind::File,
attrs: Attrs::default(),
}]));
let mut observations = Vec::new();
let report = revalidate(&index, &config, &mut |observation| {
observations.push(observation);
})
.expect("revalidate");
for observation in &observations {
index.apply_ok(observation);
}
assert!(index.is_empty());
assert_eq!(report.dirs_read, 0);
}
#[test]
fn zero_depth_applying_reconciliation_prunes_cached_root_children() {
let dir = tempfile::tempdir().expect("tempdir");
let config = ScanConfig { max_depth: Some(0), ..ScanConfig::default() };
let mut index = Index::new_with_scope(dir.path(), config.scope());
index.apply_baseline_ok(&Observation::new(vec![Op::Upsert {
path: PathBuf::from("stale.txt"),
kind: EntryKind::File,
attrs: Attrs::default(),
}]));
let report = reconcile(&mut index, &config, &mut |_| {}).expect("reconcile");
assert!(index.is_empty());
assert_eq!(report.scan.dirs_read, 0);
}
#[test]
fn filesystem_boundary_is_part_of_the_shared_descent_policy() {
let config = ScanConfig { one_filesystem: true, ..ScanConfig::default() };
let attrs = Attrs { dev: 22, ..Attrs::default() };
assert!(!should_descend(EntryKind::Dir, attrs, 0, 11, &config));
assert!(should_descend(EntryKind::Dir, Attrs { dev: 11, ..attrs }, 0, 11, &config,));
}
#[test]
fn a_cold_scan_stamps_its_own_pass_start() {
let nanos = || {
i64::try_from(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("after the epoch")
.as_nanos(),
)
.expect("nanoseconds")
};
let dir = sample_tree();
let before = nanos();
let (index, report) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
let after = nanos();
assert!(report.is_complete() && report.entries > 0, "{report:?}");
let stamp = index.writing_pass_started_at_ns();
assert!(before <= stamp && stamp <= after, "{before} <= {stamp} <= {after}");
}
#[test]
fn scanning_a_file_is_an_error_not_a_panic() {
let dir = sample_tree();
let err = scan_into_index(&dir.path().join("a.txt"), &ScanConfig::default());
assert!(err.is_err());
}
#[test]
fn deltas_arrive_in_batches_of_the_configured_size() {
let dir = tempfile::tempdir().expect("tempdir");
for i in 0..25 {
write_file(&dir.path().join(format!("f{i}.txt")), b"x");
}
let config = ScanConfig { batch_size: 10, ..ScanConfig::default() };
let mut sizes = Vec::new();
scan(dir.path(), &config, &mut |d| sizes.push(d.len())).expect("scan");
assert!(sizes.len() >= 3, "expected several batches, got {sizes:?}");
assert!(sizes.iter().all(|&n| n <= 10));
assert_eq!(sizes.iter().sum::<usize>(), 25);
}
#[test]
fn invalid_batch_sizes_are_rejected_before_allocation() {
let zero = ScanConfig { batch_size: 0, ..ScanConfig::default() };
let unbounded = ScanConfig { batch_size: usize::MAX, ..ScanConfig::default() };
assert!(matches!(zero.validate(), Err(Error::UnsupportedScanConfig(_))));
assert!(matches!(unbounded.validate(), Err(Error::UnsupportedScanConfig(_))));
}
#[test]
fn reconciliation_scope_budget_publishes_before_returning_retry() {
let directory = tempfile::tempdir().expect("root");
let config = ScanConfig::default();
let (index, _) = scan_into_index(directory.path(), &config).expect("scan root-only tree");
let handle = IndexHandle::new(index);
let mut started = false;
let mut published_partial = false;
let report = reconcile_handle(&handle, &config, &mut |commit| {
if !started {
started = true;
for child in ["missing-a", "missing-b", "missing-c"] {
let nested = reconcile_subtree_handle(&handle, Path::new(child), &config, &mut |_| {}).expect("newer absent scope");
assert!(nested.is_complete());
}
}
if commit.state.iter().any(|state| matches!(state,
crate::StateTransition::IndexState { current, .. }
if current.coverage == crate::Coverage::Partial(crate::CoverageReason::Inaccessible))) {
assert_eq!(handle.read_with(Index::state).expect("coherent state").coverage,
crate::Coverage::Partial(crate::CoverageReason::Inaccessible));
published_partial = true;
}
}).expect("interrupted pass returns retryable report");
assert!(!report.is_complete());
assert!(report.retry_required);
assert!(published_partial, "the transition precedes the caller's retry result");
let recovered = reconcile_handle(&handle, &config, &mut |_| {}).expect("retry");
assert!(recovered.is_complete());
assert_eq!(
handle.read_with(Index::state).expect("recovered state").coverage,
crate::Coverage::Complete
);
}
#[test]
fn stale_arbitration_keeps_a_reconciliation_incomplete() {
let report = ReconcileReport {
scan: ScanReport::default(),
apply: ApplyStats { stale: 1, ..ApplyStats::default() },
observations: 1,
..ReconcileReport::default()
};
assert!(!report.is_complete());
}
#[test]
fn portable_system_time_conversion_preserves_pre_epoch_values() {
let before_epoch = std::time::UNIX_EPOCH
.checked_sub(std::time::Duration::from_secs(1))
.expect("represent pre-epoch fixture");
assert_eq!(system_time_ns(before_epoch), -1_000_000_000);
assert_eq!(system_time_ns(std::time::UNIX_EPOCH), 0);
}
#[cfg(not(unix))]
#[test]
fn one_filesystem_fails_when_device_identity_is_unavailable() {
let config = ScanConfig { one_filesystem: true, ..ScanConfig::default() };
assert!(matches!(config.validate(), Err(Error::UnsupportedScanConfig(_))));
}
#[test]
fn revalidate_is_a_no_op_against_an_unchanged_tree() {
let dir = sample_tree();
let (mut index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
let before = index.total();
let mut deltas = Vec::new();
revalidate(&index, &ScanConfig::default(), &mut |d| deltas.push(d)).expect("revalidate");
let mut unchanged = 0;
for delta in &deltas {
unchanged += index.apply_ok(delta).unchanged;
}
assert_eq!(unchanged, 5, "3 files + 2 dirs all already known");
assert_eq!(index.total(), before);
}
#[cfg(windows)]
#[test]
fn windows_reconcile_detects_same_size_rewrite_with_preserved_mtime() {
let root = tempfile::tempdir().expect("tempdir");
let path = root.path().join("same.txt");
write_file(&path, b"first");
let modified = fs::metadata(&path).expect("metadata").modified().expect("mtime");
let (mut index, _) =
scan_into_index(root.path(), &ScanConfig::default()).expect("initial scan");
let before = *index.attrs(Path::new("same.txt")).expect("initial attrs");
let stamped = std::time::UNIX_EPOCH
+ std::time::Duration::from_nanos(
u64::try_from(before.ctime_ns).expect("change time after the epoch"),
);
while std::time::SystemTime::now() <= stamped + std::time::Duration::from_millis(20) {
std::thread::sleep(std::time::Duration::from_millis(5));
}
write_file(&path, b"other");
File::options()
.write(true)
.open(&path)
.expect("open rewritten file")
.set_times(std::fs::FileTimes::new().set_modified(modified))
.expect("restore mtime");
reconcile(&mut index, &ScanConfig::default(), &mut |_| {}).expect("reconcile");
let after = *index.attrs(Path::new("same.txt")).expect("rewritten attrs");
assert_eq!((after.size, after.mtime_ns), (before.size, before.mtime_ns));
assert_ne!(after.ctime_ns, before.ctime_ns, "change time detects the rewrite");
assert_ne!(after.fingerprint(), before.fingerprint());
}
#[cfg(windows)]
#[test]
fn windows_reconcile_detects_path_identity_replacement() {
let root = tempfile::tempdir().expect("tempdir");
let path = root.path().join("replace.txt");
let displaced = root.path().join("displaced.txt");
write_file(&path, b"first");
let modified = fs::metadata(&path).expect("metadata").modified().expect("mtime");
let (mut index, _) =
scan_into_index(root.path(), &ScanConfig::default()).expect("initial scan");
let before = *index.attrs(Path::new("replace.txt")).expect("initial attrs");
fs::rename(&path, &displaced).expect("retain old file identity");
write_file(&path, b"other");
File::options()
.write(true)
.open(&path)
.expect("open replacement")
.set_times(std::fs::FileTimes::new().set_modified(modified))
.expect("restore mtime");
reconcile(&mut index, &ScanConfig::default(), &mut |_| {}).expect("reconcile");
let after = *index.attrs(Path::new("replace.txt")).expect("replacement attrs");
assert_eq!((after.size, after.mtime_ns), (before.size, before.mtime_ns));
assert_ne!(
(after.dev, after.inode),
(before.dev, before.inode),
"volume serial and file index identify the replacement"
);
assert_ne!(after.fingerprint(), before.fingerprint());
}
#[test]
fn direct_reconciliation_counts_unchanged_entries_and_publishes_state_commits() {
let dir = sample_tree();
let (mut index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
let before_total = index.total();
let before_clock = index.clock();
let mut commits = Vec::new();
let report = reconcile(&mut index, &ScanConfig::default(), &mut |commit| {
commits.push(commit.clone());
})
.expect("reconcile");
assert!(report.is_complete());
assert_eq!(report.apply.unchanged, 5, "3 files + 2 dirs all already known");
assert_eq!(commits.len(), 2);
assert!(commits.iter().all(|commit| commit.changes.is_empty()));
assert_eq!(
index.clock(),
crate::Clock(before_clock.0 + 2),
"start and finish are state commits"
);
let commits = index.since(before_clock).commits;
assert_eq!(commits.len(), 2);
assert!(commits.iter().all(|commit| commit.changes.is_empty()));
assert_eq!(index.total(), before_total);
}
#[test]
fn parallel_and_serial_reconciliation_produce_the_same_index() {
let dir = sample_tree();
let portable_config =
ScanConfig { threads: Some(1), batch_size: 2, ..ScanConfig::default() };
let (mut portable, _) =
scan_into_index(dir.path(), &portable_config).expect("portable baseline");
let mut bulk = portable.clone();
fs::remove_file(dir.path().join("a.txt")).expect("remove file");
write_file(&dir.path().join("src/main.rs"), b"fn main() { much longer }");
write_file(&dir.path().join("src/added.md"), b"new file");
crate::test_support::settle_allocations(dir.path());
let portable_report = reconcile(&mut portable, &portable_config, &mut |_| {})
.expect("portable reconciliation");
let bulk_config = ScanConfig { threads: Some(2), ..portable_config };
let bulk_report =
reconcile(&mut bulk, &bulk_config, &mut |_| {}).expect("bulk reconciliation");
assert!(portable_report.is_complete());
assert!(bulk_report.is_complete());
assert_eq!(bulk_report.scan.attribution, WalkAttribution::default());
assert_eq!(bulk_report.scan.entries, portable_report.scan.entries);
assert_eq!(bulk_report.scan.dirs_read, portable_report.scan.dirs_read);
assert_eq!(bulk_report.apply, portable_report.apply);
assert_eq!(index_fingerprint(&bulk), index_fingerprint(&portable));
assert_eq!(bulk.total(), portable.total());
}
#[test]
fn parallel_reconciliation_workers_publish_directory_counters() {
let _serial = crate::counters::test_serial();
let dir = sample_tree();
let baseline = ScanConfig { threads: Some(1), ..ScanConfig::default() };
let (mut index, scan) = scan_into_index(dir.path(), &baseline).expect("baseline scan");
assert!(scan.is_complete());
crate::counters::enable(true);
crate::counters::reset();
let config = ScanConfig { threads: Some(4), ..baseline };
let report = reconcile(&mut index, &config, &mut |_| {}).expect("reconciliation");
crate::counters::flush_thread();
let counts = crate::counters::snapshot();
crate::counters::reset();
crate::counters::enable(false);
assert!(report.is_complete());
assert!(
counts.dir_opens >= report.scan.dirs_read,
"parallel worker directory opens were folded: {counts:?}, report={report:?}"
);
}
#[test]
fn parallel_reconciliation_matches_serial_across_structural_transitions() {
for max_depth in [None, Some(1), Some(2)] {
for order in [ScanOrder::BreadthFirst, ScanOrder::DepthFirst] {
let dir = reconciliation_transition_tree();
let reference_config = ScanConfig {
order,
max_depth,
threads: Some(1),
batch_size: 2,
..ScanConfig::default()
};
let (baseline, baseline_report) =
scan_into_index(dir.path(), &reference_config).expect("baseline scan");
assert!(baseline_report.is_complete());
mutate_reconciliation_transition_tree(dir.path());
let mut serial = baseline.clone();
let mut serial_commits = Vec::new();
let serial_report = reconcile(&mut serial, &reference_config, &mut |commit| {
serial_commits.push(commit.clone());
})
.expect("serial reconciliation");
let (fresh, fresh_report) =
scan_into_index(dir.path(), &reference_config).expect("fresh oracle");
assert!(serial_report.is_complete(), "serial {order:?}/{max_depth:?}");
assert!(fresh_report.is_complete(), "fresh {order:?}/{max_depth:?}");
assert_eq!(
index_fingerprint(&serial),
index_fingerprint(&fresh),
"serial did not converge to a fresh scan for {order:?}/{max_depth:?}"
);
for workers in [2, 4] {
let mut parallel = baseline.clone();
let config = ScanConfig { threads: Some(workers), ..reference_config.clone() };
let mut parallel_commits = Vec::new();
let report = reconcile(&mut parallel, &config, &mut |commit| {
parallel_commits.push(commit.clone());
})
.expect("parallel reconciliation");
let context = format!("{order:?}/{max_depth:?}/{workers} workers");
assert!(report.is_complete(), "{context}: unexpected partial report");
assert_eq!(report.scan.entries, serial_report.scan.entries, "{context}");
assert_eq!(report.scan.dirs_read, serial_report.scan.dirs_read, "{context}");
assert_eq!(report.apply, serial_report.apply, "{context}");
assert_eq!(
effective_ops(¶llel_commits),
effective_ops(&serial_commits),
"{context}: effective delta differs"
);
assert_eq!(
index_fingerprint(¶llel),
index_fingerprint(&serial),
"{context}: final index differs"
);
let (parallel_total, serial_total) = (parallel.total(), serial.total());
assert_eq!(
(
parallel_total.files,
parallel_total.dirs,
parallel_total.bytes,
parallel_total.allocated,
parallel_total.newest_mtime_ns,
),
(
serial_total.files,
serial_total.dirs,
serial_total.bytes,
serial_total.allocated,
serial_total.newest_mtime_ns,
),
"{context}: roll-up differs"
);
assert_eq!(
parallel_total.by_ext, serial_total.by_ext,
"{context}: extension roll-up differs"
);
}
}
}
}
#[cfg(unix)]
#[test]
fn revalidation_metadata_errors_do_not_delete_enumerated_entries() {
use std::os::unix::fs::PermissionsExt;
if !crate::test_support::require_permission_bits() {
return;
}
let dir = sample_tree();
let config = ScanConfig::default();
let (mut index, baseline_report) =
scan_into_index(dir.path(), &config).expect("baseline scan");
assert!(baseline_report.is_complete());
let before = index_fingerprint(&index);
let original_permissions = fs::metadata(dir.path()).expect("root metadata").permissions();
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o400))
.expect("remove search permission");
let mut observations = Vec::new();
let outcome = revalidate(&index, &config, &mut |observation| {
observations.push(observation);
});
fs::set_permissions(dir.path(), original_permissions).expect("restore permissions");
let report = outcome.expect("operational metadata errors are a partial report");
assert!(!report.errors.is_empty(), "the fixture did not induce metadata errors");
for observation in &observations {
index.apply_ok(observation);
}
assert_eq!(index_fingerprint(&index), before);
assert!(index.attrs(Path::new("a.txt")).is_some(), "existing entry was removed");
}
#[cfg(unix)]
#[test]
fn reconciliation_metadata_errors_drop_unverified_entries_like_a_cold_scan() {
use std::os::unix::fs::PermissionsExt;
if !crate::test_support::require_permission_bits() {
return;
}
let worker_counts = if cfg!(target_os = "macos") { vec![1] } else { vec![1, 2] };
for workers in worker_counts {
let dir = sample_tree();
let config =
ScanConfig { threads: Some(workers), batch_size: 2, ..ScanConfig::default() };
let (mut index, baseline_report) =
scan_into_index(dir.path(), &config).expect("baseline scan");
assert!(baseline_report.is_complete());
let before = index_fingerprint(&index);
let original_permissions =
fs::metadata(dir.path()).expect("root metadata").permissions();
fs::set_permissions(dir.path(), fs::Permissions::from_mode(0o400))
.expect("remove search permission");
let outcome = reconcile(&mut index, &config, &mut |_| {});
let cold = scan_into_index(dir.path(), &config);
fs::set_permissions(dir.path(), original_permissions).expect("restore permissions");
let report = outcome.expect("operational metadata errors are a partial report");
let (cold, cold_report) = cold.expect("cold partial scan");
assert!(!report.scan.errors.is_empty(), "the fixture did not induce metadata errors");
assert!(!report.is_complete());
assert!(!cold_report.is_complete());
assert_eq!(index_fingerprint(&index), index_fingerprint(&cold));
assert!(
index_fingerprint(&index).is_empty(),
"neither warm nor cold may retain attributes it could not verify"
);
assert_eq!(index.directory_complete(Path::new("")), Some(false));
assert_eq!(cold.directory_complete(Path::new("")), Some(false));
assert!(!before.is_empty(), "the fixture began with retained facts");
}
}
#[cfg(unix)]
#[test]
fn failed_listing_withdraws_retained_completeness_and_recovers() {
use std::os::unix::fs::PermissionsExt;
if !crate::test_support::require_permission_bits() {
return;
}
for workers in [1, 2] {
let dir = tempfile::tempdir().expect("root");
write_file(&dir.path().join("ancestor/blocked/unknown.txt"), b"unknown");
write_file(&dir.path().join("healthy/known.txt"), b"known");
let config = ScanConfig { threads: Some(workers), ..ScanConfig::default() };
let (mut warm, baseline) = scan_into_index(dir.path(), &config).expect("baseline");
assert!(baseline.is_complete());
let blocked = dir.path().join("ancestor/blocked");
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).expect("deny listing");
let probe = fs::read_dir(&blocked);
let mut commits = Vec::new();
let refreshed =
reconcile(&mut warm, &config, &mut |commit| commits.push(commit.clone()));
let cold = scan_into_index(dir.path(), &config);
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).expect("restore");
assert_eq!(
probe.expect_err("real denied listing").kind(),
std::io::ErrorKind::PermissionDenied
);
assert!(!refreshed.expect("partial refresh").is_complete());
let (cold, report) = cold.expect("partial cold scan");
assert!(!report.is_complete());
for index in [&warm, &cold] {
for path in ["", "ancestor", "healthy"] {
assert_eq!(
index.directory_complete(Path::new(path)),
Some(true),
"workers={workers}: {path}"
);
}
assert_eq!(index.directory_complete(Path::new("ancestor/blocked")), Some(false));
assert_eq!(index.freshness_at(Path::new("ancestor")), crate::Freshness::Partial);
}
assert!(commits.iter().flat_map(|commit| &commit.state).any(|state| matches!(state,
crate::StateTransition::IndexState { current, .. } if current.coverage != crate::Coverage::Complete
)), "failure is published");
assert!(reconcile(&mut warm, &config, &mut |_| {}).expect("recovery").is_complete());
assert_eq!(warm.directory_complete(Path::new("ancestor/blocked")), Some(true));
assert_eq!(warm.state().coverage, crate::Coverage::Complete);
}
}
#[test]
fn unreadable_directory_warm_answer_matches_cold_verified_tree() {
for workers in [1, 2] {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join("blocked/old.txt"), b"old");
write_file(&dir.path().join("verified.txt"), b"verified");
crate::test_support::settle_allocations(dir.path());
let config = ScanConfig { threads: Some(workers), ..ScanConfig::default() };
let (mut warm, baseline) = scan_into_index(dir.path(), &config).expect("baseline");
assert!(baseline.is_complete());
let blocked = dir.path().join("blocked").canonicalize().expect("blocked path");
let hook = install_child_metadata_hook(dir.path(), move |path| {
(path == blocked)
.then(|| std::io::Error::from(std::io::ErrorKind::PermissionDenied))
});
let warm_report = reconcile(&mut warm, &config, &mut |_| {}).expect("warm partial");
let (cold, cold_report) = scan_into_index(dir.path(), &config).expect("cold partial");
drop(hook);
assert!(!warm_report.is_complete(), "workers={workers}");
assert!(!cold_report.is_complete(), "workers={workers}");
assert!(warm.lookup(Path::new("blocked")).is_none(), "workers={workers}");
assert!(warm.lookup(Path::new("blocked/old.txt")).is_none(), "workers={workers}");
assert_eq!(index_fingerprint(&warm), index_fingerprint(&cold), "workers={workers}");
assert!(warm.lookup(Path::new("verified.txt")).is_some(), "workers={workers}");
}
}
#[test]
fn deferred_change_overflow_retries_without_applying_a_partial_wave() {
let dir = sample_tree();
let config = ScanConfig { threads: Some(2), batch_size: 2, ..ScanConfig::default() };
let (mut index, _) = scan_into_index(dir.path(), &config).expect("baseline");
let before = index_fingerprint(&index);
fs::remove_file(dir.path().join("a.txt")).expect("remove file");
write_file(&dir.path().join("added.md"), b"new file");
write_file(&dir.path().join("src/main.rs"), b"fn main() { much longer }");
crate::test_support::settle_allocations(dir.path());
let root = index.root_path().to_path_buf();
let root_meta = {
crate::counters::bump(|c| c.stats += 1);
fs::symlink_metadata(&root)
}
.expect("root metadata");
let mut commits = Vec::new();
let outcome = reconcile_direct_parallel(
&mut index,
&root,
root_device(&root, &root_meta).expect("root device"),
&config,
1,
&mut |commit| commits.push(commit.clone()),
)
.expect("parallel attempt");
let DirectParallelOutcome::RetrySerial { prefix, remaining } = outcome else {
panic!("the deliberately tiny deferred budget must trigger the retry");
};
assert_eq!(prefix.apply, ApplyStats::default());
assert_eq!(remaining, VecDeque::from([(PathBuf::new(), 0)]));
assert!(commits.is_empty());
assert_eq!(index_fingerprint(&index), before);
let serial = ScanConfig { threads: Some(1), ..config };
let report = reconcile(&mut index, &serial, &mut |_| {}).expect("serial retry");
let (expected, expected_report) = scan_into_index(dir.path(), &serial).expect("oracle");
assert!(report.is_complete());
assert!(expected_report.is_complete());
assert_eq!(index_fingerprint(&index), index_fingerprint(&expected));
assert_eq!(index.total().files, expected.total().files);
assert_eq!(index.total().dirs, expected.total().dirs);
assert_eq!(index.total().bytes, expected.total().bytes);
assert_eq!(index.total().allocated, expected.total().allocated);
assert_eq!(index.total().newest_mtime_ns, expected.total().newest_mtime_ns);
assert_eq!(index.total().by_ext, expected.total().by_ext);
}
#[test]
fn late_overflow_resumes_without_double_counting_completed_waves() {
let dir = tempfile::tempdir().expect("tempdir");
for directory in 0..=RECONCILE_WAVE_DIRECTORIES {
write_file(&dir.path().join(format!("d{directory:04}/file.txt")), b"unchanged");
}
let parallel = ScanConfig { threads: Some(2), ..ScanConfig::default() };
let (baseline, _) = scan_into_index(dir.path(), ¶llel).expect("baseline");
let mut candidate = baseline.clone();
let mut serial_oracle = baseline;
for directory in 0..=RECONCILE_WAVE_DIRECTORIES {
write_file(
&dir.path().join(format!("d{directory:04}/file.txt")),
b"changed after the first wave",
);
}
crate::test_support::settle_allocations(dir.path());
let candidate_report = reconcile_target_inner(
&mut ReconcileTarget::Direct(&mut candidate),
Path::new(""),
0,
¶llel,
0,
&mut |_| {},
)
.expect("late-overflow reconciliation");
let serial = ScanConfig { threads: Some(1), ..parallel };
let oracle_report =
reconcile(&mut serial_oracle, &serial, &mut |_| {}).expect("serial oracle");
assert_eq!(candidate_report.apply, oracle_report.apply);
assert_eq!(candidate_report.scan.entries, oracle_report.scan.entries);
assert_eq!(candidate_report.scan.dirs_read, oracle_report.scan.dirs_read);
assert_eq!(index_fingerprint(&candidate), index_fingerprint(&serial_oracle));
}
fn walked(report: &ScanReport) -> (u64, u64, u64, u64) {
(report.dirs_read, report.files_walked, report.bytes_walked, report.allocated_walked)
}
fn reported(progress: &crate::Progress) -> (u64, u64, u64, u64) {
let snapshot = progress.snapshot();
(snapshot.directories, snapshot.files, snapshot.bytes, snapshot.allocated)
}
#[test]
fn every_walker_reports_exactly_what_its_report_counts() {
let dir = tempfile::tempdir().expect("tempdir");
for directory in 0..12 {
for file in 0..5 {
write_file(
&dir.path().join(format!("d{directory}/f{file}.txt")),
&vec![b'x'; directory * 5 + file + 1],
);
}
}
for threads in [1, 4] {
let context = format!("threads={threads}");
let progress = crate::Progress::new();
let cold_config = ScanConfig {
threads: Some(threads),
batch_size: 4,
progress: Some(progress.clone()),
..ScanConfig::default()
};
let (mut index, cold) = scan_into_index(dir.path(), &cold_config).expect("cold scan");
assert_eq!(cold.dirs_read, 13, "{context}: the root and twelve children");
assert_eq!(
progress.snapshot().phase,
crate::ProgressPhase::Indexing,
"{context}: the detached walk ends by assembling the index"
);
assert_eq!(reported(&progress), walked(&cold), "{context}: detached cold walk");
let progress = crate::Progress::new();
let config = ScanConfig { progress: Some(progress.clone()), ..cold_config.clone() };
let streamed = scan(dir.path(), &config, &mut |_| {}).expect("streaming scan");
assert_eq!(walked(&streamed), walked(&cold), "{context}");
assert_eq!(reported(&progress), walked(&streamed), "{context}: streaming walk");
let progress = crate::Progress::new();
let config = ScanConfig {
read_controls: false,
progress: Some(progress.clone()),
..cold_config.clone()
};
let folded = scan_summary_fold(dir.path(), &config, &mut |_| {}).expect("fold");
assert_eq!(walked(&folded), walked(&cold), "{context}");
assert_eq!(reported(&progress), walked(&folded), "{context}: summary fold");
let progress = crate::Progress::new();
let config = ScanConfig { progress: Some(progress.clone()), ..cold_config.clone() };
let revalidated = revalidate(&index, &config, &mut |_| {}).expect("revalidate");
assert_eq!(progress.snapshot().phase, crate::ProgressPhase::Revalidating, "{context}");
assert_eq!(reported(&progress), walked(&revalidated), "{context}: revalidate");
write_file(&dir.path().join(format!("d0/new{threads}.txt")), b"added");
fs::remove_file(dir.path().join(format!("d1/f{}.txt", threads - 1))).expect("remove");
write_file(&dir.path().join("d2/f0.txt"), &vec![b'y'; 40 + threads]);
let (_, fresh) = scan_into_index(dir.path(), &ScanConfig::default()).expect("fresh");
let progress = crate::Progress::new();
let config = ScanConfig { progress: Some(progress.clone()), ..cold_config.clone() };
let reconciled = reconcile(&mut index, &config, &mut |_| {}).expect("reconcile");
assert!(reconciled.apply.mutated(), "{context}: the changes were applied");
assert_eq!(progress.snapshot().phase, crate::ProgressPhase::Revalidating, "{context}");
assert_eq!(reported(&progress), walked(&reconciled.scan), "{context}: reconcile");
assert_eq!(walked(&reconciled.scan), walked(&fresh), "{context}: the whole tree");
let handle = crate::IndexHandle::new(index);
let progress = crate::Progress::new();
let config = ScanConfig { progress: Some(progress.clone()), ..cold_config };
let shared = reconcile_handle(&handle, &config, &mut |_| {}).expect("shared");
assert_eq!(reported(&progress), walked(&shared.scan), "{context}: shared handle");
}
}
#[test]
fn progress_counts_a_rewalked_wave_twice_where_the_report_counts_it_once() {
let dir = tempfile::tempdir().expect("tempdir");
for directory in 0..=RECONCILE_WAVE_DIRECTORIES {
write_file(&dir.path().join(format!("d{directory:04}/file.txt")), b"unchanged");
}
write_file(&dir.path().join("root.txt"), b"counted by the wave that completes");
let parallel = ScanConfig { threads: Some(2), ..ScanConfig::default() };
let (mut index, _) = scan_into_index(dir.path(), ¶llel).expect("baseline");
let changed = &vec![b'c'; 8_193];
for directory in 0..=RECONCILE_WAVE_DIRECTORIES {
write_file(&dir.path().join(format!("d{directory:04}/file.txt")), changed);
}
crate::test_support::settle_allocations(dir.path());
let progress = crate::Progress::new();
let observed = ScanConfig { progress: Some(progress.clone()), ..parallel };
let report = reconcile_target_inner(
&mut ReconcileTarget::Direct(&mut index),
Path::new(""),
0,
&observed,
0,
&mut |_| {},
)
.expect("late-overflow reconciliation");
let rewalked = u64::try_from(RECONCILE_WAVE_DIRECTORIES).expect("fits");
let snapshot = progress.snapshot();
assert_eq!(snapshot.directories, report.scan.dirs_read + rewalked);
assert_eq!(snapshot.files, report.scan.files_walked + rewalked);
assert_eq!(
snapshot.bytes,
report.scan.bytes_walked + rewalked * u64::try_from(changed.len()).expect("fits")
);
let allocated = index.attrs(Path::new("d0000/file.txt")).expect("indexed").allocated;
assert!(allocated > 0, "a file with content occupies blocks");
assert_eq!(snapshot.allocated, report.scan.allocated_walked + rewalked * allocated);
}
#[test]
fn a_multi_root_reconcile_sums_every_walked_count() {
let dir = tempfile::tempdir().expect("tempdir");
for directory in ["a", "b", "c"] {
for file in 0..3 {
write_file(&dir.path().join(format!("{directory}/f{file}.txt")), b"before");
}
}
let (mut index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
for directory in ["a", "b"] {
write_file(&dir.path().join(format!("{directory}/f0.txt")), &vec![b'x'; 5_000]);
}
index.apply_ok(&Observation::new(
["a", "b"]
.into_iter()
.map(|directory| Op::InvalidateSubtree {
path: PathBuf::from(directory),
reason: crate::InvalidateReason::Requested,
})
.collect(),
));
let report =
reconcile_pending(&mut index, &ScanConfig::default(), &mut |_| {}).expect("reconcile");
let attrs: Vec<Attrs> = ["a", "b"]
.into_iter()
.flat_map(|directory| (0..3).map(move |file| format!("{directory}/f{file}.txt")))
.map(|path| *index.attrs(Path::new(&path)).expect("indexed"))
.collect();
assert_eq!(report.scan.files_walked, 6, "the two roots' files, and not c's");
assert_eq!(report.scan.bytes_walked, attrs.iter().map(|attrs| attrs.size).sum::<u64>());
assert_eq!(
report.scan.allocated_walked,
attrs.iter().map(|attrs| attrs.allocated).sum::<u64>()
);
}
#[test]
fn shared_reconciliation_retains_conditional_no_op_arbitration() {
let dir = sample_tree();
let (index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
let handle = crate::IndexHandle::new(index);
let before_clock = handle.clock().expect("clock");
let mut commits = Vec::new();
let report = reconcile_handle(&handle, &ScanConfig::default(), &mut |commit| {
commits.push(commit.clone());
})
.expect("reconcile");
assert!(report.is_complete());
assert_eq!(report.apply.unchanged, 5, "3 files + 2 dirs all already known");
assert_eq!(commits.len(), 2);
assert!(commits.iter().all(|commit| commit.changes.is_empty()));
assert_eq!(
handle.clock().expect("clock"),
crate::Clock(before_clock.0 + 2),
"start and finish are state commits"
);
let commits = handle.since(before_clock).expect("state commits").commits;
assert_eq!(commits.len(), 2);
assert!(commits.iter().all(|commit| commit.changes.is_empty()));
}
#[test]
fn revalidate_detects_additions_edits_and_deletions() {
let dir = sample_tree();
let (mut index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
fs::remove_file(dir.path().join("a.txt")).expect("remove");
write_file(&dir.path().join("src/main.rs"), b"fn main() { longer }");
write_file(&dir.path().join("added.md"), b"new");
let mut deltas = Vec::new();
revalidate(&index, &ScanConfig::default(), &mut |d| deltas.push(d)).expect("revalidate");
let mut stats = crate::index::ApplyStats::default();
for delta in &deltas {
let s = index.apply_ok(delta);
stats.inserted += s.inserted;
stats.updated += s.updated;
stats.removed += s.removed;
}
assert_eq!(stats.inserted, 1, "added.md");
assert_eq!(stats.updated, 1, "main.rs grew");
assert_eq!(stats.removed, 1, "a.txt is gone");
let total = index.total();
assert_eq!(total.files, 3);
assert_eq!(total.bytes, 20 + 9 + 3);
assert!(!total.by_ext.contains_key(".txt"));
assert_eq!(total.by_ext[".md"].files, 1);
}
#[test]
fn revalidate_removes_a_whole_vanished_directory() {
let dir = sample_tree();
let (mut index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
fs::remove_dir_all(dir.path().join("src")).expect("remove dir");
let mut deltas = Vec::new();
revalidate(&index, &ScanConfig::default(), &mut |d| deltas.push(d)).expect("revalidate");
for delta in &deltas {
index.apply_ok(delta);
}
let total = index.total();
assert_eq!(total.files, 1);
assert_eq!(total.dirs, 0);
assert!(index.lookup(Path::new("src")).is_none());
}
#[test]
fn pending_invalidation_reconciles_the_requested_subtree() {
let dir = sample_tree();
let (mut index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
write_file(&dir.path().join("src/added.rs"), b"new");
index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
path: PathBuf::from("src"),
reason: crate::InvalidateReason::Requested,
}]));
assert_eq!(index.freshness_at(Path::new("src")), crate::Freshness::Stale);
let mut applied = Vec::new();
let report = reconcile_pending(&mut index, &ScanConfig::default(), &mut |delta| {
applied.push(delta.clone());
})
.expect("reconcile pending");
assert!(report.is_complete());
assert!(index.lookup(Path::new("src/added.rs")).is_some());
assert_eq!(index.freshness_at(Path::new("src")), crate::Freshness::Fresh);
assert!(index.take_pending_invalidations().is_empty());
assert!(applied.iter().any(|commit| commit_touches(commit, Path::new("src/added.rs"))));
}
#[test]
fn reconciling_a_retained_control_file_as_the_subtree_root_rereads_its_rules() {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join(".gitignore"), b"*.log\n");
write_file(&dir.path().join("a.log"), b"log");
let config = ScanConfig { read_controls: true, ..ScanConfig::default() };
let (mut index, _) = scan_into_index(dir.path(), &config).expect("scan");
assert_eq!(
index.is_ignored(Path::new("a.log")).expect("control state observed"),
Some(true)
);
write_file(&dir.path().join(".gitignore"), b"# nothing is ignored now\n");
index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
path: PathBuf::from(".gitignore"),
reason: crate::InvalidateReason::Requested,
}]));
let report = reconcile_pending(&mut index, &config, &mut |_| {}).expect("reconcile");
assert!(report.is_complete(), "{:?}", report.scan.errors);
assert_eq!(index.freshness_at(Path::new(".gitignore")), crate::Freshness::Fresh);
assert_eq!(
index.is_ignored(Path::new("a.log")).expect("control state observed"),
Some(false)
);
}
#[cfg(unix)]
#[test]
fn reconciling_an_unreadable_control_file_root_drops_its_rules_and_stays_partial() {
use std::os::unix::fs::PermissionsExt;
if !crate::test_support::require_permission_bits() {
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let control = dir.path().join(".gitignore");
write_file(&control, b"*.log\n");
write_file(&dir.path().join("a.log"), b"log");
let config = ScanConfig { read_controls: true, ..ScanConfig::default() };
let (mut index, _) = scan_into_index(dir.path(), &config).expect("scan");
write_file(&control, b"# rewritten, then made unreadable\n");
fs::set_permissions(&control, fs::Permissions::from_mode(0o000)).expect("chmod");
index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
path: PathBuf::from(".gitignore"),
reason: crate::InvalidateReason::Requested,
}]));
let report = reconcile_pending(&mut index, &config, &mut |_| {});
fs::set_permissions(&control, fs::Permissions::from_mode(0o644)).expect("restore");
let report = report.expect("reconcile");
assert!(!report.is_complete());
assert_eq!(report.scan.errors.len(), 1, "{:?}", report.scan.errors);
assert_eq!(index.freshness_at(Path::new(".gitignore")), crate::Freshness::Partial);
assert!(
!index.controls().expect("control state observed").contains(Path::new(".gitignore"))
);
assert_eq!(index.is_ignored(Path::new("a.log")).expect("control state observed"), None);
}
#[cfg(unix)]
#[test]
fn unreadable_control_keeps_new_excluded_file_unknown_and_out_of_analysis() {
use std::os::unix::fs::PermissionsExt;
if !crate::test_support::require_permission_bits() {
return;
}
let dir = tempfile::tempdir().expect("tempdir");
let control = dir.path().join(".gitignore");
write_file(&control, b"*.log\n");
write_file(&dir.path().join("keep.rs"), b"code");
let config = ScanConfig {
population: crate::query::IgnoredEntries::Exclude,
..ScanConfig::default()
};
let (mut index, cold) = scan_into_index(dir.path(), &config).expect("cold");
assert!(cold.is_complete());
write_file(&dir.path().join("debug.log"), b"must not analyze");
fs::set_permissions(&control, fs::Permissions::from_mode(0o000)).expect("chmod");
let report = reconcile(&mut index, &config, &mut |_| {});
fs::set_permissions(&control, fs::Permissions::from_mode(0o644)).expect("restore");
let report = report.expect("reconcile");
assert!(!report.is_complete());
assert!(index.lookup(Path::new("debug.log")).is_some(), "unknown is retained");
assert_eq!(index.ignored_classification(Path::new("debug.log")), None);
assert!(!index.ignored_classification_complete_below(Path::new("")));
let candidates = index.analysis_candidates(crate::content::AnalysisSet::NONE.with_lines());
assert!(
candidates.iter().all(|candidate| candidate.relative_path != Path::new("debug.log"))
);
let repaired =
reconcile(&mut index, &config, &mut |_| {}).expect("reconcile repaired control");
assert!(repaired.is_complete(), "{:?}", repaired.scan.errors);
assert!(index.ignored_classification_complete_below(Path::new("")));
assert!(index.lookup(Path::new("debug.log")).is_none(), "known ignored file is pruned");
}
#[test]
fn handle_reconciliation_publishes_after_each_delta_is_applied() {
let dir = sample_tree();
let (index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
let handle = crate::IndexHandle::new(index);
let reader = handle.clone();
write_file(&dir.path().join("added.md"), b"new");
let mut observed_after_apply = false;
reconcile_handle(&handle, &ScanConfig::default(), &mut |commit| {
if commit_touches(commit, Path::new("added.md")) {
observed_after_apply =
reader.kind(Path::new("added.md")).expect("query index").is_some();
}
})
.expect("reconcile handle");
assert!(observed_after_apply);
}
fn delete_between_listing_and_stat(root: &Path, name: &'static str) -> WalkHookGuard {
install_child_metadata_hook(root, move |path| {
delete_if_named(path, name);
None
})
}
fn delete_between_listing_and_stat_in_a_failing_listing(
root: &Path,
name: &'static str,
) -> WalkHookGuard {
install_walk_hook(root, move |point| match point {
WalkHookPoint::ChildMetadata(path) => {
delete_if_named(path, name);
None
}
WalkHookPoint::ListingEnd => Some(std::io::Error::other("injected listing error")),
WalkHookPoint::ControlLookup(_) => None,
})
}
fn delete_if_named(path: &Path, name: &str) {
if path.file_name() == Some(OsStr::new(name)) {
fs::remove_file(path).expect("delete between listing and stat");
}
}
#[test]
fn a_child_deleted_between_listing_and_stat_is_removed_rather_than_reported() {
for threads in [1, 4] {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join("keep.txt"), b"keep");
write_file(&dir.path().join("gone.txt"), b"gone");
let config = ScanConfig { threads: Some(threads), ..ScanConfig::default() };
let (mut index, _) = scan_into_index(dir.path(), &config).expect("scan");
assert!(index.lookup(Path::new("gone.txt")).is_some());
index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
path: PathBuf::new(),
reason: crate::InvalidateReason::Requested,
}]));
let hook = delete_between_listing_and_stat(dir.path(), "gone.txt");
let report = reconcile_pending(&mut index, &config, &mut |_| {});
drop(hook);
let report = report.expect("reconcile");
assert!(report.is_complete(), "threads {threads}: {:?}", report.scan.errors);
assert_eq!(report.apply.removed, 1, "threads {threads}");
assert!(index.lookup(Path::new("gone.txt")).is_none(), "threads {threads}");
assert!(index.lookup(Path::new("keep.txt")).is_some(), "threads {threads}");
assert_eq!(index.freshness(), crate::Freshness::Fresh, "threads {threads}");
}
}
#[test]
fn a_control_file_deleted_between_listing_and_stat_takes_its_rules_with_it() {
for prune_hidden in [false, true] {
for (threads, listing_fails) in [(1, false), (4, false), (1, true), (4, true)] {
let case = format!(
"prune hidden {prune_hidden}, threads {threads}, listing fails {listing_fails}"
);
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join(".gitignore"), b"*.log\n");
write_file(&dir.path().join("a.log"), b"log");
let config = ScanConfig {
read_controls: true,
threads: Some(threads),
hidden: prune_hidden
.then(|| std::sync::Arc::new(crate::HiddenPolicy::prune_hidden([""; 0]))),
..ScanConfig::default()
};
let (mut index, _) = scan_into_index(dir.path(), &config).expect("scan");
assert_eq!(
index.is_ignored(Path::new("a.log")).expect("control state observed"),
Some(true),
"{case}"
);
index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
path: PathBuf::new(),
reason: crate::InvalidateReason::Requested,
}]));
let hook = if listing_fails {
delete_between_listing_and_stat_in_a_failing_listing(dir.path(), ".gitignore")
} else {
delete_between_listing_and_stat(dir.path(), ".gitignore")
};
let report = reconcile_pending(&mut index, &config, &mut |_| {});
drop(hook);
let report = report.expect("reconcile");
assert_eq!(
report.is_complete(),
!listing_fails,
"{case}: {:?}",
report.scan.errors
);
assert!(index.lookup(Path::new(".gitignore")).is_none(), "{case}");
assert!(
!index
.controls()
.expect("control state observed")
.contains(Path::new(".gitignore")),
"{case}"
);
assert_eq!(
index.is_ignored(Path::new("a.log")).expect("control state observed"),
Some(false),
"{case}"
);
}
}
}
#[test]
fn a_cold_walk_omits_a_child_deleted_between_listing_and_stat() {
for threads in [1, 4] {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join("keep.txt"), b"keep");
write_file(&dir.path().join("gone.txt"), b"gone");
let config = ScanConfig { threads: Some(threads), ..ScanConfig::default() };
let hook = delete_between_listing_and_stat(dir.path(), "gone.txt");
let scanned = scan_into_index_via_scanner(dir.path(), &config);
drop(hook);
let (index, report) = scanned.expect("scan");
assert!(report.is_complete(), "threads {threads}: {:?}", report.errors);
assert!(index.lookup(Path::new("gone.txt")).is_none(), "threads {threads}");
assert!(index.lookup(Path::new("keep.txt")).is_some(), "threads {threads}");
}
}
#[test]
fn revalidation_removes_a_child_deleted_between_listing_and_stat() {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join("keep.txt"), b"keep");
write_file(&dir.path().join("gone.txt"), b"gone");
let (mut index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
let hook = delete_between_listing_and_stat(dir.path(), "gone.txt");
let mut observations = Vec::new();
let report = revalidate(&index, &ScanConfig::default(), &mut |observation| {
observations.push(observation);
});
drop(hook);
let report = report.expect("revalidate");
for observation in &observations {
index.apply_ok(observation);
}
assert!(report.is_complete(), "{:?}", report.errors);
assert!(index.lookup(Path::new("gone.txt")).is_none());
assert!(index.lookup(Path::new("keep.txt")).is_some());
}
#[test]
fn revalidation_removes_the_rules_of_a_control_file_deleted_in_a_failing_listing() {
for prune_hidden in [false, true] {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join(".gitignore"), b"*.log\n");
write_file(&dir.path().join("a.log"), b"log");
let config = ScanConfig {
read_controls: true,
hidden: prune_hidden
.then(|| std::sync::Arc::new(crate::HiddenPolicy::prune_hidden([""; 0]))),
..ScanConfig::default()
};
let (mut index, _) = scan_into_index(dir.path(), &config).expect("scan");
assert_eq!(
index.is_ignored(Path::new("a.log")).expect("control state observed"),
Some(true)
);
let hook =
delete_between_listing_and_stat_in_a_failing_listing(dir.path(), ".gitignore");
let mut observations = Vec::new();
let report = revalidate(&index, &config, &mut |observation| {
observations.push(observation);
});
drop(hook);
let report = report.expect("revalidate");
for observation in &observations {
index.apply_ok(observation);
}
assert!(!report.is_complete(), "prune hidden {prune_hidden}");
assert!(index.lookup(Path::new(".gitignore")).is_none(), "prune hidden {prune_hidden}");
assert!(
!index
.controls()
.expect("control state observed")
.contains(Path::new(".gitignore")),
"prune hidden {prune_hidden}"
);
assert_eq!(
index.is_ignored(Path::new("a.log")).expect("control state observed"),
Some(false),
"prune hidden {prune_hidden}"
);
}
}
#[test]
fn reconciliation_does_not_clear_a_newer_invalidation() {
let dir = sample_tree();
let (index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
let handle = crate::IndexHandle::new(index);
write_file(&dir.path().join("added.md"), b"new");
let invalidator = handle.clone();
let mut saw_reconciling = false;
reconcile_handle(&handle, &ScanConfig::default(), &mut |commit| {
if commit_touches(commit, Path::new("added.md")) {
saw_reconciling =
invalidator.freshness().expect("query") == crate::Freshness::Reconciling;
invalidator
.apply(&Observation::new(vec![Op::InvalidateSubtree {
path: PathBuf::new(),
reason: crate::InvalidateReason::WatchOverflow,
}]))
.expect("new invalidation");
}
})
.expect("reconcile handle");
assert!(saw_reconciling);
assert_eq!(handle.freshness().expect("query"), crate::Freshness::Stale);
}
#[test]
fn failed_reconciliation_marks_the_scope_partial() {
let dir = sample_tree();
let (mut index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
fs::remove_dir_all(dir.path()).expect("remove root");
assert!(reconcile(&mut index, &ScanConfig::default(), &mut |_| {}).is_err());
assert_eq!(index.freshness(), crate::Freshness::Partial);
}
#[test]
fn successful_subtree_retry_restores_complete_root_coverage() {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join("blocked/known.txt"), b"known");
let config = ScanConfig::default();
let (mut index, report) = scan_into_index(dir.path(), &config).expect("scan");
assert!(report.is_complete());
let blocked = dir.path().join("blocked");
let fault = install_walk_hook(&blocked, |_| {
Some(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"deterministic subtree refusal",
))
});
let failed = reconcile_subtree(&mut index, Path::new("blocked"), &config, &mut |_| {})
.expect("partial");
assert!(!failed.scan.is_complete());
assert_eq!(
index.state().coverage,
crate::Coverage::Partial(crate::CoverageReason::Inaccessible)
);
drop(fault);
let recovered = reconcile_subtree(&mut index, Path::new("blocked"), &config, &mut |_| {})
.expect("retry");
assert!(recovered.scan.is_complete());
assert_eq!(index.state().coverage, crate::Coverage::Complete);
}
#[test]
fn failed_pending_reconciliation_remains_queued_for_retry() {
let dir = tempfile::tempdir().expect("tempdir");
let (mut index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
path: PathBuf::new(),
reason: crate::InvalidateReason::Requested,
}]));
fs::remove_dir_all(dir.path()).expect("remove root");
assert!(reconcile_pending(&mut index, &ScanConfig::default(), &mut |_| {}).is_err());
assert_eq!(
index.take_pending_invalidations(),
vec![(PathBuf::new(), crate::InvalidateReason::Requested)]
);
assert_eq!(index.freshness(), crate::Freshness::Partial);
}
#[cfg(unix)]
#[test]
fn partial_cold_scan_keeps_verified_siblings_complete() {
use std::os::unix::fs::PermissionsExt;
if !crate::test_support::require_permission_bits() {
return;
}
let root = tempfile::tempdir().expect("root");
write_file(&root.path().join("blocked/unknown.txt"), b"unread");
write_file(&root.path().join("healthy/nested/known.txt"), b"known");
let blocked = root.path().join("blocked");
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).expect("deny reads");
let scan = ScanConfig::default();
let detached = scan_into_index(root.path(), &scan);
let streamed = scan_into_index_via_scanner(root.path(), &scan);
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).expect("restore reads");
for result in [detached, streamed] {
let (index, report) = result.expect("partial scan still returns its facts");
assert!(!report.is_complete(), "permission fixture must fail the blocked listing");
assert!(
index
.issues()
.iter()
.any(|issue| issue.path.as_deref() == Some(Path::new("blocked")))
);
assert_eq!(index.freshness_at(Path::new("")), crate::Freshness::Partial);
assert_eq!(index.directory_complete(Path::new("")), Some(true));
assert_eq!(index.directory_complete(Path::new("blocked")), Some(false));
assert_eq!(index.freshness_at(Path::new("blocked")), crate::Freshness::Partial);
assert!(index.lookup(Path::new("blocked/unknown.txt")).is_none());
for sibling in ["healthy", "healthy/nested"] {
assert_eq!(index.directory_complete(Path::new(sibling)), Some(true), "{sibling}");
assert_eq!(
index.freshness_at(Path::new(sibling)),
crate::Freshness::Fresh,
"{sibling}"
);
}
assert!(index.lookup(Path::new("healthy/nested/known.txt")).is_some());
assert!(
!crate::stored_state::entries_writable(&index),
"partial root cannot persist metadata"
);
}
}
#[cfg(unix)]
#[test]
fn partial_pending_reconciliation_remains_queued_for_retry() {
use std::os::unix::fs::PermissionsExt;
if !crate::test_support::require_permission_bits() {
return;
}
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join("blocked/known.txt"), b"known");
let (mut index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
let blocked = dir.path().join("blocked");
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).expect("deny reads");
index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
path: PathBuf::from("blocked"),
reason: crate::InvalidateReason::VerificationFailed,
}]));
let report = reconcile_pending(&mut index, &ScanConfig::default(), &mut |_| {})
.expect("permission failure is a partial report");
let pending = index.take_pending_invalidations();
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).expect("restore reads");
assert!(!report.is_complete(), "permission fixture must make reconciliation partial");
assert_eq!(
pending,
vec![(PathBuf::from("blocked"), crate::InvalidateReason::VerificationFailed)]
);
assert_eq!(index.freshness_at(Path::new("blocked")), crate::Freshness::Partial);
}
#[cfg(unix)]
#[test]
fn partial_shared_pending_reconciliation_settles_instead_of_retrying() {
use std::os::unix::fs::PermissionsExt;
if !crate::test_support::require_permission_bits() {
return;
}
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join("blocked/known.txt"), b"known");
let (index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
let handle = crate::IndexHandle::new(index);
let blocked = dir.path().join("blocked");
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o000)).expect("deny reads");
handle
.apply(&Observation::new(vec![Op::InvalidateSubtree {
path: PathBuf::from("blocked"),
reason: crate::InvalidateReason::VerificationFailed,
}]))
.expect("invalidate");
let report = reconcile_pending_handle(&handle, &ScanConfig::default(), &mut |_| {})
.expect("permission failure is a partial report");
let pending = handle.take_pending_invalidations().expect("pending");
let freshness = handle.freshness_at(Path::new("blocked")).expect("freshness");
fs::set_permissions(&blocked, fs::Permissions::from_mode(0o700)).expect("restore reads");
assert!(!report.is_complete(), "permission fixture must make reconciliation partial");
assert!(pending.is_empty(), "{pending:?}");
assert_eq!(freshness, crate::Freshness::Partial);
assert!(!report.scan.errors.is_empty());
}
#[test]
fn pending_scope_mismatch_does_not_drain_the_retry_queue() {
let dir = tempfile::tempdir().expect("tempdir");
let shallow = ScanConfig { max_depth: Some(1), ..ScanConfig::default() };
let (mut index, _) = scan_into_index(dir.path(), &shallow).expect("scan");
index.apply_ok(&Observation::new(vec![Op::InvalidateSubtree {
path: PathBuf::new(),
reason: crate::InvalidateReason::Requested,
}]));
let error = reconcile_pending(&mut index, &ScanConfig::default(), &mut |_| {})
.expect_err("mismatched scope must fail");
assert!(matches!(error, Error::ScanScopeMismatch { .. }));
assert_eq!(
index.take_pending_invalidations(),
vec![(PathBuf::new(), crate::InvalidateReason::Requested)]
);
}
#[test]
fn reconciliation_rejects_a_scope_mismatch_before_mutating() {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join("deep/nested.txt"), b"nested");
let shallow = ScanConfig { max_depth: Some(1), ..ScanConfig::default() };
let (mut index, _) = scan_into_index(dir.path(), &shallow).expect("scan");
assert!(index.lookup(Path::new("deep/nested.txt")).is_none());
let error = reconcile(&mut index, &ScanConfig::default(), &mut |_| {})
.expect_err("mismatched scope must fail");
assert!(matches!(error, Error::ScanScopeMismatch { .. }));
assert!(index.lookup(Path::new("deep/nested.txt")).is_none());
assert_eq!(index.freshness(), crate::Freshness::Fresh);
}
#[test]
fn subtree_reconciliation_rejects_a_path_beyond_the_depth_scope() {
let dir = tempfile::tempdir().expect("tempdir");
write_file(&dir.path().join("deep/nested.txt"), b"nested");
let shallow = ScanConfig { max_depth: Some(1), ..ScanConfig::default() };
let (mut index, _) = scan_into_index(dir.path(), &shallow).expect("scan");
let result =
reconcile_subtree(&mut index, Path::new("deep/nested.txt"), &shallow, &mut |_| {});
assert!(matches!(result, Err(Error::SubtreeOutsideScanScope { .. })));
assert!(index.lookup(Path::new("deep/nested.txt")).is_none());
assert_eq!(index.freshness(), crate::Freshness::Fresh);
}
#[cfg(unix)]
#[test]
fn subtree_reconciliation_does_not_follow_an_ancestor_symlink() {
use std::os::unix::fs::symlink;
let root = tempfile::tempdir().expect("root");
let outside = tempfile::tempdir().expect("outside");
write_file(&outside.path().join("secret.txt"), b"secret");
symlink(outside.path(), root.path().join("link")).expect("symlink");
let config = ScanConfig::default();
let (mut index, _) = scan_into_index(root.path(), &config).expect("scan");
let result =
reconcile_subtree(&mut index, Path::new("link/secret.txt"), &config, &mut |_| {});
assert!(matches!(result, Err(Error::SubtreeOutsideScanScope { .. })));
assert!(index.lookup(Path::new("link/secret.txt")).is_none());
assert_eq!(index.freshness(), crate::Freshness::Fresh);
}
#[test]
fn subtree_reconciliation_widens_to_a_non_directory_ancestor() {
let root = tempfile::tempdir().expect("root");
write_file(&root.path().join("parent/child.txt"), b"old");
let config = ScanConfig::default();
let (mut index, _) = scan_into_index(root.path(), &config).expect("scan");
fs::remove_dir_all(root.path().join("parent")).expect("remove directory");
write_file(&root.path().join("parent"), b"replacement");
let report =
reconcile_subtree(&mut index, Path::new("parent/child.txt"), &config, &mut |_| {})
.expect("reconcile widened ancestor");
assert!(report.is_complete());
assert_eq!(index.kind(Path::new("parent")), Some(EntryKind::File));
assert!(index.lookup(Path::new("parent/child.txt")).is_none());
assert_eq!(index.freshness(), crate::Freshness::Fresh);
}
#[test]
fn subtree_reconciliation_widens_to_a_missing_ancestor() {
let root = tempfile::tempdir().expect("root");
write_file(&root.path().join("parent/child.txt"), b"old");
let config = ScanConfig::default();
let (mut index, _) = scan_into_index(root.path(), &config).expect("scan");
fs::remove_dir_all(root.path().join("parent")).expect("remove directory");
let report =
reconcile_subtree(&mut index, Path::new("parent/child.txt"), &config, &mut |_| {})
.expect("reconcile widened ancestor");
assert!(report.is_complete());
assert!(index.lookup(Path::new("parent")).is_none());
assert_eq!(index.freshness(), crate::Freshness::Fresh);
}
#[test]
fn observation_only_revalidation_rejects_a_scope_mismatch() {
let dir = tempfile::tempdir().expect("tempdir");
let shallow = ScanConfig { max_depth: Some(1), ..ScanConfig::default() };
let (index, _) = scan_into_index(dir.path(), &shallow).expect("scan");
let mut observations = Vec::new();
let error = revalidate(&index, &ScanConfig::default(), &mut |observation| {
observations.push(observation);
})
.expect_err("mismatched scope must fail");
assert!(matches!(error, Error::ScanScopeMismatch { .. }));
assert!(observations.is_empty());
}
#[cfg(unix)]
#[test]
fn a_new_filesystem_boundary_prunes_cached_descendants() {
use std::os::unix::fs::MetadataExt;
let root = Path::new("/");
let root_dev = {
crate::counters::bump(|c| c.stats += 1);
fs::symlink_metadata(root)
}
.expect("stat root")
.dev();
let Some(mount) = [Path::new("/dev"), Path::new("/proc"), Path::new("/sys")]
.into_iter()
.find(|candidate| {
fs::symlink_metadata(candidate)
.is_ok_and(|metadata| metadata.is_dir() && metadata.dev() != root_dev)
})
else {
return; };
let relative = mount.strip_prefix(root).expect("mount is below root");
let stale_child = relative.join(".fdu-stale-snapshot-entry");
let config = ScanConfig { one_filesystem: true, ..ScanConfig::default() };
let mount_meta = fs::symlink_metadata(mount).expect("stat mount");
let mut index = Index::new_with_scope(root, config.scope());
index.apply_baseline_ok(&Observation::new(vec![
Op::Upsert {
path: relative.to_path_buf(),
kind: EntryKind::Dir,
attrs: attrs_from(mount, &mount_meta).expect("mount attrs"),
},
Op::Upsert {
path: stale_child.clone(),
kind: EntryKind::File,
attrs: Attrs { size: 10, allocated: 10, ..Attrs::default() },
},
]));
let error = reconcile_subtree(&mut index, &stale_child, &config, &mut |_| {})
.expect_err("a descendant below the mount boundary is outside scope");
assert!(matches!(error, Error::SubtreeOutsideScanScope { .. }));
assert!(index.lookup(&stale_child).is_some());
reconcile_subtree(&mut index, relative, &config, &mut |_| {}).expect("reconcile mount");
assert!(index.lookup(relative).is_some(), "the mount point itself stays visible");
assert!(index.lookup(&stale_child).is_none(), "out-of-scope descendants are pruned");
}
#[test]
fn subtree_reconciliation_rejects_paths_outside_the_root() {
let dir = sample_tree();
let (mut index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
assert!(matches!(
reconcile_subtree(
&mut index,
Path::new("../outside"),
&ScanConfig::default(),
&mut |_| {},
),
Err(Error::PathEscapesRoot(_))
));
assert_eq!(index.freshness(), crate::Freshness::Fresh);
}
#[test]
fn normalized_walk_errors_keep_index_and_one_shot_status_in_lockstep() {
let root = Path::new("/root");
let mut order: Vec<_> = (0..66).rev().collect();
order.push(65);
let mut errors = order
.into_iter()
.map(|number| {
Error::io(
root.join(format!("file-{number:02}")),
std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
)
})
.collect::<Vec<_>>();
normalize_walk_errors(root, &mut errors);
assert_eq!(errors.len(), 66, "the repeated cause is removed once");
let mut index = crate::Index::new(root);
index.record_walk_errors(&mut errors);
let status = crate::query::TreeStatus::of_walk(
root,
&mut ScanReport { errors, ..ScanReport::default() },
);
assert_eq!(status.errors, index.issues());
assert_eq!(status.errors_omitted, index.state().issues.omitted);
assert_eq!(status.errors.len(), crate::MAX_RETAINED_ISSUES);
assert_eq!(status.errors_omitted, 2);
}
#[cfg(target_os = "linux")]
#[test]
fn scan_and_revalidate_keep_non_utf8_names_distinct() {
use std::ffi::OsString;
use std::os::unix::ffi::OsStringExt;
let dir = tempfile::tempdir().expect("tempdir");
let first = PathBuf::from(OsString::from_vec(vec![b'n', 0x80]));
let second = PathBuf::from(OsString::from_vec(vec![b'n', 0x81]));
write_file(&dir.path().join(&first), b"a");
write_file(&dir.path().join(&second), b"bb");
let (mut index, _) = scan_into_index(dir.path(), &ScanConfig::default()).expect("scan");
assert_eq!(index.total().files, 2);
assert_eq!(index.total().bytes, 3);
fs::remove_file(dir.path().join(&first)).expect("remove first");
let mut observations = Vec::new();
revalidate(&index, &ScanConfig::default(), &mut |observation| {
observations.push(observation);
})
.expect("revalidate");
for observation in &observations {
index.apply_ok(observation);
}
assert!(index.lookup(&first).is_none());
assert!(index.lookup(&second).is_some());
assert_eq!(index.total().bytes, 2);
}
}