fix(producer): retry capture on a CDP Page.captureScreenshot refusal - #3892
fix(producer): retry capture on a CDP Page.captureScreenshot refusal#3892miga-heygen wants to merge 1 commit into
Conversation
Chromium's CDP layer can refuse a screenshot capture outright ("Protocol
error (Page.captureScreenshot): Unable to capture screenshot") with no
timeout wording at all. classifyCaptureFailure() didn't recognize the
message, so it fell through to the fatal "authoring" bucket and neither
capture-retry path (the disk-capture retry loop, or the streaming-capture
pinned-fallback retry) ever attempted a retry — including under
--low-memory-mode, whose single-worker plain-screenshot capture never
engages either path's other retry conditions.
Adds the literal error text to classifyCaptureFailure()'s transient_browser
bucket, and threads the resulting classification into
shouldRetryViaPinnedFallback() as a routing-independent retry condition, so
the streaming path recovers on a fresh session the same way the disk path
already does for other transient browser failures.
Co-Authored-By: Miguel Angel <miguel.sierra@heygen.com>
terencecho
left a comment
There was a problem hiding this comment.
Verdict — APPROVE
Head verified: 51c75f08e32f946e01289c2b367f334587269fbe. Small focused fix (4 files, +71/-5) that widens the transient-classification net to one specific CDP screenshot-refusal wording that was silently falling into the fatal authoring bucket.
The three axes I was asked to verify
1. Retry is scoped to the transient screenshot-refusal case. The classification widening is one regex added to TRANSIENT_BROWSER_ERROR_PATTERNS:
/Protocol error \(Page\.captureScreenshot\): Unable to capture screenshot/iAnchored to BOTH the CDP method name AND the specific reason string. Other CDP errors on the same method (different reason strings) do not match; the fatal-authoring pattern Composition has zero duration. Runtime ready: true is a different pattern list and stays fatal. The parametrized test proves the boundary:
Protocol error (Page.captureScreenshot): Unable to capture screenshot→transient_browser✓Protocol error (Page.captureScreenshot): waiting for debugger timed out→protocol_timeout(matched first byPROTOCOL_TIMEOUT_PATTERNS, unchanged) ✓[Parallel] Capture failed: Worker 0: Protocol error (Page.captureScreenshot): Unable to capture screenshot→transient_browser(unwrapped-Parallel prefix still matches the substring) ✓
Genuine composition-broken errors are unaffected — different pattern list, different reason wording.
2. Bounded retries — no infinite loop.
- Disk path (
executeDiskCaptureWithAdaptiveRetry): existingMAX_TRANSIENT_CAPTURE_RETRIES = 1cap gates the transient-retry branch, then falls back to worker-halving on the next failure. The parametrized regression test"retries ONCE at the same worker count on … with zero progress"iterates both the tab-died and CDP-refusal shapes and confirms both hit the same one-shot behavior via the pre-existing cap. - Streaming path (
shouldRetryViaPinnedFallback): returnsboolean— a single decision, non-looping by construction. The newisTransientCaptureError === truebranch adds one more short-circuit but doesn't create a loop. - Cancellation always wins:
if (args.isCancellation || args.isEncoderInterrupted) return false;is checked FIRST, before the new transient branch. Test"never retries a transient capture-call refusal after cancellation"pins this.
3. Distinguishes Chromium-refusal from genuinely-broken composition.
classifyCaptureFailuretruth-table stays intact:authoringbucket (fatal) still catchesComposition has zero duration. Runtime ready: true; new refusal wording istransient_browser; timed-out variant isprotocol_timeout(checked first). Three separate pattern lists, each anchored to distinct wording.- The wiring on the streaming path uses
isTransientBrowserError(err)(the engine's own helper that checksclassifyCaptureFailure(...).kind) — same classifier as the disk path, so both paths agree on what "transient" means. No divergent discrimination.
Extra correctness notes
-
shouldRetryViaPinnedFallbacktruth-table exhaustive by branch:- Cancellation OR encoder-interrupted →
false(short-circuit, unchanged) - Verify error OR de-capture-error →
true(unchanged) - Renderer stall OR sequential stall →
true(unchanged) - Transient capture error →
true(NEW; routing-independent like the stalls above) - Otherwise →
deWorkerInversion === "inverted" || deParallelRouter === "routed"(unchanged fallback)
New branch order (after stalls, before routing) matches the sibling stall-retries — consistent placement for a routing-independent transient. Test
"retries a transient capture-call refusal even with no pinned routing"proves the--low-memory-modecase (no pinned routing, no drawElement) that previously had zero fallback is now covered. - Cancellation OR encoder-interrupted →
-
Single new call site:
isTransientCaptureError: isTransientBrowserError(err)inexecuteRenderPipeline. No other callers, so no legacy path silently getting the pre-fix behavior. -
parallelCoordinator.ts(not in diff) benefits from the reclassification transparently — a single worker's refusal now emits atransient_browser-classified error instead ofauthoring, so sibling workers don't abort as if the composition were broken. This is a side effect of the classification change, not a code change in the coordinator itself. -
Mutation-tested per PR body: stashing the two source-file changes (keeping tests) produced 5 assertion failures on the old fatal-on-first-occurrence behavior — the new tests actually catch the bug.
CI
10 pass, 0 fail, 0 pending on this head. mergeStateStatus=blocked waiting on this approval (last-push-approval requirement). No prior reviews on this SHA.
Stamp.
— tai
Summary
Chromium's CDP layer can refuse a screenshot capture outright —
Protocol error (Page.captureScreenshot): Unable to capture screenshot— with no timeout wording at all.classifyCaptureFailure()didn't recognize this message in any of its pattern lists, so it fell through to the fatalauthoringbucket. Both of this codebase's capture-retry mechanisms gate on that classification, so neither ever retried:executeDiskCaptureWithAdaptiveRetry) only bounces a transient failure once viaisRecoverableParallelCaptureError, which checksclassifyCaptureFailure(...).kind.shouldRetryViaPinnedFallback) only retries drawElement-specific failures or a failure on a worker count already pinned by inversion/the parallel router —--low-memory-mode's single-worker plain-screenshot capture engages neither, so that mode had no whole-render fallback for this error at all.Changes
packages/engine/src/services/captureFailure.ts: added the literal error text toTRANSIENT_BROWSER_ERROR_PATTERNS, anchored to the specific reason string (not just the CDP method name) so an unrelated, genuinely deterministicPage.captureScreenshoterror isn't swept in too. A timed-out variant of the same call still classifiesprotocol_timeout— that pattern list is checked first, unchanged.packages/producer/src/services/renderOrchestrator.ts:shouldRetryViaPinnedFallback()gained a newisTransientCaptureError?: booleanargument (computed at its one call site from the engine's ownisTransientBrowserError()helper), treated as routing-independent — the same way an existing drawElement-renderer-stall or sequential-capture-stall is retryable regardless of whether a worker count was pinned.classifyCaptureFailure(...).kind === "transient_browser", so the pattern-list addition alone fixes it.Test plan
bunx vitest run packages/engine/src/services/captureFailure.test.ts packages/producer/src/services/renderOrchestrator.test.ts— 241/241 passexecuteDiskCaptureWithAdaptiveRetrywith a mocked capture throwing this exact error and confirms it retries once and recoversbunx tsc --noEmitclean onpackages/engineandpackages/producerbunx oxlint/bunx oxfmt --writecleanparallelCoordinator.tsthat this reclassification also stops a single worker's screenshot refusal from aborting sibling workers as a "fatal" failure — consistent with how other transient browser errors (Target closed,Session closed, etc.) already behave thereMAX_TRANSIENT_CAPTURE_RETRIES = 1before falling back to worker-halving; the streaming path's retry is a single, non-looping attempt