use std::path::Path;
use std::thread;
use std::time::{Duration, Instant};
use sysinfo::{Pid, ProcessesToUpdate, Signal, System};
use crate::record::{Record, Sameness, Tenant};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Policy {
pub unconfirmed: Unconfirmed,
pub escalate_after: Option<Duration>,
pub deadline: Duration,
pub poll: Duration,
}
impl Default for Policy {
fn default() -> Self {
Self {
unconfirmed: Unconfirmed::Refuse,
escalate_after: Some(Duration::from_secs(2)),
deadline: Duration::from_secs(5),
poll: Duration::from_millis(100),
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Unconfirmed {
Refuse,
Signal,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
AlreadyGone,
Left,
Refused(Sameness),
Stubborn,
}
#[must_use]
pub fn evict(record: &Record, policy: &Policy) -> Outcome {
let Some(live) = Tenant::look_up(record.tenant.pid) else {
return Outcome::AlreadyGone;
};
match record.tenant.compare(&live) {
Sameness::Same => {}
Sameness::Inconclusive if policy.unconfirmed == Unconfirmed::Signal => {}
refused => return Outcome::Refused(refused),
}
match press(&record.tenant, policy) {
Pressed::Left => Outcome::Left,
Pressed::Stubborn => Outcome::Stubborn,
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AnonymousOutcome {
NoCandidate,
Ambiguous {
running: usize,
},
Left,
Stubborn,
}
#[must_use]
pub fn evict_anonymous(expected: &Path, policy: &Policy) -> AnonymousOutcome {
match sole(running_from(expected)) {
Err(refusal) => refusal,
Ok(candidate) => match press(&candidate, policy) {
Pressed::Left => AnonymousOutcome::Left,
Pressed::Stubborn => AnonymousOutcome::Stubborn,
},
}
}
fn sole(mut candidates: Vec<Tenant>) -> Result<Tenant, AnonymousOutcome> {
match candidates.len() {
0 => Err(AnonymousOutcome::NoCandidate),
1 => Ok(candidates.remove(0)),
running => Err(AnonymousOutcome::Ambiguous { running }),
}
}
fn running_from(expected: &Path) -> Vec<Tenant> {
let mut system = System::new();
system.refresh_processes(ProcessesToUpdate::All, true);
let mine = Pid::from_u32(std::process::id());
system
.processes()
.iter()
.filter(|(pid, process)| **pid != mine && process.exe() == Some(expected))
.map(|(pid, process)| Tenant {
pid: pid.as_u32(),
started_at: Some(process.start_time()),
image: process.exe().map(Path::to_path_buf),
})
.collect()
}
enum Pressed {
Left,
Stubborn,
}
fn press(tenant: &Tenant, policy: &Policy) -> Pressed {
let pid = Pid::from_u32(tenant.pid);
let mut system = System::new();
let _delivered = signal(&mut system, pid, Signal::Term);
let started = Instant::now();
let mut escalated = policy.escalate_after.is_none();
loop {
if Tenant::look_up(tenant.pid).is_none_or(|now| tenant.compare(&now) == Sameness::Different)
{
return Pressed::Left;
}
let waited = started.elapsed();
if waited >= policy.deadline {
return Pressed::Stubborn;
}
if !escalated && policy.escalate_after.is_some_and(|after| waited >= after) {
escalated = true;
signal(&mut system, pid, Signal::Kill);
}
thread::sleep(policy.poll);
}
}
fn signal(system: &mut System, pid: Pid, signal: Signal) -> bool {
system.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
system
.process(pid)
.and_then(|process| process.kill_with(signal))
.unwrap_or(false)
}
#[cfg(test)]
#[expect(clippy::expect_used, reason = "panic helpers are idiomatic in tests")]
mod tests {
use super::*;
use crate::identity::{Compat, Identity, Run};
fn tenant(pid: u32) -> Tenant {
Tenant {
pid,
started_at: Some(1),
image: None,
}
}
fn record(tenant: Tenant) -> Record {
Record::new(Identity::new(Run::from_raw(1), Compat::from_raw(1)), tenant)
}
fn a_reaped_pid() -> u32 {
let mut child = std::process::Command::new(
std::env::current_exe().expect("the test binary's own path"),
)
.arg("--list")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.expect("spawning the test binary");
let pid = child.id();
child.wait().expect("waiting for the child");
drop(child);
pid
}
#[test]
fn a_pid_that_is_gone_needs_no_signal() {
let outcome = evict(
&record(Tenant {
pid: a_reaped_pid(),
started_at: Some(1),
image: None,
}),
&Policy::default(),
);
assert_eq!(outcome, Outcome::AlreadyGone);
}
#[test]
fn a_pid_that_no_longer_matches_its_record_is_never_signalled() {
let outcome = evict(
&record(Tenant {
pid: std::process::id(),
started_at: Some(1),
image: None,
}),
&Policy::default(),
);
assert_eq!(outcome, Outcome::Refused(Sameness::Different));
}
#[test]
fn the_evictor_never_counts_itself_as_a_candidate() {
let mine = std::env::current_exe().expect("this test binary's path");
let candidates = running_from(&mine);
assert!(
!candidates
.iter()
.any(|tenant| tenant.pid == std::process::id()),
"the current process must never be a candidate"
);
}
#[test]
fn nothing_running_from_the_expected_image_is_left_alone() {
let outcome = evict_anonymous(
Path::new("/nonexistent/succession-helper"),
&Policy::default(),
);
assert_eq!(outcome, AnonymousOutcome::NoCandidate);
}
#[test]
fn siblings_that_cannot_be_told_apart_are_all_spared() {
let candidates = vec![tenant(11), tenant(12), tenant(13)];
assert_eq!(
sole(candidates),
Err(AnonymousOutcome::Ambiguous { running: 3 })
);
}
#[test]
fn a_forced_choice_is_taken() {
assert_eq!(sole(vec![tenant(11)]).map(|found| found.pid), Ok(11));
assert_eq!(sole(Vec::new()), Err(AnonymousOutcome::NoCandidate));
}
#[test]
fn an_unconfirmable_pid_is_refused_by_default() {
let outcome = evict(
&record(Tenant {
pid: std::process::id(),
started_at: None,
image: None,
}),
&Policy::default(),
);
assert_eq!(outcome, Outcome::Refused(Sameness::Inconclusive));
}
}