mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
})
|
||||
})
|
||||
}
|
||||
+3
-3
@@ -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
@@ -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: [] })
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user