use std::collections::{BTreeMap, BTreeSet};
use std::fs::File;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::{debug, info, warn};
use crate::tools::chrome_daemon::SessionRecovery;
pub(crate) const LIFETIME_MINUTES: u64 = 10;
const LIFETIME_MS: u64 = LIFETIME_MINUTES * 60_000;
const IN_FLIGHT_TTL_MS: u64 = 60 * 60 * 1000;
const CLOSE_ATTEMPT_LIMIT: u32 = 5;
const SESSIONS_FILE_NAME: &str = "chrome-cli-sessions.json";
const LOCK_FILE_NAME: &str = "chrome-cli-sessions.lock";
#[derive(Debug, Serialize, Deserialize)]
struct SessionRecord {
last_used_ms: u64,
#[serde(default)]
kept: bool,
#[serde(default)]
unconfirmed_closes: u32,
#[serde(default)]
in_flight: Vec<InFlight>,
}
#[derive(Debug, Serialize, Deserialize)]
struct InFlight {
pid: u32,
until_ms: u64,
}
impl InFlight {
fn new(now: u64) -> Self {
Self {
pid: std::process::id(),
until_ms: now.saturating_add(IN_FLIGHT_TTL_MS),
}
}
fn is_live(&self, now: u64) -> bool {
now < self.until_ms && crate::util::process_alive(self.pid)
}
}
type Records = BTreeMap<String, SessionRecord>;
pub(crate) struct SessionRow {
pub(crate) name: String,
pub(crate) kept: bool,
pub(crate) idle_seconds: u64,
}
pub(crate) struct UseOutcome {
pub(crate) created: bool,
pub(crate) marked: bool,
pub(crate) kept: bool,
}
struct Ledger {
root: PathBuf,
records: Records,
_lock: File,
}
impl Ledger {
fn open() -> Option<Self> {
crate::util::with_block_in_place(|| {
let root = storage_root()?;
let lock = lock_root(&root)?;
let records = read_records(&root);
Some(Self {
root,
records,
_lock: lock,
})
})
}
fn persist(&self) {
let json = match serde_json::to_string(&self.records) {
Ok(json) => json,
Err(error) => {
warn!(error = %error, "chrome CLI session record not written: serialization failed");
return;
}
};
let path = self.root.join(SESSIONS_FILE_NAME);
if let Err(error) =
crate::util::with_block_in_place(|| crate::util::write_json_record(&path, &json))
{
warn!(error = %error, "chrome CLI session record not written");
}
}
}
fn storage_root() -> Option<PathBuf> {
crate::config::CONFIG
.try_storage_root()
.or_else(|| crate::config::default_config_dir().ok())
}
fn lock_root(root: &Path) -> Option<File> {
if let Err(error) = std::fs::create_dir_all(root) {
warn!(error = %error, "chrome CLI session record unavailable: storage root not creatable");
return None;
}
let file = match std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(root.join(LOCK_FILE_NAME))
{
Ok(file) => file,
Err(error) => {
warn!(error = %error, "chrome CLI session record unavailable: lock file not openable");
return None;
}
};
match file.lock() {
Ok(()) => Some(file),
Err(error) => {
warn!(error = %error, "chrome CLI session record unavailable: lock not taken");
None
}
}
}
fn read_records(root: &Path) -> Records {
let path = root.join(SESSIONS_FILE_NAME);
let json = match std::fs::read_to_string(&path) {
Ok(json) => json,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Records::new(),
Err(error) => {
warn!(error = %error, path = %path.display(), "chrome CLI session record unreadable — starting empty");
return Records::new();
}
};
match serde_json::from_str(&json) {
Ok(records) => records,
Err(error) => {
warn!(error = %error, path = %path.display(), "chrome CLI session record unparsable — starting empty");
Records::new()
}
}
}
pub(crate) fn backstop_ms(kept: bool) -> u64 {
if kept { 0 } else { LIFETIME_MS }
}
pub(crate) fn display_name(full: &str) -> &str {
full.strip_prefix(crate::chrome::CLI_SESSION_PREFIX)
.unwrap_or(full)
}
pub(crate) fn mark_refusal(name: &str) -> String {
let name = display_name(name);
format!(
"session {name} already exists without the keep mark — stop it with \
`mahbot chrome session stop {name}` and start it again"
)
}
fn record_use(record: &mut SessionRecord, now: u64) {
record.last_used_ms = now;
record.unconfirmed_closes = 0;
}
fn apply_use(
records: &mut Records,
name: &str,
keep: bool,
now: u64,
) -> Result<UseOutcome, String> {
if let Some(record) = records.get_mut(name) {
if keep && !record.kept {
return Err(mark_refusal(name));
}
record_use(record, now);
return Ok(UseOutcome {
created: false,
marked: false,
kept: record.kept,
});
}
records.insert(
name.to_string(),
SessionRecord {
last_used_ms: now,
kept: keep,
unconfirmed_closes: 0,
in_flight: Vec::new(),
},
);
Ok(UseOutcome {
created: true,
marked: keep,
kept: keep,
})
}
fn due_for_close(records: &Records, now: u64) -> Option<String> {
records
.iter()
.filter(|(_, record)| !record.kept)
.filter(|(_, record)| !record.in_flight.iter().any(|mark| mark.is_live(now)))
.filter(|(_, record)| now.saturating_sub(record.last_used_ms) >= LIFETIME_MS)
.min_by_key(|(_, record)| record.last_used_ms)
.map(|(name, _)| name.clone())
}
pub(crate) fn check_keep(name: &str, keep: bool) -> Result<bool, String> {
if !keep {
return Ok(false);
}
let Some(ledger) = Ledger::open() else {
return Ok(false);
};
match ledger.records.get(name) {
Some(record) if !record.kept => Err(mark_refusal(name)),
other => Ok(other.is_some()),
}
}
pub(crate) fn note_use(name: &str, keep: bool) -> Result<UseOutcome, String> {
let Some(mut ledger) = Ledger::open() else {
return Ok(UseOutcome {
created: false,
marked: false,
kept: keep,
});
};
let now = crate::util::unix_millis();
let outcome = apply_use(&mut ledger.records, name, keep, now)?;
if let Some(record) = ledger.records.get_mut(name) {
let pid = std::process::id();
record.in_flight.retain(|mark| mark.pid != pid);
record.in_flight.push(InFlight::new(now));
}
ledger.persist();
Ok(outcome)
}
pub(crate) fn note_end(name: &str) {
let Some(mut ledger) = Ledger::open() else {
return;
};
let pid = std::process::id();
let Some(record) = ledger.records.get_mut(name) else {
return;
};
let marks = record.in_flight.len();
record.in_flight.retain(|mark| mark.pid != pid);
if record.in_flight.len() == marks {
return;
}
record.last_used_ms = crate::util::unix_millis();
ledger.persist();
}
pub(crate) fn touch(name: &str) -> Option<bool> {
let mut ledger = Ledger::open()?;
let kept = {
let record = ledger.records.get_mut(name)?;
record_use(record, crate::util::unix_millis());
record.kept
};
ledger.persist();
Some(kept)
}
pub(crate) fn recorded_names() -> BTreeSet<String> {
Ledger::open().map_or_else(BTreeSet::new, |ledger| ledger.records.into_keys().collect())
}
pub(crate) fn forget(name: &str) {
let Some(mut ledger) = Ledger::open() else {
return;
};
if ledger.records.remove(name).is_some() {
ledger.persist();
}
}
pub(crate) fn list() -> Vec<SessionRow> {
let now = crate::util::unix_millis();
Ledger::open().map_or_else(Vec::new, |ledger| {
ledger
.records
.into_iter()
.map(|(name, record)| SessionRow {
name: display_name(&name).to_string(),
kept: record.kept,
idle_seconds: now.saturating_sub(record.last_used_ms) / 1000,
})
.collect()
})
}
pub(crate) async fn sweep_expired() {
let Some(mut ledger) = Ledger::open() else {
return;
};
let now = crate::util::unix_millis();
let mut changed = prune_stale_marks(&mut ledger.records, now);
let Some(name) = due_for_close(&ledger.records, now) else {
if changed {
ledger.persist();
}
return;
};
match crate::tools::chrome_daemon::stop_session_by_name(&name).await {
SessionRecovery::Stopped => {
ledger.records.remove(&name);
changed = true;
info!(session = %name, "closed an idle chrome session");
}
SessionRecovery::NotStarted => {
debug!(session = %name, "idle chrome session not closed: chrome-use could not be started");
}
SessionRecovery::Unanswered => {
let given_up = ledger.records.get_mut(&name).is_some_and(|record| {
record.unconfirmed_closes = record.unconfirmed_closes.saturating_add(1);
record.unconfirmed_closes >= CLOSE_ATTEMPT_LIMIT
});
changed = true;
if given_up {
ledger.records.remove(&name);
warn!(
session = %name,
"idle chrome session close not confirmed — dropping its record"
);
} else {
debug!(
session = %name,
"idle chrome session close not confirmed — retried on the next sweep"
);
}
}
}
if changed {
ledger.persist();
}
}
fn prune_stale_marks(records: &mut Records, now: u64) -> bool {
let mut pruned = false;
for record in records.values_mut() {
let marks = record.in_flight.len();
record.in_flight.retain(|mark| mark.is_live(now));
pruned |= record.in_flight.len() != marks;
}
pruned
}
#[cfg(test)]
mod tests {
use super::*;
fn record(last_used_ms: u64, kept: bool) -> SessionRecord {
SessionRecord {
last_used_ms,
kept,
unconfirmed_closes: 0,
in_flight: Vec::new(),
}
}
#[test]
fn a_session_is_due_only_after_its_lifetime_and_never_when_kept() {
let mut records = Records::new();
records.insert("mahbot-chrome-young".to_string(), record(1_000, false));
records.insert("mahbot-chrome-old".to_string(), record(0, false));
records.insert("mahbot-chrome-kept".to_string(), record(0, true));
assert_eq!(
due_for_close(&records, LIFETIME_MS).as_deref(),
Some("mahbot-chrome-old")
);
assert_eq!(due_for_close(&records, LIFETIME_MS - 1), None);
}
#[test]
fn a_marked_session_is_never_due_while_its_call_runs() {
let mut records = Records::new();
let mut busy = record(0, false);
busy.in_flight = vec![InFlight::new(0)];
records.insert("mahbot-chrome-busy".to_string(), busy);
assert_eq!(due_for_close(&records, LIFETIME_MS), None);
let mut expired = record(0, false);
expired.in_flight = vec![InFlight {
pid: std::process::id(),
until_ms: 1,
}];
records.insert("mahbot-chrome-expired".to_string(), expired);
assert_eq!(
due_for_close(&records, LIFETIME_MS).as_deref(),
Some("mahbot-chrome-expired")
);
}
#[test]
fn the_mark_is_set_only_by_the_call_that_creates_the_session() {
let mut records = Records::new();
let created = apply_use(&mut records, "mahbot-chrome-a", true, 10).unwrap();
assert!(created.created && created.marked && created.kept);
let later = apply_use(&mut records, "mahbot-chrome-a", false, 20).unwrap();
assert!(!later.marked && later.kept);
assert_eq!(records["mahbot-chrome-a"].last_used_ms, 20);
let plain = apply_use(&mut records, "mahbot-chrome-b", false, 30).unwrap();
assert!(!plain.marked && !plain.kept);
assert!(apply_use(&mut records, "mahbot-chrome-b", true, 40).is_err());
assert!(apply_use(&mut records, "mahbot-chrome-a", true, 50).is_ok());
}
}