Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions js/src/animated-budget.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
38 changes: 36 additions & 2 deletions js/src/solver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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` : '')
+ '.');
}

/*
Expand Down Expand Up @@ -2478,6 +2487,27 @@ export class CaptchaKrakenSolver {
private async recordKeyframeBurst(captchaElement: ElementHandle): Promise<string> {
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;

Expand Down Expand Up @@ -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;
}

/**
Expand Down
31 changes: 31 additions & 0 deletions js/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
84 changes: 83 additions & 1 deletion python/src/captchakraken/page_solver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading