From 3e2ac05f94c21bdcc64337f5c43a1009e814f827 Mon Sep 17 00:00:00 2001 From: rqi14 <26152437+rqi14@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:37:09 +0300 Subject: [PATCH] fix(renderer): escalate lightpanda to a tier the pool actually holds The post-LightPanda escalation pinned the literal "chrome". A pinned name the pool does not hold is a hard error, not a fallback, so on any deployment without a Chrome CDP sidecar every escalation failed on the pin rather than on the page, and a stronger tier that was configured and healthy was never reached. The shipped config.default.toml is one of those deployments: it sets [renderer.lightpanda] with [renderer.chrome] commented out. lightpanda_escalation_target() keeps chrome as the first choice, so a pool that holds it behaves exactly as before, and otherwise falls back to the next tier the auto chain would enter by itself. auto_ladder_names() supplies that list and drops the tiers a name-pin would otherwise smuggle past a gate: a camoufox held out by include_in_auto = false, and chrome_proxy under auto_egress_escalation, where the chain lifts it out of the ladder and fires it only on a hard block. playwright is never chosen because it has no RendererKind, so a render on it would carry no breaker, renderDecision or creditCost. None means there is nothing above lightpanda. The escalation is then skipped rather than dispatched, because "auto" would re-render the same tier for the same thin result, and the response carries a warning naming the missing chrome tier so the operator learns what to configure. --- README.md | 1 - crates/crw-crawl/src/single.rs | 60 +++++++++-- crates/crw-renderer/src/lib.rs | 183 +++++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index ab491409..0f982954 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,6 @@ Embedding license: hello@fastcrw.com. paoloantinori VIVAAN-DHAWAN mj520 - rqi14

diff --git a/crates/crw-crawl/src/single.rs b/crates/crw-crawl/src/single.rs index 00ae3f4e..80592d5d 100644 --- a/crates/crw-crawl/src/single.rs +++ b/crates/crw-crawl/src/single.rs @@ -430,12 +430,60 @@ async fn scrape_url_inner( // and hide the real outcome behind a fabricated timeout. let escalation_budget = deadline.remaining(); let has_escalation_budget = escalation_budget >= crw_renderer::MIN_TIER_BUDGET; + // If the prior tier was lightpanda (returned 200 with thin/no content that + // fooled the renderer-level thinness check), escalate to chrome, and when + // this deployment runs no Chrome sidecar, to whatever stronger tier it does + // have. Pinning the bare literal made the second case a dead end: the pool + // rejects a name it does not hold outright, so the escalation failed on the + // pin rather than on the page and a configured, healthy tier was never + // reached. Chrome stays the first choice so a pool that holds it behaves + // exactly as before, including for a host the preference learner has already + // promoted to chrome; `auto_ladder_names` supplies the fallback and drops + // the tiers the auto chain would not have entered by itself. + // `None` means there is nothing above lightpanda, so the escalation is + // skipped rather than dispatched: "auto" would re-render the same tier for + // the same thin result, and a pin the pool cannot satisfy only produces a + // misleading failure. + // Otherwise (http tier), pass the caller's pin through, or `None` so + // the chain decides and chrome is reached through the existing + // failover path. + let escalation_target: Option<&str> = if prior_renderer == Some("lightpanda") { + renderer.lightpanda_escalation_target() + } else { + pinned + }; + let has_escalation_target = + escalation_target.is_some() || prior_renderer != Some("lightpanda"); let should_escalate = (md_is_byte_thin || escalate_for_quality) && used_low_tier && !js_ladder_exhausted && should_escalate_status && escalation_eligible - && has_escalation_budget; + && has_escalation_budget + && has_escalation_target; + if (md_is_byte_thin || escalate_for_quality) + && used_low_tier + && should_escalate_status + && escalation_eligible + && has_escalation_budget + && !has_escalation_target + { + tracing::debug!( + url = %req.url, + pool = ?renderer.auto_ladder_names(), + "skipping JS escalation: no tier above lightpanda in this pool" + ); + // Say so on the response as well. The thin body ships as a + // success, and without this line nothing tells the operator that + // the deployment has no browser tier to render the page with. + let skip_warning = "JS escalation skipped: no chrome tier is configured above \ + lightpanda; add one for full SPA rendering" + .to_string(); + effective_warning = Some(match effective_warning { + Some(w) => format!("{w}; {skip_warning}"), + None => skip_warning, + }); + } if (md_is_byte_thin || escalate_for_quality) && used_low_tier && should_escalate_status @@ -450,16 +498,6 @@ async fn scrape_url_inner( ); } if should_escalate { - // If the prior tier was lightpanda (returned 200 with thin/no content - // that fooled the renderer-level thinness check), force chrome on the - // escalation. Falling back to "auto" would just hit lightpanda again. - // Otherwise (http tier), let the chain decide so chrome can be reached - // through the existing failover path. - let escalation_target: Option<&str> = if prior_renderer == Some("lightpanda") { - Some("chrome") - } else { - pinned - }; let quality_score_before = md_quality.as_ref().map(|q| q.score); tracing::info!( url = %req.url, diff --git a/crates/crw-renderer/src/lib.rs b/crates/crw-renderer/src/lib.rs index 23769695..a660b18e 100644 --- a/crates/crw-renderer/src/lib.rs +++ b/crates/crw-renderer/src/lib.rs @@ -1245,6 +1245,68 @@ impl FallbackRenderer { self.js_renderers.iter().map(|r| r.name()).collect() } + /// The JS tiers the auto chain may enter on its own, in construction order. + /// + /// Drops the two tiers a name-pin would otherwise smuggle past a gate + /// `fetch_with_js` applies: + /// + /// * camoufox held out by `include_in_auto = false`: an internally chosen + /// escalation must not override a deliberate opt-out; + /// * chrome_proxy under `auto_egress_escalation`, where the chain lifts it + /// out of the ladder and holds it as a hard-block-gated, load-shed + /// recovery arm. Pinning it by name skips that gate and puts a paid + /// residential render on every page instead of the blocked ones. + /// + /// This is construction order, not the per-request order: `fetch_with_js` + /// reorders for a host the preference learner has promoted, and filters for + /// a screenshot. Callers that need one tier should express which one they + /// want rather than trusting position alone. + pub fn auto_ladder_names(&self) -> Vec<&str> { + self.js_renderers + .iter() + .map(|r| r.name()) + .filter(|name| *name != "chrome_proxy" || !self.auto_egress_escalation) + .filter(|name| { + #[cfg(feature = "camoufox")] + { + *name != "camoufox" || self.camoufox_in_auto + } + #[cfg(not(feature = "camoufox"))] + { + let _ = name; + true + } + }) + .collect() + } + + /// Which tier a post-LightPanda escalation should aim at, or `None` when + /// this pool has nothing above lightpanda and the escalation should be + /// skipped rather than dispatched. + /// + /// chrome first, so a pool that holds it behaves exactly as the old + /// hardcoded target did, including for a host the preference learner has + /// promoted to chrome. Otherwise the next tier in [`Self::auto_ladder_names`], + /// which is what makes the escalation work on a deployment that runs no + /// Chrome sidecar instead of failing on a pin the pool cannot satisfy. + /// + /// playwright is never chosen: it has no `RendererKind`, so a render on it + /// carries no breaker, no `renderDecision` and no `creditCost`. It stays + /// reachable through the auto chain and an explicit pin, as before. + pub fn lightpanda_escalation_target(&self) -> Option<&str> { + let ladder = self.auto_ladder_names(); + ladder + .iter() + .copied() + .find(|name| *name == "chrome") + .or_else(|| { + ladder + .iter() + .copied() + .find(|name| *name != "lightpanda" && *name != "playwright") + }) + } + /// Whether this instance can actually capture a screenshot: at least one /// constructed JS renderer speaks CDP `Page.captureScreenshot`. Both the /// build features (no CDP feature ⇒ no tier is constructable) and the @@ -3690,6 +3752,127 @@ mod tests { ); } + /// The escalation after a thin LightPanda body picks the first non-lightpanda + /// entry of `auto_ladder_names`. Pinning a fixed name instead dead-ends on any + /// pool that does not hold it, so these four shapes are what that choice has + /// to get right. + #[cfg(feature = "cdp")] + #[test] + fn lightpanda_escalation_target_picks_a_tier_the_pool_actually_holds() { + let ladder = |cfg: &RendererConfig| { + let r = + FallbackRenderer::new(cfg, "crw-test", None, &StealthConfig::default()).unwrap(); + r.lightpanda_escalation_target().map(str::to_string) + }; + let lp = || { + Some(CdpEndpoint { + ws_url: "ws://127.0.0.1:9222/".into(), + }) + }; + let ep = |p: &str| { + Some(CdpEndpoint { + ws_url: format!("ws://127.0.0.1:{p}/"), + }) + }; + + // Production shape: chrome present, so the target is unchanged. + assert_eq!( + ladder(&RendererConfig { + mode: RendererMode::Auto, + lightpanda: lp(), + chrome: ep("9223"), + chrome_proxy: ep("9224"), + ..Default::default() + }), + Some("chrome".to_string()) + ); + + // No chrome sidecar: the escalation must reach the tier that IS configured + // instead of failing on a pin the pool cannot satisfy. + assert_eq!( + ladder(&RendererConfig { + mode: RendererMode::Auto, + lightpanda: lp(), + chrome_proxy: ep("9224"), + ..Default::default() + }), + Some("chrome_proxy".to_string()) + ); + // playwright is untracked (no `RendererKind`), so it is not a target. + assert_eq!( + ladder(&RendererConfig { + mode: RendererMode::Auto, + lightpanda: lp(), + playwright: ep("9225"), + ..Default::default() + }), + None + ); + + // Nothing stronger than lightpanda: no target, so no unsatisfiable pin. + assert_eq!( + ladder(&RendererConfig { + mode: RendererMode::Auto, + lightpanda: lp(), + ..Default::default() + }), + None + ); + + // chrome stays the first choice even when it is not first in construction + // order, so a pool that already reached chrome keeps reaching chrome. + assert_eq!( + ladder(&RendererConfig { + mode: RendererMode::Auto, + lightpanda: lp(), + playwright: ep("9225"), + chrome: ep("9223"), + ..Default::default() + }), + Some("chrome".to_string()) + ); + + // Under auto_egress_escalation the chain lifts chrome_proxy out of the + // ladder and gates it behind a hard block, so it must not become the + // escalation target: pinning it by name would skip that gate and put a + // paid residential render on every thin page. + assert_eq!( + ladder(&RendererConfig { + mode: RendererMode::Auto, + lightpanda: lp(), + chrome_proxy: ep("9224"), + auto_egress_escalation: true, + ..Default::default() + }), + None + ); + } + + /// A camoufox held out of the auto chain is constructed and pinnable, but an + /// internally chosen escalation must not reach it: naming it bypasses the + /// exclusion, which would override a deliberate `include_in_auto = false`. + #[cfg(all(feature = "cdp", feature = "camoufox"))] + #[test] + fn auto_ladder_names_respects_the_camoufox_opt_out() { + let with_lp = |include_in_auto: bool| { + let mut cfg = camoufox_cfg(RendererMode::Auto, include_in_auto); + cfg.lightpanda = Some(CdpEndpoint { + ws_url: "ws://127.0.0.1:9222/".into(), + }); + FallbackRenderer::new(&cfg, "crw-test", None, &StealthConfig::default()).unwrap() + }; + + let held_out = with_lp(false); + assert!( + held_out.js_renderer_names().contains(&"camoufox"), + "the tier is still constructed so an explicit pin can reach it" + ); + assert_eq!(held_out.auto_ladder_names(), vec!["lightpanda"]); + + let in_chain = with_lp(true); + assert_eq!(in_chain.auto_ladder_names(), vec!["lightpanda", "camoufox"]); + } + /// The HTTP tier decodes every non-PDF body as HTML regardless of its /// declared type, so without this gate an empty `application/json` or /// `image/*` 2xx would buy a full browser render for nothing.