From c3b5a92ce242c6407ae4c3ef941dce7ff86a7b3a Mon Sep 17 00:00:00 2001 From: Oscar Laredo Date: Fri, 23 Oct 2026 23:47:52 +1000 Subject: [PATCH 1/3] fix(desktop): snapshot background preview tabs without timing out --- .../src/preview/DesktopBrowserHost.test.ts | 97 +++++++++++++++++-- .../desktop/src/preview/DesktopBrowserHost.ts | 35 ++++++- apps/web/src/browser/ElectronBrowserHost.tsx | 2 + .../src/browser/browserCaptureSurface.test.ts | 87 +++++++++++++++++ apps/web/src/browser/browserCaptureSurface.ts | 68 +++++++++++++ packages/contracts/src/ipc.ts | 3 + 6 files changed, 282 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/browser/browserCaptureSurface.test.ts create mode 100644 apps/web/src/browser/browserCaptureSurface.ts diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 50039c4e4b04..2549eee422e6 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -30,24 +30,34 @@ const command = ( }), ); -const makeDebuggee = () => { +const makeDebuggee = (prepareSurface: () => Promise = async () => true) => { const emitter = new NodeEvents.EventEmitter(); - const pending: Array<() => void> = []; + const pending: Array<(cause?: Error) => void> = []; const sent: Array = []; const sendWaiters: Array<() => void> = []; const debuggee = Object.assign(emitter, { sendCommand: (method: string, params?: { id?: number }) => - new Promise((resolve) => { + new Promise((resolve, reject) => { if (method === "Target.getTargetInfo") { resolve({ targetInfo: { targetId: "GUEST" } }); return; } sent.push(params?.id); - pending.push(() => resolve({ method })); + pending.push((cause) => (cause ? reject(cause) : resolve({ method }))); sendWaiters.shift()?.(); }), }); + const surfaceRequests: string[] = []; const webContents = Object.assign(new NodeEvents.EventEmitter(), { + id: 42, + isDestroyed: () => false, + hostWebContents: { + isDestroyed: () => false, + executeJavaScript: (expression: string) => { + surfaceRequests.push(expression); + return surfaceRequests.length === 1 ? prepareSurface() : Promise.resolve(true); + }, + }, getURL: () => "http://localhost/", getTitle: () => "Page", getUserAgent: () => "Electron", @@ -61,9 +71,10 @@ const makeDebuggee = () => { navigate: () => webContents.emit("did-navigate"), /** Ids of the commands the debugger received, in order. */ sent, + surfaceRequests, nextSend: () => new Promise((resolve) => sendWaiters.push(resolve)), release: () => pending.splice(0).forEach((resolve) => resolve()), - releaseFirst: () => pending.shift()?.(), + releaseFirst: (cause?: Error) => pending.shift()?.(cause), }; }; @@ -74,6 +85,16 @@ const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], coun Effect.map((lines) => lines.map((line) => decodeEvent(new TextDecoder().decode(line)))), ); +const sendScreenshot = Effect.fn("test.sendScreenshot")(function* ( + host: DesktopBrowserHost.DesktopBrowserHost["Service"], + debuggee: ReturnType, + id: number, +) { + const sent = debuggee.nextSend(); + yield* command(host, id, "Page.captureScreenshot"); + yield* Effect.promise(() => sent); +}); + /** * Ids of the replies the host sends, in order. A screenshot's reply leaves after its turn * has passed on, so by then the next waiting screenshot, if any, has been sent. Subscribe @@ -94,6 +115,58 @@ const watchReplies = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => }); describe("DesktopBrowserHost", () => { + it.effect.each([false, true])( + "waits for a renderable surface and restores it after capture (failed: %s)", + (failed) => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const ready = Promise.withResolvers(); + const debuggee = makeDebuggee(() => ready.promise); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + yield* command(host, 1, "Page.captureScreenshot"); + expect(debuggee.sent).toEqual([]); + + const sent = debuggee.nextSend(); + ready.resolve(true); + yield* Effect.promise(() => sent); + debuggee.releaseFirst(failed ? new Error("Capture failed") : undefined); + expect(yield* Queue.take(replies)).toBe(1); + expect(debuggee.surfaceRequests).toHaveLength(2); + }).pipe(Effect.scoped), + ); + + it.effect("does not send a screenshot superseded while its surface was becoming ready", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const ready = Promise.withResolvers(); + const debuggee = makeDebuggee(() => ready.promise); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + yield* command(host, 1, "Page.captureScreenshot"); + debuggee.navigate(); + ready.resolve(true); + expect(yield* Queue.take(replies)).toBe(1); + expect(debuggee.sent).toEqual([]); + expect(debuggee.surfaceRequests).toHaveLength(2); + }).pipe(Effect.scoped), + ); + + it.effect("does not issue a screenshot when its render surface is unavailable", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(async () => false); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + yield* command(host, 1, "Page.captureScreenshot"); + expect(yield* Queue.take(replies)).toBe(1); + expect(debuggee.sent).toEqual([]); + }).pipe(Effect.scoped), + ); + it.effect("announces tabs already attached to a backend that starts later", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; @@ -120,7 +193,7 @@ describe("DesktopBrowserHost", () => { message: encodeJson({ id, method, sessionId: "t3-preview-page" }), }), ); - yield* command(1, "Page.captureScreenshot"); + yield* sendScreenshot(host, debuggee, 1); yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* command(2, "DOM.enable"); debuggee.release(); @@ -138,13 +211,15 @@ describe("DesktopBrowserHost", () => { host.attach(key, debuggee.tab); const replies = yield* watchReplies(host); - yield* command(host, 1, "Page.captureScreenshot"); + yield* sendScreenshot(host, debuggee, 1); yield* command(host, 2, "Page.captureScreenshot"); yield* command(host, 3, "DOM.enable"); expect(debuggee.sent).toEqual([1, 3]); + const secondSent = debuggee.nextSend(); debuggee.releaseFirst(); expect(yield* Queue.take(replies)).toBe(1); + yield* Effect.promise(() => secondSent); expect(debuggee.sent).toEqual([1, 3, 2]); }).pipe(Effect.scoped), ); @@ -156,10 +231,12 @@ describe("DesktopBrowserHost", () => { host.attach(key, debuggee.tab); const replies = yield* watchReplies(host); - yield* command(host, 1, "Page.captureScreenshot"); + yield* sendScreenshot(host, debuggee, 1); yield* command(host, 2, "Page.captureScreenshot"); yield* command(host, 3, "Page.captureScreenshot"); + const secondSent = debuggee.nextSend(); debuggee.navigate(); + yield* Effect.promise(() => secondSent); expect(debuggee.sent).toEqual([1, 2]); // The superseded screenshot answering late must not start a third beside the second. @@ -167,8 +244,10 @@ describe("DesktopBrowserHost", () => { expect(yield* Queue.take(replies)).toBe(1); expect(debuggee.sent).toEqual([1, 2]); + const thirdSent = debuggee.nextSend(); debuggee.releaseFirst(); expect(yield* Queue.take(replies)).toBe(2); + yield* Effect.promise(() => thirdSent); expect(debuggee.sent).toEqual([1, 2, 3]); }).pipe(Effect.scoped), ); @@ -179,7 +258,7 @@ describe("DesktopBrowserHost", () => { const debuggee = makeDebuggee(); host.attach(key, debuggee.tab); - yield* command(host, 1, "Page.captureScreenshot"); + yield* sendScreenshot(host, debuggee, 1); yield* command(host, 2, "Page.captureScreenshot"); yield* host.handleCommandLine(encodeJson({ type: "release", ...key })); yield* command(host, 3, "Page.captureScreenshot"); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 7028d16b4be2..7a18eeb6c062 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -4,12 +4,14 @@ * the relay shares it. */ import { + DESKTOP_PREVIEW_CAPTURE_SURFACE_TRIGGER, DesktopBrowserCommand, DesktopBrowserEvent, type DesktopBrowserEvent as DesktopBrowserEventType, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as NodePath from "node:path"; +import * as NodeCrypto from "node:crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -112,7 +114,7 @@ export const make = Effect.gen(function* () { } const turn = {}; tab.screenshotTurn = turn; - capture() + withCaptureSurface(tab, relay, turn, capture) .finally(() => { if (tab.screenshotTurn === turn) nextScreenshot(tab); }) @@ -122,6 +124,37 @@ export const make = Effect.gen(function* () { else tab.waitingScreenshots.push(start); }); + const withCaptureSurface = async ( + tab: AttachedTab, + relay: CdpRelayConnection, + turn: object, + capture: () => Promise, + ) => { + const wc = tab.debuggee.webContents; + const host = wc.hostWebContents; + if (!host || host.isDestroyed() || wc.isDestroyed()) { + throw new Error("The preview render surface is unavailable."); + } + const captureId = NodeCrypto.randomUUID(); + const setSurface = (active: boolean) => + host.executeJavaScript( + `globalThis[${JSON.stringify(DESKTOP_PREVIEW_CAPTURE_SURFACE_TRIGGER)}]?.(${wc.id}, ${JSON.stringify(captureId)}, ${active})`, + ); + try { + // The guest stays offscreen until React commits the activity lease and Electron + // receives its new bounds. CDP can otherwise wait forever for a composited frame. + if ((await setSurface(true)) !== true) { + throw new Error("The preview render surface did not become ready for capture."); + } + if (tab.relay !== relay || tab.screenshotTurn !== turn || wc.isDestroyed()) { + throw new Error("The preview connection that asked for this screenshot closed."); + } + return await capture(); + } finally { + if (!host.isDestroyed()) await setSurface(false).catch(() => undefined); + } + }; + const relayFor = (tab: AttachedTab) => { if (tab.relay) return tab.relay; const { webContents, debugger: debuggee } = tab.debuggee; diff --git a/apps/web/src/browser/ElectronBrowserHost.tsx b/apps/web/src/browser/ElectronBrowserHost.tsx index 8818b377f5e3..0344b9151d43 100644 --- a/apps/web/src/browser/ElectronBrowserHost.tsx +++ b/apps/web/src/browser/ElectronBrowserHost.tsx @@ -19,6 +19,7 @@ import { useAtomCommand } from "~/state/use-atom-command"; import { readPreviewAnnotationTheme } from "./annotationTheme"; import { useBrowserDefaults } from "./browserDefaults"; +import { installBrowserCaptureSurface } from "./browserCaptureSurface"; import { useBrowserPointerStore } from "./browserPointerStore"; import { HostedBrowserWebview } from "./HostedBrowserWebview"; import { openUrlInPreview } from "./openFileInPreview"; @@ -26,6 +27,7 @@ import { rendersServerTabNatively } from "./previewRuntime"; import { previewRuntimeTabId } from "./previewRuntimeTabId"; export function ElectronBrowserHost() { + useEffect(() => (isElectron ? installBrowserCaptureSurface() : undefined), []); const previewByThreadKey = useActivePreviewSessions(); const primaryEnvironmentId = useAtomValue(primaryEnvironmentIdAtom); useDesktopHostedPreviewSessions(primaryEnvironmentId); diff --git a/apps/web/src/browser/browserCaptureSurface.test.ts b/apps/web/src/browser/browserCaptureSurface.test.ts new file mode 100644 index 000000000000..c056d30794d8 --- /dev/null +++ b/apps/web/src/browser/browserCaptureSurface.test.ts @@ -0,0 +1,87 @@ +import { DESKTOP_PREVIEW_CAPTURE_SURFACE_TRIGGER } from "@t3tools/contracts"; +import { expect, it, vi } from "vite-plus/test"; + +import { installBrowserCaptureSurface } from "./browserCaptureSurface"; +import { acquireBrowserSurfaceActivity, useBrowserSurfaceStore } from "./browserSurfaceStore"; + +function setup() { + vi.useFakeTimers(); + const frames = new Map(); + let sequence = 0; + vi.stubGlobal("window", { + requestAnimationFrame: (callback: FrameRequestCallback) => { + frames.set(++sequence, callback); + return sequence; + }, + cancelAnimationFrame: (id: number) => frames.delete(id), + setTimeout: globalThis.setTimeout, + clearTimeout: globalThis.clearTimeout, + }); + vi.stubGlobal("document", { + querySelectorAll: () => + [42, 43].map((id) => ({ + getWebContentsId: () => id, + dataset: { previewTab: `tab-${id}` }, + closest: () => ({ getBoundingClientRect: () => ({ width: 1280, height: 800 }) }), + })), + }); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + const dispose = installBrowserCaptureSurface(); + const setSurface = Reflect.get(globalThis, DESKTOP_PREVIEW_CAPTURE_SURFACE_TRIGGER) as ( + webContentsId: number, + captureId: string, + active: boolean, + ) => Promise; + return { + setSurface, + dispose, + paint: () => { + const callbacks = [...frames.values()]; + frames.clear(); + for (const callback of callbacks) callback(0); + }, + [Symbol.dispose]() { + dispose(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }, + }; +} + +it("activates only the captured tab and preserves another consumer when capture ends", async () => { + using surface = setup(); + const releaseRecording = acquireBrowserSurfaceActivity("tab-42"); + const ready = surface.setSurface(42, "capture-1", true); + expect(useBrowserSurfaceStore.getState().activityByTabId).toEqual({ "tab-42": 2 }); + surface.paint(); + surface.paint(); + expect(await ready).toBe(true); + + await surface.setSurface(42, "capture-1", false); + expect(useBrowserSurfaceStore.getState().activityByTabId).toEqual({ "tab-42": 1 }); + releaseRecording(); + expect(useBrowserSurfaceStore.getState().activityByTabId).toEqual({}); +}); + +it("releases a tab whose embedder never paints", async () => { + using surface = setup(); + const ready = surface.setSurface(42, "capture-1", true); + await vi.advanceTimersByTimeAsync(1_000); + expect(await ready).toBe(false); + expect(useBrowserSurfaceStore.getState().activityByTabId).toEqual({}); +}); + +it("bounds rendering for an abandoned capture and releases pending work on teardown", async () => { + using surface = setup(); + const ready = surface.setSurface(42, "capture-1", true); + surface.paint(); + surface.paint(); + expect(await ready).toBe(true); + await vi.advanceTimersByTimeAsync(10_000); + expect(useBrowserSurfaceStore.getState().activityByTabId).toEqual({}); + + const pending = surface.setSurface(43, "capture-2", true); + surface.dispose(); + expect(await pending).toBe(false); + expect(useBrowserSurfaceStore.getState().activityByTabId).toEqual({}); +}); diff --git a/apps/web/src/browser/browserCaptureSurface.ts b/apps/web/src/browser/browserCaptureSurface.ts new file mode 100644 index 000000000000..5ab8b19c2678 --- /dev/null +++ b/apps/web/src/browser/browserCaptureSurface.ts @@ -0,0 +1,68 @@ +import { DESKTOP_PREVIEW_CAPTURE_SURFACE_TRIGGER } from "@t3tools/contracts"; +import { flushSync } from "react-dom"; + +import { acquireBrowserSurfaceActivity } from "./browserSurfaceStore"; + +interface CaptureWebview extends HTMLElement { + getWebContentsId(): number; +} + +export function installBrowserCaptureSurface(): () => void { + const leases = new Map void>(); + const setSurface = async (webContentsId: number, captureId: string, active: boolean) => { + if (!active) { + flushSync(() => leases.get(captureId)?.()); + return true; + } + if (leases.has(captureId)) return false; + const webview = [ + ...document.querySelectorAll("webview[data-preview-tab]"), + ].find((node) => { + try { + return node.getWebContentsId() === webContentsId; + } catch { + return false; + } + }); + const tabId = webview?.dataset.previewTab; + const wrapper = webview?.closest("[data-preview-viewport]"); + if (!tabId || !wrapper) return false; + + let releaseActivity = () => {}; + flushSync(() => { + releaseActivity = acquireBrowserSurfaceActivity(tabId); + }); + return new Promise((resolve) => { + let frame: number; + const release = () => { + window.cancelAnimationFrame(frame); + window.clearTimeout(timer); + leases.delete(captureId); + releaseActivity(); + resolve(false); + }; + leases.set(captureId, release); + // Two frames let Electron receive the committed guest bounds before CDP requests + // a copy. A suspended embedder must fail readiness rather than leak the lease. + frame = window.requestAnimationFrame(() => { + wrapper.getBoundingClientRect(); + frame = window.requestAnimationFrame(() => { + window.clearTimeout(timer); + // A server timeout cannot cancel Chromium's copy request. Bound the rendering + // lease too, so an abandoned screenshot cannot keep an inactive tab painting. + timer = window.setTimeout(() => flushSync(release), 10_000); + resolve(true); + }); + }); + let timer = window.setTimeout(() => flushSync(release), 1_000); + }); + }; + Object.defineProperty(globalThis, DESKTOP_PREVIEW_CAPTURE_SURFACE_TRIGGER, { + configurable: true, + value: setSurface, + }); + return () => { + for (const release of leases.values()) release(); + Reflect.deleteProperty(globalThis, DESKTOP_PREVIEW_CAPTURE_SURFACE_TRIGGER); + }; +} diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index d1f6b349397e..baeefe2128a2 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1155,6 +1155,9 @@ export interface DesktopBridge { /** Renderer callback invoked by Electron with a fresh user gesture before display-media capture. */ export const DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER = "__t3DesktopPreviewRecordingCapture"; +/** Renderer handshake that keeps a guest compositing for a single agent screenshot. */ +export const DESKTOP_PREVIEW_CAPTURE_SURFACE_TRIGGER = "__t3DesktopPreviewCaptureSurface"; + export interface DesktopPreviewBridge { setForwardedShortcuts?: (shortcuts: ReadonlyArray) => Promise; createTab: (tabId: string, defaults?: DesktopPreviewTabDefaults) => Promise; From 4cc0368c11262029648452c4042836c50b88e431 Mon Sep 17 00:00:00 2001 From: Oscar Laredo Date: Sat, 24 Oct 2026 00:07:36 +1000 Subject: [PATCH 2/3] fix(desktop): still capture when the preview window isn't painting --- apps/desktop/src/preview/DesktopBrowserHost.test.ts | 13 +++++++++---- apps/desktop/src/preview/DesktopBrowserHost.ts | 10 ++++------ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 2549eee422e6..e29b0ed4c047 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -154,16 +154,21 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); - it.effect("does not issue a screenshot when its render surface is unavailable", () => + it.effect.each<[string, () => Promise]>([ + ["never becomes ready", async () => false], + ["can't be reached", () => Promise.reject(new Error("Script failed"))], + ])("still issues a screenshot when its render surface %s", ([, prepareSurface]) => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; - const debuggee = makeDebuggee(async () => false); + const debuggee = makeDebuggee(prepareSurface); host.attach(key, debuggee.tab); const replies = yield* watchReplies(host); - yield* command(host, 1, "Page.captureScreenshot"); + yield* sendScreenshot(host, debuggee, 1); + debuggee.releaseFirst(); expect(yield* Queue.take(replies)).toBe(1); - expect(debuggee.sent).toEqual([]); + expect(debuggee.sent).toEqual([1]); + expect(debuggee.surfaceRequests).toHaveLength(2); }).pipe(Effect.scoped), ); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 7a18eeb6c062..e2e02bf5f82f 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -132,9 +132,7 @@ export const make = Effect.gen(function* () { ) => { const wc = tab.debuggee.webContents; const host = wc.hostWebContents; - if (!host || host.isDestroyed() || wc.isDestroyed()) { - throw new Error("The preview render surface is unavailable."); - } + if (!host || host.isDestroyed() || wc.isDestroyed()) return capture(); const captureId = NodeCrypto.randomUUID(); const setSurface = (active: boolean) => host.executeJavaScript( @@ -143,9 +141,9 @@ export const make = Effect.gen(function* () { try { // The guest stays offscreen until React commits the activity lease and Electron // receives its new bounds. CDP can otherwise wait forever for a composited frame. - if ((await setSurface(true)) !== true) { - throw new Error("The preview render surface did not become ready for capture."); - } + // A window that isn't painting, like a minimized one, never reports ready, yet its + // on-screen tab can still be captured, so the screenshot is sent either way. + await setSurface(true).catch(() => false); if (tab.relay !== relay || tab.screenshotTurn !== turn || wc.isDestroyed()) { throw new Error("The preview connection that asked for this screenshot closed."); } From f5d52e7f0893191cdca585121cc016ce5691c6b3 Mon Sep 17 00:00:00 2001 From: Oscar Laredo Date: Sat, 24 Oct 2026 00:20:22 +1000 Subject: [PATCH 3/3] fix(desktop): don't let an unresponsive renderer hold preview screenshots --- .../src/preview/DesktopBrowserHost.test.ts | 41 ++++++++++++++++++- .../desktop/src/preview/DesktopBrowserHost.ts | 14 ++++++- 2 files changed, 51 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index e29b0ed4c047..29d2eb0b695f 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -6,6 +6,7 @@ import * as Fiber from "effect/Fiber"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as NodeEvents from "node:events"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; @@ -30,7 +31,10 @@ const command = ( }), ); -const makeDebuggee = (prepareSurface: () => Promise = async () => true) => { +const makeDebuggee = ( + prepareSurface: () => Promise = async () => true, + restoreSurface: () => Promise = async () => true, +) => { const emitter = new NodeEvents.EventEmitter(); const pending: Array<(cause?: Error) => void> = []; const sent: Array = []; @@ -55,7 +59,7 @@ const makeDebuggee = (prepareSurface: () => Promise = async () => true) isDestroyed: () => false, executeJavaScript: (expression: string) => { surfaceRequests.push(expression); - return surfaceRequests.length === 1 ? prepareSurface() : Promise.resolve(true); + return expression.endsWith(", true)") ? prepareSurface() : restoreSurface(); }, }, getURL: () => "http://localhost/", @@ -172,6 +176,39 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); + it.effect("sends the screenshot when the renderer never answers readiness", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(() => new Promise(() => {})); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + const sent = debuggee.nextSend(); + yield* command(host, 1, "Page.captureScreenshot"); + yield* TestClock.adjust("2 seconds"); + yield* Effect.promise(() => sent); + debuggee.releaseFirst(); + expect(yield* Queue.take(replies)).toBe(1); + }).pipe(Effect.scoped), + ); + + it.effect("answers and moves on when the renderer never answers restoring the surface", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(undefined, () => new Promise(() => {})); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + + yield* sendScreenshot(host, debuggee, 1); + yield* command(host, 2, "Page.captureScreenshot"); + const secondSent = debuggee.nextSend(); + debuggee.releaseFirst(); + expect(yield* Queue.take(replies)).toBe(1); + yield* Effect.promise(() => secondSent); + expect(debuggee.sent).toEqual([1, 2]); + }).pipe(Effect.scoped), + ); + it.effect("announces tabs already attached to a backend that starts later", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index e2e02bf5f82f..fe132a95e3e7 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -28,6 +28,8 @@ const lineEncoder = new TextEncoder(); const AGENT_INPUT_COMMAND = /"method":"(?:Input\.|Page\.navigate"|Runtime\.(?:evaluate|callFunctionOn)")/; const AGENT_DOWNLOAD_WINDOW_MS = 5_000; +// The renderer gives up on readiness after 1s; this only catches a renderer that never answers. +const CAPTURE_SURFACE_READY_TIMEOUT = "2 seconds"; export interface DesktopBrowserTabKey { readonly threadId: string; @@ -86,6 +88,7 @@ export const make = Effect.gen(function* () { readonly y: number; }>(16); const runFork = Effect.runForkWith(yield* Effect.context()); + const runPromise = Effect.runPromiseWith(yield* Effect.context()); const tabs = new Map(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); @@ -143,13 +146,20 @@ export const make = Effect.gen(function* () { // receives its new bounds. CDP can otherwise wait forever for a composited frame. // A window that isn't painting, like a minimized one, never reports ready, yet its // on-screen tab can still be captured, so the screenshot is sent either way. - await setSurface(true).catch(() => false); + // A renderer that never answers would hold this screenshot and every one queued behind it. + await runPromise( + Effect.tryPromise(() => setSurface(true)).pipe( + Effect.timeoutOption(CAPTURE_SURFACE_READY_TIMEOUT), + Effect.ignore, + ), + ); if (tab.relay !== relay || tab.screenshotTurn !== turn || wc.isDestroyed()) { throw new Error("The preview connection that asked for this screenshot closed."); } return await capture(); } finally { - if (!host.isDestroyed()) await setSurface(false).catch(() => undefined); + // The renderer bounds its own lease, so the result needn't wait for its answer. + if (!host.isDestroyed()) void setSurface(false).catch(() => undefined); } };