| 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,10 +2060,30 @@ describe("PreviewManager", () => { |
| 2060 | /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/, | 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 | 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 | ); |
| 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 | 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"; |
| 48 | import * as Ref from "effect/Ref"; | 48 | import * as Ref from "effect/Ref"; |
| 49 | import * as Schema from "effect/Schema"; | 49 | import * as Schema from "effect/Schema"; |
| 50 | import * as Semaphore from "effect/Semaphore"; | 50 | import * as Semaphore from "effect/Semaphore"; |
| 51 | import * as Schedule from "effect/Schedule"; | ||
| 51 | import * as Scope from "effect/Scope"; | 52 | import * as Scope from "effect/Scope"; |
| 52 | import * as SynchronizedRef from "effect/SynchronizedRef"; | 53 | import * as SynchronizedRef from "effect/SynchronizedRef"; |
| 53 | 54 | ||
| ... | @@ -113,6 +114,13 @@ const MAX_SCREENSHOT_WIDTH = 1280; | ... | @@ -113,6 +114,13 @@ const MAX_SCREENSHOT_WIDTH = 1280; |
| 113 | const RECORDING_ARM_GRACE_MS = 10_000; | 114 | const RECORDING_ARM_GRACE_MS = 10_000; |
| 114 | const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); | 115 | const PICTURE_IN_PICTURE_FRAME_INTERVAL_MS = Math.ceil(1_000 / 12); |
| 115 | const PICTURE_IN_PICTURE_JPEG_QUALITY = 80; | 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 | const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; | 124 | const PICTURE_IN_PICTURE_INITIAL_WIDTH = 480; |
| 117 | const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; | 125 | const PICTURE_IN_PICTURE_INITIAL_HEIGHT = 320; |
| 118 | const PICTURE_IN_PICTURE_MIN_WIDTH = 240; | 126 | const 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"; |
| 79 | import { resolveHostWaitBudgetMs, waitForHostReadiness } from "./previewAutomationHostBudget"; | ||
| 79 | import { isPreviewViewportReady } from "./previewViewportReadiness"; | 80 | import { isPreviewViewportReady } from "./previewViewportReadiness"; |
| 80 | import { shouldRollbackPreviewViewport } from "./previewViewportRollback"; | 81 | import { shouldRollbackPreviewViewport } from "./previewViewportRollback"; |
| 81 | 82 | ||
| ... | @@ -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 | }; |
| 119 | 121 | ||
| ... | @@ -317,6 +319,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) | ... | @@ -317,6 +319,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) |
| 317 | 319 | ||
| 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 @@ | ||
| 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 | } | ||