From 3105607bdbf37dcbdf7df0d1100ceda57b77438f Mon Sep 17 00:00:00 2001 From: Marcos Caballero <98906745+cabamarcos@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:20:55 +0200 Subject: [PATCH 1/4] fix(preview): bound stalled screenshot captures --- apps/server/src/preview/ServerBrowser.test.ts | 113 ++++++++++++++++++ .../src/preview/ServerBrowserPage.test.ts | 70 ++++++++++- apps/server/src/preview/ServerBrowserPage.ts | 20 +++- 3 files changed, 201 insertions(+), 2 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index b87bc8d9ca4d8..4657ad1d1d36c 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -437,6 +437,119 @@ it.live("preview_open reports its navigation timeout instead of the request budg ).pipe(Effect.provide(layer)), ); +it.live("an unanswered viewer still releases the capture lock and same-tab automation", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const context = contexts[0]!; + const captured = Promise.withResolvers>(); + const captureStarted = Promise.withResolvers(); + const newSession = context.newCDPSession; + context.newCDPSession = async (target) => { + const session = await newSession(target); + const send = session.send.getMockImplementation()!; + session.send.mockImplementation((method, input) => { + if (method === "Page.captureScreenshot") { + captureStarted.resolve(); + return captured.promise; + } + return send(method, input); + }); + return session; + }; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const attaching = yield* browser + .attachViewer(viewerInput(tabId, true)) + .pipe(Effect.forkScoped); + yield* Effect.promise(() => captureStarted.promise); + const snapshotting = yield* broker + .invoke({ scope, tabId, operation: "snapshot", input: {} }) + .pipe(Effect.forkScoped); + const evaluating = yield* broker + .invoke({ scope, tabId, operation: "evaluate", input: { expression: "document.title" } }) + .pipe(Effect.forkScoped); + + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(3_000)); + const viewer = yield* Fiber.join(attaching); + expect((yield* Fiber.join(snapshotting)).screenshot?.data).toBe(PNG_HEADER); + expect(yield* Fiber.join(evaluating)).toBe("evaluated"); + const navigated = yield* broker.invoke({ + scope, + tabId, + operation: "navigate", + input: { url: "http://localhost:5173/recovered" }, + }); + expect(navigated.url).toBe("http://localhost:5173/recovered"); + + captured.resolve({ data: "late-frame" }); + yield* Effect.promise(() => captured.promise); + expect((yield* Queue.clear(viewer.output)).some((event) => event._tag === "frame")).toBe( + false, + ); + yield* viewer.input({ type: "takeControl" }); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status.control?.owner).toBe("human"); + } finally { + vi.useRealTimers(); + } + }), + ).pipe(Effect.provide(layer)), +); + +it.live("reports a stalled snapshot capture without losing the tab or its control queue", () => + Effect.scoped( + Effect.gen(function* () { + const { broker, tabId } = yield* ready; + const session = contexts[0]!.sessions[0]!; + const send = session.send.getMockImplementation()!; + const captureStarted = Promise.withResolvers(); + const captured = Promise.withResolvers>(); + session.send.mockImplementation((method, input) => { + if (method === "Page.captureScreenshot") { + captureStarted.resolve(); + return captured.promise; + } + return send(method, input); + }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const snapshotting = yield* broker + .invoke({ scope, tabId, operation: "snapshot", input: {} }) + .pipe(Effect.flip, Effect.forkScoped); + yield* Effect.promise(() => captureStarted.promise); + const evaluating = yield* broker + .invoke({ scope, tabId, operation: "evaluate", input: { expression: "document.title" } }) + .pipe(Effect.forkScoped); + + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(6_000)); + const error = yield* Fiber.join(snapshotting); + expect(error).toMatchObject({ + _tag: "PreviewAutomationExecutionError", + message: expect.stringContaining("show or restore the T3 Code window"), + }); + expect(yield* Fiber.join(evaluating)).toBe("evaluated"); + const status = yield* broker.invoke({ + scope, + tabId, + operation: "status", + input: {}, + }); + expect(status).toMatchObject({ tabId, available: true }); + expect(status.control?.ownedByCaller).toBe(true); + captured.resolve({ data: PNG_HEADER }); + } finally { + vi.useRealTimers(); + } + }), + ).pipe(Effect.provide(layer)), +); + it.live.each(["desktop" as const, undefined])( "explicit desktop targets ($0 runtime) fail without creating a server tab", (runtime) => diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 31f180bcc923c..f5467ff0ae62f 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -5,9 +5,77 @@ 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"; +import { SessionControl } from "./SessionControl.ts"; + +describe("server browser capture deadlines", () => { + it.each(["png", "jpeg"] as const)( + "releases control after an unanswered %s capture and ignores its late reply", + async (format) => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const captured = Promise.withResolvers<{ data: string }>(); + const send = vi.fn(async (method: string) => + method === "Page.captureScreenshot" + ? captured.promise + : { result: { value: "still responsive" } }, + ); + const cdp = { send } as unknown as CDPSession; + const control = new SessionControl("agent"); + let outcome: unknown; + const capture = control + .agent("agent", () => ServerBrowserPage.captureViewport(cdp, { format })) + .catch((cause: unknown) => { + outcome = cause; + }); + const evaluation = control.agent("agent", () => + ServerBrowserPage.evaluate(cdp, { expression: "document.title" }, 1_000), + ); + + await vi.advanceTimersByTimeAsync(3_000); + expect(outcome).toMatchObject({ + tag: "PreviewAutomationExecutionError", + message: expect.stringContaining("not producing screenshot frames"), + }); + await capture; + expect(await evaluation).toBe("still responsive"); + expect(vi.getTimerCount()).toBe(0); + + captured.resolve({ data: "late-frame" }); + await captured.promise; + expect(outcome).toBeInstanceOf(ServerBrowserPage.ServerBrowserOperationError); + expect(send.mock.calls.map(([method]) => method)).toEqual([ + "Page.captureScreenshot", + "Runtime.evaluate", + ]); + } finally { + vi.useRealTimers(); + } + }, + ); + + it("clears the capture deadline when Chromium answers", async () => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const cdp = { send: async () => ({ data: "current-frame" }) } as unknown as CDPSession; + expect(await ServerBrowserPage.captureViewport(cdp, { format: "png" })).toBe("current-frame"); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); +}); describe("server browser element refs", () => { let browser: Browser; diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 9525ac7c5b579..ff8d8204cc9df 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -26,6 +26,8 @@ import { BrowserControlInterrupted } from "./SessionControl.ts"; const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_SCREENSHOT_WIDTH = 1280; +// Leave room for a snapshot retry before the broker's 15-second deadline. +const SCREENSHOT_TIMEOUT_MS = 3_000; const WAIT_POLL_MS = 100; export const DIAGNOSTIC_BUFFER_LIMIT = 200; @@ -450,10 +452,26 @@ export const captureViewport = async ( cdp: CDPSession, options: { readonly format: "png" | "jpeg"; readonly quality?: number }, ) => { - const { data } = await cdp.send("Page.captureScreenshot", { + const capture = cdp.send("Page.captureScreenshot", { format: options.format, ...(options.quality === undefined ? {} : { quality: options.quality }), }); + let timer: ReturnType | undefined; + const expired = new Promise((_, reject) => { + timer = setTimeout( + () => + reject( + new ServerBrowserOperationError( + "PreviewAutomationExecutionError", + "The browser is not producing screenshot frames. For a desktop tab, show or restore the T3 Code window and try again.", + ), + ), + SCREENSHOT_TIMEOUT_MS, + ); + }); + // Hidden native guests can leave CDP captures pending forever. This deadline also + // releases a viewer's still-capture lock, which an agent snapshot may be waiting for. + const { data } = await Promise.race([capture, expired]).finally(() => clearTimeout(timer)); return data; }; From 97eca339df51110fb881ed94e6f3736fcec9da32 Mon Sep 17 00:00:00 2001 From: Marcos Caballero <98906745+cabamarcos@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:33:18 +0200 Subject: [PATCH 2/4] test(preview): verify stalled snapshot retries --- apps/server/src/preview/ServerBrowser.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 4657ad1d1d36c..804d086c8e8d7 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -533,6 +533,9 @@ it.live("reports a stalled snapshot capture without losing the tab or its contro _tag: "PreviewAutomationExecutionError", message: expect.stringContaining("show or restore the T3 Code window"), }); + expect( + session.send.mock.calls.filter(([method]) => method === "Page.captureScreenshot"), + ).toHaveLength(2); expect(yield* Fiber.join(evaluating)).toBe("evaluated"); const status = yield* broker.invoke({ scope, From efad2e33a7c88701901dfb632809dac572ca9d5e Mon Sep 17 00:00:00 2001 From: Marcos Caballero <98906745+cabamarcos@users.noreply.github.com> Date: Tue, 27 Oct 2026 11:16:25 +0100 Subject: [PATCH 3/4] test(preview): run stalled capture tests on the test clock --- apps/server/src/preview/ServerBrowser.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 804d086c8e8d7..5b0f47d989645 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -437,7 +437,7 @@ it.live("preview_open reports its navigation timeout instead of the request budg ).pipe(Effect.provide(layer)), ); -it.live("an unanswered viewer still releases the capture lock and same-tab automation", () => +it.effect("an unanswered viewer still releases the capture lock and same-tab automation", () => Effect.scoped( Effect.gen(function* () { const { browser, broker, tabId } = yield* ready; @@ -502,7 +502,7 @@ it.live("an unanswered viewer still releases the capture lock and same-tab autom ).pipe(Effect.provide(layer)), ); -it.live("reports a stalled snapshot capture without losing the tab or its control queue", () => +it.effect("reports a stalled snapshot capture without losing the tab or its control queue", () => Effect.scoped( Effect.gen(function* () { const { broker, tabId } = yield* ready; From ddfda9ac0b83642da398426f9cd15ea461747823 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 21:18:53 +0000 Subject: [PATCH 4/4] fix(preview): adapt capture deadline to main and trim its tests Build the timeout error with main's schema-based ServerBrowserOperationError, correct the deadline comment, and keep one unit test at the capture boundary. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/preview/ServerBrowser.test.ts | 116 ------------------ .../src/preview/ServerBrowserPage.test.ts | 86 ++++++------- apps/server/src/preview/ServerBrowserPage.ts | 11 +- 3 files changed, 42 insertions(+), 171 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index dce450e25ec1f..e408519e9ac1c 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -687,122 +687,6 @@ it.live("an initial download stays available after preview_open reports the navi ).pipe(Effect.provide(layer)), ); -it.effect("an unanswered viewer still releases the capture lock and same-tab automation", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, broker, tabId } = yield* ready; - const context = contexts[0]!; - const captured = Promise.withResolvers>(); - const captureStarted = Promise.withResolvers(); - const newSession = context.newCDPSession; - context.newCDPSession = async (target) => { - const session = await newSession(target); - const send = session.send.getMockImplementation()!; - session.send.mockImplementation((method, input) => { - if (method === "Page.captureScreenshot") { - captureStarted.resolve(); - return captured.promise; - } - return send(method, input); - }); - return session; - }; - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - try { - const attaching = yield* browser - .attachViewer(viewerInput(tabId, true)) - .pipe(Effect.forkScoped); - yield* Effect.promise(() => captureStarted.promise); - const snapshotting = yield* broker - .invoke({ scope, tabId, operation: "snapshot", input: {} }) - .pipe(Effect.forkScoped); - const evaluating = yield* broker - .invoke({ scope, tabId, operation: "evaluate", input: { expression: "document.title" } }) - .pipe(Effect.forkScoped); - - yield* Effect.promise(() => vi.advanceTimersByTimeAsync(3_000)); - const viewer = yield* Fiber.join(attaching); - expect((yield* Fiber.join(snapshotting)).screenshot?.data).toBe(PNG_HEADER); - expect(yield* Fiber.join(evaluating)).toBe("evaluated"); - const navigated = yield* broker.invoke({ - scope, - tabId, - operation: "navigate", - input: { url: "http://localhost:5173/recovered" }, - }); - expect(navigated.url).toBe("http://localhost:5173/recovered"); - - captured.resolve({ data: "late-frame" }); - yield* Effect.promise(() => captured.promise); - expect((yield* Queue.clear(viewer.output)).some((event) => event._tag === "frame")).toBe( - false, - ); - yield* viewer.input({ type: "takeControl" }); - const status = yield* broker.invoke({ - scope, - tabId, - operation: "status", - input: {}, - }); - expect(status.control?.owner).toBe("human"); - } finally { - vi.useRealTimers(); - } - }), - ).pipe(Effect.provide(layer)), -); - -it.effect("reports a stalled snapshot capture without losing the tab or its control queue", () => - Effect.scoped( - Effect.gen(function* () { - const { broker, tabId } = yield* ready; - const session = contexts[0]!.sessions[0]!; - const send = session.send.getMockImplementation()!; - const captureStarted = Promise.withResolvers(); - const captured = Promise.withResolvers>(); - session.send.mockImplementation((method, input) => { - if (method === "Page.captureScreenshot") { - captureStarted.resolve(); - return captured.promise; - } - return send(method, input); - }); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - try { - const snapshotting = yield* broker - .invoke({ scope, tabId, operation: "snapshot", input: {} }) - .pipe(Effect.flip, Effect.forkScoped); - yield* Effect.promise(() => captureStarted.promise); - const evaluating = yield* broker - .invoke({ scope, tabId, operation: "evaluate", input: { expression: "document.title" } }) - .pipe(Effect.forkScoped); - - yield* Effect.promise(() => vi.advanceTimersByTimeAsync(6_000)); - const error = yield* Fiber.join(snapshotting); - expect(error).toMatchObject({ - _tag: "PreviewAutomationExecutionError", - message: expect.stringContaining("show or restore the T3 Code window"), - }); - expect( - session.send.mock.calls.filter(([method]) => method === "Page.captureScreenshot"), - ).toHaveLength(2); - expect(yield* Fiber.join(evaluating)).toBe("evaluated"); - const status = yield* broker.invoke({ - scope, - tabId, - operation: "status", - input: {}, - }); - expect(status).toMatchObject({ tabId, available: true }); - expect(status.control?.ownedByCaller).toBe(true); - captured.resolve({ data: PNG_HEADER }); - } finally { - vi.useRealTimers(); - } - }), - ).pipe(Effect.provide(layer)), -); - it.live.each(["desktop" as const, undefined])( "explicit desktop targets ($0 runtime) fail without creating a server tab", (runtime) => diff --git a/apps/server/src/preview/ServerBrowserPage.test.ts b/apps/server/src/preview/ServerBrowserPage.test.ts index 3aab780893611..cabc2b99e67ba 100644 --- a/apps/server/src/preview/ServerBrowserPage.test.ts +++ b/apps/server/src/preview/ServerBrowserPage.test.ts @@ -21,66 +21,52 @@ import { import * as ServerBrowserPage from "./ServerBrowserPage.ts"; import { SessionControl } from "./SessionControl.ts"; -describe("server browser capture deadlines", () => { - it.each(["png", "jpeg"] as const)( - "releases control after an unanswered %s capture and ignores its late reply", - async (format) => { - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - try { - const captured = Promise.withResolvers<{ data: string }>(); - const send = vi.fn(async (method: string) => - method === "Page.captureScreenshot" - ? captured.promise - : { result: { value: "still responsive" } }, - ); - const cdp = { send } as unknown as CDPSession; - const control = new SessionControl("agent"); - let outcome: unknown; - const capture = control - .agent("agent", () => ServerBrowserPage.captureViewport(cdp, { format })) - .catch((cause: unknown) => { - outcome = cause; - }); - const evaluation = control.agent("agent", () => - ServerBrowserPage.evaluate(cdp, { expression: "document.title" }, 1_000), - ); - - await vi.advanceTimersByTimeAsync(3_000); - expect(outcome).toMatchObject({ - tag: "PreviewAutomationExecutionError", - message: expect.stringContaining("not producing screenshot frames"), - }); - await capture; - expect(await evaluation).toBe("still responsive"); - expect(vi.getTimerCount()).toBe(0); - - captured.resolve({ data: "late-frame" }); - await captured.promise; - expect(outcome).toBeInstanceOf(ServerBrowserPage.ServerBrowserOperationError); - expect(send.mock.calls.map(([method]) => method)).toEqual([ - "Page.captureScreenshot", - "Runtime.evaluate", - ]); - } finally { - vi.useRealTimers(); - } - }, - ); +const decodeEvaluateInput = Schema.decodeUnknownSync(PreviewAutomationEvaluateInput); - it("clears the capture deadline when Chromium answers", async () => { +describe("server browser capture deadlines", () => { + it("releases control after an unanswered capture and ignores its late reply", async () => { vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); try { - const cdp = { send: async () => ({ data: "current-frame" }) } as unknown as CDPSession; - expect(await ServerBrowserPage.captureViewport(cdp, { format: "png" })).toBe("current-frame"); + const captured = Promise.withResolvers<{ data: string }>(); + const send = vi.fn(async (method: string) => + method === "Page.captureScreenshot" + ? captured.promise + : { result: { value: "still responsive" } }, + ); + const cdp = { send } as unknown as CDPSession; + const control = new SessionControl("agent"); + let outcome: unknown; + const capture = control + .agent("agent", () => ServerBrowserPage.captureViewport(cdp, { format: "png" })) + .catch((cause: unknown) => { + outcome = cause; + }); + const evaluation = control.agent("agent", () => + ServerBrowserPage.evaluate(cdp, { expression: "document.title" }, 1_000), + ); + + await vi.advanceTimersByTimeAsync(3_000); + expect(outcome).toMatchObject({ + tag: "PreviewAutomationExecutionError", + message: expect.stringContaining("not producing screenshot frames"), + }); + await capture; + expect(await evaluation).toBe("still responsive"); expect(vi.getTimerCount()).toBe(0); + + captured.resolve({ data: "late-frame" }); + await captured.promise; + expect(outcome).toBeInstanceOf(ServerBrowserPage.ServerBrowserOperationError); + expect(send.mock.calls.map(([method]) => method)).toEqual([ + "Page.captureScreenshot", + "Runtime.evaluate", + ]); } finally { vi.useRealTimers(); } }); }); -const decodeEvaluateInput = Schema.decodeUnknownSync(PreviewAutomationEvaluateInput); - describe("server browser element refs", () => { let browser: Browser; let context: BrowserContext; diff --git a/apps/server/src/preview/ServerBrowserPage.ts b/apps/server/src/preview/ServerBrowserPage.ts index 95312d897d755..0d5ed3d1ff893 100644 --- a/apps/server/src/preview/ServerBrowserPage.ts +++ b/apps/server/src/preview/ServerBrowserPage.ts @@ -29,7 +29,7 @@ const isBrowserControlInterrupted = Schema.is(BrowserControlInterrupted); const MAX_EVALUATION_BYTES = 64_000; const MAX_VISIBLE_TEXT_LENGTH = 20_000; const MAX_SCREENSHOT_WIDTH = 1280; -// Leave room for a snapshot retry before the broker's 15-second deadline. +// Two attempts must fit in a snapshot's 10-second deadline. const SCREENSHOT_TIMEOUT_MS = 3_000; const WAIT_POLL_MS = 100; export const DIAGNOSTIC_BUFFER_LIMIT = 200; @@ -479,10 +479,11 @@ export const captureViewport = async ( timer = setTimeout( () => reject( - new ServerBrowserOperationError( - "PreviewAutomationExecutionError", - "The browser is not producing screenshot frames. For a desktop tab, show or restore the T3 Code window and try again.", - ), + new ServerBrowserOperationError({ + tag: "PreviewAutomationExecutionError", + message: + "The browser is not producing screenshot frames. For a desktop tab, show or restore the T3 Code window and try again.", + }), ), SCREENSHOT_TIMEOUT_MS, );