diff --git a/hive-agent/src/disk_watch.rs b/hive-agent/src/disk_watch.rs index 0054bdf3..9e3a4dc3 100644 --- a/hive-agent/src/disk_watch.rs +++ b/hive-agent/src/disk_watch.rs @@ -74,7 +74,8 @@ const REPORT_DEPTH: usize = 4; /// depth on a pathological tree. const MAX_DEPTH: usize = 24; /// Directory entries the scan will look at before giving up. Bounds the -/// worst case on a huge tree; a truncated scan just names fewer dirs. +/// worst case on a huge tree. A truncated scan names fewer dirs, and its +/// footprint total is treated as unknown rather than small. const SCAN_BUDGET: u64 = 400_000; /// Background loop: periodically reconcile the disk todo. Detached task — @@ -217,17 +218,27 @@ fn roots() -> Vec { /// walk trades a little redundant `read_dir` work for keeping this and /// [`big_dirs`] as two small, independently obvious functions instead of /// one that returns a tuple nobody asked for. +/// +/// A walk that runs out of its entry budget has only counted part of the +/// tree, so its sum is a lower bound. That is reported as `u64::MAX` +/// ("at least the floor") so a truncated scan can make the todo fire but +/// never silence it. fn agent_footprint_bytes() -> u64 { - let mut budget = SCAN_BUDGET; + footprint_within(&roots(), SCAN_BUDGET) +} + +/// [`agent_footprint_bytes`] over the given roots with an explicit entry +/// budget, so the truncation rule is testable without a huge tree. +fn footprint_within(roots: &[PathBuf], mut budget: u64) -> u64 { let mut discard = Vec::new(); let mut total: u64 = 0; - for root in roots() { - let Ok(md) = std::fs::metadata(&root) else { + for root in roots { + let Ok(md) = std::fs::metadata(root) else { continue; }; - total = total.saturating_add(dir_bytes(&root, md.dev(), 0, &mut budget, &mut discard)); + total = total.saturating_add(dir_bytes(root, md.dev(), 0, &mut budget, &mut discard)); } - total + if budget == 0 { u64::MAX } else { total } } /// Walk the agent's own tree and return the directories worth naming: @@ -460,4 +471,19 @@ mod tests { reconcile(&todos, &wake, None); assert!(todos.list(Some(SUBSYSTEM)).expect("list").is_empty()); } + + /// A walk that fits its budget reports the real byte total; one that + /// runs out reports "unknown, at least the floor", so a big tree that + /// is only partly counted cannot fall under the footprint floor. + #[test] + fn a_truncated_footprint_walk_is_not_taken_for_a_small_one() { + let dir = tempfile::tempdir().expect("tempdir"); + for name in ["a", "b", "c", "d"] { + std::fs::write(dir.path().join(name), [0u8; 10]).expect("write"); + } + let roots = [dir.path().to_path_buf()]; + + assert_eq!(footprint_within(&roots, 100), 40); + assert_eq!(footprint_within(&roots, 2), u64::MAX); + } }