tui: Clear stale cached Chrome WS URL and retry via port discovery when WS connect fails (handles 404 after Chrome restart).\n\n- Prefer cached WS; on failure, clear WS cache (in-memory + disk), fallback to cached port or auto-scan, and retry once.\n- Emit clear success/failure background events; persist new connection on success.\n\nbrowser(page.rs): Inline rationale and comments for screenshot strategy to avoid flash and reduce timeouts.\n\n- Prefer from_surface(false) for visible pages; conservative retries.\n- Non-visible: fast 8×8 preflight probe (350ms) cached ~5s to choose path.\n- Final from_surface(true) fallback only as last resort.\n\nDocs: Remove external doc; comments now self-contained.

This commit is contained in:
James Peter
2025-08-20 20:32:50 +10:00
parent fb08c8445d
commit 951679486a
2 changed files with 363 additions and 39 deletions
+168 -29
View File
@@ -18,7 +18,7 @@ use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotFormat;
use chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotParams;
use chromiumoxide::page::Page as CdpPage;
use std::sync::Arc;
use std::time::Duration;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
// Use Mutex for cursor state (New)
use tokio::sync::Mutex;
@@ -46,6 +46,15 @@ pub struct Page {
current_url: Arc<RwLock<Option<String>>>,
// Add cursor state tracking (New)
cursor_state: Arc<Mutex<CursorState>>,
// Screenshot path preflight cache:
// - We strongly prefer compositor captures via from_surface(false) to avoid visible flashes in the
// user's real Chrome window. However, that path can be flaky or unavailable when the window is not
// visible/minimized. A tiny 8×8 probe (guarded by a ~350ms timeout) predicts viability and is cached
// for ~5 seconds to avoid repeated probes while navigating.
// - IMPORTANT: This cache and probe logic protect both UX (no flash on visible windows) and reliability
// (preventing repeated long timeouts when minimized). If you change this, ensure visible windows never
// start with from_surface(true), and keep a short/cheap probe for hidden/minimized states.
preflight_cache: Arc<Mutex<Option<(Instant, bool)>>>,
}
impl Page {
@@ -63,6 +72,7 @@ impl Page {
config,
current_url: Arc::new(RwLock::new(None)),
cursor_state: Arc::new(Mutex::new(initial_cursor)),
preflight_cache: Arc::new(Mutex::new(None)),
};
// Register a unified bootstrap (runs on every new document):
@@ -345,13 +355,20 @@ impl Page {
}
/// Helper function to capture screenshot with retry logic.
/// First tries with from_surface(false) to avoid flashing.
/// If that fails, retries with from_surface(true) for when window is not visible.
/// Strategy summary (critical to UX and reliability):
/// - Visible pages: Start with from_surface(false) (no-flash path). If it fails once, retry false quickly.
/// Only as a last resort use from_surface(true), because it can flash a visible window.
/// - Non-visible pages: Use a fast 8×8 preflight (false) to decide. If compositor is unavailable, start
/// with from_surface(true) immediately (safe when not visible). Fallbacks stay conservative.
/// - Final fallback: If two attempts with false fail even while visible, we try true once rather than
/// failing entirely. This prevents chronic timeouts; the flash trade-off is acceptable as a last resort.
/// Do not loosen these guarantees casually; they were tuned to balance reliability and no-flash UX.
async fn capture_screenshot_with_retry(
&self,
params_builder: chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotParamsBuilder,
) -> Result<chromiumoxide::cdp::browser_protocol::page::CaptureScreenshotReturns> {
// Determine page visibility once, to decide if from_surface(true) is safe/necessary
// Determine page visibility once to decide if from_surface(true) is safe/necessary.
// If this check fails, assume visible to avoid accidentally picking the flashing path.
let is_visible = {
let eval = self
.cdp_page
@@ -373,23 +390,76 @@ impl Page {
}
};
// First try with from_surface(false) to avoid flashing, with timeout
let params = params_builder.clone().from_surface(false).build();
let first_attempt = tokio::time::timeout(
tokio::time::Duration::from_secs(8),
self.cdp_page.execute(params),
)
.await;
// Preflight probe (non-visible only):
// - A very fast 8×8 clip via from_surface(false) predicts if the compositor path is currently viable.
// - Only run when page is not visible (no flash risk) and cache the result for ~5s.
// This avoids long timeouts on minimized windows without reintroducing flash for visible ones.
let mut prefer_false = true;
if !is_visible {
let now = Instant::now();
{
let mut cache = self.preflight_cache.lock().await;
if let Some((ts, ok)) = *cache {
if now.duration_since(ts) < Duration::from_secs(5) {
prefer_false = ok;
} else {
*cache = None;
}
}
}
if prefer_false {
let cached = {
let cache = self.preflight_cache.lock().await;
cache.is_some()
};
if !cached {
let probe_params = params_builder
.clone()
.from_surface(false)
.capture_beyond_viewport(true)
.clip(chromiumoxide::cdp::browser_protocol::page::Viewport {
x: 0.0,
y: 0.0,
width: 8.0,
height: 8.0,
scale: 1.0,
})
.build();
let probe = tokio::time::timeout(Duration::from_millis(350), self.cdp_page.execute(probe_params)).await;
let ok = matches!(probe, Ok(Ok(_)));
let mut cache = self.preflight_cache.lock().await;
*cache = Some((Instant::now(), ok));
prefer_false = ok;
if !prefer_false {
debug!("Preflight suggests compositor path unavailable; non-visible context will use from_surface(true)");
}
}
}
}
// First attempt policy:
// - Visible: Always start with from_surface(false) and a short timeout to minimize flash risk.
// - Not visible: Use preflight outcome; allow from_surface(true) immediately when compositor is unavailable.
let (first_params, first_timeout, first_is_false) = if is_visible {
(params_builder.clone().from_surface(false).build(), Duration::from_secs(3), true)
} else if prefer_false {
(params_builder.clone().from_surface(false).build(), Duration::from_secs(6), true)
} else {
(params_builder.clone().from_surface(true).build(), Duration::from_secs(6), false)
};
let first_attempt = tokio::time::timeout(first_timeout, self.cdp_page.execute(first_params)).await;
match first_attempt {
Ok(Ok(resp)) => Ok(resp.result),
Ok(Err(e)) => {
debug!(
"Screenshot with from_surface(false) failed: {} (visible={})",
e, is_visible
"Screenshot first attempt failed (used_false={}): {} (visible={})",
first_is_false, e, is_visible
);
if !is_visible {
// Retry with from_surface(true) ONLY when not visible (prevents flash when focused)
if !is_visible || !first_is_false {
// Non-visible or already tried true path: retry with from_surface(true).
// Safe for minimized/hidden windows and avoids repeated long timeouts.
let retry_params = params_builder.from_surface(true).build();
let retry_attempt = tokio::time::timeout(
tokio::time::Duration::from_secs(8),
@@ -405,7 +475,7 @@ impl Page {
)),
}
} else {
// Visible: avoid from_surface(true). Brief wait and retry once with from_surface(false)
// Visible: avoid from_surface(true) if at all possible. Brief wait and retry once with false.
tokio::time::sleep(tokio::time::Duration::from_millis(120)).await;
let retry_params = params_builder.clone().from_surface(false).build();
let retry_attempt = tokio::time::timeout(
@@ -415,20 +485,55 @@ impl Page {
.await;
match retry_attempt {
Ok(Ok(resp)) => Ok(resp.result),
Ok(Err(err2)) => Err(err2.into()),
Err(_) => Err(BrowserError::ScreenshotError(
"Screenshot timed out (from_surface=false, retried)".to_string(),
)),
Ok(Err(err2)) => {
// Last resort for visible pages: try from_surface(true).
// This can flash; we only do it after exhausting the safer path to prevent permanent failures.
debug!(
"Retry with from_surface(false) failed while visible; attempting from_surface(true) as fallback"
);
let final_params = params_builder.from_surface(true).build();
let final_attempt = tokio::time::timeout(
tokio::time::Duration::from_secs(4),
self.cdp_page.execute(final_params),
)
.await;
match final_attempt {
Ok(Ok(resp)) => Ok(resp.result),
Ok(Err(e3)) => Err(e3.into()),
Err(_) => Err(BrowserError::ScreenshotError(
"Screenshot timed out (final from_surface=true fallback)".to_string(),
)),
}
}
Err(_) => {
// Timeout on second false attempt; try true once as last resort (may flash)
debug!(
"Retry with from_surface(false) timed out while visible; attempting from_surface(true) as fallback"
);
let final_params = params_builder.from_surface(true).build();
let final_attempt = tokio::time::timeout(
tokio::time::Duration::from_secs(4),
self.cdp_page.execute(final_params),
)
.await;
match final_attempt {
Ok(Ok(resp)) => Ok(resp.result),
Ok(Err(e3)) => Err(e3.into()),
Err(_) => Err(BrowserError::ScreenshotError(
"Screenshot timed out after retries (from_surface=true fallback)".to_string(),
)),
}
}
}
}
}
Err(_) => {
debug!(
"Screenshot with from_surface(false) timed out (visible={})",
is_visible
"Screenshot first attempt timed out (used_false={}, visible={})",
first_is_false, is_visible
);
if !is_visible {
// Not visible: try from_surface(true)
if !is_visible || !first_is_false {
// Not visible (safe) or already tried false: try from_surface(true)
let retry_params = params_builder.from_surface(true).build();
let retry_attempt = tokio::time::timeout(
tokio::time::Duration::from_secs(8),
@@ -443,7 +548,7 @@ impl Page {
)),
}
} else {
// Visible: do not use from_surface(true); retry quickly with false
// Visible: avoid from_surface(true) if possible; retry quickly with false
let retry_params = params_builder.clone().from_surface(false).build();
let retry_attempt = tokio::time::timeout(
tokio::time::Duration::from_secs(4),
@@ -452,10 +557,44 @@ impl Page {
.await;
match retry_attempt {
Ok(Ok(resp)) => Ok(resp.result),
Ok(Err(e)) => Err(e.into()),
Err(_) => Err(BrowserError::ScreenshotError(
"Screenshot timed out after retries (from_surface=false only)".to_string(),
)),
Ok(Err(e)) => {
// Final fallback with from_surface(true) even though visible (see doc rationale)
debug!(
"Second attempt with from_surface(false) failed while visible; attempting final from_surface(true)"
);
let final_params = params_builder.from_surface(true).build();
let final_attempt = tokio::time::timeout(
tokio::time::Duration::from_secs(4),
self.cdp_page.execute(final_params),
)
.await;
match final_attempt {
Ok(Ok(resp)) => Ok(resp.result),
Ok(Err(e3)) => Err(e3.into()),
Err(_) => Err(BrowserError::ScreenshotError(
"Screenshot timed out after retries (final from_surface=true)".to_string(),
)),
}
}
Err(_) => {
// Timeout on second false attempt, try true once (final)
debug!(
"Second attempt with from_surface(false) timed out while visible; attempting final from_surface(true)"
);
let final_params = params_builder.from_surface(true).build();
let final_attempt = tokio::time::timeout(
tokio::time::Duration::from_secs(4),
self.cdp_page.execute(final_params),
)
.await;
match final_attempt {
Ok(Ok(resp)) => Ok(resp.result),
Ok(Err(e3)) => Err(e3.into()),
Err(_) => Err(BrowserError::ScreenshotError(
"Screenshot timed out after retries (from_surface=true fallback)".to_string(),
)),
}
}
}
}
}
+195 -10
View File
@@ -3204,6 +3204,9 @@ impl ChatWidget<'_> {
browser_manager.set_enabled_sync(true);
// Configure for CDP connection (prefer cached ws/port on auto-detect)
// Track whether we're attempting via cached WS and retain a cached port for fallback.
let mut attempted_via_cached_ws = false;
let mut cached_port_for_fallback: Option<u16> = None;
{
let mut config = browser_manager.config.write().await;
config.headless = false;
@@ -3219,11 +3222,13 @@ impl ChatWidget<'_> {
Some(v) => v,
None => codex_browser::global::get_last_connection().await,
};
cached_port_for_fallback = cached_port;
if let Some(ws) = cached_ws {
tracing::info!("[cdp] using cached Chrome WS endpoint");
attempted_via_cached_ws = true;
config.connect_ws = Some(ws);
config.connect_port = None;
} else if let Some(p) = cached_port {
} else if let Some(p) = cached_port_for_fallback {
tracing::info!("[cdp] using cached Chrome debug port: {}", p);
config.connect_ws = None;
config.connect_port = Some(p);
@@ -3404,15 +3409,195 @@ impl ChatWidget<'_> {
}
}
Err(e) => {
tracing::error!("[cdp] connect_to_chrome_only failed immediately: {}", e);
use codex_core::protocol::{BackgroundEventEvent, Event, EventMsg};
let _ = app_event_tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: format!("❌ Failed to connect to Chrome: {}", e),
}),
}));
return;
let err_msg = format!("{}", e);
// If we attempted via a cached WS, clear it and fallback to port-based discovery once.
if attempted_via_cached_ws {
tracing::warn!("[cdp] cached WS connect failed: {} — clearing WS cache and retrying via port discovery", err_msg);
let port_to_keep = cached_port_for_fallback;
// Clear WS in-memory and on-disk
codex_browser::global::set_last_connection(port_to_keep, None).await;
let _ = write_cached_connection(port_to_keep, None).await;
// Reconfigure to use port (prefer cached port, else auto-detect)
{
let mut cfg = browser_manager.config.write().await;
cfg.connect_ws = None;
cfg.connect_port = Some(port_to_keep.unwrap_or(0));
}
tracing::info!("[cdp] retrying connect via port discovery after WS failure…");
let retry_deadline = tokio::time::Duration::from_secs(20);
let retry = tokio::time::timeout(retry_deadline, browser_manager.connect_to_chrome_only()).await;
match retry {
Ok(Ok(_)) => {
tracing::info!("[cdp] Fallback connect succeeded after clearing cached WS");
// Emit success event and set up callbacks, mirroring the success path above
let (detected_port, detected_ws) = codex_browser::global::get_last_connection().await;
let mut port_num: Option<u16> = detected_port;
if port_num.is_none() {
if let Some(ws) = &detected_ws {
if let Some(after_scheme) = ws.split("//").nth(1) {
if let Some(hostport) = after_scheme.split('/').next() {
if let Some(pstr) = hostport.split(':').nth(1) {
if let Ok(p) = pstr.parse::<u16>() { port_num = Some(p); }
}
}
}
}
}
let current_url = browser_manager.get_current_url().await;
let success_msg = match (port_num, current_url) {
(Some(p), Some(url)) if !url.is_empty() => {
format!("✅ Connected to Chrome via CDP (port {}) to {}", p, url)
}
(Some(p), _) => format!("✅ Connected to Chrome via CDP (port {})", p),
(None, Some(url)) if !url.is_empty() => {
format!("✅ Connected to Chrome via CDP to {}", url)
}
_ => "✅ Connected to Chrome via CDP".to_string(),
};
use codex_core::protocol::{BackgroundEventEvent, Event, EventMsg};
let _ = app_event_tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent { message: success_msg }),
}));
// Persist last connection cache
tokio::spawn(async move {
let (p, ws) = codex_browser::global::get_last_connection().await;
let _ = write_cached_connection(p, ws).await;
});
// Navigation callback
let latest_screenshot_callback = latest_screenshot.clone();
let app_event_tx_callback = app_event_tx.clone();
browser_manager
.set_navigation_callback(move |url| {
tracing::info!("CDP Navigation callback triggered for URL: {}", url);
let latest_screenshot_inner = latest_screenshot_callback.clone();
let app_event_tx_inner = app_event_tx_callback.clone();
let url_inner = url.clone();
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
let browser_manager_inner = ChatWidget::get_browser_manager().await;
let mut attempt = 0;
let max_attempts = 2;
loop {
attempt += 1;
match browser_manager_inner.capture_screenshot_with_url().await {
Ok((paths, _)) => {
if let Some(first_path) = paths.first() {
tracing::info!("[cdp] auto-captured screenshot: {}", first_path.display());
if let Ok(mut latest) = latest_screenshot_inner.lock() {
*latest = Some((first_path.clone(), url_inner.clone()));
}
use codex_core::protocol::{BrowserScreenshotUpdateEvent, Event, EventMsg};
let _ = app_event_tx_inner.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BrowserScreenshotUpdate(BrowserScreenshotUpdateEvent {
screenshot_path: first_path.clone(),
url: url_inner,
}),
}));
break;
}
}
Err(e) => {
tracing::warn!("[cdp] auto-capture failed (attempt {}): {}", attempt, e);
if attempt >= max_attempts { break; }
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
}
}
}
});
})
.await;
// Set as global manager like success path
codex_browser::global::set_global_browser_manager(browser_manager.clone()).await;
// Initial screenshot in background (best-effort)
{
let latest_screenshot_bg = latest_screenshot.clone();
let app_event_tx_bg = app_event_tx.clone();
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
let browser_manager = ChatWidget::get_browser_manager().await;
let mut attempt = 0;
let max_attempts = 2;
loop {
attempt += 1;
match browser_manager.capture_screenshot_with_url().await {
Ok((paths, url)) => {
if let Some(first_path) = paths.first() {
tracing::info!("Initial CDP screenshot captured: {}", first_path.display());
if let Ok(mut latest) = latest_screenshot_bg.lock() {
*latest = Some((
first_path.clone(),
url.clone().unwrap_or_else(|| "Chrome".to_string()),
));
}
use codex_core::protocol::{BrowserScreenshotUpdateEvent, Event, EventMsg};
let _ = app_event_tx_bg.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BrowserScreenshotUpdate(BrowserScreenshotUpdateEvent {
screenshot_path: first_path.clone(),
url: url.unwrap_or_else(|| "Chrome".to_string()),
}),
}));
break;
}
}
Err(e) => {
tracing::warn!("Failed to capture initial CDP screenshot (attempt {}): {}", attempt, e);
if attempt >= max_attempts { break; }
tokio::time::sleep(tokio::time::Duration::from_millis(250)).await;
}
}
}
});
}
return;
}
Ok(Err(e2)) => {
tracing::error!("[cdp] Fallback connect failed: {}", e2);
use codex_core::protocol::{BackgroundEventEvent, Event, EventMsg};
let _ = app_event_tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: format!(
"❌ Failed to connect to Chrome after WS fallback: {} (original: {})",
e2, err_msg
),
}),
}));
return;
}
Err(_) => {
tracing::error!("[cdp] Fallback connect timed out after {:?}", retry_deadline);
use codex_core::protocol::{BackgroundEventEvent, Event, EventMsg};
let _ = app_event_tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: format!(
"❌ CDP connect timed out after {}s during fallback. Ensure Chrome is running with --remote-debugging-port and /json/version is reachable",
retry_deadline.as_secs()
),
}),
}));
return;
}
}
} else {
tracing::error!("[cdp] connect_to_chrome_only failed immediately: {}", err_msg);
use codex_core::protocol::{BackgroundEventEvent, Event, EventMsg};
let _ = app_event_tx.send(AppEvent::CodexEvent(Event {
id: uuid::Uuid::new_v4().to_string(),
msg: EventMsg::BackgroundEvent(BackgroundEventEvent {
message: format!("❌ Failed to connect to Chrome: {}", err_msg),
}),
}));
return;
}
}
}
}