use crate::util::UnwrapPoison;
use serde_json::Value;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tokio::process::Command;
use tracing::{debug, error, info, warn};
const CLI_TIMEOUT: Duration = Duration::from_secs(8);
const HEALTH_TTL: Duration = Duration::from_secs(10);
const UNHEALTHY_TTL: Duration = Duration::from_mins(1);
const WATCHDOG_INTERVAL: Duration = Duration::from_secs(30);
const CLI_RECHECK: Duration = Duration::from_mins(5);
const CLI_MISSING_THRESHOLD: u32 = 2;
const MAX_RESTART_ATTEMPTS: u32 = 3;
const SUSTAINED_HEALTHY_WINDOW: Duration = Duration::from_mins(1);
const RESTART_BACKOFF: [Duration; 3] = [
Duration::from_secs(30),
Duration::from_mins(2),
Duration::from_mins(10),
];
const HALT_COOLDOWN: Duration = Duration::from_mins(30);
const RELAY_REVIVE_WAIT: Duration = Duration::from_secs(40);
const SWEEP_TOTAL_BUDGET: Duration = Duration::from_secs(15);
const SWEEP_MAX_ROUNDS: u32 = 5;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProbeFailure {
NotInstalled,
HostBroken,
ExtensionDisabled,
RelayDown,
UnreachableTab,
DaemonWedge,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ProbeOutcome {
Healthy,
Down(ProbeFailure),
}
impl ProbeOutcome {
fn is_healthy(self) -> bool {
matches!(self, ProbeOutcome::Healthy)
}
fn failure(self) -> Option<ProbeFailure> {
match self {
ProbeOutcome::Healthy => None,
ProbeOutcome::Down(f) => Some(f),
}
}
}
impl ProbeFailure {
fn is_unfixable(self) -> bool {
matches!(
self,
ProbeFailure::NotInstalled
| ProbeFailure::HostBroken
| ProbeFailure::ExtensionDisabled
| ProbeFailure::UnreachableTab
)
}
}
#[derive(Default)]
struct DaemonHealth {
healthy: Option<bool>,
last_probe: Option<Instant>,
restart_attempts: u32,
next_restart_at: Option<Instant>,
halted: bool,
halted_until: Option<Instant>,
last_failure: Option<ProbeFailure>,
last_cause_warned: Option<ProbeFailure>,
healthy_since: Option<Instant>,
}
#[derive(Debug, PartialEq, Eq)]
enum RestartGate {
Allowed(u32),
Backoff,
Cooldown,
Halted,
}
impl DaemonHealth {
fn gate_restart(&mut self, now: Instant) -> RestartGate {
if self.halted {
if self.halted_until.is_some_and(|until| now < until) {
return RestartGate::Cooldown;
}
self.halted = false;
self.restart_attempts = 0;
self.halted_until = None;
self.next_restart_at = None;
}
if self.next_restart_at.is_some_and(|next| now < next) {
return RestartGate::Backoff;
}
if self.restart_attempts >= MAX_RESTART_ATTEMPTS {
self.halted = true;
self.halted_until = Some(now + HALT_COOLDOWN);
return RestartGate::Halted;
}
let attempt = self.restart_attempts + 1;
self.restart_attempts = attempt;
self.next_restart_at =
Some(now + RESTART_BACKOFF[(attempt as usize - 1).min(RESTART_BACKOFF.len() - 1)]);
RestartGate::Allowed(attempt)
}
fn apply_outcome(&mut self, outcome: ProbeOutcome, now: Instant, seed_window: bool) {
let healthy = outcome.is_healthy();
if healthy {
if self
.healthy_since
.is_some_and(|since| now.duration_since(since) >= SUSTAINED_HEALTHY_WINDOW)
{
self.restart_attempts = 0;
self.next_restart_at = None;
self.halted = false;
self.halted_until = None;
self.last_cause_warned = None;
}
if seed_window && self.healthy_since.is_none() {
self.healthy_since = Some(now);
}
} else {
self.healthy_since = None;
}
self.last_failure = outcome.failure();
self.healthy = Some(healthy);
self.last_probe = Some(now);
}
}
static HEALTH: OnceLock<Mutex<DaemonHealth>> = OnceLock::new();
static WAKE: OnceLock<tokio::sync::Notify> = OnceLock::new();
fn health() -> &'static Mutex<DaemonHealth> {
HEALTH.get_or_init(|| Mutex::new(DaemonHealth::default()))
}
fn wake() -> &'static tokio::sync::Notify {
WAKE.get_or_init(tokio::sync::Notify::new)
}
pub(crate) fn is_daemon_unavailable_error(msg: &str) -> bool {
let lower = msg.to_ascii_lowercase();
lower.contains("resource temporarily unavailable")
|| lower.contains("os error 35")
|| lower.contains("os error 11")
|| lower.contains("daemon may be busy or unresponsive")
|| lower.contains("session unresponsive")
|| lower.contains("cdp session is unresponsive after attaching")
|| lower.contains("daemon failed to start")
|| lower.contains("auto-launch failed")
|| lower.contains("failed to connect:")
}
fn is_relay_unavailable_error(msg: &str) -> bool {
let lower = msg.to_ascii_lowercase();
lower.contains("relay isn't connected")
|| lower.contains("relay is not")
|| lower.contains("relay dropped")
|| lower.contains("relay down")
|| lower.contains("could not drive your chrome")
}
fn classify_failure_text(msg: &str) -> Option<ProbeFailure> {
if is_unreachable_tab_error(msg) {
Some(ProbeFailure::UnreachableTab)
} else if is_relay_unavailable_error(msg) {
Some(ProbeFailure::RelayDown)
} else if is_daemon_unavailable_error(msg) {
Some(ProbeFailure::DaemonWedge)
} else {
None
}
}
pub(crate) fn is_daemon_unavailable_code(code: Option<&str>) -> bool {
matches!(code, Some("browser_not_launched"))
}
pub(crate) const fn browser_bin() -> &'static str {
if cfg!(target_os = "windows") {
"chrome-use.exe"
} else {
"chrome-use"
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CliStatus {
Available,
Missing,
Transient(CliProbeFailure),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CliProbeFailure {
Spawn(String),
BadVersion(String),
Timeout,
}
impl std::fmt::Display for CliProbeFailure {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CliProbeFailure::Spawn(reason) => write!(f, "spawn failed ({reason})"),
CliProbeFailure::BadVersion(status) => write!(f, "--version check failed ({status})"),
CliProbeFailure::Timeout => write!(f, "probe timed out"),
}
}
}
pub(crate) const CHROME_USE_INSTALL_HINT: &str = "Install with: curl -fsSL \
https://raw.githubusercontent.com/leeguooooo/chrome-use/main/install.sh | sh";
static CLI_PATH: OnceLock<Mutex<Option<PathBuf>>> = OnceLock::new();
pub(crate) fn cli_path() -> Option<PathBuf> {
let mut cache = CLI_PATH
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_poison();
if let Some(path) = cache.as_ref().filter(|p| crate::util::is_executable(p)) {
return Some(path.clone());
}
let found = find_cli_binary();
cache.clone_from(&found);
found
}
fn find_cli_binary() -> Option<PathBuf> {
let name = browser_bin();
if let Some(paths) = std::env::var_os("PATH") {
for dir in std::env::split_paths(&paths) {
let candidate = dir.join(name);
if crate::util::is_executable(&candidate) {
return Some(candidate);
}
}
}
if !cfg!(target_os = "windows") {
let home = directories::UserDirs::new().map(|d| d.home_dir().to_path_buf());
let literal_cargo_bin = match (std::env::var_os("CARGO_HOME"), home.as_deref()) {
(Some(cargo_home), Some(h)) if !cargo_home.is_empty() => Some(h.join(".cargo/bin")),
_ => None,
};
for base in [
home.as_deref().map(|h| h.join(".local/bin")),
crate::util::cargo_bin_dir(),
literal_cargo_bin,
Some(PathBuf::from("/usr/local/bin")),
Some(PathBuf::from("/opt/homebrew/bin")),
]
.into_iter()
.flatten()
{
let candidate = base.join(name);
if crate::util::is_executable(&candidate) {
return Some(candidate);
}
}
}
None
}
fn classify_spawn_error(e: &std::io::Error) -> CliStatus {
if e.kind() == std::io::ErrorKind::NotFound {
CliStatus::Missing
} else {
debug!("chrome-use CLI probe spawn failed: {e}");
CliStatus::Transient(CliProbeFailure::Spawn(e.to_string()))
}
}
pub(crate) async fn cli_probe() -> CliStatus {
let Some(path) = cli_path() else {
return CliStatus::Missing;
};
let mut cmd = Command::new(&path);
ensure_browser_env(&mut cmd);
cmd.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
let status = match tokio::time::timeout(CLI_TIMEOUT, cmd.status()).await {
Ok(Ok(status)) => status,
Ok(Err(e)) => return classify_spawn_error(&e),
Err(_) => {
debug!("chrome-use CLI probe timed out");
return CliStatus::Transient(CliProbeFailure::Timeout);
}
};
if status.success() {
CliStatus::Available
} else {
debug!("chrome-use CLI probe failed: --version exited with {status}");
CliStatus::Transient(CliProbeFailure::BadVersion(status.to_string()))
}
}
pub(crate) fn ensure_browser_env(cmd: &mut Command) {
if std::env::var_os("HOME").is_none() {
cmd.env("HOME", "/tmp");
}
if std::env::var_os("CHROMIUM_FLAGS").is_none() {
cmd.env(
"CHROMIUM_FLAGS",
"--no-first-run --no-default-browser-check --disable-gpu",
);
}
cmd.env("AGENT_BROWSER_DEFAULT_TIMEOUT", "15000");
cmd.env("AGENT_BROWSER_IDLE_TIMEOUT_MS", "300000");
cmd.env("AGENT_BROWSER_HUMANIZE", "human");
cmd.env("CHROME_USE_NO_UPDATE_CHECK", "1");
cmd.env("AGENT_BROWSER_NO_UPDATE_CHECK", "1");
cmd.env("AGENT_BROWSER_NO_AUTO_RECONNECT", "1");
cmd.env("AGENT_BROWSER_RELAY_REVIVE_SECS", "0");
}
async fn run_cli_bounded(args: &[&str], session: Option<&str>) -> Option<std::process::Output> {
let mut cmd = Command::new(cli_path()?);
ensure_browser_env(&mut cmd);
cmd.args(args).arg("--json");
if let Some(session) = session {
cmd.args(["--session", session]);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::null());
cmd.kill_on_drop(true);
tokio::time::timeout(CLI_TIMEOUT, cmd.output())
.await
.ok()?
.ok()
}
async fn run_cli_json_opt(args: &[&str], session: Option<&str>) -> Result<Value, Option<String>> {
let out = run_cli_bounded(args, session).await.ok_or(None)?;
if !out.status.success() {
return Err(extract_error(&out.stdout));
}
let v: Value = serde_json::from_slice(&out.stdout).map_err(|_| None)?;
if v.get("success").and_then(Value::as_bool) != Some(true) {
return Err(extract_error(&out.stdout));
}
Ok(v)
}
async fn run_cli_json(args: &[&str]) -> Option<Value> {
run_cli_json_opt(args, None).await.ok()
}
async fn service_state() -> Option<ProbeFailure> {
let Some(status) = run_cli_json(&["status"]).await else {
return None;
};
let ext = status.get("data")?.get("extension")?;
if ext.get("hostInstalled").and_then(Value::as_bool) == Some(false) {
return Some(ProbeFailure::NotInstalled);
}
if ext.get("hostHealthy").and_then(Value::as_bool) == Some(false) {
return Some(ProbeFailure::HostBroken);
}
if ext.get("relayUp").and_then(Value::as_bool) == Some(false) {
return Some(if extension_disabled().await {
ProbeFailure::ExtensionDisabled
} else {
ProbeFailure::RelayDown
});
}
None
}
async fn extension_disabled() -> bool {
let Some(status) = run_cli_json(&["extension", "status"]).await else {
return false; };
status
.get("data")
.and_then(|d| d.get("chromeExtension"))
.and_then(|c| c.get("disableReasons"))
.and_then(Value::as_array)
.is_some_and(|reasons| !reasons.is_empty())
}
async fn evaluate_health() -> ProbeOutcome {
match service_state().await {
Some(failure) => ProbeOutcome::Down(failure),
None => ProbeOutcome::Healthy,
}
}
struct SweepTab {
tab_id: String,
target_id: String,
}
pub(crate) async fn sweep_session(name: &str) {
if !is_mahbot_session_name(name) {
warn!(
session = name,
"tab sweep refused: not a mahbot-owned session (user/default/other-agent sessions are never touched)"
);
return;
}
let deadline = Instant::now() + SWEEP_TOTAL_BUDGET;
if let Some(failure) = service_state().await {
debug!(
session = name,
?failure,
"tab sweep skipped — browser service unavailable"
);
return;
}
let mut scratch: Option<String> = None;
let mut stopped = false;
for _round in 1..=SWEEP_MAX_ROUNDS {
if Instant::now() >= deadline {
break;
}
let Some(tabs) = session_tab_list(name, deadline).await else {
return; };
if tabs.is_empty() {
let _ = stop_session_daemon(name, deadline).await;
stopped = true;
scratch = None;
continue;
}
if stopped {
if tabs.len() == 1 && scratch.as_deref() != Some(tabs[0].target_id.as_str()) {
clear_sweep_warn();
let _ = stop_session_daemon(name, deadline).await;
return;
}
stopped = false;
scratch = None; }
if tabs.len() == 1 && scratch.as_deref() == Some(tabs[0].target_id.as_str()) {
let _ = stop_session_daemon(name, deadline).await;
stopped = true;
continue;
}
if scratch.is_none() {
let Some(target_id) = session_tab_new_scratch(name, deadline).await else {
return; };
scratch = Some(target_id);
}
for tab in &tabs {
if tab.target_id == *scratch.as_deref().unwrap_or_default() {
continue; }
if Instant::now() >= deadline {
break;
}
let _ = session_close_tab(name, &tab.tab_id, deadline).await;
}
}
sweep_warn_transition(SweepWarn::Deferred);
}
fn is_mahbot_session_name(name: &str) -> bool {
name.starts_with("link-enricher-")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SweepWarn {
UnreachableTab,
CannotEnumerate,
Deferred,
}
static LAST_SWEEP_WARN: OnceLock<Mutex<Option<SweepWarn>>> = OnceLock::new();
fn sweep_warn_transition(cause: SweepWarn) {
let mut last = LAST_SWEEP_WARN
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_poison();
if *last == Some(cause) {
return;
}
*last = Some(cause);
match cause {
SweepWarn::UnreachableTab => warn!(
"tab sweep: a leftover tab is unreachable (the extension lost its debugger attach; \
about:blank tabs are never re-attached) — close the leftover tab in Chrome to \
unblock this session; the sweep keeps retrying"
),
SweepWarn::CannotEnumerate => warn!(
"tab sweep: cannot enumerate session tabs (relay/daemon unreachable or malformed \
response) — deferring to the next sweep"
),
SweepWarn::Deferred => {
warn!("tab sweep: group not clean within budget — deferring to the next sweep");
}
}
}
fn clear_sweep_warn() {
*LAST_SWEEP_WARN
.get_or_init(|| Mutex::new(None))
.lock()
.unwrap_poison() = None;
}
fn sweep_none_on_cli_error<T>(name: &str, err: Option<&str>) -> Option<T> {
let msg = err.unwrap_or_default();
if is_unreachable_tab_error(msg) {
tracing::debug!(
session = name,
error = msg,
"tab sweep: unreachable-tab detail"
);
sweep_warn_transition(SweepWarn::UnreachableTab);
} else {
sweep_warn_transition(SweepWarn::CannotEnumerate);
}
None
}
async fn session_tab_list(name: &str, deadline: Instant) -> Option<Vec<SweepTab>> {
if Instant::now() >= deadline {
sweep_warn_transition(SweepWarn::Deferred);
return None;
}
let v = match run_session_cli_json(&["tab", "list"], name).await {
Ok(v) => v,
Err(err) => return sweep_none_on_cli_error(name, err.as_deref()),
};
let Some(tabs) = v
.get("data")
.and_then(|d| d.get("tabs"))
.and_then(Value::as_array)
else {
sweep_warn_transition(SweepWarn::CannotEnumerate);
return None;
};
let parsed: Option<Vec<SweepTab>> = tabs
.iter()
.map(|t| {
Some(SweepTab {
tab_id: t.get("tabId")?.as_str()?.to_string(),
target_id: t.get("targetId")?.as_str()?.to_string(),
})
})
.collect();
parsed.or_else(|| {
sweep_warn_transition(SweepWarn::CannotEnumerate);
None
})
}
pub(crate) fn is_unreachable_tab_error(msg: &str) -> bool {
let lower = msg.to_ascii_lowercase();
lower.contains("can no longer be resolved")
|| lower.contains("owns no resolvable tab")
|| lower.contains("no attached tab")
|| lower.contains("its tab is gone")
|| lower.contains("stale session")
|| lower.contains("unknown session")
|| lower.contains("the relay lost it")
}
pub(crate) fn unreachable_tab_message(error: &str) -> String {
format!(
"{error}. The chrome-use extension lost its debugger attach to this tab and never \
re-attaches about:blank tabs — close the leftover tab in Chrome to unblock this \
session (the browser daemon itself is healthy)."
)
}
async fn session_tab_new_scratch(name: &str, deadline: Instant) -> Option<String> {
if Instant::now() >= deadline {
sweep_warn_transition(SweepWarn::Deferred);
return None;
}
let resp = match run_session_cli_json(&["tab", "new"], name).await {
Ok(v) => v,
Err(err) => return sweep_none_on_cli_error(name, err.as_deref()),
};
let Some(tab_id) = resp
.get("data")
.and_then(|d| d.get("tabId"))
.and_then(Value::as_str)
.map(String::from)
else {
sweep_warn_transition(SweepWarn::CannotEnumerate);
return None;
};
let after = session_tab_list(name, deadline).await?; after
.iter()
.find(|t| t.tab_id == tab_id)
.map(|t| t.target_id.clone())
.or_else(|| {
sweep_warn_transition(SweepWarn::CannotEnumerate);
None
})
}
async fn session_close_tab(name: &str, tab_id: &str, deadline: Instant) -> Option<()> {
if Instant::now() >= deadline {
return None;
}
run_session_cli_json(&["close", tab_id], name)
.await
.ok()
.map(|_| ())
}
async fn stop_session_daemon(name: &str, deadline: Instant) -> Option<()> {
if Instant::now() >= deadline {
return None;
}
run_session_cli_json(&["session", "stop"], name)
.await
.ok()
.map(|_| ())
}
async fn run_session_cli_json(args: &[&str], session: &str) -> Result<Value, Option<String>> {
run_cli_json_opt(args, Some(session)).await
}
fn extract_error(stdout: &[u8]) -> Option<String> {
let v: Value = serde_json::from_slice(stdout).unwrap_or_default();
v.get("error")
.and_then(Value::as_str)
.map(String::from)
.filter(|s| !s.is_empty())
}
fn set_health(outcome: ProbeOutcome) {
let mut h = health().lock().unwrap_poison();
h.apply_outcome(outcome, Instant::now(), true);
}
fn set_health_after_restart(outcome: ProbeOutcome) {
let mut h = health().lock().unwrap_poison();
h.apply_outcome(outcome, Instant::now(), false);
}
pub(crate) async fn is_available() -> bool {
let cached = {
let h = health().lock().unwrap_poison();
let ttl = if h.healthy == Some(false) {
UNHEALTHY_TTL
} else {
HEALTH_TTL
};
h.last_probe
.filter(|t| t.elapsed() < ttl)
.map(|_| h.healthy)
};
if let Some(Some(healthy)) = cached {
return healthy;
}
let outcome = evaluate_health().await;
let healthy = outcome.is_healthy();
set_health(outcome);
if !healthy {
wake().notify_one();
}
healthy
}
pub(crate) fn is_advertised() -> bool {
health().lock().unwrap_poison().healthy != Some(false)
}
pub(crate) fn note_unhealthy(error: &str) {
set_health(ProbeOutcome::Down(
classify_failure_text(error).unwrap_or(ProbeFailure::DaemonWedge),
));
wake().notify_one();
}
pub(crate) fn daemon_down_message() -> String {
let h = health().lock().unwrap_poison();
let cause = match h.last_failure {
Some(ProbeFailure::NotInstalled) => {
"The chrome-use extension or native host is not installed — the browser daemon \
cannot run. Enable the chrome-use extension at chrome://extensions (or reinstall \
the chrome-use CLI); health recovers automatically once it is installed."
}
Some(ProbeFailure::HostBroken) => {
"The chrome-use native host launcher is broken — run `chrome-use doctor` (or \
reinstall the chrome-use CLI); health recovers automatically once it is fixed."
}
Some(ProbeFailure::ExtensionDisabled) => {
"The chrome-use extension is disabled — enable it at chrome://extensions. Daemon \
restarts cannot fix a Chrome-side disable; health recovers automatically once \
it is enabled."
}
Some(ProbeFailure::RelayDown) => {
"The chrome-use extension relay is down (the extension itself is enabled). \
Auto-recovery restarts the session daemons and waits for the extension to \
reconnect."
}
Some(ProbeFailure::UnreachableTab) => {
"A browser tab the session was driving is unreachable (the extension lost its \
debugger attach; about:blank tabs are never re-attached) — close the leftover \
tab in Chrome to unblock the session."
}
Some(ProbeFailure::DaemonWedge) | None => {
"The chrome-use browser daemon is down or unresponsive."
}
};
let recovery = if h.halted {
" Auto-recovery exhausted its restart attempts and is in a 30-minute cooldown (thrash \
protection); it will retry after the cooldown."
} else if h.last_failure.is_some_and(ProbeFailure::is_unfixable) {
" Auto-recovery is paused for this cause — no restart will be attempted; it resumes \
automatically once the underlying issue is resolved."
} else {
" Auto-recovery was triggered and will restart it automatically — no manual action is \
needed (note: the restart resets browser sessions)."
};
format!(
"{cause}{recovery} While it's down, use web_search, or shell `curl` for page fetches, \
instead of the browser tool."
)
}
pub async fn run_watchdog() {
let mut cli_present: Option<bool> = None;
let mut last_cli_check = Instant::now();
let mut cli_missing: u32 = 0;
let mut last_transient: Option<CliProbeFailure> = None;
let mut cleaned = false;
let mut woken = false;
loop {
let mut sleep = WATCHDOG_INTERVAL;
let mut skip_health = false;
let cli_due = last_cli_check.elapsed() >= CLI_RECHECK;
if cli_present != Some(true) || cli_due {
last_cli_check = Instant::now();
match cli_probe().await {
CliStatus::Available => {
cli_present = Some(true);
cli_missing = 0;
last_transient = None;
}
CliStatus::Transient(failure) => {
if last_transient.as_ref() != Some(&failure) {
warn!("chrome-use CLI probe transient: {failure}");
last_transient = Some(failure);
}
cli_missing = 0;
}
CliStatus::Missing => {
cli_missing += 1;
last_transient = None;
if cli_missing < CLI_MISSING_THRESHOLD {
cli_present = None;
skip_health = true;
} else {
if cli_present != Some(false) {
cli_present = Some(false);
warn!(
"chrome-use CLI not found — browser daemon watchdog standing down"
);
}
sleep = CLI_RECHECK;
skip_health = true;
}
}
}
}
if !skip_health {
if !cleaned {
cleaned = true;
cleanup_stale_sessions().await;
}
let failure = if woken {
health().lock().unwrap_poison().last_failure
} else {
None
};
if let Some(failure) = failure {
attempt_recovery(failure).await;
} else {
let outcome = evaluate_health().await;
set_health(outcome);
if let ProbeOutcome::Down(failure) = outcome {
attempt_recovery(failure).await;
}
}
}
let shutdown = crate::shutdown::shutdown_token();
woken = tokio::select! {
() = tokio::time::sleep(sleep) => false,
() = wake().notified() => true,
() = shutdown.cancelled() => break,
};
}
}
async fn cleanup_stale_sessions() {
let Some(sessions) = registered_sessions().await else {
return;
};
for name in sessions {
if name.starts_with("link-enricher-") {
sweep_session(&name).await;
}
}
}
async fn registered_sessions() -> Option<Vec<String>> {
let status = run_cli_json(&["status"]).await?;
Some(
status
.get("data")?
.get("sessions")?
.as_array()?
.iter()
.filter_map(|s| s.get("name").and_then(Value::as_str).map(String::from))
.collect(),
)
}
async fn wait_for_relay(budget: Duration) {
let deadline = Instant::now() + budget;
while Instant::now() < deadline {
if relay_up().await == Some(true) {
return;
}
tokio::time::sleep(Duration::from_secs(2)).await;
}
}
async fn relay_up() -> Option<bool> {
let status = run_cli_json(&["status"]).await?;
status
.get("data")?
.get("extension")?
.get("relayUp")
.and_then(Value::as_bool)
}
fn warn_transition(failure: ProbeFailure) {
let mut h = health().lock().unwrap_poison();
if h.last_cause_warned == Some(failure) {
return;
}
h.last_cause_warned = Some(failure);
match failure {
ProbeFailure::NotInstalled => warn!(
"chrome-use extension or native host is not installed — the browser \
daemon cannot run. Enable the chrome-use extension at \
chrome://extensions (or reinstall the chrome-use CLI). Auto-recovery \
paused until it is installed."
),
ProbeFailure::HostBroken => warn!(
"chrome-use native host launcher is broken — run `chrome-use doctor` or \
reinstall the chrome-use CLI. Auto-recovery paused until it is fixed."
),
ProbeFailure::ExtensionDisabled => warn!(
"chrome-use extension is disabled — enable it at chrome://extensions. \
Daemon restarts cannot fix a Chrome-side disable; auto-recovery paused \
until it is enabled."
),
ProbeFailure::RelayDown => warn!(
"chrome-use extension relay is down (the extension is enabled) — waiting \
for the extension to reconnect and restarting session daemons to clear \
stale relay bindings."
),
ProbeFailure::UnreachableTab => warn!(
"a browser tab the session was driving is unreachable (the extension lost its \
debugger attach; about:blank tabs are never re-attached) — close the leftover \
tab in Chrome to unblock the session"
),
ProbeFailure::DaemonWedge => {
warn!("browser daemon is unresponsive — restarting it (bounded backoff).");
}
}
}
async fn attempt_recovery(mut failure: ProbeFailure) {
warn_transition(failure);
if failure.is_unfixable() {
return;
}
let now = Instant::now();
let throttled = {
let h = health().lock().unwrap_poison();
h.next_restart_at.is_some_and(|t| now < t) || h.halted_until.is_some_and(|t| now < t)
};
if throttled {
return;
}
if failure == ProbeFailure::RelayDown {
wait_for_relay(RELAY_REVIVE_WAIT).await;
let outcome = evaluate_health().await;
set_health(outcome);
match outcome {
ProbeOutcome::Healthy => {
info!("browser daemon: relay recovered without a restart");
return;
}
ProbeOutcome::Down(f) => {
warn_transition(f);
if f.is_unfixable() {
return;
}
failure = f;
}
}
}
let gate = {
let mut h = health().lock().unwrap_poison();
h.gate_restart(Instant::now())
};
let RestartGate::Allowed(attempt) = gate else {
match gate {
RestartGate::Halted => error!(
attempts = MAX_RESTART_ATTEMPTS,
"browser daemon: {MAX_RESTART_ATTEMPTS} consecutive failed restarts; \
auto-recovery halted for 30 min (thrash protection)"
),
RestartGate::Backoff => {
debug!("browser daemon: still down; waiting out restart backoff");
}
RestartGate::Cooldown => {
debug!("browser daemon: still down; thrash-protection cooldown in progress");
}
RestartGate::Allowed(_) => unreachable!(),
}
return;
};
info!(
attempt,
max = MAX_RESTART_ATTEMPTS,
"browser daemon: attempting auto-recovery"
);
let _ = run_cli(&["daemon", "restart"]).await;
if failure == ProbeFailure::RelayDown {
wait_for_relay(RELAY_REVIVE_WAIT).await;
}
let outcome = evaluate_health().await;
set_health_after_restart(outcome);
if outcome.is_healthy() {
info!("browser daemon: recovered after restart");
} else {
warn!(
attempt,
"browser daemon: restart attempt did not restore health"
);
}
}
async fn run_cli(args: &[&str]) -> bool {
let Some(path) = cli_path() else {
return false;
};
let mut cmd = Command::new(path);
ensure_browser_env(&mut cmd);
cmd.args(args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
cmd.kill_on_drop(true);
tokio::time::timeout(Duration::from_mins(1), cmd.status())
.await
.is_ok_and(|r| r.is_ok_and(|st| st.success()))
}
#[cfg(test)]
pub(crate) async fn with_health_test_lock() -> tokio::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| tokio::sync::Mutex::new(()))
.lock()
.await
}
#[cfg(test)]
pub(crate) fn reset_health() {
*health().lock().unwrap_poison() = DaemonHealth::default();
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn advertisement_and_availability_reflect_daemon_state() {
let _guard = with_health_test_lock().await;
set_health(ProbeOutcome::Down(ProbeFailure::DaemonWedge));
assert!(!is_advertised());
assert!(!is_available().await);
set_health(ProbeOutcome::Healthy);
assert!(is_advertised());
assert!(is_available().await);
reset_health();
assert!(is_advertised());
}
#[test]
fn sustained_health_resets_restart_attempts() {
let now = Instant::now();
let mut h = DaemonHealth {
restart_attempts: 2,
next_restart_at: Some(now),
halted: true,
halted_until: Some(now),
..DaemonHealth::default()
};
h.apply_outcome(ProbeOutcome::Healthy, now, false);
assert_eq!(h.restart_attempts, 2);
assert!(h.halted);
h.apply_outcome(ProbeOutcome::Healthy, now + WATCHDOG_INTERVAL, true);
assert_eq!(h.restart_attempts, 2);
assert!(h.healthy_since.is_some());
h.apply_outcome(ProbeOutcome::Healthy, now + WATCHDOG_INTERVAL * 2, true);
assert_eq!(h.restart_attempts, 2);
assert!(h.halted);
h.apply_outcome(ProbeOutcome::Healthy, now + WATCHDOG_INTERVAL * 3, true);
assert_eq!(h.restart_attempts, 0);
assert_eq!(h.next_restart_at, None);
assert!(!h.halted);
assert!(h.halted_until.is_none());
assert_eq!(h.last_failure, None);
}
#[test]
fn cause_flapping_and_transient_health_do_not_reset_restart_budget() {
let now = Instant::now();
let mut h = DaemonHealth {
restart_attempts: 2,
next_restart_at: Some(now),
last_failure: Some(ProbeFailure::DaemonWedge),
..DaemonHealth::default()
};
h.apply_outcome(ProbeOutcome::Down(ProbeFailure::RelayDown), now, true);
assert_eq!(h.last_failure, Some(ProbeFailure::RelayDown));
assert_eq!(h.restart_attempts, 2);
assert!(h.next_restart_at.is_some());
h.apply_outcome(ProbeOutcome::Down(ProbeFailure::DaemonWedge), now, true);
h.apply_outcome(ProbeOutcome::Down(ProbeFailure::RelayDown), now, true);
assert_eq!(h.last_failure, Some(ProbeFailure::RelayDown));
assert_eq!(h.restart_attempts, 2);
h.apply_outcome(ProbeOutcome::Healthy, now, true);
assert_eq!(h.restart_attempts, 2);
assert!(h.next_restart_at.is_some());
h.apply_outcome(ProbeOutcome::Healthy, now + SUSTAINED_HEALTHY_WINDOW, true);
assert_eq!(h.last_failure, None);
assert_eq!(h.restart_attempts, 0);
assert_eq!(h.next_restart_at, None);
assert!(!h.halted);
}
#[test]
fn gate_honors_backoff_before_halt() {
let now = Instant::now();
let mut h = DaemonHealth::default();
assert_eq!(h.gate_restart(now), RestartGate::Allowed(1));
assert_eq!(h.gate_restart(now), RestartGate::Backoff);
assert_eq!(
h.gate_restart(now + RESTART_BACKOFF[0]),
RestartGate::Allowed(2)
);
let t2 = now + RESTART_BACKOFF[0] + RESTART_BACKOFF[1];
assert_eq!(h.gate_restart(t2), RestartGate::Allowed(3));
assert_eq!(h.gate_restart(t2), RestartGate::Backoff);
let t3 = t2 + RESTART_BACKOFF[2];
assert_eq!(h.gate_restart(t3), RestartGate::Halted);
assert!(h.halted);
assert_eq!(h.gate_restart(t3), RestartGate::Cooldown);
assert_eq!(h.gate_restart(t3 + HALT_COOLDOWN), RestartGate::Allowed(1));
assert_eq!(h.restart_attempts, 1);
assert!(!h.halted);
}
#[test]
fn unreachable_tab_error_signature_detected() {
for msg in [
"the tab this session was driving can no longer be resolved (it was closed, or a flaky relay dropped it)",
"the tab this command was driving is gone — it may have been closed, or the relay lost it",
"this session owns no resolvable tab in its group. Refusing to run on a tab this session does not drive",
"stale sessionId ... its tab is gone",
"unknown sessionId ...",
"no attached tab ...",
] {
assert!(is_unreachable_tab_error(msg), "should detect: {msg}");
}
for msg in [
"Auto-launch failed: Could not drive your Chrome through the ab-connect extension.",
"the tab this command was driving is gone — it navigated across processes",
"Failed to read: Resource temporarily unavailable (os error 35)",
"chrome-use error: Element not found",
] {
assert!(!is_unreachable_tab_error(msg), "should NOT detect: {msg}");
}
}
#[test]
fn daemon_unavailable_error_signature_detected() {
for msg in [
"Failed to read: Resource temporarily unavailable (os error 35) (after 5 retries - daemon may be busy or unresponsive)",
"Failed to connect: No such file or directory (os error 2) (after 5 retries - daemon may be busy or unresponsive)",
"session unresponsive: no response within 45s",
"Daemon failed to start (socket: /tmp/x.sock)",
"session unresponsive: the stuck '__mahbot_probe' daemon was stopped automatically",
"Failed to connect: the daemon endpoint for session '__mahbot_probe' disappeared (/tmp/x.sock).",
"CDP session is unresponsive after attaching (Connection reset).",
"Auto-launch failed: Could not drive your Chrome through the ab-connect extension.",
] {
assert!(is_daemon_unavailable_error(msg), "should detect: {msg}");
}
for msg in [
"chrome-use error: Element not found",
"chrome-use error: Evaluation error: ReferenceError",
"chrome-use error: Navigation failed",
"Failed to connect to example.com: Connection timed out",
] {
assert!(
!is_daemon_unavailable_error(msg),
"should NOT detect: {msg}"
);
}
}
#[test]
fn relay_unavailable_signature_detected() {
for msg in [
"The chrome-use extension is installed, but its relay isn't connected.",
"Could not drive your Chrome through the ab-connect extension.",
"Chrome relay dropped — reconnecting…",
] {
assert!(is_relay_unavailable_error(msg), "should detect: {msg}");
}
for msg in [
"chrome-use error: Element not found",
"Failed to read: Resource temporarily unavailable (os error 35)",
] {
assert!(!is_relay_unavailable_error(msg), "should NOT detect: {msg}");
}
}
#[test]
fn daemon_unavailable_code_detected() {
assert!(is_daemon_unavailable_code(Some("browser_not_launched")));
assert!(!is_daemon_unavailable_code(Some("connection_failed")));
assert!(!is_daemon_unavailable_code(Some("timeout")));
assert!(!is_daemon_unavailable_code(Some("element_not_found")));
assert!(!is_daemon_unavailable_code(None));
}
#[test]
fn failure_text_classification_is_shared_between_detection_paths() {
assert_eq!(
classify_failure_text(
"Auto-launch failed: Could not drive your Chrome through the ab-connect \
extension. The tab this session was driving can no longer be resolved (it \
was closed, or a flaky relay dropped it)"
),
Some(ProbeFailure::UnreachableTab)
);
assert_eq!(
classify_failure_text(
"Auto-launch failed: Could not drive your Chrome through the ab-connect extension."
),
Some(ProbeFailure::RelayDown)
);
assert_eq!(
classify_failure_text(
"Failed to connect: the daemon endpoint for session '__mahbot_probe' \
disappeared (/tmp/x.sock)."
),
Some(ProbeFailure::DaemonWedge)
);
assert_eq!(
classify_failure_text("chrome-use error: Element not found"),
None
);
}
#[test]
fn spawn_error_classification_distinguishes_missing_from_transient() {
let not_found = std::io::Error::from(std::io::ErrorKind::NotFound);
assert_eq!(classify_spawn_error(¬_found), CliStatus::Missing);
for kind in [
std::io::ErrorKind::WouldBlock, std::io::ErrorKind::OutOfMemory, std::io::ErrorKind::PermissionDenied, std::io::ErrorKind::StorageFull, std::io::ErrorKind::TimedOut,
] {
let err = std::io::Error::from(kind);
assert!(
matches!(
classify_spawn_error(&err),
CliStatus::Transient(CliProbeFailure::Spawn(_))
),
"kind {kind:?} must classify as transient, not missing"
);
}
}
}