fix(browser): reclaim idle headless browser tab renderers (STA-4341)

On a headless `orca serve` host every agent-created browser page is backed by
its own hidden BrowserWindow, i.e. its own renderer process, held in a map that
only ever shrank on an explicit `tab close` or process exit. Agents open pages
per measurement and rarely close them, so a long session accumulated renderers
until the host saturated: a reporter measured 9 tabs / 6 renderers at 220-480MB
each, the oldest at 1.3GB after 15h, load 6.85 on 4 cores. A hidden guest also
runs with background throttling disabled, so an animated page kept painting
where nothing could see it.

Reproduced on a real serve process (tests/e2e/headless-serve-browser-tab-reclaim
.spec.ts): 6 tabs -> 6 renderers, still 6 after 20s fully idle, and 2405 rAF
frames in ~25s on a window that is never shown.

Give the offscreen backend a lifecycle owner. It now keeps the page identity an
agent holds — id, address, worktree, profile — and treats the renderer as a
reclaimable resource. An idle page is parked (renderer destroyed, record kept)
and woken by the next command that targets it. Eviction mirrors terminal
hidden-view parking: a resident cap of 4 is the primary evictor, a 5 minute idle
window is the tail, and a page a client streams or that has a command in flight
is never parked. `tab list` keeps listing parked pages so an agent does not read
a reclaimed tab as gone and open yet another one.

Two related defects fall out of the same teardown path. The headless close path
never notified the agent-browser bridge, so every closed page left its helper
session, CDP proxy and listening port behind; park and close now both route
through the bridge before the mapping and the window go away. And because the
listing now spans resident and parked pages, `--index` resolves against that
same listing, so `tab close --index` can no longer address a different tab than
the one the caller read.

Parking discards in-page JavaScript state; cookies and local storage live in the
profile partition and survive. The thresholds are settable via
ORCA_HEADLESS_BROWSER_{RESIDENT_LIMIT,PARK_IDLE_MS,PARK_GRACE_MS,PARK_SWEEP_MS}.
Desktop is untouched — it has no offscreen backend.
This commit is contained in:
Jinwoo-H
2026-08-19 19:07:11 -04:00
parent fcdbcf85d0
commit c7320525eb
16 changed files with 1414 additions and 156 deletions
+32 -1
View File
@@ -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
+5 -1
View File
@@ -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')
}
+6
View File
@@ -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<string, number> {
const all = this.browserManager.getWebContentsIdByTabId()
if (!worktreeId) {
+20
View File
@@ -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<boolean>
/** 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
}
@@ -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<void>>(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<typeof OffscreenBrowserBackend>
manager: BrowserManager
bridge: AgentBrowserBridge
order: string[]
registered: Map<string, number>
windows: FakeWindow[]
clock: { value: number }
}
function createHarness(overrides: { pinned?: Set<string> } = {}): Harness {
const order: string[] = []
const registered = new Map<string, number>()
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<typeof vi.fn> }
bridge.onTabClosed.mockImplementation(
async () => new Promise<void>((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([])
})
})
+291 -127
View File
@@ -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 <webview>, 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<string, BrowserWindow>()
private readonly pages = new Map<string, OffscreenPage>()
/** 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<string, Promise<void>>()
private readonly waking = new Map<string, Promise<boolean>>()
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 <webview> 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<void> {
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<boolean> {
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<boolean> => {
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<void> {
const wc = win.webContents
await new Promise<void>((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<string[]> {
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<void> {
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<void> {
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<void> {
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<void> {
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()
}
}
@@ -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([])
})
})
@@ -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<string>()
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))
}
@@ -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 <webview>, 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 <webview> guests.
...ORCA_BROWSER_GUEST_WEB_PREFERENCES,
partition,
sandbox: true,
contextIsolation: true,
nodeIntegration: false
}
})
}
export async function loadOffscreenBrowserUrl(win: BrowserWindow, url: string): Promise<void> {
const wc = win.webContents
await new Promise<void>((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.
})
})
}
@@ -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(
+6 -1
View File
@@ -3165,7 +3165,12 @@ void app.whenReady().then(async () => {
await runtime.reconcileLegacyWorkerTerminals()
// Why: headless servers can't mount <webview> 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: [] })
+142 -21
View File
@@ -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<string | undefined> {
private async resolveBrowserWorktreeId(
selector?: string,
options: BrowserWorktreeResolutionOptions = {}
): Promise<string | undefined> {
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<boolean> {
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<void> {
private async ensureBrowserWorktreeActive(
worktreeId: string | undefined,
options: BrowserWorktreeResolutionOptions = {}
): Promise<void> {
// 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<BrowserTabListResult> {
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<string | null> {
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<BrowserTabSwitchResult> {
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',
+9
View File
@@ -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
}
+5
View File
@@ -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 = {
@@ -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 = `<!doctype html><meta charset="utf-8"><title>anim</title>
<canvas id="c" width="64" height="64"></canvas>
<script>
window.__frames = 0
const ctx = document.getElementById('c').getContext('2d')
function tick() {
window.__frames++
ctx.fillStyle = 'hsl(' + (window.__frames % 360) + ',80%,50%)'
ctx.fillRect(0, 0, 64, 64)
requestAnimationFrame(tick)
}
requestAnimationFrame(tick)
</script>`
// 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<ReturnType<typeof launchHeadlessPairedRuntimeHost>>
type BrowserTab = { browserPageId: string; url: string; parked?: boolean }
async function countOffscreenRenderers(host: HeadlessHost): Promise<number> {
return host.app.evaluate(
({ BrowserWindow }) => BrowserWindow.getAllWindows().filter((win) => !win.isDestroyed()).length
)
}
async function resolveSeededWorktreeId(host: HeadlessHost, testRepoPath: string): Promise<string> {
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<void>((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<string[]> {
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<BrowserTab[]> {
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()
}
})
@@ -183,7 +183,9 @@ async function readPairingOffer(app: ElectronApplication): Promise<RuntimeDeskto
})
}
export async function launchHeadlessPairedRuntimeHost(): Promise<HeadlessPairedRuntimeHost> {
export async function launchHeadlessPairedRuntimeHost(
options: { extraEnv?: Record<string, string> } = {}
): Promise<HeadlessPairedRuntimeHost> {
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<HeadlessPairedR
ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1',
ORCA_E2E_HEADLESS: '1'
},
extraEnv: {},
extraEnv: options.extraEnv ?? {},
userDataDir
})
const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js')