use super::chrome_daemon::{ExtensionState, NoVerdict, extension_state_from, run_cli_json_at};
use crate::tools::chrome::AGENT_TAB_PREFIX;
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
use std::time::{Duration, Instant};
use tracing::debug;
const TAB_READ_TIMEOUT: Duration = Duration::from_secs(15);
pub(crate) const READ_PHASE_RESERVE: Duration = TAB_READ_TIMEOUT.saturating_mul(3);
const MIN_EXTENSION_VERSION: semver::Version = semver::Version::new(0, 5, 25);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RetryLeg {
Silent,
LeftOpen,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum TabOutcome {
Gone,
Unclosable,
Retry(RetryLeg),
}
#[derive(Default, Clone)]
struct ExtensionStatus {
live: Option<semver::Version>,
installed: Option<semver::Version>,
absent: bool,
disabled: bool,
}
impl ExtensionStatus {
fn door_missing(&self) -> bool {
if self.disabled {
return true;
}
let too_old = |version: &semver::Version| *version < MIN_EXTENSION_VERSION;
match (&self.live, &self.installed) {
(Some(live), _) => too_old(live),
(None, Some(installed)) => too_old(installed),
(None, None) => self.absent,
}
}
}
pub(crate) type OwnedAgain<'a> = &'a (dyn Fn(&str) -> bool + Send + Sync);
pub(crate) const OWN_SESSION: &str = "mahbot-chrome-ephemeral-tabs";
static DOOR_CALLS: AtomicU64 = AtomicU64::new(0);
static DOOR_RELEASED: AtomicU64 = AtomicU64::new(0);
static DOOR_IN_FLIGHT: AtomicUsize = AtomicUsize::new(0);
static DOOR_ANSWERED: AtomicU64 = AtomicU64::new(0);
static UNREADABLE_ANSWER: AtomicBool = AtomicBool::new(false);
struct DoorCallInFlight;
impl Drop for DoorCallInFlight {
fn drop(&mut self) {
DOOR_IN_FLIGHT.fetch_sub(1, Ordering::SeqCst);
}
}
async fn door_call(
cli: &Path,
args: &[&str],
session: &str,
timeout: Duration,
) -> Result<Value, NoVerdict> {
let ours = session == OWN_SESSION;
let _in_flight = if ours {
DOOR_CALLS.fetch_add(1, Ordering::SeqCst);
DOOR_IN_FLIGHT.fetch_add(1, Ordering::SeqCst);
Some(DoorCallInFlight)
} else {
None
};
let result = run_cli_json_at(cli, args, Some(session), timeout).await;
if ours && result.is_ok() {
DOOR_ANSWERED.fetch_add(1, Ordering::SeqCst);
}
result
}
pub(crate) fn own_scratch_group_possible() -> bool {
DOOR_ANSWERED.load(Ordering::SeqCst) > 0
}
pub(crate) fn saw_unreadable_answer() -> bool {
UNREADABLE_ANSWER.load(Ordering::SeqCst)
}
#[cfg(test)]
pub(crate) fn reset_unreadable_answer() {
UNREADABLE_ANSWER.store(false, Ordering::SeqCst);
}
#[cfg(test)]
pub(crate) fn hold_door_call_in_flight() -> impl Drop {
DOOR_IN_FLIGHT.fetch_add(1, Ordering::SeqCst);
DoorCallInFlight
}
pub(crate) async fn release_own_session(cli: &Path, deadline: Instant) -> bool {
if DOOR_IN_FLIGHT.load(Ordering::SeqCst) != 0
|| DOOR_CALLS.load(Ordering::SeqCst) == DOOR_RELEASED.load(Ordering::SeqCst)
{
return true;
}
let gone = close_own_scratch_group(cli, deadline).await;
if gone {
DOOR_RELEASED.fetch_max(DOOR_CALLS.load(Ordering::SeqCst), Ordering::SeqCst);
if let Some(timeout) = spawn_budget(deadline, crate::chrome::SESSION_STOP_TIMEOUT)
&& let Err(verdict) = run_cli_json_at(
cli,
&["session", "stop", "--force"],
Some(OWN_SESSION),
timeout,
)
.await
{
debug!(
reason = %verdict.text(),
"the browser-read session's record could not be dropped"
);
}
}
gone
}
async fn close_own_scratch_group(cli: &Path, deadline: Instant) -> bool {
let door = Door {
cli,
session: OWN_SESSION,
deadline,
};
let Ok(groups) = read_live_groups(door).await else {
return false;
};
let ids = group_ids_titled(&groups, OWN_SESSION);
if ids.is_empty() {
return true; }
let Ok((owned, tabs)) = read_ownership(door).await else {
return false;
};
let targets = owned_group_tabs(&ids, &tabs, &owned);
if targets.is_empty() {
return false;
}
let _ = remove_tabs(door, &targets).await;
groups_gone(door, &ids).await
}
pub(crate) async fn close_sessions(
cli: &Path,
names: &[String],
session: &str,
deadline: Instant,
owned_again: OwnedAgain<'_>,
) -> Vec<(String, TabOutcome)> {
if names.is_empty() {
return Vec::new();
}
let door = Door {
cli,
session,
deadline,
};
let mut outcomes: Vec<Option<TabOutcome>> = vec![None; names.len()];
let groups = match read_live_groups(door).await {
Ok(groups) => groups,
Err(reason) => {
let all_names: Vec<usize> = (0..names.len()).collect();
fallback_outcomes(door, names, &all_names, &reason, owned_again, &mut outcomes).await;
return zip_outcomes(names, outcomes);
}
};
let matched: Vec<Vec<i64>> = names
.iter()
.map(|name| group_ids_titled(&groups, name))
.collect();
for (i, group_ids) in matched.iter().enumerate() {
if group_ids.is_empty() {
outcomes[i] = Some(TabOutcome::Gone);
}
}
if matched.iter().all(Vec::is_empty) {
return zip_outcomes(names, outcomes);
}
let (owned, tabs) = match read_ownership(door).await {
Ok(ownership) => ownership,
Err(reason) => {
let affected: Vec<usize> = matched
.iter()
.enumerate()
.filter_map(|(i, ids)| (!ids.is_empty()).then_some(i))
.collect();
fallback_outcomes(door, names, &affected, &reason, owned_again, &mut outcomes).await;
return zip_outcomes(names, outcomes);
}
};
let taken_back: Vec<bool> = names.iter().map(|name| owned_again(name)).collect();
let plan = plan_removal(&matched, &tabs, &owned, &taken_back);
for (i, settled) in plan.settled.iter().enumerate().filter(|(_, s)| s.is_some()) {
outcomes[i].clone_from(settled);
}
if !plan.verifying.is_empty() {
remove_and_verify(door, &matched, &plan, &owned, &mut outcomes).await;
}
let unclosable: Vec<usize> = outcomes
.iter()
.enumerate()
.filter(|(_, outcome)| matches!(outcome, Some(TabOutcome::Unclosable)))
.map(|(i, _)| i)
.collect();
if !unclosable.is_empty() {
confirm_unclosable(
door,
names,
&matched,
&unclosable,
owned_again,
&mut outcomes,
)
.await;
}
zip_outcomes(names, outcomes)
}
pub(crate) async fn agent_group_names(
cli: &Path,
session: &str,
deadline: Instant,
) -> Result<Vec<String>, NoVerdict> {
let door = Door {
cli,
session,
deadline,
};
let groups = read_live_groups(door).await?;
Ok(groups
.into_iter()
.map(|group| group.title)
.filter(|title| title.starts_with(AGENT_TAB_PREFIX))
.collect())
}
pub(crate) async fn forget_settled_sessions(
cli: &Path,
names: &[String],
deadline: Instant,
owned_again: OwnedAgain<'_>,
) -> LetGoOutcome {
let mut outcome = LetGoOutcome::default();
for (index, name) in names.iter().enumerate() {
if owned_again(name) {
outcome.owned.push(name.clone());
continue;
}
let ours = name == OWN_SESSION;
let stop: &[&str] = if ours {
&["session", "stop"]
} else {
&["session", "stop", "--force"]
};
if ours && DOOR_IN_FLIGHT.load(Ordering::SeqCst) != 0 {
outcome.busy.push(name.clone());
continue;
}
let calls_before_stop = ours.then(|| DOOR_CALLS.load(Ordering::SeqCst));
let Some(timeout) = spawn_budget(deadline, crate::chrome::SESSION_STOP_TIMEOUT) else {
outcome.deferred.extend(names[index..].iter().cloned());
return outcome;
};
let whole = timeout == crate::chrome::SESSION_STOP_TIMEOUT;
match run_cli_json_at(cli, stop, Some(name), timeout).await {
Ok(_) => {
if let Some(calls) = calls_before_stop {
DOOR_RELEASED.fetch_max(calls, Ordering::SeqCst);
}
}
Err(verdict) => {
if whole {
debug!(
name = %name,
reason = %verdict.text(),
"a settled chrome session could not be let go"
);
outcome.tried.push(name.clone());
} else {
debug!(
name = %name,
reason = %verdict.text(),
"a settled chrome session's stop was cut to the pass's remaining budget"
);
outcome.deferred.push(name.clone());
}
}
}
}
outcome
}
#[derive(Default)]
pub(crate) struct LetGoOutcome {
pub(crate) tried: Vec<String>,
pub(crate) owned: Vec<String>,
pub(crate) deferred: Vec<String>,
pub(crate) busy: Vec<String>,
}
#[derive(Debug, Clone, Copy)]
struct Door<'a> {
cli: &'a Path,
session: &'a str,
deadline: Instant,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LiveGroup {
id: i64,
title: String,
}
fn spawn_budget(deadline: Instant, cap: Duration) -> Option<Duration> {
let remaining = deadline.saturating_duration_since(Instant::now());
(!remaining.is_zero()).then(|| remaining.min(cap))
}
fn stop_budget(deadline: Instant) -> Option<Duration> {
let remaining = deadline.saturating_duration_since(Instant::now());
(remaining >= crate::chrome::SESSION_STOP_TIMEOUT)
.then_some(crate::chrome::SESSION_STOP_TIMEOUT)
}
fn proved_nothing_about_the_tabs(reason: &NoVerdict) -> bool {
match reason {
NoVerdict::TimedOut | NoVerdict::SpawnFailure | NoVerdict::Unreadable => true,
NoVerdict::Reported(message) => crate::chrome::contract::unreached_browser_error(message),
}
}
fn note_unreadable(reason: NoVerdict) -> NoVerdict {
if matches!(reason, NoVerdict::Unreadable) {
UNREADABLE_ANSWER.store(true, Ordering::SeqCst);
}
reason
}
fn retry(leg: RetryLeg, cause: &str) -> TabOutcome {
debug!(
cause,
"chrome tab close not settled — retrying on a later pass"
);
TabOutcome::Retry(leg)
}
fn retry_all(
outcomes: &mut [Option<TabOutcome>],
indexes: impl IntoIterator<Item = usize>,
cause: &str,
) {
for index in indexes {
outcomes[index] = Some(retry(RetryLeg::Silent, cause));
}
}
async fn read_live_groups(door: Door<'_>) -> Result<Vec<LiveGroup>, NoVerdict> {
let timeout = spawn_budget(door.deadline, TAB_READ_TIMEOUT).ok_or(NoVerdict::TimedOut)?;
let envelope = door_call(
door.cli,
&["extension", "call", "tabGroups.query", "{}"],
door.session,
timeout,
)
.await
.map_err(note_unreadable)?;
parse_live_groups(&envelope).map_err(note_unreadable)
}
async fn groups_gone(door: Door<'_>, group_ids: &[i64]) -> bool {
read_live_groups(door)
.await
.is_ok_and(|live| live.iter().all(|group| !group_ids.contains(&group.id)))
}
async fn read_ownership(
door: Door<'_>,
) -> Result<(HashSet<i64>, HashMap<i64, Vec<i64>>), NoVerdict> {
let owned = read_owned_tabs(door).await?;
let tabs = read_tabs_by_group(door).await?;
Ok((owned, tabs))
}
async fn read_owned_tabs(door: Door<'_>) -> Result<HashSet<i64>, NoVerdict> {
let timeout = spawn_budget(door.deadline, TAB_READ_TIMEOUT).ok_or(NoVerdict::TimedOut)?;
let envelope = door_call(door.cli, &["extension", "state"], door.session, timeout)
.await
.map_err(note_unreadable)?;
parse_owned_tabs(&envelope).map_err(note_unreadable)
}
async fn read_tabs_by_group(door: Door<'_>) -> Result<HashMap<i64, Vec<i64>>, NoVerdict> {
let timeout = spawn_budget(door.deadline, TAB_READ_TIMEOUT).ok_or(NoVerdict::TimedOut)?;
let envelope = door_call(
door.cli,
&["extension", "call", "tabs.query", "{}"],
door.session,
timeout,
)
.await
.map_err(note_unreadable)?;
parse_tabs_by_group(&envelope).map_err(note_unreadable)
}
async fn read_extension_status(door: Door<'_>) -> Option<ExtensionStatus> {
let timeout = spawn_budget(door.deadline, TAB_READ_TIMEOUT)?;
let envelope = door_call(door.cli, &["extension", "status"], door.session, timeout)
.await
.map_err(note_unreadable)
.ok()?;
Some(parse_extension_status(&envelope))
}
pub(crate) async fn ownership_door_missing(cli: &Path, session: &str, deadline: Instant) -> bool {
read_extension_status(Door {
cli,
session,
deadline,
})
.await
.is_some_and(|status| status.door_missing())
}
async fn remove_tabs(door: Door<'_>, tab_ids: &[i64]) -> Result<(), NoVerdict> {
let timeout = spawn_budget(door.deadline, TAB_READ_TIMEOUT).ok_or(NoVerdict::TimedOut)?;
let payload = serde_json::to_string(&[tab_ids]).expect("an array of tab ids always serializes");
door_call(
door.cli,
&["extension", "call", "tabs.remove", &payload],
door.session,
timeout,
)
.await
.map(|_| ())
}
const REFUSAL_FRAGMENT: &str = "not owned by this relay";
fn remove_answered(reason: &NoVerdict) -> bool {
let NoVerdict::Reported(message) = reason else {
return false;
};
message.contains(REFUSAL_FRAGMENT)
}
async fn remove_and_verify(
door: Door<'_>,
matched: &[Vec<i64>],
plan: &RemovalPlan,
owned: &HashSet<i64>,
outcomes: &mut [Option<TabOutcome>],
) {
let remove_leg = match remove_tabs(door, &plan.tab_ids).await {
Ok(()) => RetryLeg::LeftOpen,
Err(reason) => {
debug!(
reason = %reason.text(),
"chrome tabs.remove did not go through — reading the browser again"
);
if remove_answered(&reason) {
RetryLeg::LeftOpen
} else {
RetryLeg::Silent
}
}
};
verify(door, matched, &plan.verifying, owned, remove_leg, outcomes).await;
}
async fn fallback_outcomes(
door: Door<'_>,
names: &[String],
indices: &[usize],
reason: &NoVerdict,
owned_again: OwnedAgain<'_>,
outcomes: &mut [Option<TabOutcome>],
) {
if ownership_door_missing(door.cli, door.session, door.deadline).await {
stop_by_name(door, names, indices, owned_again, outcomes).await;
return;
}
retry_all(outcomes, indices.iter().copied(), reason.text());
}
async fn stop_by_name(
door: Door<'_>,
names: &[String],
indices: &[usize],
owned_again: OwnedAgain<'_>,
outcomes: &mut [Option<TabOutcome>],
) {
for slot in rotated_order(indices.len()) {
let index = indices[slot];
let name = &names[index];
if owned_again(name) {
outcomes[index] = Some(retry(RetryLeg::Silent, "the run owns its session again"));
continue;
}
let Some(timeout) = stop_budget(door.deadline) else {
outcomes[index] = Some(retry(
RetryLeg::Silent,
"the attempt budget no longer holds a whole stop",
));
continue;
};
outcomes[index] = Some(
match run_cli_json_at(door.cli, &["session", "stop"], Some(name), timeout).await {
Ok(_) => TabOutcome::Gone,
Err(verdict) => retry(RetryLeg::Silent, verdict.text()),
},
);
}
}
async fn verify(
door: Door<'_>,
matched: &[Vec<i64>],
verifying: &[usize],
owned: &HashSet<i64>,
remove_leg: RetryLeg,
outcomes: &mut [Option<TabOutcome>],
) {
let live = match read_live_groups(door).await {
Ok(groups) => groups,
Err(reason) => {
retry_all(outcomes, verifying.iter().copied(), reason.text());
return;
}
};
let live_ids: HashSet<i64> = live.into_iter().map(|group| group.id).collect();
let mut surviving: Vec<(usize, Vec<i64>)> = Vec::new();
for &i in verifying {
let group_ids: Vec<i64> = matched[i]
.iter()
.copied()
.filter(|id| live_ids.contains(id))
.collect();
if group_ids.is_empty() {
outcomes[i] = Some(TabOutcome::Gone);
} else {
surviving.push((i, group_ids));
}
}
if surviving.is_empty() {
return;
}
let tabs = match read_tabs_by_group(door).await {
Ok(tabs) => tabs,
Err(reason) => {
retry_all(outcomes, surviving.iter().map(|(i, _)| *i), reason.text());
return;
}
};
for (i, group_ids) in surviving {
outcomes[i] = Some(match settled_by_tabs(&tabs_of(&group_ids, &tabs), owned) {
Some(settled) => settled,
None => retry(remove_leg, "the browser did not remove the session's tabs"),
});
}
}
static STOP_ROTATION: AtomicUsize = AtomicUsize::new(0);
fn rotated_order(len: usize) -> impl Iterator<Item = usize> {
let rotation = if len == 0 {
0
} else {
STOP_ROTATION.fetch_add(1, Ordering::Relaxed) % len
};
(0..len).map(move |offset| (offset + rotation) % len)
}
async fn confirm_unclosable(
door: Door<'_>,
names: &[String],
matched: &[Vec<i64>],
indexes: &[usize],
owned_again: OwnedAgain<'_>,
outcomes: &mut [Option<TabOutcome>],
) {
let stops_until = door
.deadline
.checked_sub(READ_PHASE_RESERVE)
.unwrap_or(door.deadline);
let order: Vec<usize> = rotated_order(indexes.len())
.map(|offset| indexes[offset])
.collect();
let mut keep_queued: HashSet<usize> = HashSet::new();
for (position, &i) in order.iter().enumerate() {
if owned_again(&names[i]) {
outcomes[i] = Some(retry(RetryLeg::Silent, "the run owns its session again"));
keep_queued.insert(i);
continue;
}
let Some(timeout) = stop_budget(stops_until) else {
for &j in &order[position..] {
outcomes[j] = Some(retry(
RetryLeg::Silent,
"the pass's stop slice no longer holds a whole stop",
));
keep_queued.insert(j);
}
break;
};
match run_cli_json_at(door.cli, &["session", "stop"], Some(&names[i]), timeout).await {
Err(verdict) if proved_nothing_about_the_tabs(&verdict) => {
outcomes[i] = Some(retry(
RetryLeg::Silent,
"the session's own stop proved nothing about the tabs",
));
keep_queued.insert(i);
}
Err(verdict) => debug!(
name = %names[i],
reason = %verdict.text(),
"the session's own stop route did not close its tabs"
),
Ok(_) => {}
}
}
if keep_queued.len() == indexes.len() {
return;
}
let (owned, tabs) = match read_ownership(door).await {
Ok(ownership) => ownership,
Err(reason) => {
retry_all(outcomes, indexes.iter().copied(), reason.text());
return;
}
};
let live = match read_live_groups(door).await {
Ok(live) => live,
Err(reason) => {
retry_all(outcomes, indexes.iter().copied(), reason.text());
return;
}
};
let live_ids: HashSet<i64> = live.into_iter().map(|group| group.id).collect();
for &i in indexes {
if keep_queued.contains(&i) {
continue;
}
let group_ids: Vec<i64> = matched[i]
.iter()
.copied()
.filter(|id| live_ids.contains(id))
.collect();
outcomes[i] = Some(match settled_by_tabs(&tabs_of(&group_ids, &tabs), &owned) {
Some(settled) => settled,
None => retry(
RetryLeg::Silent,
"the browser still holds a tab the extension owns",
),
});
}
}
struct RemovalPlan {
settled: Vec<Option<TabOutcome>>,
tab_ids: Vec<i64>,
verifying: Vec<usize>,
}
fn group_ids_titled(groups: &[LiveGroup], title: &str) -> Vec<i64> {
groups
.iter()
.filter(|group| group.title == title)
.map(|group| group.id)
.collect()
}
fn settled_by_tabs(tabs: &[i64], owned: &HashSet<i64>) -> Option<TabOutcome> {
if tabs.is_empty() {
return Some(TabOutcome::Gone);
}
if tabs.iter().all(|tab| !owned.contains(tab)) {
return Some(TabOutcome::Unclosable);
}
None
}
fn plan_removal(
matched: &[Vec<i64>],
tabs_by_group: &HashMap<i64, Vec<i64>>,
owned: &HashSet<i64>,
taken_back: &[bool],
) -> RemovalPlan {
let mut settled: Vec<Option<TabOutcome>> = vec![None; matched.len()];
let mut tab_ids: Vec<i64> = Vec::new();
let mut seen: HashSet<i64> = HashSet::new();
let mut verifying: Vec<usize> = Vec::new();
for (i, group_ids) in matched.iter().enumerate() {
if group_ids.is_empty() {
settled[i] = Some(TabOutcome::Gone);
continue;
}
if taken_back[i] {
settled[i] = Some(retry(RetryLeg::Silent, "the run owns its sessions again"));
continue;
}
let tabs = tabs_of(group_ids, tabs_by_group);
if let Some(outcome) = settled_by_tabs(&tabs, owned) {
settled[i] = Some(outcome);
continue;
}
for tab in owned_group_tabs(group_ids, tabs_by_group, owned) {
if seen.insert(tab) {
tab_ids.push(tab);
}
}
verifying.push(i);
}
RemovalPlan {
settled,
tab_ids,
verifying,
}
}
fn tabs_of(group_ids: &[i64], tabs_by_group: &HashMap<i64, Vec<i64>>) -> Vec<i64> {
let mut tabs: Vec<i64> = group_ids
.iter()
.filter_map(|id| tabs_by_group.get(id))
.flatten()
.copied()
.collect();
tabs.sort_unstable();
tabs.dedup();
tabs
}
fn owned_group_tabs(
group_ids: &[i64],
tabs_by_group: &HashMap<i64, Vec<i64>>,
owned: &HashSet<i64>,
) -> Vec<i64> {
tabs_of(group_ids, tabs_by_group)
.into_iter()
.filter(|tab| owned.contains(tab))
.collect()
}
fn zip_outcomes(names: &[String], outcomes: Vec<Option<TabOutcome>>) -> Vec<(String, TabOutcome)> {
names
.iter()
.cloned()
.zip(outcomes)
.map(|(name, outcome)| {
(
name,
outcome.unwrap_or_else(|| {
retry(
RetryLeg::Silent,
"this pass left a requested name undecided",
)
}),
)
})
.collect()
}
fn parse_live_groups(envelope: &Value) -> Result<Vec<LiveGroup>, NoVerdict> {
let result = envelope
.get("data")
.and_then(|data| data.get("result"))
.and_then(Value::as_array)
.ok_or(NoVerdict::Unreadable)?;
result
.iter()
.map(|group| {
Ok(LiveGroup {
id: group
.get("id")
.and_then(Value::as_i64)
.ok_or(NoVerdict::Unreadable)?,
title: group
.get("title")
.and_then(Value::as_str)
.ok_or(NoVerdict::Unreadable)?
.to_string(),
})
})
.collect()
}
fn parse_owned_tabs(envelope: &Value) -> Result<HashSet<i64>, NoVerdict> {
let owned = envelope
.get("data")
.and_then(|data| data.get("ownedTabs"))
.and_then(Value::as_array)
.ok_or(NoVerdict::Unreadable)?;
owned
.iter()
.map(|tab| tab.as_i64().ok_or(NoVerdict::Unreadable))
.collect()
}
fn parse_tabs_by_group(envelope: &Value) -> Result<HashMap<i64, Vec<i64>>, NoVerdict> {
let result = envelope
.get("data")
.and_then(|data| data.get("result"))
.and_then(Value::as_array)
.ok_or(NoVerdict::Unreadable)?;
let mut by_group: HashMap<i64, Vec<i64>> = HashMap::new();
for tab in result {
let id = tab
.get("id")
.and_then(Value::as_i64)
.ok_or(NoVerdict::Unreadable)?;
let group = tab
.get("groupId")
.and_then(Value::as_i64)
.ok_or(NoVerdict::Unreadable)?;
if group >= 0 {
by_group.entry(group).or_default().push(id);
}
}
Ok(by_group)
}
fn parse_extension_status(envelope: &Value) -> ExtensionStatus {
let version = |value: Option<&Value>| {
value
.and_then(Value::as_str)
.and_then(|raw| semver::Version::parse(raw).ok())
};
let data = envelope.get("data");
let state = extension_state_from(envelope);
ExtensionStatus {
live: version(data.and_then(|data| data.get("liveExtensionVersion"))),
installed: version(
data.and_then(|data| data.get("chromeExtension"))
.and_then(|extension| extension.get("version")),
),
absent: state == ExtensionState::Absent,
disabled: state == ExtensionState::Disabled,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn the_door_session_is_a_mahbot_session_name() {
assert!(crate::tools::chrome_daemon::is_mahbot_session_name(
OWN_SESSION
));
}
#[test]
#[serial_test::serial(chrome_release)]
fn rotated_order_moves_the_start_by_one_each_call() {
let len = 5;
let first: Vec<usize> = rotated_order(len).collect();
let second: Vec<usize> = rotated_order(len).collect();
assert_eq!(
second,
(0..len)
.map(|offset| (offset + first[0] + 1) % len)
.collect::<Vec<_>>(),
"one step further along than the call before it"
);
assert_eq!(rotated_order(1).collect::<Vec<_>>(), vec![0]);
}
#[test]
fn names_match_group_titles_exactly() {
let groups = vec![
LiveGroup {
id: 1,
title: "agent-tab-aaaa-bbbb-a".to_string(),
},
LiveGroup {
id: 2,
title: "agent-tab-aaaa-bbbb-a".to_string(),
},
LiveGroup {
id: 3,
title: "not-a-session".to_string(),
},
];
let names = [
"agent-tab-aaaa-bbbb-a".to_string(),
"agent-tab-aaaa-bbbb-a-renamed".to_string(),
];
assert_eq!(group_ids_titled(&groups, &names[0]), vec![1, 2]);
assert!(group_ids_titled(&groups, &names[1]).is_empty());
}
#[test]
fn plan_splits_gone_unclosable_and_closeable() {
let tabs_by_group = HashMap::from([(10, vec![100, 101]), (11, vec![200]), (12, vec![300])]);
let owned: HashSet<i64> = [100, 200].into_iter().collect();
let matched = vec![vec![], vec![10], vec![12]];
let plan = plan_removal(&matched, &tabs_by_group, &owned, &[false; 3]);
assert_eq!(plan.settled[0], Some(TabOutcome::Gone));
assert_eq!(plan.settled[1], None);
assert_eq!(plan.settled[2], Some(TabOutcome::Unclosable));
assert_eq!(plan.tab_ids, vec![100]);
assert_eq!(plan.verifying, vec![1]);
}
#[test]
fn a_name_taken_back_mid_pass_leaves_a_live_group_alone() {
let tabs_by_group = HashMap::from([(10, vec![100])]);
let owned: HashSet<i64> = [100].into_iter().collect();
let plan = plan_removal(&[vec![10]], &tabs_by_group, &owned, &[true]);
assert_eq!(
plan.settled[0],
Some(TabOutcome::Retry(RetryLeg::Silent)),
"the caller keeps its record and closes nothing"
);
assert!(plan.tab_ids.is_empty());
assert!(plan.verifying.is_empty());
let absent = plan_removal(&[vec![]], &tabs_by_group, &owned, &[true]);
assert_eq!(absent.settled[0], Some(TabOutcome::Gone));
}
#[test]
fn a_group_with_no_tabs_is_gone() {
let tabs_by_group = HashMap::new();
let owned: HashSet<i64> = [100].into_iter().collect();
let plan = plan_removal(&[vec![10]], &tabs_by_group, &owned, &[false]);
assert_eq!(plan.settled[0], Some(TabOutcome::Gone));
assert!(plan.tab_ids.is_empty());
assert!(plan.verifying.is_empty());
}
#[test]
fn only_the_extensions_own_refusal_counts_as_an_answered_close() {
let refusal = NoVerdict::Reported(
"call: tabs.remove refused — tab 7 is not owned by this relay (agent-created or \
adopted tabs only)"
.to_string(),
);
assert!(remove_answered(&refusal));
assert!(!proved_nothing_about_the_tabs(&refusal));
for never in [
NoVerdict::Reported("relay isn't connected".to_string()),
NoVerdict::Reported("Browser not launched".to_string()),
NoVerdict::Reported(
"could not drive your Chrome through the ab-connect extension".to_string(),
),
NoVerdict::Reported(
"extension call requires ab-connect 0.5.25 or newer (the extension did not \
announce the `call` capability)"
.to_string(),
),
NoVerdict::Reported("CDP command timed out after 30s: ABExt.call".to_string()),
NoVerdict::TimedOut,
NoVerdict::SpawnFailure,
NoVerdict::Unreadable,
] {
assert!(
!remove_answered(&never),
"{never:?} must not read as a refusal"
);
assert!(
proved_nothing_about_the_tabs(&never),
"{never:?} must prove nothing about the tabs"
);
}
}
#[test]
fn live_groups_parse_from_the_result_list() {
let envelope = json!({
"success": true,
"data": {
"policy": "call-v1",
"result": [
{"id": 1_026_066_125, "title": "not-a-session", "windowId": 304_840_673},
{"id": 5, "title": "agent-tab-aaaa-bbbb-a"}
]
}
});
assert_eq!(
parse_live_groups(&envelope).expect("groups"),
vec![
LiveGroup {
id: 1_026_066_125,
title: "not-a-session".to_string(),
},
LiveGroup {
id: 5,
title: "agent-tab-aaaa-bbbb-a".to_string(),
},
]
);
assert!(parse_live_groups(&json!({"success": true, "data": {}})).is_err());
assert!(
parse_live_groups(&json!({
"data": {"result": [{"id": "5", "title": "agent-tab-aaaa-bbbb-a"}]}
}))
.is_err()
);
assert!(
parse_live_groups(&json!({
"data": {"result": [{"id": 5, "title": 7}]}
}))
.is_err()
);
assert!(parse_live_groups(&json!({"data": {"result": [{"id": 5}]}})).is_err());
}
#[test]
fn owned_tabs_come_from_the_ledger_not_the_stale_group_map() {
let envelope = json!({
"success": true,
"data": {
"connected": true,
"ownedTabs": [304_864_598, 304_865_350],
"groups": [{"id": 1, "name": "stale"}, {"id": 2, "name": "stale"}]
}
});
assert_eq!(
parse_owned_tabs(&envelope).expect("ledger"),
[304_864_598, 304_865_350].into_iter().collect()
);
assert!(parse_owned_tabs(&json!({"data": {"ownedTabs": [1, "2"]}})).is_err());
}
#[test]
fn tabs_are_indexed_by_group_and_ungrouped_tabs_are_skipped() {
let envelope = json!({
"success": true,
"data": {
"policy": "call-v1",
"result": [
{"id": 1, "groupId": 10, "windowId": 3},
{"id": 2, "groupId": -1, "windowId": 3},
{"id": 3, "groupId": 10, "windowId": 4},
{"id": 4, "groupId": 11, "windowId": 4}
]
}
});
let by_group = parse_tabs_by_group(&envelope).expect("tabs");
assert_eq!(by_group.get(&10), Some(&vec![1, 3]));
assert_eq!(by_group.get(&11), Some(&vec![4]));
assert!(!by_group.contains_key(&-1));
assert!(
parse_tabs_by_group(&json!({
"data": {"result": [{"id": 1, "groupId": "10"}]}
}))
.is_err()
);
assert!(
parse_tabs_by_group(&json!({
"data": {"result": [{"id": 1}]}
}))
.is_err()
);
}
#[test]
fn extension_status_reads_the_door() {
let old_live = json!({
"success": true,
"data": {
"installed": true,
"liveExtensionVersion": "0.5.24",
"chromeExtension": {"version": "0.5.24"}
}
});
assert!(
parse_extension_status(&old_live).door_missing(),
"an extension older than the gate has no door"
);
let absent = json!({
"success": true,
"data": {
"installed": true,
"liveExtensionVersion": null,
"chromeExtension": null
}
});
assert!(
parse_extension_status(&absent).door_missing(),
"an absent extension is the same gap, told with chromeExtension"
);
let disabled = json!({
"success": true,
"data": {
"installed": true,
"chromeExtension": {"version": "0.5.26", "disableReasons": ["user"]}
}
});
assert!(
parse_extension_status(&disabled).door_missing(),
"a disabled extension's door is missing even when its version passes the gate"
);
let unconnected = json!({
"success": true,
"data": {
"installed": true,
"liveExtensionVersion": null,
"chromeExtension": {"version": "0.5.26"}
}
});
assert!(!parse_extension_status(&unconnected).door_missing());
let old_unconnected = json!({
"data": {"installed": true, "chromeExtension": {"version": "0.5.24"}}
});
assert!(parse_extension_status(&old_unconnected).door_missing());
let live_new = json!({
"data": {"installed": true, "liveExtensionVersion": "0.5.26"}
});
assert!(!parse_extension_status(&live_new).door_missing());
assert!(!parse_extension_status(&json!({"data": {}})).door_missing());
assert!(!parse_extension_status(&json!({"data": {"chromeExtension": {}}})).door_missing());
}
}