use crate::config::Config;
use crate::maintenance::{Trigger, is_quiet, unix_now_secs};
use crate::store::Store;
use std::path::{Path, PathBuf};
use std::time::Duration;
const INTERVAL: Duration = Duration::from_secs(3600);
const DAY_SECS: u64 = 86_400;
const ORPHAN_GRACE_SECS: u64 = DAY_SECS;
const LAST_CHECK_FILE: &str = "target-cleanup.last";
fn last_check(cache_dir: &Path) -> u64 {
std::fs::read_to_string(cache_dir.join(LAST_CHECK_FILE))
.ok()
.and_then(|text| text.trim().parse().ok())
.unwrap_or(0)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Reason {
Orphaned,
Idle,
}
impl Reason {
fn describe(self) -> &'static str {
match self {
Reason::Orphaned => "its workspace was deleted",
Reason::Idle => "no build used it within auto_clean_idle_targets_days",
}
}
}
pub(crate) fn reason(config: &Config, orphaned: bool, idle_secs: i64) -> Option<Reason> {
let idle = u64::try_from(idle_secs).unwrap_or(0);
if orphaned && config.auto_clean_orphaned_targets && idle >= ORPHAN_GRACE_SECS {
return Some(Reason::Orphaned);
}
let days = config.auto_clean_idle_targets_days;
(days > 0 && idle >= days.saturating_mul(DAY_SECS)).then_some(Reason::Idle)
}
pub(crate) fn due(
config: &Config,
trigger: Trigger<'_>,
permits: Option<u32>,
last: u64,
now: u64,
) -> bool {
let enabled = config.auto_clean_orphaned_targets
|| config.auto_clean_idle_targets_days > 0
|| config.auto_clean_unused_units_days > 0;
let waited = last == 0 || now < last || now - last >= INTERVAL.as_secs();
enabled
&& matches!(trigger, Trigger::Periodic(_))
&& waited
&& is_quiet(permits, trigger.idle_for())
}
pub(crate) fn run(config: &Config, trigger: Trigger<'_>) {
let now = unix_now_secs();
let permits = crate::scheduler::permits_in_use(&config.cache_dir);
if !due(config, trigger, permits, last_check(&config.cache_dir), now) {
return;
}
let record = config.cache_dir.join(LAST_CHECK_FILE);
if let Err(error) = crate::atomic::atomic_replace(&record, now.to_string().as_bytes()) {
tracing::warn!("skipping target directory cleanup: cannot record the time: {error:#}");
return;
}
match sweep(config, now) {
Ok(swept) => {
for (path, reason) in swept.removed {
tracing::info!(
"removed target directory {} because {}",
path.display(),
reason.describe()
);
}
for (path, pruned) in swept.pruned {
tracing::info!(
"removed {} unused build units ({}) from {}",
pruned.units,
bytesize::ByteSize(pruned.bytes),
path.display()
);
}
}
Err(error) => tracing::warn!("target directory cleanup failed: {error:#}"),
}
}
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct Swept {
pub(crate) removed: Vec<(PathBuf, Reason)>,
pub(crate) pruned: Vec<(PathBuf, crate::unit_prune::Pruned)>,
}
pub(crate) fn unit_window(days: u64) -> Option<Duration> {
(days > 0)
.then(|| days.checked_mul(DAY_SECS))
.flatten()
.map(Duration::from_secs)
}
pub(crate) fn sweep(config: &Config, now: u64) -> anyhow::Result<Swept> {
let store = Store::open(config)?;
let window = unit_window(config.auto_clean_unused_units_days);
let at = std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(now);
let now = i64::try_from(now).unwrap_or(i64::MAX);
let mut swept = Swept::default();
for tracked in store.tracked_target_roots(0)? {
let orphaned = crate::cli::workspace_is_gone(&tracked.workspace_root);
let idle = now.saturating_sub(tracked.last_seen);
let intact = crate::machine::target_root_is_safe(&tracked.path, &tracked.workspace_root)
&& crate::machine::directory_identity(&tracked.path) == Some(tracked.identity)
&& !looks_like_a_source_root(&tracked.path);
let Some(reason) = reason(config, orphaned, idle) else {
if let Some(window) = window.filter(|_| intact) {
let pruned = crate::unit_prune::prune(&config.cache_dir, &tracked.path, window, at);
if pruned.units > 0 {
swept.pruned.push((tracked.path, pruned));
}
}
continue;
};
if !intact {
store.forget_target_root(&tracked.path)?;
continue;
}
if crate::cli::target_in_use(&tracked.path) {
continue;
}
match remove(&tracked.path, now) {
Ok(true) => {
store.forget_target_root(&tracked.path)?;
swept.removed.push((tracked.path, reason));
}
Ok(false) => {}
Err(error) => tracing::warn!(
"could not remove target directory {}: {error}",
tracked.path.display()
),
}
}
Ok(swept)
}
fn looks_like_a_source_root(path: &Path) -> bool {
["Cargo.toml", ".git"]
.iter()
.any(|name| std::fs::symlink_metadata(path.join(name)).is_ok())
}
fn remove(target: &Path, now: i64) -> std::io::Result<bool> {
let name = target.file_name().unwrap_or_default().to_string_lossy();
let aside = target.with_file_name(format!(
".{name}.kache-removing-{}-{now}",
std::process::id()
));
if let Err(error) = std::fs::rename(target, &aside) {
return refused(error, crate::cli::target_in_use(target));
}
if crate::cli::target_in_use(&aside) {
if std::fs::symlink_metadata(target).is_err() {
std::fs::rename(&aside, target)?;
}
return Ok(false);
}
std::fs::remove_dir_all(&aside)?;
Ok(true)
}
fn refused(error: std::io::Error, held: bool) -> std::io::Result<bool> {
if held { Ok(false) } else { Err(error) }
}
#[cfg(test)]
mod tests {
use super::*;
use crate::maintenance::RequestClock;
use std::path::Path;
fn config(cache: &Path, orphans: bool, days: u64) -> Config {
let mut config = crate::test_support::test_config(cache.to_path_buf());
config.auto_clean_orphaned_targets = orphans;
config.auto_clean_idle_targets_days = days;
config
}
fn tracked_target(store: &Store, root: &Path, name: &str) -> (PathBuf, PathBuf) {
let workspace = root.join(name);
let target = workspace.join("target");
std::fs::create_dir_all(target.join("debug")).unwrap();
std::fs::write(
target.join("CACHEDIR.TAG"),
"Signature: 8a477f597d28d172789f06886806bc55\n",
)
.unwrap();
store.remember_target_root(&target, &workspace).unwrap();
(workspace, target)
}
fn orphan(store: &Store, root: &Path, name: &str) -> PathBuf {
let workspace = root.join(name);
let target = root.join(format!("{name}-target"));
std::fs::create_dir_all(target.join("debug")).unwrap();
std::fs::create_dir_all(&workspace).unwrap();
std::fs::write(
target.join("CACHEDIR.TAG"),
"Signature: 8a477f597d28d172789f06886806bc55\n",
)
.unwrap();
store.remember_target_root(&target, &workspace).unwrap();
std::fs::remove_dir(&workspace).unwrap();
target
}
fn tracked(store: &Store) -> Vec<PathBuf> {
let mut paths: Vec<_> = store
.tracked_target_roots(0)
.unwrap()
.into_iter()
.map(|t| t.path)
.collect();
paths.sort();
paths
}
fn age(store: &Store, secs: u64) {
store
.file_hash_cache()
.db()
.execute(
"UPDATE target_roots SET last_seen = last_seen - ?1",
[secs as i64],
)
.unwrap();
}
#[test]
fn reasons_follow_the_configuration() {
let dir = tempfile::tempdir().unwrap();
let day = DAY_SECS as i64;
let both = config(dir.path(), true, 30);
assert_eq!(reason(&both, true, day), Some(Reason::Orphaned));
assert_eq!(reason(&both, true, day - 1), None);
assert_eq!(reason(&both, true, 0), None);
assert_eq!(reason(&both, false, 30 * day), Some(Reason::Idle));
assert_eq!(reason(&both, false, 30 * day - 1), None);
assert_eq!(reason(&both, false, -1), None);
let orphans_only = config(dir.path(), true, 0);
assert_eq!(reason(&orphans_only, false, 10_000 * day), None);
let idle_only = config(dir.path(), false, 1);
assert_eq!(reason(&idle_only, true, 0), None);
assert_eq!(reason(&idle_only, true, day), Some(Reason::Idle));
assert_eq!(DAY_SECS, 86_400);
assert_eq!(ORPHAN_GRACE_SECS, 86_400);
assert_eq!(INTERVAL, Duration::from_secs(3_600));
}
#[test]
fn unit_cleanup_turns_the_check_on() {
let dir = tempfile::tempdir().unwrap();
let mut units_only = config(dir.path(), false, 0);
units_only.auto_clean_unused_units_days = 30;
let quiet = RequestClock::idle();
assert!(due(
&units_only,
Trigger::Periodic(&quiet),
Some(0),
0,
10_000
));
assert_eq!(unit_window(0), None);
assert_eq!(unit_window(30), Some(Duration::from_secs(30 * 86_400)));
assert_eq!(unit_window(u64::MAX), None);
}
#[test]
fn a_target_that_stays_loses_its_unused_units() {
let root = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
let mut units = config(cache.path(), true, 0);
units.auto_clean_unused_units_days = 30;
let store = Store::open(&units).unwrap();
let (_, live) = tracked_target(&store, root.path(), "live");
let long_ago =
filetime::FileTime::from_unix_time((unix_now_secs() - 80 * DAY_SECS) as i64, 0);
let unit = |hash: &str| {
let dir = live.join("debug/build/dep").join(hash);
std::fs::create_dir_all(dir.join("fingerprint")).unwrap();
std::fs::write(dir.join("fingerprint/lib-dep"), "fp").unwrap();
filetime::set_file_times(dir.join("fingerprint/lib-dep"), long_ago, long_ago).unwrap();
dir
};
let stale = unit("0123456789abcdef");
let current = unit("fedcba9876543210");
let now = unix_now_secs();
assert_eq!(
sweep(&units, now - 40 * DAY_SECS).unwrap(),
Swept::default()
);
let read = filetime::FileTime::from_unix_time(now as i64, 0);
filetime::set_file_atime(current.join("fingerprint/lib-dep"), read).unwrap();
let swept = sweep(&units, now).unwrap();
if !crate::unit_prune::reads_visible(root.path()) {
assert_eq!(swept, Swept::default());
assert!(stale.exists());
return;
}
assert!(swept.removed.is_empty());
assert_eq!(swept.pruned.len(), 1);
assert_eq!(swept.pruned[0].0, live);
assert_eq!(swept.pruned[0].1.units, 1);
assert!(!stale.exists());
assert!(
current.exists() && live.exists(),
"the target and its used unit stay"
);
let stale = unit("0123456789abcdef");
units.auto_clean_unused_units_days = 0;
assert_eq!(
sweep(&units, now + 400 * DAY_SECS).unwrap(),
Swept::default()
);
assert!(stale.exists());
units.auto_clean_unused_units_days = 30;
std::fs::remove_file(live.join("CACHEDIR.TAG")).unwrap();
assert!(
sweep(&units, now + 400 * DAY_SECS)
.unwrap()
.pruned
.is_empty()
);
assert!(stale.exists());
}
#[test]
fn checks_hourly_when_on_and_quiet() {
let dir = tempfile::tempdir().unwrap();
let on = config(dir.path(), true, 0);
let idle_only = config(dir.path(), false, 5);
let off = config(dir.path(), false, 0);
let quiet = RequestClock::idle();
let busy = RequestClock::new();
let periodic = Trigger::Periodic(&quiet);
let now = 10_000;
assert!(due(&on, periodic, Some(0), 0, now));
assert!(due(&idle_only, periodic, Some(0), 0, now));
assert!(!due(&off, periodic, Some(0), 0, now));
assert!(!due(&on, Trigger::Shutdown, Some(0), 0, now));
assert!(!due(&on, Trigger::Periodic(&busy), Some(0), 0, now));
assert!(!due(&on, periodic, Some(1), 0, now));
assert!(!due(&on, periodic, None, 0, now));
assert!(!due(&on, periodic, Some(0), now - 3_599, now));
assert!(due(&on, periodic, Some(0), now - 3_600, now));
assert!(!due(&on, periodic, Some(0), now, now));
assert!(due(&on, periodic, Some(0), now + 1, now));
}
#[test]
fn removes_what_the_configuration_selects() {
let root = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
let both = config(cache.path(), true, 30);
let store = Store::open(&both).unwrap();
let (_, fresh) = tracked_target(&store, root.path(), "fresh");
let gone = orphan(&store, root.path(), "gone");
let now = unix_now_secs();
assert!(sweep(&both, now).unwrap().removed.is_empty());
let tomorrow = now + DAY_SECS;
let removed = sweep(&both, tomorrow).unwrap().removed;
assert_eq!(removed, vec![(gone.clone(), Reason::Orphaned)]);
assert!(!gone.exists());
assert!(fresh.exists());
assert_eq!(tracked(&store), vec![fresh.clone()]);
let later = now + 31 * DAY_SECS;
let orphans_only = config(cache.path(), true, 0);
assert!(sweep(&orphans_only, later).unwrap().removed.is_empty());
assert_eq!(
sweep(&both, later).unwrap().removed,
vec![(fresh.clone(), Reason::Idle)]
);
assert!(!fresh.exists());
assert!(tracked(&store).is_empty());
let left: Vec<_> = std::fs::read_dir(root.path())
.unwrap()
.map(|entry| entry.unwrap().file_name())
.collect();
assert_eq!(left, vec![std::ffi::OsString::from("fresh")]);
}
#[test]
fn keeps_directories_that_are_not_safe_to_remove() {
let root = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
let orphans = config(cache.path(), true, 0);
let store = Store::open(&orphans).unwrap();
let tag = "Signature: 8a477f597d28d172789f06886806bc55\n";
let replaced = orphan(&store, root.path(), "replaced");
std::fs::rename(&replaced, root.path().join("moved")).unwrap();
std::fs::create_dir_all(replaced.join("debug")).unwrap();
std::fs::write(replaced.join("CACHEDIR.TAG"), tag).unwrap();
let untagged = orphan(&store, root.path(), "untagged");
std::fs::remove_file(untagged.join("CACHEDIR.TAG")).unwrap();
let manifest = orphan(&store, root.path(), "manifest");
std::fs::write(manifest.join("Cargo.toml"), "").unwrap();
let repo = orphan(&store, root.path(), "repo");
std::fs::create_dir(repo.join(".git")).unwrap();
let busy = orphan(&store, root.path(), "busy");
let lock = std::fs::File::create(busy.join("debug/.cargo-lock")).unwrap();
lock.lock().unwrap();
let later = unix_now_secs() + DAY_SECS;
assert!(sweep(&orphans, later).unwrap().removed.is_empty());
for kept in [&replaced, &untagged, &manifest, &repo, &busy] {
assert!(kept.exists(), "{}", kept.display());
}
assert_eq!(tracked(&store), vec![busy.clone()]);
drop(lock);
let mut removed = Vec::new();
for _ in 0..200 {
removed = sweep(&orphans, later).unwrap().removed;
if !removed.is_empty() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(removed, vec![(busy.clone(), Reason::Orphaned)]);
}
#[test]
fn a_refused_rename_of_a_held_directory_is_in_use() {
let denied = || std::io::Error::from(std::io::ErrorKind::PermissionDenied);
assert!(!refused(denied(), true).unwrap());
assert_eq!(
refused(denied(), false).unwrap_err().kind(),
std::io::ErrorKind::PermissionDenied
);
}
#[test]
fn a_build_that_holds_the_directory_gets_it_back() {
let root = tempfile::tempdir().unwrap();
let target = root.path().join("target");
std::fs::create_dir_all(target.join("debug")).unwrap();
let lock = std::fs::File::create(target.join("debug/.cargo-lock")).unwrap();
lock.lock().unwrap();
assert!(!remove(&target, 7).unwrap());
assert!(target.join("debug/.cargo-lock").exists());
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 1);
drop(lock);
assert!(remove(&target, 7).unwrap());
assert!(!target.exists());
assert_eq!(std::fs::read_dir(root.path()).unwrap().count(), 0);
assert!(remove(&target, 7).is_err());
}
#[test]
fn the_maintenance_step_removes_orphans_once_an_hour() {
let root = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
let orphans = config(cache.path(), true, 0);
let store = Store::open(&orphans).unwrap();
let quiet = RequestClock::idle();
let gone = orphan(&store, root.path(), "gone");
age(&store, DAY_SECS);
run(&orphans, Trigger::Periodic(&quiet));
assert!(!gone.exists());
let next = orphan(&store, root.path(), "next");
age(&store, DAY_SECS);
run(&orphans, Trigger::Periodic(&quiet));
assert!(next.exists());
let recorded = last_check(cache.path());
assert!(recorded + 60 >= unix_now_secs(), "{recorded}");
std::fs::write(cache.path().join(LAST_CHECK_FILE), "garbage").unwrap();
assert_eq!(last_check(cache.path()), 0);
run(&orphans, Trigger::Periodic(&quiet));
assert!(!next.exists());
assert_eq!(Reason::Orphaned.describe(), "its workspace was deleted");
assert!(
Reason::Idle
.describe()
.contains("auto_clean_idle_targets_days")
);
}
#[cfg(unix)]
#[test]
fn skips_the_check_when_the_time_cannot_be_recorded() {
use std::os::unix::fs::PermissionsExt;
let root = tempfile::tempdir().unwrap();
let cache = tempfile::tempdir().unwrap();
let orphans = config(cache.path(), true, 0);
let store = Store::open(&orphans).unwrap();
let gone = orphan(&store, root.path(), "gone");
age(&store, DAY_SECS);
std::fs::create_dir(cache.path().join(LAST_CHECK_FILE)).unwrap();
std::fs::set_permissions(
cache.path().join(LAST_CHECK_FILE),
std::fs::Permissions::from_mode(0o755),
)
.unwrap();
run(&orphans, Trigger::Periodic(&RequestClock::idle()));
assert!(gone.exists());
}
}