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        let (stop_tx, stop_rx) = std::sync::mpsc::channel::<()>();
312        let min_free = Arc::new(AtomicU64::new(Self::NO_SAMPLE));
313        let min_t = Arc::clone(&min_free);
314        let vol: PathBuf = vol.to_path_buf();
315        let handle = std::thread::spawn(move || {
316            let sample = || {
317                if let Some(free) = available_bytes(&vol) {
318                    min_t.fetch_min(free, Ordering::Relaxed);
319                }
320            };
321            // Take an immediate reading so a build shorter than one interval
322            // still records a sample, then tick on the shared stop-interruptible
323            // cadence — `stop()`'s send (or sender-drop) wakes it at once.
324            sample();
325            crate::progress::run_ticker(&stop_rx, interval, sample);
326        });
327        Self {
328            stop_tx: Some(stop_tx),
329            min_free,
330            handle: Some(handle),
331        }
332    }
333
334    /// Stop sampling, join the thread, and return the minimum free space
335    /// observed (or `None` if the probe never succeeded).
336    pub fn stop(mut self) -> Option<u64> {
337        self.signal_and_join();
338        match self.min_free.load(Ordering::Relaxed) {
339            Self::NO_SAMPLE => None,
340            v => Some(v),
341        }
342    }
343
344    /// Signal the worker to stop and reap it. Idempotent: dropping the
345    /// sender wakes a `recv_timeout` immediately, and a `take`n handle
346    /// guards against a double-join.
347    fn signal_and_join(&mut self) {
348        // Drop the sender to wake the worker at once (its `recv_timeout`
349        // returns `Disconnected`); an explicit `send` is unnecessary.
350        self.stop_tx = None;
351        if let Some(h) = self.handle.take() {
352            // A panic in the sampler thread is non-fatal to the harness —
353            // a failed join just means "no peak measured", same as a probe
354            // gap; the guard falls back to the floor.
355            let _ = h.join();
356        }
357    }
358}
359
360impl Drop for FreeSpaceSampler {
361    fn drop(&mut self) {
362        // Defensive: if `stop()` was not called (early return / `?`), make
363        // sure the thread is signalled and reaped rather than detached.
364        self.signal_and_join();
365    }
366}
367
368/// One determinism run's MEASURED peak disk consumption on the worktree
369/// volume.
370///
371/// `free_before` is the available space measured immediately before the
372/// run's build started; `min_free_during` is the minimum the
373/// [`FreeSpaceSampler`] observed while it ran. Their difference is the
374/// peak the run needed at its worst moment — the figure the guard
375/// projects forward to gate later runs.
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub struct RunPeak {
378    /// Available bytes measured before the run built.
379    pub free_before: u64,
380    /// Minimum available bytes observed during the run's build.
381    pub min_free_during: u64,
382}
383
384impl RunPeak {
385    /// Peak bytes the run consumed: the drop from `free_before` to the
386    /// minimum observed mid-run.
387    ///
388    /// Clamped at `0`: a `min_free_during` somehow above `free_before`
389    /// (free space grew mid-run — another process cleaned up) yields no
390    /// measured pressure, so the floor becomes the only gate.
391    pub fn consumed_bytes(&self) -> u64 {
392        self.free_before.saturating_sub(self.min_free_during)
393    }
394}
395
396/// Outcome of the per-run headroom check.
397#[derive(Debug, Clone, PartialEq, Eq)]
398pub enum HeadroomDecision {
399    /// Enough free space: the run may proceed.
400    Proceed,
401    /// Insufficient free space. Carries the numbers for an actionable
402    /// abort message ([`HeadroomShortfall::message`]).
403    Abort(HeadroomShortfall),
404}
405
406/// The numbers behind an [`HeadroomDecision::Abort`], rendered into the
407/// operator-facing error.
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct HeadroomShortfall {
410    /// Zero-based index of the run that would have run next.
411    pub run_idx: u32,
412    /// Bytes the guard required to be free before the run.
413    pub required_bytes: u64,
414    /// Bytes actually free on the worktree volume.
415    pub available_bytes: u64,
416    /// The volume/path the readings were taken on (for the message).
417    pub volume: String,
418    /// Whether `required_bytes` came from a measured prior-run peak
419    /// (`true`, run-1..N) or the absolute floor alone (`false`, run-0).
420    /// Drives the message so it never over-claims a peak guarantee for
421    /// run-0.
422    pub peak_gated: bool,
423}
424
425impl HeadroomShortfall {
426    /// Actionable, number-carrying abort message. Names the run, the
427    /// deficit (with exact bytes so a near-miss is unambiguous), whether
428    /// the bound was peak-measured or the floor backstop, and the two
429    /// remedies an operator actually has.
430    pub fn message(&self) -> String {
431        let basis = if self.peak_gated {
432            "the measured peak of a prior run's universal rebuild + dmg staging"
433        } else {
434            "the absolute floor for the first run's universal rebuild + dmg staging"
435        };
436        format!(
437            "determinism run {} needs ~{} free ({}); only {} available on {}. \
438             Strengthen reclaim-disk (raise the macOS shard's reclaim target) or use a \
439             larger runner. Override the floor with {}=<GiB> / the safety factor with \
440             {}=<f> if this estimate is wrong for your project.",
441            self.run_idx + 1,
442            format_gib_exact(self.required_bytes),
443            basis,
444            format_gib_exact(self.available_bytes),
445            self.volume,
446            ABS_FLOOR_ENV,
447            SAFETY_FACTOR_ENV,
448        )
449    }
450}
451
452/// Project a measured peak forward into a required-free-space figure:
453/// `peak × safety_factor`, saturating at `u64::MAX`.
454///
455/// Pulled out so the saturation intent is explicit (S1): an absurd peak ×
456/// factor saturates to `u64::MAX`, which makes the guard always-abort —
457/// the safe direction (never under-provision), and unreachable in
458/// practice (a real peak is bounded by disk size).
459fn project_peak(peak: u64, safety_factor: f64) -> u64 {
460    let scaled = peak as f64 * safety_factor;
461    if scaled >= u64::MAX as f64 {
462        u64::MAX
463    } else {
464        scaled as u64
465    }
466}
467
468/// Decide whether a determinism run may proceed given the free space on
469/// the worktree volume.
470///
471/// The required free space is the **larger** of:
472/// - the absolute floor (`abs_floor`) — the sole gate before run-0 (when
473///   `prior_peak` is `None`) and a backstop thereafter; and
474/// - the largest prior-run MEASURED peak × `safety_factor` — the precise,
475///   project-specific gate for run-1..N.
476///
477/// Taking the max means a project whose real peak exceeds the floor is
478/// caught, while a project lighter than the floor is never rejected below
479/// it. `peak_gated` in the returned shortfall reflects which term won, so
480/// the message states the true guarantee.
481///
482/// Pure arithmetic over injected byte counts — no disk access — so the
483/// threshold logic is testable without a real low-disk condition.
484pub fn evaluate_headroom(
485    run_idx: u32,
486    available: u64,
487    abs_floor: u64,
488    prior_peak: Option<u64>,
489    safety_factor: f64,
490    volume: &str,
491) -> HeadroomDecision {
492    let projected = prior_peak.map(|p| project_peak(p, safety_factor));
493    let peak_gated = projected.is_some_and(|p| p >= abs_floor);
494    let required = abs_floor.max(projected.unwrap_or(0));
495    if available >= required {
496        HeadroomDecision::Proceed
497    } else {
498        HeadroomDecision::Abort(HeadroomShortfall {
499            run_idx,
500            required_bytes: required,
501            available_bytes: available,
502            volume: volume.to_string(),
503            peak_gated,
504        })
505    }
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511    use crate::env_source::MapEnvSource;
512    use std::fs;
513    use tempfile::tempdir;
514
515    #[test]
516    fn format_gib_one_decimal() {
517        assert_eq!(format_gib(0), "0.0 GiB");
518        assert_eq!(format_gib(GIB), "1.0 GiB");
519        assert_eq!(format_gib(GIB + GIB / 2), "1.5 GiB");
520        assert_eq!(format_gib(101 * GIB + GIB / 5), "101.2 GiB");
521    }
522
523    #[test]
524    fn format_gib_exact_carries_raw_bytes() {
525        let s = format_gib_exact(12 * GIB);
526        assert!(s.contains("12.00 GiB"), "two decimals: {s}");
527        assert!(s.contains(&format!("{}", 12 * GIB)), "raw bytes: {s}");
528    }
529
530    #[test]
531    fn available_bytes_probes_real_volume() {
532        let dir = tempdir().unwrap();
533        // A real filesystem always has *some* notion of available space;
534        // the probe must return Some (we don't assert a floor — CI runners
535        // vary — only that the call resolves).
536        assert!(
537            available_bytes(dir.path()).is_some(),
538            "available_bytes must resolve on a real dir"
539        );
540    }
541
542    #[test]
543    fn available_bytes_missing_path_is_none_not_panic() {
544        let dir = tempdir().unwrap();
545        let missing = dir.path().join("does-not-exist");
546        assert_eq!(available_bytes(&missing), None);
547    }
548
549    #[test]
550    fn dir_size_sums_regular_files_recursively() {
551        let dir = tempdir().unwrap();
552        fs::write(dir.path().join("a"), vec![0u8; 100]).unwrap();
553        fs::create_dir(dir.path().join("sub")).unwrap();
554        fs::write(dir.path().join("sub").join("b"), vec![0u8; 250]).unwrap();
555        assert_eq!(dir_size_bytes(dir.path()), 350);
556    }
557
558    #[test]
559    fn dir_size_missing_dir_is_zero() {
560        let dir = tempdir().unwrap();
561        assert_eq!(dir_size_bytes(&dir.path().join("nope")), 0);
562    }
563
564    #[test]
565    fn dir_size_does_not_follow_symlinks() {
566        let dir = tempdir().unwrap();
567        fs::write(dir.path().join("real"), vec![0u8; 500]).unwrap();
568        #[cfg(unix)]
569        {
570            use std::os::unix::fs::symlink;
571            symlink(dir.path().join("real"), dir.path().join("link")).unwrap();
572            assert_eq!(dir_size_bytes(dir.path()), 500);
573        }
574    }
575
576    #[test]
577    fn sampler_records_a_minimum_on_a_real_volume() {
578        // On a real dir the sampler must take at least one reading and
579        // return Some — the value itself is runner-dependent.
580        let dir = tempdir().unwrap();
581        let s = FreeSpaceSampler::start(dir.path(), Duration::from_millis(5));
582        std::thread::sleep(Duration::from_millis(20));
583        assert!(
584            s.stop().is_some(),
585            "sampler must record a reading on a real volume"
586        );
587    }
588
589    #[test]
590    fn sampler_probe_gap_yields_none() {
591        // A path that doesn't exist makes every probe fail; the sampler
592        // records nothing and yields None (never a manufactured value).
593        let dir = tempdir().unwrap();
594        let missing = dir.path().join("nope");
595        let s = FreeSpaceSampler::start(&missing, Duration::from_millis(5));
596        std::thread::sleep(Duration::from_millis(20));
597        assert_eq!(s.stop(), None);
598    }
599
600    #[test]
601    fn run_peak_consumed_is_the_drop_to_the_minimum() {
602        let p = RunPeak {
603            free_before: 100 * GIB,
604            min_free_during: 30 * GIB,
605        };
606        assert_eq!(p.consumed_bytes(), 70 * GIB);
607    }
608
609    #[test]
610    fn run_peak_clamps_when_free_space_grew_mid_run() {
611        let p = RunPeak {
612            free_before: 80 * GIB,
613            min_free_during: 90 * GIB,
614        };
615        assert_eq!(p.consumed_bytes(), 0);
616    }
617
618    #[test]
619    fn headroom_run0_gated_by_absolute_floor_only_not_peak() {
620        // No prior peak (run-0): the floor is the sole gate and the
621        // shortfall must NOT claim a peak basis.
622        let ok = evaluate_headroom(0, 50 * GIB, 45 * GIB, None, 1.3, "/");
623        assert_eq!(ok, HeadroomDecision::Proceed);
624
625        let bad = evaluate_headroom(0, 30 * GIB, 45 * GIB, None, 1.3, "/");
626        match bad {
627            HeadroomDecision::Abort(s) => {
628                assert_eq!(s.run_idx, 0);
629                assert_eq!(s.required_bytes, 45 * GIB);
630                assert_eq!(s.available_bytes, 30 * GIB);
631                assert!(!s.peak_gated, "run-0 is floor-gated, not peak-gated");
632                assert!(
633                    s.message().contains("absolute floor"),
634                    "run-0 message must state the floor basis: {}",
635                    s.message()
636                );
637            }
638            other => panic!("expected Abort, got {other:?}"),
639        }
640    }
641
642    #[test]
643    fn headroom_later_run_uses_measured_peak_when_above_floor() {
644        // run-0 PEAKED at 70 GiB consumed; ×1.3 = 91 GiB required (above
645        // the 45 GiB floor). This is the exact B1 failure: a net delta
646        // would have seen ~30 GiB and wrongly proceeded.
647        let prior_peak = Some(70 * GIB);
648        let decision = evaluate_headroom(1, 71 * GIB, 45 * GIB, prior_peak, 1.3, "/Volumes/x");
649        match decision {
650            HeadroomDecision::Abort(s) => {
651                assert_eq!(s.run_idx, 1);
652                assert_eq!(s.required_bytes, 91 * GIB);
653                assert_eq!(s.available_bytes, 71 * GIB);
654                assert!(s.peak_gated, "run-1 must be peak-gated");
655                assert!(
656                    s.message().contains("measured peak"),
657                    "message must state the peak basis: {}",
658                    s.message()
659                );
660            }
661            other => panic!("expected Abort (71 < 91), got {other:?}"),
662        }
663        // 95 GiB free clears the 91 GiB requirement.
664        assert_eq!(
665            evaluate_headroom(1, 95 * GIB, 45 * GIB, prior_peak, 1.3, "/Volumes/x"),
666            HeadroomDecision::Proceed
667        );
668    }
669
670    #[test]
671    fn headroom_floor_backstops_a_tiny_measured_peak() {
672        // A prior run that barely peaked; ×1.3 is below the floor, so the
673        // floor still gates and the shortfall is NOT peak-gated.
674        let prior_peak = Some(GIB);
675        let bad = evaluate_headroom(2, 30 * GIB, 45 * GIB, prior_peak, 1.3, "/");
676        match bad {
677            HeadroomDecision::Abort(s) => {
678                assert_eq!(s.required_bytes, 45 * GIB);
679                assert!(!s.peak_gated, "floor won, so not peak-gated");
680            }
681            other => panic!("expected Abort, got {other:?}"),
682        }
683    }
684
685    #[test]
686    fn project_peak_saturates_at_extremes_instead_of_wrapping() {
687        // An absurd peak × factor saturates to u64::MAX (always-abort, the
688        // safe direction) rather than wrapping to a small value.
689        assert_eq!(project_peak(u64::MAX, 2.0), u64::MAX);
690        assert_eq!(
691            project_peak(10 * GIB, 1.3),
692            (10.0 * GIB as f64 * 1.3) as u64
693        );
694    }
695
696    #[test]
697    fn shortfall_message_carries_exact_numbers_and_remedies() {
698        let s = HeadroomShortfall {
699            run_idx: 1,
700            required_bytes: 91 * GIB,
701            available_bytes: 71 * GIB,
702            volume: "/Volumes/scratch".into(),
703            peak_gated: true,
704        };
705        let msg = s.message();
706        assert!(msg.contains("determinism run 2"), "1-based run: {msg}");
707        assert!(
708            msg.contains(&format!("{}", 91 * GIB)),
709            "exact required: {msg}"
710        );
711        assert!(
712            msg.contains(&format!("{}", 71 * GIB)),
713            "exact available: {msg}"
714        );
715        assert!(msg.contains("/Volumes/scratch"), "volume: {msg}");
716        assert!(msg.contains("reclaim-disk"), "remedy: {msg}");
717        assert!(msg.contains(ABS_FLOOR_ENV), "floor override hint: {msg}");
718        assert!(
719            msg.contains(SAFETY_FACTOR_ENV),
720            "factor override hint: {msg}"
721        );
722    }
723
724    #[test]
725    fn abs_floor_env_rejects_zero_malformed_overflow_accepts_valid() {
726        // Injected env via MapEnvSource — no process-env mutation, so the
727        // test is race-free and needs neither `unsafe` nor `#[serial]`.
728        let cases: &[(&str, u64)] = &[
729            ("0", DEFAULT_ABS_FLOOR_BYTES),           // B2: 0 must not disable
730            ("abc", DEFAULT_ABS_FLOOR_BYTES),         // malformed
731            ("18446744073", DEFAULT_ABS_FLOOR_BYTES), // B3: × GiB overflows u64
732            ("60", 60 * GIB),                         // valid
733            ("", DEFAULT_ABS_FLOOR_BYTES),            // empty
734        ];
735        for (val, want) in cases {
736            let env = MapEnvSource::new().with(ABS_FLOOR_ENV, *val);
737            assert_eq!(
738                abs_floor_bytes_from_env_with(&env),
739                *want,
740                "ABS_FLOOR_ENV={val:?} should resolve to {want}"
741            );
742        }
743        // Missing key (var simply absent from the map) → default.
744        assert_eq!(
745            abs_floor_bytes_from_env_with(&MapEnvSource::new()),
746            DEFAULT_ABS_FLOOR_BYTES,
747            "unset → default"
748        );
749    }
750
751    #[test]
752    fn safety_factor_env_rejects_nonpositive_accepts_valid() {
753        let cases: &[(&str, f64)] = &[
754            ("0", DEFAULT_SAFETY_FACTOR),
755            ("-1.0", DEFAULT_SAFETY_FACTOR),
756            ("nan", DEFAULT_SAFETY_FACTOR),
757            ("xyz", DEFAULT_SAFETY_FACTOR),
758            ("2.0", 2.0),
759        ];
760        for (val, want) in cases {
761            let env = MapEnvSource::new().with(SAFETY_FACTOR_ENV, *val);
762            assert!(
763                (safety_factor_from_env_with(&env) - want).abs() < f64::EPSILON,
764                "SAFETY_FACTOR_ENV={val:?} should resolve to {want}"
765            );
766        }
767        // Missing key → default.
768        assert!(
769            (safety_factor_from_env_with(&MapEnvSource::new()) - DEFAULT_SAFETY_FACTOR).abs()
770                < f64::EPSILON
771        );
772    }
773
774    #[test]
775    fn defaults_are_the_documented_values() {
776        // A minimal liveness floor — NOT a workload peak. The measured-peak
777        // gate (run-1+) is the heavy-workload protection.
778        assert_eq!(DEFAULT_ABS_FLOOR_BYTES, 2 * GIB);
779        assert!((DEFAULT_SAFETY_FACTOR - 1.3).abs() < f64::EPSILON);
780    }
781}