use serde::Serialize;
use std::path::Path;
pub(crate) const DEFAULT_REAP_REPORT_MIN_IDLE_HOURS: u64 = 24;
pub(crate) const DEFAULT_REAP_REPORT_MAX_CPU_SECONDS: u64 = 60;
pub(crate) const DEFAULT_REAP_REPORT_SIGNATURES: &str =
"eve web,vite,playwright,chromium,bun run,bun test,npm run test,.mjs,litestream,stub-auth-api";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ReapPolicy {
pub(crate) min_idle_hours: u64,
pub(crate) max_cpu_seconds: u64,
pub(crate) signatures: Vec<String>,
pub(crate) exempt_names: Vec<String>,
}
pub(crate) fn sorted_signatures(csv: &str) -> Vec<String> {
let mut out: Vec<String> = crate::parse_kinds(csv).into_iter().collect();
out.sort();
out
}
impl Default for ReapPolicy {
fn default() -> Self {
ReapPolicy {
min_idle_hours: DEFAULT_REAP_REPORT_MIN_IDLE_HOURS,
max_cpu_seconds: DEFAULT_REAP_REPORT_MAX_CPU_SECONDS,
signatures: sorted_signatures(DEFAULT_REAP_REPORT_SIGNATURES),
exempt_names: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub(crate) struct ReapCandidate {
pub(crate) pid: i32,
pub(crate) comm: String,
pub(crate) args: String,
pub(crate) idle_hours: u64,
pub(crate) cpu_seconds: u64,
pub(crate) rss_mb: u64,
pub(crate) signature: String,
}
pub(crate) fn read_boot_time(proc_root: &Path) -> Option<u64> {
let stat = std::fs::read_to_string(proc_root.join("stat")).ok()?;
stat.lines()
.find_map(|line| line.strip_prefix("btime "))
.and_then(|value| value.trim().parse::<u64>().ok())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct StatFields {
pub(crate) state: char,
pub(crate) tty_nr: i32,
pub(crate) cpu_ticks: u64,
pub(crate) starttime: u64,
}
pub(crate) fn parse_stat(stat: &str) -> Option<StatFields> {
let after_comm = stat.rsplit_once(')')?.1;
let fields: Vec<&str> = after_comm.split_whitespace().collect();
let get = |idx: usize| -> Option<&str> { fields.get(idx).copied() };
Some(StatFields {
state: get(0)?.chars().next()?,
tty_nr: get(4)?.parse().ok()?,
cpu_ticks: get(11)?
.parse::<u64>()
.ok()?
.checked_add(get(12)?.parse::<u64>().ok()?)?,
starttime: get(19)?.parse().ok()?,
})
}
pub(crate) fn elapsed_secs(starttime: u64, ticks_per_sec: u64, boot_time: u64, now: u64) -> u64 {
if ticks_per_sec == 0 {
return 0;
}
let started = boot_time.saturating_add(starttime / ticks_per_sec);
now.saturating_sub(started)
}
fn matches_signature(args: &str, signatures: &[String]) -> Option<String> {
signatures
.iter()
.find(|needle| !needle.is_empty() && args.contains(needle.as_str()))
.cloned()
}
fn rss_mb_from_status(pid_dir: &Path) -> u64 {
let Ok(status) = std::fs::read_to_string(pid_dir.join("status")) else {
return 0;
};
status
.lines()
.find_map(|line| line.strip_prefix("VmRSS:"))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|kb| kb.parse::<u64>().ok())
.map(|kb| kb / 1024)
.unwrap_or(0)
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn scan_reap_candidates(
proc_root: &Path,
policy: &ReapPolicy,
boot_time: u64,
now: u64,
ticks_per_sec: u64,
) -> Vec<ReapCandidate> {
let Ok(entries) = std::fs::read_dir(proc_root) else {
return Vec::new();
};
let mut out: Vec<ReapCandidate> = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
let Ok(pid) = name.parse::<i32>() else {
continue;
};
let pid_dir = entry.path();
let Ok(raw) = std::fs::read_to_string(pid_dir.join("stat")) else {
continue;
};
let Some(fields) = parse_stat(&raw) else {
continue;
};
if fields.state != 'S' {
continue;
}
if fields.tty_nr != 0 {
continue;
}
let Ok(cmdline_raw) = std::fs::read_to_string(pid_dir.join("cmdline")) else {
continue;
};
let args = cmdline_raw.replace('\0', " ").trim().to_string();
if args.is_empty() {
continue;
}
if policy
.exempt_names
.iter()
.any(|exempt| args.contains(exempt.as_str()))
{
continue;
}
let Some(signature) = matches_signature(&args, &policy.signatures) else {
continue;
};
let cpu_seconds = fields.cpu_ticks / ticks_per_sec.max(1);
if cpu_seconds > policy.max_cpu_seconds {
continue;
}
let age_secs = elapsed_secs(fields.starttime, ticks_per_sec, boot_time, now);
if age_secs < policy.min_idle_hours.saturating_mul(3600) {
continue;
}
let comm = std::fs::read_to_string(pid_dir.join("comm"))
.map(|c| c.trim().to_string())
.unwrap_or_default();
out.push(ReapCandidate {
pid,
comm,
args,
idle_hours: age_secs / 3600,
cpu_seconds,
rss_mb: rss_mb_from_status(&pid_dir),
signature,
});
}
out.sort_by(|a, b| b.rss_mb.cmp(&a.rss_mb).then(a.pid.cmp(&b.pid)));
out
}