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