From 27351715e5ca0abdab5f988e0cb5a0a8c3907d19 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 17:23:40 +0000 Subject: [PATCH 1/7] test(desktop): reproduce #31014, one session's screencast stop freezes the others Two page sessions on a desktop preview tab (a viewer and a recording) share the guest's single Chromium screencast. These tests reproduce #31014: the first session's Page.stopScreencast reaches the debugger while the second still streams. They also require the native stop once the last streaming session stops or detaches. Co-Authored-By: Claude Opus 5.5 --- .../src/preview/DesktopBrowserHost.test.ts | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 2e6e54a5293a7..2141e5a2b4eb6 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -41,6 +41,7 @@ const makeDebuggee = ( const pending: Array<() => void> = []; const focusChanges: Array<() => void> = []; const sent: Array = []; + const methods: Array = []; const sendWaiters: Array<() => void> = []; const focusEmulation: Array = []; const debuggee = Object.assign(emitter, { @@ -65,6 +66,7 @@ const makeDebuggee = ( return; } sent.push(params?.id); + methods.push(method); pending.push(() => resolve({ method })); sendWaiters.shift()?.(); }), @@ -83,6 +85,8 @@ const makeDebuggee = ( navigate: () => webContents.emit("did-navigate"), /** Ids of the commands the debugger received, in order. */ sent, + /** Methods of the same commands, in order. */ + methods, focusEmulation, nextSend: () => new Promise((resolve) => sendWaiters.push(resolve)), release: () => pending.splice(0).forEach((resolve) => resolve()), @@ -912,6 +916,69 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); + it.effect("keeps a guest's one screencast running until its last streaming session stops", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const nativeStops = () => + debuggee.methods.filter((method) => method === "Page.stopScreencast").length; + + // A viewer and a recording share Chromium's single screencast for the guest. + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + debuggee.release(); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + expect(nativeStops(), "stopped the screencast another session still streams").toBe(0); + + yield* raw({ id: 6, method: "Page.stopScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + expect(nativeStops()).toBe(1); + }).pipe(Effect.scoped), + ); + + it.effect("stops a guest's screencast once its last streaming session detaches", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + const nativeStops = () => + debuggee.methods.filter((method) => method === "Page.stopScreencast").length; + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); + yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); + yield* settle; + debuggee.release(); + yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + yield* settle; + expect(nativeStops(), "stopped the screencast another session still streams").toBe(0); + + // The last streamer goes without stopping, such as a viewer that closed. + yield* raw({ + id: 6, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-2" }, + }); + expect(yield* Queue.take(replies)).toBe(6); + yield* settle; + expect(nativeStops()).toBe(1); + }).pipe(Effect.scoped), + ); + it.effect("saves a server tab's download under its CDP guid where the server asked", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; From 41a057c9f4e6bbc3b7dec0c801e84bbf96abb51a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 17:30:09 +0000 Subject: [PATCH 2/7] fix(desktop): one preview session stopping its screencast no longer freezes the others Remote viewers, agent recordings and capture pauses share a guest's single Chromium screencast. Any session's Page.stopScreencast was forwarded to the shared debugger, freezing every other session's stream, and the last streaming session detaching left the native screencast running unseen. tab.screencasts is now the owner count for that one screencast: a stop is forwarded only when no other owner still streams (otherwise it is answered with {}), and an owner leaving by detach or a refused start sends one native stop when it was the last. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/preview/DesktopBrowserHost.test.ts | 4 +++- apps/desktop/src/preview/DesktopBrowserHost.ts | 13 ++++++++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 2141e5a2b4eb6..7779edd1f3650 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -973,7 +973,9 @@ describe("DesktopBrowserHost", () => { method: "Target.detachFromTarget", params: { sessionId: "t3-preview-page-2" }, }); - expect(yield* Queue.take(replies)).toBe(6); + const answered = []; + for (let reply = 0; reply < 4; reply++) answered.push(yield* Queue.take(replies)); + expect(answered.toSorted()).toEqual([3, 4, 5, 6]); yield* settle; expect(nativeStops()).toBe(1); }).pipe(Effect.scoped), diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 3ac6c6d73caec..121c41b902011 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -69,7 +69,7 @@ interface AttachedTab { */ agentUsed: boolean; focusReleasing: boolean; - /** The sessions streaming the guest; one stopping leaves the others' screencasts running. */ + /** The sessions sharing the guest's one Chromium screencast; it stops with the last of them. */ readonly screencasts: Set; readonly onMessage: ( event: Electron.Event, @@ -272,9 +272,15 @@ export const make = Effect.gen(function* () { dropFocusHolds(tab); }; + /** An owner that leaves without stopping would otherwise keep the screencast running unseen. */ + const leaveScreencast = (tab: AttachedTab, owner: string | undefined) => { + if (!tab.screencasts.delete(owner) || tab.screencasts.size > 0) return; + void tab.debuggee.debugger.sendCommand("Page.stopScreencast").catch(() => {}); + }; + /** A detached session, such as a closed viewer, waits on nothing it sent. */ const dropSessionHolds = (tab: AttachedTab, owner: string) => { - tab.screencasts.delete(owner); + leaveScreencast(tab, owner); for (const [pending, hold] of tab.focusPending) { if (hold.owner === owner) tab.focusPending.delete(pending); } @@ -315,6 +321,7 @@ export const make = Effect.gen(function* () { if (owner !== undefined) streamingOwners.add(owner); } else if (sessionId === undefined && method === "Page.stopScreencast") { tab.screencasts.delete(owner); + if (tab.screencasts.size > 0) return Promise.resolve({}); } // A person's input through a viewer, or Playwright's own housekeeping, is not the agent's. if ( @@ -343,7 +350,7 @@ export const make = Effect.gen(function* () { : method === "Page.startScreencast" ? holdFocus(tab, method, owner, forward).catch((cause: unknown) => { // A viewer can outlive a refused stream; nothing is streaming for it. - if (sessionId === undefined && tab.relay === relay) tab.screencasts.delete(owner); + if (sessionId === undefined && tab.relay === relay) leaveScreencast(tab, owner); releaseFocusWhenIdle(tab); syncPainting(tab); throw cause; From 2850ddad2f482ab764dd87a78ce40f01089bbf9d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 4 Nov 2026 17:46:06 +0000 Subject: [PATCH 3/7] fix(desktop): a stopped screencast session no longer gets the others' frames Every page session receives the guest's one screencast's frames. A session that stopped its own stream while another still streams kept receiving and acking them, such as a recording that stopped at its size limit while a viewer watched. Frames now skip such a session; sessions that never streamed are delivered to as before. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/preview/CdpRelay.test.ts | 1 + apps/desktop/src/preview/CdpRelay.ts | 13 ++++++- .../src/preview/DesktopBrowserHost.test.ts | 38 ++++++++++++++++--- .../desktop/src/preview/DesktopBrowserHost.ts | 3 ++ 4 files changed, 47 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/preview/CdpRelay.test.ts b/apps/desktop/src/preview/CdpRelay.test.ts index d409ad8b42e09..73e46daeb1833 100644 --- a/apps/desktop/src/preview/CdpRelay.test.ts +++ b/apps/desktop/src/preview/CdpRelay.test.ts @@ -12,6 +12,7 @@ const makeTarget = ( title: () => "Fixture", userAgent: () => "Electron", setDownloadDirectory, + receivesScreencast: () => true, }); const makeRelay = (target: CdpRelayTarget, write: (raw: string) => void) => diff --git a/apps/desktop/src/preview/CdpRelay.ts b/apps/desktop/src/preview/CdpRelay.ts index 9a8e4e4c68346..0ec3af4a8d9f3 100644 --- a/apps/desktop/src/preview/CdpRelay.ts +++ b/apps/desktop/src/preview/CdpRelay.ts @@ -9,6 +9,8 @@ export interface CdpRelayTarget { owner?: string, ) => Promise; readonly sessionDetached?: (owner: string) => void; + /** Whether a page session, by owner as `send` names it, gets the page's screencast frames. */ + readonly receivesScreencast: (owner: string | undefined) => boolean; /** The page's own CDP target id; Playwright keys the main frame by it. */ readonly targetId: () => Promise; readonly url: () => string; @@ -250,8 +252,15 @@ export function createCdpRelayConnection( send({ method, params, sessionId }); return; } - for (const [id, kind] of sessions) - if (kind === "page") send({ method, params, sessionId: id }); + for (const [id, kind] of sessions) { + if (kind !== "page") continue; + if ( + method === "Page.screencastFrame" && + !target.receivesScreencast(id === PAGE_SESSION_ID ? undefined : id) + ) + continue; + send({ method, params, sessionId: id }); + } }, }; } diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 7779edd1f3650..c76f09b71b40c 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -16,6 +16,15 @@ const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); const decodeCdpReply = Schema.decodeUnknownSync( Schema.fromJsonString(Schema.Struct({ id: Schema.Number })), ); +const decodeCdpMessage = Schema.decodeUnknownSync( + Schema.fromJsonString( + Schema.Struct({ + id: Schema.optional(Schema.Number), + method: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + }), + ), +); const key = { threadId: "thread-1", tabId: "tab-1" }; /** Sends a page command whose id the fake debugger records as it receives it. */ const command = ( @@ -109,16 +118,24 @@ const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], coun /** * Ids of the replies the host sends, in order. A screenshot's reply leaves after its turn * has passed on, so by then the next waiting screenshot, if any, has been sent. Subscribe - * before sending commands: a new subscriber releases the tab's relay. + * before sending commands: a new subscriber releases the tab's relay. Screencast frames + * go to `frames` by the session they were sent to. */ -const watchReplies = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) => +const watchReplies = ( + host: DesktopBrowserHost.DesktopBrowserHost["Service"], + frames: Array = [], +) => Effect.gen(function* () { const replies = yield* Queue.unbounded(); yield* host.events.pipe( Stream.map((line) => decodeEvent(new TextDecoder().decode(line))), - Stream.runForEach((event) => - event.type === "cdp" ? Queue.offer(replies, decodeCdpReply(event.message).id) : Effect.void, - ), + Stream.runForEach((event) => { + if (event.type !== "cdp") return Effect.void; + const { id, method, sessionId } = decodeCdpMessage(event.message); + if (id !== undefined) return Queue.offer(replies, id); + if (method === "Page.screencastFrame") frames.push(sessionId); + return Effect.void; + }), Effect.forkScoped, ); yield* Effect.yieldNow; @@ -921,13 +938,16 @@ describe("DesktopBrowserHost", () => { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); host.attach(key, debuggee.tab); - const replies = yield* watchReplies(host); + const frames: Array = []; + const replies = yield* watchReplies(host, frames); const raw = (message: Record) => host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); const nativeStops = () => debuggee.methods.filter((method) => method === "Page.stopScreencast").length; // A viewer and a recording share Chromium's single screencast for the guest. + yield* raw({ id: 0, method: "Target.setAutoAttach", params: { autoAttach: true } }); + expect(yield* Queue.take(replies)).toBe(0); yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]); @@ -938,6 +958,12 @@ describe("DesktopBrowserHost", () => { yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); yield* settle; expect(nativeStops(), "stopped the screencast another session still streams").toBe(0); + debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 1 }); + yield* settle; + expect(frames, "sent a stopped session the screencast's frames").toEqual([ + "t3-preview-page", + "t3-preview-page-2", + ]); yield* raw({ id: 6, method: "Page.stopScreencast", sessionId: "t3-preview-page-2" }); yield* settle; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 121c41b902011..d68a32648a175 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -361,6 +361,9 @@ export const make = Effect.gen(function* () { detachedOwners.add(owner); dropSessionHolds(tab, owner); }, + // Frames stop only for a session that stopped its own stream, not one that never streamed. + receivesScreencast: (owner) => + tab.screencasts.has(owner) || owner === undefined || !streamingOwners.has(owner), targetId: () => debuggee .sendCommand("Target.getTargetInfo") From 4aacf471711f99363d8f22090cd56843043ec669 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 23:08:49 +0000 Subject: [PATCH 4/7] test(desktop): the detach case no longer passes on main by reusing the stop path AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/preview/DesktopBrowserHost.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 7e07957f5c5e1..e233d7a5bd712 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -1103,11 +1103,14 @@ describe("DesktopBrowserHost", () => { yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" }); yield* settle; debuggee.release(); - yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" }); + // Streamers go without stopping, such as viewers that closed. + yield* raw({ + id: 5, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); yield* settle; expect(nativeStops(), "stopped the screencast another session still streams").toBe(0); - - // The last streamer goes without stopping, such as a viewer that closed. yield* raw({ id: 6, method: "Target.detachFromTarget", From 4848d176751342a5c921fdfb4297ff3f9a3937ba Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 23:24:34 +0000 Subject: [PATCH 5/7] fix(desktop): a refused screencast start no longer stops a newer one on the same session AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/preview/DesktopBrowserHost.test.ts | 42 +++++++++++++++++++ .../desktop/src/preview/DesktopBrowserHost.ts | 22 +++++++--- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index e233d7a5bd712..4a69bdd1ee7ef 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -53,6 +53,7 @@ const makeDebuggee = ( const focusChanges: Array<() => void> = []; const sent: Array = []; const methods: Array = []; + const rejects: Array<() => void> = []; const sendWaiters: Array<() => void> = []; const focusEmulation: Array = []; const debuggee = Object.assign(emitter, { @@ -78,6 +79,7 @@ const makeDebuggee = ( } sent.push(params?.id); methods.push(method); + rejects.push(() => reject(new Error("Refused"))); pending.push(() => resolve({ method })); sendWaiters.shift()?.(); }), @@ -107,6 +109,8 @@ const makeDebuggee = ( sent, /** Methods of the same commands, in order. */ methods, + /** Refuses the pending command the debugger received with this id. */ + refuse: (id: number) => rejects[sent.indexOf(id)]?.(), focusEmulation, nextSend: () => new Promise((resolve) => sendWaiters.push(resolve)), release: () => pending.splice(0).forEach((resolve) => resolve()), @@ -1085,6 +1089,44 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); + it.effect("keeps a session's screencast when its earlier start is refused", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee(); + host.attach(key, debuggee.tab); + const frames: Array = []; + const replies = yield* watchReplies(host, frames); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 0, method: "Target.setAutoAttach", params: { autoAttach: true } }); + expect(yield* Queue.take(replies)).toBe(0); + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + yield* raw({ + id: 2, + method: "Page.startScreencast", + params: { id: 2 }, + sessionId: "t3-preview-page-1", + }); + yield* raw({ + id: 3, + method: "Page.startScreencast", + params: { id: 3 }, + sessionId: "t3-preview-page-1", + }); + yield* settle; + debuggee.refuse(2); + debuggee.release(); + expect([yield* Queue.take(replies), yield* Queue.take(replies)].toSorted()).toEqual([2, 3]); + yield* settle; + expect(debuggee.methods).not.toContain("Page.stopScreencast"); + debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 1 }); + yield* settle; + expect(frames).toContain("t3-preview-page-1"); + }).pipe(Effect.scoped), + ); + it.effect("stops a guest's screencast once its last streaming session detaches", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index efba6a060369b..6bfb435508456 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -73,8 +73,11 @@ interface AttachedTab { */ agentUsed: boolean; focusReleasing: boolean; - /** The sessions sharing the guest's one Chromium screencast; it stops with the last of them. */ - readonly screencasts: Set; + /** + * The sessions sharing the guest's one Chromium screencast, by their latest start; it stops + * with the last of them. + */ + readonly screencasts: Map; readonly onMessage: ( event: Electron.Event, method: string, @@ -325,9 +328,10 @@ export const make = Effect.gen(function* () { const relay: CdpRelayConnection = createCdpRelayConnection( { send: (method, params, sessionId, owner) => { + const start = {}; // A child session's screencast is not the page's, such as a late one on a detached session. if (sessionId === undefined && method === "Page.startScreencast") { - tab.screencasts.add(owner); + tab.screencasts.set(owner, start); if (owner !== undefined) streamingOwners.add(owner); } else if (sessionId === undefined && method === "Page.stopScreencast") { tab.screencasts.delete(owner); @@ -359,8 +363,14 @@ export const make = Effect.gen(function* () { ) : method === "Page.startScreencast" ? holdFocus(tab, method, owner, forward).catch((cause: unknown) => { - // A viewer can outlive a refused stream; nothing is streaming for it. - if (sessionId === undefined && tab.relay === relay) leaveScreencast(tab, owner); + // A viewer can outlive a refused stream; a later start may still stream for it. + if ( + sessionId === undefined && + tab.relay === relay && + tab.screencasts.get(owner) === start + ) { + leaveScreencast(tab, owner); + } releaseFocusWhenIdle(tab); syncPainting(tab); throw cause; @@ -458,7 +468,7 @@ export const make = Effect.gen(function* () { painting: false, agentUsed: false, focusReleasing: false, - screencasts: new Set(), + screencasts: new Map(), onMessage: (_event, method, params, sessionId) => { if (method === "Page.downloadWillBegin") { const guid = (params as { guid?: unknown } | undefined)?.guid; From dc9708a10303d6f0b03b3dd5d383a70325d18e81 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 23:41:00 +0000 Subject: [PATCH 6/7] fix(desktop): a refused screencast start no longer drops its session's running stream AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/preview/DesktopBrowserHost.test.ts | 27 ++++++++- .../desktop/src/preview/DesktopBrowserHost.ts | 55 ++++++++++++------- 2 files changed, 60 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 4a69bdd1ee7ef..06c654bec8dcf 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -1089,7 +1089,7 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); - it.effect("keeps a session's screencast when its earlier start is refused", () => + it.effect("keeps a session's screencast when another of its starts is refused", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; const debuggee = makeDebuggee(); @@ -1124,6 +1124,31 @@ describe("DesktopBrowserHost", () => { debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 1 }); yield* settle; expect(frames).toContain("t3-preview-page-1"); + + // A refused replacement leaves the session's running stream, shared with another session. + yield* raw({ id: 4, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(4); + yield* raw({ + id: 5, + method: "Page.startScreencast", + params: { id: 5 }, + sessionId: "t3-preview-page-2", + }); + yield* raw({ + id: 6, + method: "Page.startScreencast", + params: { id: 6 }, + sessionId: "t3-preview-page-1", + }); + yield* settle; + debuggee.refuse(6); + debuggee.release(); + expect([yield* Queue.take(replies), yield* Queue.take(replies)].toSorted()).toEqual([5, 6]); + yield* settle; + frames.length = 0; + debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 2 }); + yield* settle; + expect(frames).toEqual(["t3-preview-page", "t3-preview-page-1", "t3-preview-page-2"]); }).pipe(Effect.scoped), ); diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index 6bfb435508456..f458c083be694 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -73,11 +73,8 @@ interface AttachedTab { */ agentUsed: boolean; focusReleasing: boolean; - /** - * The sessions sharing the guest's one Chromium screencast, by their latest start; it stops - * with the last of them. - */ - readonly screencasts: Map; + /** The sessions sharing the guest's one Chromium screencast; it stops with the last of them. */ + readonly screencasts: Map; readonly onMessage: ( event: Electron.Event, method: string, @@ -328,10 +325,14 @@ export const make = Effect.gen(function* () { const relay: CdpRelayConnection = createCdpRelayConnection( { send: (method, params, sessionId, owner) => { - const start = {}; // A child session's screencast is not the page's, such as a late one on a detached session. - if (sessionId === undefined && method === "Page.startScreencast") { - tab.screencasts.set(owner, start); + const stream = + sessionId === undefined && method === "Page.startScreencast" + ? (tab.screencasts.get(owner) ?? { started: false, pending: 0 }) + : undefined; + if (stream) { + stream.pending += 1; + tab.screencasts.set(owner, stream); if (owner !== undefined) streamingOwners.add(owner); } else if (sessionId === undefined && method === "Page.stopScreencast") { tab.screencasts.delete(owner); @@ -362,19 +363,31 @@ export const make = Effect.gen(function* () { held ? holdFocus(tab, method, owner, () => forward(current)) : forward(current), ) : method === "Page.startScreencast" - ? holdFocus(tab, method, owner, forward).catch((cause: unknown) => { - // A viewer can outlive a refused stream; a later start may still stream for it. - if ( - sessionId === undefined && - tab.relay === relay && - tab.screencasts.get(owner) === start - ) { - leaveScreencast(tab, owner); - } - releaseFocusWhenIdle(tab); - syncPainting(tab); - throw cause; - }) + ? holdFocus(tab, method, owner, forward).then( + (result) => { + if (stream) { + stream.pending -= 1; + stream.started = true; + } + return result; + }, + (cause: unknown) => { + if (stream) stream.pending -= 1; + // A viewer can outlive a refused start; its running or pending stream still holds it. + if ( + stream && + !stream.started && + stream.pending === 0 && + tab.relay === relay && + tab.screencasts.get(owner) === stream + ) { + leaveScreencast(tab, owner); + } + releaseFocusWhenIdle(tab); + syncPainting(tab); + throw cause; + }, + ) : holdFocus(tab, method, owner, forward); }, sessionDetached: (owner) => { From f0f768a126a7709f6325f0932c2edf068ece4b32 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 03:09:07 +0000 Subject: [PATCH 7/7] fix(desktop): idle preview sessions get no screencast frames, and a late start stops Frames now go only to sessions with a live screencast, so the root page session and sessions that never streamed no longer receive them. A start that lands after a detach's native stop overtook it now stops the screencast again when no session is left streaming. AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../src/preview/DesktopBrowserHost.test.ts | 35 +++++++++++++++++-- .../desktop/src/preview/DesktopBrowserHost.ts | 8 +++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 06c654bec8dcf..9b2ca4a7ec01a 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -1078,8 +1078,7 @@ describe("DesktopBrowserHost", () => { expect(nativeStops(), "stopped the screencast another session still streams").toBe(0); debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 1 }); yield* settle; - expect(frames, "sent a stopped session the screencast's frames").toEqual([ - "t3-preview-page", + expect(frames, "sent a stopped or idle session the screencast's frames").toEqual([ "t3-preview-page-2", ]); @@ -1148,7 +1147,7 @@ describe("DesktopBrowserHost", () => { frames.length = 0; debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 2 }); yield* settle; - expect(frames).toEqual(["t3-preview-page", "t3-preview-page-1", "t3-preview-page-2"]); + expect(frames).toEqual(["t3-preview-page-1", "t3-preview-page-2"]); }).pipe(Effect.scoped), ); @@ -1191,6 +1190,36 @@ describe("DesktopBrowserHost", () => { }).pipe(Effect.scoped), ); + it.effect("stops a screencast whose start lands after its session detached", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const debuggee = makeDebuggee({ deferFocus: true }); + host.attach(key, debuggee.tab); + const replies = yield* watchReplies(host); + const raw = (message: Record) => + host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) })); + + yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } }); + expect(yield* Queue.take(replies)).toBe(1); + // The start waits on focus emulation while the viewer closes. + yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" }); + yield* raw({ + id: 3, + method: "Target.detachFromTarget", + params: { sessionId: "t3-preview-page-1" }, + }); + expect(yield* Queue.take(replies)).toBe(3); + debuggee.settleFocus(); + yield* settle; + debuggee.release(); + expect(yield* Queue.take(replies)).toBe(2); + yield* settle; + expect(debuggee.methods.at(-1), "left the late screencast running").toBe( + "Page.stopScreencast", + ); + }).pipe(Effect.scoped), + ); + it.effect("saves a server tab's download under its CDP guid where the server asked", () => Effect.gen(function* () { const host = yield* DesktopBrowserHost.make; diff --git a/apps/desktop/src/preview/DesktopBrowserHost.ts b/apps/desktop/src/preview/DesktopBrowserHost.ts index f458c083be694..e59aa57dcef37 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -368,6 +368,10 @@ export const make = Effect.gen(function* () { if (stream) { stream.pending -= 1; stream.started = true; + // A detach's stop can overtake a start still waiting on focus; nobody owns it now. + if (tab.screencasts.get(owner) !== stream && tab.screencasts.size === 0) { + void debuggee.sendCommand("Page.stopScreencast").catch(() => {}); + } } return result; }, @@ -394,9 +398,7 @@ export const make = Effect.gen(function* () { detachedOwners.add(owner); dropSessionHolds(tab, owner); }, - // Frames stop only for a session that stopped its own stream, not one that never streamed. - receivesScreencast: (owner) => - tab.screencasts.has(owner) || owner === undefined || !streamingOwners.has(owner), + receivesScreencast: (owner) => tab.screencasts.has(owner), targetId: () => debuggee .sendCommand("Target.getTargetInfo")