use std::collections::HashSet;
use std::path::Path;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Orphan {
pub pid: u32,
pub name: String,
}
#[must_use]
pub fn session_of(command: &str, browsers_dir: &Path) -> Option<String> {
if command.contains("--type=") {
return None;
}
let prefix = format!("--user-data-dir={}/", browsers_dir.display());
let rest = command.split(&prefix).nth(1)?;
let name = rest.split('/').next()?;
if name.is_empty() || name.split_whitespace().count() != 1 || name.contains(char::is_whitespace)
{
return None;
}
Some(name.to_string())
}
#[must_use]
fn parse_ps_line(line: &str) -> Option<(u32, &str)> {
let line = line.trim_start();
let (pid, rest) = line.split_once(char::is_whitespace)?;
Some((pid.parse().ok()?, rest.trim_start()))
}
#[must_use]
pub fn from_ps(ps_output: &str, browsers_dir: &Path, claimed_pids: &HashSet<u32>) -> Vec<Orphan> {
let mut found: Vec<Orphan> = ps_output
.lines()
.filter_map(parse_ps_line)
.filter(|(pid, _)| !claimed_pids.contains(pid))
.filter_map(|(pid, command)| {
session_of(command, browsers_dir).map(|name| Orphan { pid, name })
})
.collect();
found.sort_by_key(|o| o.pid);
found
}
#[cfg(unix)]
fn process_table() -> Option<String> {
let out = std::process::Command::new("ps").args(["-eo", "pid=,command="]).output().ok()?;
out.status.success().then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
#[cfg(not(unix))]
fn process_table() -> Option<String> {
None
}
pub fn scan(store: &crate::session::SessionStore) -> Option<Vec<Orphan>> {
let browsers_dir = crate::session::browsers_dir().ok()?;
let claimed: HashSet<u32> = store.browsers.values().filter_map(|b| b.pid).collect();
Some(from_ps(&process_table()?, &browsers_dir, &claimed))
}
pub fn cmd_close_orphans(json_mode: bool) -> Result<(), crate::BoxError> {
let store = crate::session::load_session()?;
let Some(orphans) = scan(&store) else {
return Err("Could not read the process table, so no orphan could be identified.".into());
};
let (closed, skipped): (Vec<&Orphan>, Vec<&Orphan>) = orphans
.iter()
.partition(|o| crate::run_helpers::kill_pid(o.pid) == crate::run_helpers::KillOutcome::Signalled);
let message = format!("Closed {} orphaned browser(s)", closed.len());
if json_mode {
let listed = |group: &[&Orphan]| {
group
.iter()
.map(|o| serde_json::json!({"name": o.name, "pid": o.pid}))
.collect::<Vec<_>>()
};
crate::run_helpers::json_output(&serde_json::json!({
"ok": true,
"message": message,
"closed": listed(&closed),
"skipped": listed(&skipped),
}));
} else {
for orphan in &closed {
println!("Closed orphan={} pid={}", orphan.name, orphan.pid);
}
for orphan in &skipped {
eprintln!(
"warning: orphan={} pid={} stopped being a browser before it could be signalled",
orphan.name, orphan.pid
);
}
println!("{message}");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn dir() -> PathBuf {
PathBuf::from("/Users/x/.chrome-agent/browsers")
}
#[test]
fn the_browser_process_names_its_session() {
let cmd = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/Users/x/.chrome-agent/browsers/test-tabs/chromium-profile --remote-debugging-port=0";
assert_eq!(session_of(cmd, &dir()).as_deref(), Some("test-tabs"));
}
#[test]
fn helper_processes_are_not_separate_browsers() {
let cmd = "/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Helper (Renderer).app/Contents/MacOS/Google Chrome Helper (Renderer) --type=renderer --user-data-dir=/Users/x/.chrome-agent/browsers/test-tabs/chromium-profile";
assert_eq!(session_of(cmd, &dir()), None);
}
#[test]
fn a_chrome_outside_our_profile_root_is_not_ours() {
for cmd in [
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/Users/x/Library/Application Support/Google/Chrome",
"/Users/x/.agent-browser/browsers/chrome-147/Google Chrome for Testing --remote-debugging-port=0",
] {
assert_eq!(session_of(cmd, &dir()), None, "{cmd}");
}
}
#[test]
fn a_path_that_stops_at_the_profile_root_names_no_session() {
let cmd = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/Users/x/.chrome-agent/browsers/ --headless";
assert_eq!(session_of(cmd, &dir()), None);
}
#[test]
fn a_pid_the_registry_holds_is_not_an_orphan() {
let ps = "\
16504 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/Users/x/.chrome-agent/browsers/test-tabs/chromium-profile
42289 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/Users/x/.chrome-agent/browsers/hcvar/chromium-profile
88717 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome
";
let claimed = HashSet::from([42289]);
assert_eq!(
from_ps(ps, &dir(), &claimed),
vec![Orphan { pid: 16504, name: "test-tabs".into() }]
);
}
#[test]
fn a_relaunch_leaves_the_previous_pid_orphaned_under_a_claimed_name() {
let ps = " 16504 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome --user-data-dir=/Users/x/.chrome-agent/browsers/hcvar/chromium-profile\n";
let claimed = HashSet::from([42289]);
assert_eq!(
from_ps(ps, &dir(), &claimed),
vec![Orphan { pid: 16504, name: "hcvar".into() }]
);
}
#[test]
fn a_ps_line_without_a_numeric_pid_is_skipped() {
assert_eq!(from_ps(" PID COMMAND\n", &dir(), &HashSet::new()), vec![]);
}
}