Skip to main content

kranz_engine/
disk_preflight.rs

1//! Pre-drain disk-footprint check (ticket `mission-build-footprint`).
2//!
3//! A mission builds a full `target/debug` in its integration worktree, the
4//! validation snapshot copies a warmed subset on top of that, and the merge
5//! gate may build again in a scratch worktree. On a disk with less free space
6//! than that ladder needs, the run dies mid-command with `os error 28`
7//! (ENOSPC) — burning feature respawn budgets on `Partial` runs (observed on
8//! m-a5a8fd and m-eee81f). This module refuses to START a mission when the
9//! free space under the repo is below the estimated footprint, naming the
10//! number, instead of dying mid-feature. It is deliberately a drain-time
11//! refusal, not an in-mission guard: the honest failure is up front.
12
13use cap_fs_ext::DirExt as _;
14use cap_std::ambient_authority;
15use cap_std::fs::Dir;
16use std::path::Path;
17
18#[cfg(unix)]
19fn statvfs_field_to_u64<T: Into<u64>>(value: T) -> u64 {
20    value.into()
21}
22
23/// The decision a pre-drain disk check reaches.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum DiskPreflight {
26    /// Free space meets the estimate (or could not be measured — proceed and
27    /// let the build surface any real ENOSPC, exactly as before this check;
28    /// never fabricate a "0 bytes free" refusal).
29    Sufficient,
30    /// Free space is below the estimated build footprint: refuse to start.
31    Insufficient {
32        free_bytes: u64,
33        estimate_bytes: u64,
34    },
35}
36
37/// Free bytes available to unprivileged users on the volume containing
38/// `path` (`statvfs` `f_bavail` × `f_frsize`). `None` when the call fails —
39/// the check then degrades to "proceed" rather than inventing a number.
40#[cfg(unix)]
41pub fn available_bytes(path: &Path) -> Option<u64> {
42    use std::os::unix::ffi::OsStrExt;
43    let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
44    let mut stat: libc::statvfs = unsafe { std::mem::zeroed() };
45    let rc = unsafe { libc::statvfs(c_path.as_ptr(), &mut stat) };
46    if rc != 0 {
47        return None;
48    }
49    Some(statvfs_field_to_u64(stat.f_bavail).saturating_mul(statvfs_field_to_u64(stat.f_frsize)))
50}
51
52/// No statvfs equivalent is wired up on non-Unix targets; report unmeasurable
53/// so the check degrades to proceed.
54#[cfg(not(unix))]
55pub fn available_bytes(_path: &Path) -> Option<u64> {
56    None
57}
58
59/// Estimate multiplier on the primary checkout's `target/` size: one fresh
60/// worktree build (~1×) plus the snapshot copy and merge-gate rebuild
61/// headroom (~1×). Conservative on purpose — a premature refusal costs an
62/// operator re-check, a mid-mission ENOSPC costs respawn budget.
63const FOOTPRINT_MULTIPLIER: u64 = 2;
64
65/// Floor for the estimate when the repo has no measurable `target/` yet (a
66/// fresh checkout still builds a full debug workspace).
67const FOOTPRINT_FLOOR_BYTES: u64 = 4 * 1024 * 1024 * 1024; // 4 GiB
68
69/// Estimated bytes a mission's build ladder needs: the primary checkout's
70/// current `target/` size × [`FOOTPRINT_MULTIPLIER`], floored so a
71/// target-less repo still gets a sane bound. The walk is capped at `cap`
72/// bytes — once the running total passes it the estimate is already "too
73/// big", so a multi-GiB tree is not priced to the last file.
74pub fn estimate_build_footprint_bytes(repo_root: &Path) -> u64 {
75    // Cap the walk at the point where the answer is already "insufficient"
76    // for any realistic disk: past the cap the exact size is irrelevant.
77    const WALK_CAP: u64 = 256 * 1024 * 1024 * 1024; // 256 GiB
78    let measured = dir_size_capped(&repo_root.join("target"), WALK_CAP);
79    measured
80        .saturating_mul(FOOTPRINT_MULTIPLIER)
81        .max(FOOTPRINT_FLOOR_BYTES)
82}
83
84fn dir_size_capped(dir: &Path, cap: u64) -> u64 {
85    // A repository may contain an arbitrarily wide `target/` tree. Bound
86    // both bytes and directory entries so a tree of millions of empty files
87    // cannot turn the preflight itself into an unbounded denial of service.
88    const MAX_WALK_ENTRIES: usize = 250_000;
89    dir_size_capped_with_entry_limit(dir, cap, MAX_WALK_ENTRIES)
90}
91
92fn dir_size_capped_with_entry_limit(dir: &Path, cap: u64, max_entries: usize) -> u64 {
93    let Some(parent) = dir.parent() else {
94        return 0;
95    };
96    let Some(name) = dir.file_name() else {
97        return 0;
98    };
99    // Pin the parent and open the target leaf no-follow. Descendants are then
100    // opened relative to retained directory capabilities, so a repo-authored
101    // symlink is never traversed or priced as its external target.
102    let Ok(parent) = Dir::open_ambient_dir(parent, ambient_authority()) else {
103        return 0;
104    };
105    let Ok(root) = parent.open_dir_nofollow(name) else {
106        return 0;
107    };
108
109    let mut total = 0u64;
110    let mut visited = 0usize;
111    let mut stack = vec![root];
112    while let Some(d) = stack.pop() {
113        let Ok(entries) = d.entries() else {
114            continue;
115        };
116        for entry in entries.flatten() {
117            visited = visited.saturating_add(1);
118            if visited > max_entries {
119                return cap;
120            }
121            let name = entry.file_name();
122            let Ok(file_type) = entry.file_type() else {
123                continue;
124            };
125            if file_type.is_dir() {
126                if let Ok(child) = d.open_dir_nofollow(&name) {
127                    stack.push(child);
128                }
129            } else if file_type.is_file() {
130                // Re-check the entry no-follow before using its size. If it
131                // was swapped after `file_type`, a symlink/special entry is
132                // skipped instead of followed.
133                if let Ok(meta) = d.symlink_metadata(&name) {
134                    if meta.file_type().is_file() {
135                        total = total.saturating_add(meta.len());
136                        if total >= cap {
137                            return total;
138                        }
139                    }
140                }
141            }
142        }
143    }
144    total
145}
146
147/// The pre-drain check: compare free space under `repo_root` against the
148/// estimated build footprint.
149pub fn check(repo_root: &Path) -> DiskPreflight {
150    decide(
151        available_bytes(repo_root),
152        estimate_build_footprint_bytes(repo_root),
153    )
154}
155
156/// The decision boundary, separated from the host-dependent statvfs/read_dir
157/// probes so the refusal logic is unit-testable. `None` free space (a failed
158/// measurement) degrades to proceed — never a fabricated refusal.
159fn decide(free: Option<u64>, estimate: u64) -> DiskPreflight {
160    match free {
161        Some(free) if free < estimate => DiskPreflight::Insufficient {
162            free_bytes: free,
163            estimate_bytes: estimate,
164        },
165        _ => DiskPreflight::Sufficient,
166    }
167}
168
169/// Render a byte count as `N.N GiB` for the refusal reason.
170pub fn gib(bytes: u64) -> String {
171    format!("{:.1} GiB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn mission_build_footprint_estimate_floors_a_targetless_repo() {
180        let dir = tempfile::tempdir().unwrap();
181        // No target/ at all → the floor, not zero.
182        let estimate = estimate_build_footprint_bytes(dir.path());
183        assert_eq!(estimate, FOOTPRINT_FLOOR_BYTES);
184    }
185
186    #[test]
187    fn mission_build_footprint_measurement_and_cap() {
188        let dir = tempfile::tempdir().unwrap();
189        let target = dir.path().join("target").join("debug");
190        std::fs::create_dir_all(&target).unwrap();
191        // 3 MiB of real bytes across files.
192        let chunk = vec![0_u8; 1024 * 1024];
193        for i in 0..3 {
194            std::fs::write(target.join(format!("f{i}")), &chunk).unwrap();
195        }
196        // The measurement sums real bytes; the walk stops at the cap.
197        assert_eq!(
198            dir_size_capped(&dir.path().join("target"), u64::MAX),
199            3 * 1024 * 1024
200        );
201        assert_eq!(
202            dir_size_capped(&dir.path().join("target"), 1024 * 1024),
203            1024 * 1024
204        );
205        // A small target is dominated by the floor (the multiplier never
206        // produces a sub-floor estimate).
207        assert_eq!(
208            estimate_build_footprint_bytes(dir.path()),
209            FOOTPRINT_FLOOR_BYTES
210        );
211    }
212
213    #[test]
214    fn mission_build_footprint_entry_limit_is_conservative() {
215        let dir = tempfile::tempdir().unwrap();
216        let target = dir.path().join("target");
217        std::fs::create_dir(&target).unwrap();
218        for i in 0..3 {
219            std::fs::write(target.join(format!("empty-{i}")), []).unwrap();
220        }
221        assert_eq!(
222            dir_size_capped_with_entry_limit(&target, 123_456, 2),
223            123_456
224        );
225    }
226
227    #[cfg(unix)]
228    #[test]
229    fn mission_build_footprint_never_follows_symlinks() {
230        use std::os::unix::fs::symlink;
231
232        let dir = tempfile::tempdir().unwrap();
233        let target = dir.path().join("target");
234        let outside = tempfile::tempdir().unwrap();
235        std::fs::create_dir(&target).unwrap();
236        std::fs::write(target.join("real"), [0_u8; 7]).unwrap();
237        std::fs::write(outside.path().join("large"), vec![0_u8; 1024 * 1024]).unwrap();
238        symlink(outside.path(), target.join("outside-link")).unwrap();
239
240        assert_eq!(dir_size_capped(&target, u64::MAX), 7);
241    }
242
243    #[test]
244    fn mission_build_footprint_decide_refuses_only_below_the_estimate() {
245        // Below the estimate: refuse, naming both figures.
246        assert_eq!(
247            decide(Some(1024), 2048),
248            DiskPreflight::Insufficient {
249                free_bytes: 1024,
250                estimate_bytes: 2048
251            }
252        );
253        // At and above: sufficient.
254        assert_eq!(decide(Some(2048), 2048), DiskPreflight::Sufficient);
255        assert_eq!(decide(Some(4096), 2048), DiskPreflight::Sufficient);
256        // Unmeasurable: proceed (never a fabricated refusal).
257        assert_eq!(decide(None, 2048), DiskPreflight::Sufficient);
258    }
259
260    #[cfg(unix)]
261    #[test]
262    fn mission_build_footprint_available_bytes_reports_a_real_figure() {
263        let dir = tempfile::tempdir().unwrap();
264        let free = available_bytes(dir.path()).expect("statvfs on a tempdir");
265        assert!(free > 0, "a real volume reports non-zero free bytes");
266    }
267}