Skip to main content

magi/
disk.rs

1//! Disk accounting: how much magi's own directories occupy, how much space is
2//! left on the volume they live on, and how the shared build cache is pruned.
3//!
4//! The whole module grew out of one incident: a machine with 951.8 GB of disk
5//! ran a handful of competitions and ended up with 6.7 GB free and a pile of
6//! multi-gigabyte `target/` directories. Every function here exists to keep
7//! that from being a discovery, and every number is substituted at a pure
8//! boundary so the policy can be tested without asking the OS anything.
9
10use std::path::{Path, PathBuf};
11
12use anyhow::{Context as _, Result, bail};
13
14use crate::proc::Quiet as _;
15
16/// Are `free` bytes above the floor for starting a run?
17///
18/// Pure on purpose: the threshold logic is asserted against injected numbers,
19/// and the only place the machine is actually asked anything is [`free_bytes`].
20pub fn enough_space(free: u64, min_free: u64) -> bool {
21    free >= min_free
22}
23
24/// Why a run must not start, given measured free bytes and the floor —
25/// `None` means the gate is open. Pure, so the policy is asserted directly.
26///
27/// A gate that cannot measure also closes (see [`crate::daemon::disk_gate`]):
28/// starting a run on a disk that may already be full is the incident this
29/// whole module exists to prevent.
30pub fn gate(free: u64, min_free: u64) -> Option<String> {
31    if enough_space(free, min_free) {
32        None
33    } else {
34        Some(format!(
35            "not enough free space to start a run: {free} bytes free, \
36             {min_free} required by `[disk] min_free_bytes`"
37        ))
38    }
39}
40
41/// Is `size` past `limit`? One comparison, shared by the janitor and the
42/// health view, so both answer "is the cache over its cap" identically.
43pub fn over_limit(size: u64, limit: u64) -> bool {
44    size > limit
45}
46
47/// The path a rendered command sets `CARGO_TARGET_DIR=` to, if any.
48///
49/// magi never computes the cache path itself. The operator's `magi.toml` is
50/// the only place that knows it, and by the time a [`crate::config::Config`]
51/// exists that template has been rendered — so the concrete path is read back
52/// out of the verify commands (`CARGO_TARGET_DIR={{ vars.cache }}/magi-target
53/// cargo …` becomes `C:\…\Temp\magi-target`). This is what lets the janitor
54/// prune exactly the directory the gate and the seats build into. `None` when
55/// no command sets the variable: there is then no cache to aggregate or prune,
56/// and agents build wherever the repository's own defaults put them.
57///
58/// The value may be quoted with `'` or `"`; both are understood, as is no
59/// quoting (up to the next whitespace).
60pub fn extract_cargo_target_dir(command: &str) -> Option<PathBuf> {
61    const KEY: &str = "CARGO_TARGET_DIR=";
62    let rest = command.split_once(KEY)?.1.trim_start();
63    let value = if let Some(s) = rest.strip_prefix('\'') {
64        s.split('\'').next().unwrap_or("")
65    } else if let Some(s) = rest.strip_prefix('"') {
66        s.split('"').next().unwrap_or("")
67    } else {
68        let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
69        &rest[..end]
70    };
71    if value.is_empty() {
72        None
73    } else {
74        Some(PathBuf::from(value))
75    }
76}
77
78/// Free bytes on the volume containing `path`.
79///
80/// There is no portable way to ask for this, so each platform runs its own
81/// tiny command, deliberately not a new dependency. The parsing halves are
82/// pure and asserted against fixture text; only the subprocess is live.
83pub fn free_bytes(path: &Path) -> Result<u64> {
84    free_bytes_by_os(path)
85}
86
87/// Free bytes on the volume containing `path`.
88#[cfg(unix)]
89fn free_bytes_by_os(path: &Path) -> Result<u64> {
90    let out = std::process::Command::new("df")
91        .args(["-k", "-P"])
92        .arg(path)
93        .quiet()
94        .output()
95        .with_context(|| format!("run `df` for {}", path.display()))?;
96    if !out.status.success() {
97        bail!(
98            "`df` failed: {}",
99            String::from_utf8_lossy(&out.stderr).trim()
100        );
101    }
102    let text = String::from_utf8_lossy(&out.stdout);
103    text.lines()
104        .skip(1)
105        .find_map(parse_df_available)
106        .with_context(|| format!("parse `df` output for {}", path.display()))
107}
108
109/// Free bytes on the volume containing `path`.
110#[cfg(windows)]
111fn free_bytes_by_os(path: &Path) -> Result<u64> {
112    // `fsutil volume diskfree` needs an elevated shell; the .NET DriveInfo in
113    // the Windows PowerShell that ships with the OS does not.
114    //
115    // DriveInfo is handed the **volume root**, never the path itself: its
116    // constructor accepts a drive letter or a root directory and throws on
117    // anything else, including every verbatim path. The queue stores repo
118    // paths as `\\?\C:\...` (that is what `std::path::absolute` yields for a
119    // canonicalised root), so passing the path through closed the disk gate
120    // for every task with `the disk gate refuses to let a run start blind` -
121    // nine tasks were `held` for a disk that had 164 GiB free.
122    let abs = std::path::absolute(path)
123        .with_context(|| format!("absolute path for {}", path.display()))?;
124    let root = volume_root(&abs)
125        .with_context(|| format!("no volume root in {} to measure", abs.display()))?;
126    let quoted = root.replace('\'', "''");
127    let script = format!("[System.IO.DriveInfo]::new('{quoted}').AvailableFreeSpace");
128    let out = std::process::Command::new("powershell")
129        .args(["-NoProfile", "-NonInteractive", "-Command", &script])
130        // Without this the operator watches a console window blink open for
131        // every measurement - and the health view measures on every tick, so
132        // merely leaving the deck open in a browser flashed one every few
133        // seconds. `Quiet` exists for exactly this and the probe skipped it.
134        .quiet()
135        .output()
136        .with_context(|| format!("run PowerShell for {}", abs.display()))?;
137    if !out.status.success() {
138        bail!(
139            "PowerShell failed: {}",
140            String::from_utf8_lossy(&out.stderr).trim()
141        );
142    }
143    parse_u64(&String::from_utf8_lossy(&out.stdout))
144        .with_context(|| format!("parse PowerShell bytes for {}", abs.display()))
145}
146
147/// One `df -k -P` data row: `Filesystem 1024-blocks Used Available …`.
148///
149/// The value is 1024-byte blocks, so the parse returns bytes.
150pub fn parse_df_available(line: &str) -> Option<u64> {
151    let mut fields = line.split_whitespace();
152    fields.next()?; // filesystem
153    fields.next()?; // 1024-blocks
154    fields.next()?; // used
155    let blocks: u64 = fields.next()?.parse().ok()?;
156    Some(blocks.saturating_mul(1024))
157}
158
159/// The volume root of an absolute Windows path, as DriveInfo wants it:
160/// `C:\`, never `C:\Users\...` and never a verbatim `\\?\C:\...`.
161///
162/// Pure and platform-independent so the verbatim form - which is what the
163/// queue stores and what closed the disk gate on every task - is asserted
164/// without a Windows runner. `None` when there is no drive letter to name: a
165/// UNC share has no DriveInfo of its own, and a caller must say it cannot
166/// measure rather than invent a volume.
167pub fn volume_root(path: &Path) -> Option<String> {
168    let text = path.to_str()?;
169    // Verbatim (`\\?\C:\x`) and verbatim-UNC (`\\?\UNC\server\share`) prefixes.
170    let bare = text
171        .strip_prefix(r"\\?\")
172        .or_else(|| text.strip_prefix("//?/"))
173        .unwrap_or(text);
174    let mut chars = bare.chars();
175    let letter = chars.next()?;
176    if !letter.is_ascii_alphabetic() || chars.next()? != ':' {
177        return None;
178    }
179    Some(format!(r"{letter}:\"))
180}
181
182/// A bare unsigned integer line, which is all PowerShell prints for a long.
183pub fn parse_u64(text: &str) -> Option<u64> {
184    text.trim().parse().ok()
185}
186
187/// Total bytes under `path`, without following symlinks.
188///
189/// A symlinked directory counts as the link itself, not its contents: mutable
190/// worktrees are real directories, and following an accidental link into a
191/// clone of the repository would count the same bytes twice.
192pub fn dir_size(path: &Path) -> u64 {
193    let Ok(meta) = std::fs::symlink_metadata(path) else {
194        return 0;
195    };
196    if meta.is_file() {
197        return meta.len();
198    }
199    if !meta.is_dir() {
200        return 0;
201    }
202    let mut total = 0u64;
203    let mut stack = vec![path.to_path_buf()];
204    while let Some(dir) = stack.pop() {
205        let Ok(rd) = std::fs::read_dir(&dir) else {
206            continue;
207        };
208        for entry in rd.flatten() {
209            // `DirEntry::metadata` reports the entry itself, so a symlink is
210            // never traversed.
211            let Ok(meta) = entry.metadata() else {
212                continue;
213            };
214            if meta.is_dir() {
215                stack.push(entry.path());
216            } else if meta.is_file() {
217                total += meta.len();
218            }
219        }
220    }
221    total
222}
223
224/// What a prune removed, for the report.
225#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
226pub struct Prune {
227    /// Bytes actually freed.
228    pub freed: u64,
229    /// Files deleted.
230    pub files: usize,
231    /// Bytes still under the directory afterwards.
232    pub remaining: u64,
233}
234
235/// Delete files under `dir` oldest-first until its size is at or below `limit`.
236///
237/// The comparison is [`over_limit`], so a directory exactly at the cap is left
238/// alone. Oldest-first keeps the newest generation of artifacts — the one the
239/// next run reuses — and sheds the generations that only compile history. A
240/// deleted file costs the next build a rebuild of that one unit; deleting the
241/// whole directory would cost it everything, which is precisely the work
242/// [`prune_dir`] is keeping for it.
243///
244/// Empty directories left behind are swept depth-first, so cargo's deep
245/// `fingerprint`/`deps` trees do not outlive the files that made them.
246///
247/// Nothing is deleted when the directory is missing.
248pub fn prune_dir(dir: &Path, limit: u64) -> Result<Prune> {
249    let Some(tree) = Tree::of(dir) else {
250        return Ok(Prune {
251            freed: 0,
252            files: 0,
253            remaining: 0,
254        });
255    };
256    let mut total = tree.total;
257    if !over_limit(total, limit) {
258        return Ok(Prune {
259            freed: 0,
260            files: 0,
261            remaining: total,
262        });
263    }
264    let mut freed = 0u64;
265    let mut removed = 0usize;
266    for (_, size, path) in tree.files {
267        if !over_limit(total, limit) {
268            break;
269        }
270        // A file that is being read elsewhere (a concurrent build, a snapshot)
271        // fails on Windows; skip it and continue — the next prune gets it.
272        if std::fs::remove_file(&path).is_ok() {
273            total = total.saturating_sub(size);
274            freed += size;
275            removed += 1;
276        }
277    }
278    strip_empty_dirs(&tree.dirs);
279    Ok(Prune {
280        freed,
281        files: removed,
282        remaining: total,
283    })
284}
285
286/// Files and directories under one root, walked up-front.
287struct Tree {
288    total: u64,
289    files: Vec<(u128, u64, PathBuf)>,
290    dirs: Vec<(usize, PathBuf)>,
291}
292
293impl Tree {
294    /// Walk `dir`, collecting files (mtime-nanoseconds, size, path) and
295    /// directories (depth, path). `None` when the directory does not exist.
296    fn of(dir: &Path) -> Option<Tree> {
297        if dir.symlink_metadata().ok()?.is_dir() {
298            Some(Tree::from_dir(dir))
299        } else {
300            None
301        }
302    }
303
304    fn from_dir(dir: &Path) -> Tree {
305        let mut total = 0u64;
306        let mut files = Vec::new();
307        let mut dirs = Vec::new();
308        // Depth-first so directories are recorded before their contents; the
309        // dir list is then sorted by descending depth for the sweep.
310        let mut stack: Vec<(usize, PathBuf)> = vec![(0, dir.to_path_buf())];
311        while let Some((depth, d)) = stack.pop() {
312            let Ok(rd) = std::fs::read_dir(&d) else {
313                continue;
314            };
315            for entry in rd.flatten() {
316                let Ok(meta) = entry.metadata() else {
317                    continue;
318                };
319                let path = entry.path();
320                if meta.is_dir() {
321                    dirs.push((depth + 1, path.clone()));
322                    stack.push((depth + 1, path));
323                } else if meta.is_file() {
324                    let size = meta.len();
325                    total += size;
326                    let mtime = meta
327                        .modified()
328                        .ok()
329                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
330                        .map(|d| d.as_nanos())
331                        .unwrap_or(0);
332                    files.push((mtime, size, path));
333                }
334            }
335        }
336        // Oldest first, and on a tie the larger file: a whole generation of
337        // cargo artifacts is written within one filesystem timestamp tick, so
338        // mtime alone leaves the order to `read_dir` and the sort's
339        // instability - the same cache pruned twice would shed different
340        // files, and a test over two same-tick files passed on one platform
341        // and failed on another. Larger-first also reaches the cap in fewer
342        // deletions, which is fewer rebuilt units for the next run.
343        files.sort_unstable_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)).then(a.2.cmp(&b.2)));
344        Tree { total, files, dirs }
345    }
346}
347
348/// Remove empty directories, deepest first, never the root itself.
349fn strip_empty_dirs(dirs: &[(usize, PathBuf)]) {
350    let mut by_depth: Vec<&PathBuf> = dirs.iter().map(|(_, d)| d).collect();
351    by_depth.sort_unstable_by_key(|d| std::cmp::Reverse(d.iter().count()));
352    for d in by_depth {
353        let _ = std::fs::remove_dir(d);
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use std::fs;
361
362    #[test]
363    fn the_free_space_predicate_is_the_boundary() {
364        assert!(enough_space(100, 100));
365        assert!(enough_space(101, 100));
366        assert!(!enough_space(99, 100));
367        // A zero floor disables the gate: the operator opted out.
368        assert!(enough_space(0, 0));
369    }
370
371    #[test]
372    fn the_gate_text_conveys_both_numbers_and_opens_with_room() {
373        assert_eq!(
374            gate(9, 10).expect("closed"),
375            "not enough free space to start a run: 9 bytes free, 10 required by `[disk] min_free_bytes`"
376        );
377        assert_eq!(gate(10, 10), None, "exactly at the floor is open");
378        assert_eq!(gate(10_000, 0), None, "a zero floor is an opt-out");
379    }
380
381    #[test]
382    fn over_limit_uses_strict_greater_than() {
383        assert!(over_limit(11, 10));
384        assert!(!over_limit(10, 10));
385        assert!(!over_limit(9, 10));
386    }
387
388    #[test]
389    fn df_row_parses_1024_blocks_into_bytes() {
390        let row = "/dev/sda1 976762584 808522388 168240196 83% /home";
391        assert_eq!(parse_df_available(row), Some(168_240_196 * 1024));
392        assert_eq!(parse_df_available("garbage"), None);
393        assert_eq!(parse_df_available("a b c x"), None);
394    }
395
396    #[test]
397    fn a_powershell_number_is_one_unsigned_integer() {
398        assert_eq!(parse_u64("     82072211456\r\n"), Some(82_072_211_456));
399        assert_eq!(parse_u64("nah"), None);
400    }
401
402    /// DriveInfo takes a volume, and the queue hands out verbatim paths.
403    #[test]
404    fn the_volume_root_is_a_drive_not_the_path_it_came_from() {
405        // The form that closed the gate on every queued task: the queue
406        // records the repo as `\\?\C:\...`.
407        assert_eq!(
408            volume_root(Path::new(
409                r"\\?\C:\Users\yukimemi\src\github.com\yukimemi\magi"
410            )),
411            Some(r"C:\".to_owned())
412        );
413        assert_eq!(
414            volume_root(Path::new(r"C:\Users\yukimemi")),
415            Some(r"C:\".to_owned())
416        );
417        assert_eq!(volume_root(Path::new(r"D:\")), Some(r"D:\".to_owned()));
418        // Forward slashes reach magi from configs written by hand.
419        assert_eq!(
420            volume_root(Path::new("C:/Users/yukimemi/src")),
421            Some(r"C:\".to_owned())
422        );
423        // No drive to name: a share has no DriveInfo, and a POSIX path has no
424        // volume at all. The caller has to report that it cannot measure.
425        assert_eq!(volume_root(Path::new(r"\\server\share\dir")), None);
426        assert_eq!(volume_root(Path::new(r"\\?\UNC\server\share")), None);
427        assert_eq!(volume_root(Path::new("/home/yukimemi")), None);
428    }
429
430    #[test]
431    fn the_cache_dir_is_read_back_out_of_a_rendered_command() {
432        let cmd = r"CARGO_TARGET_DIR=C:\Users\me\Temp\magi-target cargo make check";
433        assert_eq!(
434            extract_cargo_target_dir(cmd),
435            Some(PathBuf::from(r"C:\Users\me\Temp\magi-target"))
436        );
437        // Quoted forms survive spaces; a config with none stays None.
438        assert_eq!(
439            extract_cargo_target_dir(r"CARGO_TARGET_DIR='/tmp/a b' cargo test"),
440            Some(PathBuf::from("/tmp/a b"))
441        );
442        assert_eq!(
443            extract_cargo_target_dir(r#"CARGO_TARGET_DIR="/tmp/qq" cargo test"#),
444            Some(PathBuf::from("/tmp/qq"))
445        );
446        assert_eq!(extract_cargo_target_dir("cargo make check"), None);
447        assert_eq!(extract_cargo_target_dir("CARGO_TARGET_DIR="), None);
448        // Second occurrence is irrelevant: the first is what the build used
449        // (a command's environment applies once).
450        let two = "CARGO_TARGET_DIR=/first and CARGO_TARGET_DIR=/second cargo x";
451        assert_eq!(extract_cargo_target_dir(two), Some(PathBuf::from("/first")));
452    }
453
454    #[test]
455    fn dir_size_is_zero_for_missing_and_counts_files_without_following_links() {
456        let t = tempfile::TempDir::new().expect("temp");
457        assert_eq!(dir_size(&t.path().join("nope")), 0);
458        fs::write(t.path().join("a"), b"12345").expect("write");
459        fs::create_dir(t.path().join("sub")).expect("dir");
460        fs::write(t.path().join("sub").join("b"), b"678").expect("write");
461        assert_eq!(dir_size(t.path()), 8);
462        #[cfg(unix)]
463        {
464            std::os::unix::fs::symlink(t.path().join("sub"), t.path().join("link"))
465                .expect("symlink");
466            assert_eq!(dir_size(t.path()), 8, "a link is counted as a link");
467        }
468    }
469
470    #[test]
471    fn prune_deletes_oldest_first_until_the_cap_is_met() {
472        let t = tempfile::TempDir::new().expect("temp");
473        let old = t.path().join("old");
474        fs::write(&old, b"yyyy").expect("write");
475        // Give the older file a measurably older mtime; a second is past the
476        // granularity of the filesystems magi runs on.
477        std::thread::sleep(std::time::Duration::from_millis(1_200));
478        fs::write(t.path().join("new"), b"xxxxx").expect("write");
479
480        // Cap above the total: nothing moves.
481        let keep = prune_dir(t.path(), 9).expect("prune");
482        assert_eq!(
483            keep,
484            Prune {
485                freed: 0,
486                files: 0,
487                remaining: 9
488            }
489        );
490
491        // Cap below: the oldest file goes, the new one stays.
492        let pruned = prune_dir(t.path(), 6).expect("prune");
493        assert!(pruned.freed > 0);
494        assert_eq!(pruned.files, 1);
495        assert_eq!(pruned.remaining, 5);
496        assert!(!old.exists(), "the older file is the one shed");
497        assert!(t.path().join("new").exists());
498    }
499
500    #[test]
501    fn prune_leaves_a_missing_dir_alone() {
502        let t = tempfile::TempDir::new().expect("temp");
503        let out = prune_dir(&t.path().join("absent"), 1).expect("prune");
504        assert_eq!(out, Prune::default());
505    }
506
507    #[test]
508    fn prune_sweeps_directories_the_files_leave_empty() {
509        let t = tempfile::TempDir::new().expect("temp");
510        let deep = t.path().join("a").join("b").join("c");
511        fs::create_dir_all(&deep).expect("dirs");
512        fs::write(deep.join("f"), b"1234").expect("write");
513        let out = prune_dir(t.path(), 0).expect("prune");
514        assert_eq!(out.files, 1);
515        assert_eq!(out.remaining, 0);
516        assert!(!t.path().join("a").exists(), "empty chain swept");
517    }
518}