From aa82fbaf7940dc4d6e2e4181ca21ffdf6e9d6f2c Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 14 Oct 2026 13:36:39 +0000 Subject: [PATCH 1/9] fix(server): taps and typing from another device reach desktop browser tabs A tab the desktop app renders is driven over CDP, where Playwright has no viewport, so viewers never got a page size and the phone dropped every tap, scroll and key. Measure the desktop page's CSS size in an isolated world and send it when a viewer attaches, when the webview resizes, in status, and after a dialog closes. Ported from #17493. Co-authored-by: Max Nylander Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 243 +++++++++++++++++- apps/server/src/preview/ServerBrowser.ts | 75 +++++- 2 files changed, 301 insertions(+), 17 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 99df18c10cf5a..ce5c0b5b498a1 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -56,6 +56,9 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ } async connectDesktopPage(endpoint: string) { const context = makeContext(); + context.page.viewportSize.mockReturnValue(null); + // A hidden `` measures 0x0 until it is shown. + if (desktopHiddenNext) Object.assign(context.cssViewport, { width: 0, height: 0 }); desktopConnections.push({ endpoint, context }); return { browser: { close: async () => {} }, page: context.page as unknown as Page }; } @@ -83,11 +86,13 @@ const PNG_HEADER = (() => { return header.toString("base64"); })(); -function makeSession(frame?: ReturnType) { +function makeSession(frame: ReturnType | undefined, viewport: { width: number; height: number }) { const events = new NodeEvents.EventEmitter(); return { - on: vi.fn((name: string, listener: () => void) => events.on(name, listener)), - emit: (name: string) => events.emit(name), + on: vi.fn((name: string, callback: (...args: unknown[]) => void) => events.on(name, callback)), + emit: (name: string, ...args: unknown[]) => events.emit(name, ...args), + emitAsync: (name: string, ...args: unknown[]) => + Promise.all(events.listeners(name).map((listener) => listener(...args))), detach: vi.fn(async () => {}), send: vi.fn(async (method: string, input?: unknown): Promise> => { if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] }; @@ -101,7 +106,7 @@ function makeSession(frame?: ReturnType) { if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; if (method === "Page.createIsolatedWorld") { return { - executionContextId: (input as { worldName?: string }).worldName === "t3-preview" ? 1 : 7, + executionContextId: (input as { worldName?: string }).worldName === "t3-preview-viewport" ? 9 : (input as { worldName?: string }).worldName === "t3-preview" ? 1 : 7, }; } if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 1) { @@ -118,6 +123,7 @@ function makeSession(frame?: ReturnType) { else pageCopy = ""; return { result: { value } }; } + if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 9) return { result: { value: { ...viewport } } }; return { result: { value: "evaluated" } }; }), }; @@ -129,7 +135,8 @@ function makeContext(onClose?: (context: BrowserContext) => void) { const setChooserFiles = vi.fn(async (_files: unknown) => {}); const sessions: ReturnType[] = []; let url = "about:blank"; - let viewport = { width: 1280, height: 800 }; + let viewport: ReturnType = { width: 1280, height: 800 }; + const cssViewport = { width: 1920, height: 1080 }; let closed = false; let contextClosed = false; const page = { @@ -159,7 +166,7 @@ function makeContext(onClose?: (context: BrowserContext) => void) { frames: () => [page], url: () => url, title: vi.fn(async () => "test page"), - viewportSize: () => viewport, + viewportSize: vi.fn(() => viewport), setViewportSize: vi.fn(async (size: typeof viewport) => { viewport = size; }), @@ -197,11 +204,12 @@ function makeContext(onClose?: (context: BrowserContext) => void) { const context = { page, sessions, + cssViewport, newPage: async () => page as unknown as Page, grantPermissions: vi.fn(async () => {}), newCDPSession: async (target: unknown) => { const session = makeSession( - target === page ? undefined : (target as ReturnType), + target === page ? undefined : (target as ReturnType), cssViewport, ); sessions.push(session); return session; @@ -268,6 +276,7 @@ let focusedFrame: ReturnType | null = null; let contextFailure: Error | null = null; let initialNavigation: (() => Promise) | null = null; let desktopRendersNext = false; +let desktopHiddenNext = false; const desktopDetaches = new NodeEvents.EventEmitter(); const desktopTabs = new Set(); const desktopRenders = (tabId: string) => { @@ -367,6 +376,13 @@ const viewerInput = (tabId: string, canOperate: boolean) => ({ maxHeight: 800, quality: 70, }); +/** Waits for a viewport. Pulling drains the queue, so it also discards what arrived with it. */ +const nextViewerViewport = (viewer: ServerBrowser.ServerBrowserViewer) => + Stream.fromQueue(viewer.output).pipe( + Stream.filter((item) => item._tag === "viewport"), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); beforeEach(() => { contexts.length = 0; @@ -380,6 +396,7 @@ beforeEach(() => { focusedFrame = null; desktopTabs.clear(); desktopRendersNext = false; + desktopHiddenNext = false; releasedDesktopTabs.length = 0; desktopConnections.length = 0; }); @@ -1144,6 +1161,218 @@ it.live("only the current controller can answer a file picker", () => ).pipe(Effect.provide(layer)), ); +it.live("desktop viewers follow the page's native size and zoom", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + const broker = yield* Broker.PreviewAutomationBroker; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const operator = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const observer = yield* browser.attachViewer(viewerInput(opened.tabId, false)); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + yield* operator.input({ type: "takeControl" }); + // The desktop panel sizes its page, even for a fixed setting. + const setting = { _tag: "preset", presetId: "desktop-1920x1080", width: 1920, height: 1080 }; + yield* operator.input({ type: "viewport", setting }); + for (const viewer of [operator, observer]) yield* nextViewerViewport(viewer); + // The measured size already includes the desktop's native zoom. + yield* manager.adjust({ + threadId: scope.thread.threadId, + tabId: opened.tabId, + zoomFactor: 2, + }); + Object.assign(context.cssViewport, { width: 1000, height: 600 }); + cdp.emit("Page.frameResized"); + for (const viewer of [operator, observer]) { + expect(yield* nextViewerViewport(viewer)).toEqual({ + _tag: "viewport", + width: 1000, + height: 600, + }); + } + expect(context.page.setViewportSize).not.toHaveBeenCalled(); + expect(cdp.send.mock.calls.some(([method]) => method.startsWith("Emulation."))).toBe(false); + expect( + yield* broker.invoke({ + scope, + operation: "status", + input: {}, + tabId: PreviewTabId.make(opened.tabId), + }), + ).toMatchObject({ viewport: { width: 1000, height: 600 } }); + // Viewers map clicks with that size, so the page gets CSS coordinates as sent. + yield* operator.input({ type: "mouse", action: "down", x: 750, y: 450, button: "left" }); + expect(context.sessions[1]!.send).toHaveBeenCalledWith( + "Input.dispatchMouseEvent", + expect.objectContaining({ type: "mousePressed", x: 750, y: 450 }), + ); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a desktop dialog reuses the last size and remeasures when it closes", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + const broker = yield* Broker.PreviewAutomationBroker; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const status = broker.invoke({ + scope, + operation: "status", + input: {}, + tabId: PreviewTabId.make(opened.tabId), + }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + const dialog = { + type: () => "confirm", + message: () => "Continue?", + defaultValue: () => "", + accept: vi.fn(async () => {}), + dismiss: vi.fn(async () => {}), + }; + // An open dialog blocks page script, so status and a new viewer use the last size. + context.page.emit("dialog", dialog); + cdp.send.mockClear(); + expect(yield* status).toMatchObject({ + viewport: { width: 1920, height: 1080 }, + dialog: { type: "confirm", message: "Continue?" }, + }); + const reconnected = yield* browser.attachViewer(viewerInput(opened.tabId, false)); + expect(yield* nextViewerViewport(reconnected)).toEqual({ + _tag: "viewport", + width: 1920, + height: 1080, + }); + expect(cdp.send).not.toHaveBeenCalled(); + yield* Queue.clear(viewer.output); + Object.assign(context.cssViewport, { width: 1440, height: 900 }); + yield* viewer.input({ type: "dialog", accept: true }); + expect(dialog.accept).toHaveBeenCalledOnce(); + expect(yield* nextViewerViewport(viewer)).toEqual({ + _tag: "viewport", + width: 1440, + height: 900, + }); + // An agent can answer it too, and the status it reads next must not hide the new size. + yield* viewer.input({ type: "releaseControl" }); + context.page.emit("dialog", dialog); + Object.assign(context.cssViewport, { width: 1280, height: 720 }); + yield* broker.invoke({ + scope, + operation: "dialog", + input: { accept: true }, + tabId: PreviewTabId.make(opened.tabId), + }); + expect(yield* nextViewerViewport(viewer)).toEqual({ + _tag: "viewport", + width: 1280, + height: 720, + }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a desktop size read that is late, empty or fails keeps the newest size", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + const broker = yield* Broker.PreviewAutomationBroker; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + const send = cdp.send.getMockImplementation()!; + // Each queued answer replaces one page evaluation; the rest measure cssViewport. + const evaluations: Array<() => Promise>> = []; + cdp.send.mockImplementation((method, input) => + method === "Runtime.evaluate" && evaluations.length > 0 + ? evaluations.shift()!() + : send(method, input), + ); + const late = Promise.withResolvers>(); + yield* Effect.addFinalizer(() => Effect.sync(() => late.resolve({}))); + yield* nextViewerViewport(viewer); + // An older read stalls until after a newer read finishes. + evaluations.push(() => late.promise); + const stale = cdp.emitAsync("Page.frameResized"); + Object.assign(context.cssViewport, { width: 1500, height: 900 }); + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + late.resolve({ result: { value: { width: 100, height: 100 } } }); + yield* Effect.promise(() => stale); + // A hidden webview measures 0x0. + Object.assign(context.cssViewport, { width: 0, height: 0 }); + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + const sizes = (yield* Queue.takeAll(viewer.output)).flatMap((item) => + item._tag === "viewport" ? [`${item.width}x${item.height}`] : [], + ); + expect(new Set(sizes)).toEqual(new Set(["1500x900"])); + // A navigation can destroy the isolated world mid-read. + evaluations.push(() => Promise.reject(new Error("Cannot find context with specified id"))); + expect( + yield* broker.invoke({ + scope, + operation: "status", + input: {}, + tabId: PreviewTabId.make(opened.tabId), + }), + ).toMatchObject({ viewport: { width: 1500, height: 900 } }); + expect(evaluations).toEqual([]); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a late first desktop size still reaches viewers when a newer read fails", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopRendersNext = true; + desktopHiddenNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + // The viewer attached while hidden, so no size is known yet. + while (!cdp.send.mock.calls.some(([method]) => method === "Runtime.evaluate")) + yield* Effect.yieldNow; + const send = cdp.send.getMockImplementation()!; + const late = Promise.withResolvers>(); + yield* Effect.addFinalizer(() => Effect.sync(() => late.resolve({}))); + // Once shown, an older read stalls and a navigation fails the newer one. + const evaluations = [ + () => late.promise, + () => Promise.reject(new Error("Cannot find context with specified id")), + ]; + cdp.send.mockImplementation((method, input) => + method === "Runtime.evaluate" && evaluations.length > 0 + ? evaluations.shift()!() + : send(method, input), + ); + const stale = cdp.emitAsync("Page.frameResized"); + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + late.resolve({ result: { value: { width: 1500, height: 900 } } }); + yield* Effect.promise(() => stale); + const sizes = (yield* Queue.takeAll(viewer.output)).flatMap((item) => + item._tag === "viewport" ? [`${item.width}x${item.height}`] : [], + ); + expect(sizes).toEqual(["1500x900"]); + }), + ).pipe(Effect.provide(layer)), +); + it.live("agents read a human's tab with no arguments and act on it only while nobody drives", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 6f3e86a7501ea..6384500a47480 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -309,6 +309,10 @@ interface ServerTab { setting: PreviewViewportSetting; colorScheme: PreviewAppearancePreference; zoomFactor: number; + /** Last CSS size measured on the desktop, usable while a dialog blocks page script. */ + desktopViewport: { readonly width: number; readonly height: number } | null; + /** Counts desktop size reads, so a slower older read cannot overwrite a newer one. */ + desktopViewportReads: number; loading: boolean; closing: boolean; recording: Recording | null; @@ -550,6 +554,8 @@ const make = Effect.gen(function* () { if (tab.dialog === dialog) tab.dialog = null; ServerBrowserPage.invalidateRefs(tab.page); broadcastControl(tab); + // The desktop page could not be measured while the dialog was open. + if (tab.desktop) await broadcastViewport(tab).catch(constVoid); }; const report = (tab: ServerTab, navStatus: PreviewNavStatus) => { @@ -605,9 +611,49 @@ const make = Effect.gen(function* () { ); }; - const layoutSize = (tab: ServerTab) => { + /** + * Reads the desktop page's CSS size, including scrollbars and native zoom, in + * an isolated world so page script cannot override it. Keeps the last size + * while a dialog blocks script, when a navigation destroys the world mid-read, + * when a newer read already stored one, and for a hidden webview's 0x0. + */ + const measureDesktopViewport = async (tab: ServerTab) => { + if (tab.dialog) return tab.desktopViewport; + const read = ++tab.desktopViewportReads; + let value: unknown; + try { + const { frameTree } = await tab.cdp.send("Page.getFrameTree"); + const { executionContextId } = await tab.cdp.send("Page.createIsolatedWorld", { + frameId: frameTree.frame.id, + worldName: "t3-preview-viewport", + }); + const { result } = await tab.cdp.send("Runtime.evaluate", { + expression: "({ width: innerWidth, height: innerHeight })", + contextId: executionContextId, + returnByValue: true, + }); + value = result.value; + } catch { + return tab.desktopViewport; + } + if (read !== tab.desktopViewportReads && tab.desktopViewport) return tab.desktopViewport; + const size = asRecord(value); + if (!size || typeof size.width !== "number" || typeof size.height !== "number") + return tab.desktopViewport; + if (size.width <= 0 || size.height <= 0) return tab.desktopViewport; + tab.desktopViewport = { width: size.width, height: size.height }; + return tab.desktopViewport; + }; + + /** + * The CSS size the page lays out in. A desktop page is measured, since a page + * Playwright reached over CDP has no viewport. A headless page's size is its + * viewport shrunk by the tab's zoom, as Chrome zooms. + */ + const layoutSize = async (tab: ServerTab) => { + if (tab.desktop) return measureDesktopViewport(tab); const size = tab.page.viewportSize(); - if (!size || tab.desktop) return size; + if (!size) return size; return { width: Math.max(1, Math.round(size.width / tab.zoomFactor)), height: Math.max(1, Math.round(size.height / tab.zoomFactor)), @@ -622,7 +668,7 @@ const make = Effect.gen(function* () { await tab.page.setViewportSize(size); return; } - const layout = layoutSize(tab)!; + const layout = (await layoutSize(tab))!; await tab.cdp.send("Emulation.setDeviceMetricsOverride", { width: layout.width, height: layout.height, @@ -631,8 +677,8 @@ const make = Effect.gen(function* () { }); }; - const broadcastViewport = (tab: ServerTab) => { - const size = layoutSize(tab); + const broadcastViewport = async (tab: ServerTab) => { + const size = await layoutSize(tab); if (!size) return; for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size }); }; @@ -650,7 +696,7 @@ const make = Effect.gen(function* () { UNATTACHED_FILL_VIEWPORT; await tab.page.setViewportSize({ width: size.width, height: size.height }); await applyZoom(tab); - broadcastViewport(tab); + await broadcastViewport(tab); }; const applyRendering = async (tab: ServerTab, snapshot: PreviewSessionSnapshot) => { @@ -663,7 +709,7 @@ const make = Effect.gen(function* () { if (zoomFactor !== tab.zoomFactor && !tab.desktop) { tab.zoomFactor = zoomFactor; await applyZoom(tab); - broadcastViewport(tab); + await broadcastViewport(tab); } }; @@ -789,6 +835,8 @@ const make = Effect.gen(function* () { setting: snapshot.viewport ?? FILL_PREVIEW_VIEWPORT, colorScheme: "system", zoomFactor: 1, + desktopViewport: null, + desktopViewportReads: 0, loading: false, closing: false, recording: null, @@ -803,6 +851,13 @@ const make = Effect.gen(function* () { await page.setViewportSize(fixedViewportSize(tab.setting) ?? UNATTACHED_FILL_VIEWPORT); } await applyRendering(tab, snapshot); + if (desktop) { + // The desktop resizes and zooms its webview itself; viewers map input with the new size. + cdp.on("Page.frameResized", () => { + if (tab.viewers.size > 0) return broadcastViewport(tab).catch(constVoid); + }); + await cdp.send("Page.enable"); + } const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => request.isNavigationRequest() && request.frame() === page.mainFrame(); const navigationGenerations = new WeakMap(); @@ -1383,7 +1438,7 @@ const make = Effect.gen(function* () { }; } const url = tab.page.url(); - const viewport = tab.page.viewportSize(); + const viewport = tab.desktop ? await layoutSize(tab) : tab.page.viewportSize(); const status = { available: true, visible: tab.viewers.size > 0, @@ -2213,7 +2268,7 @@ const make = Effect.gen(function* () { const current = tab.page.viewportSize(); if (current?.width === width && current.height === height) return; await tab.page.setViewportSize({ width, height }); - broadcastViewport(tab); + await broadcastViewport(tab); return; } case "viewport": { @@ -2410,7 +2465,7 @@ const make = Effect.gen(function* () { ), }); }); - broadcastViewport(tab); + void broadcastViewport(tab).catch(constVoid); yield* Effect.promise(() => startScreencast(1)); // An idle page does not repaint for a new screencast, so the viewer // starts from a still unless a live frame beat it. From ea4f95d64ac4226752a2912e52dcf99f22b7ac26 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 14 Oct 2026 13:55:49 +0000 Subject: [PATCH 2/9] fix(server): keep the desktop tab size through stalls, dialogs and slow viewers - Give up on a size read after a second, so a dialog that opens mid-read cannot hang status or viewers, and ignore its late answer. - Let a late successful read land when a newer read failed instead of stored a size. - Clear a dialog the desktop closes itself and measure again. - A size message replaces a full viewer backlog like control does, keeping the latest size, control state, one-time notifications and gone/reconnect. Ported from #17493. Co-authored-by: Max Nylander Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 214 ++++++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 68 ++++-- 2 files changed, 267 insertions(+), 15 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index ce5c0b5b498a1..e6cf546ed8b7d 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -377,6 +377,23 @@ const viewerInput = (tabId: string, canOperate: boolean) => ({ quality: 70, }); /** Waits for a viewport. Pulling drains the queue, so it also discards what arrived with it. */ +const stallViewer = ( + viewer: ServerBrowser.ServerBrowserViewer, + item: ServerBrowser.ServerBrowserViewerOutput, +) => { + // The service hands out the read side of a queue it also writes to. + const output = viewer.output as unknown as Queue.Queue; + while (Queue.offerUnsafe(output, item)); +}; +const viewerInput = (tabId: string, canOperate: boolean) => ({ + threadId: scope.thread.threadId, + tabId, + canOperate, + maxWidth: 1280, + maxHeight: 800, + quality: 70, +}); +/** Waits for a viewport. Pulling drains the queue, so it also discards what arrived with it. */ const nextViewerViewport = (viewer: ServerBrowser.ServerBrowserViewer) => Stream.fromQueue(viewer.output).pipe( Stream.filter((item) => item._tag === "viewport"), @@ -1161,6 +1178,79 @@ it.live("only the current controller can answer a file picker", () => ).pipe(Effect.provide(layer)), ); +it.live("a stalled viewer's backlog replacement keeps its other state and notifications", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + yield* Queue.clear(viewer.output); + const output = + viewer.output as unknown as Queue.Queue; + /** Fills the stalled backlog, sends input that replaces it, and returns what is left. */ + const replaceBacklog = (input: unknown) => + Effect.gen(function* () { + // Chromium sends no new frames until each dropped one is acked. + const acked = Promise.withResolvers(); + let unacked = 0; + const ack = Effect.sync(() => { + if (--unacked === 0) acked.resolve(); + }); + while (Queue.offerUnsafe(output, { _tag: "frame", data: Buffer.alloc(0), ack })) + unacked++; + yield* viewer.input(input); + yield* Effect.promise(() => acked.promise); + return yield* Queue.takeAll(viewer.output); + }); + // One-time notifications cannot be rebuilt from tab state. + const notifications: Array = [ + { _tag: "download", id: "download-1", fileName: "report.csv", sizeBytes: 3 }, + { _tag: "clipboard", text: "copied" }, + { _tag: "popup", tabId: "popup-1" }, + { _tag: "fileChooserClosed", id: "chooser-1" }, + ]; + for (const item of notifications) Queue.offerUnsafe(output, item); + yield* viewer.input({ type: "takeControl" }); + const resized = yield* replaceBacklog({ type: "resize", width: 900, height: 600 }); + expect(resized).toHaveLength(2 + notifications.length); + expect(resized).toEqual( + expect.arrayContaining([ + { _tag: "viewport", width: 900, height: 600 }, + expect.objectContaining({ _tag: "control", controller: "you" }), + ]), + ); + expect(resized.filter((item) => notifications.includes(item))).toEqual(notifications); + yield* viewer.input({ type: "resize", width: 640, height: 480 }); + const released = yield* replaceBacklog({ type: "releaseControl" }); + expect(released).toHaveLength(2); + expect(released).toEqual( + expect.arrayContaining([ + expect.objectContaining({ _tag: "control", controller: "agent" }), + { _tag: "viewport", width: 640, height: 480 }, + ]), + ); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a closing tab still ends a stalled viewer whose backlog is all notifications", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* Queue.clear(viewer.output); + stallViewer(viewer, { + _tag: "download", + id: "download-1", + fileName: "report.csv", + sizeBytes: 3, + }); + contexts[0]!.page.emit("close"); + expect(yield* Queue.takeAll(viewer.output)).toContainEqual({ _tag: "gone" }); + }), + ).pipe(Effect.provide(layer)), +); + it.live("desktop viewers follow the page's native size and zoom", () => Effect.scoped( Effect.gen(function* () { @@ -1262,6 +1352,16 @@ it.live("a desktop dialog reuses the last size and remeasures when it closes", ( width: 1440, height: 900, }); + // The host can also close its own dialog, outside viewer input. + context.page.emit("dialog", dialog); + Object.assign(context.cssViewport, { width: 960, height: 600 }); + cdp.emit("Page.javascriptDialogClosed"); + expect(yield* nextViewerViewport(viewer)).toEqual({ + _tag: "viewport", + width: 960, + height: 600, + }); + expect(yield* status).toMatchObject({ dialog: null, viewport: { width: 960, height: 600 } }); // An agent can answer it too, and the status it reads next must not hide the new size. yield* viewer.input({ type: "releaseControl" }); context.page.emit("dialog", dialog); @@ -1373,6 +1473,120 @@ it.live("a late first desktop size still reaches viewers when a newer read fails ).pipe(Effect.provide(layer)), ); +it.live("a late desktop size still replaces the cached one when a newer read fails", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + yield* nextViewerViewport(viewer); + const cdp = desktopConnections[0]!.context.sessions[0]!; + const send = cdp.send.getMockImplementation()!; + const late = Promise.withResolvers>(); + yield* Effect.addFinalizer(() => Effect.sync(() => late.resolve({}))); + // An older read measures the resized page late; a navigation fails the newer one. + const evaluations = [ + () => late.promise, + () => Promise.reject(new Error("Cannot find context with specified id")), + ]; + cdp.send.mockImplementation((method, input) => + method === "Runtime.evaluate" && evaluations.length > 0 + ? evaluations.shift()!() + : send(method, input), + ); + const stale = cdp.emitAsync("Page.frameResized"); + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + late.resolve({ result: { value: { width: 1000, height: 600 } } }); + yield* Effect.promise(() => stale); + const sizes = (yield* Queue.takeAll(viewer.output)).flatMap((item) => + item._tag === "viewport" ? [`${item.width}x${item.height}`] : [], + ); + expect(sizes.at(-1)).toBe("1000x600"); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a desktop size read that never answers gives up and keeps the last size", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + const broker = yield* Broker.PreviewAutomationBroker; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); + yield* nextViewerViewport(viewer); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + const send = cdp.send.getMockImplementation()!; + const stuck = Promise.withResolvers>(); + yield* Effect.addFinalizer(() => Effect.sync(() => stuck.resolve({}))); + let stall = true; + // A dialog that opens after a read starts blocks its page script. + cdp.send.mockImplementation((method, input) => { + if (method !== "Runtime.evaluate" || !stall) return send(method, input); + stall = false; + return stuck.promise; + }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + const resized = cdp.emitAsync("Page.frameResized"); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(1_000)); + vi.useRealTimers(); + yield* Effect.promise(() => resized); + expect(yield* nextViewerViewport(viewer)).toEqual({ + _tag: "viewport", + width: 1920, + height: 1080, + }); + // Its answer arrives after the read gave up, so it does not count. + stuck.resolve({ result: { value: { width: 100, height: 100 } } }); + context.page.emit("dialog", { + type: () => "alert", + message: () => "Wait", + defaultValue: () => "", + accept: vi.fn(async () => {}), + dismiss: vi.fn(async () => {}), + }); + expect( + yield* broker.invoke({ + scope, + operation: "status", + input: {}, + tabId: PreviewTabId.make(opened.tabId), + }), + ).toMatchObject({ viewport: { width: 1920, height: 1080 } }); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a queued reconnect survives later viewer state on a stalled viewer", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, false)); + yield* nextViewerViewport(viewer); + const cdp = desktopConnections[0]!.context.sessions[0]!; + while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow; + stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); + // One free slot: the reconnect lands as the last queued item. + yield* Queue.take(viewer.output); + const capacity = (yield* Queue.size(viewer.output)) + 1; + desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); + while ((yield* Queue.size(viewer.output)) < capacity) yield* Effect.yieldNow; + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); + expect((yield* Queue.takeAll(viewer.output)).at(-1)).toEqual({ _tag: "reconnect" }); + }), + ).pipe(Effect.provide(layer)), +); + it.live("agents read a human's tab with no arguments and act on it only while nobody drives", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 6384500a47480..b966875bbff8d 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -102,6 +102,8 @@ const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } a const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; +/** A dialog that opens mid-read blocks page script, so a desktop size read gives up after this. */ +const DESKTOP_VIEWPORT_READ_TIMEOUT_MS = 1_000; const sleepUntil = (deadline: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); @@ -311,8 +313,10 @@ interface ServerTab { zoomFactor: number; /** Last CSS size measured on the desktop, usable while a dialog blocks page script. */ desktopViewport: { readonly width: number; readonly height: number } | null; - /** Counts desktop size reads, so a slower older read cannot overwrite a newer one. */ + /** Numbers desktop size reads, so a slower older read cannot overwrite a newer one. */ desktopViewportReads: number; + /** The read that stored `desktopViewport`. */ + desktopViewportApplied: number; loading: boolean; closing: boolean; recording: Recording | null; @@ -614,14 +618,13 @@ const make = Effect.gen(function* () { /** * Reads the desktop page's CSS size, including scrollbars and native zoom, in * an isolated world so page script cannot override it. Keeps the last size - * while a dialog blocks script, when a navigation destroys the world mid-read, - * when a newer read already stored one, and for a hidden webview's 0x0. + * while a dialog blocks script, when a read fails or times out, when a newer + * read already stored a size, and for a hidden webview's 0x0. */ const measureDesktopViewport = async (tab: ServerTab) => { if (tab.dialog) return tab.desktopViewport; const read = ++tab.desktopViewportReads; - let value: unknown; - try { + const readSize = async () => { const { frameTree } = await tab.cdp.send("Page.getFrameTree"); const { executionContextId } = await tab.cdp.send("Page.createIsolatedWorld", { frameId: frameTree.frame.id, @@ -632,15 +635,22 @@ const make = Effect.gen(function* () { contextId: executionContextId, returnByValue: true, }); - value = result.value; - } catch { - return tab.desktopViewport; - } - if (read !== tab.desktopViewportReads && tab.desktopViewport) return tab.desktopViewport; - const size = asRecord(value); + return asRecord(result.value); + }; + let timer: ReturnType | undefined; + const size = await Promise.race([ + // A navigation can destroy the isolated world mid-read. + readSize().catch(() => null), + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), DESKTOP_VIEWPORT_READ_TIMEOUT_MS); + }), + ]); + clearTimeout(timer); + if (read < tab.desktopViewportApplied) return tab.desktopViewport; if (!size || typeof size.width !== "number" || typeof size.height !== "number") return tab.desktopViewport; if (size.width <= 0 || size.height <= 0) return tab.desktopViewport; + tab.desktopViewportApplied = read; tab.desktopViewport = { width: size.width, height: size.height }; return tab.desktopViewport; }; @@ -837,6 +847,7 @@ const make = Effect.gen(function* () { zoomFactor: 1, desktopViewport: null, desktopViewportReads: 0, + desktopViewportApplied: 0, loading: false, closing: false, recording: null, @@ -856,6 +867,14 @@ const make = Effect.gen(function* () { cdp.on("Page.frameResized", () => { if (tab.viewers.size > 0) return broadcastViewport(tab).catch(constVoid); }); + // Someone at the desktop can close a dialog in its own window, outside resolveDialog. + cdp.on("Page.javascriptDialogClosed", () => { + if (!tab.dialog) return; + tab.dialog = null; + ServerBrowserPage.invalidateRefs(page); + broadcastControl(tab); + if (tab.viewers.size > 0) void broadcastViewport(tab).catch(constVoid); + }); await cdp.send("Page.enable"); } const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => @@ -2343,6 +2362,7 @@ const make = Effect.gen(function* () { const recentFrames: Array = []; let screencastParams = Promise.resolve(); let screencastScale = 1; + let ended = false; const startScreencast = (scale: number) => { screencastScale = scale; screencastParams = screencastParams.then(async () => { @@ -2366,18 +2386,36 @@ const make = Effect.gen(function* () { pressedButtons: new Map(), push: (next) => { // Dropped frames must still release Chromium. + if (ended) { + if (next._tag === "frame") runFork(next.ack); + return; + } + if (next._tag === "gone" || next._tag === "reconnect") ended = true; if (Queue.offerUnsafe(output, next)) return; if (next._tag === "frame") runFork(next.ack); // State a stalled viewer cannot miss replaces its backlog. It runs // synchronously so an older replacement can never land after a newer one. - else if (next._tag === "gone" || next._tag === "control" || next._tag === "fileChooser") { + else if (["gone", "control", "viewport", "fileChooser"].includes(next._tag)) { const dropped = Effect.runSyncExit(Queue.clear(output)); if (dropped._tag === "Failure") return; Queue.offerUnsafe(output, next); - - const chooser = next._tag === "control" ? fileChooserMessage(tab) : null; + // Without its latest size the viewer cannot map input at all. + for (const tag of ["control", "viewport"] as const) { + if (next._tag === tag) continue; + const retained = dropped.value.findLast((item) => item._tag === tag); + if (retained) Queue.offerUnsafe(output, retained); + } + // The controller's open picker may have been in the dropped backlog. + const chooser = + next._tag === "control" || next._tag === "viewport" ? fileChooserMessage(tab) : null; if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); - for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); + // One-time notifications cannot be rebuilt from state; keep them in order. + // A former controller may still cache an older picker, so closes stay too. + for (const item of dropped.value) { + if (["fileChooserClosed", "popup", "download", "clipboard"].includes(item._tag)) + Queue.offerUnsafe(output, item); + else if (item._tag === "frame") runFork(item.ack); + } } }, pause: () => { From 35984046152cf946a312de06c5bc8282d014852a Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Wed, 14 Oct 2026 14:01:54 +0000 Subject: [PATCH 3/9] fix(server): a desktop reconnect reaches a stalled viewer - A reconnect replaces a full viewer backlog like gone does. Otherwise it was dropped and the viewer, already marked ended, never heard the stream close. - When a replaced backlog's notifications no longer all fit, drop the oldest instead of the newest, so the latest clipboard copy survives. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 41 +++++++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 17 +++++--- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index e6cf546ed8b7d..8d34c4ecef20c 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1233,6 +1233,26 @@ it.live("a stalled viewer's backlog replacement keeps its other state and notifi ).pipe(Effect.provide(layer)), ); +it.live("a backlog of only notifications keeps the newest ones when replaced", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* viewer.input({ type: "takeControl" }); + yield* Queue.clear(viewer.output); + const output = + viewer.output as unknown as Queue.Queue; + let copied = 0; + while (Queue.offerUnsafe(output, { _tag: "clipboard", text: `copy ${copied}` })) copied++; + yield* viewer.input({ type: "resize", width: 900, height: 600 }); + const kept = yield* Queue.takeAll(viewer.output); + expect(kept[0]).toEqual({ _tag: "viewport", width: 900, height: 600 }); + expect(kept.at(-1)).toEqual({ _tag: "clipboard", text: `copy ${copied - 1}` }); + expect(kept).toHaveLength(copied); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a closing tab still ends a stalled viewer whose backlog is all notifications", () => Effect.scoped( Effect.gen(function* () { @@ -1563,6 +1583,27 @@ it.live("a desktop size read that never answers gives up and keeps the last size ).pipe(Effect.provide(layer)), ); +it.live("a desktop reconnect replaces a stalled viewer's full backlog", () => + Effect.scoped( + Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + yield* Effect.yieldNow; + desktopRendersNext = true; + const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, false)); + yield* nextViewerViewport(viewer); + while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow; + // The viewer stopped reading: its output is full when the desktop takes the page back. + stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); + const capacity = yield* Queue.size(viewer.output); + desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); + while ((yield* Queue.size(viewer.output)) === capacity) yield* Effect.yieldNow; + expect(yield* Queue.takeAll(viewer.output)).toEqual([{ _tag: "reconnect" }]); + }), + ).pipe(Effect.provide(layer)), +); + it.live("a queued reconnect survives later viewer state on a stalled viewer", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index b966875bbff8d..73bfd8e8a118e 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -2395,7 +2395,9 @@ const make = Effect.gen(function* () { if (next._tag === "frame") runFork(next.ack); // State a stalled viewer cannot miss replaces its backlog. It runs // synchronously so an older replacement can never land after a newer one. - else if (["gone", "control", "viewport", "fileChooser"].includes(next._tag)) { + else if ( + ["gone", "reconnect", "control", "viewport", "fileChooser"].includes(next._tag) + ) { const dropped = Effect.runSyncExit(Queue.clear(output)); if (dropped._tag === "Failure") return; Queue.offerUnsafe(output, next); @@ -2411,11 +2413,14 @@ const make = Effect.gen(function* () { if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); // One-time notifications cannot be rebuilt from state; keep them in order. // A former controller may still cache an older picker, so closes stay too. - for (const item of dropped.value) { - if (["fileChooserClosed", "popup", "download", "clipboard"].includes(item._tag)) - Queue.offerUnsafe(output, item); - else if (item._tag === "frame") runFork(item.ack); - } + // If they no longer all fit, the oldest go, so the latest clipboard survives. + const notifications = dropped.value.filter((item) => + ["fileChooserClosed", "popup", "download", "clipboard"].includes(item._tag), + ); + const room = VIEWER_OUTPUT_LIMIT - Effect.runSync(Queue.size(output)); + for (const item of notifications.slice(Math.max(0, notifications.length - room))) + Queue.offerUnsafe(output, item); + for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); } }, pause: () => { From 9e8da6fa48192a7c1e7057944f5af27c2295f682 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 30 Oct 2026 10:04:14 +0000 Subject: [PATCH 4/9] fix(server): adapt viewport regressions to current preview fixtures --- apps/server/src/preview/ServerBrowser.test.ts | 57 +++++++++++-------- apps/server/src/preview/ServerBrowser.ts | 20 ++----- 2 files changed, 38 insertions(+), 39 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 8d34c4ecef20c..4db2a31e6a215 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -86,7 +86,10 @@ const PNG_HEADER = (() => { return header.toString("base64"); })(); -function makeSession(frame: ReturnType | undefined, viewport: { width: number; height: number }) { +function makeSession( + frame: ReturnType | undefined, + viewport: { width: number; height: number }, +) { const events = new NodeEvents.EventEmitter(); return { on: vi.fn((name: string, callback: (...args: unknown[]) => void) => events.on(name, callback)), @@ -106,7 +109,12 @@ function makeSession(frame: ReturnType | undefined, viewport: if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } }; if (method === "Page.createIsolatedWorld") { return { - executionContextId: (input as { worldName?: string }).worldName === "t3-preview-viewport" ? 9 : (input as { worldName?: string }).worldName === "t3-preview" ? 1 : 7, + executionContextId: + (input as { worldName?: string }).worldName === "t3-preview-viewport" + ? 9 + : (input as { worldName?: string }).worldName === "t3-preview" + ? 1 + : 7, }; } if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 1) { @@ -123,7 +131,8 @@ function makeSession(frame: ReturnType | undefined, viewport: else pageCopy = ""; return { result: { value } }; } - if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 9) return { result: { value: { ...viewport } } }; + if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 9) + return { result: { value: { ...viewport } } }; return { result: { value: "evaluated" } }; }), }; @@ -209,7 +218,8 @@ function makeContext(onClose?: (context: BrowserContext) => void) { grantPermissions: vi.fn(async () => {}), newCDPSession: async (target: unknown) => { const session = makeSession( - target === page ? undefined : (target as ReturnType), cssViewport, + target === page ? undefined : (target as ReturnType), + cssViewport, ); sessions.push(session); return session; @@ -278,6 +288,8 @@ let initialNavigation: (() => Promise) | null = null; let desktopRendersNext = false; let desktopHiddenNext = false; const desktopDetaches = new NodeEvents.EventEmitter(); +let desktopDetachReady = Promise.withResolvers(); +let desktopReleased = Promise.withResolvers(); const desktopTabs = new Set(); const desktopRenders = (tabId: string) => { if (desktopRendersNext) { @@ -326,6 +338,7 @@ const dependencies = Layer.mergeAll( const onDetach = (key: { threadId: string; tabId: string }) => Queue.offerUnsafe(queue, key); desktopDetaches.on("detach", onDetach); + desktopDetachReady.resolve(); return onDetach; }), (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)), @@ -345,7 +358,10 @@ const dependencies = Layer.mergeAll( isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), endpoint: (key) => Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => - Effect.sync(() => releasedDesktopTabs.push(key.tabId)), + Effect.sync(() => { + releasedDesktopTabs.push(key.tabId); + desktopReleased.resolve(); + }), ), pointer: () => Effect.void, turn: (threadId, active) => Effect.sync(() => desktopTurns.emit("turn", threadId, active)), @@ -376,24 +392,13 @@ const viewerInput = (tabId: string, canOperate: boolean) => ({ maxHeight: 800, quality: 70, }); -/** Waits for a viewport. Pulling drains the queue, so it also discards what arrived with it. */ const stallViewer = ( viewer: ServerBrowser.ServerBrowserViewer, item: ServerBrowser.ServerBrowserViewerOutput, ) => { - // The service hands out the read side of a queue it also writes to. const output = viewer.output as unknown as Queue.Queue; while (Queue.offerUnsafe(output, item)); }; -const viewerInput = (tabId: string, canOperate: boolean) => ({ - threadId: scope.thread.threadId, - tabId, - canOperate, - maxWidth: 1280, - maxHeight: 800, - quality: 70, -}); -/** Waits for a viewport. Pulling drains the queue, so it also discards what arrived with it. */ const nextViewerViewport = (viewer: ServerBrowser.ServerBrowserViewer) => Stream.fromQueue(viewer.output).pipe( Stream.filter((item) => item._tag === "viewport"), @@ -413,6 +418,8 @@ beforeEach(() => { focusedFrame = null; desktopTabs.clear(); desktopRendersNext = false; + desktopDetachReady = Promise.withResolvers(); + desktopReleased = Promise.withResolvers(); desktopHiddenNext = false; releasedDesktopTabs.length = 0; desktopConnections.length = 0; @@ -1130,6 +1137,11 @@ it.live("a page's file picker goes to the controller and takes its uploaded file let offered = yield* Queue.take(viewer.output); while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output); expect(offered).toMatchObject({ multiple: false, accept: ".csv" }); + stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); + yield* viewer.input({ type: "resize", width: 900, height: 600 }); + expect(yield* Queue.takeAll(viewer.output)).toEqual( + expect.arrayContaining([{ _tag: "viewport", width: 900, height: 600 }, offered]), + ); const file = (name: string) => ({ name, mimeType: "text/csv", buffer: Buffer.from(name) }); const answer = (chooserId: string) => browser.answerFileChooser({ @@ -1466,8 +1478,7 @@ it.live("a late first desktop size still reaches viewers when a newer read fails const context = desktopConnections[0]!.context; const cdp = context.sessions[0]!; // The viewer attached while hidden, so no size is known yet. - while (!cdp.send.mock.calls.some(([method]) => method === "Runtime.evaluate")) - yield* Effect.yieldNow; + yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); const send = cdp.send.getMockImplementation()!; const late = Promise.withResolvers>(); yield* Effect.addFinalizer(() => Effect.sync(() => late.resolve({}))); @@ -1593,12 +1604,11 @@ it.live("a desktop reconnect replaces a stalled viewer's full backlog", () => const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, false)); yield* nextViewerViewport(viewer); - while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow; + yield* Effect.promise(() => desktopDetachReady.promise); // The viewer stopped reading: its output is full when the desktop takes the page back. stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); - const capacity = yield* Queue.size(viewer.output); desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); - while ((yield* Queue.size(viewer.output)) === capacity) yield* Effect.yieldNow; + yield* Effect.promise(() => desktopReleased.promise); expect(yield* Queue.takeAll(viewer.output)).toEqual([{ _tag: "reconnect" }]); }), ).pipe(Effect.provide(layer)), @@ -1615,13 +1625,12 @@ it.live("a queued reconnect survives later viewer state on a stalled viewer", () const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, false)); yield* nextViewerViewport(viewer); const cdp = desktopConnections[0]!.context.sessions[0]!; - while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow; + yield* Effect.promise(() => desktopDetachReady.promise); stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); // One free slot: the reconnect lands as the last queued item. yield* Queue.take(viewer.output); - const capacity = (yield* Queue.size(viewer.output)) + 1; desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); - while ((yield* Queue.size(viewer.output)) < capacity) yield* Effect.yieldNow; + yield* Effect.promise(() => desktopReleased.promise); yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); expect((yield* Queue.takeAll(viewer.output)).at(-1)).toEqual({ _tag: "reconnect" }); }), diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 73bfd8e8a118e..56896366eab92 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -465,6 +465,7 @@ const make = Effect.gen(function* () { const previewBrowser = yield* PreviewBrowser.PreviewBrowser; const desktopChannel = yield* DesktopBrowserChannel.DesktopBrowserChannel; const agentTurns = yield* (yield* PreviewAgentActivity.PreviewAgentActivity).changes; + const runSync = Effect.runSyncWith(yield* Effect.context()); const runFork = Effect.runForkWith(yield* Effect.context()); const runPromise = Effect.runPromiseWith(yield* Effect.context()); const launchServices = yield* Effect.context(); @@ -615,12 +616,8 @@ const make = Effect.gen(function* () { ); }; - /** - * Reads the desktop page's CSS size, including scrollbars and native zoom, in - * an isolated world so page script cannot override it. Keeps the last size - * while a dialog blocks script, when a read fails or times out, when a newer - * read already stored a size, and for a hidden webview's 0x0. - */ + // Desktop pages have no Playwright viewport; an isolated world keeps page script + // from overriding the CSS size, and failed reads preserve the last usable size. const measureDesktopViewport = async (tab: ServerTab) => { if (tab.dialog) return tab.desktopViewport; const read = ++tab.desktopViewportReads; @@ -655,11 +652,6 @@ const make = Effect.gen(function* () { return tab.desktopViewport; }; - /** - * The CSS size the page lays out in. A desktop page is measured, since a page - * Playwright reached over CDP has no viewport. A headless page's size is its - * viewport shrunk by the tab's zoom, as Chrome zooms. - */ const layoutSize = async (tab: ServerTab) => { if (tab.desktop) return measureDesktopViewport(tab); const size = tab.page.viewportSize(); @@ -2395,9 +2387,7 @@ const make = Effect.gen(function* () { if (next._tag === "frame") runFork(next.ack); // State a stalled viewer cannot miss replaces its backlog. It runs // synchronously so an older replacement can never land after a newer one. - else if ( - ["gone", "reconnect", "control", "viewport", "fileChooser"].includes(next._tag) - ) { + else if (["gone", "reconnect", "control", "viewport", "fileChooser"].includes(next._tag)) { const dropped = Effect.runSyncExit(Queue.clear(output)); if (dropped._tag === "Failure") return; Queue.offerUnsafe(output, next); @@ -2417,7 +2407,7 @@ const make = Effect.gen(function* () { const notifications = dropped.value.filter((item) => ["fileChooserClosed", "popup", "download", "clipboard"].includes(item._tag), ); - const room = VIEWER_OUTPUT_LIMIT - Effect.runSync(Queue.size(output)); + const room = VIEWER_OUTPUT_LIMIT - runSync(Queue.size(output)); for (const item of notifications.slice(Math.max(0, notifications.length - room))) Queue.offerUnsafe(output, item); for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); From 7ba24f81147f0984ce6e3bc704958b7f938f4149 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 30 Oct 2026 11:55:46 +0000 Subject: [PATCH 5/9] fix(server): coalesce desktop resize reads and trim viewport tests A burst of Page.frameResized events now runs one size read at a time with at most one queued after it, instead of one CDP read and viewport message each. The viewport tests are cut to two cases that fail on main: a desktop viewer gets the measured size and keeps it when a read cannot measure, and a stalled viewer still gets its size, control and reconnect. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 437 +----------------- apps/server/src/preview/ServerBrowser.ts | 17 +- 2 files changed, 38 insertions(+), 416 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 4db2a31e6a215..01575cd032ff5 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -57,8 +57,6 @@ vi.mock("./ServerBrowserContexts.ts", () => ({ async connectDesktopPage(endpoint: string) { const context = makeContext(); context.page.viewportSize.mockReturnValue(null); - // A hidden `` measures 0x0 until it is shown. - if (desktopHiddenNext) Object.assign(context.cssViewport, { width: 0, height: 0 }); desktopConnections.push({ endpoint, context }); return { browser: { close: async () => {} }, page: context.page as unknown as Page }; } @@ -286,7 +284,6 @@ let focusedFrame: ReturnType | null = null; let contextFailure: Error | null = null; let initialNavigation: (() => Promise) | null = null; let desktopRendersNext = false; -let desktopHiddenNext = false; const desktopDetaches = new NodeEvents.EventEmitter(); let desktopDetachReady = Promise.withResolvers(); let desktopReleased = Promise.withResolvers(); @@ -399,12 +396,6 @@ const stallViewer = ( const output = viewer.output as unknown as Queue.Queue; while (Queue.offerUnsafe(output, item)); }; -const nextViewerViewport = (viewer: ServerBrowser.ServerBrowserViewer) => - Stream.fromQueue(viewer.output).pipe( - Stream.filter((item) => item._tag === "viewport"), - Stream.runHead, - Effect.map(Option.getOrThrow), - ); beforeEach(() => { contexts.length = 0; @@ -420,7 +411,6 @@ beforeEach(() => { desktopRendersNext = false; desktopDetachReady = Promise.withResolvers(); desktopReleased = Promise.withResolvers(); - desktopHiddenNext = false; releasedDesktopTabs.length = 0; desktopConnections.length = 0; }); @@ -1137,11 +1127,6 @@ it.live("a page's file picker goes to the controller and takes its uploaded file let offered = yield* Queue.take(viewer.output); while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output); expect(offered).toMatchObject({ multiple: false, accept: ".csv" }); - stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); - yield* viewer.input({ type: "resize", width: 900, height: 600 }); - expect(yield* Queue.takeAll(viewer.output)).toEqual( - expect.arrayContaining([{ _tag: "viewport", width: 900, height: 600 }, offered]), - ); const file = (name: string) => ({ name, mimeType: "text/csv", buffer: Buffer.from(name) }); const answer = (chooserId: string) => browser.answerFileChooser({ @@ -1190,153 +1175,7 @@ it.live("only the current controller can answer a file picker", () => ).pipe(Effect.provide(layer)), ); -it.live("a stalled viewer's backlog replacement keeps its other state and notifications", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, tabId } = yield* ready; - const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); - yield* viewer.input({ type: "takeControl" }); - yield* Queue.clear(viewer.output); - const output = - viewer.output as unknown as Queue.Queue; - /** Fills the stalled backlog, sends input that replaces it, and returns what is left. */ - const replaceBacklog = (input: unknown) => - Effect.gen(function* () { - // Chromium sends no new frames until each dropped one is acked. - const acked = Promise.withResolvers(); - let unacked = 0; - const ack = Effect.sync(() => { - if (--unacked === 0) acked.resolve(); - }); - while (Queue.offerUnsafe(output, { _tag: "frame", data: Buffer.alloc(0), ack })) - unacked++; - yield* viewer.input(input); - yield* Effect.promise(() => acked.promise); - return yield* Queue.takeAll(viewer.output); - }); - // One-time notifications cannot be rebuilt from tab state. - const notifications: Array = [ - { _tag: "download", id: "download-1", fileName: "report.csv", sizeBytes: 3 }, - { _tag: "clipboard", text: "copied" }, - { _tag: "popup", tabId: "popup-1" }, - { _tag: "fileChooserClosed", id: "chooser-1" }, - ]; - for (const item of notifications) Queue.offerUnsafe(output, item); - yield* viewer.input({ type: "takeControl" }); - const resized = yield* replaceBacklog({ type: "resize", width: 900, height: 600 }); - expect(resized).toHaveLength(2 + notifications.length); - expect(resized).toEqual( - expect.arrayContaining([ - { _tag: "viewport", width: 900, height: 600 }, - expect.objectContaining({ _tag: "control", controller: "you" }), - ]), - ); - expect(resized.filter((item) => notifications.includes(item))).toEqual(notifications); - yield* viewer.input({ type: "resize", width: 640, height: 480 }); - const released = yield* replaceBacklog({ type: "releaseControl" }); - expect(released).toHaveLength(2); - expect(released).toEqual( - expect.arrayContaining([ - expect.objectContaining({ _tag: "control", controller: "agent" }), - { _tag: "viewport", width: 640, height: 480 }, - ]), - ); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a backlog of only notifications keeps the newest ones when replaced", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, tabId } = yield* ready; - const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); - yield* viewer.input({ type: "takeControl" }); - yield* Queue.clear(viewer.output); - const output = - viewer.output as unknown as Queue.Queue; - let copied = 0; - while (Queue.offerUnsafe(output, { _tag: "clipboard", text: `copy ${copied}` })) copied++; - yield* viewer.input({ type: "resize", width: 900, height: 600 }); - const kept = yield* Queue.takeAll(viewer.output); - expect(kept[0]).toEqual({ _tag: "viewport", width: 900, height: 600 }); - expect(kept.at(-1)).toEqual({ _tag: "clipboard", text: `copy ${copied - 1}` }); - expect(kept).toHaveLength(copied); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a closing tab still ends a stalled viewer whose backlog is all notifications", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, tabId } = yield* ready; - const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); - yield* Queue.clear(viewer.output); - stallViewer(viewer, { - _tag: "download", - id: "download-1", - fileName: "report.csv", - sizeBytes: 3, - }); - contexts[0]!.page.emit("close"); - expect(yield* Queue.takeAll(viewer.output)).toContainEqual({ _tag: "gone" }); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("desktop viewers follow the page's native size and zoom", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - const broker = yield* Broker.PreviewAutomationBroker; - yield* Effect.yieldNow; - desktopRendersNext = true; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const operator = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - const observer = yield* browser.attachViewer(viewerInput(opened.tabId, false)); - const context = desktopConnections[0]!.context; - const cdp = context.sessions[0]!; - yield* operator.input({ type: "takeControl" }); - // The desktop panel sizes its page, even for a fixed setting. - const setting = { _tag: "preset", presetId: "desktop-1920x1080", width: 1920, height: 1080 }; - yield* operator.input({ type: "viewport", setting }); - for (const viewer of [operator, observer]) yield* nextViewerViewport(viewer); - // The measured size already includes the desktop's native zoom. - yield* manager.adjust({ - threadId: scope.thread.threadId, - tabId: opened.tabId, - zoomFactor: 2, - }); - Object.assign(context.cssViewport, { width: 1000, height: 600 }); - cdp.emit("Page.frameResized"); - for (const viewer of [operator, observer]) { - expect(yield* nextViewerViewport(viewer)).toEqual({ - _tag: "viewport", - width: 1000, - height: 600, - }); - } - expect(context.page.setViewportSize).not.toHaveBeenCalled(); - expect(cdp.send.mock.calls.some(([method]) => method.startsWith("Emulation."))).toBe(false); - expect( - yield* broker.invoke({ - scope, - operation: "status", - input: {}, - tabId: PreviewTabId.make(opened.tabId), - }), - ).toMatchObject({ viewport: { width: 1000, height: 600 } }); - // Viewers map clicks with that size, so the page gets CSS coordinates as sent. - yield* operator.input({ type: "mouse", action: "down", x: 750, y: 450, button: "left" }); - expect(context.sessions[1]!.send).toHaveBeenCalledWith( - "Input.dispatchMouseEvent", - expect.objectContaining({ type: "mousePressed", x: 750, y: 450 }), - ); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a desktop dialog reuses the last size and remeasures when it closes", () => +it.live("desktop viewers get the page's CSS size and keep the last one a read cannot measure", () => Effect.scoped( Effect.gen(function* () { const browser = yield* ServerBrowser.ServerBrowser; @@ -1345,113 +1184,27 @@ it.live("a desktop dialog reuses the last size and remeasures when it closes", ( yield* Effect.yieldNow; desktopRendersNext = true; const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const status = broker.invoke({ - scope, - operation: "status", - input: {}, - tabId: PreviewTabId.make(opened.tabId), - }); const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); const context = desktopConnections[0]!.context; const cdp = context.sessions[0]!; - const dialog = { - type: () => "confirm", - message: () => "Continue?", - defaultValue: () => "", - accept: vi.fn(async () => {}), - dismiss: vi.fn(async () => {}), - }; - // An open dialog blocks page script, so status and a new viewer use the last size. - context.page.emit("dialog", dialog); + Object.assign(context.cssViewport, { width: 1000, height: 600 }); cdp.send.mockClear(); - expect(yield* status).toMatchObject({ - viewport: { width: 1920, height: 1080 }, - dialog: { type: "confirm", message: "Continue?" }, - }); - const reconnected = yield* browser.attachViewer(viewerInput(opened.tabId, false)); - expect(yield* nextViewerViewport(reconnected)).toEqual({ - _tag: "viewport", - width: 1920, - height: 1080, - }); - expect(cdp.send).not.toHaveBeenCalled(); - yield* Queue.clear(viewer.output); - Object.assign(context.cssViewport, { width: 1440, height: 900 }); - yield* viewer.input({ type: "dialog", accept: true }); - expect(dialog.accept).toHaveBeenCalledOnce(); - expect(yield* nextViewerViewport(viewer)).toEqual({ - _tag: "viewport", - width: 1440, - height: 900, - }); - // The host can also close its own dialog, outside viewer input. - context.page.emit("dialog", dialog); - Object.assign(context.cssViewport, { width: 960, height: 600 }); - cdp.emit("Page.javascriptDialogClosed"); - expect(yield* nextViewerViewport(viewer)).toEqual({ + // A burst of resizes measures the page once more after the read in flight. + yield* Effect.promise(() => + Promise.all([1, 2, 3].map(() => cdp.emitAsync("Page.frameResized"))), + ); + expect(yield* Queue.takeAll(viewer.output)).toContainEqual({ _tag: "viewport", - width: 960, + width: 1000, height: 600, }); - expect(yield* status).toMatchObject({ dialog: null, viewport: { width: 960, height: 600 } }); - // An agent can answer it too, and the status it reads next must not hide the new size. - yield* viewer.input({ type: "releaseControl" }); - context.page.emit("dialog", dialog); - Object.assign(context.cssViewport, { width: 1280, height: 720 }); - yield* broker.invoke({ - scope, - operation: "dialog", - input: { accept: true }, - tabId: PreviewTabId.make(opened.tabId), - }); - expect(yield* nextViewerViewport(viewer)).toEqual({ - _tag: "viewport", - width: 1280, - height: 720, - }); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a desktop size read that is late, empty or fails keeps the newest size", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - const broker = yield* Broker.PreviewAutomationBroker; - yield* Effect.yieldNow; - desktopRendersNext = true; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - const context = desktopConnections[0]!.context; - const cdp = context.sessions[0]!; - const send = cdp.send.getMockImplementation()!; - // Each queued answer replaces one page evaluation; the rest measure cssViewport. - const evaluations: Array<() => Promise>> = []; - cdp.send.mockImplementation((method, input) => - method === "Runtime.evaluate" && evaluations.length > 0 - ? evaluations.shift()!() - : send(method, input), + const reads = cdp.send.mock.calls.filter( + ([method, input]) => + method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 9, ); - const late = Promise.withResolvers>(); - yield* Effect.addFinalizer(() => Effect.sync(() => late.resolve({}))); - yield* nextViewerViewport(viewer); - // An older read stalls until after a newer read finishes. - evaluations.push(() => late.promise); - const stale = cdp.emitAsync("Page.frameResized"); - Object.assign(context.cssViewport, { width: 1500, height: 900 }); - yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); - late.resolve({ result: { value: { width: 100, height: 100 } } }); - yield* Effect.promise(() => stale); + expect(reads).toHaveLength(2); // A hidden webview measures 0x0. Object.assign(context.cssViewport, { width: 0, height: 0 }); - yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); - const sizes = (yield* Queue.takeAll(viewer.output)).flatMap((item) => - item._tag === "viewport" ? [`${item.width}x${item.height}`] : [], - ); - expect(new Set(sizes)).toEqual(new Set(["1500x900"])); - // A navigation can destroy the isolated world mid-read. - evaluations.push(() => Promise.reject(new Error("Cannot find context with specified id"))); expect( yield* broker.invoke({ scope, @@ -1459,180 +1212,34 @@ it.live("a desktop size read that is late, empty or fails keeps the newest size" input: {}, tabId: PreviewTabId.make(opened.tabId), }), - ).toMatchObject({ viewport: { width: 1500, height: 900 } }); - expect(evaluations).toEqual([]); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a late first desktop size still reaches viewers when a newer read fails", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - desktopRendersNext = true; - desktopHiddenNext = true; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - const context = desktopConnections[0]!.context; - const cdp = context.sessions[0]!; - // The viewer attached while hidden, so no size is known yet. - yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); - const send = cdp.send.getMockImplementation()!; - const late = Promise.withResolvers>(); - yield* Effect.addFinalizer(() => Effect.sync(() => late.resolve({}))); - // Once shown, an older read stalls and a navigation fails the newer one. - const evaluations = [ - () => late.promise, - () => Promise.reject(new Error("Cannot find context with specified id")), - ]; - cdp.send.mockImplementation((method, input) => - method === "Runtime.evaluate" && evaluations.length > 0 - ? evaluations.shift()!() - : send(method, input), - ); - const stale = cdp.emitAsync("Page.frameResized"); - yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); - late.resolve({ result: { value: { width: 1500, height: 900 } } }); - yield* Effect.promise(() => stale); - const sizes = (yield* Queue.takeAll(viewer.output)).flatMap((item) => - item._tag === "viewport" ? [`${item.width}x${item.height}`] : [], - ); - expect(sizes).toEqual(["1500x900"]); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a late desktop size still replaces the cached one when a newer read fails", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - desktopRendersNext = true; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - yield* nextViewerViewport(viewer); - const cdp = desktopConnections[0]!.context.sessions[0]!; - const send = cdp.send.getMockImplementation()!; - const late = Promise.withResolvers>(); - yield* Effect.addFinalizer(() => Effect.sync(() => late.resolve({}))); - // An older read measures the resized page late; a navigation fails the newer one. - const evaluations = [ - () => late.promise, - () => Promise.reject(new Error("Cannot find context with specified id")), - ]; - cdp.send.mockImplementation((method, input) => - method === "Runtime.evaluate" && evaluations.length > 0 - ? evaluations.shift()!() - : send(method, input), - ); - const stale = cdp.emitAsync("Page.frameResized"); - yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); - late.resolve({ result: { value: { width: 1000, height: 600 } } }); - yield* Effect.promise(() => stale); - const sizes = (yield* Queue.takeAll(viewer.output)).flatMap((item) => - item._tag === "viewport" ? [`${item.width}x${item.height}`] : [], - ); - expect(sizes.at(-1)).toBe("1000x600"); + ).toMatchObject({ viewport: { width: 1000, height: 600 } }); }), ).pipe(Effect.provide(layer)), ); -it.live("a desktop size read that never answers gives up and keeps the last size", () => +it.live("a stalled desktop viewer still gets its size, control and reconnect", () => Effect.scoped( Effect.gen(function* () { const browser = yield* ServerBrowser.ServerBrowser; const manager = yield* Manager.PreviewManager; - const broker = yield* Broker.PreviewAutomationBroker; yield* Effect.yieldNow; desktopRendersNext = true; const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - yield* nextViewerViewport(viewer); + yield* viewer.input({ type: "takeControl" }); const context = desktopConnections[0]!.context; - const cdp = context.sessions[0]!; - const send = cdp.send.getMockImplementation()!; - const stuck = Promise.withResolvers>(); - yield* Effect.addFinalizer(() => Effect.sync(() => stuck.resolve({}))); - let stall = true; - // A dialog that opens after a read starts blocks its page script. - cdp.send.mockImplementation((method, input) => { - if (method !== "Runtime.evaluate" || !stall) return send(method, input); - stall = false; - return stuck.promise; - }); - vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - const resized = cdp.emitAsync("Page.frameResized"); - yield* Effect.promise(() => vi.advanceTimersByTimeAsync(1_000)); - vi.useRealTimers(); - yield* Effect.promise(() => resized); - expect(yield* nextViewerViewport(viewer)).toEqual({ - _tag: "viewport", - width: 1920, - height: 1080, - }); - // Its answer arrives after the read gave up, so it does not count. - stuck.resolve({ result: { value: { width: 100, height: 100 } } }); - context.page.emit("dialog", { - type: () => "alert", - message: () => "Wait", - defaultValue: () => "", - accept: vi.fn(async () => {}), - dismiss: vi.fn(async () => {}), - }); - expect( - yield* broker.invoke({ - scope, - operation: "status", - input: {}, - tabId: PreviewTabId.make(opened.tabId), - }), - ).toMatchObject({ viewport: { width: 1920, height: 1080 } }); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a desktop reconnect replaces a stalled viewer's full backlog", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - desktopRendersNext = true; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, false)); - yield* nextViewerViewport(viewer); yield* Effect.promise(() => desktopDetachReady.promise); - // The viewer stopped reading: its output is full when the desktop takes the page back. stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); - desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); - yield* Effect.promise(() => desktopReleased.promise); - expect(yield* Queue.takeAll(viewer.output)).toEqual([{ _tag: "reconnect" }]); - }), - ).pipe(Effect.provide(layer)), -); - -it.live("a queued reconnect survives later viewer state on a stalled viewer", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - desktopRendersNext = true; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, false)); - yield* nextViewerViewport(viewer); - const cdp = desktopConnections[0]!.context.sessions[0]!; - yield* Effect.promise(() => desktopDetachReady.promise); + Object.assign(context.cssViewport, { width: 1000, height: 600 }); + yield* Effect.promise(() => context.sessions[0]!.emitAsync("Page.frameResized")); + expect(yield* Queue.takeAll(viewer.output)).toEqual([ + { _tag: "viewport", width: 1000, height: 600 }, + expect.objectContaining({ _tag: "control", controller: "you" }), + ]); stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); - // One free slot: the reconnect lands as the last queued item. - yield* Queue.take(viewer.output); desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); yield* Effect.promise(() => desktopReleased.promise); - yield* Effect.promise(() => cdp.emitAsync("Page.frameResized")); - expect((yield* Queue.takeAll(viewer.output)).at(-1)).toEqual({ _tag: "reconnect" }); + expect(yield* Queue.takeAll(viewer.output)).toEqual([{ _tag: "reconnect" }]); }), ).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 56896366eab92..be27ed6cd1589 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -856,8 +856,23 @@ const make = Effect.gen(function* () { await applyRendering(tab, snapshot); if (desktop) { // The desktop resizes and zooms its webview itself; viewers map input with the new size. + // Resizes arrive in bursts, so one read runs and at most one more follows it. + let resizing: Promise | null = null; + let resizedAgain = false; cdp.on("Page.frameResized", () => { - if (tab.viewers.size > 0) return broadcastViewport(tab).catch(constVoid); + if (tab.viewers.size === 0) return; + if (resizing) { + resizedAgain = true; + return resizing; + } + resizing = (async () => { + do { + resizedAgain = false; + await broadcastViewport(tab).catch(constVoid); + } while (resizedAgain); + resizing = null; + })(); + return resizing; }); // Someone at the desktop can close a dialog in its own window, outside resolveDialog. cdp.on("Page.javascriptDialogClosed", () => { From 5149d75370976a0b947c104bccafa116f8e52ae2 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 30 Oct 2026 12:59:24 +0000 Subject: [PATCH 6/9] fix(server): keep only the desktop viewport recovery a real path needs Desktop size reads now run one at a time per tab, and callers that arrive during a read share the next one, so reads stay ordered without counters and a burst of resizes costs two reads. The read timeout runs on the Effect clock. Dropped as over-built: the dialog shortcut and remeasure (the read timeout already covers a dialog-blocked read), the ended flag (broadcastViewport now skips a closing tab, the only late pusher this PR added), and retaining notifications across a backlog replacement, which main never did. The two tests run as it.effect and cover what remains: ordered burst reads, the timeout and late answer, failed and 0x0 reads, retained control and viewport in both directions, the picker after a resize, reconnect after a late resize, and acknowledgement of every dropped frame. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 176 ++++++++++++------ apps/server/src/preview/ServerBrowser.ts | 97 +++------- 2 files changed, 144 insertions(+), 129 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 01575cd032ff5..8418e815acf8c 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -24,6 +24,7 @@ import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import type { BrowserContext, Page } from "playwright-core"; import { beforeEach, expect, vi } from "vite-plus/test"; @@ -389,13 +390,36 @@ const viewerInput = (tabId: string, canOperate: boolean) => ({ maxHeight: 800, quality: 70, }); -const stallViewer = ( - viewer: ServerBrowser.ServerBrowserViewer, - item: ServerBrowser.ServerBrowserViewerOutput, -) => { +/** Fills a viewer's output with frames; resolves once every one of them is acknowledged. */ +const stallViewer = (viewer: ServerBrowser.ServerBrowserViewer) => { const output = viewer.output as unknown as Queue.Queue; - while (Queue.offerUnsafe(output, item)); + const acked = Promise.withResolvers(); + let unacked = 0; + const ack = Effect.sync(() => { + if (--unacked === 0) acked.resolve(); + }); + while (Queue.offerUnsafe(output, { _tag: "frame", data: Buffer.alloc(0), ack })) unacked++; + return Effect.promise(() => acked.promise); }; +const viewportSizes = (items: ReadonlyArray) => + items.flatMap((item) => (item._tag === "viewport" ? [`${item.width}x${item.height}`] : [])); +const openDesktopViewer = Effect.gen(function* () { + const browser = yield* ServerBrowser.ServerBrowser; + const manager = yield* Manager.PreviewManager; + desktopRendersNext = true; + const { tabId } = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + const context = desktopConnections[0]!.context; + const cdp = context.sessions[0]!; + const resize = (width: number, height: number) => { + Object.assign(context.cssViewport, { width, height }); + return Effect.promise(() => cdp.emitAsync("Page.frameResized")); + }; + // Ready once the size measured on attach and on a first resize both reached the viewer. + yield* resize(1920, 1080); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual(["1920x1080", "1920x1080"]); + return { tabId, viewer, context, cdp, resize }; +}); beforeEach(() => { contexts.length = 0; @@ -1175,71 +1199,103 @@ it.live("only the current controller can answer a file picker", () => ).pipe(Effect.provide(layer)), ); -it.live("desktop viewers get the page's CSS size and keep the last one a read cannot measure", () => - Effect.scoped( - Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - const broker = yield* Broker.PreviewAutomationBroker; - yield* Effect.yieldNow; - desktopRendersNext = true; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - const context = desktopConnections[0]!.context; - const cdp = context.sessions[0]!; - Object.assign(context.cssViewport, { width: 1000, height: 600 }); - cdp.send.mockClear(); - // A burst of resizes measures the page once more after the read in flight. - yield* Effect.promise(() => - Promise.all([1, 2, 3].map(() => cdp.emitAsync("Page.frameResized"))), - ); - expect(yield* Queue.takeAll(viewer.output)).toContainEqual({ - _tag: "viewport", - width: 1000, - height: 600, - }); - const reads = cdp.send.mock.calls.filter( - ([method, input]) => - method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 9, - ); - expect(reads).toHaveLength(2); - // A hidden webview measures 0x0. - Object.assign(context.cssViewport, { width: 0, height: 0 }); - expect( - yield* broker.invoke({ - scope, - operation: "status", - input: {}, - tabId: PreviewTabId.make(opened.tabId), - }), - ).toMatchObject({ viewport: { width: 1000, height: 600 } }); - }), - ).pipe(Effect.provide(layer)), +it.effect( + "desktop viewers get the page's CSS size and keep the last one a read cannot measure", + () => + Effect.scoped( + Effect.gen(function* () { + const { viewer, cdp } = yield* openDesktopViewer; + // Each page size read waits here for its answer. + const reads = yield* Queue.unbounded>>(); + const send = cdp.send.getMockImplementation()!; + cdp.send.mockImplementation((method, input) => { + if (method !== "Runtime.evaluate" || (input as { contextId?: number }).contextId !== 9) + return send(method, input); + const read = Promise.withResolvers>(); + Queue.offerUnsafe(reads, read); + return read.promise; + }); + const answer = (width: number, height: number) => ({ + result: { value: { width, height } }, + }); + const resized = () => cdp.emitAsync("Page.frameResized"); + // Resizes during a read share one more read, so the newest size arrives last. + const first = resized(); + const firstRead = yield* Queue.take(reads); + const burst = Promise.all([resized(), resized()]); + firstRead.resolve(answer(800, 500)); + (yield* Queue.take(reads)).resolve(answer(1000, 600)); + expect(yield* Queue.size(reads)).toBe(0); + yield* Effect.promise(() => Promise.all([first, burst])); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "800x500", + "1000x600", + "1000x600", + ]); + // A dialog blocks page script, so a read that never answers gives up. + const blocked = resized(); + const stuck = yield* Queue.take(reads); + yield* TestClock.adjust("1 second"); + yield* Effect.promise(() => blocked); + // Its late answer, a read a navigation breaks and a hidden webview keep the last size. + stuck.resolve(answer(100, 100)); + const navigated = resized(); + (yield* Queue.take(reads)).reject(new Error("Cannot find context with specified id")); + yield* Effect.promise(() => navigated); + const hidden = resized(); + (yield* Queue.take(reads)).resolve(answer(0, 0)); + yield* Effect.promise(() => hidden); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "1000x600", + "1000x600", + "1000x600", + ]); + }), + ).pipe(Effect.provide(layer)), ); -it.live("a stalled desktop viewer still gets its size, control and reconnect", () => +it.effect("a stalled desktop viewer still gets its size, control and reconnect", () => Effect.scoped( Effect.gen(function* () { - const browser = yield* ServerBrowser.ServerBrowser; - const manager = yield* Manager.PreviewManager; - yield* Effect.yieldNow; - desktopRendersNext = true; - const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" }); - const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true)); - yield* viewer.input({ type: "takeControl" }); - const context = desktopConnections[0]!.context; + const { tabId, viewer, context, resize } = yield* openDesktopViewer; yield* Effect.promise(() => desktopDetachReady.promise); - stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); - Object.assign(context.cssViewport, { width: 1000, height: 600 }); - yield* Effect.promise(() => context.sessions[0]!.emitAsync("Page.frameResized")); + yield* viewer.input({ type: "takeControl" }); + context.page.emit("filechooser", { + isMultiple: () => false, + element: () => ({ getAttribute: async () => ".csv" }), + setFiles: vi.fn(async () => {}), + }); + let chooser = yield* Queue.take(viewer.output); + while (chooser._tag !== "fileChooser") chooser = yield* Queue.take(viewer.output); + yield* Queue.clear(viewer.output); + const control = expect.objectContaining({ _tag: "control", controller: "you" }); + // A resize replaces a full backlog but keeps control and the controller's open picker. + yield* viewer.input({ type: "takeControl" }); + let acked = stallViewer(viewer); + yield* resize(1000, 600); expect(yield* Queue.takeAll(viewer.output)).toEqual([ { _tag: "viewport", width: 1000, height: 600 }, - expect.objectContaining({ _tag: "control", controller: "you" }), + control, + chooser, + ]); + yield* acked; + // A control change keeps the newest size. + yield* resize(900, 500); + yield* resize(1200, 700); + acked = stallViewer(viewer); + yield* viewer.input({ type: "releaseControl" }); + expect(yield* Queue.takeAll(viewer.output)).toEqual([ + expect.objectContaining({ _tag: "control", controller: "unclaimed" }), + { _tag: "viewport", width: 1200, height: 700 }, ]); - stallViewer(viewer, { _tag: "frame", data: Buffer.alloc(0), ack: Effect.void }); - desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId }); + yield* acked; + // The desktop takes the page back; a resize after it cannot bury the reconnect. + acked = stallViewer(viewer); + desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId }); yield* Effect.promise(() => desktopReleased.promise); + yield* resize(1000, 600); expect(yield* Queue.takeAll(viewer.output)).toEqual([{ _tag: "reconnect" }]); + yield* acked; }), ).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index be27ed6cd1589..a5b300e15e5a8 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -102,8 +102,8 @@ const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } a const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; -/** A dialog that opens mid-read blocks page script, so a desktop size read gives up after this. */ -const DESKTOP_VIEWPORT_READ_TIMEOUT_MS = 1_000; +/** A dialog blocks page script, so a desktop size read gives up after this. */ +const DESKTOP_VIEWPORT_READ_TIMEOUT = "1 second"; const sleepUntil = (deadline: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); @@ -311,12 +311,11 @@ interface ServerTab { setting: PreviewViewportSetting; colorScheme: PreviewAppearancePreference; zoomFactor: number; - /** Last CSS size measured on the desktop, usable while a dialog blocks page script. */ + /** Last usable CSS size measured on the desktop. */ desktopViewport: { readonly width: number; readonly height: number } | null; - /** Numbers desktop size reads, so a slower older read cannot overwrite a newer one. */ - desktopViewportReads: number; - /** The read that stored `desktopViewport`. */ - desktopViewportApplied: number; + desktopViewportRead: Promise; + /** A read queued behind `desktopViewportRead`, shared by every caller that waits for it. */ + desktopViewportNext: Promise | null; loading: boolean; closing: boolean; recording: Recording | null; @@ -465,7 +464,6 @@ const make = Effect.gen(function* () { const previewBrowser = yield* PreviewBrowser.PreviewBrowser; const desktopChannel = yield* DesktopBrowserChannel.DesktopBrowserChannel; const agentTurns = yield* (yield* PreviewAgentActivity.PreviewAgentActivity).changes; - const runSync = Effect.runSyncWith(yield* Effect.context()); const runFork = Effect.runForkWith(yield* Effect.context()); const runPromise = Effect.runPromiseWith(yield* Effect.context()); const launchServices = yield* Effect.context(); @@ -559,8 +557,6 @@ const make = Effect.gen(function* () { if (tab.dialog === dialog) tab.dialog = null; ServerBrowserPage.invalidateRefs(tab.page); broadcastControl(tab); - // The desktop page could not be measured while the dialog was open. - if (tab.desktop) await broadcastViewport(tab).catch(constVoid); }; const report = (tab: ServerTab, navStatus: PreviewNavStatus) => { @@ -618,9 +614,7 @@ const make = Effect.gen(function* () { // Desktop pages have no Playwright viewport; an isolated world keeps page script // from overriding the CSS size, and failed reads preserve the last usable size. - const measureDesktopViewport = async (tab: ServerTab) => { - if (tab.dialog) return tab.desktopViewport; - const read = ++tab.desktopViewportReads; + const readDesktopViewport = async (tab: ServerTab) => { const readSize = async () => { const { frameTree } = await tab.cdp.send("Page.getFrameTree"); const { executionContextId } = await tab.cdp.send("Page.createIsolatedWorld", { @@ -634,24 +628,27 @@ const make = Effect.gen(function* () { }); return asRecord(result.value); }; - let timer: ReturnType | undefined; - const size = await Promise.race([ + const size = await runPromise( // A navigation can destroy the isolated world mid-read. - readSize().catch(() => null), - new Promise((resolve) => { - timer = setTimeout(() => resolve(null), DESKTOP_VIEWPORT_READ_TIMEOUT_MS); - }), - ]); - clearTimeout(timer); - if (read < tab.desktopViewportApplied) return tab.desktopViewport; - if (!size || typeof size.width !== "number" || typeof size.height !== "number") - return tab.desktopViewport; - if (size.width <= 0 || size.height <= 0) return tab.desktopViewport; - tab.desktopViewportApplied = read; - tab.desktopViewport = { width: size.width, height: size.height }; + Effect.promise(() => readSize().catch(() => null)).pipe( + Effect.timeoutOption(DESKTOP_VIEWPORT_READ_TIMEOUT), + Effect.map(Option.getOrNull), + ), + ); + const width = size?.width; + const height = size?.height; + if (typeof width === "number" && width > 0 && typeof height === "number" && height > 0) + tab.desktopViewport = { width, height }; return tab.desktopViewport; }; + // Reads run one at a time, and callers that arrive during a read share the next one. + const measureDesktopViewport = (tab: ServerTab) => + (tab.desktopViewportNext ??= tab.desktopViewportRead.then(() => { + tab.desktopViewportNext = null; + return (tab.desktopViewportRead = readDesktopViewport(tab)); + })); + const layoutSize = async (tab: ServerTab) => { if (tab.desktop) return measureDesktopViewport(tab); const size = tab.page.viewportSize(); @@ -681,7 +678,7 @@ const make = Effect.gen(function* () { const broadcastViewport = async (tab: ServerTab) => { const size = await layoutSize(tab); - if (!size) return; + if (!size || tab.closing) return; for (const viewer of tab.viewers) viewer.push({ _tag: "viewport", ...size }); }; @@ -838,8 +835,8 @@ const make = Effect.gen(function* () { colorScheme: "system", zoomFactor: 1, desktopViewport: null, - desktopViewportReads: 0, - desktopViewportApplied: 0, + desktopViewportRead: Promise.resolve(null), + desktopViewportNext: null, loading: false, closing: false, recording: null, @@ -856,31 +853,8 @@ const make = Effect.gen(function* () { await applyRendering(tab, snapshot); if (desktop) { // The desktop resizes and zooms its webview itself; viewers map input with the new size. - // Resizes arrive in bursts, so one read runs and at most one more follows it. - let resizing: Promise | null = null; - let resizedAgain = false; cdp.on("Page.frameResized", () => { - if (tab.viewers.size === 0) return; - if (resizing) { - resizedAgain = true; - return resizing; - } - resizing = (async () => { - do { - resizedAgain = false; - await broadcastViewport(tab).catch(constVoid); - } while (resizedAgain); - resizing = null; - })(); - return resizing; - }); - // Someone at the desktop can close a dialog in its own window, outside resolveDialog. - cdp.on("Page.javascriptDialogClosed", () => { - if (!tab.dialog) return; - tab.dialog = null; - ServerBrowserPage.invalidateRefs(page); - broadcastControl(tab); - if (tab.viewers.size > 0) void broadcastViewport(tab).catch(constVoid); + if (tab.viewers.size > 0) return broadcastViewport(tab).catch(constVoid); }); await cdp.send("Page.enable"); } @@ -2369,7 +2343,6 @@ const make = Effect.gen(function* () { const recentFrames: Array = []; let screencastParams = Promise.resolve(); let screencastScale = 1; - let ended = false; const startScreencast = (scale: number) => { screencastScale = scale; screencastParams = screencastParams.then(async () => { @@ -2393,11 +2366,6 @@ const make = Effect.gen(function* () { pressedButtons: new Map(), push: (next) => { // Dropped frames must still release Chromium. - if (ended) { - if (next._tag === "frame") runFork(next.ack); - return; - } - if (next._tag === "gone" || next._tag === "reconnect") ended = true; if (Queue.offerUnsafe(output, next)) return; if (next._tag === "frame") runFork(next.ack); // State a stalled viewer cannot miss replaces its backlog. It runs @@ -2416,15 +2384,6 @@ const make = Effect.gen(function* () { const chooser = next._tag === "control" || next._tag === "viewport" ? fileChooserMessage(tab) : null; if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); - // One-time notifications cannot be rebuilt from state; keep them in order. - // A former controller may still cache an older picker, so closes stay too. - // If they no longer all fit, the oldest go, so the latest clipboard survives. - const notifications = dropped.value.filter((item) => - ["fileChooserClosed", "popup", "download", "clipboard"].includes(item._tag), - ); - const room = VIEWER_OUTPUT_LIMIT - runSync(Queue.size(output)); - for (const item of notifications.slice(Math.max(0, notifications.length - room))) - Queue.offerUnsafe(output, item); for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); } }, From a61829a9b9241f77a065d4b3b36a89e8e37cfca3 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 30 Oct 2026 13:30:24 +0000 Subject: [PATCH 7/9] fix(server): remeasure after a desktop dialog closes and keep notifications Closing a JavaScript dialog resizes nothing, so a viewer that attached while an alert blocked the first size read never got a size. The desktop tab now remeasures on Page.javascriptDialogClosed through the same single read. Compacting a stalled viewer's backlog drops frames and superseded control and viewport messages, and keeps pickers and one-time notifications in order. When they no longer fit, the oldest notifications go before the newest control or size. Keeping the picker message makes rebuilding it unnecessary. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 97 ++++++++++++------- apps/server/src/preview/ServerBrowser.ts | 33 ++++--- 2 files changed, 79 insertions(+), 51 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 8418e815acf8c..b25eded0a8240 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -131,7 +131,7 @@ function makeSession( return { result: { value } }; } if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 9) - return { result: { value: { ...viewport } } }; + return desktopSizeAnswer?.() ?? { result: { value: { ...viewport } } }; return { result: { value: "evaluated" } }; }), }; @@ -285,6 +285,8 @@ let focusedFrame: ReturnType | null = null; let contextFailure: Error | null = null; let initialNavigation: (() => Promise) | null = null; let desktopRendersNext = false; +/** Replaces the answer to the desktop page's size reads. */ +let desktopSizeAnswer: (() => Promise>) | null = null; const desktopDetaches = new NodeEvents.EventEmitter(); let desktopDetachReady = Promise.withResolvers(); let desktopReleased = Promise.withResolvers(); @@ -415,9 +417,6 @@ const openDesktopViewer = Effect.gen(function* () { Object.assign(context.cssViewport, { width, height }); return Effect.promise(() => cdp.emitAsync("Page.frameResized")); }; - // Ready once the size measured on attach and on a first resize both reached the viewer. - yield* resize(1920, 1080); - expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual(["1920x1080", "1920x1080"]); return { tabId, viewer, context, cdp, resize }; }); @@ -433,6 +432,7 @@ beforeEach(() => { focusedFrame = null; desktopTabs.clear(); desktopRendersNext = false; + desktopSizeAnswer = null; desktopDetachReady = Promise.withResolvers(); desktopReleased = Promise.withResolvers(); releasedDesktopTabs.length = 0; @@ -1204,21 +1204,41 @@ it.effect( () => Effect.scoped( Effect.gen(function* () { - const { viewer, cdp } = yield* openDesktopViewer; // Each page size read waits here for its answer. const reads = yield* Queue.unbounded>>(); - const send = cdp.send.getMockImplementation()!; - cdp.send.mockImplementation((method, input) => { - if (method !== "Runtime.evaluate" || (input as { contextId?: number }).contextId !== 9) - return send(method, input); - const read = Promise.withResolvers>(); - Queue.offerUnsafe(reads, read); - return read.promise; - }); + const deferReads = () => { + desktopSizeAnswer = () => { + const read = Promise.withResolvers>(); + Queue.offerUnsafe(reads, read); + return read.promise; + }; + }; + deferReads(); + const { viewer, cdp } = yield* openDesktopViewer; const answer = (width: number, height: number) => ({ result: { value: { width, height } }, }); const resized = () => cdp.emitAsync("Page.frameResized"); + // An alert open on attach blocks the first read, which gives up with no size to send. + const blocked = yield* Queue.take(reads); + yield* TestClock.adjust("1 second"); + // Closing it resizes nothing, so the close itself remeasures. + desktopSizeAnswer = null; + yield* Effect.promise(() => cdp.emitAsync("Page.javascriptDialogClosed")); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual(["1920x1080"]); + // The late answer, a read a navigation breaks and a hidden webview keep the last size. + blocked.resolve(answer(100, 100)); + deferReads(); + const navigated = resized(); + (yield* Queue.take(reads)).reject(new Error("Cannot find context with specified id")); + yield* Effect.promise(() => navigated); + const hidden = resized(); + (yield* Queue.take(reads)).resolve(answer(0, 0)); + yield* Effect.promise(() => hidden); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "1920x1080", + "1920x1080", + ]); // Resizes during a read share one more read, so the newest size arrives last. const first = resized(); const firstRead = yield* Queue.take(reads); @@ -1232,24 +1252,6 @@ it.effect( "1000x600", "1000x600", ]); - // A dialog blocks page script, so a read that never answers gives up. - const blocked = resized(); - const stuck = yield* Queue.take(reads); - yield* TestClock.adjust("1 second"); - yield* Effect.promise(() => blocked); - // Its late answer, a read a navigation breaks and a hidden webview keep the last size. - stuck.resolve(answer(100, 100)); - const navigated = resized(); - (yield* Queue.take(reads)).reject(new Error("Cannot find context with specified id")); - yield* Effect.promise(() => navigated); - const hidden = resized(); - (yield* Queue.take(reads)).resolve(answer(0, 0)); - yield* Effect.promise(() => hidden); - expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ - "1000x600", - "1000x600", - "1000x600", - ]); }), ).pipe(Effect.provide(layer)), ); @@ -1258,6 +1260,14 @@ it.effect("a stalled desktop viewer still gets its size, control and reconnect", Effect.scoped( Effect.gen(function* () { const { tabId, viewer, context, resize } = yield* openDesktopViewer; + const output = + viewer.output as unknown as Queue.Queue; + // Ready once the sizes measured on attach and on a first resize both arrived. + yield* resize(1920, 1080); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "1920x1080", + "1920x1080", + ]); yield* Effect.promise(() => desktopDetachReady.promise); yield* viewer.input({ type: "takeControl" }); context.page.emit("filechooser", { @@ -1268,18 +1278,26 @@ it.effect("a stalled desktop viewer still gets its size, control and reconnect", let chooser = yield* Queue.take(viewer.output); while (chooser._tag !== "fileChooser") chooser = yield* Queue.take(viewer.output); yield* Queue.clear(viewer.output); - const control = expect.objectContaining({ _tag: "control", controller: "you" }); - // A resize replaces a full backlog but keeps control and the controller's open picker. + // A resize compacts a full backlog but keeps control, the open picker and + // one-time notifications in order. yield* viewer.input({ type: "takeControl" }); + const notifications: Array = [ + { _tag: "fileChooserClosed", id: "chooser-0" }, + { _tag: "popup", tabId: "popup-1" }, + { _tag: "download", id: "download-1", fileName: "report.csv", sizeBytes: 3 }, + { _tag: "clipboard", text: "copied" }, + ]; + for (const item of notifications) Queue.offerUnsafe(output, item); let acked = stallViewer(viewer); yield* resize(1000, 600); expect(yield* Queue.takeAll(viewer.output)).toEqual([ - { _tag: "viewport", width: 1000, height: 600 }, - control, chooser, + ...notifications, + expect.objectContaining({ _tag: "control", controller: "you" }), + { _tag: "viewport", width: 1000, height: 600 }, ]); yield* acked; - // A control change keeps the newest size. + // A control change keeps only the newest size. yield* resize(900, 500); yield* resize(1200, 700); acked = stallViewer(viewer); @@ -1289,6 +1307,13 @@ it.effect("a stalled desktop viewer still gets its size, control and reconnect", { _tag: "viewport", width: 1200, height: 700 }, ]); yield* acked; + // Notifications that no longer fit go oldest first, never the size. + let copies = 0; + while (Queue.offerUnsafe(output, { _tag: "clipboard", text: `copy ${copies}` })) copies++; + yield* resize(1000, 600); + const compacted = yield* Queue.takeAll(viewer.output); + expect(compacted.at(0)).toEqual({ _tag: "clipboard", text: "copy 1" }); + expect(compacted.at(-1)).toEqual({ _tag: "viewport", width: 1000, height: 600 }); // The desktop takes the page back; a resize after it cannot bury the reconnect. acked = stallViewer(viewer); desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId }); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index a5b300e15e5a8..6a31d7c978157 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -853,9 +853,12 @@ const make = Effect.gen(function* () { await applyRendering(tab, snapshot); if (desktop) { // The desktop resizes and zooms its webview itself; viewers map input with the new size. - cdp.on("Page.frameResized", () => { + const remeasure = () => { if (tab.viewers.size > 0) return broadcastViewport(tab).catch(constVoid); - }); + }; + cdp.on("Page.frameResized", remeasure); + // A dialog blocks size reads, and closing one resizes nothing. + cdp.on("Page.javascriptDialogClosed", remeasure); await cdp.send("Page.enable"); } const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => @@ -2368,22 +2371,22 @@ const make = Effect.gen(function* () { // Dropped frames must still release Chromium. if (Queue.offerUnsafe(output, next)) return; if (next._tag === "frame") runFork(next.ack); - // State a stalled viewer cannot miss replaces its backlog. It runs - // synchronously so an older replacement can never land after a newer one. + // State a stalled viewer cannot miss compacts its backlog. It runs + // synchronously so an older compaction can never land after a newer one. else if (["gone", "reconnect", "control", "viewport", "fileChooser"].includes(next._tag)) { const dropped = Effect.runSyncExit(Queue.clear(output)); if (dropped._tag === "Failure") return; - Queue.offerUnsafe(output, next); - // Without its latest size the viewer cannot map input at all. - for (const tag of ["control", "viewport"] as const) { - if (next._tag === tag) continue; - const retained = dropped.value.findLast((item) => item._tag === tag); - if (retained) Queue.offerUnsafe(output, retained); - } - // The controller's open picker may have been in the dropped backlog. - const chooser = - next._tag === "control" || next._tag === "viewport" ? fileChooserMessage(tab) : null; - if (chooser && tab.control.controller === viewer.id) Queue.offerUnsafe(output, chooser); + const items = [...dropped.value, next]; + // Without its latest size and control the viewer cannot map input at all. + const state = (["control", "viewport"] as const).flatMap( + (tag) => items.findLast((item) => item._tag === tag) ?? [], + ); + // Everything else is one-time; if it no longer fits, the oldest goes first. + const rest = items.filter( + (item) => item._tag !== "frame" && item._tag !== "control" && item._tag !== "viewport", + ); + for (const item of [...rest.slice(state.length - VIEWER_OUTPUT_LIMIT), ...state]) + Queue.offerUnsafe(output, item); for (const item of dropped.value) if (item._tag === "frame") runFork(item.ack); } }, From ac808f6a0315731b3d48b8544b6bfe4ec32c9d93 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 30 Oct 2026 13:53:12 +0000 Subject: [PATCH 8/9] fix(server): desktop size reads wait out an open dialog Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 10 +++++++++- apps/server/src/preview/ServerBrowser.ts | 10 +++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index b25eded0a8240..91988cd91c95e 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1214,7 +1214,7 @@ it.effect( }; }; deferReads(); - const { viewer, cdp } = yield* openDesktopViewer; + const { viewer, cdp, context } = yield* openDesktopViewer; const answer = (width: number, height: number) => ({ result: { value: { width, height } }, }); @@ -1222,6 +1222,14 @@ it.effect( // An alert open on attach blocks the first read, which gives up with no size to send. const blocked = yield* Queue.take(reads); yield* TestClock.adjust("1 second"); + // While it stays open nothing reads again; the host closing it clears it. + context.page.emit("dialog", { + type: () => "alert", + message: () => "", + defaultValue: () => "", + }); + yield* Effect.promise(resized); + expect(yield* Queue.size(reads)).toBe(0); // Closing it resizes nothing, so the close itself remeasures. desktopSizeAnswer = null; yield* Effect.promise(() => cdp.emitAsync("Page.javascriptDialogClosed")); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 6a31d7c978157..42e22533def79 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -650,7 +650,8 @@ const make = Effect.gen(function* () { })); const layoutSize = async (tab: ServerTab) => { - if (tab.desktop) return measureDesktopViewport(tab); + // A dialog blocks page script; its close measures again. + if (tab.desktop) return tab.dialog ? tab.desktopViewport : measureDesktopViewport(tab); const size = tab.page.viewportSize(); if (!size) return size; return { @@ -857,8 +858,11 @@ const make = Effect.gen(function* () { if (tab.viewers.size > 0) return broadcastViewport(tab).catch(constVoid); }; cdp.on("Page.frameResized", remeasure); - // A dialog blocks size reads, and closing one resizes nothing. - cdp.on("Page.javascriptDialogClosed", remeasure); + // Closing a dialog resizes nothing, and the host can close it without us. + cdp.on("Page.javascriptDialogClosed", () => { + tab.dialog = null; + return remeasure(); + }); await cdp.send("Page.enable"); } const isMainNavigation = (request: { isNavigationRequest(): boolean; frame(): unknown }) => From 1b9ca7a8726f9cf64ec7f8b470957934109fd545 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 30 Oct 2026 13:57:27 +0000 Subject: [PATCH 9/9] fix(server): a page load measures a desktop tab again Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 16 ++++++++++++---- apps/server/src/preview/ServerBrowser.ts | 2 ++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 91988cd91c95e..2f09e2b084613 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1219,10 +1219,16 @@ it.effect( result: { value: { width, height } }, }); const resized = () => cdp.emitAsync("Page.frameResized"); - // An alert open on attach blocks the first read, which gives up with no size to send. + // The read on attach never answers and gives up with no size to send. const blocked = yield* Queue.take(reads); yield* TestClock.adjust("1 second"); - // While it stays open nothing reads again; the host closing it clears it. + // The next page load measures again. + desktopSizeAnswer = null; + yield* Effect.promise(() => cdp.emitAsync("Page.loadEventFired")); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual(["1920x1080"]); + // While a dialog is open nothing reads. The host closing it clears it, and since + // closing resizes nothing, the close itself remeasures. + deferReads(); context.page.emit("dialog", { type: () => "alert", message: () => "", @@ -1230,10 +1236,12 @@ it.effect( }); yield* Effect.promise(resized); expect(yield* Queue.size(reads)).toBe(0); - // Closing it resizes nothing, so the close itself remeasures. desktopSizeAnswer = null; yield* Effect.promise(() => cdp.emitAsync("Page.javascriptDialogClosed")); - expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual(["1920x1080"]); + expect(viewportSizes(yield* Queue.takeAll(viewer.output))).toEqual([ + "1920x1080", + "1920x1080", + ]); // The late answer, a read a navigation breaks and a hidden webview keep the last size. blocked.resolve(answer(100, 100)); deferReads(); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 42e22533def79..57e35be778634 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -858,6 +858,8 @@ const make = Effect.gen(function* () { if (tab.viewers.size > 0) return broadcastViewport(tab).catch(constVoid); }; cdp.on("Page.frameResized", remeasure); + // A read a navigation broke has a fresh page to measure. + cdp.on("Page.loadEventFired", remeasure); // Closing a dialog resizes nothing, and the host can close it without us. cdp.on("Page.javascriptDialogClosed", () => { tab.dialog = null;