new_page() opened a tab that was only closed on the happy path; a `?` on wait_for_nav / content() leaked it (chromiumoxide doesn't close on drop), so errored fetches accumulated tabs in the shared browser. Add a generic, unit-tested `close_after(close, body)` helper and wrap the three fetch sites (chapter content, session probe, source navigate) so the tab is closed on every exit path — including navigate's content()-read branch, which previously leaked. Bump to 0.124.5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
298 lines
12 KiB
Rust
298 lines
12 KiB
Rust
//! 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<R>(
|
|
close: impl std::future::Future<Output = ()>,
|
|
body: impl std::future::Future<Output = R>,
|
|
) -> 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::<NavError>() {
|
|
if nav.is_likely_browser_dead() {
|
|
return true;
|
|
}
|
|
}
|
|
if cause.downcast_ref::<CdpError>().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<i32> = 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::<i32>("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}");
|
|
}
|
|
}
|