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