use std::collections::HashMap;
use std::path::Path;
use crate::session::{BrowserSession, SessionError, SessionStore};
pub fn save_to(path: &Path, store: &mut SessionStore) -> Result<(), SessionError> {
let parent = path
.parent()
.ok_or_else(|| SessionError("session path has no parent directory".into()))?;
crate::secure_fs::create_private_dir_all(parent)
.map_err(|e| SessionError(format!("Failed to create dir: {e}")))?;
let _lock = FileLock::acquire(&parent.join("sessions.lock"))?;
let mut merged = crate::session_load::reread_for_merge(path)?;
for name in &store.loaded_names {
if !store.browsers.contains_key(name) {
let on_disk_is_what_we_loaded = merged
.browsers
.get(name)
.and_then(|entry| serde_json::to_string(entry).ok())
.is_none_or(|json| store.loaded_entries.get(name) == Some(&json));
if on_disk_is_what_we_loaded {
merged.browsers.remove(name);
}
}
}
for (name, entry) in &store.browsers {
let untouched = serde_json::to_string(entry)
.ok()
.is_some_and(|json| store.loaded_entries.get(name) == Some(&json));
if untouched && merged.browsers.contains_key(name) {
continue;
}
merged.browsers.insert(name.clone(), entry.clone());
}
let pruned = prune_dead(&mut merged.browsers);
for name in &pruned {
store.browsers.remove(name);
}
let json = serde_json::to_string_pretty(&merged)
.map_err(|e| SessionError(format!("Failed to serialize session: {e}")))?;
let tmp_path = path.with_extension(format!("json.tmp.{}", std::process::id()));
std::fs::write(&tmp_path, &json)
.map_err(|e| SessionError(format!("Failed to write {}: {e}", tmp_path.display())))?;
crate::secure_fs::restrict_file(&tmp_path).map_err(|e| {
SessionError(format!(
"Failed to restrict {} to mode 0600: {e}",
tmp_path.display()
))
})?;
std::fs::rename(&tmp_path, path).map_err(|e| {
let _ = std::fs::remove_file(&tmp_path);
SessionError(format!("Failed to rename session file: {e}"))
})?;
crate::profiles::sweep_orphans(
&parent.join("browsers"),
&merged.browsers.keys().cloned().collect(),
&crate::profiles::Limits::default(),
);
store.take_baseline();
Ok(())
}
#[cfg(unix)]
pub struct FileLock(std::fs::File);
#[cfg(unix)]
impl FileLock {
pub fn acquire(path: &Path) -> Result<Self, SessionError> {
use std::os::unix::io::AsRawFd;
let file = std::fs::OpenOptions::new()
.create(true)
.truncate(false)
.write(true)
.open(path)
.map_err(|e| SessionError(format!("Failed to open lock {}: {e}", path.display())))?;
crate::secure_fs::restrict_file(path).map_err(|e| {
SessionError(format!(
"Failed to restrict lock {} to mode 0600: {e}",
path.display()
))
})?;
#[allow(unsafe_code)]
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) };
if rc != 0 {
return Err(SessionError(format!(
"Failed to lock session store: {}",
std::io::Error::last_os_error()
)));
}
Ok(Self(file))
}
}
#[cfg(unix)]
impl Drop for FileLock {
fn drop(&mut self) {
use std::os::unix::io::AsRawFd;
#[allow(unsafe_code)]
unsafe {
libc::flock(self.0.as_raw_fd(), libc::LOCK_UN);
}
}
}
#[cfg(not(unix))]
pub struct FileLock;
#[cfg(not(unix))]
impl FileLock {
pub fn acquire(_path: &Path) -> Result<Self, SessionError> {
Ok(Self)
}
}
fn prune_dead(browsers: &mut HashMap<String, BrowserSession>) -> Vec<String> {
let dead: Vec<String> = browsers
.iter()
.filter(|(_, session)| is_provably_dead(session))
.map(|(name, _)| name.clone())
.collect();
for name in &dead {
browsers.remove(name);
}
dead
}
fn is_provably_dead(session: &BrowserSession) -> bool {
session
.pid
.is_some_and(|pid| liveness(pid) == Liveness::Dead)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Liveness {
Alive,
Dead,
Unknown,
}
pub fn liveness(pid: u32) -> Liveness {
#[cfg(unix)]
{
let Ok(raw) = libc::pid_t::try_from(pid) else {
return Liveness::Unknown;
};
if raw <= 0 {
return Liveness::Unknown;
}
#[allow(unsafe_code)]
let rc = unsafe { libc::kill(raw, 0) };
if rc == 0 {
return Liveness::Alive;
}
if std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
Liveness::Dead
} else {
Liveness::Unknown
}
}
#[cfg(not(unix))]
{
let _ = pid;
Liveness::Unknown
}
}
pub fn cleanup_stale(store: &mut SessionStore) {
prune_dead(&mut store.browsers);
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
use crate::session::{SESSION_FILE, browser_fixture as browser, ensure_browser, ensure_page};
use crate::session_load::load_from;
#[test]
fn a_reader_does_not_clobber_another_agents_concurrent_write() {
let dir =
std::env::temp_dir().join(format!("chrome-agent-session-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sessions.json");
let _ = std::fs::remove_file(&path);
let mut a = SessionStore::default();
ensure_browser(&mut a, "agent-a", "ws://a", None, true, None, Vec::new());
save_to(&path, &mut a).unwrap();
let mut b = load_from(&path).unwrap();
ensure_browser(&mut b, "agent-b", "ws://b", None, true, None, Vec::new());
let mut a2 = load_from(&path).unwrap();
let browser = a2.browsers.get_mut("agent-a").unwrap();
let page = ensure_page(browser, "default", "target-1");
page.last_snapshot = Some("uid=n1 RootWebArea".into());
save_to(&path, &mut a2).unwrap();
save_to(&path, &mut b).unwrap();
let final_state = load_from(&path).unwrap();
assert!(
final_state.browsers.contains_key("agent-b"),
"agent-b should be saved"
);
let snapshot = final_state.browsers["agent-a"]
.pages
.get("default")
.and_then(|p| p.last_snapshot.as_deref());
assert_eq!(
snapshot,
Some("uid=n1 RootWebArea"),
"agent-a's snapshot was clobbered by an agent that only read it"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_stale_delete_does_not_clobber_a_concurrent_relaunch() {
let dir = std::env::temp_dir().join(format!(
"chrome-agent-session-relaunch-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sessions.json");
let relaunched = LivePid::spawn();
let mut original = SessionStore::default();
ensure_browser(
&mut original,
"foo",
"ws://old",
Some(std::process::id()),
true,
None,
Vec::new(),
);
save_to(&path, &mut original).unwrap();
let mut heartbeat = load_from(&path).unwrap();
heartbeat.browsers.remove("foo");
let mut agent = load_from(&path).unwrap();
agent.browsers.remove("foo");
ensure_browser(
&mut agent,
"foo",
"ws://fresh",
Some(relaunched.id()),
true,
None,
Vec::new(),
);
save_to(&path, &mut agent).unwrap();
save_to(&path, &mut heartbeat).unwrap();
let final_state = load_from(&path).unwrap();
let survivor = final_state.browsers.get("foo");
assert_eq!(
survivor.and_then(|b| b.pid),
Some(relaunched.id()),
"the freshly relaunched browser was deleted by a stale-cleanup decision made about its predecessor"
);
let _ = std::fs::remove_dir_all(&dir);
}
struct LivePid(std::process::Child);
impl LivePid {
fn spawn() -> Self {
Self(
std::process::Command::new("sleep")
.arg("30")
.spawn()
.expect("spawn a stand-in for a running browser"),
)
}
fn id(&self) -> u32 {
self.0.id()
}
}
impl Drop for LivePid {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
#[test]
fn a_save_that_cannot_publish_leaves_no_temp_file_behind() {
let dir = std::env::temp_dir().join(format!(
"chrome-agent-session-tmpleak-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("sessions.json");
std::fs::create_dir_all(path.join("occupied")).unwrap();
let mut store = SessionStore::default();
ensure_browser(&mut store, "leaky", "ws://x", None, true, None, Vec::new());
let result = save_to(&path, &mut store);
let err = result
.expect_err("an unpublishable destination should fail the save")
.0;
assert!(
err.contains("Failed to read"),
"the refusal names what it could not do: {err}"
);
let tmp_path = path.with_extension(format!("json.tmp.{}", std::process::id()));
assert!(
!tmp_path.exists(),
"failed save left {} behind",
tmp_path.display()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[cfg(unix)]
fn a_dead_pid() -> u32 {
(60_000..99_990u32)
.find(|&pid| liveness(pid) == Liveness::Dead)
.expect("no unused pid in range")
}
#[cfg(unix)]
#[test]
fn save_drops_dead_browsers_and_keeps_live_and_pidless_ones() {
let dir = tmp_dir("prune");
let path = dir.join(SESSION_FILE);
let mut seed = SessionStore::default();
ensure_browser(
&mut seed,
"live",
"ws://live",
Some(std::process::id()),
true,
None,
Vec::new(),
);
ensure_browser(
&mut seed,
"dead",
"ws://dead",
Some(a_dead_pid()),
true,
None,
Vec::new(),
);
ensure_browser(
&mut seed,
"dead-2",
"ws://dead2",
Some(a_dead_pid()),
true,
None,
Vec::new(),
);
ensure_browser(
&mut seed,
"external",
"ws://127.0.0.1:9222/x",
None,
false,
None,
Vec::new(),
);
for name in ["dead", "dead-2"] {
let browser = seed.browsers.get_mut(name).unwrap();
let page = ensure_page(browser, "default", "target-1");
page.last_snapshot = Some("x".repeat(4096));
}
std::fs::write(&path, serde_json::to_string_pretty(&seed).unwrap()).unwrap();
let size_with_dead = std::fs::metadata(&path).unwrap().len();
let mut reader = load_from(&path).unwrap();
save_to(&path, &mut reader).unwrap();
let disk = load_from(&path).unwrap();
let mut survivors: Vec<&str> = disk.browsers.keys().map(String::as_str).collect();
survivors.sort_unstable();
assert_eq!(
survivors,
["external", "live"],
"expected only the dead entries to go"
);
let size_pruned = std::fs::metadata(&path).unwrap().len();
assert!(
size_pruned < size_with_dead,
"file did not shrink: {size_with_dead} -> {size_pruned}"
);
assert!(
!reader.browsers.contains_key("dead"),
"the pruned entry is still staged in memory: {:?}",
reader.browsers.keys()
);
save_to(&path, &mut reader).unwrap();
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
size_pruned,
"a second save was not a no-op"
);
std::fs::remove_dir_all(&dir).ok();
}
#[cfg(unix)]
#[test]
fn pruning_leaves_a_concurrent_agents_live_browser_alone() {
let dir = tmp_dir("prune-concurrent");
let path = dir.join(SESSION_FILE);
let mut a = SessionStore::default();
ensure_browser(
&mut a,
"agent-a",
"ws://a",
Some(std::process::id()),
true,
None,
Vec::new(),
);
let browser = a.browsers.get_mut("agent-a").unwrap();
ensure_page(browser, "default", "target-a").last_snapshot =
Some("uid=n1 RootWebArea".into());
save_to(&path, &mut a).unwrap();
let mut b = load_from(&path).unwrap();
ensure_browser(
&mut b,
"agent-b",
"ws://b",
Some(std::process::id()),
true,
None,
Vec::new(),
);
ensure_browser(
&mut b,
"leftover",
"ws://old",
Some(a_dead_pid()),
true,
None,
Vec::new(),
);
save_to(&path, &mut b).unwrap();
save_to(&path, &mut a).unwrap();
let disk = load_from(&path).unwrap();
assert!(
!disk.browsers.contains_key("leftover"),
"dead entry survived"
);
assert_eq!(
disk.browsers["agent-a"]
.pages
.get("default")
.and_then(|p| p.last_snapshot.as_deref()),
Some("uid=n1 RootWebArea"),
"agent-a lost its snapshot"
);
assert!(
disk.browsers.contains_key("agent-b"),
"another agent's live browser was pruned: {:?}",
disk.browsers.keys()
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn only_a_pid_the_os_calls_gone_makes_an_entry_droppable() {
let mut external = browser("ws://127.0.0.1:9222/x");
external.pid = None;
assert!(!is_provably_dead(&external), "--connect entry must be kept");
let mut live = browser("ws://live");
live.pid = Some(std::process::id());
assert!(!is_provably_dead(&live));
let mut absurd = browser("ws://absurd");
absurd.pid = Some(u32::MAX);
assert!(!is_provably_dead(&absurd));
let mut zero = browser("ws://zero");
zero.pid = Some(0);
assert!(!is_provably_dead(&zero));
#[cfg(unix)]
{
let mut dead = browser("ws://dead");
dead.pid = Some(a_dead_pid());
assert!(is_provably_dead(&dead));
}
}
#[test]
fn the_heartbeat_sweep_keeps_what_it_cannot_prove_dead() {
let mut store = SessionStore::default();
let mut external = browser("ws://127.0.0.1:1/devtools/browser/x");
external.pid = None;
store.browsers.insert("external".into(), external);
let mut live = browser("ws://127.0.0.1:1/devtools/browser/y");
live.pid = Some(std::process::id());
store.browsers.insert("live".into(), live);
cleanup_stale(&mut store);
assert!(
store.browsers.contains_key("external"),
"a pidless entry carries no liveness information and must be kept"
);
assert!(
store.browsers.contains_key("live"),
"a live pid must be kept"
);
#[cfg(unix)]
{
let mut dead = browser("ws://dead");
dead.pid = Some(a_dead_pid());
store.browsers.insert("dead".into(), dead);
cleanup_stale(&mut store);
assert!(
!store.browsers.contains_key("dead"),
"a pid the OS calls gone is the one thing the sweep may drop"
);
}
}
fn tmp_dir(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("chrome-agent_sess_{}_{}", tag, std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn save_merges_concurrent_additions_from_another_process() {
let dir = tmp_dir("merge");
let path = dir.join(SESSION_FILE);
let mut mine = load_from(&path).unwrap();
mine.browsers.insert("a".into(), browser("ws://a"));
let mut theirs = load_from(&path).unwrap();
theirs.browsers.insert("b".into(), browser("ws://b"));
save_to(&path, &mut theirs).unwrap();
save_to(&path, &mut mine).unwrap();
let disk = load_from(&path).unwrap();
assert!(
disk.browsers.contains_key("a"),
"own entry lost: {:?}",
disk.browsers.keys()
);
assert!(
disk.browsers.contains_key("b"),
"concurrent entry clobbered: {:?}",
disk.browsers.keys()
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn save_deletes_only_entries_this_process_removed() {
let dir = tmp_dir("delete");
let path = dir.join(SESSION_FILE);
let mut seed = SessionStore::default();
seed.browsers.insert("a".into(), browser("ws://a"));
seed.browsers.insert("b".into(), browser("ws://b"));
save_to(&path, &mut seed).unwrap();
let mut store = load_from(&path).unwrap();
store.browsers.remove("a");
save_to(&path, &mut store).unwrap();
let disk = load_from(&path).unwrap();
assert!(
!disk.browsers.contains_key("a"),
"removed entry should be gone"
);
assert!(
disk.browsers.contains_key("b"),
"untouched entry should remain"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn save_does_not_delete_entries_added_by_others_after_load() {
let dir = tmp_dir("nodelete");
let path = dir.join(SESSION_FILE);
let mut mine = load_from(&path).unwrap();
mine.browsers.insert("a".into(), browser("ws://a"));
let mut other = load_from(&path).unwrap();
other.browsers.insert("c".into(), browser("ws://c"));
save_to(&path, &mut other).unwrap();
save_to(&path, &mut mine).unwrap();
let disk = load_from(&path).unwrap();
assert!(disk.browsers.contains_key("a"));
assert!(
disk.browsers.contains_key("c"),
"must not delete an entry we never loaded"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn concurrent_saves_under_lock_lose_no_updates() {
let dir = tmp_dir("threads");
let path = dir.join(SESSION_FILE);
let n = 24;
let handles: Vec<_> = (0..n)
.map(|i| {
let path = path.clone();
std::thread::spawn(move || {
let mut store = load_from(&path).unwrap_or_default();
store
.browsers
.insert(format!("b{i}"), browser(&format!("ws://{i}")));
save_to(&path, &mut store).unwrap();
})
})
.collect();
for h in handles {
h.join().unwrap();
}
let disk = load_from(&path).unwrap();
for i in 0..n {
assert!(
disk.browsers.contains_key(&format!("b{i}")),
"lost update for b{i}; have {:?}",
disk.browsers.keys()
);
}
std::fs::remove_dir_all(&dir).ok();
}
}