use std::path::Path;
use super::slots::HolderRecord;
pub const START_WINDOW_SECS: i64 = 60;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Leftover {
Clear,
Running(HolderRecord),
Unknown(String),
}
#[must_use]
pub fn read_leftover(path: &Path) -> Leftover {
let body = match std::fs::read(path) {
Ok(body) => body,
Err(err) => return Leftover::Unknown(format!("slot record unreadable: {err}")),
};
if body.iter().all(u8::is_ascii_whitespace) {
return Leftover::Clear;
}
match serde_json::from_slice::<HolderRecord>(&body) {
Ok(record) => classify(record, &LiveTable),
Err(err) => Leftover::Unknown(format!("corrupt slot record ({} bytes): {err}", body.len())),
}
}
trait ProcessTable {
fn alive(&self, pid: u32) -> Result<bool, String>;
fn start_secs(&self, pid: u32) -> Option<Option<i64>>;
}
struct LiveTable;
impl ProcessTable for LiveTable {
fn alive(&self, pid: u32) -> Result<bool, String> {
let raw = i32::try_from(pid).map_err(|_| format!("pid {pid} is out of range"))?;
if unsafe { libc::kill(raw, 0) } == 0 {
return Ok(true);
}
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EPERM) => Ok(true),
Some(libc::ESRCH) => Ok(false),
_ => Err(err.to_string()),
}
}
fn start_secs(&self, pid: u32) -> Option<Option<i64>> {
use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
let pid = Pid::from_u32(pid);
let mut sys = System::new();
sys.refresh_processes_specifics(
ProcessesToUpdate::Some(&[pid]),
true,
ProcessRefreshKind::nothing(),
);
let process = sys.process(pid)?;
Some(i64::try_from(process.start_time()).ok().filter(|s| *s > 0))
}
}
fn classify(record: HolderRecord, table: &dyn ProcessTable) -> Leftover {
let Some(pid) = record.child_pid.filter(|pid| *pid != 0) else {
return Leftover::Clear;
};
match table.alive(pid) {
Ok(true) => {}
Ok(false) => return Leftover::Clear,
Err(err) => return Leftover::Unknown(format!("could not check build pid {pid}: {err}")),
}
let Ok(recorded) = chrono::DateTime::parse_from_rfc3339(&record.started_at) else {
return Leftover::Unknown(format!(
"unparseable started_at {:?} for build pid {pid}",
record.started_at
));
};
let Some(start) = table.start_secs(pid) else {
return match table.alive(pid) {
Ok(false) => Leftover::Clear,
_ => Leftover::Unknown(format!("no process-table entry for live build pid {pid}")),
};
};
let Some(start) = start else {
return Leftover::Unknown(format!("start time of build pid {pid} unreadable"));
};
if starts_in_window(start, recorded.timestamp()) {
Leftover::Running(record)
} else {
Leftover::Clear
}
}
fn starts_in_window(start: i64, recorded: i64) -> bool {
(recorded - 1..=recorded + START_WINDOW_SECS).contains(&start)
}
#[cfg(test)]
#[serial_test::serial(build_slot_fds)]
mod tests {
use super::*;
fn record(command: &str, child_pid: Option<u32>, started_at: String) -> HolderRecord {
let mut record = HolderRecord::new(0, command, "/repo");
record.pid = 0;
record.child_pid = child_pid;
record.started_at = started_at;
record
}
struct Fake {
alive: Result<bool, String>,
start: Option<Option<i64>>,
}
impl ProcessTable for Fake {
fn alive(&self, _pid: u32) -> Result<bool, String> {
self.alive.clone()
}
fn start_secs(&self, _pid: u32) -> Option<Option<i64>> {
self.start
}
}
fn classify_now(table: &Fake) -> Leftover {
classify(
record("cargo test", Some(42), chrono::Utc::now().to_rfc3339()),
table,
)
}
#[test]
fn a_record_naming_a_live_build_is_an_orphan() {
let started = chrono::Utc::now().to_rfc3339();
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn a stand-in build");
let rec = record("sleep 30", Some(child.id()), started);
let found = classify(rec.clone(), &LiveTable);
let _ = child.kill();
let _ = child.wait();
assert_eq!(found, Leftover::Running(rec), "a dead holder's live build");
}
#[test]
fn a_record_naming_a_dead_build_is_not_an_orphan() {
let started = chrono::Utc::now().to_rfc3339();
let mut child = std::process::Command::new("true").spawn().expect("spawn");
let pid = child.id();
child.wait().expect("reap");
assert_eq!(
classify(record("true", Some(pid), started), &LiveTable),
Leftover::Clear
);
assert_eq!(
classify(record("true", None, String::new()), &LiveTable),
Leftover::Clear
);
}
#[test]
fn a_reused_pid_is_not_an_orphan() {
let later = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let rec = record("cargo test", Some(std::process::id()), later);
assert_eq!(classify(rec, &LiveTable), Leftover::Clear);
assert!(starts_in_window(100, 100));
assert!(starts_in_window(99, 100));
assert!(!starts_in_window(98, 100));
assert!(!starts_in_window(100 + START_WINDOW_SECS + 1, 100));
}
#[test]
fn a_failed_pid_check_is_unknown() {
let table = Fake {
alive: Err("EINVAL".into()),
start: None,
};
assert!(matches!(classify_now(&table), Leftover::Unknown(e) if e.contains("EINVAL")));
}
#[test]
fn an_unreadable_start_time_is_unknown() {
let unreadable = Fake {
alive: Ok(true),
start: Some(None),
};
assert!(
matches!(classify_now(&unreadable), Leftover::Unknown(e) if e.contains("start time"))
);
let absent = Fake {
alive: Ok(true),
start: None,
};
assert!(
matches!(classify_now(&absent), Leftover::Unknown(e) if e.contains("no process-table"))
);
}
}