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,
}
pub fn kill_pid(pid: u32) -> KillOutcome {
#[cfg(unix)]
{
let comm = std::process::Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "comm="])
.output()
.ok()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
let Some(comm) = comm.filter(|c| !c.is_empty()) else {
return KillOutcome::Gone;
};
if !is_browser_process(&comm) {
return KillOutcome::NotABrowser;
}
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::NotABrowser
}
}
#[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)"
),
}
}
#[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] {
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 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}");
}
}
}