static UNPERSISTED: std::sync::Mutex<Vec<u32>> = std::sync::Mutex::new(Vec::new());
pub fn arm(pid: u32) {
if let Ok(mut armed) = UNPERSISTED.lock() {
armed.push(pid);
}
}
pub fn disarm(pid: u32) {
if let Ok(mut armed) = UNPERSISTED.lock() {
armed.retain(|&p| p != pid);
}
}
pub fn reap_unpersisted() {
let armed: Vec<u32> = UNPERSISTED.lock().map(|mut a| std::mem::take(&mut *a)).unwrap_or_default();
for pid in armed {
let _ = kill_pid(pid);
}
}
#[cfg(any(unix, test))]
fn is_browser_process(comm: &str) -> bool {
let base = comm.rsplit('/').next().unwrap_or(comm).to_ascii_lowercase();
if base.contains("chrome-agent") || base.contains("chromedriver") {
return false;
}
base.contains("chrome") || base.contains("chromium") || base.contains("headless_shell")
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum KillOutcome {
Signalled,
NotABrowser,
Gone,
Unverified,
}
impl KillOutcome {
#[must_use]
pub const fn entry_may_be_dropped(self) -> bool {
!matches!(self, Self::Unverified)
}
}
#[cfg(unix)]
fn process_name(pid: u32) -> Option<String> {
let out = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm="])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let comm = String::from_utf8_lossy(&out.stdout).trim().to_string();
(!comm.is_empty()).then_some(comm)
}
#[cfg(any(unix, test))]
fn classify(existence: crate::session::Liveness, comm: Option<&str>) -> Option<KillOutcome> {
if existence == crate::session::Liveness::Dead {
return Some(KillOutcome::Gone);
}
match comm {
None => Some(KillOutcome::Unverified),
Some(comm) if !is_browser_process(comm) => Some(KillOutcome::NotABrowser),
Some(_) => None,
}
}
pub fn kill_pid(pid: u32) -> KillOutcome {
#[cfg(unix)]
{
let existence = crate::session::liveness(pid);
let comm = (existence != crate::session::Liveness::Dead)
.then(|| process_name(pid))
.flatten();
if let Some(refusal) = classify(existence, comm.as_deref()) {
return refusal;
}
let _ = std::process::Command::new("kill")
.arg(pid.to_string())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
KillOutcome::Signalled
}
#[cfg(not(unix))]
{
let _ = pid;
KillOutcome::Unverified
}
}
#[must_use]
pub fn wait_until_gone(pid: u32, timeout: std::time::Duration) -> bool {
let deadline = std::time::Instant::now() + timeout;
loop {
if crate::session::liveness(pid) == crate::session::Liveness::Dead {
return true;
}
if std::time::Instant::now() >= deadline {
return false;
}
std::thread::sleep(std::time::Duration::from_millis(25));
}
}
#[must_use]
pub fn close_message(browser_name: &str, pid: u32, outcome: KillOutcome) -> String {
match outcome {
KillOutcome::Signalled => format!("Closed browser={browser_name} (pid={pid})"),
KillOutcome::Gone => {
format!("Removed session={browser_name} (pid={pid} was no longer running)")
}
KillOutcome::NotABrowser => format!(
"Removed session={browser_name} (pid={pid} now belongs to another process and was left alone)"
),
KillOutcome::Unverified => format!(
"Kept session={browser_name} (pid={pid} could not be checked against the process table, \
so nothing was signalled and the browser may still be running)"
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_unpersisted_spawn_is_reaped_and_a_persisted_one_is_left_alone() {
let mut leaked = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("stand-in for a spawned browser");
arm(leaked.id());
assert_eq!(UNPERSISTED.lock().unwrap().as_slice(), &[leaked.id()]);
disarm(leaked.id());
assert!(
UNPERSISTED.lock().unwrap().is_empty(),
"a saved pid is the store's to reap, not this invocation's"
);
arm(leaked.id());
reap_unpersisted();
assert!(UNPERSISTED.lock().unwrap().is_empty(), "reap must drain what it took");
let _ = leaked.kill();
let _ = leaked.wait();
}
#[test]
fn a_refused_kill_is_not_reported_as_a_close() {
let signalled = close_message("s9", 80548, KillOutcome::Signalled);
assert!(signalled.starts_with("Closed browser=s9"), "{signalled}");
for refused in [KillOutcome::NotABrowser, KillOutcome::Gone, KillOutcome::Unverified] {
let message = close_message("s9", 80548, refused);
assert!(
!message.contains("Closed"),
"a kill that never happened must not be worded as one: {message}"
);
assert!(message.contains("80548"), "the pid left alone is the fact: {message}");
}
}
#[test]
fn a_process_table_that_will_not_answer_is_not_a_dead_process() {
use crate::session::Liveness;
for existence in [Liveness::Alive, Liveness::Unknown] {
assert_eq!(
classify(existence, None),
Some(KillOutcome::Unverified),
"a reading that did not happen is not a fact about the process ({existence:?})"
);
}
let message = close_message("s9", 80548, KillOutcome::Unverified);
assert!(!message.contains("no longer running"), "{message}");
assert!(!message.contains("another process"), "{message}");
assert!(message.contains("may still be running"), "{message}");
assert!(
!KillOutcome::Unverified.entry_may_be_dropped(),
"dropping the entry is how the browser it names becomes unreachable"
);
assert_eq!(classify(Liveness::Dead, None), Some(KillOutcome::Gone));
assert_eq!(classify(Liveness::Alive, Some("sleep")), Some(KillOutcome::NotABrowser));
assert_eq!(classify(Liveness::Alive, Some("Google Chrome")), None, "this one signals");
for settled in [KillOutcome::Signalled, KillOutcome::Gone, KillOutcome::NotABrowser] {
assert!(settled.entry_may_be_dropped(), "{settled:?} settles what the entry names");
}
}
#[test]
fn a_pid_that_is_gone_is_answered_without_the_process_table() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn a pid we can then make dead");
let pid = child.id();
child.kill().expect("signal the stand-in");
child.wait().expect("reap it, so the pid is genuinely free");
assert_eq!(
crate::session::liveness(pid),
crate::session::Liveness::Dead,
"the fixture did not produce a dead pid"
);
assert_eq!(kill_pid(pid), KillOutcome::Gone);
}
#[test]
fn kill_pid_reports_the_pid_it_refused_instead_of_staying_silent() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn a stand-in for the reused pid");
assert_eq!(kill_pid(child.id()), KillOutcome::NotABrowser);
let _ = child.kill();
let _ = child.wait();
}
#[test]
fn kill_pid_refuses_a_pid_that_no_longer_belongs_to_a_browser() {
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn a stand-in for the reused pid");
let _ = kill_pid(child.id());
std::thread::sleep(std::time::Duration::from_millis(300));
let status = child.try_wait().expect("poll the stand-in");
let survived = status.is_none();
let _ = child.kill();
let _ = child.wait();
assert!(survived, "kill_pid killed an unrelated process holding a reused pid");
}
#[test]
fn browser_executables_are_recognised_and_bystanders_are_not() {
for browser in [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"chrome",
"chromium",
"chromium-browser",
"headless_shell",
"Google Chrome for Testing",
] {
assert!(is_browser_process(browser), "should recognise {browser}");
}
for bystander in [
"sleep",
"postgres",
"/usr/bin/python3",
"node",
"chrome-agent",
"/tmp/chrome-agent",
"chromedriver",
] {
assert!(!is_browser_process(bystander), "must not kill {bystander}");
}
}
}