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,
#[serde(skip_serializing)]
pub(crate) starttime: u64,
}
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) ppid: i32,
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()?,
ppid: get(1)?.parse().ok()?,
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,
starttime: fields.starttime,
});
}
out.sort_by(|a, b| b.rss_mb.cmp(&a.rss_mb).then(a.pid.cmp(&b.pid)));
out
}
pub(crate) const PROC_TICKS_PER_SEC: u64 = 100;
const TERM_GRACE_POLLS: u32 = 50;
const REAP_PASS_BUDGET: std::time::Duration = std::time::Duration::from_secs(60);
const KILL_GRACE_POLLS: u32 = 20;
const GRACE_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100);
pub(crate) fn verify_candidate_for_reap(
proc_root: &Path,
candidate: &ReapCandidate,
policy: &ReapPolicy,
ticks_per_sec: u64,
) -> bool {
if candidate.pid <= 1 || candidate.pid == std::process::id() as i32 {
return false;
}
let pid_dir = proc_root.join(candidate.pid.to_string());
let Ok(raw) = std::fs::read_to_string(pid_dir.join("stat")) else {
return false;
};
let Some(fields) = parse_stat(&raw) else {
return false;
};
if fields.starttime != candidate.starttime {
return false;
}
if fields.state != 'S' || fields.tty_nr != 0 {
return false;
}
if fields.cpu_ticks / ticks_per_sec.max(1) > policy.max_cpu_seconds {
return false;
}
let Ok(cmdline_raw) = std::fs::read_to_string(pid_dir.join("cmdline")) else {
return false;
};
let args = cmdline_raw.replace('\0', " ").trim().to_string();
if args.is_empty() {
return false;
}
if policy
.exempt_names
.iter()
.any(|exempt| args.contains(exempt.as_str()))
{
return false;
}
matches_signature(&args, &policy.signatures).is_some()
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) enum TerminateOutcome {
Signalled { escalated_to_sigkill: bool },
AlreadyGone,
Refused { reason: &'static str },
Failed { reason: &'static str },
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub(crate) struct ReapedProcess {
pub(crate) pid: i32,
pub(crate) comm: String,
pub(crate) signature: String,
pub(crate) verified: bool,
pub(crate) outcome: Option<TerminateOutcome>,
pub(crate) disposition: ReapDisposition,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum ReapDisposition {
Signalled,
Deferred,
NotVerified,
}
fn pid_is_alive(pid: i32) -> bool {
let rc = unsafe { libc::kill(pid, 0) };
let exists = if rc == 0 {
true
} else {
unsafe { *libc::__errno_location() != libc::ESRCH }
};
if !exists {
return false;
}
match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
Ok(raw) => !matches!(parse_stat(&raw).map(|f| f.state), Some('Z') | Some('X')),
Err(_) => exists,
}
}
pub(crate) fn terminate_process(pid: i32) -> TerminateOutcome {
if pid <= 1 {
return TerminateOutcome::Refused {
reason: "reserved pid",
};
}
if pid == std::process::id() as i32 {
return TerminateOutcome::Refused {
reason: "guard will not signal itself",
};
}
if !pid_is_alive(pid) {
return TerminateOutcome::AlreadyGone;
}
let rc = unsafe { libc::kill(pid, libc::SIGTERM) };
if rc != 0 {
let errno = unsafe { *libc::__errno_location() };
if errno == libc::ESRCH {
return TerminateOutcome::AlreadyGone;
}
if errno == libc::EPERM {
return TerminateOutcome::Refused {
reason: "permission denied",
};
}
return TerminateOutcome::Failed {
reason: "SIGTERM syscall failed",
};
}
for _ in 0..TERM_GRACE_POLLS {
if !pid_is_alive(pid) {
return TerminateOutcome::Signalled {
escalated_to_sigkill: false,
};
}
std::thread::sleep(GRACE_POLL_INTERVAL);
}
let rc = unsafe { libc::kill(pid, libc::SIGKILL) };
if rc != 0 {
let errno = unsafe { *libc::__errno_location() };
if errno == libc::ESRCH {
return TerminateOutcome::Signalled {
escalated_to_sigkill: false,
};
}
return TerminateOutcome::Failed {
reason: "SIGKILL syscall failed",
};
}
for _ in 0..KILL_GRACE_POLLS {
if !pid_is_alive(pid) {
return TerminateOutcome::Signalled {
escalated_to_sigkill: true,
};
}
std::thread::sleep(GRACE_POLL_INTERVAL);
}
TerminateOutcome::Failed {
reason: "process survived SIGKILL (uninterruptible sleep?)",
}
}
pub(crate) fn auto_reap_stale_servers(
proc_root: &Path,
policy: &ReapPolicy,
candidates: &[ReapCandidate],
ticks_per_sec: u64,
) -> Vec<ReapedProcess> {
reap_verified_candidates(
proc_root,
policy,
candidates,
ticks_per_sec,
verify_candidate_for_reap,
"reap",
REAP_PASS_BUDGET,
)
}
pub(crate) fn verify_orphan_for_reap(
proc_root: &Path,
candidate: &ReapCandidate,
policy: &ReapPolicy,
ticks_per_sec: u64,
) -> bool {
let _ = ticks_per_sec;
if candidate.pid <= 1 || candidate.pid == std::process::id() as i32 {
return false;
}
let pid_dir = proc_root.join(candidate.pid.to_string());
let Ok(raw) = std::fs::read_to_string(pid_dir.join("stat")) else {
return false;
};
let Some(fields) = parse_stat(&raw) else {
return false;
};
if fields.starttime != candidate.starttime {
return false;
}
if !parent_comm_is_systemd(proc_root, fields.ppid) {
return false;
}
if fields.tty_nr != 0 {
return false;
}
let Ok(cmdline_raw) = std::fs::read_to_string(pid_dir.join("cmdline")) else {
return false;
};
let args = cmdline_raw.replace('\0', " ").trim().to_string();
if args.is_empty() {
return false;
}
if policy
.exempt_names
.iter()
.any(|exempt| args.contains(exempt.as_str()))
{
return false;
}
matches_signature(&args, &policy.signatures).is_some()
}
pub(crate) fn reap_pressure_orphans(
proc_root: &Path,
policy: &ReapPolicy,
candidates: &[ReapCandidate],
ticks_per_sec: u64,
) -> Vec<ReapedProcess> {
reap_verified_candidates(
proc_root,
policy,
candidates,
ticks_per_sec,
verify_orphan_for_reap,
"pressure-reap",
REAP_PASS_BUDGET,
)
}
pub(crate) fn reap_verified_candidates(
proc_root: &Path,
policy: &ReapPolicy,
candidates: &[ReapCandidate],
ticks_per_sec: u64,
verify: fn(&Path, &ReapCandidate, &ReapPolicy, u64) -> bool,
log_tag: &str,
budget: std::time::Duration,
) -> Vec<ReapedProcess> {
let mut out = Vec::with_capacity(candidates.len());
let pass_started = std::time::Instant::now();
let mut deferred = 0usize;
for candidate in candidates {
if pass_started.elapsed() >= budget {
eprintln!(
"🛡️ {log_tag}: pid {} ({}) deferred -- pass budget reached, next pass re-scans it",
candidate.pid, candidate.comm,
);
out.push(ReapedProcess {
pid: candidate.pid,
comm: candidate.comm.clone(),
signature: candidate.signature.clone(),
verified: false,
outcome: None,
disposition: ReapDisposition::Deferred,
});
deferred += 1;
continue;
}
let verified = verify(proc_root, candidate, policy, ticks_per_sec);
if !verified {
eprintln!(
"🛡️ {log_tag}: pid {} ({}) no longer verifies -- skipped, nothing signalled",
candidate.pid, candidate.comm,
);
out.push(ReapedProcess {
pid: candidate.pid,
comm: candidate.comm.clone(),
signature: candidate.signature.clone(),
verified: false,
outcome: None,
disposition: ReapDisposition::NotVerified,
});
continue;
}
let outcome = terminate_process(candidate.pid);
eprintln!(
"🛡️ {log_tag}: pid {} ({}, {}) -> {outcome:?}",
candidate.pid, candidate.comm, candidate.signature,
);
out.push(ReapedProcess {
pid: candidate.pid,
comm: candidate.comm.clone(),
signature: candidate.signature.clone(),
verified: true,
outcome: Some(outcome),
disposition: ReapDisposition::Signalled,
});
}
if deferred > 0 {
eprintln!(
"🛡️ {log_tag}: pass budget ({:?}) reached — {} candidate(s) deferred to the next pass (re-scanned and re-verified there)",
REAP_PASS_BUDGET, deferred
);
}
out
}
pub(crate) fn parent_comm_is_systemd(proc_root: &Path, ppid: i32) -> bool {
if ppid <= 0 {
return false;
}
std::fs::read_to_string(proc_root.join(ppid.to_string()).join("comm"))
.map(|comm| comm.trim() == "systemd")
.unwrap_or(false)
}
pub(crate) fn scan_pressure_orphans(
proc_root: &Path,
policy: &ReapPolicy,
boot_time: u64,
now: u64,
ticks_per_sec: u64,
) -> Vec<ReapCandidate> {
let self_pid = std::process::id() as i32;
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;
};
if pid <= 1 || pid == self_pid {
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 !parent_comm_is_systemd(proc_root, fields.ppid) {
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);
let age_secs = elapsed_secs(fields.starttime, ticks_per_sec, boot_time, now);
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,
starttime: fields.starttime,
});
}
out.sort_by(|a, b| b.rss_mb.cmp(&a.rss_mb).then(a.pid.cmp(&b.pid)));
out
}