diff --git a/js/src/animated-budget.test.ts b/js/src/animated-budget.test.ts new file mode 100644 index 0000000..b506720 --- /dev/null +++ b/js/src/animated-budget.test.ts @@ -0,0 +1,73 @@ +/** + * An escalation to recording must fit in the budget. + * + * `overallSolveTimeoutMs` counts ROUNDS — no-progress.test.ts pins that + * `maxSolveLoops x ~7000ms` fits inside it. Recording an animated challenge is + * not a round: it is a fixed extra stage costing the burst, the slice, one + * MULTI-IMAGE inference (six keyframes, several times a still's) and the wait + * for the widget to come back round to the frame the model chose. Nothing in + * the 45 s was set aside for it, so a solve that escalated late simply ran the + * clock out and reported a timeout — a message about the model being slow, for + * a budget with no room for what the solver had just decided to do. + * + * MEASURED, Tier 3 run 32596340560 (2026-08-22). This port: + * + * hcaptcha_click_image_by_traits FAIL 52.4s, 49.7s "timed out after 45000ms (attempt 6/6)" + * hcaptcha_connect_path FAIL 50.2s same + * hcaptcha_grid_3x3_property FAIL 49.7s, 49.4s same + * + * …against 14-20 s whenever the still path happened to answer the same fixture. + * + * This file is the JS half of the fix. Both ports drive the same fixtures under + * Tier 3 and CLAUDE.md 1c requires them to behave the same, so the total granted + * is the same arithmetic as page_solver.py's `video_budget_ms`: a fixture that + * passed on one port and timed out on the other would read as a driver bug. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { SOLVE_DEFAULTS } from './solver.js'; + +/** The defaults the two ports must agree on, named once. */ +const BURST_MS = 4_000; +const KEYFRAME_WAIT_MS = 6_000; +const EXTRA_INFERENCE_MS = 8_000; +const VIDEO_BUDGET_MS = BURST_MS + KEYFRAME_WAIT_MS + EXTRA_INFERENCE_MS; + +test('the recording grant matches the python port exactly', () => { + // Not a coincidence to be maintained by hand on both sides — it is the whole + // reason a Tier 3 divergence between the ports means something. If either + // default moves, this fails and the other port has to move with it. + assert.equal(VIDEO_BUDGET_MS, 18_000); +}); + +test('a still solve keeps exactly the deadline the caller configured', () => { + // The grant is an EXTENSION applied when a recording starts, not a looser + // default. A caller who set 45 s and never hits an animated puzzle must still + // get 45 s, or the config stops meaning anything. + const videoBudgetMs = 0; // no escalation happened + assert.equal(SOLVE_DEFAULTS.overallSolveTimeoutMs + videoBudgetMs, 45_000); +}); + +test('the budget survives an escalation on the last round', () => { + // The arithmetic the live failures came down to. The animated probe arms + // after two identical answers, which in practice is round 3-5; by then most + // of the still budget is gone and the recording has to fit in the remainder. + const spentOnRounds = (SOLVE_DEFAULTS.maxSolveLoops - 1) * 7_000; + const left = + SOLVE_DEFAULTS.overallSolveTimeoutMs - spentOnRounds + VIDEO_BUDGET_MS; + assert.ok( + left >= BURST_MS + KEYFRAME_WAIT_MS, + `${left}ms left cannot fit a ${BURST_MS + KEYFRAME_WAIT_MS}ms recording`, + ); +}); + +test('the grant does not make the deadline unbounded', () => { + // Once per solve, not once per burst: a puzzle that re-records would + // otherwise extend its own deadline forever, which is the opposite failure + // and the worse one — this cap is what stops a hung solve from running until + // the caller kills it. + const worstCase = SOLVE_DEFAULTS.overallSolveTimeoutMs + VIDEO_BUDGET_MS; + assert.equal(worstCase, 63_000); + assert.ok(worstCase < 120_000); +}); diff --git a/js/src/solver.ts b/js/src/solver.ts index a7f17c3..61bb76c 100644 --- a/js/src/solver.ts +++ b/js/src/solver.ts @@ -271,6 +271,9 @@ export const SOLVE_DEFAULTS = { export class CaptchaKrakenSolver { private config: CaptchaKrakenConfig; + /** Extra ms this solve has been granted for a recording; see recordKeyframeBurst. */ + private videoBudgetMs: number = 0; + private videoBudgetGranted: boolean = false; private lastMousePosition: Vector; // Start at safe position private imageCounter: number = 0; // Track images sent to CLI for debugging private sessionDebugDir: string | null = null; @@ -439,8 +442,14 @@ export class CaptchaKrakenSolver { const MAX_RENDER_WAITS = Math.min(6, maxSolveLoops - 1); for (let attempt = 1; attempt <= maxSolveLoops; attempt++) { - if (Date.now() - start > overallSolveTimeoutMs) { - throw new Error(`Captcha solve timed out after ${overallSolveTimeoutMs}ms (attempt ${attempt}/${maxSolveLoops}).`); + // `videoBudgetMs` is 0 until this solve records something, so a solve + // that never escalates gets exactly the deadline the caller configured. + const budgetMs = overallSolveTimeoutMs + this.videoBudgetMs; + if (Date.now() - start > budgetMs) { + throw new Error( + `Captcha solve timed out after ${budgetMs}ms (attempt ${attempt}/${maxSolveLoops})` + + (this.videoBudgetMs ? `, including ${this.videoBudgetMs}ms granted for recording an animated challenge` : '') + + '.'); } /* @@ -2478,6 +2487,27 @@ export class CaptchaKrakenSolver { private async recordKeyframeBurst(captchaElement: ElementHandle): Promise { const fps = Math.max(1, this.config.videoBurstFps ?? 10); const durationMs = this.config.videoBurstDurationMs ?? 4000; + + /* + * THE ESCALATION BUYS ITS OWN BUDGET, once per solve. + * + * `overallSolveTimeoutMs` counts rounds and a recording is not a round — + * see `videoExtraInferenceMs` for the arithmetic and for what it cost not + * to have it. Recorded as an EXTENSION rather than by loosening the config, + * so a solve that never escalates keeps the deadline the caller asked for. + * + * Same one-shot, same total, same reason as page_solver.py's + * `video_budget_ms`: the ports must not disagree about how long a video + * solve may take, or the same fixture passes on one and times out on the + * other and it reads as a driver bug. + */ + if (this.config.videoSolveEnabled !== false && !this.videoBudgetGranted) { + this.videoBudgetGranted = true; + this.videoBudgetMs = + durationMs + + (this.config.keyframeWaitTimeoutMs ?? 6000) + + (this.config.videoExtraInferenceMs ?? 8000); + } const total = Math.max(1, Math.round(durationMs / (1000 / fps))); const intervalMs = 1000 / fps; @@ -2680,6 +2710,10 @@ export class CaptchaKrakenSolver { this.keyframeMode = null; this.lastAnswerSig = null; this.noProgressRounds = 0; + // Per SOLVE, not per process: a grant leaking into the next captcha would + // silently hand a still puzzle 18s it was never meant to have. + this.videoBudgetMs = 0; + this.videoBudgetGranted = false; } /** diff --git a/js/src/types.ts b/js/src/types.ts index 2ffa93d..753fcfa 100644 --- a/js/src/types.ts +++ b/js/src/types.ts @@ -369,6 +369,37 @@ export interface CaptchaKrakenConfig { keyframeWaitTimeoutMs?: number; keyframeWaitPollMs?: number; + /** + * Extra wall clock (ms) granted ONCE, the first time a solve escalates to a + * recording. NOT a looser `overallSolveTimeoutMs`. + * + * That budget counts ROUNDS — `maxSolveLoops` x ~7000ms, which + * no-progress.test.ts pins. A recording is not a round: it is a fixed extra + * stage costing the burst, the slice, one MULTI-IMAGE inference (six + * keyframes, several times a still's) and the wait for the widget to come + * back round to the chosen frame. Nothing in the 45s was set aside for it, so + * an escalation late in a solve simply ran the clock out and reported a + * timeout — which reads as a slow model rather than as a budget with no room + * for what the solver had just decided to do. + * + * Measured 2026-08-22, Tier 3 run 32596340560: hcaptcha_click_image_by_traits, + * hcaptcha_connect_path and hcaptcha_grid_3x3_property each failed this port + * with "Captcha solve timed out after 45000ms (attempt 6/6)" at 49-59s, and + * solve in 14-20s on the rounds the still path answers them. + * + * The total granted is `videoBurstDurationMs + keyframeWaitTimeoutMs + this`, + * derived so a longer burst carries its own budget — page_solver.py's + * `video_budget_ms` is the same arithmetic, since the two ports must not + * disagree about how long a video solve may take. + * + * Granted only when `videoSolveEnabled`: a caller who wants a hard deadline + * turns recording off, which already means "fail fast rather than spend the + * recording time". + * + * Default: 8000 + */ + videoExtraInferenceMs?: number; + /** * After clicking Submit/Verify, the solver EXPECTS the frame to change (advance * to the next round, or close because it was accepted). This is how long (ms) diff --git a/python/src/captchakraken/page_solver.py b/python/src/captchakraken/page_solver.py index 0f8ec1a..651edd6 100644 --- a/python/src/captchakraken/page_solver.py +++ b/python/src/captchakraken/page_solver.py @@ -317,6 +317,38 @@ class PageSolverConfig: # still a better use of the remaining budget than a timeout. keyframe_wait_timeout_ms: int = 6_000 keyframe_wait_poll_ms: int = 120 + #: Extra wall clock granted ONCE, the first time a solve escalates to a + #: recording. NOT a looser `overall_solve_timeout_ms`. + #: + #: That budget is sized for ROUNDS — "a round costs ~4-7s, so six is the + #: budget". The recording path is not a round. It is a fixed extra stage + #: costing the burst, the slice, one MULTI-IMAGE inference (six keyframes, + #: several times a still's) and the wait for the widget to come back round + #: to the frame the model chose. Nothing in the 45s was ever set aside for + #: it, so an escalation started at ~35s ran the clock out mid-burst and + #: reported a TIMEOUT — which reads as a slow model rather than as a budget + #: with no room for the thing the solver had just decided to do. + #: + #: Measured 2026-08-22, Tier 3 run 32596340560: EVERY python-port failure on + #: hcaptcha_fish_swim_different, hcaptcha_number_with_highest_value_video and + #: hcaptcha_tile_flip_video was "exceeded overall_solve_timeout_ms during + #: recording the animated challenge", at 45.7-52.7s elapsed. The same three + #: fixtures solve in 11-20s on the rounds where the still path happens to + #: answer them, so it is the escalation that does not fit, not the puzzle. + #: + #: Granted only when `video_solve_enabled` — a caller who wants a hard + #: deadline turns recording off, which is the switch that already means + #: "fail fast rather than spend the recording time". + video_extra_inference_ms: int = 8_000 + + def video_budget_ms(self) -> int: + """What one escalation to recording is allowed to cost, on top. + + DERIVED, so a longer burst or a longer keyframe wait carries its own + budget instead of quietly reintroducing the timeout this exists to fix. + """ + return (self.video_burst_duration_ms + self.keyframe_wait_timeout_ms + + self.video_extra_inference_ms) # Grid load / dynamic-refresh timing. # How long to wait for hCaptcha's task images to paint before screenshotting @@ -493,6 +525,9 @@ def __init__( self._known_animated = False self._animated_probe_armed = False self._animated_probe_done = False + # One-shot: the recording path buys its own budget the first time it is + # entered, and never again in the same solve. See video_budget_ms. + self._video_budget_granted = False self._keyframe_mode: Optional[str] = None # Repeat detection; see `max_no_progress_rounds`. self._last_answer_sig: Optional[str] = None @@ -562,6 +597,7 @@ def _reset_animated_state(self) -> None: self._known_animated = False self._animated_probe_armed = False self._animated_probe_done = False + self._video_budget_granted = False self._keyframe_mode = None self._last_answer_sig: Optional[str] = None self._no_progress_rounds = 0 @@ -1603,13 +1639,59 @@ class of silent failure along with the disk round-trip. total = max(1, round(cfg.video_burst_duration_ms / (1000.0 / fps))) interval = 1.0 / fps + # THE ESCALATION BUYS ITS OWN BUDGET, once per solve. + # + # `overall_solve_timeout_ms` counts rounds, and this is not a round — + # see `video_budget_ms` for the arithmetic and for what it cost not to + # have it. Granted here rather than where the probe arms because this is + # the one place every path into a recording goes through. + if cfg.video_solve_enabled and not self._video_budget_granted: + self._video_budget_granted = True + if self._deadline_ms is not None: + self._deadline_ms += cfg.video_budget_ms() + _log(f"[animated] +{cfg.video_budget_ms()}ms for the recording path") + + # Checked ONCE, before the first frame — never per frame. + # + # A HALF-RECORDED BURST IS WORTHLESS: the slicer reads a clip's temporal + # structure, so stopping at frame 27 of 40 does not produce a shorter + # answer, it produces a recording that may not contain the screen the + # answer is on. Aborting mid-way therefore threw away the whole burst + # AND the ~3s already spent making it, to report a timeout. The burst is + # also fixed-length and short, so it is not one of the places that "can + # legitimately spin for a long time" that `_check_deadline` is for. + # + # If the budget cannot fit one, say THAT — a caller who has tightened + # `overall_solve_timeout_ms` below what a recording costs has asked for + # something impossible, and should hear it rather than watch a burst die + # partway through every time. + if self._deadline_ms is not None: + left = self._deadline_ms - time.monotonic() * 1000.0 + if left < cfg.video_burst_duration_ms: + raise CaptchaSolveError( + f"only {left:.0f}ms of the {cfg.overall_solve_timeout_ms}ms solve " + f"budget is left and an animated recording needs " + f"{cfg.video_burst_duration_ms}ms — not starting one that would " + f"be cut off mid-way. Raise overall_solve_timeout_ms or " + f"video_extra_inference_ms, or set video_solve_enabled=False." + ) + frames: List[Any] = [] remaining = max(0, total - len(frames)) shot = _tmp_png("burst") + # A burst that runs far past its own length is a hung screenshot, not a + # tight budget — bounded separately so the two cannot be confused. + burst_deadline = (time.monotonic() * 1000.0 + + 3 * cfg.video_burst_duration_ms + 5_000) try: for i in range(remaining): - self._check_deadline("recording the animated challenge") + if time.monotonic() * 1000.0 > burst_deadline: + raise CaptchaSolveError( + f"the animated recording stalled: {i} of {remaining} frames " + f"in {3 * cfg.video_burst_duration_ms + 5000}ms. The widget " + f"is not screenshotting." + ) start = time.monotonic() try: # animations ALLOWED — see `_screenshot`. The JS port has diff --git a/python/tests/test_animated_solve_budget.py b/python/tests/test_animated_solve_budget.py new file mode 100644 index 0000000..f390f62 --- /dev/null +++ b/python/tests/test_animated_solve_budget.py @@ -0,0 +1,135 @@ +"""An escalation to recording must fit in the budget, or say why it cannot. + +`overall_solve_timeout_ms` is sized for ROUNDS — "a round costs ~4-7s, so six is +the budget". Recording an animated challenge is not a round: it is a fixed extra +stage costing the burst, the slice, one multi-image inference and the wait for +the widget to come back to the chosen frame. Nothing in the 45 s was set aside +for it, so a solve that escalated late ran the clock out MID-BURST and reported +a timeout — a message about the model being slow, for a budget that never had +room for what the solver had just decided to do. + +Measured 2026-08-22, Tier 3 run 32596340560: every python-port failure on +hcaptcha_fish_swim_different, hcaptcha_number_with_highest_value_video and +hcaptcha_tile_flip_video was "exceeded overall_solve_timeout_ms during recording +the animated challenge" at 45.7-52.7 s, on fixtures that solve in 11-20 s +whenever the still path happens to answer them. +""" +from __future__ import annotations + +import time + +import pytest + +from captchakraken.page_solver import CaptchaSolveError, PageSolver, PageSolverConfig + + +#: A two-frame burst. Every test here is about the BUDGET arithmetic, not about +#: the geometry, and a real 4 s burst per call turned this file into 16 s of +#: sleeping. +FAST_BURST = {"video_burst_duration_ms": 200, "video_burst_fps": 10} + + +def _solver(**overrides) -> PageSolver: + return PageSolver(config=PageSolverConfig(**{**FAST_BURST, **overrides})) + + +class _Element: + """An element whose screenshot always works, instantly.""" + + def __init__(self) -> None: + self.shots = 0 + + def screenshot(self, **kwargs) -> None: + self.shots += 1 + + +def test_the_budget_is_derived_from_what_a_recording_actually_costs(): + """A longer burst carries its own budget rather than reintroducing the bug. + + The whole failure was a fixed number that had no relationship to the work, + so a constant here would be the same mistake with a friendlier value. + """ + cfg = PageSolverConfig() + assert cfg.video_budget_ms() == (cfg.video_burst_duration_ms + + cfg.keyframe_wait_timeout_ms + + cfg.video_extra_inference_ms) + + longer = PageSolverConfig(video_burst_duration_ms=cfg.video_burst_duration_ms * 2) + assert longer.video_budget_ms() - cfg.video_budget_ms() == cfg.video_burst_duration_ms + + +def test_recording_extends_the_deadline_once_and_only_once(monkeypatch): + """Once per solve. A grant per burst would make the deadline unbounded on a + puzzle that re-records, which is the opposite failure and a worse one.""" + solver = _solver() + solver._reset_animated_state() + start = time.monotonic() * 1000.0 + solver._deadline_ms = start + solver.config.overall_solve_timeout_ms + monkeypatch.setattr(solver, "_screenshot", lambda *a, **k: None) + + for _ in range(3): + with pytest.raises(Exception): + # No real frames come back, so it raises after the grant — which is + # the part under test. + solver._record_keyframes(_Element()) + + granted = solver._deadline_ms - (start + solver.config.overall_solve_timeout_ms) + assert round(granted) == solver.config.video_budget_ms() + + +def test_a_caller_who_turned_recording_off_gets_no_extension(monkeypatch): + """`video_solve_enabled=False` already means "fail fast rather than spend the + recording time"; silently extending that caller's deadline would ignore the + one switch they used to say so.""" + solver = _solver(video_solve_enabled=False) + solver._reset_animated_state() + start = time.monotonic() * 1000.0 + solver._deadline_ms = start + solver.config.overall_solve_timeout_ms + monkeypatch.setattr(solver, "_screenshot", lambda *a, **k: None) + + with pytest.raises(Exception): + solver._record_keyframes(_Element()) + assert solver._deadline_ms == start + solver.config.overall_solve_timeout_ms + + +def test_a_burst_is_never_abandoned_partway_for_being_over_budget(monkeypatch): + """The regression test. + + A half-recorded burst is WORTHLESS — the slicer reads the clip's temporal + structure, so stopping at frame 27 of 40 does not give a shorter answer, it + gives a recording that may not contain the screen the answer is on. The old + per-frame `_check_deadline` threw away both the frames and the seconds spent + making them. Here the deadline is already blown when the burst starts, and + every frame must still be taken. + """ + solver = _solver() + solver._reset_animated_state() + # Deep in the red: even after the grant there is no budget left at all. + solver._deadline_ms = time.monotonic() * 1000.0 - 10_000 + solver._video_budget_granted = True # pretend the grant already happened + + element = _Element() + monkeypatch.setattr(solver, "_screenshot", lambda *a, **k: element.screenshot()) + + with pytest.raises(CaptchaSolveError) as excinfo: + solver._record_keyframes(element) + + # It refused BEFORE recording anything, rather than stopping halfway. + assert element.shots == 0 + assert "not starting one that would be cut off" in str(excinfo.value) + # …and it names the knobs, because "your budget cannot fit a recording" is + # only actionable if you know which number to move. + assert "overall_solve_timeout_ms" in str(excinfo.value) + + +def test_the_default_budget_is_enough_for_an_escalation_on_the_last_round(): + """The arithmetic the live failures came down to. + + Five still rounds at ~7 s is 35 s — inside the 45 s cap, and exactly where + the animated probe arms after two identical answers. The recording that + follows has to fit in what is left, and before this change it could not. + """ + cfg = PageSolverConfig() + spent_on_rounds = (cfg.max_solve_loops - 1) * 7_000 + left = cfg.overall_solve_timeout_ms - spent_on_rounds + cfg.video_budget_ms() + assert left >= cfg.video_burst_duration_ms + cfg.keyframe_wait_timeout_ms