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", () => {...@@ -2060,10 +2060,30 @@ describe("PreviewManager", () => {
2060 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,2060 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,
2061 );2061 );
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();
2063 const captureCause = new Error("capture failed");2078 const captureCause = new Error("capture failed");
2064 capturePage.mockRejectedValueOnce(captureCause);2079 capturePage.mockRejectedValue(captureCause);
2065 const exit = yield* Effect.exit(manager.captureScreenshot("tab_1"));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);
2066 expect(Exit.isFailure(exit)).toBe(true);2085 expect(Exit.isFailure(exit)).toBe(true);
2086 expect(capturePage).toHaveBeenCalledTimes(3);
2067 if (Exit.isSuccess(exit)) return;2087 if (Exit.isSuccess(exit)) return;
2068 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));2088 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
2069 expect(error).toMatchObject({2089 expect(error).toMatchObject({
...@@ -2397,6 +2417,127 @@ describe("PreviewManager", () => {...@@ -2397,6 +2417,127 @@ describe("PreviewManager", () => {
2397 ),2417 ),
2398 );2418 );
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
2400 effectIt.effect("grants each concurrent preview recording its own tab frame", () =>2541 effectIt.effect("grants each concurrent preview recording its own tab frame", () =>
2401 withManager((manager) =>2542 withManager((manager) =>
2402 Effect.gen(function* () {2543 Effect.gen(function* () {
apps/desktop/src/preview/Manager.ts+50-4
...@@ -48,6 +48,7 @@ import * as Path from "effect/Path";...@@ -48,6 +48,7 @@ import * as Path from "effect/Path";
48import * as Ref from "effect/Ref";48import * as Ref from "effect/Ref";
49import * as Schema from "effect/Schema";49import * as Schema from "effect/Schema";
50import * as Semaphore from "effect/Semaphore";50import * as Semaphore from "effect/Semaphore";
51import * as Schedule from "effect/Schedule";
51import * as Scope from "effect/Scope";52import * as Scope from "effect/Scope";
52import * as SynchronizedRef from "effect/SynchronizedRef";53import * as SynchronizedRef from "effect/SynchronizedRef";
5354
...@@ -113,6 +114,13 @@ const MAX_SCREENSHOT_WIDTH = 1280;...@@ -113,6 +114,13 @@ const MAX_SCREENSHOT_WIDTH = 1280;
113const RECORDING_ARM_GRACE_MS = 10_000;114const RECORDING_ARM_GRACE_MS = 10_000;
114const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);115const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12);
115const PICTURE_IN_PICTURE_JPEG_QUALITY = 80;116const 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;
116const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480;124const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480;
117const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;125const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320;
118const PICTURE_IN_PICTURE_MIN_WIDTH = 240;126const PICTURE_IN_PICTURE_MIN_WIDTH = 240;
...@@ -639,6 +647,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -639,6 +647,42 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
639 try: evaluate,647 try: evaluate,
640 catch: (cause) => new PreviewOperationError({ ...errorContext, cause }),648 catch: (cause) => new PreviewOperationError({ ...errorContext, cause }),
641 });649 });
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 });
642 const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));686 const currentIso = DateTime.now.pipe(Effect.map(DateTime.formatIso));
643 const currentMillis = Clock.currentTimeMillis;687 const currentMillis = Clock.currentTimeMillis;
644 const encodeJson = (errorContext: PreviewOperationContext, value: unknown) =>688 const encodeJson = (errorContext: PreviewOperationContext, value: unknown) =>
...@@ -2648,13 +2692,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -2648,13 +2692,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
2648 const [createdAt, millis, image] = yield* Effect.all([2692 const [createdAt, millis, image] = yield* Effect.all([
2649 currentIso,2693 currentIso,
2650 currentMillis,2694 currentMillis,
2651 attemptPromise(2695 capturePageWithRetry(
2652 {2696 {
2653 operation: "captureScreenshot.capturePage",2697 operation: "captureScreenshot.capturePage",
2654 tabId,2698 tabId,
2655 webContentsId: wc.id,2699 webContentsId: wc.id,
2656 },2700 },
2657 () => wc.capturePage(),2701 tabId,
2702 wc,
2658 ),2703 ),
2659 ]);2704 ]);
2660 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`;2705 const id = `browser-screenshot-${artifactSiteSlug(wc.getURL())}-${millis.toString(36)}`;
...@@ -3513,13 +3558,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -3513,13 +3558,14 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
3513 );3558 );
3514 const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([3559 const [accessibility, sourceImage, diagnostics, timelines] = yield* Effect.all([
3515 send("Accessibility.getFullAXTree"),3560 send("Accessibility.getFullAXTree"),
3516 attemptPromise(3561 capturePageWithRetry(
3517 {3562 {
3518 operation: "automationSnapshot.capturePage",3563 operation: "automationSnapshot.capturePage",
3519 tabId,3564 tabId,
3520 webContentsId: wc.id,3565 webContentsId: wc.id,
3521 },3566 },
3522 () => wc.capturePage(),3567 tabId,
3568 wc,
3523 ),3569 ),
3524 Ref.get(diagnosticsRef),3570 Ref.get(diagnosticsRef),
3525 Ref.get(actionTimelineRef),3571 Ref.get(actionTimelineRef),
apps/web/src/components/preview/PreviewAutomationHosts.tsx+12-8
...@@ -76,6 +76,7 @@ import {...@@ -76,6 +76,7 @@ import {
76 resolvePreviewAutomationOpenTab,76 resolvePreviewAutomationOpenTab,
77 resolvePreviewAutomationTarget,77 resolvePreviewAutomationTarget,
78} from "./previewAutomationTarget";78} from "./previewAutomationTarget";
79import { resolveHostWaitBudgetMs, waitForHostReadiness } from "./previewAutomationHostBudget";
79import { isPreviewViewportReady } from "./previewViewportReadiness";80import { isPreviewViewportReady } from "./previewViewportReadiness";
80import { shouldRollbackPreviewViewport } from "./previewViewportRollback";81import { shouldRollbackPreviewViewport } from "./previewViewportRollback";
8182
...@@ -95,25 +96,26 @@ const waitForDesktopOverlay = async (...@@ -95,25 +96,26 @@ const waitForDesktopOverlay = async (
95 tabId: string,96 tabId: string,
96 runtimeTabId: string,97 runtimeTabId: string,
97 operation: PreviewAutomationRequest["operation"],98 operation: PreviewAutomationRequest["operation"],
98 timeoutMs: number,99 deadlineMs: number,
99): Promise<void> => {100): Promise<void> => {
100 const deadline = Date.now() + timeoutMs;101 const waitBudgetMs = Math.max(0, deadlineMs - Date.now());
101 while (Date.now() <= deadline) {102 const ready = await waitForHostReadiness(deadlineMs, async () => {
102 const state = assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, {103 const state = assertPreviewRuntimeCurrent(threadRef, tabId, runtimeTabId, {
103 operation,104 operation,
104 requestId,105 requestId,
105 });106 });
106 if (state.desktopByTabId[tabId] && previewBridge && isPreviewWebviewRendering(runtimeTabId)) {107 if (state.desktopByTabId[tabId] && previewBridge && isPreviewWebviewRendering(runtimeTabId)) {
107 const status = await previewBridge.automation.status(runtimeTabId);108 const status = await previewBridge.automation.status(runtimeTabId);
108 if (status.available) return;109 return status.available;
109 }110 }
110 await new Promise<void>((resolve) => window.setTimeout(resolve, 50));111 return false;
111 }112 });
113 if (ready) return;
112 throw new PreviewAutomationOverlayTimeoutError({114 throw new PreviewAutomationOverlayTimeoutError({
113 requestId,115 requestId,
114 environmentId: threadRef.environmentId,116 environmentId: threadRef.environmentId,
115 threadId: threadRef.threadId,117 threadId: threadRef.threadId,
116 timeoutMs,118 timeoutMs: waitBudgetMs,
117 });119 });
118};120};
119121
...@@ -317,6 +319,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -317,6 +319,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
317319
318 const handleRequest = useCallback(320 const handleRequest = useCallback(
319 async (request: PreviewAutomationRequest): Promise<unknown> => {321 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);
320 const threadRef: ScopedThreadRef = {324 const threadRef: ScopedThreadRef = {
321 environmentId,325 environmentId,
322 threadId: request.threadId,326 threadId: request.threadId,
...@@ -378,7 +382,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })...@@ -378,7 +382,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId })
378 readyTabId,382 readyTabId,
379 runtimeTabId,383 runtimeTabId,
380 request.operation,384 request.operation,
381 request.timeoutMs,385 hostDeadlineMs,
382 );386 );
383 return {387 return {
384 bridge,388 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}