authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-04 19:41:58-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 19:41:58-07:00
logde1b798c6ed4223cabd64f56eadcc8f512963043
tree2251e9f01be5b68fc9d480d4d60ac136a4facdbd
parent7f8cf30ca479474f8c6e0a85f6e0a1169bcca365
signature Signed by PGP key B5690EEEBB952194

fix(preview): bound automation waits and screenshot captures (#4685)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

5 files changed, 383 insertions(+), 14 deletions(-)

apps/desktop/src/preview/Manager.test.ts+143-2
......@@ -2060,10 +2060,30 @@ describe("PreviewManager", () => {
20602060 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,
20612061 );
20622062
2063 // Chromium reports UnknownVizError while a hidden guest warms its
2064 // first compositor frame, so transient failures are retried.
2065 capturePage.mockClear();
2066 capturePage.mockRejectedValueOnce(new Error("UnknownVizError"));
2067 capturePage.mockRejectedValueOnce(new Error("UnknownVizError"));
2068 const retriedFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe(
2069 Effect.forkChild({ startImmediately: true }),
2070 );
2071 yield* TestClock.adjust(1_000);
2072 const retriedExit = yield* Fiber.join(retriedFiber);
2073 expect(Exit.isSuccess(retriedExit)).toBe(true);
2074 expect(capturePage).toHaveBeenCalledTimes(3);
2075
2076 // A persistent failure still surfaces once the retries are spent.
2077 capturePage.mockClear();
20632078 const captureCause = new Error("capture failed");
2064 capturePage.mockRejectedValueOnce(captureCause);
2065 const exit = yield* Effect.exit(manager.captureScreenshot("tab_1"));
2079 capturePage.mockRejectedValue(captureCause);
2080 const failingFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe(
2081 Effect.forkChild({ startImmediately: true }),
2082 );
2083 yield* TestClock.adjust(1_000);
2084 const exit = yield* Fiber.join(failingFiber);
20662085 expect(Exit.isFailure(exit)).toBe(true);
2086 expect(capturePage).toHaveBeenCalledTimes(3);
20672087 if (Exit.isSuccess(exit)) return;
20682088 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
20692089 expect(error).toMatchObject({
......@@ -2397,6 +2417,127 @@ describe("PreviewManager", () => {
23972417 ),
23982418 );
23992419
2420 effectIt.effect("stops capture retries when the tab swaps during the retry delay", () =>
2421 withManager((manager) =>
2422 Effect.gen(function* () {
2423 const capturePage = vi.fn(async () => ({
2424 toPNG: () => Buffer.from("png"),
2425 toJPEG: () => Buffer.from("jpeg"),
2426 getSize: () => ({ width: 100, height: 80 }),
2427 }));
2428 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42));
2429 yield* manager.createTab("tab_1");
2430 yield* manager.registerWebview("tab_1", 42);
2431
2432 capturePage.mockRejectedValueOnce(new Error("UnknownVizError"));
2433 const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe(
2434 Effect.forkChild({ startImmediately: true }),
2435 );
2436 // Let the rejection schedule its retry before replacing the guest.
2437 yield* TestClock.adjust(60);
2438 expect(capturePage).toHaveBeenCalledTimes(1);
2439 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43));
2440 yield* manager.registerWebview("tab_1", 43);
2441 yield* TestClock.adjust(1_000);
2442 const exit = yield* Fiber.join(fiber);
2443
2444 expect(Exit.isFailure(exit)).toBe(true);
2445 expect(capturePage).toHaveBeenCalledTimes(1);
2446 expect(writeFile).not.toHaveBeenCalled();
2447 }),
2448 ),
2449 );
2450
2451 effectIt.effect("discards a screenshot that resolves after its guest is replaced", () =>
2452 withManager((manager) =>
2453 Effect.gen(function* () {
2454 const image = {
2455 toPNG: () => Buffer.from("stale-png"),
2456 toJPEG: () => Buffer.from("stale-jpeg"),
2457 getSize: () => ({ width: 100, height: 80 }),
2458 };
2459 const pending = Promise.withResolvers<typeof image>();
2460 const capturePage = vi.fn(() => pending.promise);
2461 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42));
2462 yield* manager.createTab("tab_1");
2463 yield* manager.registerWebview("tab_1", 42);
2464
2465 const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe(
2466 Effect.forkChild({ startImmediately: true }),
2467 );
2468 yield* TestClock.adjust(0);
2469 expect(capturePage).toHaveBeenCalledOnce();
2470 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43));
2471 yield* manager.registerWebview("tab_1", 43);
2472 pending.resolve(image);
2473 const exit = yield* Fiber.join(fiber);
2474
2475 expect(Exit.isFailure(exit)).toBe(true);
2476 expect(capturePage).toHaveBeenCalledOnce();
2477 expect(writeFile).not.toHaveBeenCalled();
2478 }),
2479 ),
2480 );
2481
2482 effectIt.effect("releases snapshot control when every capture attempt stalls", () =>
2483 withManager((manager) =>
2484 Effect.gen(function* () {
2485 const capturePage = vi.fn(() => new Promise<TestCapturedPreviewImage>(() => {}));
2486 const wc = makeTestPreviewWebContents(capturePage);
2487 Object.assign(wc, { isDevToolsOpened: () => false });
2488 Object.assign(wc.debugger, {
2489 sendCommand: vi.fn(async (method: string, params?: Record<string, unknown>) => {
2490 if (method === "Runtime.evaluate") {
2491 return {
2492 result: {
2493 value:
2494 params?.["expression"] === "42"
2495 ? 42
2496 : {
2497 url: "https://example.com",
2498 title: "Example",
2499 loading: false,
2500 visibleText: "Example",
2501 interactiveElements: [],
2502 },
2503 },
2504 };
2505 }
2506 return method === "Accessibility.getFullAXTree" ? { nodes: [] } : undefined;
2507 }),
2508 });
2509 fromId.mockReturnValue(wc);
2510 yield* manager.createTab("tab_1");
2511 yield* manager.registerWebview("tab_1", 42);
2512
2513 const snapshot = yield* Effect.exit(manager.automationSnapshot("tab_1")).pipe(
2514 Effect.forkChild({ startImmediately: true }),
2515 );
2516 yield* TestClock.adjust(100);
2517 expect(capturePage).toHaveBeenCalledOnce();
2518 const evaluate = yield* manager
2519 .automationEvaluate("tab_1", { expression: "42" })
2520 .pipe(Effect.forkChild({ startImmediately: true }));
2521 expect(evaluate.pollUnsafe()).toBeUndefined();
2522
2523 yield* TestClock.adjust(4_000);
2524 const exit = yield* Fiber.join(snapshot);
2525 expect(Exit.isFailure(exit)).toBe(true);
2526 expect(capturePage).toHaveBeenCalledTimes(3);
2527 if (Exit.isSuccess(exit)) return;
2528 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
2529 expect(error).toMatchObject({
2530 _tag: "PreviewOperationError",
2531 operation: "automationSnapshot.capturePage",
2532 tabId: "tab_1",
2533 webContentsId: 42,
2534 cause: { _tag: "TimeoutError" },
2535 });
2536 expect(yield* Fiber.join(evaluate)).toBe(42);
2537 }),
2538 ),
2539 );
2540
24002541 effectIt.effect("grants each concurrent preview recording its own tab frame", () =>
24012542 withManager((manager) =>
24022543 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+50-4
......@@ -48,6 +48,7 @@ import * as Path from "effect/Path";
4848import * as Ref from "effect/Ref";
4949import * as Schema from "effect/Schema";
5050import * as Semaphore from "effect/Semaphore";
51import * as Schedule from "effect/Schedule";
5152import * as Scope from "effect/Scope";
5253import * as SynchronizedRef from "effect/SynchronizedRef";
5354
......@@ -113,6 +114,13 @@ const MAX_SCREENSHOT_WIDTH = 1280;
113114const RECORDING_ARM_GRACE_MS = 10_000;
114115const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);
115116const PICTURE_IN_PICTURE_JPEG_QUALITY = 80;
117/**
118 * Cold guests can reject capturePage with UnknownVizError or never settle it.
119 * Bound each attempt so snapshots release control even when Chromium stalls.
120 */
121const CAPTURE_PAGE_RETRY_ATTEMPTS = 3;
122const CAPTURE_PAGE_RETRY_DELAY_MS = 120;
123const CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS = 1_000;
116124const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480;
117125const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;
118126const PICTURE_IN_PICTURE_MIN_WIDTH = 240;
......@@ -639,6 +647,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
639647 try: evaluate,
640648 catch: (cause) => new PreviewOperationError({ ...errorContext, cause }),
641649 });
650 const capturePageWithRetry = Effect.fn("PreviewManager.capturePageWithRetry")(function* (
651 errorContext: PreviewOperationContext,
652 tabId: string,
653 wc: Electron.WebContents,
654 ) {
655 const requireCurrentGuest = Effect.gen(function* () {
656 const tabs = yield* SynchronizedRef.get(tabsRef);
657 if (wc.isDestroyed() || tabs.get(tabId)?.webContentsId !== wc.id) {
658 return yield* new PreviewWebContentsNotFoundError({ tabId, webContentsId: wc.id });
659 }
660 });
661 const capture = Effect.gen(function* () {
662 // Check after the retry delay, and again before accepting its result.
663 yield* requireCurrentGuest;
664 const image = yield* Effect.tryPromise({
665 // An abort-signal parameter makes a stalled promise interruptible.
666 try: (_signal) => wc.capturePage(),
667 catch: (cause) => new PreviewOperationError({ ...errorContext, cause }),
668 }).pipe(
669 Effect.timeout(CAPTURE_PAGE_ATTEMPT_TIMEOUT_MS),
670 Effect.catchTags({
671 TimeoutError: (cause) =>
672 Effect.fail(new PreviewOperationError({ ...errorContext, cause })),
673 }),
674 );
675 yield* requireCurrentGuest;
676 return image;
677 });
678 return yield* capture.pipe(
679 Effect.retry({
680 times: CAPTURE_PAGE_RETRY_ATTEMPTS - 1,
681 schedule: Schedule.spaced(CAPTURE_PAGE_RETRY_DELAY_MS),
682 while: isPreviewOperationError,
683 }),
684 );
685 });
642686 const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));
643687 const currentMillis = Clock.currentTimeMillis;
644688 const encodeJson = (errorContext: PreviewOperationContext, value: unknown) =>
......@@ -2648,13 +2692,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
26482692 const [createdAt, millis, image] = yield* Effect.all([
26492693 currentIso,
26502694 currentMillis,
2651 attemptPromise(
2695 capturePageWithRetry(
26522696 {
26532697 operation: "captureScreenshot.capturePage",
26542698 tabId,
26552699 webContentsId: wc.id,
26562700 },
2657 () => wc.capturePage(),
2701 tabId,
2702 wc,
26582703 ),
26592704 ]);
26602705 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`;
......@@ -3513,13 +3558,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
35133558 );
35143559 const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([
35153560 send("Accessibility.getFullAXTree"),
3516 attemptPromise(
3561 capturePageWithRetry(
35173562 {
35183563 operation: "automationSnapshot.capturePage",
35193564 tabId,
35203565 webContentsId: wc.id,
35213566 },
3522 () => wc.capturePage(),
3567 tabId,
3568 wc,
35233569 ),
35243570 Ref.get(diagnosticsRef),
35253571 Ref.get(actionTimelineRef),
apps/web/src/components/preview/PreviewAutomationHosts.tsx+12-8
......@@ -76,6 +76,7 @@ import {
7676 resolvePreviewAutomationOpenTab,
7777 resolvePreviewAutomationTarget,
7878} from "./previewAutomationTarget";
79import { resolveHostWaitBudgetMs, waitForHostReadiness } from "./previewAutomationHostBudget";
7980import { isPreviewViewportReady } from "./previewViewportReadiness";
8081import { shouldRollbackPreviewViewport } from "./previewViewportRollback";
8182
......@@ -95,25 +96,26 @@ const waitForDesktopOverlay = async (
9596 tabId: string,
9697 runtimeTabId: string,
9798 operation: PreviewAutomationRequest["operation"],
98 timeoutMs: number,
99 deadlineMs: number,
99100): Promise<void> => {
100 const deadline = Date.now() + timeoutMs;
101 while (Date.now() <= deadline) {
101 const waitBudgetMs = Math.max(0, deadlineMs - Date.now());
102 const ready = await waitForHostReadiness(deadlineMs, async () => {
102103 const state = assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, {
103104 operation,
104105 requestId,
105106 });
106107 if (state.desktopByTabId[tabId] && previewBridge && isPreviewWebviewRendering(runtimeTabId)) {
107108 const status = await previewBridge.automation.status(runtimeTabId);
108 if (status.available) return;
109 return status.available;
109110 }
110 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));
111 }
111 return false;
112 });
113 if (ready) return;
112114 throw new PreviewAutomationOverlayTimeoutError({
113115 requestId,
114116 environmentId: threadRef.environmentId,
115117 threadId: threadRef.threadId,
116 timeoutMs,
118 timeoutMs: waitBudgetMs,
117119 });
118120};
119121
......@@ -317,6 +319,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
317319
318320 const handleRequest = useCallback(
319321 async (request: PreviewAutomationRequest): Promise<unknown> => {
322 // Session sync and tab creation consume the same budget as overlay registration.
323 const hostDeadlineMs = Date.now() + resolveHostWaitBudgetMs(request.timeoutMs);
320324 const threadRef: ScopedThreadRef = {
321325 environmentId,
322326 threadId: request.threadId,
......@@ -378,7 +382,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
378382 readyTabId,
379383 runtimeTabId,
380384 request.operation,
381 request.timeoutMs,
385 hostDeadlineMs,
382386 );
383387 return {
384388 bridge,
apps/web/src/components/preview/previewAutomationHostBudget.test.ts created+136
......@@ -0,0 +1,136 @@
1import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 PREVIEW_HOST_RESPONSE_MARGIN_MS,
5 resolveHostWaitBudgetMs,
6 waitForHostReadiness,
7} from "./previewAutomationHostBudget";
8
9describe("resolveHostWaitBudgetMs", () => {
10 it("reserves the full response margin once the request budget allows it", () => {
11 expect(resolveHostWaitBudgetMs(15_000)).toBe(15_000 - PREVIEW_HOST_RESPONSE_MARGIN_MS);
12 expect(resolveHostWaitBudgetMs(60_000)).toBe(60_000 - PREVIEW_HOST_RESPONSE_MARGIN_MS);
13 });
14
15 it("keeps most of a short request budget instead of collapsing it", () => {
16 expect(resolveHostWaitBudgetMs(1_000)).toBe(800);
17 expect(resolveHostWaitBudgetMs(100)).toBe(80);
18 });
19
20 it("returns a non-negative budget for invalid input", () => {
21 for (const invalid of [0, -5, Number.NaN, Number.POSITIVE_INFINITY]) {
22 expect(resolveHostWaitBudgetMs(invalid)).toBeGreaterThanOrEqual(0);
23 }
24 });
25});
26
27describe("waitForHostReadiness", () => {
28 beforeEach(() => {
29 vi.useFakeTimers();
30 vi.setSystemTime(0);
31 });
32
33 afterEach(() => {
34 vi.useRealTimers();
35 });
36
37 it.each([1, 10, 100, 250, 1_000, 15_000])(
38 "stops unavailable-overlay polling before a %ims broker timeout",
39 async (requestTimeoutMs) => {
40 const deadlineMs = Date.now() + resolveHostWaitBudgetMs(requestTimeoutMs);
41 let finishedAt: number | undefined;
42 const result = waitForHostReadiness(deadlineMs, async () => false).then((ready) => {
43 finishedAt = Date.now();
44 return ready;
45 });
46
47 await vi.advanceTimersByTimeAsync(requestTimeoutMs);
48
49 expect(await result).toBe(false);
50 expect(finishedAt).toBeLessThan(requestTimeoutMs);
51 expect(vi.getTimerCount()).toBe(0);
52 },
53 );
54
55 it("includes session setup in the deadline instead of starting a fresh wait budget", async () => {
56 const deadlineMs = Date.now() + resolveHostWaitBudgetMs(15_000);
57 await vi.advanceTimersByTimeAsync(2_000);
58 let finished = false;
59 const result = waitForHostReadiness(deadlineMs, async () => false).then((ready) => {
60 finished = true;
61 return ready;
62 });
63
64 await vi.advanceTimersByTimeAsync(11_499);
65 expect(finished).toBe(false);
66 await vi.advanceTimersByTimeAsync(1);
67 expect(await result).toBe(false);
68 expect(Date.now()).toBe(deadlineMs);
69 expect(vi.getTimerCount()).toBe(0);
70 });
71
72 it("skips readiness probes when setup has already exhausted the deadline", async () => {
73 const deadlineMs = Date.now() + resolveHostWaitBudgetMs(100);
74 await vi.advanceTimersByTimeAsync(100);
75 const isReady = vi.fn(async () => false);
76
77 expect(await waitForHostReadiness(deadlineMs, isReady)).toBe(false);
78 expect(isReady).not.toHaveBeenCalled();
79 expect(vi.getTimerCount()).toBe(0);
80 });
81
82 it("returns as soon as the overlay is ready and clears its timeout", async () => {
83 const isReady = vi.fn().mockResolvedValueOnce(false).mockResolvedValueOnce(true);
84 const result = waitForHostReadiness(800, isReady);
85
86 await vi.advanceTimersByTimeAsync(50);
87
88 expect(await result).toBe(true);
89 expect(isReady).toHaveBeenCalledTimes(2);
90 expect(vi.getTimerCount()).toBe(0);
91 });
92
93 it.each([false, true])(
94 "keeps a probe result of %s that wins the deadline race",
95 async (ready) => {
96 const isReady = vi.fn(
97 () => new Promise<boolean>((resolve) => setTimeout(() => resolve(ready), 80)),
98 );
99 const result = waitForHostReadiness(80, isReady);
100
101 await vi.advanceTimersByTimeAsync(80);
102
103 expect(await result).toBe(ready);
104 expect(isReady).toHaveBeenCalledTimes(1);
105 expect(vi.getTimerCount()).toBe(0);
106 },
107 );
108
109 it("bounds a stalled status probe and ignores a late ready result", async () => {
110 let completeProbe!: (ready: boolean) => void;
111 const isReady = vi.fn(
112 () =>
113 new Promise<boolean>((resolve) => {
114 completeProbe = resolve;
115 }),
116 );
117 const result = waitForHostReadiness(80, isReady);
118
119 await vi.advanceTimersByTimeAsync(80);
120 expect(await result).toBe(false);
121 expect(vi.getTimerCount()).toBe(0);
122
123 completeProbe(true);
124 await vi.runAllTimersAsync();
125 expect(isReady).toHaveBeenCalledTimes(1);
126 expect(vi.getTimerCount()).toBe(0);
127 });
128
129 it("preserves probe failures and clears the pending timeout", async () => {
130 const error = new Error("Preview target was replaced");
131 const isReady = vi.fn().mockRejectedValue(error);
132
133 await expect(waitForHostReadiness(800, isReady)).rejects.toBe(error);
134 expect(vi.getTimerCount()).toBe(0);
135 });
136});
apps/web/src/components/preview/previewAutomationHostBudget.ts created+42
......@@ -0,0 +1,42 @@
1/** Allow time to deliver an overlay failure before the broker times out. */
2export const PREVIEW_HOST_RESPONSE_MARGIN_MS = 1_500;
3
4const HOST_RESPONSE_MARGIN_FRACTION = 0.2;
5
6export function resolveHostWaitBudgetMs(requestTimeoutMs: number): number {
7 if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
8 return 0;
9 }
10 const reservedMs = Math.min(
11 PREVIEW_HOST_RESPONSE_MARGIN_MS,
12 Math.ceil(requestTimeoutMs * HOST_RESPONSE_MARGIN_FRACTION),
13 );
14 return Math.max(0, requestTimeoutMs - reservedMs);
15}
16
17/** Both readiness probes and polling delays share the request's host deadline. */
18export async function waitForHostReadiness(
19 deadlineMs: number,
20 isReady: () => Promise<boolean>,
21): Promise<boolean> {
22 while (Date.now() < deadlineMs) {
23 let timeout: ReturnType<typeof setTimeout> | undefined;
24 let ready: boolean | null;
25 try {
26 ready = await Promise.race([
27 isReady(),
28 new Promise<null>((resolve) => {
29 timeout = setTimeout(() => resolve(null), Math.max(0, deadlineMs - Date.now()));
30 }),
31 ]);
32 } finally {
33 clearTimeout(timeout);
34 }
35 if (ready) return true;
36 if (ready === null || Date.now() >= deadlineMs) return false;
37 await new Promise<void>((resolve) =>
38 setTimeout(resolve, Math.min(50, deadlineMs - Date.now())),
39 );
40 }
41 return false;
42}