From 246636a12add43d36ed049eb4bfda1cd791c6324 Mon Sep 17 00:00:00 2001 From: David Mendes Date: Thu, 22 Oct 2026 10:21:51 +0100 Subject: [PATCH 1/3] fix(server): a preview capture that never answers no longer blocks the tab --- .../src/preview/ServerBrowserPage.test.ts | 31 +++++++++++++- apps/server/src/preview/ServerBrowserPage.ts | 42 +++++++++++++------ 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 2491e6ac9a5fc..c2e4ab0d0ef2c 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -5,7 +5,16 @@ import { type CDPSession, type Page, } from "playwright-core"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; +import { + afterAll, + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vite-plus/test"; import * as ServerBrowserPage from "./ServerBrowserPage.ts"; @@ -287,3 +296,23 @@ describe("server browser drag", () => { } }); }); + +describe("server browser capture", () => { + it("fails a capture the page never answers instead of waiting for it", async () => { + vi.useFakeTimers(); + try { + const cdp = { send: () => new Promise(() => {}) } as unknown as CDPSession; + const capture = ServerBrowserPage.captureViewport({} as Page, cdp, { + format: "jpeg", + scale: 1, + }).then( + () => null, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(5_000); + expect(await capture).toMatchObject({ tag: "PreviewAutomationTimeoutError" }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 02089cca3205c..dcbea8fff1fd4 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -201,6 +201,8 @@ const withoutTreeDataPayloads = (tree: string) => const SNAPSHOT_TIMEOUT_MS = 10_000; // How long a frame in another process gets to answer; one that crashed never does. const FRAME_ANSWER_MS = 1_000; +// A tab that draws no frames never answers a screenshot, and callers hold the tab's queue while waiting. +const CAPTURE_TIMEOUT_MS = 5_000; // Values the page draws as dots (password fields, and text fields whose CSS // draws dots), normalized like accessible text. Open shadow roots included. @@ -234,6 +236,12 @@ const tooBusy = () => "The page was too busy to capture a snapshot in time. Wait for it to settle and take another snapshot.", ); +const noFrame = () => + new ServerBrowserOperationError( + "PreviewAutomationTimeoutError", + "The page drew no frame to capture in time. Try again once the tab is showing or has settled.", + ); + const VALUE_TOO_LONG = "A password field holds a value too long to hide, so no snapshot was taken."; /** A frame in another process keeps one CDP session, so none pile up across snapshots. */ @@ -469,18 +477,15 @@ const readPageMetrics = async (cdp: CDPSession) => { ); }; -// Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets. -export const captureViewport = async ( - page: Page, - cdp: CDPSession, - options: { - readonly format: "png" | "jpeg"; - readonly quality?: number; - readonly scale: number; - readonly viewport?: Viewport; - readonly offset?: { readonly x: number; readonly y: number } | undefined; - }, -) => { +type CaptureOptions = { + readonly format: "png" | "jpeg"; + readonly quality?: number; + readonly scale: number; + readonly viewport?: Viewport; + readonly offset?: { readonly x: number; readonly y: number } | undefined; +}; + +const readViewport = async (page: Page, cdp: CDPSession, options: CaptureOptions) => { let clip; if (options.scale < 1) { const viewport = options.viewport ?? page.viewportSize() ?? { width: 1280, height: 800 }; @@ -504,6 +509,19 @@ export const captureViewport = async ( return data; }; +// Scaled captures repaint live screencasts, so callers pause them. Clips use document offsets. +export const captureViewport = async (page: Page, cdp: CDPSession, options: CaptureOptions) => { + let timer: ReturnType | undefined; + const expired = new Promise((_, reject) => { + timer = setTimeout(() => reject(noFrame()), CAPTURE_TIMEOUT_MS); + }); + try { + return await Promise.race([readViewport(page, cdp, options), expired]); + } finally { + clearTimeout(timer); + } +}; + type SnapshotInput = { readonly page: Page; readonly cdp: CDPSession; From 5a18571dfc711567eeadedc116b61984187ff478 Mon Sep 17 00:00:00 2001 From: David Mendes Date: Mon, 26 Oct 2026 09:14:37 +0000 Subject: [PATCH 2/3] fix(server): a session keeps at most one unanswered preview screenshot --- .../src/preview/ServerBrowserPage.test.ts | 33 +++++++++++++++++++ apps/server/src/preview/ServerBrowserPage.ts | 18 +++++++--- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index c475a5df271ee..f985b909c8257 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -413,4 +413,37 @@ describe("server browser capture", () => { vi.useRealTimers(); } }); + + it("sends no new screenshot while an earlier one is unanswered", async () => { + vi.useFakeTimers(); + try { + const firstAnswer = Promise.withResolvers<{ data: string }>(); + const send = vi + .fn() + .mockReturnValueOnce(firstAnswer.promise) + .mockResolvedValue({ data: "second" }); + const cdp = { send } as unknown as CDPSession; + const outcome = (capture: Promise) => + capture.then( + (data) => data, + (error: unknown) => error, + ); + + const first = outcome(ServerBrowserPage.captureViewport(cdp, { format: "jpeg" })); + await vi.advanceTimersByTimeAsync(5_000); + expect(await first).toMatchObject({ tag: "PreviewAutomationTimeoutError" }); + + const blocked = outcome(ServerBrowserPage.captureViewport(cdp, { format: "jpeg" })); + await vi.advanceTimersByTimeAsync(5_000); + expect(await blocked).toMatchObject({ tag: "PreviewAutomationTimeoutError" }); + expect(send).toHaveBeenCalledTimes(1); + + firstAnswer.resolve({ data: "first" }); + await vi.advanceTimersByTimeAsync(0); + expect(await ServerBrowserPage.captureViewport(cdp, { format: "jpeg" })).toBe("second"); + expect(send).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index c7cca2d4e3aab..afd7f9936587b 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -452,6 +452,9 @@ const readPageMetrics = async (cdp: CDPSession) => { ); }; +// CDP cannot cancel a screenshot, so a session keeps at most one unanswered. +const unansweredCaptures = new WeakMap>(); + // Never pass a clip scale: Chromium mirrors the top-left of the page into a `backdrop-filter` // in any capture it shrinks. export const captureViewport = async ( @@ -462,11 +465,18 @@ export const captureViewport = async ( const expired = new Promise((_, reject) => { timer = setTimeout(() => reject(noFrame()), CAPTURE_TIMEOUT_MS); }); - const captured = cdp.send("Page.captureScreenshot", { - format: options.format, - ...(options.quality === undefined ? {} : { quality: options.quality }), - }); try { + const unanswered = unansweredCaptures.get(cdp); + if (unanswered) await Promise.race([unanswered, expired]); + const captured = cdp.send("Page.captureScreenshot", { + format: options.format, + ...(options.quality === undefined ? {} : { quality: options.quality }), + }); + const settled = captured.then(constVoid, constVoid); + unansweredCaptures.set(cdp, settled); + void settled.then(() => { + if (unansweredCaptures.get(cdp) === settled) unansweredCaptures.delete(cdp); + }); return (await Promise.race([captured, expired])).data; } finally { clearTimeout(timer); From e5dc08e209870bbbd276c16740bc3ecb535d3e35 Mon Sep 17 00:00:00 2001 From: David Mendes Date: Mon, 26 Oct 2026 09:25:14 +0000 Subject: [PATCH 3/3] fix(server): only one waiting preview capture sends once the unanswered one settles --- .../src/preview/ServerBrowserPage.test.ts | 25 +++++++++++++++++++ apps/server/src/preview/ServerBrowserPage.ts | 10 ++++++-- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index f985b909c8257..7812b6a3a952c 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -446,4 +446,29 @@ describe("server browser capture", () => { vi.useRealTimers(); } }); + + it("lets only one waiting capture send once the unanswered one settles", async () => { + vi.useFakeTimers(); + try { + const firstAnswer = Promise.withResolvers<{ data: string }>(); + const send = vi + .fn() + .mockReturnValueOnce(firstAnswer.promise) + .mockReturnValue(new Promise(() => {})); + const cdp = { send } as unknown as CDPSession; + const captures = [1, 2, 3].map(() => + ServerBrowserPage.captureViewport(cdp, { format: "jpeg" }).catch(() => null), + ); + + firstAnswer.resolve({ data: "first" }); + await vi.advanceTimersByTimeAsync(0); + expect(send).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(5_000); + await Promise.all(captures); + expect(send).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index afd7f9936587b..46a0d199512f1 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -466,8 +466,14 @@ export const captureViewport = async ( timer = setTimeout(() => reject(noFrame()), CAPTURE_TIMEOUT_MS); }); try { - const unanswered = unansweredCaptures.get(cdp); - if (unanswered) await Promise.race([unanswered, expired]); + // Callers waiting on the same capture wake together; only the first may send. + for ( + let unanswered = unansweredCaptures.get(cdp); + unanswered; + unanswered = unansweredCaptures.get(cdp) + ) { + await Promise.race([unanswered, expired]); + } const captured = cdp.send("Page.captureScreenshot", { format: options.format, ...(options.quality === undefined ? {} : { quality: options.quality }),