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}
71
72/// Which protocol surfaced a `TargetGone`. Useful for diagnostics; the
73/// recovery path treats both kinds identically.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum TargetKind {
76 Cdp,
77 Bidi,
78}
79
80/// Substrings that, when found in a CDP error `message`, indicate the
81/// target/session referenced by the call no longer exists. Centralised so
82/// the client-layer classifier and the recovery-wrapper fallback agree.
83pub const CDP_TARGET_GONE_NEEDLES: &[&str] = &[
84 "no target with given id",
85 "session is gone",
86 "no session with given id",
87 "target closed",
88];
89
90/// Substrings that indicate a BiDi context/frame/session is gone. BiDi
91/// also surfaces context-gone via the dedicated error codes
92/// `no such frame` / `no such context` etc.; we match on message text so
93/// we catch both shapes.
94pub const BIDI_TARGET_GONE_NEEDLES: &[&str] = &[
95 "no such frame",
96 "no such node",
97 "no such context",
98 "invalid session id",
99];
100
101/// Returns true if `message` matches any CDP "target gone" indicator.
102pub fn is_cdp_target_gone(message: &str) -> bool {
103 let m = message.to_ascii_lowercase();
104 CDP_TARGET_GONE_NEEDLES.iter().any(|n| m.contains(n))
105}
106
107/// Returns true if `code` or `message` indicates a BiDi context is gone.
108/// BiDi codes are stable per spec (`no such frame`, etc.), but we also
109/// scan the message in case the code is generic.
110pub fn is_bidi_target_gone(code: &str, message: &str) -> bool {
111 let c = code.to_ascii_lowercase();
112 if BIDI_TARGET_GONE_NEEDLES.iter().any(|n| c.contains(n)) {
113 return true;
114 }
115 let m = message.to_ascii_lowercase();
116 BIDI_TARGET_GONE_NEEDLES.iter().any(|n| m.contains(n))
117}
118
119/// Single shared predicate for "the tab the op targeted is dead; one
120/// recover-and-retry round is appropriate." Used by `with_scratch_recovery`,
121/// `with_named_tab_recovery`, and the origin-bound evaluate helper.
122/// Keeping these in one place avoids the call
123/// sites drifting apart on what counts as recoverable.
124pub fn is_recoverable_tab_failure(err: &anyhow::Error) -> bool {
125 if let Some(se) = err.downcast_ref::<SessionError>() {
126 return matches!(
127 se,
128 SessionError::TabHung { .. }
129 | SessionError::TabCrashed { .. }
130 | SessionError::TargetGone { .. }
131 );
132 }
133 let msg = format!("{err:#}").to_ascii_lowercase();
134 CDP_TARGET_GONE_NEEDLES.iter().any(|n| msg.contains(n))
135 || BIDI_TARGET_GONE_NEEDLES.iter().any(|n| msg.contains(n))
136}