feat(renderer): let camoufox wait out a JS challenge instead of reporting it - #507
Conversation
…ting it
`run_sequence_inner` evaluates `outerHTML` exactly once and immediately judges
the result (`crates/crw-renderer/src/camoufox.rs:230-247`). A Cloudflare-style
JS challenge resolves client-side several seconds after navigation, so that
single evaluate can only ever observe the interstitial. The tier then reports a
retryable wall for pages it would have gotten had it looked again -- and since
camoufox is normally the last tier, that failure is the request's failure.
The CDP tiers already solve this. `cdp.rs` polls a challenge through
`CHALLENGE_MAX_RETRIES` / `CHALLENGE_POLL_INTERVAL_MS`, sizes the reservation
with `challenge_retry_budget()`, and exposes the knob as
`chrome_challenge_max_retries` wired in via `.with_challenge_retries(...)` at
both CDP construction sites. Waiting out a challenge is a first-class concept
in this codebase; camoufox is the one tier that never got it.
`renderer.camoufox_challenge_wait_ms` (default 20s, `0` restores today's
single-shot behaviour) gives camoufox the same loop, configured the same way --
`CamoufoxRenderer::new(...).with_challenge_wait(...)`, mirroring
`CdpRenderer::new(...).with_challenge_retries(...)`. `new` keeps its four
arguments, so this is additive for any external caller.
The loop polls only `looks_like_wall() == Some("challenge")`. The `"wall"`
markers ("attention required! | cloudflare", "enable javascript and cookies to
continue") are terminal refusals, not work in progress; polling those would
spend the entire ceiling to arrive at the identical error. A clean page never
enters the loop at all.
Env: `CRW_RENDERER__CAMOUFOX_CHALLENGE_WAIT_MS`.
The predicate the new loop polls on matched the bare `/cdn-cgi/challenge-platform` directory. Cloudflare re-injects that telemetry loader (`scripts/jsd/main.js`) into pages that have ALREADY cleared, so on a managed site camoufox renders the real page, the predicate still reads "challenge", and the loop spends the whole ceiling before discarding it. That marker was removed from crw_crawl::single::classify_block and from detector::looks_like_cloudflare_challenge for exactly this reason, each after a live capture; this list had kept it. Narrowed to `challenge-platform/h/`, the orchestrator path, which the telemetry loader never uses. Terminal walls are now matched first. `find` returns the first needle in LIST order, and every challenge needle preceded both wall needles, so a page carrying both classified as a clearing challenge and got polled for the whole ceiling to arrive at the same refusal, which is what the loop's comment says it avoids. The deadline is re-checked after the sleep. It could otherwise be spent by the sleep itself, and the next evaluate would then return `Timeout`, replacing this tier's wall error and its antibot attribution with a bare "timed out". Tests: the cleared-page fixture now carries the telemetry loader, so it fails against the old marker; a page carrying both marker kinds pins wall precedence; and the terminal-wall test counts evaluate calls instead of asserting on wall-clock, which cannot flake on a loaded runner.
|
Thanks for this, and for raising the budget interaction yourself rather than leaving it to be I have pushed a commit onto your branch. Three changes. 1. The predicate the loop polls on was not fit for looping.
The fixture in 2. Terminal walls are now matched first. 3. The deadline is re-checked between the sleep and the evaluate. The sleep can consume Also replaced
Two things still open. None of this tier's tests run in CI. The budget reservation you raised is still worth doing, and it does not belong in For scope: this tier is opt-in and is not configured on the hosted deployment, so the blast |
The wall-first ordering classified the standard Cloudflare managed-challenge interstitial as a terminal wall: "enable javascript and cookies to continue" is the noscript line that page ships, and the repo's own capture in tests/egress_latch_no_latch_on_cf.rs carries it next to the orchestrator script. The poll loop therefore never ran on the page class it was written for. Only the "Attention Required" title is terminal-first now; the noscript line is a refusal only when no challenge marker is present and it sits outside <noscript>. The poll tolerates one failed evaluate per streak, since the challenge clears by reloading the tab and an evaluate in that window fails; two in a row surface the sidecar error. Each evaluate is bounded by what is left of the ceiling so the poll cannot overrun it, and the loop stops when less than 250 ms of the request deadline remains rather than dispatching a call that can only time out. When a cloak endpoint is configured and cloak_recover_on_cf is off, the poll leaves CLOAK_ARM_FLOOR_MS of the deadline untouched so the recovery arm that runs after the ladder can still arm, and only while that much deadline remains. Docs and config.default.toml showed camoufox_timeout_ms and camoufox_challenge_wait_ms under [renderer.camoufox], where the endpoint table silently ignores them; both now sit in the [renderer] table they belong to.
|
pushed one more commit on top, after a second look at the branch as it would land. the wall-first ordering was classifying the standard cloudflare managed-challenge interstitial as a terminal wall: "enable javascript and cookies to continue" is the noscript line that page carries, and the repo's own capture in also in that commit: one failed evaluate per streak is tolerated (the challenge clears by reloading the tab, and an evaluate in that window fails), each evaluate is bounded by what is left of the ceiling, the loop stops at 250 ms of remaining deadline instead of dispatching a call that can only time out, and when a cloak endpoint is configured in auto mode with camoufox in the ladder the poll leaves the cloak arm's floor untouched. the docs and
|
The gap
run_sequence_innerevaluatesouterHTMLexactly once, then immediately judges what came back:https://github.com/us/crw/blob/c74bcf3/crates/crw-renderer/src/camoufox.rs#L223-L247
A Cloudflare-style JS challenge resolves client-side, seconds after navigation. That single immediate evaluate can therefore only ever observe the interstitial. The tier reports a retryable wall for pages it would have gotten had it looked again — and since camoufox is normally the last tier, its failure is the request's failure.
Why this is the missing half of an existing pattern
Waiting out a challenge is already a first-class concept here — the CDP tiers do exactly this:
CHALLENGE_MAX_RETRIES,CHALLENGE_POLL_INTERVAL_MScdp.rs:24-28challenge_retry_budget()— explicit deadline reservationcdp.rs:30-39chrome_challenge_max_retriesconfig keyconfig.rs:711-717.with_challenge_retries(...)at both CDP siteslib.rs:879,lib.rs:927Camoufox — the tier explicitly documented as the one that "covers fingerprint/bot-challenge blocks that the CDP tiers cannot pass" — is the only tier that never got the loop.
This does not overlap the recent wall work (
294eb3d,82a56f6,c74bcf3). Those made an unclearable wall report honestly instead of shipping as a billed success; none of them attempt to clear one. This is the complementary half: don't call it a wall until you've given it the chance the CDP tiers already get.The change
renderer.camoufox_challenge_wait_ms,#[serde(default)]→ 20s.Some(0)restores today's exact single-shot behaviour.Configured the same way the CDP tiers are —
CamoufoxRenderer::new(...).with_challenge_wait(...)mirrorsCdpRenderer::new(...).with_challenge_retries(...).newkeeps its four arguments, so this is purely additive for any external caller.The loop polls only
looks_like_wall(&html) == Some("challenge"). The"wall"kinds ("attention required! | cloudflare","enable javascript and cookies to continue") are terminal refusals rather than work in progress — polling those would spend the entire ceiling to arrive at the identical error. A clean page never enters the loop, sincelooks_like_wallisNoneon the first evaluate.Budget interaction — raising this before you have to ask
challenge_budget = self.challenge_wait.min(deadline.remaining()), so a challenge that never clears cannot outlive the request. But note the asymmetry with CDP:challenge_retry_budget()reserves its poll time in the outer timeout, whereas this clamps against whatever remains. Whenremaining < 20sthis tier can therefore consume nearly all of it.In practice camoufox is the last tier in the ladder (
include_in_auto = falseby default; when enabled it is pushed last), so there is normally nothing downstream to starve. If you would rather it be explicit, I am happy to add a camoufox analogue ofchallenge_retry_budget()and reserve it intier_timeouts_from— say the word and I will push it.Verification
Tests added
config::tests::renderer_config_default_all_fields— the new field added to the per-field default listconfig::tests::camoufox_challenge_wait_default_and_override— default / override / explicit-0-disables, shaped aftercamoufox_timeout_default_and_overridecamoufox::tests::challenge_that_clears_on_a_later_poll_is_returned_as_content— the actual new behaviour: interstitial first, real page on the next pollcamoufox::tests::terminal_wall_is_not_polled— a"wall"fails in well under one poll interval even with a 30s ceilingwall_detection_returns_retryable_renderer_error— given a small ceiling so the never-clears path stays sub-second (0.07s) instead of spending the production budgetDocs
config.default.tomlanddocs/docs/js-rendering.md, alongside the siblingcamoufox_timeout_ms. Only the authored Markdown is touched;docs/<slug>/index.htmlregenerates onmainviagoogle-indexing.yml, matching how upstream feature commits do it.Not reproduced locally
cargo test -p crw-renderer --libalso reports 4 failures inhttp_only::tests(direct_blackhole_*,connection_failure_catches_connect_timeout,is_retriable_error_false_for_connect_timeout), andcargo test -p crw-server --test apireports 4 more. All eight fail identically on unmodifiedc74bcf3on this machine — they depend on192.0.2.1/198.18.x.xresolving as unroutable, and this network intercepts both ranges. Unrelated to this change.