diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index 0e07c06ebf3..3c8f8a6b7b6 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -274,6 +274,37 @@ sudo systemctl daemon-reload sudo systemctl enable --now orca-xvfb.service orca-serve.service ``` +## Browser Tab Renderer Reclamation + +Each browser page on a headless server is backed by its own hidden Electron +window, so it costs a renderer process — several hundred MB, plus continuous +CPU if the page animates. Agents open pages far more readily than they close +them, so the server reclaims those renderers itself rather than relying on the +agent to tidy up. + +An open page whose renderer has been reclaimed is **parked**: the page keeps +its id, address, worktree and profile, `orca tab list` still shows it (marked +`(parked)`, and `parked: true` under `--json`), and the next command that +targets it transparently rebuilds the renderer and reloads the address. What a +park does _not_ preserve is in-page JavaScript state — cookies and local +storage live in the profile partition and survive, but an unsubmitted form or +an SPA's in-memory state does not. A page is never parked while a paired +client is streaming it or while a command is in flight. + +Two evictors decide what stays resident, mirroring terminal pane parking. The +cap is the primary one: + +| Variable | Default | Meaning | +| -------------------------------------- | -------- | ------------------------------------------------------------------ | +| `ORCA_HEADLESS_BROWSER_RESIDENT_LIMIT` | `4` | Renderers kept resident. Least-recently-used pages park past this. | +| `ORCA_HEADLESS_BROWSER_PARK_IDLE_MS` | `300000` | A page parks after this long untouched, even under the cap. | +| `ORCA_HEADLESS_BROWSER_PARK_GRACE_MS` | `30000` | A page is never parked this soon after its last command. | +| `ORCA_HEADLESS_BROWSER_PARK_SWEEP_MS` | `15000` | How often the server re-evaluates residency. | + +Raise the limit on a host with memory to spare, or set both the limit and the +idle window very high to keep every page resident — at the cost this +reclamation exists to avoid. + ## CLI Install Note On a headless host, you do not need to open the desktop UI just to run the @@ -838,7 +869,7 @@ refuse to run there and print the command to run on the machine you want. - Clients cannot connect: make sure `--pairing-address` is an address reachable from the client, and make sure firewalls allow the selected `--port`. - Journal shows `Another Orca instance is already running for this userData - profile` and the unit exits `3`: another process already owns the profile, so +profile` and the unit exits `3`: another process already owns the profile, so `RestartPreventExitStatus=3` leaves the unit `failed` on purpose. Find the owner with `systemctl status orca-serve` and `pgrep -af orca`. Stop it (or keep it and leave the unit down), then run diff --git a/src/cli/browser-format.ts b/src/cli/browser-format.ts index 76fdc18db75..aec5673da05 100644 --- a/src/cli/browser-format.ts +++ b/src/cli/browser-format.ts @@ -34,7 +34,11 @@ export function formatTabListWithProfiles( .map((t) => { const marker = t.active ? '* ' : ' ' const profile = showProfile ? ` [${t.profileLabel ?? t.profileId ?? 'Unknown'}]` : '' - return `${marker}[${t.index}] ${t.browserPageId} ${t.title} — ${t.url}${profile}` + // Why (STA-4341): a parked tab is still open and still operable — the + // next command targeting it wakes it — but its renderer has been + // reclaimed, so in-page JavaScript state did not survive. + const parked = t.parked ? ' (parked)' : '' + return `${marker}[${t.index}] ${t.browserPageId} ${t.title} — ${t.url}${profile}${parked}` }) .join('\n') } diff --git a/src/main/browser/agent-browser-bridge.ts b/src/main/browser/agent-browser-bridge.ts index 565352d3d50..4ed53c17c09 100644 --- a/src/main/browser/agent-browser-bridge.ts +++ b/src/main/browser/agent-browser-bridge.ts @@ -708,6 +708,12 @@ export class AgentBrowserBridge { // ── Worktree-scoped tab queries ── + // Why (STA-4341): the headless reclaimer must never park a page that is + // mid-command; the per-session queue already knows when one is executing. + hasInFlightCommand(browserPageId: string): boolean { + return this.processingQueues.has(`orca-tab-${browserPageId}`) + } + getRegisteredTabs(worktreeId?: string): Map { const all = this.browserManager.getWebContentsIdByTabId() if (!worktreeId) { diff --git a/src/main/browser/browser-backend.ts b/src/main/browser/browser-backend.ts index 939684e49b3..4c2cfd60d17 100644 --- a/src/main/browser/browser-backend.ts +++ b/src/main/browser/browser-backend.ts @@ -13,6 +13,15 @@ export type BrowserBackendCreateTab = { browserPageId?: string } +/** A page whose renderer has been reclaimed but whose identity is retained. */ +export type ParkedBrowserPage = { + browserPageId: string + worktreeId?: string + profileId?: string + url: string + title: string +} + export type BrowserBackend = { /** Create a browser page and register its WebContents. Returns the page id. */ createTab(params: BrowserBackendCreateTab): Promise<{ browserPageId: string }> @@ -21,4 +30,15 @@ export type BrowserBackend = { /** Tear down every page this backend owns (process shutdown). Optional — * renderer-hosted backends are torn down with their window. */ destroyAll?(): void + /** Make a page's renderer resident and restart its reclaim clock. Cheap and + * idempotent for an already-resident page, so command routing calls it + * unconditionally. Resolves false when the page id is unknown. Optional — + * only the offscreen backend owns renderer lifetime; renderer-hosted pages + * are parked by the window that hosts them. */ + wakeTab?(browserPageId: string): Promise + /** Pages this backend still owns whose renderer is currently reclaimed. */ + listParkedPages?(worktreeId?: string): ParkedBrowserPage[] + /** Most-recently-used parked page, for commands that target a worktree + * rather than a page id. */ + getMostRecentlyUsedParkedPageId?(worktreeId?: string): string | null } diff --git a/src/main/browser/offscreen-browser-backend-reclaim.test.ts b/src/main/browser/offscreen-browser-backend-reclaim.test.ts new file mode 100644 index 00000000000..7ab7fcc9445 --- /dev/null +++ b/src/main/browser/offscreen-browser-backend-reclaim.test.ts @@ -0,0 +1,337 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { BrowserWindow } from 'electron' +import type { AgentBrowserBridge } from './agent-browser-bridge' +import type { BrowserManager } from './browser-manager' + +const createOffscreenBrowserWindow = vi.fn<(partition: string) => unknown>() +const loadOffscreenBrowserUrl = vi.fn<(win: unknown, url: string) => Promise>(async () => {}) + +vi.mock('./offscreen-browser-window', () => ({ + createOffscreenBrowserWindow: (partition: string) => createOffscreenBrowserWindow(partition), + loadOffscreenBrowserUrl: (win: unknown, url: string) => loadOffscreenBrowserUrl(win, url) +})) + +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + getProfile: (id: string) => ({ id, partition: `persist:${id}`, label: id }), + getDefaultProfile: () => ({ id: 'default', partition: 'persist:default', label: 'Default' }) + } +})) + +const { OffscreenBrowserBackend } = await import('./offscreen-browser-backend') + +type FakeWindow = BrowserWindow & { + __id: number + __destroyed: boolean + __url: string + __destroyedListeners: (() => void)[] +} + +let nextWebContentsId = 100 + +function makeWindow(): FakeWindow { + const id = nextWebContentsId++ + const win = { + __id: id, + __destroyed: false, + __url: 'about:blank', + __destroyedListeners: [] as (() => void)[], + isDestroyed: () => win.__destroyed, + destroy: () => { + win.__destroyed = true + for (const listener of win.__destroyedListeners) { + listener() + } + }, + webContents: { + id, + isDestroyed: () => win.__destroyed, + getURL: () => win.__url, + getTitle: () => `title-${id}`, + once: (event: string, listener: () => void) => { + if (event === 'destroyed') { + win.__destroyedListeners.push(listener) + } + } + } + } as unknown as FakeWindow + return win +} + +type Harness = { + backend: InstanceType + manager: BrowserManager + bridge: AgentBrowserBridge + order: string[] + registered: Map + windows: FakeWindow[] + clock: { value: number } +} + +function createHarness(overrides: { pinned?: Set } = {}): Harness { + const order: string[] = [] + const registered = new Map() + const windows: FakeWindow[] = [] + const clock = { value: 1_000_000 } + + createOffscreenBrowserWindow.mockImplementation(() => { + const win = makeWindow() + windows.push(win) + return win + }) + + const manager = { + registerOffscreenGuest: ({ + browserPageId, + webContentsId + }: { + browserPageId: string + webContentsId: number + }) => { + order.push(`register:${browserPageId}:${webContentsId}`) + registered.set(browserPageId, webContentsId) + }, + unregisterGuest: (browserPageId: string) => { + order.push(`unregister:${browserPageId}`) + registered.delete(browserPageId) + }, + getGuestWebContentsId: (browserPageId: string) => registered.get(browserPageId) ?? null + } as unknown as BrowserManager + + const bridge = { + onTabClosed: vi.fn(async (webContentsId: number) => { + order.push(`session-destroy:${webContentsId}`) + }), + onProcessSwap: vi.fn(async (browserPageId: string, webContentsId: number) => { + order.push(`process-swap:${browserPageId}:${webContentsId}`) + }) + } as unknown as AgentBrowserBridge + + const backend = new OffscreenBrowserBackend(manager, { + getAgentBrowserBridge: () => bridge, + isPagePinned: (id) => overrides.pinned?.has(id) === true, + now: () => clock.value + }) + + return { backend, manager, bridge, order, registered, windows, clock } +} + +beforeEach(() => { + vi.clearAllMocks() + nextWebContentsId = 100 + process.env.ORCA_HEADLESS_BROWSER_RESIDENT_LIMIT = '2' + process.env.ORCA_HEADLESS_BROWSER_PARK_IDLE_MS = '60000' + process.env.ORCA_HEADLESS_BROWSER_PARK_GRACE_MS = '5000' + process.env.ORCA_HEADLESS_BROWSER_PARK_SWEEP_MS = '100000' +}) + +describe('OffscreenBrowserBackend reclamation', () => { + it('parks an idle page: renderer destroyed, page kept and listed', async () => { + const h = createHarness() + await h.backend.createTab({ url: 'https://example.test/a' }) + const [pageId] = [...h.registered.keys()] + + h.clock.value += 120_000 + expect(await h.backend.reclaimIdlePages()).toEqual([pageId]) + + expect(h.windows[0].isDestroyed()).toBe(true) + expect(h.registered.has(pageId)).toBe(false) + expect(h.backend.listParkedPages()).toEqual([ + { + browserPageId: pageId, + worktreeId: undefined, + profileId: 'default', + url: 'about:blank', + title: `title-${h.windows[0].webContents.id}` + } + ]) + }) + + it('tears the helper session down before the mapping and the renderer go away', async () => { + // Why (STA-4341): the headless close path used to skip the bridge entirely, + // so every closed page left its agent-browser session and CDP proxy behind. + const h = createHarness() + await h.backend.createTab({ url: 'https://example.test/a' }) + const [pageId] = [...h.registered.keys()] + h.order.length = 0 + + await h.backend.closeTab(pageId) + + expect(h.order).toEqual([ + `session-destroy:${h.windows[0].webContents.id}`, + `unregister:${pageId}` + ]) + expect(h.windows[0].isDestroyed()).toBe(true) + }) + + it('tears the helper session down when parking too', async () => { + const h = createHarness() + await h.backend.createTab({ url: 'https://example.test/a' }) + const [pageId] = [...h.registered.keys()] + h.order.length = 0 + + h.clock.value += 120_000 + await h.backend.reclaimIdlePages() + + expect(h.order).toEqual([ + `session-destroy:${h.windows[0].webContents.id}`, + `unregister:${pageId}` + ]) + }) + + it('wakes a parked page under the same id and reloads where it left off', async () => { + const h = createHarness() + await h.backend.createTab({ url: 'https://example.test/a' }) + const [pageId] = [...h.registered.keys()] + h.windows[0].__url = 'https://example.test/moved' + + h.clock.value += 120_000 + await h.backend.reclaimIdlePages() + h.order.length = 0 + loadOffscreenBrowserUrl.mockClear() + + expect(await h.backend.wakeTab(pageId)).toBe(true) + + const wokenId = h.windows[1].webContents.id + expect(h.windows).toHaveLength(2) + expect(h.registered.get(pageId)).toBe(wokenId) + expect(h.order).toEqual([`register:${pageId}:${wokenId}`, `process-swap:${pageId}:${wokenId}`]) + expect(loadOffscreenBrowserUrl).toHaveBeenCalledWith(h.windows[1], 'https://example.test/moved') + expect(h.backend.listParkedPages()).toEqual([]) + }) + + it('coalesces concurrent wakes into one renderer', async () => { + const h = createHarness() + await h.backend.createTab({ url: 'https://example.test/a' }) + const [pageId] = [...h.registered.keys()] + h.clock.value += 120_000 + await h.backend.reclaimIdlePages() + + const [first, second] = await Promise.all([ + h.backend.wakeTab(pageId), + h.backend.wakeTab(pageId) + ]) + + expect([first, second]).toEqual([true, true]) + expect(h.windows).toHaveLength(2) + }) + + it('reports false when waking a page it does not own', async () => { + const h = createHarness() + expect(await h.backend.wakeTab('nope')).toBe(false) + }) + + it('does not park a pinned page even when it is the oldest', async () => { + const h = createHarness({ pinned: new Set(['streamed']) }) + await h.backend.createTab({ url: 'https://a', browserPageId: 'streamed' }) + h.clock.value += 1_000 + await h.backend.createTab({ url: 'https://b', browserPageId: 'idle' }) + h.clock.value += 120_000 + + expect(await h.backend.reclaimIdlePages()).toEqual(['idle']) + }) + + it('keeps the resident cap by evicting least-recently-used pages', async () => { + const h = createHarness() + for (const id of ['a', 'b', 'c', 'd']) { + await h.backend.createTab({ url: `https://example.test/${id}`, browserPageId: id }) + h.clock.value += 1_000 + } + // Why: past the grace floor but well inside the idle window, so the cap is + // provably the evictor here. + h.clock.value += 10_000 + + expect(await h.backend.reclaimIdlePages()).toEqual(['a', 'b']) + expect(h.backend.listParkedPages().map((page) => page.browserPageId)).toEqual(['a', 'b']) + }) + + it('reports the most recently used parked page for implicit targeting', async () => { + const h = createHarness() + for (const id of ['a', 'b', 'c', 'd']) { + await h.backend.createTab({ url: `https://example.test/${id}`, browserPageId: id }) + h.clock.value += 1_000 + } + h.clock.value += 10_000 + await h.backend.reclaimIdlePages() + + expect(h.backend.getMostRecentlyUsedParkedPageId()).toBe('b') + }) + + it('restarts the reclaim clock when a resident page is used', async () => { + const h = createHarness() + await h.backend.createTab({ url: 'https://example.test/a', browserPageId: 'a' }) + h.clock.value += 120_000 + + // Why: waking a resident page must not rebuild its renderer, only mark it used. + expect(await h.backend.wakeTab('a')).toBe(true) + expect(h.windows).toHaveLength(1) + expect(await h.backend.reclaimIdlePages()).toEqual([]) + }) + + it('does not let a command land on a renderer that a park is tearing down', async () => { + let releaseSession = (): void => {} + const h = createHarness() + await h.backend.createTab({ url: 'https://example.test/a', browserPageId: 'a' }) + const bridge = h.bridge as unknown as { onTabClosed: ReturnType } + bridge.onTabClosed.mockImplementation( + async () => new Promise((resolve) => (releaseSession = resolve)) + ) + + h.clock.value += 120_000 + const park = h.backend.reclaimIdlePages() + const wake = h.backend.wakeTab('a') + releaseSession() + await park + expect(await wake).toBe(true) + + // The woken renderer is a fresh one, not the window the park destroyed. + expect(h.windows).toHaveLength(2) + expect(h.windows[0].isDestroyed()).toBe(true) + expect(h.windows[1].isDestroyed()).toBe(false) + expect(h.registered.get('a')).toBe(h.windows[1].webContents.id) + }) + + it('keeps a parked page after its renderer emits destroyed', async () => { + // Why: parking destroys the window on purpose; the crash handler must not + // read that as the page going away or the record is lost. + const h = createHarness() + await h.backend.createTab({ url: 'https://example.test/a', browserPageId: 'a' }) + h.clock.value += 120_000 + await h.backend.reclaimIdlePages() + + expect(h.backend.listParkedPages().map((page) => page.browserPageId)).toEqual(['a']) + }) + + it('drops a page whose renderer dies on its own', async () => { + const h = createHarness() + await h.backend.createTab({ url: 'https://example.test/a', browserPageId: 'a' }) + + h.windows[0].destroy() + + expect(h.backend.listParkedPages()).toEqual([]) + expect(await h.backend.wakeTab('a')).toBe(false) + }) + + it('scopes parked listings and implicit targeting to a worktree', async () => { + const h = createHarness() + await h.backend.createTab({ url: 'https://a', browserPageId: 'a', worktreeId: 'wt-1' }) + h.clock.value += 1_000 + await h.backend.createTab({ url: 'https://b', browserPageId: 'b', worktreeId: 'wt-2' }) + h.clock.value += 120_000 + await h.backend.reclaimIdlePages() + + expect(h.backend.listParkedPages('wt-1').map((page) => page.browserPageId)).toEqual(['a']) + expect(h.backend.getMostRecentlyUsedParkedPageId('wt-2')).toBe('b') + }) + + it('destroys every page it owns on shutdown', async () => { + const h = createHarness() + await h.backend.createTab({ url: 'https://a', browserPageId: 'a' }) + await h.backend.createTab({ url: 'https://b', browserPageId: 'b' }) + + h.backend.destroyAll() + + expect(h.windows.every((win) => win.isDestroyed())).toBe(true) + expect(h.backend.listParkedPages()).toEqual([]) + }) +}) diff --git a/src/main/browser/offscreen-browser-backend.ts b/src/main/browser/offscreen-browser-backend.ts index 64e96ede29d..b8d0e26c686 100644 --- a/src/main/browser/offscreen-browser-backend.ts +++ b/src/main/browser/offscreen-browser-backend.ts @@ -1,30 +1,62 @@ import { randomUUID } from 'node:crypto' -import { BrowserWindow } from 'electron' +import type { BrowserWindow } from 'electron' import { ORCA_BROWSER_PARTITION } from '../../shared/constants' -import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' -import type { BrowserBackend, BrowserBackendCreateTab } from './browser-backend' +import type { AgentBrowserBridge } from './agent-browser-bridge' +import type { BrowserBackend, BrowserBackendCreateTab, ParkedBrowserPage } from './browser-backend' import type { BrowserManager } from './browser-manager' import { browserSessionRegistry } from './browser-session-registry' +import { + readOffscreenBrowserReclaimPolicy, + selectOffscreenBrowserPagesToPark, + type OffscreenBrowserReclaimPolicy +} from './offscreen-browser-page-reclaim' +import { createOffscreenBrowserWindow, loadOffscreenBrowserUrl } from './offscreen-browser-window' -// Why: headless orca serve has no renderer window to host a , so each -// browser page is backed by a main-process offscreen BrowserWindow. The window -// is never shown — it exists only so its WebContents can be driven over CDP and -// streamed via the existing screencast path. Verified on macOS and on headless -// Linux under Xvfb (Electron --headless segfaults; a virtual display is -// required there — provisioned in the serve image, not by this code). +// Why (STA-4341): this backend is the lifecycle owner for headless browser +// pages. It keeps the page identity an agent holds (`browserPageId`, URL, +// worktree, profile) for as long as the page is open, but treats the renderer +// process behind it as a reclaimable resource: an idle page is parked (renderer +// destroyed, record kept) and woken on the next command that targets it. -const DEFAULT_VIEWPORT_WIDTH = 1280 -const DEFAULT_VIEWPORT_HEIGHT = 800 -const LOAD_TIMEOUT_MS = 30_000 +type OffscreenPage = { + browserPageId: string + worktreeId?: string + profileId?: string + partition: string + url: string + title: string + /** null while parked — the page exists, its renderer does not. */ + window: BrowserWindow | null + lastActivityAt: number +} + +export type OffscreenBrowserBackendOptions = { + getAgentBrowserBridge?: () => AgentBrowserBridge | null + /** Pages a client is streaming or that have a command in flight. */ + isPagePinned?: (browserPageId: string) => boolean + now?: () => number +} export class OffscreenBrowserBackend implements BrowserBackend { - private readonly windowsByPageId = new Map() + private readonly pages = new Map() + /** Renderer teardowns this backend initiated, keyed by page. Park and close + * both destroy the window on purpose, so the crash handler stands down for + * them — and a wake waits on one rather than racing it. */ + private readonly releasing = new Map>() + private readonly waking = new Map>() + private readonly policy: OffscreenBrowserReclaimPolicy + private sweepTimer: NodeJS.Timeout | null = null - constructor(private readonly browserManager: BrowserManager) {} + constructor( + private readonly browserManager: BrowserManager, + private readonly options: OffscreenBrowserBackendOptions = {} + ) { + this.policy = readOffscreenBrowserReclaimPolicy() + } async createTab(params: BrowserBackendCreateTab): Promise<{ browserPageId: string }> { const browserPageId = params.browserPageId ?? randomUUID() - if (this.windowsByPageId.has(browserPageId)) { + if (this.pages.has(browserPageId)) { throw new Error(`Browser page ${browserPageId} already exists`) } // Why: profiles map to Electron partitions; using the profile's partition @@ -32,142 +64,274 @@ export class OffscreenBrowserBackend implements BrowserBackend { const profile = params.profileId ? browserSessionRegistry.getProfile(params.profileId) : browserSessionRegistry.getDefaultProfile() - const partition = profile?.partition ?? ORCA_BROWSER_PARTITION - - const win = new BrowserWindow({ - show: false, - width: DEFAULT_VIEWPORT_WIDTH, - height: DEFAULT_VIEWPORT_HEIGHT, - webPreferences: { - // Why: offscreen pages are the SSH/headless browser backend; keep their - // HTML fullscreen behavior aligned with desktop guests. - ...ORCA_BROWSER_GUEST_WEB_PREFERENCES, - partition, - sandbox: true, - contextIsolation: true, - nodeIntegration: false - } - }) - - this.windowsByPageId.set(browserPageId, win) - - // Why: if the offscreen window is destroyed out from under us (crash, app - // teardown), drop the registry entry so commands fail cleanly instead of - // resolving a dead WebContents. - win.webContents.once('destroyed', () => { - this.windowsByPageId.delete(browserPageId) - this.browserManager.unregisterGuest(browserPageId) - }) - - // Why: register the guest and return immediately so the new tab appears - // without waiting for the page to finish loading. Previously createTab - // awaited the full navigation, so clicking "New Browser Tab" did nothing for - // up to a second on real URLs. The page loads asynchronously and streams - // once it paints; a failed load leaves the (usable) tab open, matching how a - // normal browser tab survives a failed navigation. - this.browserManager.registerOffscreenGuest({ + const url = params.url || 'about:blank' + const page: OffscreenPage = { browserPageId, worktreeId: params.worktreeId, - sessionProfileId: profile?.id ?? null, - userAgentMode: profile?.userAgentMode, - webContentsId: win.webContents.id - }) - - const url = params.url || 'about:blank' - void this.loadUrl(win, url).catch((error) => { + profileId: profile?.id ?? undefined, + partition: profile?.partition ?? ORCA_BROWSER_PARTITION, + url, + title: '', + window: null, + lastActivityAt: this.now() + } + this.pages.set(browserPageId, page) + // Why: register the guest and return immediately so the new tab appears + // without waiting for the page to finish loading. A failed load leaves the + // (usable) tab open, matching how a normal browser tab survives one. + this.materialize(page) + void this.loadPage(page, url).catch((error) => { console.warn( '[offscreen-browser] page load failed:', error instanceof Error ? error.message : String(error) ) }) - + this.ensureSweepScheduled() return { browserPageId } } async closeTab(browserPageId: string): Promise { - const win = this.windowsByPageId.get(browserPageId) - this.windowsByPageId.delete(browserPageId) - this.browserManager.unregisterGuest(browserPageId) - if (win && !win.isDestroyed()) { - win.destroy() + const page = this.pages.get(browserPageId) + this.pages.delete(browserPageId) + this.waking.delete(browserPageId) + await this.releaseRenderer(page ?? null, browserPageId) + if (this.pages.size === 0) { + this.stopSweep() } } - getWebContentsId(browserPageId: string): number | null { - const win = this.windowsByPageId.get(browserPageId) - return win && !win.isDestroyed() ? win.webContents.id : null + /** + * Make a page's renderer resident and restart its reclaim clock. Cheap and + * idempotent for a page that never parked, so command routing can call it + * unconditionally; that is also what serialises a command against a park + * that is already tearing the renderer down. + */ + async wakeTab(browserPageId: string): Promise { + const page = this.pages.get(browserPageId) + if (!page) { + return false + } + page.lastActivityAt = this.now() + const releasing = this.releasing.get(browserPageId) + if (!releasing && page.window && !page.window.isDestroyed()) { + return true + } + const inFlight = this.waking.get(browserPageId) + if (inFlight) { + return inFlight + } + const wake = (async (): Promise => { + await releasing + if (!this.pages.has(browserPageId)) { + return false + } + if (page.window && !page.window.isDestroyed()) { + return true + } + const previousWebContentsId = this.browserManager.getGuestWebContentsId(browserPageId) + this.materialize(page) + const bridge = this.options.getAgentBrowserBridge?.() ?? null + const webContentsId = page.window?.webContents.id + if (bridge && webContentsId != null) { + // Why: same page id, new renderer — reuse the existing process-swap + // path so the stale helper session and CDP proxy are torn down. + await bridge.onProcessSwap(browserPageId, webContentsId, previousWebContentsId ?? undefined) + } + await this.loadPage(page, page.url).catch(() => { + // A parked page whose URL no longer loads stays open and reports the + // failure through the same load-error surface as a live page. + }) + page.lastActivityAt = this.now() + return true + })() + this.waking.set(browserPageId, wake) + try { + return await wake + } finally { + this.waking.delete(browserPageId) + } + } + + listParkedPages(worktreeId?: string): ParkedBrowserPage[] { + const parked: ParkedBrowserPage[] = [] + for (const page of this.pages.values()) { + if (page.window && !page.window.isDestroyed()) { + continue + } + if (worktreeId && page.worktreeId !== worktreeId) { + continue + } + parked.push({ + browserPageId: page.browserPageId, + worktreeId: page.worktreeId, + profileId: page.profileId, + url: page.url, + title: page.title + }) + } + return parked + } + + /** Most-recently-used parked page in a worktree, for implicit targeting. */ + getMostRecentlyUsedParkedPageId(worktreeId?: string): string | null { + let best: OffscreenPage | null = null + for (const page of this.pages.values()) { + if (page.window && !page.window.isDestroyed()) { + continue + } + if (worktreeId && page.worktreeId !== worktreeId) { + continue + } + if (!best || page.lastActivityAt > best.lastActivityAt) { + best = page + } + } + return best?.browserPageId ?? null } destroyAll(): void { - for (const [pageId, win] of this.windowsByPageId) { + this.stopSweep() + for (const [pageId, page] of this.pages) { this.browserManager.unregisterGuest(pageId) - if (!win.isDestroyed()) { - win.destroy() + if (page.window && !page.window.isDestroyed()) { + page.window.destroy() } } - this.windowsByPageId.clear() + this.pages.clear() + this.waking.clear() } - private async loadUrl(win: BrowserWindow, url: string): Promise { - const wc = win.webContents - await new Promise((resolve, reject) => { - let settled = false - const timer = setTimeout(() => { - if (settled) { - return - } - settled = true - cleanup() - // Why: about:blank and slow pages can resolve via timeout without a - // did-finish-load; treat that as success so the tab is still operable. - resolve() - }, LOAD_TIMEOUT_MS) + /** Park every page the policy no longer wants resident. Exposed for tests. */ + async reclaimIdlePages(): Promise { + const now = this.now() + const resident = [...this.pages.values()].filter( + (page) => page.window && !page.window.isDestroyed() && !this.releasing.has(page.browserPageId) + ) + const doomed = selectOffscreenBrowserPagesToPark( + resident.map((page) => ({ + browserPageId: page.browserPageId, + lastActivityAt: page.lastActivityAt, + pinned: this.options.isPagePinned?.(page.browserPageId) === true + })), + now, + this.policy + ) + for (const browserPageId of doomed) { + await this.parkPage(browserPageId) + } + return doomed + } - const onFinish = (): void => { - if (settled) { - return - } - settled = true - cleanup() - resolve() - } - const onFail = ( - _e: unknown, - errorCode: number, - errorDescription: string, - _validatedURL: string, - isMainFrame: boolean - ): void => { - // Why: subframe/iframe (e.g. ad/tracker) load failures also fire - // did-fail-load. Only the main frame failing means the page itself - // failed; ignore the rest or an otherwise-usable page gets rejected. - if (!isMainFrame) { - return - } - if (settled) { - return - } - settled = true - cleanup() - // Why: aborted loads (-3) happen on redirects/SPA navigations and are not - // real failures; the page is still usable. - if (errorCode === -3) { - resolve() - return - } - reject(new Error(`${errorDescription} (${errorCode})`)) - } - const cleanup = (): void => { - clearTimeout(timer) - wc.removeListener('did-finish-load', onFinish) - wc.removeListener('did-fail-load', onFail) - } + private async parkPage(browserPageId: string): Promise { + const page = this.pages.get(browserPageId) + if (!page?.window || page.window.isDestroyed()) { + return + } + // Why: capture the committed address before teardown so a woken page + // returns to where the agent left it, not to its original create URL. + const wc = page.window.webContents + const committed = wc.getURL() + if (committed && !committed.startsWith('chrome-error://')) { + page.url = committed + } + page.title = wc.getTitle() ?? page.title + await this.releaseRenderer(page, browserPageId) + page.window = null + } - wc.on('did-finish-load', onFinish) - wc.on('did-fail-load', onFail) - void wc.loadURL(url).catch(() => { - // loadURL rejects on aborted navigations; did-fail-load handles the rest. - }) + // Why: teardown order matters — the bridge must destroy the helper session and + // detach its debugger while the WebContents is still alive and still mapped, + // or the session, its CDP proxy and its listening port outlive the page. + private async releaseRenderer(page: OffscreenPage | null, browserPageId: string): Promise { + const pending = this.releasing.get(browserPageId) + if (pending) { + await pending + return + } + const release = this.runReleaseRenderer(page, browserPageId) + this.releasing.set(browserPageId, release) + try { + await release + } finally { + if (this.releasing.get(browserPageId) === release) { + this.releasing.delete(browserPageId) + } + } + } + + private async runReleaseRenderer( + page: OffscreenPage | null, + browserPageId: string + ): Promise { + const bridge = this.options.getAgentBrowserBridge?.() ?? null + const webContentsId = this.browserManager.getGuestWebContentsId(browserPageId) + if (bridge && webContentsId != null) { + await bridge.onTabClosed(webContentsId) + } + this.browserManager.unregisterGuest(browserPageId) + if (page?.window && !page.window.isDestroyed()) { + page.window.destroy() + } + } + + private materialize(page: OffscreenPage): void { + const win = createOffscreenBrowserWindow(page.partition) + page.window = win + // Why: if the window is destroyed out from under us (crash, app teardown), + // drop the page so commands fail cleanly instead of resolving a dead + // WebContents. Parking destroys it deliberately, so it opts out here. + win.webContents.once('destroyed', () => { + if (this.releasing.has(page.browserPageId) || page.window !== win) { + return + } + this.pages.delete(page.browserPageId) + this.browserManager.unregisterGuest(page.browserPageId) + }) + const profile = page.profileId ? browserSessionRegistry.getProfile(page.profileId) : null + this.browserManager.registerOffscreenGuest({ + browserPageId: page.browserPageId, + worktreeId: page.worktreeId, + sessionProfileId: page.profileId ?? null, + userAgentMode: profile?.userAgentMode, + webContentsId: win.webContents.id }) } + + private async loadPage(page: OffscreenPage, url: string): Promise { + const win = page.window + if (!win || win.isDestroyed()) { + return + } + await loadOffscreenBrowserUrl(win, url) + if (page.window === win && !win.isDestroyed()) { + page.title = win.webContents.getTitle() ?? page.title + // Why: the reclaim clock must start when the page is ready, not when the + // create call returned, or a slow load can be parked mid-flight. + page.lastActivityAt = this.now() + } + } + + private ensureSweepScheduled(): void { + if (this.sweepTimer) { + return + } + this.sweepTimer = setInterval(() => { + void this.reclaimIdlePages().catch(() => { + // A failed park is retried on the next sweep. + }) + }, this.policy.sweepIntervalMs) + // Why: reclamation must never be the reason the process stays alive. + this.sweepTimer.unref?.() + } + + private stopSweep(): void { + if (this.sweepTimer) { + clearInterval(this.sweepTimer) + this.sweepTimer = null + } + } + + private now(): number { + return this.options.now?.() ?? Date.now() + } } diff --git a/src/main/browser/offscreen-browser-page-reclaim.test.ts b/src/main/browser/offscreen-browser-page-reclaim.test.ts new file mode 100644 index 00000000000..17a83da58f5 --- /dev/null +++ b/src/main/browser/offscreen-browser-page-reclaim.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { + selectOffscreenBrowserPagesToPark, + type OffscreenBrowserReclaimCandidate, + type OffscreenBrowserReclaimPolicy +} from './offscreen-browser-page-reclaim' + +const NOW = 1_000_000 + +const POLICY: OffscreenBrowserReclaimPolicy = { + residentLimit: 2, + idleParkMs: 60_000, + parkGraceMs: 5_000, + sweepIntervalMs: 1_000 +} + +function page( + browserPageId: string, + idleMs: number, + pinned = false +): OffscreenBrowserReclaimCandidate { + return { browserPageId, lastActivityAt: NOW - idleMs, pinned } +} + +describe('selectOffscreenBrowserPagesToPark', () => { + it('keeps everything resident while the working set fits under the cap', () => { + expect( + selectOffscreenBrowserPagesToPark([page('a', 10_000), page('b', 20_000)], NOW, POLICY) + ).toEqual([]) + }) + + it('parks the least recently used pages down to the cap', () => { + const parked = selectOffscreenBrowserPagesToPark( + [page('newest', 6_000), page('oldest', 40_000), page('middle', 20_000)], + NOW, + POLICY + ) + expect(parked).toEqual(['oldest']) + }) + + it('parks an idle page even when the working set fits under the cap', () => { + expect( + selectOffscreenBrowserPagesToPark([page('a', 10_000), page('stale', 90_000)], NOW, POLICY) + ).toEqual(['stale']) + }) + + it('never parks a page inside the grace window, even over the cap', () => { + // Why: a page touched moments ago is the one an agent is mid-workflow on. + const parked = selectOffscreenBrowserPagesToPark( + [page('a', 100), page('b', 200), page('c', 300), page('d', 400)], + NOW, + POLICY + ) + expect(parked).toEqual([]) + }) + + it('never parks a pinned page and lets it push the cap', () => { + const parked = selectOffscreenBrowserPagesToPark( + [ + page('streamed', 200_000, true), + page('alsoStreamed', 300_000, true), + page('idle', 40_000), + page('idler', 50_000) + ], + NOW, + POLICY + ) + // Two pinned pages already fill the cap, so both evictable pages go. + expect(parked.sort()).toEqual(['idle', 'idler']) + }) + + it('counts pinned pages against the cap without evicting them', () => { + const parked = selectOffscreenBrowserPagesToPark( + [page('pinned', 200_000, true), page('a', 10_000), page('b', 20_000)], + NOW, + POLICY + ) + expect(parked).toEqual(['b']) + }) + + it('is deterministic when two pages share a last-activity stamp', () => { + const candidates = [page('zebra', 30_000), page('apple', 30_000), page('mango', 1_000)] + const first = selectOffscreenBrowserPagesToPark(candidates, NOW, POLICY) + const second = selectOffscreenBrowserPagesToPark(candidates.toReversed(), NOW, POLICY) + expect(first).toEqual(['apple']) + expect(second).toEqual(first) + }) + + it('parks nothing when the policy disables both evictors', () => { + expect( + selectOffscreenBrowserPagesToPark([page('a', 10_000_000), page('b', 10_000_000)], NOW, { + ...POLICY, + residentLimit: Number.MAX_SAFE_INTEGER, + idleParkMs: Number.MAX_SAFE_INTEGER + }) + ).toEqual([]) + }) +}) diff --git a/src/main/browser/offscreen-browser-page-reclaim.ts b/src/main/browser/offscreen-browser-page-reclaim.ts new file mode 100644 index 00000000000..3dd69682139 --- /dev/null +++ b/src/main/browser/offscreen-browser-page-reclaim.ts @@ -0,0 +1,108 @@ +// Why (STA-4341): on a headless `orca serve` host every agent-opened browser +// page is backed by its own hidden BrowserWindow, i.e. its own renderer +// process. Nothing owned those renderers, so a long agent session accumulated +// them until the host saturated. This module is the policy half of that owner: +// given the resident pages it decides which ones to park (drop the renderer, +// keep the page). It mirrors terminal hidden-view parking — the cap is the +// primary evictor, the idle clock is the tail — and is pure so the decision is +// testable without Electron. + +/** Resident renderers kept warm. Matches TERMINAL_WORKTREE_HOT_RETAIN_LIMIT. */ +export const OFFSCREEN_BROWSER_RESIDENT_LIMIT = 4 +/** An untouched page parks once idle this long, even under the cap. */ +export const OFFSCREEN_BROWSER_IDLE_PARK_MS = 5 * 60_000 +/** A page is never parked before this much time without a command. */ +export const OFFSCREEN_BROWSER_PARK_GRACE_MS = 30_000 + +/** How often the backend re-evaluates which pages should be resident. */ +export const OFFSCREEN_BROWSER_SWEEP_INTERVAL_MS = 15_000 + +export type OffscreenBrowserReclaimPolicy = { + residentLimit: number + idleParkMs: number + parkGraceMs: number + sweepIntervalMs: number +} + +export type OffscreenBrowserReclaimCandidate = { + browserPageId: string + lastActivityAt: number + /** Never parked: a client is streaming it, or a command is in flight. */ + pinned: boolean +} + +function readPositiveIntEnv(name: string, fallback: number): number { + const raw = process.env[name] + if (raw === undefined) { + return fallback + } + const parsed = Number.parseInt(raw, 10) + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback +} + +export function readOffscreenBrowserReclaimPolicy(): OffscreenBrowserReclaimPolicy { + return { + residentLimit: readPositiveIntEnv( + 'ORCA_HEADLESS_BROWSER_RESIDENT_LIMIT', + OFFSCREEN_BROWSER_RESIDENT_LIMIT + ), + idleParkMs: readPositiveIntEnv( + 'ORCA_HEADLESS_BROWSER_PARK_IDLE_MS', + OFFSCREEN_BROWSER_IDLE_PARK_MS + ), + parkGraceMs: readPositiveIntEnv( + 'ORCA_HEADLESS_BROWSER_PARK_GRACE_MS', + OFFSCREEN_BROWSER_PARK_GRACE_MS + ), + sweepIntervalMs: Math.max( + 100, + readPositiveIntEnv('ORCA_HEADLESS_BROWSER_PARK_SWEEP_MS', OFFSCREEN_BROWSER_SWEEP_INTERVAL_MS) + ) + } +} + +/** + * Pick the resident pages to park, least-recently-used first. + * + * Pinned pages are never returned, so the resident cap can be exceeded by + * pages a client is actively watching — bounding those would break the + * legitimate browser work the cap exists to protect. + */ +export function selectOffscreenBrowserPagesToPark( + residentPages: readonly OffscreenBrowserReclaimCandidate[], + now: number, + policy: OffscreenBrowserReclaimPolicy +): string[] { + // Why: a page used moments ago is the one an agent is mid-workflow on; the + // grace floor applies to eviction by cap as well as by clock. + const parkable = residentPages + .filter((page) => !page.pinned && now - page.lastActivityAt >= policy.parkGraceMs) + .sort( + (left, right) => + left.lastActivityAt - right.lastActivityAt || + left.browserPageId.localeCompare(right.browserPageId) + ) + + const parked = new Set() + for (const page of parkable) { + if (now - page.lastActivityAt >= policy.idleParkMs) { + parked.add(page.browserPageId) + } + } + + // Why: the cap counts every resident page, pinned ones included — the budget + // is renderer processes on the host, not just the evictable ones. + let resident = residentPages.length - parked.size + for (const page of parkable) { + if (resident <= policy.residentLimit) { + break + } + if (parked.has(page.browserPageId)) { + continue + } + parked.add(page.browserPageId) + resident-- + } + + return parkable.map((page) => page.browserPageId).filter((id) => parked.has(id)) +} diff --git a/src/main/browser/offscreen-browser-window.ts b/src/main/browser/offscreen-browser-window.ts new file mode 100644 index 00000000000..ad9f7fc4424 --- /dev/null +++ b/src/main/browser/offscreen-browser-window.ts @@ -0,0 +1,96 @@ +import { BrowserWindow } from 'electron' +import { ORCA_BROWSER_GUEST_WEB_PREFERENCES } from '../../shared/browser-guest-web-preferences' + +// Why: headless orca serve has no renderer window to host a , so each +// browser page is backed by a main-process offscreen BrowserWindow. The window +// is never shown — it exists only so its WebContents can be driven over CDP and +// streamed via the existing screencast path. Verified on macOS and on headless +// Linux under Xvfb (Electron --headless segfaults; a virtual display is +// required there — provisioned in the serve image, not by this code). + +const DEFAULT_VIEWPORT_WIDTH = 1280 +const DEFAULT_VIEWPORT_HEIGHT = 800 +const LOAD_TIMEOUT_MS = 30_000 + +export function createOffscreenBrowserWindow(partition: string): BrowserWindow { + return new BrowserWindow({ + show: false, + width: DEFAULT_VIEWPORT_WIDTH, + height: DEFAULT_VIEWPORT_HEIGHT, + webPreferences: { + // Why: offscreen pages are the SSH/headless browser backend; keep their + // HTML fullscreen behavior aligned with desktop guests. + ...ORCA_BROWSER_GUEST_WEB_PREFERENCES, + partition, + sandbox: true, + contextIsolation: true, + nodeIntegration: false + } + }) +} + +export async function loadOffscreenBrowserUrl(win: BrowserWindow, url: string): Promise { + const wc = win.webContents + await new Promise((resolve, reject) => { + let settled = false + const timer = setTimeout(() => { + if (settled) { + return + } + settled = true + cleanup() + // Why: about:blank and slow pages can resolve via timeout without a + // did-finish-load; treat that as success so the tab is still operable. + resolve() + }, LOAD_TIMEOUT_MS) + + const onFinish = (): void => { + if (settled) { + return + } + settled = true + cleanup() + resolve() + } + const onFail = ( + _e: unknown, + errorCode: number, + errorDescription: string, + _validatedURL: string, + isMainFrame: boolean + ): void => { + // Why: subframe/iframe (e.g. ad/tracker) load failures also fire + // did-fail-load. Only the main frame failing means the page itself + // failed; ignore the rest or an otherwise-usable page gets rejected. + if (!isMainFrame) { + return + } + if (settled) { + return + } + settled = true + cleanup() + // Why: aborted loads (-3) happen on redirects/SPA navigations and are not + // real failures; the page is still usable. + if (errorCode === -3) { + resolve() + return + } + reject(new Error(`${errorDescription} (${errorCode})`)) + } + const cleanup = (): void => { + clearTimeout(timer) + if (wc.isDestroyed()) { + return + } + wc.removeListener('did-finish-load', onFinish) + wc.removeListener('did-fail-load', onFail) + } + + wc.on('did-finish-load', onFinish) + wc.on('did-fail-load', onFail) + void wc.loadURL(url).catch(() => { + // loadURL rejects on aborted navigations; did-fail-load handles the rest. + }) + }) +} diff --git a/src/main/browser/offscreen-browser-backend.web-preferences.test.ts b/src/main/browser/offscreen-browser-window.web-preferences.test.ts similarity index 79% rename from src/main/browser/offscreen-browser-backend.web-preferences.test.ts rename to src/main/browser/offscreen-browser-window.web-preferences.test.ts index 1ff2f8c2c47..37e0f762146 100644 --- a/src/main/browser/offscreen-browser-backend.web-preferences.test.ts +++ b/src/main/browser/offscreen-browser-window.web-preferences.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' -const OFFSCREEN_BACKEND_SOURCE = resolve(__dirname, 'offscreen-browser-backend.ts') +const OFFSCREEN_WINDOW_SOURCE = resolve(__dirname, 'offscreen-browser-window.ts') function sourceBetween(source: string, start: string, end: string): string { const startIndex = source.indexOf(start) @@ -14,9 +14,9 @@ function sourceBetween(source: string, start: string, end: string): string { return source.slice(startIndex, endIndex) } -describe('OffscreenBrowserBackend web preferences', () => { +describe('offscreen browser window web preferences', () => { it('uses the shared browser guest fullscreen policy', () => { - const source = readFileSync(OFFSCREEN_BACKEND_SOURCE, 'utf8') + const source = readFileSync(OFFSCREEN_WINDOW_SOURCE, 'utf8') const webPreferencesBlock = sourceBetween(source, 'webPreferences: {', 'partition,') expect(source).toContain( diff --git a/src/main/index.ts b/src/main/index.ts index ecd94696965..ea46575d239 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3165,7 +3165,12 @@ void app.whenReady().then(async () => { await runtime.reconcileLegacyWorkerTerminals() // Why: headless servers can't mount panes; use offscreen WebContents, gated on a real display so browser.headless.v1 stays honest. if (headlessBrowserDisplayAvailable) { - runtime.setOffscreenBrowserBackend(new OffscreenBrowserBackend(browserManager)) + runtime.setOffscreenBrowserBackend( + new OffscreenBrowserBackend(browserManager, { + getAgentBrowserBridge: () => runtime?.getAgentBrowserBridge() ?? null, + isPagePinned: (browserPageId) => runtime?.isBrowserPagePinned(browserPageId) === true + }) + ) } // Why: headless servers have no renderer graph publisher; publish an explicit empty graph so status clients see a ready server. runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index 33f1b5dddc5..eeb79501c1e 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -76,6 +76,11 @@ export type BrowserCommandTargetParams = { page?: string } +type BrowserWorktreeResolutionOptions = { + /** Whether an implicit target may wake a parked headless page. */ + wakeParkedPage?: boolean +} + type ResolvedBrowserCommandTarget = { worktreeId?: string browserPageId?: string @@ -175,6 +180,12 @@ export class RuntimeBrowserCommands { return bridge } + // Why (STA-4341): a page a paired client is streaming must never be parked — + // the reclaimer would black out a pane the user is actively looking at. + isBrowserPageStreamed(browserPageId: string): boolean { + return this.activeScreencastPageIds.has(browserPageId) + } + private hasLiveRegisteredBrowserTab( bridge: AgentBrowserBridge, worktreeId: string | undefined @@ -202,13 +213,16 @@ export class RuntimeBrowserCommands { } // Why: the CLI sends selectors (e.g. "path:/...") but the bridge keys tabs by "repoId::path"; resolve to that store-compatible id. - private async resolveBrowserWorktreeId(selector?: string): Promise { + private async resolveBrowserWorktreeId( + selector?: string, + options: BrowserWorktreeResolutionOptions = {} + ): Promise { if (!selector) { // Why: after restart, webviews mount only when the pane is visible; activate the view so persisted tabs become operable via registerGuest. const bridge = this.host.getAgentBrowserBridge() if (bridge && !this.hasLiveRegisteredBrowserTab(bridge, undefined)) { try { - await this.ensureBrowserWorktreeActive(undefined) + await this.ensureBrowserWorktreeActive(undefined, options) } catch { // Window may not exist yet (e.g. during startup or in tests) } @@ -221,7 +235,7 @@ export class RuntimeBrowserCommands { const bridge = this.host.getAgentBrowserBridge() if (bridge && !this.hasLiveRegisteredBrowserTab(bridge, worktreeId)) { try { - await this.ensureBrowserWorktreeActive(worktreeId) + await this.ensureBrowserWorktreeActive(worktreeId, options) } catch { // Fall through with the validated worktree id so routing stays scoped to the caller's explicit selector. } @@ -235,16 +249,25 @@ export class RuntimeBrowserCommands { const browserPageId = typeof params.page === 'string' && params.page.length > 0 ? params.page : undefined if (!browserPageId) { - return { - worktreeId: await this.resolveBrowserWorktreeId(params.worktree) - } + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) + // Why (STA-4341): an implicit target still counts as using its page, or a + // steady stream of `--page`-less commands would let the reclaim clock run + // out under the very agent that is driving the tab. + await this.markHeadlessBrowserPageUsed( + this.host.getAgentBrowserBridge()?.getActivePageId(worktreeId) ?? undefined + ) + return { worktreeId } } const worktreeId = params.worktree ? (await this.host.resolveWorktreeSelector(params.worktree)).id : undefined + // Why (STA-4341): a headless page's renderer may have been reclaimed, or be + // mid-reclaim right now. Asking the backend first both wakes it and + // serialises against that teardown; it is a no-op for a resident page. + const woken = await this.markHeadlessBrowserPageUsed(browserPageId) const bridge = this.host.getAgentBrowserBridge() - if (bridge && !this.hasLiveRegisteredBrowserPage(bridge, worktreeId, browserPageId)) { + if (!woken && bridge && !this.hasLiveRegisteredBrowserPage(bridge, worktreeId, browserPageId)) { try { await this.ensureBrowserPageActive(worktreeId, browserPageId) } catch { @@ -258,6 +281,13 @@ export class RuntimeBrowserCommands { } } + private async markHeadlessBrowserPageUsed(browserPageId: string | undefined): Promise { + if (!browserPageId || this.host.getAvailableAuthoritativeWindow()) { + return false + } + return (await this.host.getOffscreenBrowserBackend()?.wakeTab?.(browserPageId)) === true + } + private resolveBrowserPageWebContents( worktreeId: string | undefined, browserPageId: string | undefined @@ -286,7 +316,21 @@ export class RuntimeBrowserCommands { } // Why: background-mount the worktree via a hidden visibility lease so the webview guest can register without stealing the user's visible pane. - private async ensureBrowserWorktreeActive(worktreeId: string | undefined): Promise { + private async ensureBrowserWorktreeActive( + worktreeId: string | undefined, + options: BrowserWorktreeResolutionOptions = {} + ): Promise { + // Why: on headless serve an implicit target (no --page) must still find a + // page when every page in the worktree is parked; wake the most recently + // used one rather than every one, so reclamation is not undone wholesale. + // Read-only callers opt out — listing tabs must never cost a renderer. + if (options.wakeParkedPage !== false && !this.host.getAvailableAuthoritativeWindow()) { + const offscreen = this.host.getOffscreenBrowserBackend() + const parkedPageId = offscreen?.getMostRecentlyUsedParkedPageId?.(worktreeId) + if (parkedPageId && (await offscreen?.wakeTab?.(parkedPageId))) { + return + } + } const win = this.host.getAuthoritativeWindow() win.webContents.send('browser:activateView', worktreeId ? { worktreeId } : {}) // Why: the pane is operable only after the webview mounts and calls registerGuest; wait on that IPC rather than a flaky fixed sleep. @@ -593,11 +637,66 @@ export class RuntimeBrowserCommands { } async browserTabList(params: { worktree?: string }): Promise { - const worktreeId = await this.resolveBrowserWorktreeId(params.worktree) - const result = this.requireAgentBrowserBridge().tabList(worktreeId) - return { - tabs: result.tabs.map((tab) => this.enrichBrowserTabInfo(tab)) + const worktreeId = await this.resolveBrowserWorktreeId(params.worktree, { + wakeParkedPage: false + }) + return { tabs: this.listBrowserTabsIncludingParked(worktreeId) } + } + + // Why (STA-4341): a parked page is open — it just has no renderer right now. + // Dropping it from the listing would tell an agent its tab is gone and push + // it into opening yet another one, which is the leak this fixes. Every + // index-addressed command resolves through this same list so `tab list` + // indices and `--index` never disagree. + private listBrowserTabsIncludingParked( + worktreeId: string | undefined + ): BrowserTabListResult['tabs'] { + const live = this.requireAgentBrowserBridge() + .tabList(worktreeId) + .tabs.map((tab) => this.enrichBrowserTabInfo(tab)) + const parked = ( + this.host.getOffscreenBrowserBackend()?.listParkedPages?.(worktreeId) ?? [] + ).map((page, offset): BrowserTabListResult['tabs'][number] => ({ + browserPageId: page.browserPageId, + index: live.length + offset, + url: page.url, + title: page.title, + active: false, + parked: true, + worktreeId: page.worktreeId ?? null, + profileId: page.profileId ?? null, + profileLabel: + browserSessionRegistry.getProfile(page.profileId ?? 'default')?.label ?? + browserSessionRegistry.getDefaultProfile().label + })) + return [...live, ...parked] + } + + /** + * Resolve an `--index` against the listing the caller saw, waking the target + * when it names a parked page. Returns null on desktop, where there are no + * parked pages and the bridge's own index handling still applies. + */ + private async resolveIndexedParkedAwarePageId( + worktreeId: string | undefined, + index: number + ): Promise { + const offscreen = this.host.getOffscreenBrowserBackend() + if (!offscreen?.listParkedPages || this.host.getAvailableAuthoritativeWindow()) { + return null } + const tabs = this.listBrowserTabsIncludingParked(worktreeId) + const chosen = tabs[index] + if (!chosen) { + throw new BrowserError( + 'browser_tab_not_found', + `Tab index ${index} out of range (0-${tabs.length - 1})` + ) + } + if (chosen.parked) { + await offscreen.wakeTab?.(chosen.browserPageId) + } + return chosen.browserPageId } async browserProceedCertificate( @@ -632,7 +731,15 @@ export class RuntimeBrowserCommands { ): Promise { const target = await this.resolveBrowserCommandTarget(params) const bridge = this.requireAgentBrowserBridge() - const result = await bridge.tabSwitch(params.index, target.worktreeId, target.browserPageId) + const indexedPageId = + params.index !== undefined && !target.browserPageId + ? await this.resolveIndexedParkedAwarePageId(target.worktreeId, params.index) + : null + const result = await bridge.tabSwitch( + indexedPageId ? undefined : params.index, + target.worktreeId, + target.browserPageId ?? indexedPageId ?? undefined + ) if (params.focus) { // Why: scope focus to the tab's owning worktree; the renderer never yanks the user across worktrees on this signal (see focusBrowserTabInWorktree). const worktreeId = @@ -1613,18 +1720,23 @@ export class RuntimeBrowserCommands { ? params.worktree ? (await this.host.resolveWorktreeSelector(params.worktree)).id : undefined - : await this.resolveBrowserWorktreeId(params.worktree) + : await this.resolveBrowserWorktreeId(params.worktree, { wakeParkedPage: false }) let tabId: string | null = null if (typeof params.page === 'string' && params.page.length > 0) { tabId = params.page } else if (params.index !== undefined) { - const tabs = bridge.getRegisteredTabs(worktreeId) - const entries = [...tabs.entries()] - if (params.index < 0 || params.index >= entries.length) { - throw new Error(`Tab index ${params.index} out of range (0-${entries.length - 1})`) + // Why (STA-4341): a headless listing includes parked pages, so an index + // must be resolved against that same listing or `tab close --index` can + // destroy a different tab than the one the caller read. + tabId = await this.resolveIndexedParkedAwarePageId(worktreeId, params.index) + if (tabId === null) { + const entries = [...bridge.getRegisteredTabs(worktreeId).entries()] + if (params.index < 0 || params.index >= entries.length) { + throw new Error(`Tab index ${params.index} out of range (0-${entries.length - 1})`) + } + tabId = entries[params.index][0] } - tabId = entries[params.index][0] } else { // Why: try the bridge first; fall back to the renderer for tabs whose webview hasn't mounted yet (e.g. just created). const tabs = bridge.getRegisteredTabs(worktreeId) @@ -1640,11 +1752,20 @@ export class RuntimeBrowserCommands { const offscreen = authoritativeWindow ? null : this.host.getOffscreenBrowserBackend() if (offscreen) { // Why: resolve the active page for implicit close so we don't report success while closing nothing. - const resolvedTabId = tabId ?? bridge.getActivePageId(worktreeId) + const resolvedTabId = + tabId ?? + bridge.getActivePageId(worktreeId) ?? + offscreen.getMostRecentlyUsedParkedPageId?.(worktreeId) ?? + null if (!resolvedTabId) { return { closed: false } } - if (explicitPage && !bridge.getRegisteredTabs(worktreeId).has(resolvedTabId)) { + // Why (STA-4341): a parked page is still open, so closing one must not + // report "not found" — and must not wake a renderer just to destroy it. + const isParked = (offscreen.listParkedPages?.(worktreeId) ?? []).some( + (page) => page.browserPageId === resolvedTabId + ) + if (explicitPage && !isParked && !bridge.getRegisteredTabs(worktreeId).has(resolvedTabId)) { const scope = worktreeId ? ' in this worktree' : '' throw new BrowserError( 'browser_tab_not_found', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 429c3327036..bdbfe09afc0 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6607,6 +6607,15 @@ export class OrcaRuntimeService { return this.offscreenBrowserBackend } + // Why (STA-4341): the headless reclaimer asks the runtime before parking a + // page, so a streamed or mid-command page keeps its renderer. + isBrowserPagePinned(browserPageId: string): boolean { + return ( + this.browserCommands.isBrowserPageStreamed(browserPageId) || + this.agentBrowserBridge?.hasInFlightCommand(browserPageId) === true + ) + } + setEmulatorBridge(bridge: EmulatorBridge | null): void { this.emulatorBridge = bridge } diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 8a00c11c540..a82eea0d563 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -1071,6 +1071,11 @@ export type BrowserTabInfo = { worktreeId?: string | null profileId?: string | null profileLabel?: string | null + // Why (STA-4341): a headless runtime reclaims an idle page's renderer while + // keeping the page. Parked pages stay listed and stay operable — the next + // command targeting one wakes it — but surfacing the state lets an agent see + // that its in-page JavaScript state did not survive. + parked?: boolean } export type BrowserTabListResult = { diff --git a/tests/e2e/headless-serve-browser-tab-reclaim.spec.ts b/tests/e2e/headless-serve-browser-tab-reclaim.spec.ts new file mode 100644 index 00000000000..998c3cfb56f --- /dev/null +++ b/tests/e2e/headless-serve-browser-tab-reclaim.spec.ts @@ -0,0 +1,252 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { expect, test } from './helpers/orca-app' +import { launchHeadlessPairedRuntimeHost } from './helpers/headless-paired-runtime-host' + +// Why (STA-4341): every agent-created browser tab on a headless `orca serve` +// host is backed by its own hidden BrowserWindow, i.e. its own renderer +// process. Before the reclaimer nothing owned them: six tabs meant six +// renderers that survived any amount of idleness, and an animated page kept +// painting at ~96fps in a window no one could ever see. These specs drive the +// exact RPCs `orca tab …` sends against a real serve process and count the +// renderers the host actually holds. + +// Why: an animated canvas is the workload the report measured — it keeps a +// hidden renderer busy, so a leaked renderer costs CPU as well as memory. +const ANIMATED_PAGE = `anim + +` + +// Why: minutes-long production windows would make this spec unrunnable in CI; +// the reclaim policy reads them from the environment so the behaviour under +// test is the shipping one, only faster. +const FAST_RECLAIM_ENV = { + ORCA_HEADLESS_BROWSER_PARK_IDLE_MS: '1500', + ORCA_HEADLESS_BROWSER_PARK_GRACE_MS: '400', + ORCA_HEADLESS_BROWSER_PARK_SWEEP_MS: '250', + ORCA_HEADLESS_BROWSER_RESIDENT_LIMIT: '2' +} + +type HeadlessHost = Awaited> +type BrowserTab = { browserPageId: string; url: string; parked?: boolean } + +async function countOffscreenRenderers(host: HeadlessHost): Promise { + return host.app.evaluate( + ({ BrowserWindow }) => BrowserWindow.getAllWindows().filter((win) => !win.isDestroyed()).length + ) +} + +async function resolveSeededWorktreeId(host: HeadlessHost, testRepoPath: string): Promise { + const added = await host.client.call<{ repo: { id: string } }>('repo.add', { + path: testRepoPath, + kind: 'git' + }) + let worktreeId = '' + await expect + .poll( + async () => { + const listed = await host.client.call<{ worktrees: { id: string }[] }>('worktree.list', { + repo: `id:${added.result.repo.id}` + }) + worktreeId = listed.result.worktrees[0]?.id ?? '' + return worktreeId + }, + { timeout: 30_000 } + ) + .not.toBe('') + return worktreeId +} + +async function startAnimatedPageServer(): Promise<{ url: string; close: () => void }> { + const server: Server = createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) + res.end(ANIMATED_PAGE) + }) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + return { + url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/`, + close: () => server.close() + } +} + +async function createTabs( + host: HeadlessHost, + worktreeId: string, + url: string, + count: number +): Promise { + const pageIds: string[] = [] + for (let index = 0; index < count; index++) { + const created = await host.client.call<{ browserPageId: string }>( + 'browser.tabCreate', + { url, worktree: `id:${worktreeId}` }, + { timeoutMs: 60_000 } + ) + pageIds.push(created.result.browserPageId) + } + return pageIds +} + +async function listTabs(host: HeadlessHost, worktreeId: string): Promise { + const listed = await host.client.call<{ tabs: BrowserTab[] }>('browser.tabList', { + worktree: `id:${worktreeId}` + }) + return listed.result.tabs +} + +test('reclaims idle agent browser tab renderers without losing the tabs', async ({ + testRepoPath +}) => { + test.setTimeout(240_000) + const host = await launchHeadlessPairedRuntimeHost({ extraEnv: FAST_RECLAIM_ENV }) + const pageServer = await startAnimatedPageServer() + try { + const worktreeId = await resolveSeededWorktreeId(host, testRepoPath) + expect(await countOffscreenRenderers(host)).toBe(0) + + const pageIds = await createTabs(host, worktreeId, pageServer.url, 6) + expect(await countOffscreenRenderers(host)).toBe(6) + + // Nothing targets these pages and no client streams them, so every + // renderer must be reclaimed once the idle window elapses. + await expect.poll(() => countOffscreenRenderers(host), { timeout: 30_000 }).toBe(0) + + // The pages themselves survive: an agent holding a page id still sees its + // tab, marked parked, at the address it left it on. + const tabs = await listTabs(host, worktreeId) + expect(tabs.map((tab) => tab.browserPageId).sort()).toEqual([...pageIds].sort()) + expect(tabs.every((tab) => tab.parked === true)).toBe(true) + expect(tabs.every((tab) => tab.url === pageServer.url)).toBe(true) + } finally { + pageServer.close() + await host.dispose() + } +}) + +test('wakes a parked browser tab on the next command that targets it', async ({ testRepoPath }) => { + test.setTimeout(240_000) + const host = await launchHeadlessPairedRuntimeHost({ extraEnv: FAST_RECLAIM_ENV }) + const pageServer = await startAnimatedPageServer() + try { + const worktreeId = await resolveSeededWorktreeId(host, testRepoPath) + const [pageId] = await createTabs(host, worktreeId, pageServer.url, 1) + await expect.poll(() => countOffscreenRenderers(host), { timeout: 30_000 }).toBe(0) + + const evaluated = await host.client.call<{ result: string; origin: string }>( + 'browser.eval', + { expression: 'document.title', page: pageId }, + { timeoutMs: 60_000 } + ) + expect(evaluated.result.result).toBe('anim') + expect(evaluated.result.origin).toBe(pageServer.url) + expect(await countOffscreenRenderers(host)).toBe(1) + + const tabs = await listTabs(host, worktreeId) + expect(tabs).toHaveLength(1) + expect(tabs[0].parked).toBeFalsy() + + // A woken page must still be closable, and closing it must free the renderer. + const closed = await host.client.call<{ closed: boolean }>('browser.tabClose', { page: pageId }) + expect(closed.result.closed).toBe(true) + await expect.poll(() => countOffscreenRenderers(host), { timeout: 15_000 }).toBe(0) + expect(await listTabs(host, worktreeId)).toHaveLength(0) + } finally { + pageServer.close() + await host.dispose() + } +}) + +test('bounds resident renderers by the cap while tabs are still in use', async ({ + testRepoPath +}) => { + test.setTimeout(240_000) + const host = await launchHeadlessPairedRuntimeHost({ + // Why: a long idle window isolates the cap as the evictor — nothing here + // parks because of the clock. + extraEnv: { ...FAST_RECLAIM_ENV, ORCA_HEADLESS_BROWSER_PARK_IDLE_MS: '600000' } + }) + const pageServer = await startAnimatedPageServer() + try { + const worktreeId = await resolveSeededWorktreeId(host, testRepoPath) + const pageIds = await createTabs(host, worktreeId, pageServer.url, 5) + + await expect.poll(() => countOffscreenRenderers(host), { timeout: 30_000 }).toBe(2) + + // The most recently used pages are the ones kept resident. + const tabs = await listTabs(host, worktreeId) + expect(tabs).toHaveLength(5) + const parkedIds = tabs.filter((tab) => tab.parked).map((tab) => tab.browserPageId) + expect(parkedIds.sort()).toEqual(pageIds.slice(0, 3).sort()) + } finally { + pageServer.close() + await host.dispose() + } +}) + +test('resolves --index against the listing that includes parked tabs', async ({ testRepoPath }) => { + test.setTimeout(240_000) + const host = await launchHeadlessPairedRuntimeHost({ + // Why: a tight cap with no idle clock leaves a mixed listing — some tabs + // resident, some parked — which is where an index can address the wrong tab. + extraEnv: { ...FAST_RECLAIM_ENV, ORCA_HEADLESS_BROWSER_PARK_IDLE_MS: '600000' } + }) + const pageServer = await startAnimatedPageServer() + try { + const worktreeId = await resolveSeededWorktreeId(host, testRepoPath) + await createTabs(host, worktreeId, pageServer.url, 5) + await expect.poll(() => countOffscreenRenderers(host), { timeout: 30_000 }).toBe(2) + + const before = await listTabs(host, worktreeId) + expect(before).toHaveLength(5) + // Why: parked pages sit after the resident ones, so the last index is the + // one the bridge's live-only index handling would never have reached. + const targetIndex = before.length - 1 + const targeted = before[targetIndex] + expect(targeted.parked).toBe(true) + + await host.client.call('browser.tabClose', { + index: targetIndex, + worktree: `id:${worktreeId}` + }) + + const after = await listTabs(host, worktreeId) + expect(after.map((tab) => tab.browserPageId)).not.toContain(targeted.browserPageId) + expect(after).toHaveLength(4) + } finally { + pageServer.close() + await host.dispose() + } +}) + +test('keeps a parked tab closable without waking its renderer', async ({ testRepoPath }) => { + test.setTimeout(240_000) + const host = await launchHeadlessPairedRuntimeHost({ extraEnv: FAST_RECLAIM_ENV }) + const pageServer = await startAnimatedPageServer() + try { + const worktreeId = await resolveSeededWorktreeId(host, testRepoPath) + const pageIds = await createTabs(host, worktreeId, pageServer.url, 2) + await expect.poll(() => countOffscreenRenderers(host), { timeout: 30_000 }).toBe(0) + + const closed = await host.client.call<{ closed: boolean }>('browser.tabClose', { + page: pageIds[0] + }) + expect(closed.result.closed).toBe(true) + expect(await countOffscreenRenderers(host)).toBe(0) + + const tabs = await listTabs(host, worktreeId) + expect(tabs.map((tab) => tab.browserPageId)).toEqual([pageIds[1]]) + } finally { + pageServer.close() + await host.dispose() + } +}) diff --git a/tests/e2e/helpers/headless-paired-runtime-host.ts b/tests/e2e/helpers/headless-paired-runtime-host.ts index dec59682938..c3d1ecc1ac5 100644 --- a/tests/e2e/helpers/headless-paired-runtime-host.ts +++ b/tests/e2e/helpers/headless-paired-runtime-host.ts @@ -183,7 +183,9 @@ async function readPairingOffer(app: ElectronApplication): Promise { +export async function launchHeadlessPairedRuntimeHost( + options: { extraEnv?: Record } = {} +): Promise { const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-headless-paired-')) let app: ElectronApplication | undefined try { @@ -200,7 +202,7 @@ export async function launchHeadlessPairedRuntimeHost(): Promise