Skip to content

fix(cli): propagate the live capture budget - #2930

Merged
miguel-heygen merged 2 commits into
mainfrom
fix/studio-5419-capture-budget-propagation
Jul 31, 2026
Merged

fix(cli): propagate the live capture budget#2930
miguel-heygen merged 2 commits into
mainfrom
fix/studio-5419-capture-budget-propagation

Conversation

@miguel-heygen

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

Copy link
Copy Markdown
Collaborator

What

Propagate the live remaining capture budget through provider and media boundaries, bound provider response-body reads, and validate budget milliseconds before work starts.

Stack 3/4 for STUDIO-5419. Base: fix/studio-5419-capture-runtime-tests. Next: fix/studio-5419-blocked-and-degraded-captures.

Why

A timeout snapshot can expire while a phase is already waiting. The caller then observes a hang even though the capture command accepted a budget.

How

Pass deadline callbacks instead of static values and race response/body settlement against the remaining budget, including SDKs that ignore abort. Changed LOC: 466.

Test plan

  • Unit tests added/updated: 43 focused tests passed
  • Manual testing performed: CLI typecheck passed
  • Documentation updated (not applicable)

@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.

Live-budget propagation into the primitives that #2928 gated externally. APPROVE.

Notable behavior:

  1. resolveVisionPhaseCompletion(outcome, remainingMs) distinguishes request-timeout from budget-exhausted — new reason: "request-timeout" in the CapturePhaseProgress union. Priority order is correct: budgetExhausted || remainingMs<=0 wins over timedOutRequests>0 (a capture that ran out of budget WHILE also timing out reports the budget cause, which is the more actionable signal for EF). Good.
  2. Lottie now budget-threaded internallysaveLottieAnimations gates per-iteration + caps AbortSignal.timeout at Math.min(10_000, remainingMs). renderLottiePreviews has three fresh gates (before readdir continuation, before newPage(), and immediately before screenshot()). break inside the inner try correctly runs the finally that closes previewPage, so no page leak on budget-exhaust mid-iteration. Verified with the "does not open another Lottie preview page after the live budget expires" test (line 261-282).
  3. captureFullPagePlate now checks budget between docHeight measurement and page.screenshot({fullPage: true}) — the screenshot call is the expensive one (native Chrome capture of an 8000px plate); gating just before it is the right seam. The fake-timer test at line 630-644 (evaluate advances time by 100ms mid-call, then post-call check returns null) validates the exact narrow window.
  4. sampleVideoDom waits Math.min(2000, budgetMs-elapsed, liveRemaining) — if any of the three is 0, waitMs=0 and setTimeout is skipped entirely (not zero-ms setTimeout that microtask-loops). While-loop next iteration exits on liveRemainingMs > 0 check. Clean.
  5. --capture-budget description contains all the safety keywords — "post-navigation", "cooperative", "not a hard wall-clock timeout", "already-started native/core work", and "--timeout". Test at line 51-64 asserts every substring. The description-is-part-of-the-contract shape is unusual but here it's justified: this flag WILL be miscategorized as a hard timeout by anyone reading only the name, and the corrective language belongs in the description proper (not just docs). Fine.
  6. parseCaptureBudget validationNumber.isInteger rejects "Infinity" (returns false), "0.5", "not-a-number", and <= 0. Test coverage is comprehensive (line 77-97).

Nits (none blocking):

  1. indexBudget.test.ts is deleted with no replacement — the invariant it guarded (Math.min(15_000, remainingMs()) bound + JS-side deadline gate in the page.evaluate template literal) is no longer test-locked. I understand the deletion (source-string tests are fragile), but the specific invariant is the one place in the code where the budget lives inside a template-literal string that's evaluated in the browser, so behavioral testing genuinely can't reach it. Consider re-adding at least the Math.min(15_000, remainingMs()) substring assertion; the other two (lazyLoadDeadline name + if (Date.now() >= lazyLoadDeadline) break; shape) can go.
  2. liveRemainingMs(budget, fallbackMs) uses different fallbacks per site — 10_000 for save/download, 1 for lottie/screenshot gates, 2000 for sampleVideoDom wait clamp, 120_000 for video download. That's fine but the heterogeneity means "no budget provided" behaves very differently in different call sites (video download runs to 120s, screenshot gate runs to 1ms). Documented by the parameter name; just note that no-budget tests are effectively no-op-timeout scenarios that don't exercise the timeout path. That matters for the tests in this PR that intentionally pass budget: {} — those test the shape but not the timing.
  3. The assetDownloader.ts comment update (line 267-272) is scope-adjacent to #2928's font-attempt cap change and would arguably read better as part of that PR's diff. Trivial — Graphite handles the interleaving fine either way.
  4. Video preview screenshot gate at line 543-553 is if (liveRemainingMs(opts ?? {}, 1) > 0) — the fallback of 1 means "unbounded" in the no-budget test case (any positive value passes > 0). That's semantically "unlimited" here which is correct. Consistent with earlier code.

CI clean at ce7ffcee. remainingVideoDownloadTimeoutMs unit tests + all four new behavioral tests (lottie fetch, lottie preview, video sampling, scroll settling, plate screenshot) are the strongest layer of the stack — thanks for the fake-timer discipline.

Stack-level observation: the combined package (#2928 sets contract + gates externally, #2929 covers regressions, #2930 threads it through the primitives) is a clean 3-PR shape. EF gets a schema-versioned phase stream, HF gets cooperative deadlines all the way down, and rollback is per-PR if any layer misbehaves. Ready to ship.

— 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 ce7ffcee.

Completes the fix. #2928 established the callback contract; this PR propagates it into provider layers where SDKs may ignore AbortSignal — the Gemini generateContent path, media probe walks, screenshot capture, and asset/font fetches. parseCaptureBudget in packages/cli/src/commands/capture.ts rejects "0", "-1", "0.5", "Infinity", and "not-a-number" before capture starts, so bad CLI input surfaces at the argv boundary rather than mid-run. resolveVisionPhaseCompletion codifies the priority: budget-exhausted beats request-timeout when both are true, which reads as the right call — budget exhaustion is the higher-order cause and gets recorded first. The new reason: "request-timeout" in the CapturePhaseProgress union closes the gap where a vision batch produced timedOutRequests > 0 but a positive remainingMs, and downstream watchers can now distinguish "we ran out of budget" from "provider hung on request N".

Concerns

  • OpenRouter response-body reads are still unbounded — the PR body claims "bound provider response-body reads" but this is not honored on the OpenRouter path. packages/cli/src/capture/contentExtractor.ts:60-78runBoundedVisionRequest clears its setTimeout inside finally and only calls controller.abort() from within the timer callback. Once fetch() wins the race with headers, the timer is cleared and the AbortController is orphaned. The subsequent const detail = await res.text().catch(() => "") at :311 and const data = (await res.json()) as { ... } at :314 are executed with no active signal — no budget race, no size cap, no timeout. A provider that returns headers within timeoutMs but streams (or stalls) the body will hang past the vision timeout and past the capture budget, which is the exact "SDKs that ignore abort" failure mode the PR is closing. Contrast with packages/cli/src/capture/assetDownloader.ts's fetchBuffer(url, timeoutMs) — it uses AbortSignal.timeout(timeoutMs) directly on the fetch call, so the signal stays armed through arrayBuffer(). Two viable fixes: (a) use AbortSignal.timeout(timeoutMs) in runBoundedVisionRequest and drop the manual timer-then-clear pattern, so the signal aborts a stalled body read; (b) wrap res.text() / res.json() in another Promise.race against remainingMs(). Either closes the OpenRouter body-read gap. Gemini path is unaffected — the SDK returns a parsed response, not a streaming Response.

Nits

  • RemainingBudget = {} default on every new signature (saveLottieAnimations, renderLottiePreviews, captureFullPagePlate, sampleVideoDom via opts ?? {}, etc.) makes each budget guard silently a no-op for any caller that forgets to plumb it — budget.remainingMs?.() ?? fallback returns the fallback (10_000, 2000, 1), which trips guard > 0 as true. Every call site in packages/cli/src/capture/index.ts does thread the callback through, so today this is defence-in-depth rather than a live regression. But the type-level default hides future additions where a new caller is added and the guard silently disables. Making the parameter required (and having any legitimate no-budget caller pass an explicit { remainingMs: () => Number.POSITIVE_INFINITY }) would make the guard load-bearing at compile time.
  • packages/cli/src/capture/mediaCapture.ts:506-510 drops the aggregate downloadBudgetMs ceiling when opts.remainingMs is defined. The iteration break uses liveRemainingMs(opts ?? {}, remainingVideoDownloadTimeoutMs(dlStart, downloadBudgetMs)) — that's fallback semantics: if opts.remainingMs is present, the aggregate cap is dropped entirely rather than Math.min'd in. Compare with the per-request timeout at :556-559 which correctly takes Math.min(remainingVideoDownloadTimeoutMs(...), liveRemainingMs(opts, ...)). Currently harmless because index.ts:499 passes downloadBudgetMs: videoBudgetMs (a snapshot of remainingMs()) and both signals shrink in parallel with the live budget. A future caller that wants a stricter video-specific cap would have it silently bypassed while remainingMs() still has time — same shape as the concern above.
  • indexBudget.test.ts deletion is coverage-neutral in practice, but the three source-text assertions on the lazy-load loop are gone with no direct behavioural replacement (the loop is inside page1.evaluate and hard to exercise in vitest). The invariant is now defended only by review; the deleted test was low-signal, so no substantive loss.

What I didn't verify

  • Windows CI at ce7ffcee is green. I did not repro the vision-body-unbounded failure against a real slow-body provider — the finding is from tracing signal ownership through runBoundedVisionRequest and its callers.
  • The stack's 4/4 (fix/studio-5419-blocked-and-degraded-captures) may already fix the OpenRouter body-read gap. If so, the finding above can defer there instead of landing in this PR — worth confirming.

Review by Rames D Jusso

@miguel-heygen
miguel-heygen force-pushed the fix/studio-5419-capture-budget-propagation branch from ce7ffce to d360760 Compare July 31, 2026 20:30
@miguel-heygen
miguel-heygen force-pushed the fix/studio-5419-capture-runtime-tests branch from 647a5ec to 45aead8 Compare July 31, 2026 20:30
Base automatically changed from fix/studio-5419-capture-runtime-tests to main July 31, 2026 20:32
@miguel-heygen
miguel-heygen merged commit d360760 into main Jul 31, 2026
50 of 85 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-5419-capture-budget-propagation 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.

3 participants