From aaa6723d2fa106a200802557325acba77bffc77b Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sat, 26 Sep 2026 11:51:46 +0200 Subject: [PATCH] prune: garbage-collect resumable build sessions Sessions accumulate under /var/tmp/pkh/sessions for every failed, interrupted or --keep build and can reach tens of gigabytes for kernel-sized chroots: prune is their primary GC. By default remove the sessions untouched for longer than the retention window (7 days) plus the corrupt leftovers; --all removes everything. Session roots hold the same mounts as residual chroots (/proc bind mount, overlays), so they are unmounted before removal. prune_in() (the testable core) deliberately stays session-free: the test suite runs prune tests and e2e builds concurrently, and scanning the real sessions root from the tests deleted live sessions mid-build. Session pruning is an explicit opt-in of prune_in_roots(), used by the production prune(). --- src/prune.rs | 148 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 139 insertions(+), 9 deletions(-) diff --git a/src/prune.rs b/src/prune.rs index 6c86ced..641824b 100644 --- a/src/prune.rs +++ b/src/prune.rs @@ -9,6 +9,11 @@ //! may contain root-owned device nodes (created via `mknod`), a //! bind-mounted `/proc` and overlay filesystems, so they require careful //! unmounting before they can be removed. +//! - **Resumable build sessions** under `/var/tmp/pkh/sessions` (see +//! [`crate::deb::session`]): a kept session is a resumable build; the +//! default policy removes the ones untouched for longer than the session +//! retention window plus the corrupt leftovers, `--all` removes every +//! session. //! - **Cached chroot tarballs** (`~/.cache/pkh/*-buildd.tar.xz`) and their //! **stale download lockfiles** (`~/.cache/pkh/*.lock` untouched for longer //! than [`LOCK_STALE_AFTER`]; younger lockfiles may belong to a concurrent @@ -92,6 +97,8 @@ enum Artifact { Tarball(PathBuf), /// A build log file under `/logs`. LogFile(PathBuf), + /// A resumable build session root (chroot + journal). + Session(PathBuf), } impl Artifact { @@ -101,7 +108,8 @@ impl Artifact { | Artifact::KeyringDir(p) | Artifact::LockFile(p) | Artifact::Tarball(p) - | Artifact::LogFile(p) => p, + | Artifact::LogFile(p) + | Artifact::Session(p) => p, } } @@ -112,10 +120,55 @@ impl Artifact { Artifact::LockFile(_) => "stale lockfile", Artifact::Tarball(_) => "cached chroot tarball", Artifact::LogFile(_) => "build log", + Artifact::Session(_) => "resumable build session", } } } +/// Discover the build sessions that prune may remove under `sessions_dir`: +/// with `--all`, every session; otherwise only those untouched for longer +/// than the retention window ([`crate::deb::session::SESSION_RETENTION`]) +/// and the ones whose manifest is missing or unreadable (inert leftovers). +fn discover_sessions(sessions_dir: &Path, all: bool) -> Vec { + let Ok(entries) = fs::read_dir(sessions_dir) else { + return Vec::new(); + }; + entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .filter(|path| is_prunable_session(path, all)) + .map(Artifact::Session) + .collect() +} + +/// Whether a session root is prunable under the given policy: everything +/// with `--all`, otherwise stale (untouched beyond the retention window) +/// or corrupt (no readable manifest) sessions. Live sessions record a +/// fresh `last_used` on every journal write and are never stale. +fn is_prunable_session(path: &Path, all: bool) -> bool { + if all { + return true; + } + let last_used = fs::read_to_string(path.join("session.json")) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .and_then(|value| { + value + .get("last_used") + .and_then(|v| v.as_str()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + }); + match last_used { + Some(then) => chrono::Utc::now() + .signed_duration_since(then) + .to_std() + .is_ok_and(|age| age >= crate::deb::session::SESSION_RETENTION), + // No readable manifest: an inert leftover. + None => true, + } +} + /// Determine the pkh cache directory (e.g. `~/.cache/pkh`), if project dirs /// can be resolved on this platform. pub fn cache_dir() -> Option { @@ -413,19 +466,39 @@ fn unmount(mountpoint: &str) -> std::io::Result<()> { } } -/// Execute the prune operation against the given roots, removing residual pkh -/// artifacts. +/// Execute the prune operation against the given temp and cache roots, +/// removing residual pkh artifacts. /// /// This is the testable core of [`prune()`]: callers pass in the temp -/// directory and optional cache directory to scan. See the -/// [module documentation](self) for what is removed. Returns a [`PruneReport`] -/// describing what was removed (or, in dry-run mode, what would be removed). +/// directory and optional cache directory to scan. Build sessions are +/// deliberately out of scope here — tests scan controlled roots, and a +/// concurrent test suite must never touch the live sessions under the +/// real sessions root. See the [module documentation](self) for what is +/// removed. Returns a [`PruneReport`] describing what was removed (or, in +/// dry-run mode, what would be removed). pub fn prune_in( temp_dir: &Path, cache_dir: Option<&Path>, options: PruneOptions, ) -> Result> { - let artifacts = discover_artifacts(temp_dir, cache_dir); + prune_in_roots(temp_dir, cache_dir, None, options) +} + +/// Execute the prune operation against explicit roots. +/// +/// `sessions_dir` opts in to build-session pruning (the production +/// [`prune()`] passes the real sessions root; tests pass a controlled +/// directory or `None`). +pub fn prune_in_roots( + temp_dir: &Path, + cache_dir: Option<&Path>, + sessions_dir: Option<&Path>, + options: PruneOptions, +) -> Result> { + let mut artifacts = discover_artifacts(temp_dir, cache_dir); + if let Some(sessions_dir) = sessions_dir { + artifacts.extend(discover_sessions(sessions_dir, options.all)); + } let mut report = PruneReport { dry_run: options.dry_run, @@ -459,7 +532,8 @@ pub fn prune_in( // Residual build directories may hold bind-mounted /proc and overlay // filesystems that must be unmounted before the directory can be // removed, otherwise `rm -rf` will fail or leave dangling mounts. - if let Artifact::TempDir(_) = artifact { + // Session roots hold the same kind of mounts inside their chroot. + if let Artifact::TempDir(_) | Artifact::Session(_) = artifact { let mounts_content = fs::read_to_string("/proc/mounts").unwrap_or_default(); for mp in mounts_under_from_content(&mounts_content, path) { log::debug!("Unmounting {} under {}", mp, path.display()); @@ -487,7 +561,12 @@ pub fn prune_in( pub fn prune(options: PruneOptions) -> Result> { let temp_dir = std::env::temp_dir(); let cache = cache_dir(); - prune_in(&temp_dir, cache.as_deref(), options) + prune_in_roots( + &temp_dir, + cache.as_deref(), + Some(&crate::deb::session::sessions_root()), + options, + ) } #[cfg(test)] @@ -685,6 +764,7 @@ none /tmp/other proc rw 0 0 Artifact::LockFile(_) => locks += 1, Artifact::Tarball(_) => tarballs += 1, Artifact::LogFile(_) => logs += 1, + Artifact::Session(_) => {} } } assert_eq!(temp_dirs, 2); @@ -1084,4 +1164,54 @@ none /tmp/other proc rw 0 0 let report = prune_in(temp.path(), Some(cache.path()), PruneOptions::default()).unwrap(); assert!(report.is_empty()); } + + /// Write a minimal session manifest with the given `last_used`. + fn write_session_manifest(session_dir: &Path, last_used: &str) { + fs::create_dir_all(session_dir).unwrap(); + fs::write( + session_dir.join("session.json"), + format!(r#"{{ "last_used": "{last_used}" }}"#), + ) + .unwrap(); + } + + /// Session pruning: stale and corrupt sessions go by default, live ones + /// stay; `--all` takes everything. + #[test] + fn test_session_pruning_retention() { + let temp = tempdir().unwrap(); + let sessions = temp.path().join("sessions"); + let stale = sessions.join("stale"); + let fresh = sessions.join("fresh"); + let corrupt = sessions.join("corrupt"); + write_session_manifest(&stale, "2020-01-01T00:00:00+00:00"); + write_session_manifest( + &fresh, + &(chrono::Utc::now() - chrono::Duration::hours(1)).to_rfc3339(), + ); + fs::create_dir_all(&corrupt).unwrap(); + fs::write(corrupt.join("session.json"), "not json").unwrap(); + + let report = + prune_in_roots(temp.path(), None, Some(&sessions), PruneOptions::default()).unwrap(); + assert!(report.removed.contains(&stale)); + assert!(report.removed.contains(&corrupt)); + assert!(fresh.exists(), "a live session is not pruned by default"); + assert!(!stale.exists()); + assert!(!corrupt.exists()); + + // --all removes everything, including the live session. + let report = prune_in_roots( + temp.path(), + None, + Some(&sessions), + PruneOptions { + all: true, + ..Default::default() + }, + ) + .unwrap(); + assert!(report.removed.contains(&fresh)); + assert!(!fresh.exists()); + } }