use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use crate::session::{liveness, Liveness};
const GRACE: Duration = Duration::from_hours(24);
const EXAMINE_CAP: usize = 32;
const REMOVE_CAP: usize = 1;
const IMPLICIT_BROWSER: &str = "default";
const PROFILE_SUBDIR: &str = "chromium-profile";
pub struct Limits {
pub grace: Duration,
pub examine: usize,
pub remove: usize,
}
impl Default for Limits {
fn default() -> Self {
Self { grace: GRACE, examine: EXAMINE_CAP, remove: REMOVE_CAP }
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Hold {
Free,
Held,
Unknown,
}
pub fn sweep_orphans(
browsers_dir: &Path,
referenced: &HashSet<String>,
limits: &Limits,
) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(browsers_dir) else {
return Vec::new();
};
let mut names: Vec<String> = entries
.filter_map(|entry| entry.ok()?.file_name().into_string().ok())
.collect();
names.sort_unstable();
if names.is_empty() {
return Vec::new();
}
let rotation = rotation_offset(names.len());
let now = SystemTime::now();
let mut removed = Vec::new();
for offset in 0..names.len().min(limits.examine) {
let name = &names[(rotation + offset) % names.len()];
if !removable(browsers_dir, name, referenced, now, limits.grace) {
continue;
}
if std::fs::remove_dir_all(browsers_dir.join(name)).is_ok() {
removed.push(name.clone());
}
if removed.len() >= limits.remove {
break;
}
}
removed
}
fn rotation_offset(len: usize) -> usize {
let secs = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_or(0, |d| d.as_secs());
let mixed = secs.wrapping_add(u64::from(std::process::id()));
usize::try_from(mixed % len as u64).unwrap_or(0)
}
fn removable(
browsers_dir: &Path,
name: &str,
referenced: &HashSet<String>,
now: SystemTime,
grace: Duration,
) -> bool {
if name == IMPLICIT_BROWSER || referenced.contains(name) {
return false;
}
if crate::browser::validate_browser_name(name).is_err() {
return false;
}
let root = browsers_dir.join(name);
let profile = root.join(PROFILE_SUBDIR);
if !profile.is_dir() {
return false;
}
if holder(&profile) != Hold::Free {
return false;
}
let Some(touched) = last_touched(&root, &profile) else {
return false;
};
now.duration_since(touched).is_ok_and(|idle| idle >= grace)
}
fn holder(profile: &Path) -> Hold {
match std::fs::read_link(profile.join("SingletonLock")) {
Ok(target) => {
let hold = singleton_lock_holder(&target.to_string_lossy());
if hold != Hold::Free {
return hold;
}
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => return Hold::Unknown,
}
for artefact in ["SingletonSocket", "SingletonCookie"] {
if profile.join(artefact).symlink_metadata().is_ok() {
return Hold::Unknown;
}
}
devtools_port_holder(&profile.join("DevToolsActivePort"))
}
fn singleton_lock_holder(target: &str) -> Hold {
let Some((host, pid)) = target.rsplit_once('-') else {
return Hold::Unknown;
};
if this_host().is_none_or(|ours| ours != host) {
return Hold::Unknown;
}
let Ok(pid) = pid.parse::<u32>() else {
return Hold::Unknown;
};
match liveness(pid) {
Liveness::Alive => Hold::Held,
Liveness::Dead => Hold::Free,
Liveness::Unknown => Hold::Unknown,
}
}
fn devtools_port_holder(path: &Path) -> Hold {
let Ok(contents) = std::fs::read_to_string(path) else {
return if path.symlink_metadata().is_ok() { Hold::Unknown } else { Hold::Free };
};
let Some(port) = contents.lines().next().and_then(|l| l.trim().parse::<u16>().ok()) else {
return Hold::Unknown;
};
if port == 0 {
return Hold::Unknown;
}
let addr = std::net::SocketAddr::from(([127, 0, 0, 1], port));
match std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(80)) {
Ok(_) => Hold::Held,
Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => Hold::Free,
Err(_) => Hold::Unknown,
}
}
fn last_touched(root: &Path, profile: &Path) -> Option<SystemTime> {
let mut newest = mtime(root)?.max(mtime(profile)?);
for dir in [profile.to_path_buf(), profile.join("Default")] {
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound && dir != profile => continue,
Err(_) => return None,
};
for entry in entries {
let entry = entry.ok()?;
let meta = entry.metadata().ok().or_else(|| entry.path().symlink_metadata().ok())?;
newest = newest.max(meta.modified().ok()?);
}
}
Some(newest)
}
fn mtime(path: &Path) -> Option<SystemTime> {
path.symlink_metadata().ok()?.modified().ok()
}
fn this_host() -> Option<String> {
#[cfg(unix)]
{
let mut buf = vec![0 as libc::c_char; 256];
let len = buf.len() - 1;
#[allow(unsafe_code)]
let rc = unsafe { libc::gethostname(buf.as_mut_ptr(), len) };
if rc != 0 {
return None;
}
#[allow(unsafe_code)]
let host = unsafe { std::ffi::CStr::from_ptr(buf.as_ptr()) };
host.to_str().ok().map(str::to_owned)
}
#[cfg(not(unix))]
{
None
}
}
pub fn all_removable(
browsers_dir: &Path,
referenced: &HashSet<String>,
grace: Duration,
) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(browsers_dir) else {
return Vec::new();
};
let now = SystemTime::now();
let mut found: Vec<PathBuf> = entries
.filter_map(Result::ok)
.filter_map(|entry| {
let name = entry.file_name().into_string().ok()?;
removable(browsers_dir, &name, referenced, now, grace)
.then(|| browsers_dir.join(name))
})
.collect();
found.sort_unstable();
found
}
#[cfg(test)]
mod tests {
use super::*;
fn profile(browsers: &Path, name: &str, idle: Duration) -> PathBuf {
let root = browsers.join(name);
let dir = root.join(PROFILE_SUBDIR);
std::fs::create_dir_all(dir.join("Default")).unwrap();
std::fs::write(dir.join("Local State"), "{}").unwrap();
std::fs::write(dir.join("Default").join("Cookies"), "x").unwrap();
backdate(&root, idle);
root
}
fn backdate(root: &Path, idle: Duration) {
let when = SystemTime::now() - idle;
let secs = when.duration_since(SystemTime::UNIX_EPOCH).unwrap().as_secs();
let profile = root.join(PROFILE_SUBDIR);
let mut paths = vec![root.to_path_buf(), profile.clone()];
for dir in [profile.clone(), profile.join("Default")] {
if let Ok(entries) = std::fs::read_dir(&dir) {
paths.extend(entries.filter_map(|e| e.ok().map(|e| e.path())));
}
}
paths.reverse();
for path in paths {
set_mtime(&path, secs);
}
}
#[cfg(unix)]
fn set_mtime(path: &Path, secs: u64) {
use std::os::unix::ffi::OsStrExt;
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap();
let ts = libc::timespec { tv_sec: secs as libc::time_t, tv_nsec: 0 };
let times = [ts, ts];
#[allow(unsafe_code)]
unsafe {
libc::utimensat(libc::AT_FDCWD, c_path.as_ptr(), times.as_ptr(), libc::AT_SYMLINK_NOFOLLOW);
}
}
fn tmp_dir(tag: &str) -> PathBuf {
let dir = std::env::temp_dir()
.join(format!("chrome-agent_profiles_{}_{}", tag, std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
fn week() -> Duration {
Duration::from_hours(24 * 7)
}
struct LivePid(std::process::Child);
impl LivePid {
fn spawn() -> Self {
Self(std::process::Command::new("sleep").arg("30").spawn().unwrap())
}
}
impl Drop for LivePid {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[cfg(unix)]
#[test]
fn only_an_unreferenced_unheld_and_idle_profile_is_removed() {
let browsers = tmp_dir("predicate");
let held = LivePid::spawn();
profile(&browsers, "in-store", week());
profile(&browsers, "orphan-old", week());
profile(&browsers, "orphan-fresh", Duration::from_secs(0));
let live = profile(&browsers, "orphan-locked", week());
std::os::unix::fs::symlink(
format!("{}-{}", this_host().unwrap(), held.0.id()),
live.join(PROFILE_SUBDIR).join("SingletonLock"),
)
.unwrap();
let referenced: HashSet<String> = std::iter::once("in-store".to_string()).collect();
let limits = Limits { grace: Duration::from_mins(1), examine: 64, remove: 64 };
let removed = sweep_orphans(&browsers, &referenced, &limits);
assert_eq!(removed, vec!["orphan-old".to_string()], "wrong set removed");
for kept in ["in-store", "orphan-fresh", "orphan-locked"] {
assert!(browsers.join(kept).is_dir(), "{kept} was removed");
}
std::fs::remove_dir_all(&browsers).ok();
}
#[test]
fn a_just_created_profile_survives_a_concurrent_agents_sweep() {
let browsers = tmp_dir("race");
profile(&browsers, "agent-a", Duration::from_secs(0));
profile(&browsers, "agent-b", Duration::from_secs(0));
let limits = || Limits { grace: GRACE, examine: 64, remove: 64 };
let a_sees: HashSet<String> = std::iter::once("agent-b".to_string()).collect();
let b_sees: HashSet<String> = std::iter::once("agent-a".to_string()).collect();
assert!(sweep_orphans(&browsers, &a_sees, &limits()).is_empty());
assert!(sweep_orphans(&browsers, &b_sees, &limits()).is_empty());
assert!(browsers.join("agent-a").is_dir(), "agent-a's fresh profile was deleted");
assert!(browsers.join("agent-b").is_dir(), "agent-b's fresh profile was deleted");
std::fs::remove_dir_all(&browsers).ok();
}
#[test]
fn removal_is_capped_per_invocation() {
let browsers = tmp_dir("cap");
for i in 0..12 {
profile(&browsers, &format!("orphan-{i}"), week());
}
let referenced = HashSet::new();
let limits = Limits { grace: Duration::from_mins(1), examine: 32, remove: 3 };
let removed = sweep_orphans(&browsers, &referenced, &limits);
assert_eq!(removed.len(), 3, "removal cap ignored: {removed:?}");
assert_eq!(
std::fs::read_dir(&browsers).unwrap().count(),
9,
"removed a different number than reported"
);
std::fs::remove_dir_all(&browsers).ok();
}
#[test]
fn repeated_sweeps_reach_every_orphan() {
let browsers = tmp_dir("rotate");
for i in 0..8 {
profile(&browsers, &format!("orphan-{i}"), week());
}
let referenced = HashSet::new();
for _ in 0..40 {
let limits = Limits { grace: Duration::from_mins(1), examine: 2, remove: 1 };
if sweep_orphans(&browsers, &referenced, &limits).is_empty()
&& std::fs::read_dir(&browsers).unwrap().count() == 0
{
break;
}
}
assert_eq!(
std::fs::read_dir(&browsers).unwrap().count(),
0,
"a capped sweep never reached some orphans"
);
std::fs::remove_dir_all(&browsers).ok();
}
#[test]
fn a_directory_that_is_not_a_profile_is_left_alone() {
let browsers = tmp_dir("foreign");
std::fs::create_dir_all(browsers.join("notes")).unwrap();
std::fs::write(browsers.join("notes").join("keep.txt"), "mine").unwrap();
std::fs::create_dir_all(browsers.join("has space").join(PROFILE_SUBDIR)).unwrap();
let removed = sweep_orphans(
&browsers,
&HashSet::new(),
&Limits { grace: Duration::from_secs(0), examine: 64, remove: 64 },
);
assert!(removed.is_empty(), "removed a non-profile: {removed:?}");
assert!(browsers.join("notes").join("keep.txt").exists());
std::fs::remove_dir_all(&browsers).ok();
}
#[test]
fn the_implicit_browser_is_exempt() {
let browsers = tmp_dir("implicit");
profile(&browsers, IMPLICIT_BROWSER, week());
let removed = sweep_orphans(
&browsers,
&HashSet::new(),
&Limits { grace: Duration::from_mins(1), examine: 64, remove: 64 },
);
assert!(removed.is_empty(), "the default profile was swept: {removed:?}");
std::fs::remove_dir_all(&browsers).ok();
}
#[cfg(unix)]
#[test]
fn a_lock_from_another_host_is_never_a_verdict() {
assert_eq!(
singleton_lock_holder("some-other-host-1"),
Hold::Unknown,
"another host's lock was read as a local pid"
);
assert_eq!(singleton_lock_holder("no-separator"), Hold::Unknown);
let host = this_host().unwrap();
assert_eq!(singleton_lock_holder(&format!("{host}-notanumber")), Hold::Unknown);
assert_eq!(
singleton_lock_holder(&format!("{host}-{}", std::process::id())),
Hold::Held
);
}
#[test]
fn an_absent_or_empty_store_sweeps_to_nothing() {
let dir = tmp_dir("absent");
assert!(sweep_orphans(&dir.join("nope"), &HashSet::new(), &Limits::default()).is_empty());
assert!(sweep_orphans(&dir, &HashSet::new(), &Limits::default()).is_empty());
std::fs::remove_dir_all(&dir).ok();
}
}