Skip to content

fix(cli): bound capture runtime stages - #2928

Merged
miguel-heygen merged 1 commit into
mainfrom
fix/studio-5419-capture-runtime
Jul 31, 2026
Merged

fix(cli): bound capture runtime stages#2928
miguel-heygen merged 1 commit into
mainfrom
fix/studio-5419-capture-runtime

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

What

Add a cooperative post-navigation capture budget and stable phase breadcrumbs. Bound optional fonts, assets, screenshots, media, vision, and contact-sheet entry points without changing the navigation timeout.

Stack 1/4 for STUDIO-5419. Next: fix/studio-5419-capture-runtime-tests.

Why

Optional capture stages had no shared deadline, so HyperFrames could outlive EF's outer watchdog and leave only a caller-side timeout. This PR establishes the runtime contract used by the rest of the stack.

How

Track one monotonic deadline after navigation, pass live remaining-budget callbacks into optional stages, emit non-sensitive phase records, and expose the final phase in JSON output. Changed LOC: 661.

Test plan

  • Unit tests added/updated: 34 focused tests passed
  • Manual testing performed: CLI typecheck and pre-commit lint, format, fallow, and typecheck passed
  • Documentation updated (not applicable)

Comment thread packages/cli/src/capture/contentExtractor.ts

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice cooperative-budget contract — the schema-versioned phase record (hyperframes.capture.phase.v1), monotonic post-navigation deadline, and threaded remainingMs give EF a real handoff instead of a "I've been sitting here for a while" heuristic. APPROVE as the base of the stack.

Things I checked and liked:

  1. Font attempt-cap re-order in assetDownloader.ts — moving familyCount++ / count++ BEFORE fetchBuffer (was AFTER if (buffer)) is a real defect fix, not just plumbing. With the old code, a slow/failing font endpoint could bypass MAX_TOTAL_FONTS=30 because failed fetches didn't count. The new test at assetDownloader.test.ts:44-48 (35 URLs → asserts exactly 30 fetch attempts) captures this cleanly. Same for the six-per-family cap.
  2. runBoundedVisionRequest timeout racetimer fires → reject(VisionRequestTimeoutError) runs BEFORE controller.abort(), so Promise.race always resolves with the typed timeout error even when the SDK ignores abort. Necessary for @google/genai per the memory this stack encodes. Clean.
  3. Pre-nav remainingMs() returns budgetMs unbounded before postNavigationDeadline is set (line 613-616). Every pre-nav call sees full budget; every post-nav call sees the deadline. Correct — page-load has its own timeout opt and shouldn't share the same budget.
  4. phase("browser", "started") fires at line 646, before loadEnvFile / browser launch — so EF sees the first phase event even if browser init throws. Good.
  5. skipVision returns empty captions early (line 384), and the hasVisionKey computation at index.ts line 693 correctly includes !skipVision — so the fallback header prose matches actual behavior instead of pretending the key is present.
  6. CLI HYPERFRAMES_CAPTURE_PHASE prefix on stderr via diag.notice — machine-readable framing that stays out of stdout JSON.
  7. indexBudget.test.ts source-string assertion — I know these are fragile, but the invariant (a template-literal JS if (Date.now() >= lazyLoadDeadline) break; inside page.evaluate) genuinely can't be reached with a behavioral test. This is the right trade-off; just accept the maintenance friction.

Nits (none blocking):

  1. Lottie stages not budget-threaded internally — the gate is if (discoveredLotties.length > 0 && remainingMs() > 0) at index.ts line 719, but saveLottieAnimations / renderLottiePreviews don't receive remainingMs in this PR. A hang inside either won't be deadline-enforced; only the outer gate protects against them. I see #2930 addresses this — worth calling that out here as "known follow-up in stack tip" in the PR body so a future reader doesn't wonder.
  2. Vision per-request timeout Math.min(requestTimeoutMs, remainingMs) — when remainingMs is small (say 500ms), every OpenRouter/Gemini call gets 500ms and effectively all time out. The timedOutCount + summary warning handles this gracefully (no throw, no crash), and it's arguably correct behavior (don't start work you can't finish). Just be aware that during tight-budget captures the "vision degraded" state will be normal, not an anomaly worth investigating.
  3. captureVideoManifest receives downloadBudgetMs as a snapshot of remainingMs() at call time (index.ts line 748) — not a live callback. Video downloads happen serially before downstream stages so this is fine; the interlock holds because downstream stages recompute from remainingMs(). Just noting that the snapshot boundary is here (in case it needs to become live in the follow-up stack).

CI: build + lint + producer-integration + typecheck + shim (linux/mac/win) all green at ca9bba1d; Windows tests + CLI smoke pending.

— Via

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at ca9bba1d.

Clean primitive: a monotonic postNavigationDeadline anchored to page1.goto completion, a remainingMs() closure passed as { remainingMs } into every optional stage, and schema-tagged (hyperframes.capture.phase.v1) CapturePhaseProgress events fed through onPhase. The commit-to-callback-not-snapshot design is the load-bearing choice for this fix — a caller reading the callback at each boundary observes the current remaining budget, not one frozen when the stage entered. That's carried through consistently for fonts, assets, contact-sheets, screenshots, and vision.

Concerns

  • Video stage is the one exception to the callback-not-snapshot rule at this SHA. packages/cli/src/capture/index.ts at the captureVideoManifest call site passes downloadBudgetMs: videoBudgetMs — a scalar snapshot of remainingMs() taken before the video stage runs. Inside mediaCapture.ts (captureVideoManifestsampleVideoDom → the download loop), the loop bounds itself with remainingVideoDownloadTimeoutMs(dlStart, downloadBudgetMs) where dlStart = Date.now() is taken after sampleVideoDom completes (up to sampleMs ≈ 12s). Net: the video stage can overrun the shared deadline by up to sampleMs. This is exactly the "outlives EF's watchdog" regression the PR is meant to close — the fix is real for every other optional stage but skipped for video. #2930 closes this gap by threading liveRemainingMs through captureVideoManifest and sampleVideoDom. Worth calling out here because if #2928 landed on main in isolation (unlikely given the stack intent, but the branches merge base-first), STUDIO-5419 still recurs for sites with video content.
  • Phase-event contract is asymmetric — degraded-only branches skip started. The else branches for fonts (index.ts:538), assets (index.ts:613), vision (index.ts:669 and :674), and the phase("complete", "completed") at :812 all emit a terminal state without a paired phase(..., "started"). CapturePhaseProgress doesn't document that started is optional, so any watchdog reconstructing a per-phase state machine (started → {completed|degraded}) sees skipped phases as terminal-only orphans. Either document the invariant in CapturePhase/CapturePhaseProgress JSDoc ("a phase MAY appear as a terminal degraded event without a started predecessor when it never ran") or emit a synthetic started before the terminal event. Impacts EF's ability to build a reliable capture-timeline visualisation on top of HYPERFRAMES_CAPTURE_PHASE records.
  • lastPhase is not emitted in the error-path JSON. packages/cli/src/commands/capture.ts catch block writes { ok: false, error: errMsg } (no lastPhase) and a BLOCKED.md, then exits. The success path writes lastPhase: result.lastPhase. A caller consuming stdout JSON alone loses "how far did capture get" on exactly the failure the PR is meant to diagnose (outer-watchdog kill → thrown error). The HYPERFRAMES_CAPTURE_PHASE diag notices to stderr partially compensate, but the stdout JSON becomes a strictly weaker signal than the streamed events on the error path. Adding a captured lastPhase (initialised to the module-level browser/started record and updated by onPhase) into the caller's scope, then emitted in the catch, would restore parity.

Nits

  • postNavigationBudgetMs = 0 is coerced to default (120 000 ms). Number.isFinite(postNavigationBudgetMs) && postNavigationBudgetMs > 0 at packages/cli/src/capture/index.ts:72-74 treats zero/negative/invalid identically. A programmatic caller wanting "no post-navigation work" can't express it — they'd get the full budget silently. The JSDoc (default: 120000) doesn't disclose the fallback also fires on 0. Either accept 0 as "disable post-navigation stages" or document the coercion. Given the fix motivation, the fallback-is-safety-floor semantics is defensible — just make it discoverable.
  • packages/cli/src/capture/indexBudget.test.ts is a source-text tripwire, not a behavioural test. The three expect(source).toContain(...) assertions match on internal template-literal substrings inside a page1.evaluate block. They'd trip on any whitespace refactor without catching a real regression, and they verify zero runtime behaviour. #2930 deletes it; nothing of substantive coverage is lost. Noting because for the intervening merge state (main immediately post-#2928), this fragile pattern is protecting a browser-side eval string that no other test exercises.

What I didn't verify

  • Windows CI (Tests on windows-latest, Render on windows-latest) was still pending at review time; Ubuntu + macOS matrix is green.
  • I reasoned about (didn't repro) the video-stage overrun; the sub-agent I ran on this PR confirmed the snapshot semantics against the diff, and #2930's addition of liveRemainingMs(opts ?? {}, ...) in sampleVideoDom is prima-facie evidence the issue is real.

Review by Rames D Jusso

@miguel-heygen
miguel-heygen force-pushed the fix/studio-5419-capture-runtime branch from ca9bba1 to 765a5ae Compare July 31, 2026 20:30
@miguel-heygen
miguel-heygen merged commit 765a5ae into main Jul 31, 2026
44 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-5419-capture-runtime branch July 31, 2026 20:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants