use crate::browser::config::CHROME_BROWSER_IDLE_TIMEOUT;
use crate::browser::{ConnectionOptions, LaunchOptions};
use crate::dom::{DocumentMetadata, DomTree};
use crate::error::{BrowserError, Result};
use headless_chrome::protocol::cdp::Page;
use headless_chrome::{Browser, Tab};
use serde::de::DeserializeOwned;
use serde_json::Value;
use std::ffi::OsStr;
use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
use std::sync::{Arc, RwLock};
use std::time::{Duration, Instant};
pub(crate) const DEBUG_PORT_START: u16 = 40_000;
pub(crate) const DEBUG_PORT_END: u16 = 59_999;
pub(crate) const ATTACH_PAGE_TARGET_LOST_CODE: &str = "attach_page_target_lost";
pub(crate) const ATTACH_SESSION_PAGE_TARGET_LOSS_KIND: &str = "page_target_lost";
const ATTACH_SESSION_DEGRADED_REASON_PREFIX: &str = "chromewright:attach-session-degraded:";
const ATTACH_SESSION_RECOVERY_HINT: &str = "Run tab_list, then switch_tab to reacquire an active page target. If page actions still fail, reconnect the attach session and rerun snapshot.";
static DEBUG_PORT_COUNTER: AtomicU16 = AtomicU16::new(DEBUG_PORT_START);
fn session_close_result(total_tabs: usize, failures: Vec<String>) -> Result<()> {
if failures.is_empty() {
return Ok(());
}
Err(BrowserError::TabOperationFailed(format!(
"Session close encountered {} error(s) after attempting {} tab(s): {}",
failures.len(),
total_tabs,
failures.join("; ")
)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TabDescriptor {
pub id: String,
pub title: String,
pub url: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ScreenshotMode {
#[default]
Viewport,
FullPage,
}
impl ScreenshotMode {
pub(crate) fn from_legacy_full_page(full_page: bool) -> Self {
if full_page {
Self::FullPage
} else {
Self::Viewport
}
}
fn capture_beyond_viewport(self) -> bool {
matches!(self, Self::FullPage)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ScreenshotScale {
#[default]
Device,
Css,
}
impl ScreenshotScale {
fn viewport_scale(self, device_pixel_ratio: f64) -> f64 {
match self {
Self::Device => 1.0,
Self::Css => 1.0 / sanitize_device_pixel_ratio(device_pixel_ratio),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ScreenshotFormat {
Png,
}
impl ScreenshotFormat {
pub(crate) fn extension(self) -> &'static str {
match self {
Self::Png => "png",
}
}
pub(crate) fn mime_type(self) -> &'static str {
match self {
Self::Png => "image/png",
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct ScreenshotClip {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
impl ScreenshotClip {
fn validate(&self) -> Result<()> {
for (label, value) in [
("x", self.x),
("y", self.y),
("width", self.width),
("height", self.height),
] {
if !value.is_finite() {
return Err(BrowserError::InvalidArgument(format!(
"screenshot clip {label} must be finite"
)));
}
}
if self.x < 0.0 || self.y < 0.0 {
return Err(BrowserError::InvalidArgument(
"screenshot clip origin must be non-negative".to_string(),
));
}
if self.width <= 0.0 || self.height <= 0.0 {
return Err(BrowserError::InvalidArgument(
"screenshot clip width and height must be greater than zero".to_string(),
));
}
Ok(())
}
fn to_viewport(&self, scale: f64) -> Page::Viewport {
Page::Viewport {
x: self.x,
y: self.y,
width: self.width,
height: self.height,
scale,
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize, Default)]
pub struct ScreenshotRequest {
#[serde(default)]
pub mode: ScreenshotMode,
#[serde(default)]
pub scale: ScreenshotScale,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clip: Option<ScreenshotClip>,
}
impl ScreenshotRequest {
pub(crate) fn from_legacy_full_page(full_page: bool) -> Self {
Self {
mode: ScreenshotMode::from_legacy_full_page(full_page),
scale: ScreenshotScale::Device,
tab_id: None,
clip: None,
}
}
pub(crate) fn validate(&self) -> Result<()> {
if let Some(tab_id) = self.tab_id.as_deref() {
if tab_id.trim().is_empty() {
return Err(BrowserError::InvalidArgument(
"screenshot tab_id cannot be empty".to_string(),
));
}
}
if let Some(clip) = self.clip.as_ref() {
clip.validate()?;
}
if self.clip.is_some() && self.mode.capture_beyond_viewport() {
return Err(BrowserError::InvalidArgument(
"full-page screenshots cannot be combined with a clip region".to_string(),
));
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ScreenshotCapture {
pub mode: ScreenshotMode,
pub scale: ScreenshotScale,
pub tab: TabDescriptor,
pub format: ScreenshotFormat,
pub mime_type: &'static str,
pub byte_count: usize,
pub width: u32,
pub height: u32,
pub css_width: f64,
pub css_height: f64,
pub device_pixel_ratio: f64,
pub pixel_scale: f64,
pub clip: Option<ScreenshotClip>,
pub bytes: Vec<u8>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
struct ScreenshotImageMetrics {
css_width: f64,
css_height: f64,
device_pixel_ratio: f64,
}
impl ScreenshotCapture {
fn from_png_bytes(
mode: ScreenshotMode,
scale: ScreenshotScale,
tab: TabDescriptor,
clip: Option<ScreenshotClip>,
metrics: ScreenshotImageMetrics,
bytes: Vec<u8>,
) -> Result<Self> {
let (width, height) = png_dimensions(&bytes)?;
let css_width = sanitize_css_dimension(metrics.css_width, width);
let css_height = sanitize_css_dimension(metrics.css_height, height);
let pixel_scale = infer_pixel_scale(width, height, css_width, css_height);
Ok(Self {
mode,
scale,
tab,
format: ScreenshotFormat::Png,
mime_type: ScreenshotFormat::Png.mime_type(),
byte_count: bytes.len(),
width,
height,
css_width,
css_height,
device_pixel_ratio: sanitize_device_pixel_ratio(metrics.device_pixel_ratio),
pixel_scale,
clip,
bytes,
})
}
}
#[derive(Debug, Clone, Copy, serde::Deserialize)]
struct ScreenshotPageMetrics {
inner_width: f64,
inner_height: f64,
scroll_width: f64,
scroll_height: f64,
device_pixel_ratio: f64,
}
fn screenshot_page_metrics_script() -> &'static str {
r#"JSON.stringify((() => {
const root = document.documentElement;
const body = document.body;
return {
inner_width: Number(window.innerWidth || (root ? root.clientWidth : 0) || 0),
inner_height: Number(window.innerHeight || (root ? root.clientHeight : 0) || 0),
scroll_width: Number(Math.max(
window.innerWidth || 0,
root ? root.scrollWidth : 0,
body ? body.scrollWidth : 0
)),
scroll_height: Number(Math.max(
window.innerHeight || 0,
root ? root.scrollHeight : 0,
body ? body.scrollHeight : 0
)),
device_pixel_ratio: Number(window.devicePixelRatio || 1)
};
})())"#
}
fn decode_browser_json_value<T>(value: Value, context: &str) -> Result<T>
where
T: DeserializeOwned,
{
match value {
Value::String(json) => serde_json::from_str(&json)
.map_err(|e| BrowserError::ScreenshotFailed(format!("{context}: {e}"))),
other => serde_json::from_value(other)
.map_err(|e| BrowserError::ScreenshotFailed(format!("{context}: {e}"))),
}
}
impl ScreenshotPageMetrics {
fn evaluate(tab: &Arc<Tab>) -> Result<Self> {
let evaluation = tab
.evaluate(screenshot_page_metrics_script(), false)
.map_err(|e| BrowserError::ScreenshotFailed(e.to_string()))?;
let value = evaluation.value.ok_or_else(|| {
BrowserError::ScreenshotFailed(
"Screenshot capture could not read page metrics".to_string(),
)
})?;
let metrics: Self =
decode_browser_json_value(value, "Screenshot capture could not decode page metrics")?;
Ok(Self {
inner_width: sanitize_length(metrics.inner_width, 1.0),
inner_height: sanitize_length(metrics.inner_height, 1.0),
scroll_width: sanitize_length(metrics.scroll_width, metrics.inner_width),
scroll_height: sanitize_length(metrics.scroll_height, metrics.inner_height),
device_pixel_ratio: sanitize_device_pixel_ratio(metrics.device_pixel_ratio),
})
}
fn css_size_for(&self, request: &ScreenshotRequest) -> (f64, f64) {
if let Some(clip) = request.clip.as_ref() {
return (clip.width, clip.height);
}
match request.mode {
ScreenshotMode::Viewport => (self.inner_width, self.inner_height),
ScreenshotMode::FullPage => (self.scroll_width, self.scroll_height),
}
}
fn capture_clip_for(&self, request: &ScreenshotRequest) -> Option<ScreenshotClip> {
if let Some(clip) = request.clip.as_ref() {
return Some(clip.clone());
}
match (request.mode, request.scale) {
(ScreenshotMode::Viewport, ScreenshotScale::Css) => Some(ScreenshotClip {
x: 0.0,
y: 0.0,
width: self.inner_width,
height: self.inner_height,
}),
(ScreenshotMode::FullPage, ScreenshotScale::Css) => Some(ScreenshotClip {
x: 0.0,
y: 0.0,
width: self.scroll_width,
height: self.scroll_height,
}),
_ => None,
}
}
}
fn sanitize_device_pixel_ratio(device_pixel_ratio: f64) -> f64 {
if device_pixel_ratio.is_finite() && device_pixel_ratio > 0.0 {
device_pixel_ratio
} else {
1.0
}
}
fn sanitize_length(value: f64, fallback: f64) -> f64 {
if value.is_finite() && value > 0.0 {
value
} else if fallback.is_finite() && fallback > 0.0 {
fallback
} else {
1.0
}
}
fn sanitize_css_dimension(value: f64, image_dimension: u32) -> f64 {
sanitize_length(value, image_dimension as f64)
}
fn infer_pixel_scale(width: u32, height: u32, css_width: f64, css_height: f64) -> f64 {
let width_scale = if css_width > 0.0 {
width as f64 / css_width
} else {
0.0
};
let height_scale = if css_height > 0.0 {
height as f64 / css_height
} else {
0.0
};
if width_scale.is_finite() && width_scale > 0.0 {
width_scale
} else if height_scale.is_finite() && height_scale > 0.0 {
height_scale
} else {
1.0
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub(crate) struct ScriptEvaluation {
pub value: Option<Value>,
pub description: Option<String>,
pub type_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub(crate) struct AttachSessionDegradedDetails {
pub kind: String,
pub operation: String,
pub error: String,
pub recovery_hint: String,
}
impl AttachSessionDegradedDetails {
pub(crate) fn page_target_lost(operation: &str, error: impl Into<String>) -> Self {
Self {
kind: ATTACH_SESSION_PAGE_TARGET_LOSS_KIND.to_string(),
operation: operation.to_string(),
error: error.into(),
recovery_hint: ATTACH_SESSION_RECOVERY_HINT.to_string(),
}
}
pub(crate) fn encode_reason(&self) -> String {
match serde_json::to_string(self) {
Ok(payload) => format!("{ATTACH_SESSION_DEGRADED_REASON_PREFIX}{payload}"),
Err(_) => self.error.clone(),
}
}
pub(crate) fn decode(reason: &str) -> Option<Self> {
let payload = reason.strip_prefix(ATTACH_SESSION_DEGRADED_REASON_PREFIX)?;
serde_json::from_str(payload).ok()
}
pub(crate) fn into_browser_error(self) -> BrowserError {
BrowserError::TabOperationFailed(self.encode_reason())
}
}
pub(crate) trait SessionBackend: Send + Sync {
fn navigate(&self, url: &str) -> Result<()>;
fn wait_for_navigation(&self) -> Result<()>;
fn wait_for_document_ready_with_timeout(&self, timeout: Duration) -> Result<()>;
fn document_metadata(&self) -> Result<DocumentMetadata>;
fn extract_dom(&self) -> Result<DomTree>;
fn extract_dom_for_tab(&self, tab_id: &str) -> Result<DomTree> {
if self.active_tab()?.id == tab_id {
return self.extract_dom();
}
Err(BrowserError::TabOperationFailed(
"This browser backend cannot extract DOM from a specific tab yet".to_string(),
))
}
fn extract_dom_with_prefix(&self, prefix: &str) -> Result<DomTree>;
fn evaluate(&self, script: &str, await_promise: bool) -> Result<ScriptEvaluation>;
fn evaluate_on_tab(
&self,
tab_id: &str,
script: &str,
await_promise: bool,
) -> Result<ScriptEvaluation> {
if self.active_tab()?.id == tab_id {
return self.evaluate(script, await_promise);
}
Err(BrowserError::TabOperationFailed(
"This browser backend cannot evaluate JavaScript in a specific tab yet".to_string(),
))
}
fn capture_screenshot(&self, full_page: bool) -> Result<Vec<u8>>;
fn capture_screenshot_with_request(
&self,
request: &ScreenshotRequest,
) -> Result<ScreenshotCapture> {
request.validate()?;
if request.tab_id.is_some() {
return Err(BrowserError::ScreenshotFailed(
"This browser backend cannot capture screenshots from a specific tab yet"
.to_string(),
));
}
if request.clip.is_some() {
return Err(BrowserError::ScreenshotFailed(
"This browser backend cannot capture clipped screenshots yet".to_string(),
));
}
let bytes = self.capture_screenshot(request.mode.capture_beyond_viewport())?;
let tab = self.active_tab()?;
ScreenshotCapture::from_png_bytes(
request.mode,
request.scale,
tab,
None,
ScreenshotImageMetrics {
css_width: 1.0,
css_height: 1.0,
device_pixel_ratio: 1.0,
},
bytes,
)
}
fn press_key(&self, key: &str) -> Result<()>;
fn list_tabs(&self) -> Result<Vec<TabDescriptor>>;
fn active_tab(&self) -> Result<TabDescriptor>;
fn open_tab(&self, url: &str) -> Result<TabDescriptor>;
fn activate_tab(&self, tab_id: &str) -> Result<()>;
fn close_tab(&self, tab_id: &str, with_unload: bool) -> Result<()>;
fn close(&self) -> Result<()>;
}
pub(crate) fn choose_debug_port() -> u16 {
let span = DEBUG_PORT_END - DEBUG_PORT_START + 1;
let offset = DEBUG_PORT_COUNTER.fetch_add(1, Ordering::Relaxed) % span;
DEBUG_PORT_START + offset
}
pub(crate) fn build_launch_options(
options: LaunchOptions,
) -> headless_chrome::LaunchOptions<'static> {
let mut launch_opts = headless_chrome::LaunchOptions::default();
launch_opts
.ignore_default_args
.push(OsStr::new("--enable-automation"));
launch_opts
.args
.push(OsStr::new("--disable-blink-features=AutomationControlled"));
launch_opts.idle_browser_timeout = CHROME_BROWSER_IDLE_TIMEOUT;
launch_opts.headless = options.headless;
launch_opts.window_size = Some((options.window_width, options.window_height));
launch_opts.port = Some(options.debug_port.unwrap_or_else(choose_debug_port));
launch_opts.sandbox = options.sandbox;
if let Some(path) = options.chrome_path {
launch_opts.path = Some(path);
}
if let Some(dir) = options.user_data_dir {
launch_opts.user_data_dir = Some(dir);
}
launch_opts
}
pub(crate) struct ChromeSessionBackend {
browser: Browser,
attach_mode: bool,
active_tab_hint: RwLock<Option<String>>,
page_target_degraded: AtomicBool,
}
impl ChromeSessionBackend {
pub(crate) fn launch(options: LaunchOptions) -> Result<Self> {
let launch_opts = build_launch_options(options);
let browser =
Browser::new(launch_opts).map_err(|e| BrowserError::LaunchFailed(e.to_string()))?;
let initial_tab = browser
.new_tab()
.map_err(|e| BrowserError::LaunchFailed(format!("Failed to create tab: {}", e)))?;
Ok(Self {
browser,
attach_mode: false,
active_tab_hint: RwLock::new(Some(tab_id(&initial_tab))),
page_target_degraded: AtomicBool::new(false),
})
}
pub(crate) fn connect(options: ConnectionOptions) -> Result<Self> {
let ws_url = options.resolved_ws_url()?;
let browser = Browser::connect_with_timeout(ws_url, CHROME_BROWSER_IDLE_TIMEOUT)
.map_err(|e| BrowserError::ConnectionFailed(e.to_string()))?;
Ok(Self {
browser,
attach_mode: true,
active_tab_hint: RwLock::new(None),
page_target_degraded: AtomicBool::new(false),
})
}
pub(crate) fn active_tab_handle(&self) -> Result<Arc<Tab>> {
if let Some(tab) = self.cached_active_tab()? {
return Ok(tab);
}
let tabs = self.tabs()?;
self.detect_active_tab_from_tabs(&tabs)
}
fn detect_active_tab_from_tabs(&self, tabs: &[Arc<Tab>]) -> Result<Arc<Tab>> {
for tab in tabs {
let result = tab.evaluate(
"document.visibilityState === 'visible' && document.hasFocus()",
false,
);
match result {
Ok(remote_object) => {
if remote_object
.value
.as_ref()
.and_then(|value| value.as_bool())
.unwrap_or(false)
{
self.set_active_tab_hint(Some(tab_id(tab)))?;
return Ok(tab.clone());
}
}
Err(e) => {
log::debug!("Failed to check tab status: {}", e);
}
}
}
for tab in tabs {
let result = tab.evaluate("document.visibilityState === 'visible'", false);
match result {
Ok(remote_object) => {
if remote_object
.value
.as_ref()
.and_then(|value| value.as_bool())
.unwrap_or(false)
{
self.set_active_tab_hint(Some(tab_id(tab)))?;
return Ok(tab.clone());
}
}
Err(_) => continue,
}
}
Err(BrowserError::TabOperationFailed(
"No active tab found".to_string(),
))
}
pub(crate) fn tabs(&self) -> Result<Vec<Arc<Tab>>> {
let tabs = self
.browser
.get_tabs()
.lock()
.map_err(|e| BrowserError::TabOperationFailed(format!("Failed to get tabs: {}", e)))?
.clone();
Ok(tabs)
}
pub(crate) fn activate_real_tab(&self, tab: &Arc<Tab>) -> Result<()> {
tab.activate().map_err(|e| {
BrowserError::TabOperationFailed(format!("Failed to activate tab: {}", e))
})?;
self.set_active_tab_hint(Some(tab_id(tab)))?;
self.mark_page_target_healthy();
Ok(())
}
pub(crate) fn open_real_tab(&self, url: &str) -> Result<Arc<Tab>> {
let tab = self.browser.new_tab().map_err(|e| {
BrowserError::TabOperationFailed(format!("Failed to create tab: {}", e))
})?;
tab.navigate_to(url).map_err(|e| {
BrowserError::NavigationFailed(format!("Failed to navigate to {}: {}", url, e))
})?;
tab.wait_until_navigated().map_err(|e| {
BrowserError::NavigationFailed(format!("Navigation to {} did not complete: {}", url, e))
})?;
self.activate_real_tab(&tab)?;
Ok(tab)
}
fn cached_active_tab(&self) -> Result<Option<Arc<Tab>>> {
let Some(tab_id_hint) = self.active_tab_hint()? else {
return Ok(None);
};
Ok(self
.tabs()?
.into_iter()
.find(|tab| tab_id(tab) == tab_id_hint))
}
fn active_tab_hint(&self) -> Result<Option<String>> {
Ok(self
.active_tab_hint
.read()
.map_err(|e| {
BrowserError::TabOperationFailed(format!("Failed to read active tab hint: {}", e))
})?
.clone())
}
fn set_active_tab_hint(&self, tab_id: Option<String>) -> Result<()> {
*self.active_tab_hint.write().map_err(|e| {
BrowserError::TabOperationFailed(format!("Failed to write active tab hint: {}", e))
})? = tab_id;
Ok(())
}
fn mark_page_target_degraded(&self) {
self.page_target_degraded.store(true, Ordering::Relaxed);
}
fn mark_page_target_healthy(&self) {
self.page_target_degraded.store(false, Ordering::Relaxed);
}
fn page_target_is_degraded(&self) -> bool {
self.page_target_degraded.load(Ordering::Relaxed)
}
fn browser_inventory_available(&self) -> bool {
self.tabs().map(|tabs| !tabs.is_empty()).unwrap_or(false)
}
fn recover_active_tab_handle(&self) -> Result<Arc<Tab>> {
let previous_hint = self.active_tab_hint()?;
self.set_active_tab_hint(None)?;
let tabs = self.tabs()?;
if tabs.is_empty() {
return Err(BrowserError::TabOperationFailed(
"No surviving tabs available for attach-session recovery".to_string(),
));
}
if let Some(previous_hint) = previous_hint.as_deref() {
if let Some(tab) = tabs.iter().find(|tab| tab_id(tab) == previous_hint) {
self.set_active_tab_hint(Some(previous_hint.to_string()))?;
return Ok(tab.clone());
}
}
if let Ok(tab) = self.detect_active_tab_from_tabs(&tabs) {
return Ok(tab);
}
if tabs.len() == 1 {
let tab = tabs[0].clone();
self.set_active_tab_hint(Some(tab_id(&tab)))?;
return Ok(tab);
}
Err(BrowserError::TabOperationFailed(
"Unable to reacquire an active page target from surviving tab inventory".to_string(),
))
}
fn with_active_tab_operation<T, F>(
&self,
operation_name: &'static str,
mut operation: F,
) -> Result<T>
where
F: FnMut(&Arc<Tab>) -> Result<T>,
{
let tab = self.active_tab_handle()?;
let result = match operation(&tab) {
Ok(value) => Ok(value),
Err(error) => {
if !self.attach_mode
|| !is_recoverable_page_target_loss(&error)
|| !self.browser_inventory_available()
{
Err(error)
} else {
match self.recover_active_tab_handle() {
Ok(recovered_tab) => match operation(&recovered_tab) {
Ok(value) => Ok(value),
Err(retry_error) if is_recoverable_page_target_loss(&retry_error) => {
Err(attach_session_page_target_loss(
operation_name,
format!(
"Attached browser session lost its active page target during {operation_name}. One recovery attempt ran, but the page target stayed unavailable: {}",
browser_error_detail(&retry_error)
),
))
}
Err(retry_error) => Err(retry_error),
},
Err(recovery_error) => Err(attach_session_page_target_loss(
operation_name,
format!(
"Attached browser session lost its active page target during {operation_name}. Reacquiring the active page target failed: {}. Original error: {}",
browser_error_detail(&recovery_error),
browser_error_detail(&error)
),
)),
}
}
}
};
match &result {
Ok(_) => self.mark_page_target_healthy(),
Err(BrowserError::TabOperationFailed(reason))
if AttachSessionDegradedDetails::decode(reason).is_some() =>
{
self.mark_page_target_degraded();
}
Err(_) => {}
}
result
}
fn tab_handle_by_id(&self, target_tab_id: &str) -> Result<Arc<Tab>> {
self.tabs()?
.into_iter()
.find(|tab| tab_id(tab) == target_tab_id)
.ok_or_else(|| {
BrowserError::TabOperationFailed(format!("No tab found for id {}", target_tab_id))
})
}
fn with_specific_tab_operation<T, F>(
&self,
operation_name: &'static str,
target_tab_id: &str,
mut operation: F,
) -> Result<T>
where
F: FnMut(&Arc<Tab>) -> Result<T>,
{
let tab = self.tab_handle_by_id(target_tab_id)?;
match operation(&tab) {
Ok(value) => Ok(value),
Err(error)
if self.attach_mode
&& is_recoverable_page_target_loss(&error)
&& self.browser_inventory_available() =>
{
let recovered_tab = self.tab_handle_by_id(target_tab_id).map_err(|recovery_error| {
attach_session_page_target_loss(
operation_name,
format!(
"Attached browser session lost tab {target_tab_id} during {operation_name}. Reacquiring the same tab target failed: {}. Original error: {}",
browser_error_detail(&recovery_error),
browser_error_detail(&error)
),
)
})?;
match operation(&recovered_tab) {
Ok(value) => Ok(value),
Err(retry_error) if is_recoverable_page_target_loss(&retry_error) => {
Err(attach_session_page_target_loss(
operation_name,
format!(
"Attached browser session lost tab {target_tab_id} during {operation_name}. One recovery attempt ran, but the tab target stayed unavailable: {}",
browser_error_detail(&retry_error)
),
))
}
Err(retry_error) => Err(retry_error),
}
}
Err(error) => Err(error),
}
}
fn document_ready_state_for_tab(&self, tab: &Arc<Tab>) -> Result<String> {
let result = tab.evaluate("document.readyState", false).map_err(|e| {
BrowserError::NavigationFailed(format!("Failed to read readyState: {}", e))
})?;
result
.value
.and_then(|value| value.as_str().map(str::to_string))
.ok_or_else(|| {
BrowserError::NavigationFailed(
"Browser did not return a document.readyState value".to_string(),
)
})
}
fn wait_for_document_ready_with_tab(&self, tab: &Arc<Tab>, timeout: Duration) -> Result<()> {
let start = Instant::now();
loop {
let ready_state = self.document_ready_state_for_tab(tab)?;
if ready_state == "complete" {
return Ok(());
}
if start.elapsed() >= timeout {
return Err(BrowserError::Timeout(format!(
"Document did not reach readyState=complete within {} ms",
timeout.as_millis()
)));
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
fn attach_session_page_target_loss(operation_name: &str, detail: String) -> BrowserError {
AttachSessionDegradedDetails::page_target_lost(operation_name, detail).into_browser_error()
}
fn browser_error_detail(error: &BrowserError) -> String {
match error {
BrowserError::LaunchFailed(reason)
| BrowserError::ConnectionFailed(reason)
| BrowserError::Timeout(reason)
| BrowserError::SelectorInvalid(reason)
| BrowserError::ElementNotFound(reason)
| BrowserError::DomParseFailed(reason)
| BrowserError::InvalidArgument(reason)
| BrowserError::NavigationFailed(reason)
| BrowserError::EvaluationFailed(reason)
| BrowserError::ScreenshotFailed(reason)
| BrowserError::DownloadFailed(reason)
| BrowserError::TabOperationFailed(reason)
| BrowserError::ChromeError(reason) => reason.clone(),
BrowserError::ToolExecutionFailed { reason, .. } => reason.clone(),
BrowserError::JsonError(error) => error.to_string(),
BrowserError::IoError(error) => error.to_string(),
}
}
fn is_recoverable_page_target_loss(error: &BrowserError) -> bool {
let reason = browser_error_detail(error).to_ascii_lowercase();
[
"underlying connection is closed",
"connection is closed",
"connection closed",
"session closed. most likely the page has been closed",
"target closed",
]
.iter()
.any(|fragment| reason.contains(fragment))
}
impl SessionBackend for ChromeSessionBackend {
fn navigate(&self, url: &str) -> Result<()> {
self.with_active_tab_operation("navigate", |tab| {
tab.navigate_to(url).map_err(|e| {
BrowserError::NavigationFailed(format!("Failed to navigate to {}: {}", url, e))
})?;
Ok(())
})
}
fn wait_for_navigation(&self) -> Result<()> {
self.with_active_tab_operation("wait_for_navigation", |tab| {
tab.wait_until_navigated().map_err(|e| {
BrowserError::NavigationFailed(format!("Navigation timeout: {}", e))
})?;
self.wait_for_document_ready_with_tab(tab, Duration::from_secs(30))
})
}
fn wait_for_document_ready_with_timeout(&self, timeout: Duration) -> Result<()> {
self.with_active_tab_operation("wait_for_document_ready", |tab| {
self.wait_for_document_ready_with_tab(tab, timeout)
})
}
fn document_metadata(&self) -> Result<DocumentMetadata> {
self.with_active_tab_operation("document_metadata", DocumentMetadata::from_tab)
}
fn extract_dom(&self) -> Result<DomTree> {
self.with_active_tab_operation("extract_dom", DomTree::from_tab)
}
fn extract_dom_for_tab(&self, tab_id: &str) -> Result<DomTree> {
self.with_specific_tab_operation("extract_dom", tab_id, DomTree::from_tab)
}
fn extract_dom_with_prefix(&self, prefix: &str) -> Result<DomTree> {
self.with_active_tab_operation("extract_dom", |tab| {
DomTree::from_tab_with_prefix(tab, prefix)
})
}
fn evaluate(&self, script: &str, await_promise: bool) -> Result<ScriptEvaluation> {
let result = self.with_active_tab_operation("evaluate", |tab| {
tab.evaluate(script, await_promise)
.map_err(|e| BrowserError::EvaluationFailed(e.to_string()))
})?;
Ok(ScriptEvaluation {
value: result.value,
description: result.description,
type_name: Some(format!("{:?}", result.Type)),
})
}
fn evaluate_on_tab(
&self,
tab_id: &str,
script: &str,
await_promise: bool,
) -> Result<ScriptEvaluation> {
let result = self.with_specific_tab_operation("evaluate", tab_id, |tab| {
tab.evaluate(script, await_promise)
.map_err(|e| BrowserError::EvaluationFailed(e.to_string()))
})?;
Ok(ScriptEvaluation {
value: result.value,
description: result.description,
type_name: Some(format!("{:?}", result.Type)),
})
}
fn capture_screenshot(&self, full_page: bool) -> Result<Vec<u8>> {
let request = ScreenshotRequest::from_legacy_full_page(full_page);
Ok(self.capture_screenshot_with_request(&request)?.bytes)
}
fn capture_screenshot_with_request(
&self,
request: &ScreenshotRequest,
) -> Result<ScreenshotCapture> {
request.validate()?;
let capture_from_tab = |tab: &Arc<Tab>| -> Result<ScreenshotCapture> {
let metrics = ScreenshotPageMetrics::evaluate(tab)?;
let resolved_clip = metrics.capture_clip_for(request);
let (css_width, css_height) = metrics.css_size_for(request);
let clip = resolved_clip.as_ref().map(|clip| {
clip.to_viewport(request.scale.viewport_scale(metrics.device_pixel_ratio))
});
let data = tab
.call_method(Page::CaptureScreenshot {
format: Some(Page::CaptureScreenshotFormatOption::Png),
quality: None,
clip,
from_surface: Some(true),
capture_beyond_viewport: Some(request.mode.capture_beyond_viewport()),
optimize_for_speed: None,
})
.map_err(|e| BrowserError::ScreenshotFailed(e.to_string()))?
.data;
let bytes = decode_base64_standard(&data).map_err(BrowserError::ScreenshotFailed)?;
ScreenshotCapture::from_png_bytes(
request.mode,
request.scale,
descriptor_for_tab(tab),
resolved_clip,
ScreenshotImageMetrics {
css_width,
css_height,
device_pixel_ratio: metrics.device_pixel_ratio,
},
bytes,
)
};
match request.tab_id.as_deref() {
Some(tab_id) => {
self.with_specific_tab_operation("capture_screenshot", tab_id, capture_from_tab)
}
None => self.with_active_tab_operation("capture_screenshot", capture_from_tab),
}
}
fn press_key(&self, key: &str) -> Result<()> {
self.with_active_tab_operation("press_key", |tab| {
tab.press_key(key)
.map_err(|e| BrowserError::ToolExecutionFailed {
tool: "press_key".to_string(),
reason: e.to_string(),
})?;
Ok(())
})
}
fn list_tabs(&self) -> Result<Vec<TabDescriptor>> {
Ok(self.tabs()?.iter().map(descriptor_for_tab).collect())
}
fn active_tab(&self) -> Result<TabDescriptor> {
if self.attach_mode && self.page_target_is_degraded() {
return Err(attach_session_page_target_loss(
"active_tab",
"Active tab metadata is available, but DOM-backed page access is degraded until the attached session is recovered".to_string(),
));
}
Ok(descriptor_for_tab(&self.active_tab_handle()?))
}
fn open_tab(&self, url: &str) -> Result<TabDescriptor> {
Ok(descriptor_for_tab(&self.open_real_tab(url)?))
}
fn activate_tab(&self, target_tab_id: &str) -> Result<()> {
let tab = self
.tabs()?
.into_iter()
.find(|tab| tab_id(tab) == target_tab_id)
.ok_or_else(|| {
BrowserError::TabOperationFailed(format!("No tab found for id {}", target_tab_id))
})?;
self.activate_real_tab(&tab)
}
fn close_tab(&self, target_tab_id: &str, with_unload: bool) -> Result<()> {
let tab = self
.tabs()?
.into_iter()
.find(|tab| tab_id(tab) == target_tab_id)
.ok_or_else(|| {
BrowserError::TabOperationFailed(format!("No tab found for id {}", target_tab_id))
})?;
if self.active_tab_hint()?.as_deref() == Some(target_tab_id) {
self.set_active_tab_hint(None)?;
}
tab.close(with_unload)
.map_err(|e| BrowserError::TabOperationFailed(format!("Failed to close tab: {}", e)))
.map(|_| ())
}
fn close(&self) -> Result<()> {
let tabs = self.tabs()?;
let total_tabs = tabs.len();
let mut failures = Vec::new();
for tab in tabs {
let descriptor = descriptor_for_tab(&tab);
if let Err(err) = tab.close(false) {
failures.push(format!(
"failed to close '{}' ({}) [id={}]: {}",
descriptor.title, descriptor.url, descriptor.id, err
));
}
}
if let Err(err) = self.set_active_tab_hint(None) {
failures.push(format!("failed to clear active tab hint: {}", err));
}
self.mark_page_target_healthy();
session_close_result(total_tabs, failures)
}
}
fn png_dimensions(bytes: &[u8]) -> Result<(u32, u32)> {
const PNG_SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n";
if bytes.len() < 24 || &bytes[..8] != PNG_SIGNATURE {
return Err(BrowserError::ScreenshotFailed(
"Browser returned invalid PNG data".to_string(),
));
}
let width = u32::from_be_bytes(bytes[16..20].try_into().map_err(|_| {
BrowserError::ScreenshotFailed("PNG width header was truncated".to_string())
})?);
let height = u32::from_be_bytes(bytes[20..24].try_into().map_err(|_| {
BrowserError::ScreenshotFailed("PNG height header was truncated".to_string())
})?);
Ok((width, height))
}
fn decode_base64_standard(data: &str) -> std::result::Result<Vec<u8>, String> {
fn value(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a' + 26),
b'0'..=b'9' => Some(byte - b'0' + 52),
b'+' => Some(62),
b'/' => Some(63),
_ => None,
}
}
let filtered = data
.bytes()
.filter(|byte| !matches!(byte, b' ' | b'\n' | b'\r' | b'\t'))
.collect::<Vec<_>>();
if filtered.len() % 4 != 0 {
return Err("Screenshot response contained invalid base64 length".to_string());
}
let mut decoded = Vec::with_capacity(filtered.len() / 4 * 3);
for chunk in filtered.chunks_exact(4) {
let mut values = [0u8; 4];
let mut padding = 0usize;
for (index, byte) in chunk.iter().copied().enumerate() {
match byte {
b'=' => {
values[index] = 0;
padding += 1;
}
_ => {
values[index] = value(byte).ok_or_else(|| {
format!("Screenshot response contained invalid base64 byte 0x{byte:02x}")
})?;
}
}
}
decoded.push((values[0] << 2) | (values[1] >> 4));
if padding < 2 {
decoded.push((values[1] << 4) | (values[2] >> 2));
}
if padding == 0 {
decoded.push((values[2] << 6) | values[3]);
}
}
Ok(decoded)
}
fn tab_id(tab: &Arc<Tab>) -> String {
tab.get_target_id().to_string()
}
fn descriptor_for_tab(tab: &Arc<Tab>) -> TabDescriptor {
TabDescriptor {
id: tab_id(tab),
title: tab.get_title().unwrap_or_default(),
url: tab.get_url(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
fn simulate_attach_recovery<T, Op, Recover>(
attach_mode: bool,
operation_name: &'static str,
mut operation: Op,
mut recover_and_retry: Recover,
inventory_available: bool,
) -> Result<T>
where
Op: FnMut() -> Result<T>,
Recover: FnMut() -> Result<T>,
{
match operation() {
Ok(value) => Ok(value),
Err(error) => {
if !attach_mode || !is_recoverable_page_target_loss(&error) || !inventory_available
{
return Err(error);
}
match recover_and_retry() {
Ok(value) => Ok(value),
Err(retry_error) if is_recoverable_page_target_loss(&retry_error) => {
Err(attach_session_page_target_loss(
operation_name,
format!(
"Attached browser session lost its active page target during {operation_name}. One recovery attempt ran, but the page target stayed unavailable: {}",
browser_error_detail(&retry_error)
),
))
}
Err(retry_error) => Err(retry_error),
}
}
}
}
fn closed_connection_error() -> BrowserError {
BrowserError::EvaluationFailed(
"Unable to make method calls because underlying connection is closed".to_string(),
)
}
#[test]
fn screenshot_page_metrics_script_returns_a_json_string_expression() {
let script = screenshot_page_metrics_script().trim_start();
assert!(script.starts_with("JSON.stringify(("));
assert!(script.contains("device_pixel_ratio"));
}
#[test]
fn screenshot_page_metrics_decode_accepts_json_string_payloads() {
let metrics: ScreenshotPageMetrics = decode_browser_json_value(
serde_json::json!(
"{\"inner_width\":1016,\"inner_height\":568,\"scroll_width\":1016,\"scroll_height\":11915,\"device_pixel_ratio\":2}"
),
"Screenshot capture could not decode page metrics",
)
.expect("json string payload should decode");
assert_eq!(metrics.inner_width, 1016.0);
assert_eq!(metrics.inner_height, 568.0);
assert_eq!(metrics.scroll_width, 1016.0);
assert_eq!(metrics.scroll_height, 11915.0);
assert_eq!(metrics.device_pixel_ratio, 2.0);
}
#[test]
fn screenshot_page_metrics_decode_preserves_object_payload_support() {
let metrics: ScreenshotPageMetrics = decode_browser_json_value(
serde_json::json!({
"inner_width": 800,
"inner_height": 600,
"scroll_width": 1200,
"scroll_height": 2400,
"device_pixel_ratio": 1.5
}),
"Screenshot capture could not decode page metrics",
)
.expect("object payload should decode");
assert_eq!(metrics.inner_width, 800.0);
assert_eq!(metrics.inner_height, 600.0);
assert_eq!(metrics.scroll_width, 1200.0);
assert_eq!(metrics.scroll_height, 2400.0);
assert_eq!(metrics.device_pixel_ratio, 1.5);
}
#[test]
fn attach_recovery_retries_once_for_recoverable_page_target_loss() {
let attempts = Cell::new(0usize);
let recoveries = Cell::new(0usize);
let result = simulate_attach_recovery(
true,
"snapshot",
|| {
let attempt = attempts.get() + 1;
attempts.set(attempt);
if attempt == 1 {
Err(closed_connection_error())
} else {
Ok("ok")
}
},
|| {
recoveries.set(recoveries.get() + 1);
attempts.set(attempts.get() + 1);
Ok("ok")
},
true,
);
assert_eq!(result.expect("retry should recover"), "ok");
assert_eq!(attempts.get(), 2);
assert_eq!(recoveries.get(), 1);
}
#[test]
fn attach_recovery_surfaces_structured_degraded_error_after_single_retry() {
let attempts = Cell::new(0usize);
let recoveries = Cell::new(0usize);
let result = simulate_attach_recovery(
true,
"snapshot",
|| {
attempts.set(attempts.get() + 1);
Err::<(), _>(closed_connection_error())
},
|| {
recoveries.set(recoveries.get() + 1);
attempts.set(attempts.get() + 1);
Err::<(), _>(closed_connection_error())
},
true,
)
.expect_err("persistent page-target loss should surface a degraded attach-session error");
assert_eq!(attempts.get(), 2);
assert_eq!(recoveries.get(), 1);
let BrowserError::TabOperationFailed(reason) = result else {
panic!("expected degraded attach-session tab error, got {result:?}");
};
let details = AttachSessionDegradedDetails::decode(&reason)
.expect("degraded attach-session error should be encoded");
assert_eq!(details.kind, ATTACH_SESSION_PAGE_TARGET_LOSS_KIND);
assert_eq!(details.operation, "snapshot");
assert!(details.error.contains("One recovery attempt ran"));
}
#[test]
fn attach_recovery_does_not_retry_when_tab_inventory_is_gone() {
let attempts = Cell::new(0usize);
let recoveries = Cell::new(0usize);
let result = simulate_attach_recovery(
true,
"snapshot",
|| {
attempts.set(attempts.get() + 1);
Err::<(), _>(closed_connection_error())
},
|| {
recoveries.set(recoveries.get() + 1);
attempts.set(attempts.get() + 1);
Ok(())
},
false,
)
.expect_err("without surviving inventory the original error should bubble");
assert_eq!(attempts.get(), 1);
assert_eq!(recoveries.get(), 0);
assert!(matches!(result, BrowserError::EvaluationFailed(_)));
}
#[test]
fn attach_session_degraded_details_round_trip_through_reason_encoding() {
let details = AttachSessionDegradedDetails::page_target_lost(
"evaluate",
"Attached browser session lost its active page target".to_string(),
);
let decoded = AttachSessionDegradedDetails::decode(&details.encode_reason())
.expect("encoded degraded-session details should round-trip");
assert_eq!(decoded, details);
}
#[test]
fn screenshot_request_rejects_empty_tab_id() {
let request = ScreenshotRequest {
mode: ScreenshotMode::Viewport,
scale: ScreenshotScale::Device,
tab_id: Some(" ".to_string()),
clip: None,
};
let err = request
.validate()
.expect_err("empty tab ids should be rejected");
assert!(matches!(err, BrowserError::InvalidArgument(_)));
}
#[test]
fn screenshot_request_rejects_full_page_clip_combination() {
let request = ScreenshotRequest {
mode: ScreenshotMode::FullPage,
scale: ScreenshotScale::Device,
tab_id: None,
clip: Some(ScreenshotClip {
x: 0.0,
y: 0.0,
width: 100.0,
height: 80.0,
}),
};
let err = request
.validate()
.expect_err("full-page clipped screenshots should be rejected");
assert!(matches!(err, BrowserError::InvalidArgument(_)));
}
#[test]
fn png_dimensions_reads_fake_backend_png_header() {
let bytes = vec![
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1,
8, 6, 0, 0, 0, 31, 21, 196, 137,
];
let (width, height) = png_dimensions(&bytes).expect("header should parse");
assert_eq!((width, height), (1, 1));
}
#[test]
fn decode_base64_standard_decodes_png_signature() {
let decoded = decode_base64_standard("iVBORw0KGgo=").expect("base64 should decode");
assert_eq!(decoded, b"\x89PNG\r\n\x1a\n");
}
}
#[cfg(test)]
mod fake;
#[cfg(test)]
pub(crate) use fake::FakeSessionBackend;