use std::path::{Path, PathBuf};
use std::time::SystemTime;
use super::orphan::Leftover;
use super::slots::SlotDir;
use super::stale_guard::cargo_lock_held;
pub const DEFAULT_EVICT_PCT: u8 = 85;
pub const EVICT_PCT_KEY: &str = "builders.slot_pool_evict_pct";
#[must_use]
pub fn effective_evict_pct(configured: Option<u8>, guard_pct: u8) -> u8 {
let wanted = configured
.filter(|pct| (1..=99).contains(pct))
.unwrap_or(DEFAULT_EVICT_PCT);
wanted.min(guard_pct.saturating_sub(1)).max(1)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct PoolSlot {
pub path: PathBuf,
pub index: u32,
pub last_used: SystemTime,
}
pub fn list_pool_slots(root: &Path) -> std::io::Result<Vec<PoolSlot>> {
let owners = match std::fs::read_dir(root) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err),
};
let mut slots = Vec::new();
for owner in real_dirs(owners) {
let Ok(repos) = std::fs::read_dir(&owner) else {
continue;
};
for repo in real_dirs(repos) {
let Ok(entries) = std::fs::read_dir(&repo) else {
continue;
};
for path in real_dirs(entries) {
if let Some(index) = slot_index(&path) {
let last_used = last_used(&path);
slots.push(PoolSlot {
path,
index,
last_used,
});
}
}
}
}
slots.sort_by(|a, b| a.last_used.cmp(&b.last_used).then(a.path.cmp(&b.path)));
Ok(slots)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Spared {
LeaseHeld,
Unknown(String),
OrphanedBuild(u32),
CargoLockHeld,
Seeding,
}
#[derive(Debug, Clone, Default, PartialEq)]
#[non_exhaustive]
pub struct EvictReport {
pub evicted: Vec<PathBuf>,
pub spared: Vec<(PathBuf, Spared)>,
pub failed: Vec<(PathBuf, String)>,
pub leftovers_removed: usize,
pub usage_after: Option<f32>,
pub cancelled: bool,
}
impl EvictReport {
#[must_use]
pub fn failed(&self) -> bool {
!self.failed.is_empty()
}
}
#[derive(Debug, Clone, PartialEq)]
#[non_exhaustive]
pub enum SweepOutcome {
NoPool,
Unmeasurable,
UnderThreshold(f32),
Unlistable(String),
Refused(String),
Swept(EvictReport),
}
pub fn sweep(
pool_root: &Path,
home: &Path,
store: &SlotDir,
threshold_pct: u8,
measure: &mut dyn FnMut(&Path) -> Option<f32>,
cancelled: &dyn Fn() -> bool,
) -> SweepOutcome {
let pool_root = match check_pool_root(pool_root, home) {
RootCheck::Missing => return SweepOutcome::NoPool,
RootCheck::Refused(why) => return SweepOutcome::Refused(why),
RootCheck::Safe(canon) => canon,
};
let pool_root = pool_root.as_path();
let threshold = f32::from(threshold_pct);
let Some(usage) = measure(pool_root) else {
return SweepOutcome::Unmeasurable;
};
if usage < threshold {
return SweepOutcome::UnderThreshold(usage);
}
let mut report = EvictReport::default();
remove_leftovers(pool_root, &mut report, cancelled);
let slots = match list_pool_slots(pool_root) {
Ok(slots) => slots,
Err(err) => return SweepOutcome::Unlistable(err.to_string()),
};
report.usage_after = measure(pool_root);
for slot in slots {
if report.usage_after.is_none_or(|usage| usage < threshold) {
break;
}
if report.cancelled || cancelled() {
report.cancelled = true;
break;
}
match evict_one(&slot, store) {
Ok(()) => report.evicted.push(slot.path),
Err(Outcome::Spared(why)) => report.spared.push((slot.path, why)),
Err(Outcome::Failed(err)) => report.failed.push((slot.path, err)),
}
report.usage_after = measure(pool_root);
}
SweepOutcome::Swept(report)
}
enum RootCheck {
Missing,
Refused(String),
Safe(PathBuf),
}
type FileId = (u64, u64);
fn file_id(path: &Path) -> Option<FileId> {
use std::os::unix::fs::MetadataExt;
std::fs::metadata(path).ok().map(|m| (m.dev(), m.ino()))
}
fn check_pool_root(root: &Path, home: &Path) -> RootCheck {
check_pool_root_with(root, home, &file_id)
}
fn check_pool_root_with(
root: &Path,
home: &Path,
id_of: &dyn Fn(&Path) -> Option<FileId>,
) -> RootCheck {
let canon = match std::fs::canonicalize(root) {
Ok(canon) => canon,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return RootCheck::Missing,
Err(err) => {
return RootCheck::Refused(format!("{} does not resolve ({err})", root.display()));
}
};
if !canon.is_dir() {
return RootCheck::Missing;
}
if canon.parent().is_none() {
return RootCheck::Refused(format!("{} is the filesystem root", root.display()));
}
let home = match std::fs::canonicalize(home) {
Ok(home) => home,
Err(err) => {
return RootCheck::Refused(format!(
"home {} does not resolve ({err}), so {} cannot be checked against it",
home.display(),
root.display()
));
}
};
if home.starts_with(&canon) {
return RootCheck::Refused(format!(
"{} is the home directory or an ancestor of it",
root.display()
));
}
let Some(root_id) = id_of(&canon) else {
return RootCheck::Refused(format!(
"{} has unreadable metadata, so it cannot be checked against home",
root.display()
));
};
if home.ancestors().any(|dir| id_of(dir) == Some(root_id)) {
return RootCheck::Refused(format!(
"{} is the same directory as home or an ancestor of it",
root.display()
));
}
RootCheck::Safe(canon)
}
enum Outcome {
Spared(Spared),
Failed(String),
}
fn evict_one(slot: &PoolSlot, store: &SlotDir) -> Result<(), Outcome> {
let parent = slot.path.parent().unwrap_or(&slot.path);
if live_staging(parent, slot.index) {
return Err(Outcome::Spared(Spared::Seeding));
}
let guard = match store.try_acquire(slot.index) {
Ok(Some(guard)) => guard,
Ok(None) => return Err(Outcome::Spared(Spared::LeaseHeld)),
Err(err) => return Err(Outcome::Spared(Spared::Unknown(err.to_string()))),
};
match guard.leftover() {
Leftover::Clear => {}
Leftover::Running(record) => {
guard.release_keeping_record();
let pid = record.child_pid.unwrap_or(record.pid);
return Err(Outcome::Spared(Spared::OrphanedBuild(pid)));
}
Leftover::Unknown(err) => {
guard.release_keeping_record();
return Err(Outcome::Spared(Spared::Unknown(err)));
}
}
if cargo_lock_held(&slot.path) {
return Err(Outcome::Spared(Spared::CargoLockHeld));
}
let doomed = parent.join(format!(
".slot-{}{EVICTING_INFIX}{}.{}",
slot.index,
std::process::id(),
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos())
));
std::fs::rename(&slot.path, &doomed)
.map_err(|err| Outcome::Failed(format!("rename to {}: {err}", doomed.display())))?;
drop(guard);
std::fs::remove_dir_all(&doomed)
.map_err(|err| Outcome::Failed(format!("delete {}: {err}", doomed.display())))
}
pub(crate) const EVICTING_INFIX: &str = ".evicting.";
fn remove_leftovers(root: &Path, report: &mut EvictReport, cancelled: &dyn Fn() -> bool) {
for repo in repo_dirs(root) {
let Ok(entries) = std::fs::read_dir(&repo) else {
continue;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(pid) = name
.to_str()
.filter(|n| n.starts_with(".slot-"))
.and_then(|n| n.split_once(EVICTING_INFIX))
.and_then(|(_, rest)| rest.split('.').next()?.parse::<u32>().ok())
else {
continue;
};
if pid != std::process::id() && crate::core::process::is_process_alive(pid) {
continue;
}
if cancelled() {
report.cancelled = true;
return;
}
match std::fs::remove_dir_all(entry.path()) {
Ok(()) => report.leftovers_removed += 1,
Err(err) => report.failed.push((entry.path(), err.to_string())),
}
}
}
}
fn live_staging(parent: &Path, index: u32) -> bool {
let prefix = format!(".slot-{index}.seeding.");
let Ok(entries) = std::fs::read_dir(parent) else {
return true;
};
entries.flatten().any(|entry| {
let name = entry.file_name();
let Some(rest) = name.to_str().and_then(|n| n.strip_prefix(&prefix)) else {
return false;
};
rest.split('.')
.next()
.and_then(|pid| pid.parse::<u32>().ok())
.is_none_or(crate::core::process::is_process_alive)
})
}
pub(crate) fn repo_dirs(root: &Path) -> Vec<PathBuf> {
let Ok(owners) = std::fs::read_dir(root) else {
return Vec::new();
};
real_dirs(owners)
.into_iter()
.filter_map(|owner| std::fs::read_dir(owner).ok())
.flat_map(real_dirs)
.collect()
}
fn real_dirs(entries: std::fs::ReadDir) -> Vec<PathBuf> {
entries
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.map(|e| e.path())
.collect()
}
fn slot_index(path: &Path) -> Option<u32> {
path.file_name()?
.to_str()?
.strip_prefix("slot-")?
.parse()
.ok()
}
fn last_used(path: &Path) -> SystemTime {
let mtime = |p: &Path| {
std::fs::symlink_metadata(p)
.and_then(|m| m.modified())
.unwrap_or_else(|_| SystemTime::now())
};
let mut newest = mtime(path);
let children = std::fs::read_dir(path).map(real_dirs).unwrap_or_default();
for child in children {
newest = newest.max(mtime(&child));
if let Ok(grand) = std::fs::read_dir(&child) {
for entry in grand.flatten() {
newest = newest.max(mtime(&entry.path()));
}
}
}
newest
}
#[cfg(test)]
#[path = "evict_tests.rs"]
mod tests;