use crate::util::UnwrapPoison;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use tracing::warn;
pub(crate) const MAX_AGENT_TABS: usize = 5;
const TAB_LEDGER_FILE_NAME: &str = "chrome-agent-tabs.json";
#[derive(Clone, Default, Serialize, Deserialize)]
struct NamespaceRecord {
#[serde(default)]
seq: u64,
#[serde(default)]
tabs: Vec<TabEntry>,
}
impl NamespaceRecord {
fn next_seq(&mut self) -> u64 {
self.seq += 1;
self.seq
}
}
#[derive(Clone, Default, Serialize, Deserialize)]
struct TabEntry {
name: String,
#[serde(default)]
seq: u64,
#[serde(default)]
closed: bool,
#[serde(skip)]
evicting: bool,
}
type Namespaces = BTreeMap<String, NamespaceRecord>;
#[derive(Default)]
struct LedgerState {
loaded_path: Option<PathBuf>,
namespaces: Namespaces,
}
impl LedgerState {
fn ensure_loaded(&mut self) {
let path = store_path();
if self.loaded_path == path {
return;
}
self.namespaces = path.as_deref().map_or_else(Namespaces::new, read_ledger);
self.loaded_path = path;
}
fn persist(&self) {
let Some(path) = store_path() else {
return;
};
let json = match serde_json::to_string(&self.namespaces) {
Ok(json) => json,
Err(error) => {
warn!(error = %error, "chrome tab ledger not written: serialization failed");
return;
}
};
if let Err(error) = crate::util::write_json_record(&path, &json) {
warn!(error = %error, path = %path.display(), "chrome tab ledger not written");
}
}
}
fn read_ledger(path: &Path) -> Namespaces {
let json = match std::fs::read_to_string(path) {
Ok(json) => json,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Namespaces::new(),
Err(error) => {
warn!(error = %error, path = %path.display(), "chrome tab ledger unreadable — starting empty");
return Namespaces::new();
}
};
match serde_json::from_str(&json) {
Ok(namespaces) => namespaces,
Err(error) => {
warn!(error = %error, path = %path.display(), "chrome tab ledger unparsable — starting empty");
Namespaces::new()
}
}
}
static LEDGER: OnceLock<Mutex<LedgerState>> = OnceLock::new();
fn ledger_state() -> &'static Mutex<LedgerState> {
LEDGER.get_or_init(|| Mutex::new(LedgerState::default()))
}
fn store_path() -> Option<PathBuf> {
#[cfg(test)]
if let Some(store) = test_store() {
return Some(store);
}
crate::config::CONFIG
.try_storage_root()
.map(|root| root.join(TAB_LEDGER_FILE_NAME))
}
#[derive(Clone, Debug)]
pub(crate) struct Eviction {
pub(crate) name: String,
pub(crate) seq: u64,
}
#[must_use]
pub(crate) fn note_addressed(namespace: &str, tab: &str, reached: bool) -> Option<Eviction> {
let mut state = ledger_state().lock().unwrap_poison();
state.ensure_loaded();
let (victim, changed) = record_addressed(&mut state.namespaces, namespace, tab, reached);
if changed {
state.persist();
}
victim
}
fn record_addressed(
namespaces: &mut Namespaces,
namespace: &str,
tab: &str,
reached: bool,
) -> (Option<Eviction>, bool) {
if !reached {
return (None, false);
}
let record = namespaces.entry(namespace.to_string()).or_default();
if let Some(index) = record.tabs.iter().position(|entry| entry.name == tab) {
let revived = record.tabs[index].closed;
record.tabs[index].closed = false;
record.tabs[index].seq = record.next_seq();
return if revived {
(choose_eviction(record, tab), true)
} else {
(None, true)
};
}
let seq = record.next_seq();
record.tabs.push(TabEntry {
name: tab.to_string(),
seq,
..TabEntry::default()
});
(choose_eviction(record, tab), true)
}
fn choose_eviction(record: &mut NamespaceRecord, current: &str) -> Option<Eviction> {
let open = record.tabs.iter().filter(|entry| !entry.closed).count();
if open <= MAX_AGENT_TABS {
return None;
}
let victim = record
.tabs
.iter_mut()
.filter(|entry| !entry.closed && !entry.evicting && entry.name != current)
.min_by_key(|entry| entry.seq)?;
victim.evicting = true;
Some(Eviction {
name: victim.name.clone(),
seq: victim.seq,
})
}
#[must_use]
pub(crate) fn is_closed(namespace: &str, tab: &str) -> bool {
let mut state = ledger_state().lock().unwrap_poison();
state.ensure_loaded();
state
.namespaces
.get(namespace)
.and_then(|record| record.tabs.iter().find(|entry| entry.name == tab))
.is_some_and(|entry| entry.closed)
}
pub(crate) fn take_eviction(namespace: &str, eviction: &Eviction) -> bool {
let mut state = ledger_state().lock().unwrap_poison();
state.ensure_loaded();
let Some(entry) = state.namespaces.get_mut(namespace).and_then(|record| {
record
.tabs
.iter_mut()
.find(|entry| entry.name == eviction.name)
}) else {
return false;
};
if entry.seq == eviction.seq && !entry.closed {
return true;
}
entry.evicting = false;
false
}
pub(crate) fn settle_eviction(namespace: &str, eviction: &Eviction, gone: bool) {
let mut state = ledger_state().lock().unwrap_poison();
state.ensure_loaded();
let closed_now = {
let Some(entry) = state.namespaces.get_mut(namespace).and_then(|record| {
record
.tabs
.iter_mut()
.find(|entry| entry.name == eviction.name)
}) else {
return;
};
let closed_now = gone && entry.seq == eviction.seq && !entry.closed;
entry.evicting = false;
if closed_now {
entry.closed = true;
}
closed_now
};
if closed_now {
state.persist();
}
}
pub(crate) fn forget_namespace(namespace: &str) {
let mut state = ledger_state().lock().unwrap_poison();
state.ensure_loaded();
if state.namespaces.remove(namespace).is_some() {
state.persist();
}
}
pub(crate) fn prune(protected: impl Fn(&str) -> bool) {
let mut state = ledger_state().lock().unwrap_poison();
state.ensure_loaded();
let before = state.namespaces.len();
state.namespaces.retain(|namespace, _| protected(namespace));
if state.namespaces.len() != before {
state.persist();
}
}
#[cfg(test)]
static TEST_STORE: Mutex<Option<PathBuf>> = Mutex::new(None);
#[cfg(test)]
fn test_store() -> Option<PathBuf> {
TEST_STORE.lock().unwrap_poison().clone()
}
#[cfg(test)]
fn swap_store(store: PathBuf) -> Option<PathBuf> {
TEST_STORE.lock().unwrap_poison().replace(store)
}
#[cfg(test)]
fn restore_store(previous: Option<PathBuf>) {
*TEST_STORE.lock().unwrap_poison() = previous;
}
#[cfg(test)]
fn reset_state() {
*ledger_state().lock().unwrap_poison() = LedgerState::default();
}
#[cfg(test)]
pub(crate) struct TestLedgerGuard {
path: PathBuf,
previous: Option<PathBuf>,
_dir: tempfile::TempDir,
}
#[cfg(test)]
impl TestLedgerGuard {
pub(crate) fn install() -> Self {
let dir = tempfile::tempdir().expect("chrome tab ledger test dir");
let path = dir.path().join(TAB_LEDGER_FILE_NAME);
let previous = swap_store(path.clone());
reset_state();
Self {
path,
previous,
_dir: dir,
}
}
pub(crate) fn path(&self) -> &Path {
&self.path
}
pub(crate) fn holds(&self, namespace: &str) -> bool {
let Ok(json) = std::fs::read_to_string(&self.path) else {
return false;
};
serde_json::from_str::<serde_json::Value>(&json)
.is_ok_and(|record| record.get(namespace).is_some())
}
}
#[cfg(test)]
impl Drop for TestLedgerGuard {
fn drop(&mut self) {
reset_state();
restore_store(self.previous.take());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn namespaces() -> Namespaces {
let mut state = ledger_state().lock().unwrap_poison();
state.ensure_loaded();
state.namespaces.clone()
}
fn fill_to_cap(namespace: &str) {
for tab in ["a", "b", "c", "d", "e"] {
assert!(
note_addressed(namespace, tab, true).is_none(),
"no cap crossed while filling to {MAX_AGENT_TABS}"
);
}
}
#[test]
#[serial_test::serial(chrome_tabs)]
fn confirmed_evictions_keep_the_cap() {
let _guard = TestLedgerGuard::install();
let ns = "agent-tab-aaaaaaaaaaaa-";
for i in 0..12 {
let tab = format!("tab-{i}");
if let Some(victim) = note_addressed(ns, &tab, true) {
assert!(
!is_closed(ns, &victim.name),
"the victim stays open until settled"
);
settle_eviction(ns, &victim, true);
assert!(
is_closed(ns, &victim.name),
"a confirmed eviction closes the tab"
);
}
}
let open = (0..12)
.filter(|i| !is_closed(ns, &format!("tab-{i}")))
.count();
assert_eq!(
open, MAX_AGENT_TABS,
"the cap left exactly {MAX_AGENT_TABS} open"
);
}
#[test]
#[serial_test::serial(chrome_tabs)]
fn victim_is_the_least_recently_addressed_tab() {
let _guard = TestLedgerGuard::install();
let ns = "agent-tab-bbbbbbbbbbbb-";
fill_to_cap(ns);
assert!(
note_addressed(ns, "a", true).is_none(),
"a re-address does not cross the cap"
);
assert_eq!(
note_addressed(ns, "f", true).map(|eviction| eviction.name),
Some("b".to_string())
);
}
#[test]
#[serial_test::serial(chrome_tabs)]
fn failed_eviction_stays_open_and_is_picked_again() {
let _guard = TestLedgerGuard::install();
let ns = "agent-tab-cccccccccccc-";
fill_to_cap(ns);
let victim = note_addressed(ns, "f", true).expect("the cap was crossed");
assert_eq!(victim.name, "a");
settle_eviction(ns, &victim, false);
assert!(
!is_closed(ns, &victim.name),
"a failed eviction leaves the tab open"
);
assert_eq!(
note_addressed(ns, "g", true).map(|eviction| eviction.name),
Some("a".to_string())
);
}
#[test]
#[serial_test::serial(chrome_tabs)]
fn a_settle_for_a_pick_the_run_addressed_again_is_ignored() {
let _guard = TestLedgerGuard::install();
let ns = "agent-tab-999999999999-";
fill_to_cap(ns);
let victim = note_addressed(ns, "f", true).expect("the cap was crossed");
assert_eq!(victim.name, "a");
assert!(note_addressed(ns, "a", true).is_none());
settle_eviction(ns, &victim, true);
assert!(
!is_closed(ns, &victim.name),
"the close lost the race: the tab the run went back to stays open"
);
assert_eq!(
note_addressed(ns, "g", true).map(|eviction| eviction.name),
Some("b".to_string())
);
}
#[test]
#[serial_test::serial(chrome_tabs)]
fn take_eviction_drops_a_pick_the_run_addressed_again() {
let _guard = TestLedgerGuard::install();
let ns = "agent-tab-888888888888-";
fill_to_cap(ns);
let victim = note_addressed(ns, "f", true).expect("the cap was crossed");
assert!(take_eviction(ns, &victim), "the pick is still owed");
assert!(note_addressed(ns, "a", true).is_none());
assert!(
!take_eviction(ns, &victim),
"the run went back to the tab: the close is dropped"
);
assert_eq!(
note_addressed(ns, "g", true).map(|eviction| eviction.name),
Some("b".to_string())
);
}
#[test]
#[serial_test::serial(chrome_tabs)]
fn a_closed_name_reopens_only_through_a_reached_call() {
let _guard = TestLedgerGuard::install();
let ns = "agent-tab-dddddddddddd-";
fill_to_cap(ns);
let victim = note_addressed(ns, "f", true).expect("the cap was crossed");
assert_eq!(victim.name, "a");
settle_eviction(ns, &victim, true);
assert!(is_closed(ns, "a"));
assert!(note_addressed(ns, "a", false).is_none());
assert!(is_closed(ns, "a"));
let revictim = note_addressed(ns, "a", true);
assert!(!is_closed(ns, "a"), "a reached call brings the name back");
if let Some(name) = revictim {
settle_eviction(ns, &name, true);
}
}
#[test]
#[serial_test::serial(chrome_tabs)]
fn an_unreached_call_on_an_unknown_name_records_nothing() {
let guard = TestLedgerGuard::install();
let ns = "agent-tab-eeeeeeeeeeee-";
assert!(note_addressed(ns, "a", false).is_none());
assert!(!is_closed(ns, "a"));
assert!(
!guard.path().exists(),
"an unreached call must not create a phantom tab or touch the file"
);
assert!(namespaces().is_empty(), "no namespace entry was created");
assert!(note_addressed(ns, "a", true).is_none());
assert!(
namespaces().contains_key(ns),
"the reached call recorded the namespace"
);
}
#[test]
#[serial_test::serial(chrome_tabs)]
fn the_record_survives_a_restart_with_its_order_intact() {
let _guard = TestLedgerGuard::install();
let ns = "agent-tab-ffffffffffff-";
fill_to_cap(ns);
let victim = note_addressed(ns, "f", true).expect("the cap was crossed");
assert_eq!(victim.name, "a");
settle_eviction(ns, &victim, true);
reset_state();
assert!(is_closed(ns, "a"), "the closed tab survived the restart");
assert!(note_addressed(ns, "f", true).is_none());
assert_eq!(
note_addressed(ns, "g", true).map(|eviction| eviction.name),
Some("b".to_string())
);
}
#[test]
#[serial_test::serial(chrome_tabs)]
fn prune_and_forget_drop_records() {
let _guard = TestLedgerGuard::install();
let keep = "agent-tab-222222222222-";
for ns in ["agent-tab-111111111111-", keep, "agent-tab-333333333333-"] {
assert!(note_addressed(ns, "a", true).is_none());
}
assert_eq!(namespaces().len(), 3);
prune(|ns| ns == keep);
assert_eq!(namespaces().len(), 1);
assert!(namespaces().contains_key(keep));
reset_state();
assert_eq!(namespaces().len(), 1);
forget_namespace(keep);
assert!(namespaces().is_empty());
reset_state();
assert!(namespaces().is_empty(), "the drop was persisted");
}
}