From 00ab9fdd390d5593c984b40df46d8e424d3a5b40 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:35:08 +0000 Subject: [PATCH 1/2] test(server): a stalled viewer still blocks agent snapshots and later calls (#23022) A remote viewer's still capture that never answers holds the tab's capture lock, so the agent's snapshot waits past the broker deadline before its own 10s timer starts, and a later evaluate on that tab times out too. AI-assisted; verified with the tests and tools listed above. --- apps/server/src/preview/ServerBrowser.test.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 6ab35a9613674..0d7a8404b4b61 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -799,6 +799,44 @@ it.effect("an agent's tab the reloaded desktop dropped goes back to the desktop" ).pipe(Effect.provide(layer)), ); +it.effect("a viewer still that never answers does not block the agent's snapshot", () => + Effect.scoped( + Effect.gen(function* () { + const { browser, broker, tabId } = yield* ready; + const context = contexts[0]!; + const stalled = Promise.withResolvers(); + const newCDPSession = context.newCDPSession; + context.newCDPSession = async (target) => { + const session = await newCDPSession(target); + const send = session.send.getMockImplementation()!; + session.send.mockImplementation((method, input) => { + if (method !== "Page.captureScreenshot") return send(method, input); + stalled.resolve(); + return new Promise(() => {}); + }); + return session; + }; + yield* browser.attachViewer(viewerInput(tabId, false)).pipe(Effect.forkScoped); + yield* Effect.promise(() => stalled.promise); + + const snapshotting = yield* broker + .invoke({ scope, tabId, operation: "snapshot", input: {} }) + .pipe(Effect.result, Effect.forkScoped); + yield* TestClock.adjust("20 seconds"); + const snapshot = yield* Fiber.join(snapshotting); + const evaluating = yield* broker + .invoke({ scope, tabId, operation: "evaluate", input: { expression: "document.title" } }) + .pipe(Effect.result, Effect.forkScoped); + yield* TestClock.adjust("20 seconds"); + const evaluated = yield* Fiber.join(evaluating); + expect({ snapshot: snapshot._tag, evaluated: evaluated._tag }).toEqual({ + snapshot: "Success", + evaluated: "Success", + }); + }), + ).pipe(Effect.provide(layer)), +); + it.effect("a desktop page that loaded before the server attached reports its loaded title", () => Effect.scoped( Effect.gen(function* () { From 585f8a517f9b16f15fc91dcdb1e78c9395e37c6d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 07:45:50 +0000 Subject: [PATCH 2/2] fix(server): a viewer still that never answers no longer blocks agent captures A viewer's settle still queued on the tab's capture lock, so a webview that cannot paint held the lock forever and the agent's snapshot, and every later call on that tab, waited past the broker deadline. The still now waits for captures already queued but does not join the queue itself. The regression test now invokes directly instead of advancing the clock past the broker deadline, which failed even without a stalled viewer. Fixes pingdotgg/t3code#23022 AI-assisted; verified with the tests and tools listed above. --- apps/server/src/preview/ServerBrowser.test.ts | 12 ++++-------- apps/server/src/preview/ServerBrowser.ts | 12 +++++++++--- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 0d7a8404b4b61..e7cf944942548 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -819,16 +819,12 @@ it.effect("a viewer still that never answers does not block the agent's snapshot yield* browser.attachViewer(viewerInput(tabId, false)).pipe(Effect.forkScoped); yield* Effect.promise(() => stalled.promise); - const snapshotting = yield* broker + const snapshot = yield* broker .invoke({ scope, tabId, operation: "snapshot", input: {} }) - .pipe(Effect.result, Effect.forkScoped); - yield* TestClock.adjust("20 seconds"); - const snapshot = yield* Fiber.join(snapshotting); - const evaluating = yield* broker + .pipe(Effect.result); + const evaluated = yield* broker .invoke({ scope, tabId, operation: "evaluate", input: { expression: "document.title" } }) - .pipe(Effect.result, Effect.forkScoped); - yield* TestClock.adjust("20 seconds"); - const evaluated = yield* Fiber.join(evaluating); + .pipe(Effect.result); expect({ snapshot: snapshot._tag, evaluated: evaluated._tag }).toEqual({ snapshot: "Success", evaluated: "Success", diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index f06c6b34d41b5..cdd57cf3ada87 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -2776,9 +2776,15 @@ const make = Effect.gen(function* () { broadcastControl(tab); pushFileChooser(tab); const pushStill = async () => { - const data = await withCaptureLock(tab, () => - ServerBrowserPage.captureViewport(session, { format: "jpeg", quality }), - ).catch(() => null); + // Waits out queued captures without joining the queue: a webview that cannot + // paint never answers, and the agent's next capture must not wait on it. + const data = await tab.captureLock + .then(() => + tab.closing + ? null + : ServerBrowserPage.captureViewport(session, { format: "jpeg", quality }), + ) + .catch(() => null); if (data) viewer.push({ _tag: "frame", data: Buffer.from(data, "base64"), ack: Effect.void }); }; let settleTimer: ReturnType | null = null;