From 2cb5cdf36839f299eebfdbd8cec2f9634befeda1 Mon Sep 17 00:00:00 2001 From: Melbourne Baldove Date: Tue, 10 Nov 2026 08:24:45 +0000 Subject: [PATCH 1/3] test(server): a stalled viewer screencast must not hang attach or snapshots --- apps/server/src/preview/ServerBrowser.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index c32947b30b995..d989df8e538f3 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -2525,3 +2525,39 @@ it.live("a full server takes back the slot of the longest idle agent tab nobody }), ).pipe(Effect.provide(layer)), ); + +it.effect( + "a viewer whose screencast Chromium never starts still attaches and lets snapshots run", + () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const context = contexts[0]!; + const stalledStarts = yield* Queue.unbounded(); + const openSession = context.newCDPSession; + context.newCDPSession = async (target: unknown) => { + const session = await openSession(target); + const answer = session.send.getMockImplementation()!; + session.send.mockImplementation(async (method: string, input?: unknown) => { + if (method !== "Page.startScreencast") return answer(method, input); + Queue.offerUnsafe(stalledStarts, undefined); + return new Promise(() => {}); + }); + return session; + }; + const attaching = yield* browser + .attachViewer(viewerInput(tabId, true)) + .pipe(Effect.forkScoped); + yield* Queue.take(stalledStarts); + yield* TestClock.adjust("5 seconds"); + const viewer = yield* Fiber.join(attaching); + expect((yield* Queue.takeAll(viewer.output)).map((item) => item._tag)).toContain("frame"); + const snapshotting = yield* broker + .invoke({ scope, tabId, operation: "snapshot", input: {} }) + .pipe(Effect.forkScoped); + yield* Queue.take(stalledStarts); + yield* TestClock.adjust("5 seconds"); + expect((yield* Fiber.join(snapshotting)).url).toBeDefined(); + }), + ).pipe(Effect.provide(layer)), +); From 35054dcf80262d6c53b5b7cf1b29222a5bbcea74 Mon Sep 17 00:00:00 2001 From: Melbourne Baldove Date: Tue, 10 Nov 2026 08:25:06 +0000 Subject: [PATCH 2/3] fix(server): a stalled viewer screencast no longer locks its preview tab Viewer screencast commands and the viewer's still capture waited on Chromium with no deadline. One unanswered Page.startScreencast kept attachViewer from returning, so the panel never connected, and every later snapshot and viewer on the tab waited behind it. Each viewer command now gives up after five seconds. --- apps/server/src/preview/ServerBrowser.ts | 29 ++++++++++++++++-------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index ee31b17cc5889..ed5a50b8ea655 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -105,6 +105,7 @@ const AGENT_CURSOR_MOVE_MS = 160; const AGENT_CURSOR_CLICK_LEAD_MS = 40; /** A dialog blocks page script, so a desktop size read gives up after this. */ const DESKTOP_VIEWPORT_READ_TIMEOUT = "1 second"; +const VIEWER_COMMAND_TIMEOUT = "5 seconds"; const sleepUntil = (deadline: number) => new Promise((resolve) => setTimeout(resolve, Math.max(0, deadline - Date.now()))); @@ -1787,6 +1788,14 @@ const make = Effect.gen(function* () { return started; }; + const withinViewerDeadline = (command: Promise): Promise => + runPromise( + Effect.promise(() => command.catch(() => null)).pipe( + Effect.timeoutOption(VIEWER_COMMAND_TIMEOUT), + Effect.map(Option.getOrNull), + ), + ); + const withScreencastsPaused = (tab: ServerTab, capture: () => Promise): Promise => withCaptureLock(tab, async () => { tab.capturing += 1; @@ -2560,15 +2569,15 @@ const make = Effect.gen(function* () { screencastScale = scale; screencastParams = screencastParams.then(async () => { if (tab.capturing > 0) return; - await session.send("Page.stopScreencast").catch(constVoid); - await session - .send("Page.startScreencast", { + await withinViewerDeadline(session.send("Page.stopScreencast")); + await withinViewerDeadline( + session.send("Page.startScreencast", { format: "jpeg", quality: screencastScale < 1 ? Math.min(quality, SCREENCAST_MOTION_QUALITY) : quality, maxWidth: Math.max(1, Math.round(input.maxWidth * screencastScale)), maxHeight: Math.max(1, Math.round(input.maxHeight * screencastScale)), - }) - .catch(constVoid); + }), + ); }); return screencastParams; }; @@ -2601,9 +2610,9 @@ const make = Effect.gen(function* () { } }, pause: () => { - screencastParams = screencastParams.then(() => - session.send("Page.stopScreencast").then(constVoid, constVoid), - ); + screencastParams = screencastParams + .then(() => withinViewerDeadline(session.send("Page.stopScreencast"))) + .then(constVoid); return screencastParams; }, resume: () => startScreencast(screencastScale), @@ -2630,7 +2639,9 @@ const make = Effect.gen(function* () { pushFileChooser(tab); const pushStill = async () => { const data = await withCaptureLock(tab, () => - ServerBrowserPage.captureViewport(session, { format: "jpeg", quality }), + withinViewerDeadline( + ServerBrowserPage.captureViewport(session, { format: "jpeg", quality }), + ), ).catch(() => null); if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); }; From 9086af2603b3383a4bd79ae6b0c7865a073f9ebd Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Wed, 11 Nov 2026 22:08:01 +0000 Subject: [PATCH 3/3] fix(server): bound viewer screencast cleanup --- apps/server/src/preview/ServerBrowser.test.ts | 11 ++++++++--- apps/server/src/preview/ServerBrowser.ts | 4 +--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 246e9bc3f876b..1b57dba45b6c0 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -2657,20 +2657,25 @@ it.effect("viewer cleanup detaches after a stalled stop command", () => Effect.gen(function* () { const { browser, tabId } = yield* ready; const viewerScope = yield* Scope.make(); - yield* browser.attachViewer(viewerInput(tabId, true)).pipe(Effect.provideService(Scope.Scope, viewerScope)); + yield* browser + .attachViewer(viewerInput(tabId, true)) + .pipe(Effect.provideService(Scope.Scope, viewerScope)); const session = contexts[0]!.sessions.at(-1)!; const stopped = yield* Queue.unbounded(); + const stalledStop = Promise.withResolvers>(); const answer = session.send.getMockImplementation()!; session.send.mockImplementation(async (method: string, input?: unknown) => { if (method !== "Page.stopScreencast") return answer(method, input); Queue.offerUnsafe(stopped, undefined); - return new Promise(() => {}); + return stalledStop.promise; }); const closing = yield* Scope.close(viewerScope, Exit.void).pipe(Effect.forkScoped); yield* Queue.take(stopped); yield* TestClock.adjust("5 seconds"); + const detached = session.detach.mock.calls.length; + stalledStop.resolve({}); yield* Fiber.join(closing); - expect(session.detach).toHaveBeenCalled(); + expect(detached).toBe(1); }), ).pipe(Effect.provide(layer)), ); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 8af89a28661b4..24499058f1aad 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -2578,9 +2578,7 @@ const make = Effect.gen(function* () { }), (session) => Effect.promise(() => - session - .send("Page.stopScreencast") - .catch(constVoid) + withinViewerDeadline(session.send("Page.stopScreencast")) .then(() => session.detach()) .catch(constVoid), ),