From 1be1e1651fee4bbc771f6a5af12b008a697afe9c Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 23 Oct 2026 17:04:07 +0000 Subject: [PATCH 1/5] fix(server): idle agent tabs free their slot when the server's browser is full In the desktop app every server tab is desktop-rendered, and the idle sweep skips those, so 32 agent tabs nobody uses filled the server's budget for good and preview_open failed in every thread until restart. When the server is full, the agent tab idle longest (30 minutes, no streamed viewer) now gives up its slot, desktop-rendered or not. Below the limit nothing changes, and tabs a person opened are never closed. Fixes #24159 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 40 +++++++++++++++++++ apps/server/src/preview/ServerBrowser.ts | 29 +++++++++----- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 99df18c10cf5a..0f95f4ca2a8f8 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1301,3 +1301,43 @@ it.live( }), ).pipe(Effect.provide(layer)), ); + +it.live("a full server takes back the slot of the longest idle agent tab, even a desktop one", () => + Effect.scoped( + Effect.gen(function* () { + const broker = yield* Broker.PreviewAutomationBroker; + const manager = yield* Manager.PreviewManager; + yield* ServerBrowser.ServerBrowser; + yield* Effect.yieldNow; + const now = vi.spyOn(Date, "now").mockReturnValue(0); + yield* Effect.addFinalizer(() => Effect.sync(() => now.mockRestore())); + const open = (providerSessionId: string) => { + desktopRendersNext = true; + return broker.invoke({ + scope: asSession(providerSessionId), + operation: "open", + input: { reuseExistingTab: false, open: false }, + }); + }; + const opened: Array = []; + for (let index = 0; index < 32; index++) { + now.mockReturnValue(index); + opened.push((yield* open(`agent-${Math.floor(index / 8)}`)).tabId!); + } + + // Inside the idle window every desktop tab keeps its slot. + now.mockReturnValue(29 * 60 * 1000); + expect(yield* open("agent-new").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); + expect(releasedDesktopTabs).toEqual([]); + + now.mockReturnValue(31 * 60 * 1000); + const fresh = yield* open("agent-new"); + expect(fresh.tabId).toBeDefined(); + expect(releasedDesktopTabs).toEqual([opened[0]]); + const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); + expect(sessions.map((session) => session.tabId).toSorted()).toEqual( + [...opened.slice(1), fresh.tabId!].toSorted(), + ); + }), + ).pipe(Effect.provide(layer)), +); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 6f3e86a7501ea..8a8b58ed06a19 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1284,19 +1284,27 @@ const make = Effect.gen(function* () { ); }; + const idleAgentTabs = () => { + const cutoff = Date.now() - AGENT_TAB_IDLE_MS; + return [...tabs.values()].filter( + (tab) => tab.control.agentId !== null && tab.viewers.size === 0 && tab.usedAt < cutoff, + ); + }; + // Desktop browsing bypasses streamed viewers and server input tracking, // so these idle signals only apply to headless tabs. const closeIdleAgentTabs = () => { - const cutoff = Date.now() - AGENT_TAB_IDLE_MS; - for (const tab of tabs.values()) { - if ( - tab.control.agentId !== null && - tab.desktop === null && - tab.viewers.size === 0 && - tab.usedAt < cutoff - ) - dropTab(tab, true); - } + for (const tab of idleAgentTabs()) if (tab.desktop === null) dropTab(tab, true); + }; + + // The server can't see whether someone is looking at a desktop tab, but the + // desktop app renders every server tab, so idle desktop agent tabs would + // otherwise hold the server's budget forever. They give up their slots, + // longest idle first, only when an agent needs one. + const freeIdleAgentSlot = () => { + if (tabs.size + pendingTabs.size < SERVER_TAB_LIMIT) return; + const longestIdle = idleAgentTabs().sort((left, right) => left.usedAt - right.usedAt)[0]; + if (longestIdle) dropTab(longestIdle, true); }; const latestThreadTab = (threadId: string, agentSessionId?: string) => { @@ -1793,6 +1801,7 @@ const make = Effect.gen(function* () { const profileId = existing ? undefined : resolveOpenProfile(open.profileId); if (!existing) { closeIdleAgentTabs(); + freeIdleAgentSlot(); assertTabCapacity(request.agentSessionId); } const tab = From f091bc49360027123f3cf9dab48a4479295f60bb Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Fri, 23 Oct 2026 17:40:07 +0000 Subject: [PATCH 2/5] fix(server): don't free a slot for an agent already at its own tab limit Co-Authored-By: Claude Opus 5.5 --- apps/server/src/preview/ServerBrowser.test.ts | 3 +++ apps/server/src/preview/ServerBrowser.ts | 16 +++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 0f95f4ca2a8f8..354c1e67520c1 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -1331,6 +1331,9 @@ it.live("a full server takes back the slot of the longest idle agent tab, even a expect(releasedDesktopTabs).toEqual([]); now.mockReturnValue(31 * 60 * 1000); + // A session at its own limit couldn't use a freed slot, so nothing closes for it. + expect(yield* open("agent-3").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); + expect(releasedDesktopTabs).toEqual([]); const fresh = yield* open("agent-new"); expect(fresh.tabId).toBeDefined(); expect(releasedDesktopTabs).toEqual([opened[0]]); diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 8a8b58ed06a19..1729aef465a82 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1271,10 +1271,12 @@ const make = Effect.gen(function* () { }); }); - const atTabLimit = (agentSessionId: string) => - tabs.size + pendingTabs.size >= SERVER_TAB_LIMIT || + const serverAtTabLimit = () => tabs.size + pendingTabs.size >= SERVER_TAB_LIMIT; + const agentAtTabLimit = (agentSessionId: string) => [...tabs.values()].filter((tab) => tab.control.agentId === agentSessionId).length >= - AGENT_TAB_LIMIT; + AGENT_TAB_LIMIT; + const atTabLimit = (agentSessionId: string) => + serverAtTabLimit() || agentAtTabLimit(agentSessionId); const assertTabCapacity = (agentSessionId: string) => { if (atTabLimit(agentSessionId)) @@ -1300,9 +1302,9 @@ const make = Effect.gen(function* () { // The server can't see whether someone is looking at a desktop tab, but the // desktop app renders every server tab, so idle desktop agent tabs would // otherwise hold the server's budget forever. They give up their slots, - // longest idle first, only when an agent needs one. - const freeIdleAgentSlot = () => { - if (tabs.size + pendingTabs.size < SERVER_TAB_LIMIT) return; + // longest idle first, only when an agent needs one and has room for it. + const freeIdleAgentSlot = (agentSessionId: string) => { + if (!serverAtTabLimit() || agentAtTabLimit(agentSessionId)) return; const longestIdle = idleAgentTabs().sort((left, right) => left.usedAt - right.usedAt)[0]; if (longestIdle) dropTab(longestIdle, true); }; @@ -1801,7 +1803,7 @@ const make = Effect.gen(function* () { const profileId = existing ? undefined : resolveOpenProfile(open.profileId); if (!existing) { closeIdleAgentTabs(); - freeIdleAgentSlot(); + freeIdleAgentSlot(request.agentSessionId); assertTabCapacity(request.agentSessionId); } const tab = From 8521005c8e58287554748934a545c9dc670696e6 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Thu, 29 Oct 2026 01:27:33 +0000 Subject: [PATCH 3/5] test(server): await idle tab eviction cleanup --- apps/server/src/preview/ServerBrowser.test.ts | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 354c1e67520c1..00f0879e5f3d1 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -336,7 +336,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); + desktopDetaches.emit("release", key.tabId); + }), ), pointer: () => Effect.void, turn: (threadId, active) => Effect.sync(() => desktopTurns.emit("turn", threadId, active)), @@ -1325,6 +1328,14 @@ it.live("a full server takes back the slot of the longest idle agent tab, even a opened.push((yield* open(`agent-${Math.floor(index / 8)}`)).tabId!); } + now.mockReturnValue(100); + yield* broker.invoke({ + scope: asSession("agent-observer"), + operation: "status", + tabId: PreviewTabId.make(opened[0]!), + input: {}, + }); + // Inside the idle window every desktop tab keeps its slot. now.mockReturnValue(29 * 60 * 1000); expect(yield* open("agent-new").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); @@ -1334,12 +1345,24 @@ it.live("a full server takes back the slot of the longest idle agent tab, even a // A session at its own limit couldn't use a freed slot, so nothing closes for it. expect(yield* open("agent-3").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); expect(releasedDesktopTabs).toEqual([]); + const events = yield* manager.subscribeEvents; + const releases = yield* Queue.unbounded(); + const recordRelease = (tabId: string) => Queue.offerUnsafe(releases, tabId); + desktopDetaches.on("release", recordRelease); + yield* Effect.addFinalizer(() => + Effect.sync(() => desktopDetaches.off("release", recordRelease)), + ); const fresh = yield* open("agent-new"); expect(fresh.tabId).toBeDefined(); - expect(releasedDesktopTabs).toEqual([opened[0]]); + expect(yield* Queue.take(releases)).toBe(opened[1]); + yield* Stream.fromSubscription(events).pipe( + Stream.filter((event) => event.type === "closed" && event.tabId === opened[1]), + Stream.runHead, + ); + expect(releasedDesktopTabs).toEqual([opened[1]]); const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); expect(sessions.map((session) => session.tabId).toSorted()).toEqual( - [...opened.slice(1), fresh.tabId!].toSorted(), + [...opened.filter((tabId) => tabId !== opened[1]), fresh.tabId!].toSorted(), ); }), ).pipe(Effect.provide(layer)), From df51c851ccc27ec72bb56537aacb751eb93e0c6f Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Mon, 2 Nov 2026 05:09:01 +0000 Subject: [PATCH 4/5] fix(server): reclaim only hidden idle agent tabs when the browser is full The desktop now reports which of its native tabs a window shows (the `shown` signal from #23589: renderer -> desktop host -> server channel), and reclamation skips tabs that are shown or have a streamed viewer. Co-Authored-By: Claude Opus 5.5 --- apps/desktop/src/ipc/channels.ts | 1 + apps/desktop/src/ipc/methods/preview.ts | 11 ++++++++++ apps/desktop/src/preload.ts | 2 ++ .../src/preview/DesktopBrowserHost.test.ts | 21 ++++++++++++++++++ .../desktop/src/preview/DesktopBrowserHost.ts | 22 ++++++++++++++++++- apps/desktop/src/preview/Manager.ts | 20 +++++++++++++++++ .../src/preview/DesktopBrowserChannel.ts | 12 ++++++++++ apps/server/src/preview/ServerBrowser.test.ts | 19 +++++++++++----- apps/server/src/preview/ServerBrowser.ts | 13 ++++++----- apps/web/src/browser/HostedBrowserWebview.tsx | 15 ++++++++++++- packages/contracts/src/desktopBrowser.ts | 2 ++ packages/contracts/src/ipc.ts | 7 ++++++ 12 files changed, 133 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 538dd06b0b42a..fb718ee230240 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -89,6 +89,7 @@ export const PREVIEW_SET_ZOOM_FACTOR_CHANNEL = "desktop:preview-set-zoom-factor" export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_SET_AUDIO_MUTED_CHANNEL = "desktop:preview-set-audio-muted"; +export const PREVIEW_SET_SHOWN_CHANNEL = "desktop:preview-set-shown"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 20b7bb4877d59..c4c33f6b8dca8 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -9,6 +9,7 @@ import { DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, DesktopPreviewSetAudioMutedInputSchema, + DesktopPreviewSetShownInputSchema, DesktopPreviewSetColorSchemeInputSchema, DesktopPreviewSetZoomFactorInputSchema, BrowserImportResult, @@ -193,6 +194,15 @@ const setAudioMuted = DesktopIpc.makeIpcMethod({ yield* manager.setAudioMuted(tabId, audioMuted); }), }); +const setShown = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_SHOWN_CHANNEL, + payload: DesktopPreviewSetShownInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setShown")(function* ({ tabId, shown }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setShown(tabId, shown); + }), +}); const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", @@ -432,6 +442,7 @@ export const methods = [ setColorScheme, setZoomFactor, setAudioMuted, + setShown, openDevTools, clearCookies, clearCache, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 950ed972c6453..94d45c0a0e29a 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -347,6 +347,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), setAudioMuted: (tabId, audioMuted) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_AUDIO_MUTED_CHANNEL, { tabId, audioMuted }), + setShown: (tabId, shown) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_SHOWN_CHANNEL, { tabId, shown }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), listBrowserImportSources: () => ipcRenderer.invoke(IpcChannels.PREVIEW_IMPORT_SOURCES_CHANNEL), diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 2e6e54a5293a7..7fa943890e2f1 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -144,6 +144,27 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("re-sends shown to a backend that starts later and when a tab attaches again", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + host.attach(key, makeDebuggee().tab); + host.setShown(key, true); + const reader = yield* takeEvents(host, 5).pipe(Effect.forkScoped); + + yield* Effect.yieldNow; + host.detach(key); + host.attach(key, makeDebuggee().tab); + + expect(yield* Fiber.join(reader)).toEqual([ + { type: "attached", ...key }, + { type: "shown", ...key, shown: true }, + { type: "detached", ...key }, + { type: "attached", ...key }, + { type: "shown", ...key, shown: true }, + ]); + }), + ); + it.effect("drops replies from a relay the server released", () => 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 3ac6c6d73caec..2e86fde5effe3 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -87,6 +87,7 @@ export class DesktopBrowserHost extends Context.Service< readonly handleCommandLine: (line: string) => Effect.Effect; readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; readonly detach: (key: DesktopBrowserTabKey) => void; + readonly setShown: (key: DesktopBrowserTabKey, shown: boolean) => void; readonly humanStartedDownload: (source: Electron.WebContents) => boolean; readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; readonly pointers: Stream.Stream<{ @@ -119,6 +120,9 @@ export const make = Effect.gen(function* () { const tabs = new Map(); /** Threads whose agent turn runs, by the server's account. */ const agentTurns = new Set(); + // Kept apart from attachment: the renderer can show a webview before its debugger attaches, + // and the server forgets `shown` on detach, for example while DevTools holds the debugger. + const shownTabs = new Set(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); const nextScreenshot = (tab: AttachedTab) => { @@ -456,6 +460,15 @@ export const make = Effect.gen(function* () { debuggee.debugger.on("message", tab.onMessage); debuggee.webContents.on("did-navigate", tab.onNewDocument); emit({ type: "attached", ...key }); + if (shownTabs.has(id)) emit({ type: "shown", ...key, shown: true }); + }; + + const setShown = (key: DesktopBrowserTabKey, shown: boolean) => { + const id = keyOf(key); + if (shownTabs.has(id) === shown) return; + if (shown) shownTabs.add(id); + else shownTabs.delete(id); + if (tabs.has(id)) emit({ type: "shown", ...key, shown }); }; const handleCommandLine = (line: string) => @@ -494,7 +507,13 @@ export const make = Effect.gen(function* () { [...tabs.values()], (tab) => { dropRelay(tab); - return PubSub.publish(outbox, { type: "attached", ...tab.key }); + return PubSub.publish(outbox, { type: "attached", ...tab.key }).pipe( + Effect.andThen( + shownTabs.has(keyOf(tab.key)) + ? PubSub.publish(outbox, { type: "shown", ...tab.key, shown: true }) + : Effect.void, + ), + ); }, { discard: true }, ); @@ -514,6 +533,7 @@ export const make = Effect.gen(function* () { handleCommandLine, attach, detach, + setShown, placeDownload, humanStartedDownload, }); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 0087444a8412c..c88ec102e4f71 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1685,6 +1685,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); if (Option.isNone(tab)) return; const closedTab = tab.value; + if (closedTab.serverTab) browserHost.setShown(closedTab.serverTab, false); if (closedTab.webContentsId != null) { yield* Effect.all( [ @@ -1893,6 +1894,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* rendererHistory.register(wc, { surface: "preview" }); const webContentsId = wc.id; wc.once("destroyed", () => { + runFork(hideDestroyedGuest(webContentsId)); runFork(detachControlSession(webContentsId)); }); yield* ensureControlSession(wc).pipe( @@ -2346,6 +2348,21 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* applyColorScheme(tabId, wc, colorScheme); }); + const setShown = Effect.fn("PreviewManager.setShown")(function* (tabId: string, shown: boolean) { + const serverTab = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.serverTab; + if (serverTab) browserHost.setShown(serverTab, shown); + }); + + // A window reload or guest crash destroys the guest without the renderer reporting it hidden. + // The renderer reports again once a new guest registers. + const hideDestroyedGuest = Effect.fnUntraced(function* (webContentsId: number) { + for (const tab of (yield* SynchronizedRef.get(tabsRef)).values()) { + if (tab.webContentsId === webContentsId && tab.serverTab) { + browserHost.setShown(tab.serverTab, false); + } + } + }); + const setAudioMuted = Effect.fn("PreviewManager.setAudioMuted")(function* ( tabId: string, audioMuted: boolean, @@ -3307,6 +3324,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setAnnotationTheme, setAnnotationSendEnabled, setAudioMuted, + setShown, setColorScheme, setMainWindow, setForwardedShortcuts: (shortcuts: ReadonlyArray) => @@ -3512,6 +3530,7 @@ export class PreviewManager extends Context.Service< tabId: string, audioMuted: boolean, ) => Effect.Effect; + readonly setShown: (tabId: string, shown: boolean) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: ( partitions?: ReadonlyArray, @@ -3652,6 +3671,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { setColorScheme: operations.setColorScheme, setZoomFactor: operations.setZoomFactor, setAudioMuted: operations.setAudioMuted, + setShown: operations.setShown, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* (partitions) { yield* browserSession diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index d407fc78faf57..9805d0d77acfe 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -44,6 +44,8 @@ export class DesktopBrowserChannel extends Context.Service< readonly detached: Stream.Stream; readonly attached: Stream.Stream; readonly isAttached: (key: DesktopTabKey) => Effect.Effect; + /** Whether a desktop window draws the tab on screen, which no streamed viewer counts. */ + readonly isShown: (key: DesktopTabKey) => boolean; readonly endpoint: (key: DesktopTabKey) => Effect.Effect; readonly pointer: ( key: DesktopTabKey, @@ -60,6 +62,8 @@ const make = Effect.gen(function* () { const controlFd = config.desktopBrowserControlFd; const changes = yield* PubSub.unbounded<{ key: DesktopTabKey; attached: boolean }>(); const attachedTabs = new Set(); + // Only attached tabs count: the desktop re-sends `shown` when a tab attaches again. + const shownTabs = new Set(); const inbound = new Map>(); const writeLock = yield* Semaphore.make(1); @@ -83,6 +87,7 @@ const make = Effect.gen(function* () { pointer: Effect.fn("DesktopBrowserChannel.pointer")(function () { return Effect.void; }), + isShown: () => false, }); } @@ -123,14 +128,20 @@ const make = Effect.gen(function* () { return PubSub.publish(changes, { key, attached: true }); case "detached": { attachedTabs.delete(id); + shownTabs.delete(id); const queue = inbound.get(id); return (queue ? Queue.shutdown(queue) : Effect.void).pipe( Effect.andThen(PubSub.publish(changes, { key, attached: false })), ); } + case "shown": + if (event.shown) shownTabs.add(id); + else shownTabs.delete(id); + return Effect.void; } }), Effect.catchCause((cause) => Effect.logWarning("desktop browser channel stopped", { cause })), + Effect.ensuring(Effect.sync(() => shownTabs.clear())), Effect.forkScoped, ); @@ -221,6 +232,7 @@ const make = Effect.gen(function* () { isAttached: Effect.fn("DesktopBrowserChannel.isAttached")(function (key) { return Effect.sync(() => attachedTabs.has(keyOf(key))); }), + isShown: (key) => shownTabs.has(keyOf(key)), endpoint, turn: Effect.fn("DesktopBrowserChannel.turn")(function (threadId, active) { return command({ type: "turn", threadId, active }); diff --git a/apps/server/src/preview/ServerBrowser.test.ts b/apps/server/src/preview/ServerBrowser.test.ts index 6f163111491a4..7d35ef0b3beb6 100644 --- a/apps/server/src/preview/ServerBrowser.test.ts +++ b/apps/server/src/preview/ServerBrowser.test.ts @@ -313,6 +313,7 @@ const desktopRenders = (tabId: string) => { } return desktopTabs.has(tabId); }; +const shownDesktopTabs = new Set(); const releasedDesktopTabs: Array = []; const desktopTurns = new NodeEvents.EventEmitter(); const desktopConnections: Array<{ endpoint: string; context: ReturnType }> = []; @@ -371,6 +372,7 @@ const dependencies = Layer.mergeAll( ), ), isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)), + isShown: (key) => shownDesktopTabs.has(key.tabId), endpoint: (key) => Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () => Effect.sync(() => { @@ -447,6 +449,7 @@ beforeEach(() => { keyCopy = ""; focusedFrame = null; desktopTabs.clear(); + shownDesktopTabs.clear(); desktopRendersNext = false; desktopPageUrl = "about:blank"; desktopSizeAnswer = null; @@ -2226,7 +2229,7 @@ it.live("failed session and browser launches release capacity, as does closing a ).pipe(Effect.provide(layer)), ); -it.live("a full server takes back the slot of the longest idle agent tab, even a desktop one", () => +it.live("a full server takes back the slot of the longest idle agent tab nobody views", () => Effect.scoped( Effect.gen(function* () { const broker = yield* Broker.PreviewAutomationBroker; @@ -2266,6 +2269,12 @@ it.live("a full server takes back the slot of the longest idle agent tab, even a // A session at its own limit couldn't use a freed slot, so nothing closes for it. expect(yield* open("agent-3").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); expect(releasedDesktopTabs).toEqual([]); + // Tabs a desktop window shows keep their slots however long they sit idle. + for (const tabId of opened) shownDesktopTabs.add(tabId); + expect(yield* open("agent-new").pipe(Effect.flip)).toMatchObject({ reason: "tabLimit" }); + expect(releasedDesktopTabs).toEqual([]); + shownDesktopTabs.clear(); + shownDesktopTabs.add(opened[1]!); const events = yield* manager.subscribeEvents; const releases = yield* Queue.unbounded(); const recordRelease = (tabId: string) => Queue.offerUnsafe(releases, tabId); @@ -2275,15 +2284,15 @@ it.live("a full server takes back the slot of the longest idle agent tab, even a ); const fresh = yield* open("agent-new"); expect(fresh.tabId).toBeDefined(); - expect(yield* Queue.take(releases)).toBe(opened[1]); + expect(yield* Queue.take(releases)).toBe(opened[2]); yield* Stream.fromSubscription(events).pipe( - Stream.filter((event) => event.type === "closed" && event.tabId === opened[1]), + Stream.filter((event) => event.type === "closed" && event.tabId === opened[2]), Stream.runHead, ); - expect(releasedDesktopTabs).toEqual([opened[1]]); + expect(releasedDesktopTabs).toEqual([opened[2]]); const { sessions } = yield* manager.list({ threadId: scope.thread.threadId }); expect(sessions.map((session) => session.tabId).toSorted()).toEqual( - [...opened.filter((tabId) => tabId !== opened[1]), fresh.tabId!].toSorted(), + [...opened.filter((tabId) => tabId !== opened[2]), fresh.tabId!].toSorted(), ); }), ).pipe(Effect.provide(layer)), diff --git a/apps/server/src/preview/ServerBrowser.ts b/apps/server/src/preview/ServerBrowser.ts index 71cdfdce9a976..77a0b30d8fe7e 100644 --- a/apps/server/src/preview/ServerBrowser.ts +++ b/apps/server/src/preview/ServerBrowser.ts @@ -1393,17 +1393,20 @@ const make = Effect.gen(function* () { ); }; + // The desktop draws its tabs in its own webview, so they never get a streamed viewer. + const isVisible = (tab: ServerTab) => + tab.viewers.size > 0 || (tab.desktop !== null && desktopChannel.isShown(tab)); + const idleAgentTabs = () => { const cutoff = Date.now() - AGENT_TAB_IDLE_MS; return [...tabs.values()].filter( - (tab) => tab.control.agentId !== null && tab.viewers.size === 0 && tab.usedAt < cutoff, + (tab) => tab.control.agentId !== null && !isVisible(tab) && tab.usedAt < cutoff, ); }; - // The server can't see whether someone is looking at a desktop tab, but the - // desktop app renders every server tab, so idle desktop agent tabs would - // otherwise hold the server's budget forever. They give up their slots, - // longest idle first, only when an agent needs one and has room for it. + // The desktop app renders every server tab, so idle desktop agent tabs would otherwise + // hold the server's budget forever. Hidden ones give up their slots, longest idle first, + // only when an agent needs one and has room for it. const freeIdleAgentSlot = (agentSessionId: string) => { if (!serverAtTabLimit() || agentAtTabLimit(agentSessionId)) return; const longestIdle = idleAgentTabs().sort((left, right) => left.usedAt - right.usedAt)[0]; diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 70eef380dbff9..c68c5ee99e295 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -135,6 +135,9 @@ export function HostedBrowserWebview(props: { const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); + const active = presentation.visible && presentation.rect !== null; + const shown = active || pictureInPicture; + const reportedShown = useEffectEvent(() => serverDriven && shown); const remountCrashedWebview = useEffectEvent(() => { setRecoverySrc(initialUrl ?? initialSrc); setWebviewGeneration((generation) => generation + 1); @@ -162,6 +165,9 @@ export function HostedBrowserWebview(props: { const webContentsId = webview.getWebContentsId(); if (Number.isInteger(webContentsId) && webContentsId > 0) { await bridge.registerWebview(runtimeTabId, webContentsId); + if (reportedShown()) { + withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, true)); + } } } catch {} })(); @@ -203,7 +209,6 @@ export function HostedBrowserWebview(props: { webviewGeneration, ]); - const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; const layoutZoomFactor = useBrowserLayoutZoomFactor(zoomFactor); const viewportWidth = viewport._tag === "fill" ? null : viewport.width; @@ -289,6 +294,14 @@ export function HostedBrowserWebview(props: { viewportWidth, ]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Report the mounted webview's visibility to the desktop host. + useEffect(() => { + const bridge = window.desktopBridge?.preview; + if (!clientSettingsHydrated || !bridge || !serverDriven || !shown) return; + withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, true)); + return () => withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, false)); + }, [clientSettingsHydrated, runtimeTabId, serverDriven, shown]); + if (!clientSettingsHydrated || !config) return null; const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; diff --git a/packages/contracts/src/desktopBrowser.ts b/packages/contracts/src/desktopBrowser.ts index 17d256f0d8d3e..52fb66885adfe 100644 --- a/packages/contracts/src/desktopBrowser.ts +++ b/packages/contracts/src/desktopBrowser.ts @@ -12,6 +12,8 @@ export const DesktopBrowserEvent = Schema.Union([ /** Its `` went away: closed, crashed, swapped, or devtools took the debugger. */ Schema.Struct({ type: Schema.Literal("detached"), ...TabKey }), Schema.Struct({ type: Schema.Literal("cdp"), ...TabKey, message: Schema.String }), + /** Its `` started or stopped being on screen in a desktop window. */ + Schema.Struct({ type: Schema.Literal("shown"), ...TabKey, shown: Schema.Boolean }), ]); export type DesktopBrowserEvent = typeof DesktopBrowserEvent.Type; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 59f8fd12c3f1a..b3c5b4983f803 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -993,6 +993,11 @@ export const DesktopPreviewSetAudioMutedInputSchema = Schema.Struct({ audioMuted: Schema.Boolean, }); +export const DesktopPreviewSetShownInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + shown: Schema.Boolean, +}); + export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ theme: DesktopPreviewAnnotationThemeSchema, }); @@ -1156,6 +1161,8 @@ export interface DesktopPreviewBridge { hardReload: (tabId: string) => Promise; setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise; setAudioMuted: (tabId: string, audioMuted: boolean) => Promise; + /** Tells the tab's server whether its webview is on screen. */ + setShown: (tabId: string, shown: boolean) => Promise; openDevTools: (tabId: string) => Promise; clearCookies: (environmentId: EnvironmentId, profileId?: string) => Promise; clearCache: (environmentId: EnvironmentId, profileId?: string) => Promise; From 0aa0f8f9a24315d1191639dadc21522535fbe3d7 Mon Sep 17 00:00:00 2001 From: PR Batch Tester Date: Mon, 2 Nov 2026 06:44:08 +0000 Subject: [PATCH 5/5] fix(desktop): protect tabs until their visibility is known --- .../src/preview/DesktopBrowserHost.test.ts | 17 +++++ .../desktop/src/preview/DesktopBrowserHost.ts | 23 ++++--- apps/desktop/src/preview/Manager.ts | 2 +- .../src/preview/DesktopBrowserChannel.test.ts | 67 +++++++++++++++++++ .../src/preview/DesktopBrowserChannel.ts | 9 ++- apps/web/src/browser/HostedBrowserWebview.tsx | 8 +-- 6 files changed, 106 insertions(+), 20 deletions(-) create mode 100644 apps/server/src/preview/DesktopBrowserChannel.test.ts diff --git a/apps/desktop/src/preview/DesktopBrowserHost.test.ts b/apps/desktop/src/preview/DesktopBrowserHost.test.ts index 7fa943890e2f1..b2a3886f87668 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.test.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.test.ts @@ -165,6 +165,23 @@ describe("DesktopBrowserHost", () => { }), ); + it.effect("replays an explicit hidden report before announcing another tab", () => + Effect.gen(function* () { + const host = yield* DesktopBrowserHost.make; + const other = { ...key, tabId: "tab-2" }; + host.setShown(key, false); + host.attach(key, makeDebuggee().tab); + host.setShown(other, true); + host.attach(other, makeDebuggee().tab); + + expect(yield* takeEvents(host, 3)).toEqual([ + { type: "attached", ...key }, + { type: "shown", ...key, shown: false }, + { type: "attached", ...other }, + ]); + }), + ); + it.effect("drops replies from a relay the server released", () => 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 2e86fde5effe3..80a23417b7163 100644 --- a/apps/desktop/src/preview/DesktopBrowserHost.ts +++ b/apps/desktop/src/preview/DesktopBrowserHost.ts @@ -87,7 +87,7 @@ export class DesktopBrowserHost extends Context.Service< readonly handleCommandLine: (line: string) => Effect.Effect; readonly attach: (key: DesktopBrowserTabKey, debuggee: DesktopBrowserTabDebugger) => void; readonly detach: (key: DesktopBrowserTabKey) => void; - readonly setShown: (key: DesktopBrowserTabKey, shown: boolean) => void; + readonly setShown: (key: DesktopBrowserTabKey, shown: boolean | undefined) => void; readonly humanStartedDownload: (source: Electron.WebContents) => boolean; readonly placeDownload: (source: Electron.WebContents, item: Electron.DownloadItem) => boolean; readonly pointers: Stream.Stream<{ @@ -122,7 +122,7 @@ export const make = Effect.gen(function* () { const agentTurns = new Set(); // Kept apart from attachment: the renderer can show a webview before its debugger attaches, // and the server forgets `shown` on detach, for example while DevTools holds the debugger. - const shownTabs = new Set(); + const shownTabs = new Map(); const emit = (event: DesktopBrowserEventType) => runFork(PubSub.publish(outbox, event)); const nextScreenshot = (tab: AttachedTab) => { @@ -460,14 +460,18 @@ export const make = Effect.gen(function* () { debuggee.debugger.on("message", tab.onMessage); debuggee.webContents.on("did-navigate", tab.onNewDocument); emit({ type: "attached", ...key }); - if (shownTabs.has(id)) emit({ type: "shown", ...key, shown: true }); + const shown = shownTabs.get(id); + if (shown !== undefined) emit({ type: "shown", ...key, shown }); }; - const setShown = (key: DesktopBrowserTabKey, shown: boolean) => { + const setShown = (key: DesktopBrowserTabKey, shown: boolean | undefined) => { const id = keyOf(key); - if (shownTabs.has(id) === shown) return; - if (shown) shownTabs.add(id); - else shownTabs.delete(id); + if (shown === undefined) { + shownTabs.delete(id); + return; + } + if (shownTabs.get(id) === shown) return; + shownTabs.set(id, shown); if (tabs.has(id)) emit({ type: "shown", ...key, shown }); }; @@ -507,10 +511,11 @@ export const make = Effect.gen(function* () { [...tabs.values()], (tab) => { dropRelay(tab); + const shown = shownTabs.get(keyOf(tab.key)); return PubSub.publish(outbox, { type: "attached", ...tab.key }).pipe( Effect.andThen( - shownTabs.has(keyOf(tab.key)) - ? PubSub.publish(outbox, { type: "shown", ...tab.key, shown: true }) + shown !== undefined + ? PubSub.publish(outbox, { type: "shown", ...tab.key, shown }) : Effect.void, ), ); diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index c88ec102e4f71..2d9bec73fdaa8 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -1685,7 +1685,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function }); if (Option.isNone(tab)) return; const closedTab = tab.value; - if (closedTab.serverTab) browserHost.setShown(closedTab.serverTab, false); + if (closedTab.serverTab) browserHost.setShown(closedTab.serverTab, undefined); if (closedTab.webContentsId != null) { yield* Effect.all( [ diff --git a/apps/server/src/preview/DesktopBrowserChannel.test.ts b/apps/server/src/preview/DesktopBrowserChannel.test.ts new file mode 100644 index 0000000000000..ab5464cc4f6f7 --- /dev/null +++ b/apps/server/src/preview/DesktopBrowserChannel.test.ts @@ -0,0 +1,67 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeStream from "@effect/platform-node/NodeStream"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { vi } from "vite-plus/test"; + +import * as ServerConfig from "../config.ts"; +import * as DesktopBrowserChannel from "./DesktopBrowserChannel.ts"; + +vi.mock("@effect/platform-node/NodeStream", () => ({ fromReadable: vi.fn() })); +vi.mock("node:net", () => ({ + Socket: class { + destroy() {} + }, +})); + +it.effect("protects unknown desktop visibility until an explicit hidden report arrives", () => + Effect.gen(function* () { + const input = yield* Queue.unbounded(); + vi.mocked(NodeStream.fromReadable).mockReturnValue(Stream.fromQueue(input)); + const config = yield* ServerConfig.ServerConfig; + yield* Effect.gen(function* () { + const channel = yield* DesktopBrowserChannel.DesktopBrowserChannel; + const key = { threadId: "thread-1", tabId: "tab-1" }; + const send = (event: object) => + Queue.offer(input, new TextEncoder().encode(`${JSON.stringify(event)}\n`)); + + expect(channel.isShown(key)).toBe(true); + yield* send({ type: "attached", ...key }); + yield* channel.awaitAttached(key, "5 seconds"); + expect(channel.isShown(key)).toBe(true); + + yield* send({ type: "shown", ...key, shown: false }); + yield* send({ type: "attached", threadId: key.threadId, tabId: "sentinel-1" }); + yield* channel.awaitAttached({ threadId: key.threadId, tabId: "sentinel-1" }, "5 seconds"); + expect(channel.isShown(key)).toBe(false); + + yield* send({ type: "shown", ...key, shown: true }); + yield* send({ type: "attached", threadId: key.threadId, tabId: "sentinel-2" }); + yield* channel.awaitAttached({ threadId: key.threadId, tabId: "sentinel-2" }, "5 seconds"); + expect(channel.isShown(key)).toBe(true); + + yield* send({ type: "shown", ...key, shown: false }); + yield* send({ type: "detached", ...key }); + yield* send({ type: "attached", threadId: key.threadId, tabId: "sentinel-3" }); + yield* channel.awaitAttached({ threadId: key.threadId, tabId: "sentinel-3" }, "5 seconds"); + expect(channel.isShown(key)).toBe(true); + }).pipe( + Effect.provide( + DesktopBrowserChannel.layer.pipe( + Layer.provide( + ServerConfig.layer({ ...config, desktopBrowserFd: 41, desktopBrowserControlFd: 42 }), + ), + ), + ), + ); + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "desktop-browser-channel-" }).pipe( + Layer.provide(NodeServices.layer), + ), + ), + ), +); diff --git a/apps/server/src/preview/DesktopBrowserChannel.ts b/apps/server/src/preview/DesktopBrowserChannel.ts index 9805d0d77acfe..65129274206da 100644 --- a/apps/server/src/preview/DesktopBrowserChannel.ts +++ b/apps/server/src/preview/DesktopBrowserChannel.ts @@ -62,8 +62,8 @@ const make = Effect.gen(function* () { const controlFd = config.desktopBrowserControlFd; const changes = yield* PubSub.unbounded<{ key: DesktopTabKey; attached: boolean }>(); const attachedTabs = new Set(); - // Only attached tabs count: the desktop re-sends `shown` when a tab attaches again. - const shownTabs = new Set(); + // Missing reports protect desktop tabs until the renderer explicitly marks them hidden. + const shownTabs = new Map(); const inbound = new Map>(); const writeLock = yield* Semaphore.make(1); @@ -135,8 +135,7 @@ const make = Effect.gen(function* () { ); } case "shown": - if (event.shown) shownTabs.add(id); - else shownTabs.delete(id); + shownTabs.set(id, event.shown); return Effect.void; } }), @@ -232,7 +231,7 @@ const make = Effect.gen(function* () { isAttached: Effect.fn("DesktopBrowserChannel.isAttached")(function (key) { return Effect.sync(() => attachedTabs.has(keyOf(key))); }), - isShown: (key) => shownTabs.has(keyOf(key)), + isShown: (key) => shownTabs.get(keyOf(key)) ?? true, endpoint, turn: Effect.fn("DesktopBrowserChannel.turn")(function (threadId, active) { return command({ type: "turn", threadId, active }); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index c68c5ee99e295..34c7978b7b40b 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -165,9 +165,7 @@ export function HostedBrowserWebview(props: { const webContentsId = webview.getWebContentsId(); if (Number.isInteger(webContentsId) && webContentsId > 0) { await bridge.registerWebview(runtimeTabId, webContentsId); - if (reportedShown()) { - withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, true)); - } + withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, reportedShown())); } } catch {} })(); @@ -297,8 +295,8 @@ export function HostedBrowserWebview(props: { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Report the mounted webview's visibility to the desktop host. useEffect(() => { const bridge = window.desktopBridge?.preview; - if (!clientSettingsHydrated || !bridge || !serverDriven || !shown) return; - withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, true)); + if (!clientSettingsHydrated || !bridge || !serverDriven) return; + withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, shown)); return () => withDesktopTab(runtimeTabId, () => bridge.setShown(runtimeTabId, false)); }, [clientSettingsHydrated, runtimeTabId, serverDriven, shown]);