Skip to main content

browser_control/
errors.rs

1//! Shared error types.
2
3use thiserror::Error;
4
5/// Typed errors raised from the page-session layer so callers (and tests) can
6/// pattern-match on the failure category — in particular, `TabHung` is the
7/// catch-all for the alive-but-unresponsive renderer case that has no
8/// protocol event signal.
9#[derive(Debug, Error)]
10pub enum SessionError {
11    /// Op exceeded its per-call timeout without a reply and without a crash
12    /// event. The most likely cause is a wedged renderer (service-worker
13    /// suspended page, infinite loop, modal dialog, etc.). The agent should
14    /// treat the tab as unusable until reloaded or recreated.
15    #[error("tab hung: op exceeded {timeout_ms}ms without reply ({hint}) [target={target_id:?} url={url:?}]")]
16    TabHung {
17        target_id: Option<String>,
18        url: Option<String>,
19        timeout_ms: u64,
20        hint: &'static str,
21    },
22    /// Renderer crashed mid-op (observed via `Target.targetCrashed` /
23    /// `Inspector.targetCrashed`). Distinct from `TabHung` so callers can
24    /// distinguish "definitely dead" from "presumed wedged."
25    #[error("tab crashed: {reason} [target={target_id:?}]")]
26    TabCrashed { target_id: String, reason: String },
27    /// `<browser>/<name>` referenced a tab that doesn't exist in the
28    /// `tabs` registry. Agents see this when they reference a tab they
29    /// haven't `tab open`'d yet — the recovery is to open it first.
30    /// Distinct from `TabHung` because the tab was never there to wedge.
31    #[error("no tab `{name}` registered for browser `{browser}` — run `browser-control tab open {browser}/{name}` first")]
32    TabNotFound { browser: String, name: String },
33    /// Protocol-level error indicating the underlying target/session/context
34    /// referenced by the request no longer exists in the browser. Raised by
35    /// the CDP/BiDi client layer when the server returns a recognised
36    /// "gone" code (e.g. `no target with given id`, `no such frame`). The
37    /// recover-once wrappers treat this as a signal to recreate the tab
38    /// and retry; otherwise it would surface as a generic protocol error.
39    #[error("{kind:?} target gone: {details}")]
40    TargetGone { kind: TargetKind, details: String },
41    /// The requested operation cannot run against the currently selected
42    /// browser engine. Used by the Playwright sidecar tools (snapshot,
43    /// click, type, etc.) when the active browser is BiDi (Firefox) —
44    /// Playwright can't drive a user-launched Firefox. The agent's
45    /// recovery is to `browser_select` a Chromium-family browser, or
46    /// to use the engine-agnostic tools (`browser_get_html`,
47    /// `browser_fetch`, `browser_navigate`, etc.) which work on both engines.
48    #[error("tool `{tool}` requires {required_engine} engine; current browser uses {current_engine} ({hint})")]
49    EngineUnsupported {
50        tool: String,
51        required_engine: String,
52        current_engine: String,
53        hint: &'static str,
54    },
55    /// A Playwright sidecar tool failed at the sidecar/CDP connection layer,
56    /// but browser-control's native backend was still able to wake and probe
57    /// the target tab. This is intentionally distinct from `TabHung`: agents
58    /// should not treat it as evidence that the page or app is stuck.
59    #[error(
60        "Playwright sidecar connection failed while running `{tool}` ({method}) against target {target_id:?} url {url:?}: {details}. browser-control reached the tab with native CDP, so this is a Playwright sidecar/CDP attachment failure, not evidence that the page is hung ({hint})"
61    )]
62    SidecarConnectionFailed {
63        tool: String,
64        method: String,
65        target_id: String,
66        url: Option<String>,
67        details: String,
68        hint: &'static str,
69    },
70    /// An element ref (`e12`) was passed that this tab's ref table has
71    /// never handed out. Refs come from `browser_snapshot` / `browser_find`;
72    /// the agent must take one first.
73    #[error(
74        "unknown ref `{element}` for tab {target_id}; call browser_snapshot or browser_find first"
75    )]
76    RefUnknown { element: String, target_id: String },
77    /// A known ref no longer resolves: the page navigated (document token
78    /// changed) or the node was removed. The agent must re-snapshot.
79    #[error("ref `{element}` is stale ({reason}); the page navigated or the element was removed — take a new browser_snapshot")]
80    StaleRef {
81        element: String,
82        target_id: String,
83        reason: &'static str,
84    },
85    /// Native CDP input could not resolve a `backendDOMNodeId` (the node
86    /// left the document). Mapped to [`SessionError::StaleRef`] by the tool
87    /// layer, which knows the agent-facing ref.
88    #[error("DOM node {backend_node_id} no longer exists: {details}")]
89    NodeGone {
90        backend_node_id: u64,
91        details: String,
92    },
93}
94
95/// Substrings in CDP `DOM.*` error messages that mean the referenced
96/// `backendDOMNodeId` no longer belongs to a document.
97pub const CDP_NODE_GONE_NEEDLES: &[&str] = &[
98    "no node with given id",
99    "could not find node",
100    "does not belong to the document",
101    "node is detached",
102];
103
104pub fn is_cdp_node_gone(message: &str) -> bool {
105    let m = message.to_ascii_lowercase();
106    CDP_NODE_GONE_NEEDLES.iter().any(|n| m.contains(n))
107}
108
109/// Which protocol surfaced a `TargetGone`. Useful for diagnostics; the
110/// recovery path treats both kinds identically.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum TargetKind {
113    Cdp,
114    Bidi,
115}
116
117/// Substrings that, when found in a CDP error `message`, indicate the
118/// target/session referenced by the call no longer exists. Centralised so
119/// the client-layer classifier and the recovery-wrapper fallback agree.
120pub const CDP_TARGET_GONE_NEEDLES: &[&str] = &[
121    "no target with given id",
122    "session is gone",
123    "no session with given id",
124    "target closed",
125];
126
127/// Substrings that indicate a BiDi context/frame/session is gone. BiDi
128/// also surfaces context-gone via the dedicated error codes
129/// `no such frame` / `no such context` etc.; we match on message text so
130/// we catch both shapes.
131pub const BIDI_TARGET_GONE_NEEDLES: &[&str] = &[
132    "no such frame",
133    "no such node",
134    "no such context",
135    "invalid session id",
136];
137
138/// Returns true if `message` matches any CDP "target gone" indicator.
139pub fn is_cdp_target_gone(message: &str) -> bool {
140    let m = message.to_ascii_lowercase();
141    CDP_TARGET_GONE_NEEDLES.iter().any(|n| m.contains(n))
142}
143
144/// Returns true if `code` or `message` indicates a BiDi context is gone.
145/// BiDi codes are stable per spec (`no such frame`, etc.), but we also
146/// scan the message in case the code is generic.
147pub fn is_bidi_target_gone(code: &str, message: &str) -> bool {
148    let c = code.to_ascii_lowercase();
149    if BIDI_TARGET_GONE_NEEDLES.iter().any(|n| c.contains(n)) {
150        return true;
151    }
152    let m = message.to_ascii_lowercase();
153    BIDI_TARGET_GONE_NEEDLES.iter().any(|n| m.contains(n))
154}
155
156/// Single shared predicate for "the tab the op targeted is dead; one
157/// recover-and-retry round is appropriate." Used by `with_scratch_recovery`,
158/// `with_named_tab_recovery`, and the origin-bound evaluate helper.
159/// Keeping these in one place avoids the call
160/// sites drifting apart on what counts as recoverable.
161pub fn is_recoverable_tab_failure(err: &anyhow::Error) -> bool {
162    if let Some(se) = err.downcast_ref::<SessionError>() {
163        return matches!(
164            se,
165            SessionError::TabHung { .. }
166                | SessionError::TabCrashed { .. }
167                | SessionError::TargetGone { .. }
168        );
169    }
170    let msg = format!("{err:#}").to_ascii_lowercase();
171    CDP_TARGET_GONE_NEEDLES.iter().any(|n| msg.contains(n))
172        || BIDI_TARGET_GONE_NEEDLES.iter().any(|n| msg.contains(n))
173}