Skip to main content

anodizer_core/
disk.rs

1//! Disk-headroom instrumentation and a peak-measured fail-fast guard for
2//! the determinism harness.
3//!
4//! The harness rebuilds the full release pipeline `runs` times in a fresh
5//! worktree per iteration. On the macOS shard each run builds both darwin
6//! arches for the universal binary, then archive/sbom/sign/**dmg**/pkg —
7//! and `hdiutil` (the dmg builder) stages a temp read/write image before
8//! compressing, so the dmg stage is the disk high-water mark. When the
9//! runner is gradually exhausted across runs, the pipeline limps into an
10//! opaque `hdiutil: create failed - No space left on device` with zero
11//! disk awareness.
12//!
13//! ## Why a PEAK measurement, not a net delta
14//!
15//! A run's footprint is not its *net* on-disk residue. The per-triple
16//! build-intermediate reclaim (`stage-build`'s
17//! `free_cargo_build_intermediates`) and the worktree drop settle a run's
18//! consumption down AFTER the dmg stage has already peaked. So a
19//! between-runs net delta (`free_before_run_k − free_before_run_{k+1}`)
20//! systematically EXCLUDES the mid-dmg peak it is meant to bound, and a
21//! guard built on it under-provisions and limps into the same ENOSPC.
22//!
23//! The build runs in a child `anodizer release` subprocess, so the harness
24//! cannot read the mid-run peak inline. Instead it runs a background
25//! [`FreeSpaceSampler`] for the duration of each run's build: a thread that
26//! polls [`available_bytes`] on a short interval, recording the MINIMUM
27//! free space it observes. `peak_consumed_run_k = free_before_run_k −
28//! min_free_during_run_k` is then a *measured* bound on what that run
29//! actually needed at its worst moment. The guard gates run-1..N on the
30//! MAX peak observed across all prior runs × a modest safety factor — a
31//! real measured bound, not a guess.
32//!
33//! ## Guarantee, honestly stated
34//!
35//! - **run-0** has no prior peak to measure, so it is gated by the
36//!   absolute floor ([`DEFAULT_ABS_FLOOR_BYTES`]) ALONE — a liveness
37//!   backstop, not a peak guarantee. The sampler still records run-0's
38//!   peak (emitted at verbose) to gate run-1.
39//! - **run-1..N** are gated on the measured peak of every prior run — the
40//!   observed failure was run-1, and peak-gating it is the fix.
41//!
42//! Everything here is subprocess-free: free space via [`fs4`]'s
43//! `statvfs`/`GetDiskFreeSpaceExW` wrapper, directory size via a `std::fs`
44//! recursive walk, mounted volumes via `std::fs::read_dir("/Volumes")` on
45//! macOS. Nothing mutates disk state; it only observes and decides. The
46//! footprint/threshold math ([`RunPeak`], [`evaluate_headroom`]) takes
47//! plain byte counts so it is unit-testable without a real low-disk host.
48
49use crate::env_source::{EnvSource, ProcessEnvSource};
50use std::path::{Path, PathBuf};
51use std::sync::Arc;
52use std::sync::atomic::{AtomicU64, Ordering};
53use std::thread::JoinHandle;
54use std::time::Duration;
55
56/// `1 GiB` in bytes — the unit the harness reports headroom in.
57const GIB: u64 = 1024 * 1024 * 1024;
58
59/// Default absolute free-space floor, in bytes, required before run-0
60/// starts and as a backstop for run-1..N.
61///
62/// This is a minimal **liveness backstop**, NOT a peak guarantee:
63/// "enough free for a build to even start". It must NOT be sized to any
64/// one workload's peak. A blanket absolute floor is workload-blind — it
65/// cannot tell a tiny project apart from the macOS universal+dmg
66/// pipeline — so sizing it to the heavy case would false-positive on
67/// every small real project running `anodizer check determinism` on a
68/// normal machine (and on the harness's own integration tests, which run
69/// minimal workspaces with only a few GiB free).
70///
71/// The real heavy-workload protection is the MEASURED-PEAK gate on
72/// run-1..N: run-0 starts on the reclaimed macOS shard (~101 GiB free),
73/// clears this `2 GiB` floor trivially, and the [`FreeSpaceSampler`]
74/// measures run-0's true peak to gate run-1 — the run that actually
75/// exhausted the disk. If a future instrumented CI run shows run-0 itself
76/// is marginal, raise the floor for that shard via [`ABS_FLOOR_ENV`]
77/// rather than baking a macOS peak into the cross-workload default.
78pub const DEFAULT_ABS_FLOOR_BYTES: u64 = 2 * GIB;
79
80/// Default multiplier applied to a run's MEASURED peak consumption when
81/// gating subsequent runs. `1.3×` is slack ABOVE the observed peak —
82/// absorbing sampling jitter (the sampler may miss the exact instant of
83/// maximum consumption between polls) and minor run-to-run variance —
84/// without demanding headroom a correctly-sized runner can't supply.
85///
86/// Because the base figure is now a real peak (not a net→peak guess), the
87/// factor stays modest; raising it compensates only for measurement
88/// granularity, not for a structural under-estimate.
89pub const DEFAULT_SAFETY_FACTOR: f64 = 1.3;
90
91/// Default interval between [`FreeSpaceSampler`] polls.
92///
93/// Tradeoff: responsiveness (catching the dmg-stage high-water mark, which
94/// can spike and recede within a couple of seconds) vs syscall frequency.
95/// `available_bytes` is a single cheap `statvfs`/`GetDiskFreeSpaceExW`
96/// syscall, so 2-3× more samples is negligible cost — and for a feature
97/// that must NEVER silently under-report the peak, a `0.5s` cadence
98/// materially lowers the probability that the high-water mark falls
99/// entirely between two polls. The `DEFAULT_SAFETY_FACTOR` (1.3×) remains
100/// the cushion for any residual under-measure between samples. No env
101/// override — the const is the single tuning point.
102pub const DEFAULT_SAMPLE_INTERVAL: Duration = Duration::from_millis(500);
103
104/// Env override for [`DEFAULT_ABS_FLOOR_BYTES`], expressed in **whole
105/// GiB** (e.g. `ANODIZER_DET_DISK_FLOOR_GIB=60`). A zero / non-positive /
106/// malformed / overflowing value falls back to the default — `0` must
107/// never disable the guard.
108pub const ABS_FLOOR_ENV: &str = "ANODIZER_DET_DISK_FLOOR_GIB";
109
110/// Env override for [`DEFAULT_SAFETY_FACTOR`] (e.g.
111/// `ANODIZER_DET_DISK_SAFETY_FACTOR=2.0`). A non-finite or non-positive
112/// value falls back to the default.
113pub const SAFETY_FACTOR_ENV: &str = "ANODIZER_DET_DISK_SAFETY_FACTOR";
114
115/// Resolve the absolute free-space floor (bytes) from the environment,
116/// falling back to [`DEFAULT_ABS_FLOOR_BYTES`]. The override is read in
117/// whole GiB so an operator strengthening reclaim can raise the floor
118/// without computing byte counts.
119///
120/// `0` is rejected (it would make `required = 0` and silently disable the
121/// guard for an entire CI history) along with malformed input and any
122/// value whose `× GiB` would overflow `u64`.
123///
124/// Thin production wrapper over [`abs_floor_bytes_from_env_with`] reading
125/// the real process env via [`ProcessEnvSource`].
126pub fn abs_floor_bytes_from_env() -> u64 {
127    abs_floor_bytes_from_env_with(&ProcessEnvSource)
128}
129
130/// [`abs_floor_bytes_from_env`] parameterized over an [`EnvSource`] so the
131/// parse/validation branches are testable by injecting a [`MapEnvSource`]
132/// — no process-env mutation, no test-isolation marker, race-free by
133/// construction. An absent key reads as `None` and falls through to the
134/// default.
135///
136/// [`MapEnvSource`]: crate::env_source::MapEnvSource
137pub fn abs_floor_bytes_from_env_with(env: &dyn EnvSource) -> u64 {
138    env.var(ABS_FLOOR_ENV)
139        .and_then(|v| v.trim().parse::<u64>().ok())
140        .filter(|&gib| gib > 0)
141        .and_then(|gib| gib.checked_mul(GIB))
142        .unwrap_or(DEFAULT_ABS_FLOOR_BYTES)
143}
144
145/// Resolve the safety factor from the environment, falling back to
146/// [`DEFAULT_SAFETY_FACTOR`]. Non-finite or non-positive overrides are
147/// rejected (they would invert or zero the guard).
148///
149/// Thin production wrapper over [`safety_factor_from_env_with`] reading
150/// the real process env via [`ProcessEnvSource`].
151pub fn safety_factor_from_env() -> f64 {
152    safety_factor_from_env_with(&ProcessEnvSource)
153}
154
155/// [`safety_factor_from_env`] parameterized over an [`EnvSource`] so the
156/// parse/validation branches are testable by injecting a [`MapEnvSource`]
157/// without mutating the process env.
158///
159/// [`MapEnvSource`]: crate::env_source::MapEnvSource
160pub fn safety_factor_from_env_with(env: &dyn EnvSource) -> f64 {
161    env.var(SAFETY_FACTOR_ENV)
162        .and_then(|v| v.trim().parse::<f64>().ok())
163        .filter(|f| f.is_finite() && *f > 0.0)
164        .unwrap_or(DEFAULT_SAFETY_FACTOR)
165}
166
167/// Format a byte count as a human-readable `GiB` string with one decimal
168/// (`101.2 GiB`). Used in routine disk log lines so the units are uniform.
169pub fn format_gib(bytes: u64) -> String {
170    format!("{:.1} GiB", bytes as f64 / GIB as f64)
171}
172
173/// Format a byte count as `GiB` with two decimals plus the raw byte count
174/// (`12.04 GiB (12929498972 bytes)`). Used in the abort message so a
175/// near-miss (`required 12.04 / available 11.96`) never renders as
176/// `12.0 vs 12.0`; the exact bytes make the decision auditable.
177pub fn format_gib_exact(bytes: u64) -> String {
178    format!("{:.2} GiB ({bytes} bytes)", bytes as f64 / GIB as f64)
179}
180
181/// Free (available-to-this-user) bytes on the filesystem backing `path`.
182///
183/// Returns `None` when the probe fails (path doesn't exist yet, an
184/// unsupported platform, a transient stat error). Callers treat `None`
185/// as "headroom unknown" and degrade to a no-op rather than abort — the
186/// guard must never *manufacture* a failure from a probe gap.
187///
188/// `fs4::available_space` reports the space available to an unprivileged
189/// process (statvfs `f_bavail` × `f_frsize` on unix, the caller-quota
190/// figure from `GetDiskFreeSpaceExW` on Windows) — the correct number for
191/// "will this write succeed", as opposed to total free including
192/// root-reserved blocks.
193pub fn available_bytes(path: &Path) -> Option<u64> {
194    // Honor the documented "missing path → None" contract on every
195    // platform. `statvfs` (unix) already fails for an absent `path`, but
196    // `GetDiskFreeSpaceExW` (windows) resolves the *parent volume* of a
197    // missing path and reports its free space — which both contradicts the
198    // contract and masks a not-yet-mounted target (it would report the
199    // parent FS, not the intended mount). Gate on existence first so an
200    // absent path degrades to "headroom unknown" uniformly, never a
201    // manufactured parent-volume reading. A probe error (`Err`) is likewise
202    // treated as unknown. The harness always probes the worktree root
203    // (created before any run), so the existence check is satisfied in
204    // practice and costs one extra stat at the sampler's 0.5s cadence.
205    if !path.try_exists().unwrap_or(false) {
206        return None;
207    }
208    fs4::available_space(path).ok()
209}
210
211/// Recursively sum the size of every regular file under `dir`.
212///
213/// Symlinks are not followed (their target may live on another volume or
214/// form a cycle); their own entry contributes nothing. A missing `dir`
215/// (e.g. `dist/` before the first produce-stage) sums to `0` rather than
216/// erroring — this is a diagnostic aid, not a correctness gate. Per-entry
217/// stat errors are skipped so one unreadable file can't blank the whole
218/// total.
219pub fn dir_size_bytes(dir: &Path) -> u64 {
220    let mut total = 0u64;
221    let mut stack = vec![dir.to_path_buf()];
222    while let Some(cur) = stack.pop() {
223        let entries = match std::fs::read_dir(&cur) {
224            Ok(e) => e,
225            Err(_) => continue,
226        };
227        for entry in entries.flatten() {
228            // `entry.metadata()` does NOT traverse symlinks (it is a
229            // `symlink_metadata`): a symlinked dir is recorded as a (tiny)
230            // link, never recursed into.
231            let meta = match entry.metadata() {
232                Ok(m) => m,
233                Err(_) => continue,
234            };
235            let ft = meta.file_type();
236            if ft.is_symlink() {
237                continue;
238            }
239            if ft.is_dir() {
240                stack.push(entry.path());
241            } else if ft.is_file() {
242                total = total.saturating_add(meta.len());
243            }
244        }
245    }
246    total
247}
248
249/// Names of `anodize`-related mounts under `/Volumes` (macOS only).
250///
251/// **Diagnostic only.** `hdiutil` mounts the dmg's transient r/w image at
252/// `/Volumes/<volname>` while copying the staging tree in; the `stage-dmg`
253/// crate already `hdiutil detach -force`es a stale same-named mount before
254/// `create`, so name COLLISION is handled there. This list exists purely
255/// to make a *leaked* mount visible in the harness log (its disk
256/// occupancy is not reclaimed here) so a recurrence is diagnosable.
257/// Returns an empty vector off macOS or when `/Volumes` is unreadable.
258#[cfg(target_os = "macos")]
259pub fn mounted_volumes() -> Vec<String> {
260    let mut names: Vec<String> = match std::fs::read_dir("/Volumes") {
261        Ok(entries) => entries
262            .flatten()
263            .map(|e| e.file_name().to_string_lossy().into_owned())
264            .collect(),
265        Err(_) => Vec::new(),
266    };
267    names.sort();
268    names
269}
270
271/// Non-macOS stub: there is no `/Volumes` concept, so the mount list is
272/// always empty. Kept so callers need no `cfg` of their own.
273#[cfg(not(target_os = "macos"))]
274pub fn mounted_volumes() -> Vec<String> {
275    Vec::new()
276}
277
278/// Background free-space sampler for one determinism run's build.
279///
280/// Spawned just before a run's build subprocess starts and
281/// [`stop`](Self::stop)ped when it returns. While alive, a thread polls
282/// [`available_bytes`] every `interval` and records the MINIMUM free space
283/// it observes into a shared atomic. The minimum, subtracted from the
284/// free space measured before the run, is the run's measured PEAK
285/// consumption — the mid-dmg high-water mark a between-runs net delta
286/// cannot see.
287///
288/// If the probe is unavailable (`available_bytes` returns `None`), the
289/// sampler records nothing and [`stop`](Self::stop) yields `None`; the
290/// guard then has no peak for that run and falls back to the floor, never
291/// a manufactured value.
292pub struct FreeSpaceSampler {
293    /// Stop signal. The worker blocks on `recv_timeout(interval)`; a send
294    /// (or sender-drop) wakes it IMMEDIATELY, so `stop()` never has to
295    /// wait out a full poll interval before the thread joins.
296    stop_tx: Option<std::sync::mpsc::Sender<()>>,
297    /// Minimum free bytes observed; `u64::MAX` sentinel until the first
298    /// successful probe so a probe-gap run yields `None`.
299    min_free: Arc<AtomicU64>,
300    handle: Option<JoinHandle<()>>,
301}
302
303impl FreeSpaceSampler {
304    /// Sentinel meaning "no successful probe yet". Distinguishes a genuine
305    /// `0 bytes free` reading from a run where the probe never succeeded.
306    const NO_SAMPLE: u64 = u64::MAX;
307
308    /// Start sampling free space on the volume backing `vol`, polling every
309    /// `interval`.
310    pub fn start(vol: &Path, interval: Duration) -> Self {
311        use std::sync::mpsc::{RecvTimeoutError, channel};
312        let (stop_tx, stop_rx) = channel::<()>();
313        let min_free = Arc::new(AtomicU64::new(Self::NO_SAMPLE));
314        let min_t = Arc::clone(&min_free);
315        let vol: PathBuf = vol.to_path_buf();
316        let handle = std::thread::spawn(move || {
317            // Take an immediate reading so a build shorter than one
318            // interval still records a sample, then poll on a timeout that
319            // a stop-send interrupts at once.
320            loop {
321                if let Some(free) = available_bytes(&vol) {
322                    min_t.fetch_min(free, Ordering::Relaxed);
323                }
324                // `recv_timeout` returns `Ok`/`Disconnected` the instant
325                // `stop()` sends or drops the sender → prompt shutdown;
326                // `Timeout` means the interval elapsed → take another
327                // sample. Sampling cadence stays at `interval`; only stop
328                // responsiveness improves.
329                match stop_rx.recv_timeout(interval) {
330                    Err(RecvTimeoutError::Timeout) => continue,
331                    Ok(()) | Err(RecvTimeoutError::Disconnected) => break,
332                }
333            }
334        });
335        Self {
336            stop_tx: Some(stop_tx),
337            min_free,
338            handle: Some(handle),
339        }
340    }
341
342    /// Stop sampling, join the thread, and return the minimum free space
343    /// observed (or `None` if the probe never succeeded).
344    pub fn stop(mut self) -> Option<u64> {
345        self.signal_and_join();
346        match self.min_free.load(Ordering::Relaxed) {
347            Self::NO_SAMPLE => None,
348            v => Some(v),
349        }
350    }
351
352    /// Signal the worker to stop and reap it. Idempotent: dropping the
353    /// sender wakes a `recv_timeout` immediately, and a `take`n handle
354    /// guards against a double-join.
355    fn signal_and_join(&mut self) {
356        // Drop the sender to wake the worker at once (its `recv_timeout`
357        // returns `Disconnected`); an explicit `send` is unnecessary.
358        self.stop_tx = None;
359        if let Some(h) = self.handle.take() {
360            // A panic in the sampler thread is non-fatal to the harness —
361            // a failed join just means "no peak measured", same as a probe
362            // gap; the guard falls back to the floor.
363            let _ = h.join();
364        }
365    }
366}
367
368impl Drop for FreeSpaceSampler {
369    fn drop(&mut self) {
370        // Defensive: if `stop()` was not called (early return / `?`), make
371        // sure the thread is signalled and reaped rather than detached.
372        self.signal_and_join();
373    }
374}
375
376/// One determinism run's MEASURED peak disk consumption on the worktree
377/// volume.
378///
379/// `free_before` is the available space measured immediately before the
380/// run's build started; `min_free_during` is the minimum the
381/// [`FreeSpaceSampler`] observed while it ran. Their difference is the
382/// peak the run needed at its worst moment — the figure the guard
383/// projects forward to gate later runs.
384#[derive(Debug, Clone, Copy, PartialEq, Eq)]
385pub struct RunPeak {
386    /// Available bytes measured before the run built.
387    pub free_before: u64,
388    /// Minimum available bytes observed during the run's build.
389    pub min_free_during: u64,
390}
391
392impl RunPeak {
393    /// Peak bytes the run consumed: the drop from `free_before` to the
394    /// minimum observed mid-run.
395    ///
396    /// Clamped at `0`: a `min_free_during` somehow above `free_before`
397    /// (free space grew mid-run — another process cleaned up) yields no
398    /// measured pressure, so the floor becomes the only gate.
399    pub fn consumed_bytes(&self) -> u64 {
400        self.free_before.saturating_sub(self.min_free_during)
401    }
402}
403
404/// Outcome of the per-run headroom check.
405#[derive(Debug, Clone, PartialEq, Eq)]
406pub enum HeadroomDecision {
407    /// Enough free space: the run may proceed.
408    Proceed,
409    /// Insufficient free space. Carries the numbers for an actionable
410    /// abort message ([`HeadroomShortfall::message`]).
411    Abort(HeadroomShortfall),
412}
413
414/// The numbers behind an [`HeadroomDecision::Abort`], rendered into the
415/// operator-facing error.
416#[derive(Debug, Clone, PartialEq, Eq)]
417pub struct HeadroomShortfall {
418    /// Zero-based index of the run that would have run next.
419    pub run_idx: u32,
420    /// Bytes the guard required to be free before the run.
421    pub required_bytes: u64,
422    /// Bytes actually free on the worktree volume.
423    pub available_bytes: u64,
424    /// The volume/path the readings were taken on (for the message).
425    pub volume: String,
426    /// Whether `required_bytes` came from a measured prior-run peak
427    /// (`true`, run-1..N) or the absolute floor alone (`false`, run-0).
428    /// Drives the message so it never over-claims a peak guarantee for
429    /// run-0.
430    pub peak_gated: bool,
431}
432
433impl HeadroomShortfall {
434    /// Actionable, number-carrying abort message. Names the run, the
435    /// deficit (with exact bytes so a near-miss is unambiguous), whether
436    /// the bound was peak-measured or the floor backstop, and the two
437    /// remedies an operator actually has.
438    pub fn message(&self) -> String {
439        let basis = if self.peak_gated {
440            "the measured peak of a prior run's universal rebuild + dmg staging"
441        } else {
442            "the absolute floor for the first run's universal rebuild + dmg staging"
443        };
444        format!(
445            "determinism run {} needs ~{} free ({}); only {} available on {}. \
446             Strengthen reclaim-disk (raise the macOS shard's reclaim target) or use a \
447             larger runner. Override the floor with {}=<GiB> / the safety factor with \
448             {}=<f> if this estimate is wrong for your project.",
449            self.run_idx + 1,
450            format_gib_exact(self.required_bytes),
451            basis,
452            format_gib_exact(self.available_bytes),
453            self.volume,
454            ABS_FLOOR_ENV,
455            SAFETY_FACTOR_ENV,
456        )
457    }
458}
459
460/// Project a measured peak forward into a required-free-space figure:
461/// `peak × safety_factor`, saturating at `u64::MAX`.
462///
463/// Pulled out so the saturation intent is explicit (S1): an absurd peak ×
464/// factor saturates to `u64::MAX`, which makes the guard always-abort —
465/// the safe direction (never under-provision), and unreachable in
466/// practice (a real peak is bounded by disk size).
467fn project_peak(peak: u64, safety_factor: f64) -> u64 {
468    let scaled = peak as f64 * safety_factor;
469    if scaled >= u64::MAX as f64 {
470        u64::MAX
471    } else {
472        scaled as u64
473    }
474}
475
476/// Decide whether a determinism run may proceed given the free space on
477/// the worktree volume.
478///
479/// The required free space is the **larger** of:
480/// - the absolute floor (`abs_floor`) — the sole gate before run-0 (when
481///   `prior_peak` is `None`) and a backstop thereafter; and
482/// - the largest prior-run MEASURED peak × `safety_factor` — the precise,
483///   project-specific gate for run-1..N.
484///
485/// Taking the max means a project whose real peak exceeds the floor is
486/// caught, while a project lighter than the floor is never rejected below
487/// it. `peak_gated` in the returned shortfall reflects which term won, so
488/// the message states the true guarantee.
489///
490/// Pure arithmetic over injected byte counts — no disk access — so the
491/// threshold logic is testable without a real low-disk condition.
492pub fn evaluate_headroom(
493    run_idx: u32,
494    available: u64,
495    abs_floor: u64,
496    prior_peak: Option<u64>,
497    safety_factor: f64,
498    volume: &str,
499) -> HeadroomDecision {
500    let projected = prior_peak.map(|p| project_peak(p, safety_factor));
501    let peak_gated = projected.is_some_and(|p| p >= abs_floor);
502    let required = abs_floor.max(projected.unwrap_or(0));
503    if available >= required {
504        HeadroomDecision::Proceed
505    } else {
506        HeadroomDecision::Abort(HeadroomShortfall {
507            run_idx,
508            required_bytes: required,
509            available_bytes: available,
510            volume: volume.to_string(),
511            peak_gated,
512        })
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use crate::env_source::MapEnvSource;
520    use std::fs;
521    use tempfile::tempdir;
522
523    #[test]
524    fn format_gib_one_decimal() {
525        assert_eq!(format_gib(0), "0.0 GiB");
526        assert_eq!(format_gib(GIB), "1.0 GiB");
527        assert_eq!(format_gib(GIB + GIB / 2), "1.5 GiB");
528        assert_eq!(format_gib(101 * GIB + GIB / 5), "101.2 GiB");
529    }
530
531    #[test]
532    fn format_gib_exact_carries_raw_bytes() {
533        let s = format_gib_exact(12 * GIB);
534        assert!(s.contains("12.00 GiB"), "two decimals: {s}");
535        assert!(s.contains(&format!("{}", 12 * GIB)), "raw bytes: {s}");
536    }
537
538    #[test]
539    fn available_bytes_probes_real_volume() {
540        let dir = tempdir().unwrap();
541        // A real filesystem always has *some* notion of available space;
542        // the probe must return Some (we don't assert a floor — CI runners
543        // vary — only that the call resolves).
544        assert!(
545            available_bytes(dir.path()).is_some(),
546            "available_bytes must resolve on a real dir"
547        );
548    }
549
550    #[test]
551    fn available_bytes_missing_path_is_none_not_panic() {
552        let dir = tempdir().unwrap();
553        let missing = dir.path().join("does-not-exist");
554        assert_eq!(available_bytes(&missing), None);
555    }
556
557    #[test]
558    fn dir_size_sums_regular_files_recursively() {
559        let dir = tempdir().unwrap();
560        fs::write(dir.path().join("a"), vec![0u8; 100]).unwrap();
561        fs::create_dir(dir.path().join("sub")).unwrap();
562        fs::write(dir.path().join("sub").join("b"), vec![0u8; 250]).unwrap();
563        assert_eq!(dir_size_bytes(dir.path()), 350);
564    }
565
566    #[test]
567    fn dir_size_missing_dir_is_zero() {
568        let dir = tempdir().unwrap();
569        assert_eq!(dir_size_bytes(&dir.path().join("nope")), 0);
570    }
571
572    #[test]
573    fn dir_size_does_not_follow_symlinks() {
574        let dir = tempdir().unwrap();
575        fs::write(dir.path().join("real"), vec![0u8; 500]).unwrap();
576        #[cfg(unix)]
577        {
578            use std::os::unix::fs::symlink;
579            symlink(dir.path().join("real"), dir.path().join("link")).unwrap();
580            assert_eq!(dir_size_bytes(dir.path()), 500);
581        }
582    }
583
584    #[test]
585    fn sampler_records_a_minimum_on_a_real_volume() {
586        // On a real dir the sampler must take at least one reading and
587        // return Some — the value itself is runner-dependent.
588        let dir = tempdir().unwrap();
589        let s = FreeSpaceSampler::start(dir.path(), Duration::from_millis(5));
590        std::thread::sleep(Duration::from_millis(20));
591        assert!(
592            s.stop().is_some(),
593            "sampler must record a reading on a real volume"
594        );
595    }
596
597    #[test]
598    fn sampler_probe_gap_yields_none() {
599        // A path that doesn't exist makes every probe fail; the sampler
600        // records nothing and yields None (never a manufactured value).
601        let dir = tempdir().unwrap();
602        let missing = dir.path().join("nope");
603        let s = FreeSpaceSampler::start(&missing, Duration::from_millis(5));
604        std::thread::sleep(Duration::from_millis(20));
605        assert_eq!(s.stop(), None);
606    }
607
608    #[test]
609    fn run_peak_consumed_is_the_drop_to_the_minimum() {
610        let p = RunPeak {
611            free_before: 100 * GIB,
612            min_free_during: 30 * GIB,
613        };
614        assert_eq!(p.consumed_bytes(), 70 * GIB);
615    }
616
617    #[test]
618    fn run_peak_clamps_when_free_space_grew_mid_run() {
619        let p = RunPeak {
620            free_before: 80 * GIB,
621            min_free_during: 90 * GIB,
622        };
623        assert_eq!(p.consumed_bytes(), 0);
624    }
625
626    #[test]
627    fn headroom_run0_gated_by_absolute_floor_only_not_peak() {
628        // No prior peak (run-0): the floor is the sole gate and the
629        // shortfall must NOT claim a peak basis.
630        let ok = evaluate_headroom(0, 50 * GIB, 45 * GIB, None, 1.3, "/");
631        assert_eq!(ok, HeadroomDecision::Proceed);
632
633        let bad = evaluate_headroom(0, 30 * GIB, 45 * GIB, None, 1.3, "/");
634        match bad {
635            HeadroomDecision::Abort(s) => {
636                assert_eq!(s.run_idx, 0);
637                assert_eq!(s.required_bytes, 45 * GIB);
638                assert_eq!(s.available_bytes, 30 * GIB);
639                assert!(!s.peak_gated, "run-0 is floor-gated, not peak-gated");
640                assert!(
641                    s.message().contains("absolute floor"),
642                    "run-0 message must state the floor basis: {}",
643                    s.message()
644                );
645            }
646            other => panic!("expected Abort, got {other:?}"),
647        }
648    }
649
650    #[test]
651    fn headroom_later_run_uses_measured_peak_when_above_floor() {
652        // run-0 PEAKED at 70 GiB consumed; ×1.3 = 91 GiB required (above
653        // the 45 GiB floor). This is the exact B1 failure: a net delta
654        // would have seen ~30 GiB and wrongly proceeded.
655        let prior_peak = Some(70 * GIB);
656        let decision = evaluate_headroom(1, 71 * GIB, 45 * GIB, prior_peak, 1.3, "/Volumes/x");
657        match decision {
658            HeadroomDecision::Abort(s) => {
659                assert_eq!(s.run_idx, 1);
660                assert_eq!(s.required_bytes, 91 * GIB);
661                assert_eq!(s.available_bytes, 71 * GIB);
662                assert!(s.peak_gated, "run-1 must be peak-gated");
663                assert!(
664                    s.message().contains("measured peak"),
665                    "message must state the peak basis: {}",
666                    s.message()
667                );
668            }
669            other => panic!("expected Abort (71 < 91), got {other:?}"),
670        }
671        // 95 GiB free clears the 91 GiB requirement.
672        assert_eq!(
673            evaluate_headroom(1, 95 * GIB, 45 * GIB, prior_peak, 1.3, "/Volumes/x"),
674            HeadroomDecision::Proceed
675        );
676    }
677
678    #[test]
679    fn headroom_floor_backstops_a_tiny_measured_peak() {
680        // A prior run that barely peaked; ×1.3 is below the floor, so the
681        // floor still gates and the shortfall is NOT peak-gated.
682        let prior_peak = Some(GIB);
683        let bad = evaluate_headroom(2, 30 * GIB, 45 * GIB, prior_peak, 1.3, "/");
684        match bad {
685            HeadroomDecision::Abort(s) => {
686                assert_eq!(s.required_bytes, 45 * GIB);
687                assert!(!s.peak_gated, "floor won, so not peak-gated");
688            }
689            other => panic!("expected Abort, got {other:?}"),
690        }
691    }
692
693    #[test]
694    fn project_peak_saturates_at_extremes_instead_of_wrapping() {
695        // An absurd peak × factor saturates to u64::MAX (always-abort, the
696        // safe direction) rather than wrapping to a small value.
697        assert_eq!(project_peak(u64::MAX, 2.0), u64::MAX);
698        assert_eq!(
699            project_peak(10 * GIB, 1.3),
700            (10.0 * GIB as f64 * 1.3) as u64
701        );
702    }
703
704    #[test]
705    fn shortfall_message_carries_exact_numbers_and_remedies() {
706        let s = HeadroomShortfall {
707            run_idx: 1,
708            required_bytes: 91 * GIB,
709            available_bytes: 71 * GIB,
710            volume: "/Volumes/scratch".into(),
711            peak_gated: true,
712        };
713        let msg = s.message();
714        assert!(msg.contains("determinism run 2"), "1-based run: {msg}");
715        assert!(
716            msg.contains(&format!("{}", 91 * GIB)),
717            "exact required: {msg}"
718        );
719        assert!(
720            msg.contains(&format!("{}", 71 * GIB)),
721            "exact available: {msg}"
722        );
723        assert!(msg.contains("/Volumes/scratch"), "volume: {msg}");
724        assert!(msg.contains("reclaim-disk"), "remedy: {msg}");
725        assert!(msg.contains(ABS_FLOOR_ENV), "floor override hint: {msg}");
726        assert!(
727            msg.contains(SAFETY_FACTOR_ENV),
728            "factor override hint: {msg}"
729        );
730    }
731
732    #[test]
733    fn abs_floor_env_rejects_zero_malformed_overflow_accepts_valid() {
734        // Injected env via MapEnvSource — no process-env mutation, so the
735        // test is race-free and needs neither `unsafe` nor `#[serial]`.
736        let cases: &[(&str, u64)] = &[
737            ("0", DEFAULT_ABS_FLOOR_BYTES),           // B2: 0 must not disable
738            ("abc", DEFAULT_ABS_FLOOR_BYTES),         // malformed
739            ("18446744073", DEFAULT_ABS_FLOOR_BYTES), // B3: × GiB overflows u64
740            ("60", 60 * GIB),                         // valid
741            ("", DEFAULT_ABS_FLOOR_BYTES),            // empty
742        ];
743        for (val, want) in cases {
744            let env = MapEnvSource::new().with(ABS_FLOOR_ENV, *val);
745            assert_eq!(
746                abs_floor_bytes_from_env_with(&env),
747                *want,
748                "ABS_FLOOR_ENV={val:?} should resolve to {want}"
749            );
750        }
751        // Missing key (var simply absent from the map) → default.
752        assert_eq!(
753            abs_floor_bytes_from_env_with(&MapEnvSource::new()),
754            DEFAULT_ABS_FLOOR_BYTES,
755            "unset → default"
756        );
757    }
758
759    #[test]
760    fn safety_factor_env_rejects_nonpositive_accepts_valid() {
761        let cases: &[(&str, f64)] = &[
762            ("0", DEFAULT_SAFETY_FACTOR),
763            ("-1.0", DEFAULT_SAFETY_FACTOR),
764            ("nan", DEFAULT_SAFETY_FACTOR),
765            ("xyz", DEFAULT_SAFETY_FACTOR),
766            ("2.0", 2.0),
767        ];
768        for (val, want) in cases {
769            let env = MapEnvSource::new().with(SAFETY_FACTOR_ENV, *val);
770            assert!(
771                (safety_factor_from_env_with(&env) - want).abs() < f64::EPSILON,
772                "SAFETY_FACTOR_ENV={val:?} should resolve to {want}"
773            );
774        }
775        // Missing key → default.
776        assert!(
777            (safety_factor_from_env_with(&MapEnvSource::new()) - DEFAULT_SAFETY_FACTOR).abs()
778                < f64::EPSILON
779        );
780    }
781
782    #[test]
783    fn defaults_are_the_documented_values() {
784        // A minimal liveness floor — NOT a workload peak. The measured-peak
785        // gate (run-1+) is the heavy-workload protection.
786        assert_eq!(DEFAULT_ABS_FLOOR_BYTES, 2 * GIB);
787        assert!((DEFAULT_SAFETY_FACTOR - 1.3).abs() < f64::EPSILON);
788    }
789}