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}