From bd20f6af25beef5e4cf8174ea34e1eee96c65dd7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 06:11:28 +0000 Subject: [PATCH 1/4] test(server): viewer attach hangs when the first still never returns Red tests for pingdotgg/t3code#24852: attachViewer awaits the initial Page.captureScreenshot before the stream upgrades, and the stuck capture holds the tab's capture lock for later snapshots and viewers. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 0a9118e7b6d8b..3644792d6adb4 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -92,6 +92,7 @@ function makeSession(frame?: ReturnType) { if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] }; if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } }; if (method === "Page.captureScreenshot") { + if ((input as { format: string }).format === "jpeg" && heldStill) await heldStill(); // A snapshot sizes its screenshot from the PNG header; viewers get JPEG frames as-is. return { data: (input as { format: string }).format === "png" ? PNG_HEADER : "ZnJhbWU=" }; } @@ -259,6 +260,7 @@ const contexts: ReturnType[] = []; const encoderStreams: Array<{ frameRate: number | undefined; bitsPerSecond: number }> = []; const contextRequests: Array<{ profileId: string; isolated: boolean }> = []; let contextGate: PromiseWithResolvers | null = null; +let heldStill: (() => Promise) | null = null; let pageCopy = ""; let keyCopy = ""; let focusedFrame: ReturnType | null = null; @@ -367,6 +369,7 @@ beforeEach(() => { encoderStreams.length = 0; contextRequests.length = 0; contextGate = null; + heldStill = null; contextFailure = null; initialNavigation = null; pageCopy = ""; @@ -599,6 +602,57 @@ it.live("enforces provider ownership and explicit targets when a session has mul ).pipe(Effect.provide(layer)), ); +it.live("a viewer's stream opens before its first still arrives", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, tabId } = yield* ready; + const capturing = Promise.withResolvers(); + const still = Promise.withResolvers(); + heldStill = () => { + capturing.resolve(); + return still.promise; + }; + yield* Effect.addFinalizer(() => Effect.sync(() => still.resolve())); + const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); + yield* Effect.promise(() => capturing.promise); + still.resolve(); + const frame = yield* Stream.fromQueue(viewer.output).pipe( + Stream.filter((output) => output._tag === "frame"), + Stream.runHead, + ); + expect(Option.isSome(frame)).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + +it.live("a first still that never returns does not stall the tab's later captures", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const stuck = Promise.withResolvers(); + heldStill = () => { + heldStill = null; + return stuck.promise; + }; + yield* Effect.addFinalizer(() => Effect.sync(() => stuck.resolve())); + yield* browser.attachViewer(viewerInput(tabId, true)); + const snapshot = yield* broker.invoke({ + scope, + tabId, + operation: "snapshot", + input: {}, + }); + expect(snapshot.accessibilityTree).toContain("delete"); + const second = yield* browser.attachViewer(viewerInput(tabId, false)); + const frame = yield* Stream.fromQueue(second.output).pipe( + Stream.filter((output) => output._tag === "frame"), + Stream.runHead, + ); + expect(Option.isSome(frame)).toBe(true); + }), + ).pipe(Effect.provide(layer)), +); + it.live("streams to a read-only viewer without allowing takeover, input, or viewport changes", () => Effect.scoped( Effect.gen(function* () { From 925ce7849bc241b68304d1d00d7191aba1ffec80 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 06:19:43 +0000 Subject: [PATCH 2/4] fix(server): viewer attach no longer waits on its first still attachViewer awaited the initial Page.captureScreenshot before returning, and that capture held the tab's capture lock. When Chromium never answered, the viewer's stream never opened and later snapshots and viewers queued behind the lock forever. The initial still now runs in a scoped fiber after the screencast starts, and the viewer capture gives up after 2 seconds so the lock is released. Fixes pingdotgg/t3code#24852 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 22 ++++++++++++------- apps/server/src/preview/ServerBrowser.ts | 22 +++++++++++++------ 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 3644792d6adb4..bf6b3df47bf62 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"; @@ -625,23 +626,25 @@ it.live("a viewer's stream opens before its first still arrives", () => ).pipe(Effect.provide(layer)), ); -it.live("a first still that never returns does not stall the tab's later captures", () => +it.effect("a first still that never returns does not stall the tab's later captures", () => Effect.scoped( Effect.gen(function* () { const { browser, broker, tabId } = yield* ready; + const capturing = Promise.withResolvers(); const stuck = Promise.withResolvers(); heldStill = () => { heldStill = null; + capturing.resolve(); return stuck.promise; }; yield* Effect.addFinalizer(() => Effect.sync(() => stuck.resolve())); yield* browser.attachViewer(viewerInput(tabId, true)); - const snapshot = yield* broker.invoke({ - scope, - tabId, - operation: "snapshot", - input: {}, - }); + yield* Effect.promise(() => capturing.promise); + const snapshotting = yield* broker + .invoke({ scope, tabId, operation: "snapshot", input: {} }) + .pipe(Effect.forkScoped); + yield* TestClock.adjust("2 seconds"); + const snapshot = yield* Fiber.join(snapshotting); expect(snapshot.accessibilityTree).toContain("delete"); const second = yield* browser.attachViewer(viewerInput(tabId, false)); const frame = yield* Stream.fromQueue(second.output).pipe( @@ -671,7 +674,10 @@ it.live("streams to a read-only viewer without allowing takeover, input, or view yield* viewer.input(message); expect(page.setViewportSize).not.toHaveBeenCalled(); expect(session.send.mock.calls.some(([method]) => method.startsWith("Input."))).toBe(false); - const outputs = yield* Queue.takeAll(viewer.output); + const outputs = yield* Stream.fromQueue(viewer.output).pipe( + Stream.takeUntil((output) => output._tag === "frame"), + Stream.runCollect, + ); expect(outputs).toContainEqual( expect.objectContaining({ _tag: "frame", data: Buffer.from("frame") }), ); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 574f843b1991c..79e4d02126215 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -95,6 +95,8 @@ const SCREENCAST_MOTION_WINDOW_MS = 300; const SCREENCAST_MOTION_QUALITY = 50; const HOST_RECONNECT_DELAY = "1 second"; const DESKTOP_ATTACH_TIMEOUT = "10 seconds"; +// A viewer still is best-effort; a capture Chromium never answers must not hold the capture lock. +const VIEWER_STILL_TIMEOUT = "2 seconds"; const VIEWER_OUTPUT_LIMIT = 64; const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); @@ -454,7 +456,9 @@ const make = Effect.gen(function* () { const environment = yield* ServerEnvironment.ServerEnvironment; const previewBrowser = yield* PreviewBrowser.PreviewBrowser; const desktopChannel = yield* DesktopBrowserChannel.DesktopBrowserChannel; - const runFork = Effect.runForkWith(yield* Effect.context()); + const services = yield* Effect.context(); + const runFork = Effect.runForkWith(services); + const runPromise = Effect.runPromiseWith(services); const launchServices = yield* Effect.context(); const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); @@ -2333,11 +2337,15 @@ const make = Effect.gen(function* () { // Full scale: a scaled capture would flash in every other viewer. const pushStill = async () => { const data = await withCaptureLock(tab, () => - ServerBrowserPage.captureViewport(tab.page, session, { - format: "jpeg", - quality, - scale: 1, - }), + runPromise( + Effect.tryPromise(() => + ServerBrowserPage.captureViewport(tab.page, session, { + format: "jpeg", + quality, + scale: 1, + }), + ).pipe(Effect.timeoutOption(VIEWER_STILL_TIMEOUT), Effect.map(Option.getOrNull)), + ), ).catch(() => null); if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); }; @@ -2396,7 +2404,7 @@ const make = Effect.gen(function* () { 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. - if (!screencastStarted) yield* Effect.promise(pushStill); + if (!screencastStarted) yield* Effect.forkScoped(Effect.promise(pushStill)); return { output, input: (raw: unknown) => From 51fced1066a624c7aab7923b3e831adcda55efe1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 06:26:26 +0000 Subject: [PATCH 3/4] fix(mobile): time out preview startup until its first frame --- .../features/browser/PreviewStreamWebView.tsx | 6 ++--- apps/server/src/preview/ServerBrowser.test.ts | 5 ++-- apps/server/src/preview/ServerBrowser.ts | 24 +++++++++++-------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx index de7ecb1b0e690..23115bf10107a 100644 --- a/apps/mobile/src/features/browser/PreviewStreamWebView.tsx +++ b/apps/mobile/src/features/browser/PreviewStreamWebView.tsx @@ -247,7 +247,6 @@ function PreviewStreamDocumentView({ const [error, setError] = useState(null); const [gone, setGone] = useState(false); const [hostSetup, setHostSetup] = useState(null); - const [started, setStarted] = useState(false); const [control, setControl] = useState(null); const dialogRef = useRef(null); const [promptText, setPromptText] = useState(""); @@ -292,10 +291,10 @@ function PreviewStreamDocumentView({ fail("Browser viewer could not start. Reconnect to try again."), ); useEffect(() => { - if (started) return; + if (status !== "connecting") return; const timer = setTimeout(bootstrapTimedOut, 15_000); return () => clearTimeout(timer); - }, [started]); + }, [status]); const source = useMemo( () => ({ html: previewStreamDocument(configuration, previewStreamScript), @@ -407,7 +406,6 @@ function PreviewStreamDocumentView({ onPictureInPicture?.(message, message.detail); return; case "status": - setStarted(true); if (message.status === "error") { fail(message.detail ?? "Browser stream failed."); return; diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index bf6b3df47bf62..6667810add046 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -24,7 +24,6 @@ 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"; @@ -630,6 +629,8 @@ it.effect("a first still that never returns does not stall the tab's later captu Effect.scoped( Effect.gen(function* () { const { browser, broker, tabId } = yield* ready; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + yield* Effect.addFinalizer(() => Effect.sync(() => vi.useRealTimers())); const capturing = Promise.withResolvers(); const stuck = Promise.withResolvers(); heldStill = () => { @@ -643,7 +644,7 @@ it.effect("a first still that never returns does not stall the tab's later captu const snapshotting = yield* broker .invoke({ scope, tabId, operation: "snapshot", input: {} }) .pipe(Effect.forkScoped); - yield* TestClock.adjust("2 seconds"); + yield* Effect.promise(() => vi.advanceTimersByTimeAsync(2_000)); const snapshot = yield* Fiber.join(snapshotting); expect(snapshot.accessibilityTree).toContain("delete"); const second = yield* browser.attachViewer(viewerInput(tabId, false)); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 79e4d02126215..12cce0802f34c 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -96,7 +96,7 @@ const SCREENCAST_MOTION_QUALITY = 50; const HOST_RECONNECT_DELAY = "1 second"; const DESKTOP_ATTACH_TIMEOUT = "10 seconds"; // A viewer still is best-effort; a capture Chromium never answers must not hold the capture lock. -const VIEWER_STILL_TIMEOUT = "2 seconds"; +const VIEWER_STILL_TIMEOUT_MS = 2_000; const VIEWER_OUTPUT_LIMIT = 64; const RECORDING_SCREENCAST = { format: "jpeg", quality: 90, everyNthFrame: 1 } as const; const decodeViewportSetting = Schema.decodeUnknownSync(PreviewViewportSettingSchema); @@ -456,9 +456,7 @@ const make = Effect.gen(function* () { const environment = yield* ServerEnvironment.ServerEnvironment; const previewBrowser = yield* PreviewBrowser.PreviewBrowser; const desktopChannel = yield* DesktopBrowserChannel.DesktopBrowserChannel; - const services = yield* Effect.context(); - const runFork = Effect.runForkWith(services); - const runPromise = Effect.runPromiseWith(services); + const runFork = Effect.runForkWith(yield* Effect.context()); const launchServices = yield* Effect.context(); const setupCommand = yield* resolveRootCliCommand(PreviewBrowserHost.SETUP_SUBCOMMAND); @@ -2336,17 +2334,23 @@ const make = Effect.gen(function* () { pushFileChooser(tab); // Full scale: a scaled capture would flash in every other viewer. const pushStill = async () => { - const data = await withCaptureLock(tab, () => - runPromise( - Effect.tryPromise(() => + const data = await withCaptureLock(tab, async () => { + let timer: ReturnType | undefined; + try { + return await Promise.race([ ServerBrowserPage.captureViewport(tab.page, session, { format: "jpeg", quality, scale: 1, }), - ).pipe(Effect.timeoutOption(VIEWER_STILL_TIMEOUT), Effect.map(Option.getOrNull)), - ), - ).catch(() => null); + new Promise((resolve) => { + timer = setTimeout(() => resolve(null), VIEWER_STILL_TIMEOUT_MS); + }), + ]); + } finally { + clearTimeout(timer); + } + }).catch(() => null); if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); }; let settleTimer: ReturnType | null = null; From ba3c446d2d9d9cd3ba8b75beb9e624da06043cab Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 13 Nov 2026 02:56:56 +0000 Subject: [PATCH 4/4] test(server): keep one regression test for a stalled first viewer still Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 8715264c157d3..08879064bdd5d 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1132,29 +1132,6 @@ it.live("enforces provider ownership and explicit targets when a session has mul ).pipe(Effect.provide(layer)), ); -it.live("a viewer's stream opens before its first still arrives", () => - Effect.scoped( - Effect.gen(function* () { - const { browser, tabId } = yield* ready; - const capturing = Promise.withResolvers(); - const still = Promise.withResolvers(); - heldStill = () => { - capturing.resolve(); - return still.promise; - }; - yield* Effect.addFinalizer(() => Effect.sync(() => still.resolve())); - const viewer = yield* browser.attachViewer(viewerInput(tabId, true)); - yield* Effect.promise(() => capturing.promise); - still.resolve(); - const frame = yield* Stream.fromQueue(viewer.output).pipe( - Stream.filter((output) => output._tag === "frame"), - Stream.runHead, - ); - expect(Option.isSome(frame)).toBe(true); - }), - ).pipe(Effect.provide(layer)), -); - it.effect("a first still that never returns does not stall the tab's later captures", () => Effect.scoped( Effect.gen(function* () {