//! Page navigation helpers — wrap `chromiumoxide` `wait_for_navigation` //! with a timeout so a hung TLS handshake or a page that never fires //! `load` cannot wedge a worker (or the cron metadata pass) forever. //! //! [`NAV_TIMEOUT`] is the global budget. Callers in the crawler use //! [`wait_for_nav`] to get back a typed error so transient timeouts can //! be reported separately from underlying CDP errors. use std::time::Duration; use chromiumoxide::error::CdpError; use chromiumoxide::Page; use thiserror::Error; /// Maximum wall-clock time we'll wait for a single page navigation. A /// healthy Chromium reaches `load` in well under a second on the target /// site; a 30-second cap is generous enough for slow TLS handshakes on /// the first request after a fresh process while still catching real /// hangs before they wedge the daemon. pub const NAV_TIMEOUT: Duration = Duration::from_secs(30); /// Outcome of a timed-out navigation. `Timeout` is the transient signal /// callers translate into a retry-friendly error /// ([`crate::crawler::detect::PageError::Transient`] in the source path, /// a context'd anyhow elsewhere). `Cdp` carries the underlying /// chromiumoxide error unchanged. #[derive(Debug, Error)] pub enum NavError { #[error("navigation timed out after {0:?}")] Timeout(Duration), #[error(transparent)] Cdp(#[from] CdpError), } /// Wait for the page's next navigation to complete, capped at /// [`NAV_TIMEOUT`]. Replaces bare `page.wait_for_navigation().await` /// throughout the crawler. pub async fn wait_for_nav(page: &Page) -> Result<(), NavError> { match tokio::time::timeout(NAV_TIMEOUT, page.wait_for_navigation()).await { Err(_elapsed) => Err(NavError::Timeout(NAV_TIMEOUT)), Ok(Err(e)) => Err(NavError::Cdp(e)), Ok(Ok(_)) => Ok(()), } } /// Poll interval for [`wait_for_selector`]. 100ms is fast enough that a /// page rendering in 200ms isn't held back noticeably, and slow enough /// not to spam CDP with `find_element` calls on a page that's actually /// taking its time. const SELECTOR_POLL_INTERVAL: Duration = Duration::from_millis(100); /// Wait until `selector` matches at least one element on `page`, or /// `timeout` elapses. Used after a navigation to confirm a page-type- /// specific marker is in the DOM before parsing — replaces the fixed /// post-nav sleep that previously masked partial-render races. /// /// chromiumoxide 0.7.0 has no built-in `wait_for_selector`, so we poll /// `find_element` at [`SELECTOR_POLL_INTERVAL`] until success or budget /// exhaustion. A failed `find_element` is *not* an error here — it just /// means "not yet" — we only surface an error once the overall /// `timeout` is up. pub async fn wait_for_selector( page: &Page, selector: &str, timeout: Duration, ) -> Result<(), NavError> { let deadline = tokio::time::Instant::now() + timeout; loop { if page.find_element(selector).await.is_ok() { return Ok(()); } if tokio::time::Instant::now() >= deadline { return Err(NavError::Timeout(timeout)); } let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); let sleep_for = SELECTOR_POLL_INTERVAL.min(remaining); tokio::time::sleep(sleep_for).await; } } /// Per-page-type budget for [`wait_for_selector`]. Shorter than /// [`NAV_TIMEOUT`] because by the time we're waiting on a selector, the /// page has already responded — we're only absorbing post-load JS /// finishing its row injection, which on a healthy site takes well /// under a second. pub const SELECTOR_TIMEOUT: Duration = Duration::from_secs(10); /// Run `body` to completion, then run `close` — on *every* exit path, /// including an early `?` / `return` inside `body`. chromiumoxide's `Page` /// does **not** close its CDP target on drop, so a fetch helper that opens a /// page with `browser.new_page(...)` and then bails via `?` on a nav / /// content-read error would leak a browser tab for the process's lifetime. /// Wrapping the fallible work in `close_after` — with `close` built from a /// clone of the page — guarantees the tab is closed regardless of how `body` /// returns. /// /// Both arguments are pre-built futures, so this stays generic over the /// body's return type (the anyhow and `PageError` fetch paths both use it) /// and references no browser types — which also lets it be unit-tested /// without standing up a real `Page`. pub(crate) async fn close_after( close: impl std::future::Future, body: impl std::future::Future, ) -> R { let result = body.await; close.await; result } impl NavError { /// Does this navigation error indicate the underlying Chromium /// process has died or its CDP connection has dropped? Used by the /// dispatcher to decide whether to invalidate the /// [`crate::crawler::browser_manager::BrowserManager`] handle so /// the next acquire re-launches. /// /// Both variants count: a `Timeout` past [`NAV_TIMEOUT`] is in /// practice always either a hung CDP transport or a wedged page /// the browser can't recover from on its own, and a `Cdp` error /// surfacing at the navigation layer means the chromium-facing /// channel is the failing layer. pub fn is_likely_browser_dead(&self) -> bool { match self { Self::Timeout(_) => true, Self::Cdp(_) => true, } } } /// Walk an `anyhow::Error` chain looking for typed evidence that the /// chromium-facing layer is the failing one. Two markers count: /// /// 1. A wrapped [`NavError`] flagged by [`NavError::is_likely_browser_dead`]. /// 2. A wrapped [`CdpError`] (via `anyhow::Error::from(CdpError)` at a /// `Browser::new_page` call site, or any other direct CDP boundary). /// /// Earlier versions also substring-matched the chain for "connection", /// "closed", "channel", etc. as a fallback. That was too broad — /// reqwest TCP-reset errors during CDN image downloads, sqlx /// connection-pool errors, and similar non-browser failures contain /// those words and triggered spurious chromium relaunches. The typed /// downcasts cover every place we hand a chromium error to anyhow, /// so the fallback is unnecessary. pub fn anyhow_looks_browser_dead(err: &anyhow::Error) -> bool { for cause in err.chain() { if let Some(nav) = cause.downcast_ref::() { if nav.is_likely_browser_dead() { return true; } } if cause.downcast_ref::().is_some() { return true; } } false } #[cfg(test)] mod tests { use super::*; use std::future::pending; /// Sanity-check the timeout pattern used by [`wait_for_nav`]: a /// future that never resolves must yield `Elapsed` within the /// configured budget. We can't easily stand up a real `Page` in a /// unit test, so we assert the underlying primitive behaves the way /// the helper depends on. #[tokio::test(flavor = "current_thread", start_paused = true)] async fn timeout_elapses_on_a_future_that_never_resolves() { let result = tokio::time::timeout(Duration::from_millis(50), pending::<()>()).await; assert!(result.is_err(), "expected Elapsed on a hung future"); } #[tokio::test] async fn close_after_runs_close_and_returns_value_on_ok() { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; let closed = Arc::new(AtomicBool::new(false)); let c = closed.clone(); let out: anyhow::Result = close_after( async move { c.store(true, Ordering::SeqCst) }, async { Ok(7) }, ) .await; assert_eq!(out.unwrap(), 7); assert!(closed.load(Ordering::SeqCst), "close must run on the happy path"); } #[tokio::test] async fn close_after_runs_close_even_when_body_errs() { use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; let closed = Arc::new(AtomicBool::new(false)); let c = closed.clone(); // This is the leak the fix targets: the body bails before it could // close the page itself, and `close_after` must still close it. let out: anyhow::Result<()> = close_after(async move { c.store(true, Ordering::SeqCst) }, async { anyhow::bail!("nav failed") }) .await; assert!(out.is_err()); assert!( closed.load(Ordering::SeqCst), "the page must be closed even when the body returns Err" ); } #[test] fn nav_error_timeout_message_includes_duration() { let e = NavError::Timeout(Duration::from_secs(30)); assert_eq!(e.to_string(), "navigation timed out after 30s"); } #[test] fn timeout_is_treated_as_likely_browser_dead() { let e = NavError::Timeout(NAV_TIMEOUT); assert!(e.is_likely_browser_dead()); } #[test] fn anyhow_with_nav_timeout_in_chain_is_flagged() { let outer = NavError::Timeout(NAV_TIMEOUT); let wrapped: anyhow::Error = anyhow::Error::new(outer).context("wait for chapter nav"); assert!(anyhow_looks_browser_dead(&wrapped)); } #[test] fn anyhow_with_cdp_error_in_chain_is_flagged() { // `Browser::new_page` errors get wrapped via // `anyhow::Error::from(CdpError)` at the navigate / dispatch // call sites. Walking the chain and downcasting to CdpError is // what catches that path. Any CdpError variant counts; the // Serde variant is the easiest to construct in a unit test. let serde_err: serde_json::Error = serde_json::from_str::("not a number").unwrap_err(); let cdp = CdpError::Serde(serde_err); let wrapped: anyhow::Error = anyhow::Error::from(cdp).context("open chapter page"); assert!(anyhow_looks_browser_dead(&wrapped)); } #[test] fn anyhow_with_innocuous_parse_error_is_not_flagged() { let e: anyhow::Error = anyhow::anyhow!("parse manga detail: chapter row regex did not match"); assert!(!anyhow_looks_browser_dead(&e)); } #[test] fn anyhow_with_reqwest_style_connection_message_is_not_flagged() { // Regression: the earlier substring fallback flagged any error // whose message contained "connection" or "closed" as browser- // dead. A TCP reset from a CDN during image download, or a // sqlx pool-connection error, would burn a chromium relaunch // even though the browser is fine. Typed downcasts only — // these untyped strings must pass through. for msg in [ "error sending request: connection reset by peer", "PoolTimedOut: timed out waiting for a connection", "request to https://cdn/x.jpg: connection closed before message completed", "transport error during image fetch", ] { let e: anyhow::Error = anyhow::anyhow!("{msg}"); assert!( !anyhow_looks_browser_dead(&e), "must not flag non-browser error: {msg}" ); } } /// Same sanity check as [`timeout_elapses_on_a_future_that_never_resolves`], /// but for the [`wait_for_selector`] polling pattern: the loop must /// surrender on `Elapsed` rather than spinning past the deadline. #[tokio::test(flavor = "current_thread", start_paused = true)] async fn selector_polling_pattern_surrenders_at_deadline() { let timeout = Duration::from_millis(300); let start = tokio::time::Instant::now(); let deadline = start + timeout; // Simulate find_element forever returning "not found". let mut polls = 0u32; let result: Result<(), NavError> = loop { polls += 1; if tokio::time::Instant::now() >= deadline { break Err(NavError::Timeout(timeout)); } tokio::time::sleep(SELECTOR_POLL_INTERVAL).await; }; assert!(matches!(result, Err(NavError::Timeout(_)))); // 300ms / 100ms poll interval ≈ 3 iterations plus the final check // that breaks out. Allow some slack since the first poll happens // before any sleep. assert!(polls >= 3, "expected at least 3 poll iterations, got {polls}"); } }