mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 00:03:52 +00:00
refactor(browser): share one retention budget and drop the reclaim sweep
Both hosts already bounded live browser renderers, but with two separate implementations: the desktop app evicted hidden worktrees' webview guests on a visibility change, and the headless page reclaimer re-evaluated residency every 15 seconds. The rule they were each expressing turned out to be the same one — keep the most recently used, never evict something in use, optionally shed what has gone stale — so it now lives in src/shared/browser-retention-budget.ts and each host supplies its own configuration and its own trigger. The desktop keeps its exact behaviour: rank-only, no clock (idleMs Infinity, graceMs 0), the active worktree left out of the budget entirely, and isEvictable still consulted lazily. Its 13 tests pass unchanged. Headless drops the recurring sweep. A repeating heartbeat was never needed: the limit can only be crossed when a page is created, woken or closed, and every one of those already re-arms. Only the idle window needs a clock, so the reclaimer now computes the earliest moment its own answer could change and arms one timer for it. A host with nothing resident holds no timer at all, and one pinned by a download or a streamed client re-asks on a bounded cadence because releasing those pins raises no event the backend can observe. Two guards fell out of building it. The deadline and the selector are separate functions, so a fuzz test asserts nothing can become evictable before its deadline — it catches an off-by-one in the rank branch. And a sweep that frees nothing now backs off, measured by observed residency rather than by trusting park(), which no-ops for a window that is already gone; without it a deadline that lands early re-arms at 0ms and spins. Headless semantics change in one case: a stale pinned page no longer forces fresher unpinned pages to be evicted in its place. That is the desktop rule, and the limit was already documented as exceedable by pinned work. Removes ORCA_HEADLESS_BROWSER_PARK_SWEEP_MS, which no longer has a meaning.
This commit is contained in:
@@ -299,15 +299,19 @@ renderer forever, which is the failure this exists to prevent; waking it simply
|
||||
retries the address. Parking never closes a page: the page stays open until
|
||||
something closes it, exactly as on the desktop.
|
||||
|
||||
Two evictors decide what stays resident, mirroring terminal pane parking. The
|
||||
cap is the primary one:
|
||||
What stays resident is the same budget the desktop app applies to its own
|
||||
browser guests (`src/shared/browser-retention-budget.ts`): keep the most
|
||||
recently used, never evict something in use. The desktop learns a working set
|
||||
went cold when you switch worktrees; a headless host has no such event, so it
|
||||
configures the same budget with an idle window and schedules itself a one-shot
|
||||
check for the moment its answer could next change. A server with nothing
|
||||
resident holds no timer at all.
|
||||
|
||||
| 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
|
||||
|
||||
@@ -195,7 +195,6 @@ beforeEach(() => {
|
||||
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', () => {
|
||||
@@ -489,10 +488,9 @@ describe('OffscreenBrowserBackend reclamation', () => {
|
||||
expect(createTimeout).toBeUndefined()
|
||||
})
|
||||
|
||||
it('stops sweeping once nothing is resident, and resumes on wake', async () => {
|
||||
// Why: an idle headless host should not keep waking every interval to find
|
||||
// nothing to reclaim. Waking has to bring the sweep back or the woken page
|
||||
// would never be reclaimed again.
|
||||
it('arms no timer once nothing is resident, and re-arms on wake', async () => {
|
||||
// Why: an idle headless host should hold no reclaim timer at all. Waking has
|
||||
// to re-arm it or the woken page would never be reclaimed again.
|
||||
const h = createHarness()
|
||||
await h.backend.createTab({ url: 'https://a', browserPageId: 'a' })
|
||||
const reclaimer = (h.backend as unknown as { reclaimer: { isScheduled: boolean } }).reclaimer
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 } from './offscreen-browser-page-reclaim'
|
||||
import { readOffscreenBrowserRetentionBudget } from './offscreen-browser-page-reclaim'
|
||||
import { OffscreenBrowserPageReclaimer } from './offscreen-browser-page-reclaimer'
|
||||
import {
|
||||
OffscreenBrowserOpenPages,
|
||||
@@ -47,7 +47,7 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
) {
|
||||
this.reclaimer = new OffscreenBrowserPageReclaimer({
|
||||
pages: this.pages,
|
||||
policy: readOffscreenBrowserReclaimPolicy(),
|
||||
budget: readOffscreenBrowserRetentionBudget(),
|
||||
isReleasing: (browserPageId) => this.releasing.has(browserPageId),
|
||||
isWaking: (browserPageId) => this.waking.has(browserPageId),
|
||||
hasCertificateChallenge: (browserPageId) =>
|
||||
@@ -109,7 +109,7 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
})
|
||||
this.reclaimer.ensureScheduled()
|
||||
this.reclaimer.reschedule()
|
||||
return { browserPageId }
|
||||
}
|
||||
|
||||
@@ -117,9 +117,9 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
const page = this.pages.delete(browserPageId) ?? null
|
||||
this.waking.delete(browserPageId)
|
||||
await this.releaseRenderer(page, browserPageId)
|
||||
if (this.pages.size === 0) {
|
||||
this.reclaimer.stop()
|
||||
}
|
||||
// Why: closing changes both residency and rank, and with the last page gone
|
||||
// this arms nothing at all.
|
||||
this.reclaimer.reschedule()
|
||||
// Why: closing a parked page has no WebContents teardown to piggyback on,
|
||||
// so nothing else tells paired clients the tab is gone — they would keep
|
||||
// showing a ghost until an operation against it failed.
|
||||
@@ -139,7 +139,7 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
if (!page) {
|
||||
return false
|
||||
}
|
||||
page.lastActivityAt = this.now()
|
||||
this.touch(page)
|
||||
// Why: a wake materializes the window before it swaps the helper session
|
||||
// and reloads the address, so the page looks resident well before it is
|
||||
// usable. Join an in-flight wake first or a second command runs against a
|
||||
@@ -184,10 +184,9 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
if (!stillOwned()) {
|
||||
return false
|
||||
}
|
||||
page.lastActivityAt = this.now()
|
||||
// Why: the sweep stops once nothing is resident, so a wake has to bring
|
||||
// it back or this page would never be reclaimed again.
|
||||
this.reclaimer.ensureScheduled()
|
||||
// Why: waking makes this page resident again, so the budget has to be
|
||||
// re-armed or the page it just displaced would never be reclaimed.
|
||||
this.touch(page)
|
||||
return true
|
||||
})()
|
||||
this.waking.set(browserPageId, wake)
|
||||
@@ -360,9 +359,7 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
?.onTabClosed(webContentsId)
|
||||
.catch(() => {})
|
||||
this.browserManager.unregisterGuest(page.browserPageId)
|
||||
if (this.pages.size === 0) {
|
||||
this.reclaimer.stop()
|
||||
}
|
||||
this.reclaimer.reschedule()
|
||||
})
|
||||
const profile = page.profileId ? browserSessionRegistry.getProfile(page.profileId) : null
|
||||
this.browserManager.registerOffscreenGuest({
|
||||
@@ -393,10 +390,17 @@ export class OffscreenBrowserBackend implements BrowserBackend {
|
||||
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()
|
||||
this.touch(page)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: every activity stamp moves the next reclaim deadline, so pairing the
|
||||
// two here makes it impossible to record use without re-arming the budget.
|
||||
private touch(page: OffscreenBrowserPage): void {
|
||||
page.lastActivityAt = this.now()
|
||||
this.reclaimer.reschedule()
|
||||
}
|
||||
|
||||
private now(): number {
|
||||
return this.options.now?.() ?? Date.now()
|
||||
}
|
||||
|
||||
@@ -1,98 +1,161 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
selectOffscreenBrowserPagesToPark,
|
||||
type OffscreenBrowserReclaimCandidate,
|
||||
type OffscreenBrowserReclaimPolicy
|
||||
selectBrowserRetentionEvictions,
|
||||
type BrowserRetentionBudget,
|
||||
type BrowserRetentionCandidate
|
||||
} from '../../shared/browser-retention-budget'
|
||||
import {
|
||||
nextOffscreenBrowserReclaimCheckAt,
|
||||
readOffscreenBrowserRetentionBudget,
|
||||
OFFSCREEN_BROWSER_IDLE_PARK_MS,
|
||||
OFFSCREEN_BROWSER_PARK_GRACE_MS,
|
||||
OFFSCREEN_BROWSER_PINNED_RECHECK_MS,
|
||||
OFFSCREEN_BROWSER_RESIDENT_LIMIT
|
||||
} from './offscreen-browser-page-reclaim'
|
||||
|
||||
const NOW = 1_000_000
|
||||
|
||||
const POLICY: OffscreenBrowserReclaimPolicy = {
|
||||
residentLimit: 2,
|
||||
idleParkMs: 60_000,
|
||||
parkGraceMs: 5_000,
|
||||
sweepIntervalMs: 1_000
|
||||
const BUDGET: BrowserRetentionBudget = {
|
||||
limit: 2,
|
||||
idleMs: 60_000,
|
||||
graceMs: 5_000
|
||||
}
|
||||
|
||||
function page(
|
||||
browserPageId: string,
|
||||
idleMs: number,
|
||||
pinned = false
|
||||
): OffscreenBrowserReclaimCandidate {
|
||||
return { browserPageId, lastActivityAt: NOW - idleMs, pinned }
|
||||
function used(id: string, msAgo: number): BrowserRetentionCandidate {
|
||||
return { id, lastActivityAt: NOW - msAgo }
|
||||
}
|
||||
|
||||
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([])
|
||||
function nextCheck(
|
||||
candidates: readonly BrowserRetentionCandidate[],
|
||||
pinned: readonly string[] = [],
|
||||
now = NOW,
|
||||
budget = BUDGET
|
||||
): number | null {
|
||||
return nextOffscreenBrowserReclaimCheckAt({
|
||||
candidates,
|
||||
isPinned: (id) => pinned.includes(id),
|
||||
now,
|
||||
budget
|
||||
})
|
||||
}
|
||||
|
||||
describe('readOffscreenBrowserRetentionBudget', () => {
|
||||
const KEYS = [
|
||||
'ORCA_HEADLESS_BROWSER_RESIDENT_LIMIT',
|
||||
'ORCA_HEADLESS_BROWSER_PARK_IDLE_MS',
|
||||
'ORCA_HEADLESS_BROWSER_PARK_GRACE_MS'
|
||||
]
|
||||
afterEach(() => {
|
||||
for (const key of KEYS) {
|
||||
delete process.env[key]
|
||||
}
|
||||
})
|
||||
|
||||
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('defaults to the shipped policy', () => {
|
||||
expect(readOffscreenBrowserRetentionBudget()).toEqual({
|
||||
limit: OFFSCREEN_BROWSER_RESIDENT_LIMIT,
|
||||
idleMs: OFFSCREEN_BROWSER_IDLE_PARK_MS,
|
||||
graceMs: OFFSCREEN_BROWSER_PARK_GRACE_MS
|
||||
})
|
||||
})
|
||||
|
||||
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('reads each knob from the environment', () => {
|
||||
process.env.ORCA_HEADLESS_BROWSER_RESIDENT_LIMIT = '7'
|
||||
process.env.ORCA_HEADLESS_BROWSER_PARK_IDLE_MS = '1234'
|
||||
process.env.ORCA_HEADLESS_BROWSER_PARK_GRACE_MS = '56'
|
||||
expect(readOffscreenBrowserRetentionBudget()).toEqual({
|
||||
limit: 7,
|
||||
idleMs: 1234,
|
||||
graceMs: 56
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
it('ignores a malformed knob rather than disabling the budget', () => {
|
||||
process.env.ORCA_HEADLESS_BROWSER_RESIDENT_LIMIT = 'lots'
|
||||
expect(readOffscreenBrowserRetentionBudget().limit).toBe(OFFSCREEN_BROWSER_RESIDENT_LIMIT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextOffscreenBrowserReclaimCheckAt', () => {
|
||||
it('arms nothing when there is nothing resident', () => {
|
||||
expect(nextCheck([])).toBeNull()
|
||||
})
|
||||
|
||||
it('waits out the idle window for a page within the limit', () => {
|
||||
expect(nextCheck([used('a', 10_000)])).toBe(NOW - 10_000 + BUDGET.idleMs)
|
||||
})
|
||||
|
||||
it('waits only out the grace floor for a page beyond the limit', () => {
|
||||
const candidates = [used('a', 1_000), used('b', 2_000), used('over', 3_000)]
|
||||
expect(nextCheck(candidates)).toBe(NOW - 3_000 + BUDGET.graceMs)
|
||||
})
|
||||
|
||||
it('takes the earliest deadline across pages', () => {
|
||||
const candidates = [used('a', 1_000), used('b', 59_000)]
|
||||
expect(nextCheck(candidates)).toBe(NOW - 59_000 + BUDGET.idleMs)
|
||||
})
|
||||
|
||||
it('re-asks on a bounded cadence for a pinned page whose release it cannot observe', () => {
|
||||
expect(nextCheck([used('a', 90_000)], ['a'])).toBe(NOW + OFFSCREEN_BROWSER_PINNED_RECHECK_MS)
|
||||
})
|
||||
|
||||
it('arms nothing when a page can never age out', () => {
|
||||
// A single page within a limit that no clock rule can evict.
|
||||
expect(
|
||||
selectOffscreenBrowserPagesToPark([page('a', 10_000_000), page('b', 10_000_000)], NOW, {
|
||||
...POLICY,
|
||||
residentLimit: Number.MAX_SAFE_INTEGER,
|
||||
idleParkMs: Number.MAX_SAFE_INTEGER
|
||||
nextCheck([used('a', 10)], [], NOW, {
|
||||
...BUDGET,
|
||||
idleMs: Number.POSITIVE_INFINITY
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('returns a past deadline when a page is already evictable', () => {
|
||||
expect(nextCheck([used('a', 90_000)])).toBeLessThanOrEqual(NOW)
|
||||
})
|
||||
})
|
||||
|
||||
describe('the deadline never sleeps through an eviction', () => {
|
||||
// Why: the timer replaced a fixed sweep, so a deadline that lands after a page
|
||||
// becomes evictable would silently leak exactly the renderers this reclaims.
|
||||
// Fuzzed deterministically: no state may become evictable before its deadline.
|
||||
it('holds across randomised resident sets', () => {
|
||||
let seed = 42
|
||||
const random = (bound: number): number => {
|
||||
seed = (seed * 1_103_515_245 + 12_345) % 2_147_483_648
|
||||
return seed % bound
|
||||
}
|
||||
for (let trial = 0; trial < 500; trial++) {
|
||||
const size = random(6)
|
||||
const candidates: BrowserRetentionCandidate[] = []
|
||||
for (let index = 0; index < size; index++) {
|
||||
// Why the mix: uniform ages almost never land inside the grace floor,
|
||||
// which is exactly the boundary a wrong deadline overshoots.
|
||||
const msAgo = random(2) === 0 ? random(2 * BUDGET.graceMs) : random(200_000)
|
||||
candidates.push(used(`p${index}`, msAgo))
|
||||
}
|
||||
candidates.sort((left, right) => (right.lastActivityAt ?? 0) - (left.lastActivityAt ?? 0))
|
||||
const pinned = candidates.filter(() => random(4) === 0).map((candidate) => candidate.id)
|
||||
const isPinned = (id: string): boolean => pinned.includes(id)
|
||||
const evictableNow = selectBrowserRetentionEvictions({
|
||||
candidates,
|
||||
isPinned,
|
||||
now: NOW,
|
||||
budget: BUDGET
|
||||
})
|
||||
).toEqual([])
|
||||
if (evictableNow.length > 0) {
|
||||
continue
|
||||
}
|
||||
const deadline = nextCheck(candidates, pinned)
|
||||
const horizon = deadline === null ? NOW + 10 * BUDGET.idleMs : deadline
|
||||
for (let at = NOW; at < horizon; at += Math.max(1, Math.floor((horizon - NOW) / 20))) {
|
||||
expect(
|
||||
selectBrowserRetentionEvictions({
|
||||
candidates,
|
||||
isPinned,
|
||||
now: at,
|
||||
budget: BUDGET
|
||||
})
|
||||
).toEqual([])
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,35 +1,30 @@
|
||||
import {
|
||||
BROWSER_RETENTION_LIMIT,
|
||||
type BrowserRetentionBudget,
|
||||
type BrowserRetentionCandidate
|
||||
} from '../../shared/browser-retention-budget'
|
||||
|
||||
// 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.
|
||||
// them until the host saturated. This module configures the shared browser
|
||||
// retention budget for that host and answers when the decision could next
|
||||
// change — a headless host has no visibility event to react to, so it has to
|
||||
// know its own deadlines.
|
||||
|
||||
/** 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. */
|
||||
/** Resident renderers kept warm. The desktop guest budget uses the same 4. */
|
||||
export const OFFSCREEN_BROWSER_RESIDENT_LIMIT = BROWSER_RETENTION_LIMIT
|
||||
/** An untouched page parks once idle this long, even under the limit. */
|
||||
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
|
||||
}
|
||||
// Why a re-check at all: a page pinned by a download, a streamed client or a
|
||||
// certificate prompt releases that pin with no event this backend observes, so
|
||||
// its deadline is unknowable. Re-asking on the grace cadence is bounded, runs
|
||||
// only while something is genuinely in flight, and stops with the last pin —
|
||||
// unlike a fixed sweep, which ran forever on a host doing nothing.
|
||||
export const OFFSCREEN_BROWSER_PINNED_RECHECK_MS = OFFSCREEN_BROWSER_PARK_GRACE_MS
|
||||
|
||||
function readPositiveIntEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name]
|
||||
@@ -40,69 +35,60 @@ function readPositiveIntEnv(name: string, fallback: number): number {
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
export function readOffscreenBrowserReclaimPolicy(): OffscreenBrowserReclaimPolicy {
|
||||
export function readOffscreenBrowserRetentionBudget(): BrowserRetentionBudget {
|
||||
return {
|
||||
residentLimit: readPositiveIntEnv(
|
||||
limit: readPositiveIntEnv(
|
||||
'ORCA_HEADLESS_BROWSER_RESIDENT_LIMIT',
|
||||
OFFSCREEN_BROWSER_RESIDENT_LIMIT
|
||||
),
|
||||
idleParkMs: readPositiveIntEnv(
|
||||
idleMs: readPositiveIntEnv(
|
||||
'ORCA_HEADLESS_BROWSER_PARK_IDLE_MS',
|
||||
OFFSCREEN_BROWSER_IDLE_PARK_MS
|
||||
),
|
||||
parkGraceMs: readPositiveIntEnv(
|
||||
graceMs: 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.
|
||||
* The earliest time selectBrowserRetentionEvictions could return something new
|
||||
* if nothing else happens, or null if only an event can change the answer.
|
||||
*
|
||||
* 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.
|
||||
* Rank changes only when a page is created, woken or closed, and every one of
|
||||
* those already re-arms the timer — so the clock only has to cover the idle and
|
||||
* grace windows.
|
||||
*/
|
||||
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)
|
||||
export function nextOffscreenBrowserReclaimCheckAt(args: {
|
||||
/** Most-recently-used first, matching the selector's ranking. */
|
||||
candidates: readonly BrowserRetentionCandidate[]
|
||||
isPinned: (id: string) => boolean
|
||||
now: number
|
||||
budget: BrowserRetentionBudget
|
||||
}): number | null {
|
||||
let next: number | null = null
|
||||
const consider = (at: number): void => {
|
||||
if (!Number.isFinite(at)) {
|
||||
return
|
||||
}
|
||||
next = next === null ? at : Math.min(next, at)
|
||||
}
|
||||
|
||||
// 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
|
||||
args.candidates.forEach((candidate, rank) => {
|
||||
if (args.isPinned(candidate.id)) {
|
||||
consider(args.now + OFFSCREEN_BROWSER_PINNED_RECHECK_MS)
|
||||
return
|
||||
}
|
||||
if (parked.has(page.browserPageId)) {
|
||||
continue
|
||||
if (candidate.lastActivityAt === undefined) {
|
||||
return
|
||||
}
|
||||
parked.add(page.browserPageId)
|
||||
resident--
|
||||
}
|
||||
|
||||
return parkable.map((page) => page.browserPageId).filter((id) => parked.has(id))
|
||||
if (rank >= args.budget.limit) {
|
||||
// Grace is the only thing still holding it resident.
|
||||
consider(candidate.lastActivityAt + args.budget.graceMs)
|
||||
return
|
||||
}
|
||||
// Within the limit only the idle window can evict it, and grace still applies.
|
||||
consider(candidate.lastActivityAt + Math.max(args.budget.graceMs, args.budget.idleMs))
|
||||
})
|
||||
return next
|
||||
}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserRetentionBudget } from '../../shared/browser-retention-budget'
|
||||
import { OffscreenBrowserPageReclaimer } from './offscreen-browser-page-reclaimer'
|
||||
import type {
|
||||
OffscreenBrowserOpenPages,
|
||||
OffscreenBrowserPage
|
||||
} from './offscreen-browser-open-pages'
|
||||
|
||||
// Why: the reclaimer replaced a fixed sweep with a deadline it computes itself,
|
||||
// so the scheduling contract is now load-bearing — an idle host must hold no
|
||||
// timer, a woken page must re-arm one, and a deadline that fires with nothing to
|
||||
// do must never re-arm at zero.
|
||||
|
||||
const BUDGET: BrowserRetentionBudget = {
|
||||
limit: 2,
|
||||
idleMs: 60_000,
|
||||
graceMs: 5_000
|
||||
}
|
||||
|
||||
function page(browserPageId: string, lastActivityAt: number): OffscreenBrowserPage {
|
||||
return {
|
||||
browserPageId,
|
||||
worktreeId: undefined,
|
||||
profileId: undefined,
|
||||
partition: 'persist:test',
|
||||
url: 'https://example.test/',
|
||||
title: '',
|
||||
window: {} as OffscreenBrowserPage['window'],
|
||||
activeWhenParked: false,
|
||||
loading: false,
|
||||
loadError: null,
|
||||
lastActivityAt
|
||||
}
|
||||
}
|
||||
|
||||
function createReclaimer(
|
||||
args: {
|
||||
pages?: OffscreenBrowserPage[]
|
||||
pinned?: readonly string[]
|
||||
park?: (browserPageId: string, live: Map<string, OffscreenBrowserPage>) => Promise<void>
|
||||
} = {}
|
||||
) {
|
||||
const live = new Map<string, OffscreenBrowserPage>(
|
||||
(args.pages ?? []).map((entry) => [entry.browserPageId, entry])
|
||||
)
|
||||
const clock = { value: 1_000_000 }
|
||||
const parked: string[] = []
|
||||
const pages = {
|
||||
get: (browserPageId: string) => live.get(browserPageId),
|
||||
resident: () => [...live.values()]
|
||||
} as unknown as OffscreenBrowserOpenPages
|
||||
const reclaimer = new OffscreenBrowserPageReclaimer({
|
||||
pages,
|
||||
budget: BUDGET,
|
||||
isReleasing: () => false,
|
||||
isWaking: () => false,
|
||||
hasCertificateChallenge: () => false,
|
||||
hasActiveDownload: () => false,
|
||||
isHostPinned: (browserPageId) => (args.pinned ?? []).includes(browserPageId),
|
||||
park: async (browserPageId) => {
|
||||
if (args.park) {
|
||||
await args.park(browserPageId, live)
|
||||
return
|
||||
}
|
||||
live.delete(browserPageId)
|
||||
parked.push(browserPageId)
|
||||
},
|
||||
now: () => clock.value
|
||||
})
|
||||
return { reclaimer, clock, live, parked }
|
||||
}
|
||||
|
||||
describe('OffscreenBrowserPageReclaimer scheduling', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('arms nothing when no page is resident', () => {
|
||||
const { reclaimer } = createReclaimer()
|
||||
reclaimer.reschedule()
|
||||
expect(reclaimer.isScheduled).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('arms the idle deadline for a page inside the limit', () => {
|
||||
const { reclaimer, clock } = createReclaimer({
|
||||
pages: [page('a', 1_000_000)]
|
||||
})
|
||||
reclaimer.reschedule()
|
||||
expect(reclaimer.isScheduled).toBe(true)
|
||||
|
||||
// Nothing fires before the idle window closes.
|
||||
clock.value += BUDGET.idleMs - 1
|
||||
vi.advanceTimersByTime(BUDGET.idleMs - 1)
|
||||
expect(reclaimer.isScheduled).toBe(true)
|
||||
})
|
||||
|
||||
it('parks on its own deadline with no periodic sweep in between', async () => {
|
||||
const { reclaimer, clock, parked } = createReclaimer({
|
||||
pages: [page('a', 1_000_000)]
|
||||
})
|
||||
reclaimer.reschedule()
|
||||
|
||||
clock.value += BUDGET.idleMs
|
||||
await vi.advanceTimersByTimeAsync(BUDGET.idleMs)
|
||||
|
||||
expect(parked).toEqual(['a'])
|
||||
// Why: the last resident page is gone, so the host goes quiet entirely.
|
||||
expect(reclaimer.isScheduled).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('parks the coldest page first when the limit is exceeded', async () => {
|
||||
const { reclaimer, clock, parked } = createReclaimer({
|
||||
pages: [page('newest', 1_000_000), page('middle', 990_000), page('oldest', 980_000)]
|
||||
})
|
||||
clock.value += BUDGET.graceMs
|
||||
reclaimer.reschedule()
|
||||
await vi.advanceTimersByTimeAsync(BUDGET.graceMs)
|
||||
|
||||
expect(parked).toEqual(['oldest'])
|
||||
})
|
||||
|
||||
it('re-asks on a bounded cadence while a pin holds a page over the limit', async () => {
|
||||
const { reclaimer, clock, parked } = createReclaimer({
|
||||
pages: [page('a', 1_000_000), page('b', 1_000_000), page('pinned', 900_000)],
|
||||
pinned: ['pinned']
|
||||
})
|
||||
clock.value += BUDGET.graceMs
|
||||
reclaimer.reschedule()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(BUDGET.graceMs * 4)
|
||||
expect(parked).toEqual([])
|
||||
// Still watching, because releasing that pin raises no event this can see.
|
||||
expect(reclaimer.isScheduled).toBe(true)
|
||||
})
|
||||
|
||||
it('never re-arms at zero after a sweep that parks nothing', async () => {
|
||||
// Why: a deadline that lands earlier than the moment a page truly becomes
|
||||
// evictable would otherwise spin the timer flat out on an idle server.
|
||||
let attempts = 0
|
||||
const { reclaimer, clock } = createReclaimer({
|
||||
pages: [page('a', 1_000_000), page('b', 1_000_000), page('stuck', 500_000)],
|
||||
park: async (browserPageId, live) => {
|
||||
if (browserPageId !== 'stuck') {
|
||||
return
|
||||
}
|
||||
attempts += 1
|
||||
// Why the escape hatch: without the backoff this re-arms at 0ms, and a
|
||||
// test that hangs reports far worse than one that fails.
|
||||
if (attempts > 100) {
|
||||
live.delete(browserPageId)
|
||||
}
|
||||
}
|
||||
})
|
||||
clock.value += BUDGET.graceMs
|
||||
reclaimer.reschedule()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(BUDGET.graceMs * 10)
|
||||
// One try per backoff window, not an unbounded spin.
|
||||
expect(attempts).toBeGreaterThan(0)
|
||||
expect(attempts).toBeLessThanOrEqual(11)
|
||||
})
|
||||
|
||||
it('stops holding a timer once stopped', () => {
|
||||
const { reclaimer } = createReclaimer({ pages: [page('a', 1_000_000)] })
|
||||
reclaimer.reschedule()
|
||||
expect(reclaimer.isScheduled).toBe(true)
|
||||
reclaimer.stop()
|
||||
expect(reclaimer.isScheduled).toBe(false)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,25 @@
|
||||
import {
|
||||
selectBrowserRetentionEvictions,
|
||||
isBrowserRetentionCandidateInGrace,
|
||||
type BrowserRetentionBudget,
|
||||
type BrowserRetentionCandidate
|
||||
} from '../../shared/browser-retention-budget'
|
||||
import type {
|
||||
OffscreenBrowserOpenPages,
|
||||
OffscreenBrowserPage
|
||||
} from './offscreen-browser-open-pages'
|
||||
import {
|
||||
selectOffscreenBrowserPagesToPark,
|
||||
type OffscreenBrowserReclaimPolicy
|
||||
} from './offscreen-browser-page-reclaim'
|
||||
import { nextOffscreenBrowserReclaimCheckAt } from './offscreen-browser-page-reclaim'
|
||||
|
||||
// Why (STA-4341): the sweep that decides which headless pages keep a renderer,
|
||||
// separated from the backend that owns the renderers themselves. It is the
|
||||
// headless counterpart of the desktop guest budget in
|
||||
// browser-guest-worktree-retention.ts — same intent, different unit: desktop
|
||||
// evicts a hidden worktree's guests on UI visibility, headless evicts an
|
||||
// individual page on command activity, because a headless host has no UI and an
|
||||
// agent lives in one worktree.
|
||||
// Why (STA-4341): the trigger half of the headless page budget — it decides
|
||||
// *when* to ask, while the shared browser retention budget decides *what* to
|
||||
// evict. The desktop guest budget gets its trigger for free from a UI
|
||||
// visibility change; a headless host has no such event, so this arms a one-shot
|
||||
// timer for the moment its own answer could next change. There is deliberately
|
||||
// no recurring sweep: a host doing nothing holds no timer at all.
|
||||
|
||||
export type OffscreenBrowserReclaimerDeps = {
|
||||
pages: OffscreenBrowserOpenPages
|
||||
policy: OffscreenBrowserReclaimPolicy
|
||||
budget: BrowserRetentionBudget
|
||||
/** A teardown this backend already started for the page. */
|
||||
isReleasing: (browserPageId: string) => boolean
|
||||
/** A wake still rebuilding the page's renderer. */
|
||||
@@ -33,23 +35,102 @@ export type OffscreenBrowserReclaimerDeps = {
|
||||
}
|
||||
|
||||
export class OffscreenBrowserPageReclaimer {
|
||||
private sweepTimer: NodeJS.Timeout | null = null
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private sweepInFlight: Promise<unknown> | null = null
|
||||
/** Set when a sweep parked nothing — see reschedule(). */
|
||||
private backoffUntil = 0
|
||||
|
||||
constructor(private readonly deps: OffscreenBrowserReclaimerDeps) {}
|
||||
|
||||
/** Park every page the policy no longer wants resident. */
|
||||
/** Park every page the budget no longer wants resident, then re-arm. */
|
||||
async sweep(): Promise<string[]> {
|
||||
const resident = this.deps.pages
|
||||
.resident()
|
||||
.filter((page) => !this.deps.isReleasing(page.browserPageId))
|
||||
const doomed = selectOffscreenBrowserPagesToPark(
|
||||
resident.map((page) => this.toCandidate(page)),
|
||||
this.deps.now(),
|
||||
this.deps.policy
|
||||
)
|
||||
const parked = await this.parkOverBudgetPages()
|
||||
this.reschedule()
|
||||
return parked
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-arm the timer for the next moment the answer could change.
|
||||
*
|
||||
* Call after anything that moves a page's activity, residency or pin state.
|
||||
* Recomputing from live state means a missed call can only delay a park,
|
||||
* never schedule a wrong one, and an all-parked host arms nothing.
|
||||
*/
|
||||
reschedule(): void {
|
||||
this.clearTimer()
|
||||
// Why: a sweep re-arms itself when it settles. Arming now would race it
|
||||
// against state the in-flight parks are still changing.
|
||||
if (this.sweepInFlight) {
|
||||
return
|
||||
}
|
||||
const now = this.deps.now()
|
||||
const at = nextOffscreenBrowserReclaimCheckAt({
|
||||
candidates: this.candidates(),
|
||||
isPinned: (browserPageId) => this.isPinnedById(browserPageId),
|
||||
now,
|
||||
budget: this.deps.budget
|
||||
})
|
||||
if (at === null) {
|
||||
return
|
||||
}
|
||||
// Why the floor: a deadline in the past that the last sweep could not act on
|
||||
// would re-arm at 0ms, and a timer that re-arms itself instantly is a pegged
|
||||
// CPU on an otherwise idle server.
|
||||
const delay = Math.max(0, at - now, this.backoffUntil - now)
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
this.runSweep()
|
||||
}, delay)
|
||||
// Why: reclamation must never be the reason the process stays alive.
|
||||
this.timer.unref?.()
|
||||
}
|
||||
|
||||
/** Whether a check is currently armed. Exposed so the owner can assert it. */
|
||||
get isScheduled(): boolean {
|
||||
return this.timer !== null
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.clearTimer()
|
||||
this.sweepInFlight = null
|
||||
this.backoffUntil = 0
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
private runSweep(): void {
|
||||
if (this.sweepInFlight) {
|
||||
return
|
||||
}
|
||||
const sweep = this.sweep().catch(() => {
|
||||
// A failed park is retried on the next check.
|
||||
})
|
||||
this.sweepInFlight = sweep
|
||||
void sweep.finally(() => {
|
||||
if (this.sweepInFlight === sweep) {
|
||||
this.sweepInFlight = null
|
||||
this.reschedule()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async parkOverBudgetPages(): Promise<string[]> {
|
||||
const residentBefore = this.deps.pages.resident().length
|
||||
const doomed = selectBrowserRetentionEvictions({
|
||||
candidates: this.candidates(),
|
||||
isPinned: (browserPageId) => this.isPinnedById(browserPageId),
|
||||
now: this.deps.now(),
|
||||
budget: this.deps.budget
|
||||
})
|
||||
const parked: string[] = []
|
||||
for (const browserPageId of doomed) {
|
||||
// Why reversed: the selector ranks most-recently-used first, so this parks
|
||||
// the coldest page first — the one least likely to be woken mid-teardown.
|
||||
for (const browserPageId of doomed.toReversed()) {
|
||||
// Why: parking awaits the helper session's teardown, so a page later in
|
||||
// this list can be woken and driven while an earlier one is still being
|
||||
// torn down. The selection is a proposal, not a licence — re-check each
|
||||
@@ -60,51 +141,29 @@ export class OffscreenBrowserPageReclaimer {
|
||||
await this.deps.park(browserPageId)
|
||||
parked.push(browserPageId)
|
||||
}
|
||||
// Why: with nothing resident there is nothing left to reclaim, so the sweep
|
||||
// has no work until a page is created or woken — both of which reschedule
|
||||
// it. Otherwise an idle host keeps waking up forever to find nothing.
|
||||
if (this.deps.pages.resident().length === 0) {
|
||||
this.stop()
|
||||
}
|
||||
// Why measured rather than reported: a sweep that frees nothing leaves the
|
||||
// deadline in the past, and re-arming at 0ms would spin the timer flat out.
|
||||
// park() cannot be trusted to say so — it no-ops for a window that is
|
||||
// already gone — so residency is the only honest signal of progress.
|
||||
const madeProgress = this.deps.pages.resident().length < residentBefore
|
||||
this.backoffUntil = madeProgress ? 0 : this.deps.now() + this.deps.budget.graceMs
|
||||
return parked
|
||||
}
|
||||
|
||||
/** Whether a sweep is currently scheduled. Exposed so the owner can assert it. */
|
||||
get isScheduled(): boolean {
|
||||
return this.sweepTimer !== null
|
||||
}
|
||||
|
||||
ensureScheduled(): void {
|
||||
if (this.sweepTimer) {
|
||||
return
|
||||
}
|
||||
this.sweepTimer = setInterval(() => {
|
||||
// Why: a park waits on the helper session's own bounded teardown, which
|
||||
// can outlast the interval. Without this guard slow teardowns would stack
|
||||
// sweeps on top of each other for as long as they lag.
|
||||
if (this.sweepInFlight) {
|
||||
return
|
||||
}
|
||||
const sweep = this.sweep().catch(() => {
|
||||
// A failed park is retried on the next sweep.
|
||||
})
|
||||
this.sweepInFlight = sweep
|
||||
void sweep.finally(() => {
|
||||
if (this.sweepInFlight === sweep) {
|
||||
this.sweepInFlight = null
|
||||
}
|
||||
})
|
||||
}, this.deps.policy.sweepIntervalMs)
|
||||
// Why: reclamation must never be the reason the process stays alive.
|
||||
this.sweepTimer.unref?.()
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.sweepTimer) {
|
||||
clearInterval(this.sweepTimer)
|
||||
this.sweepTimer = null
|
||||
}
|
||||
this.sweepInFlight = null
|
||||
/** Resident pages the budget can rank, most recently used first. */
|
||||
private candidates(): BrowserRetentionCandidate[] {
|
||||
return this.deps.pages
|
||||
.resident()
|
||||
.filter((page) => !this.deps.isReleasing(page.browserPageId))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
right.lastActivityAt - left.lastActivityAt ||
|
||||
left.browserPageId.localeCompare(right.browserPageId)
|
||||
)
|
||||
.map((page) => ({
|
||||
id: page.browserPageId,
|
||||
lastActivityAt: page.lastActivityAt
|
||||
}))
|
||||
}
|
||||
|
||||
// Why: a page is off limits while anything depends on its renderer — a client
|
||||
@@ -128,16 +187,9 @@ export class OffscreenBrowserPageReclaimer {
|
||||
)
|
||||
}
|
||||
|
||||
private toCandidate(page: OffscreenBrowserPage): {
|
||||
browserPageId: string
|
||||
lastActivityAt: number
|
||||
pinned: boolean
|
||||
} {
|
||||
return {
|
||||
browserPageId: page.browserPageId,
|
||||
lastActivityAt: page.lastActivityAt,
|
||||
pinned: this.isPinned(page)
|
||||
}
|
||||
private isPinnedById(browserPageId: string): boolean {
|
||||
const page = this.deps.pages.get(browserPageId)
|
||||
return page ? this.isPinned(page) : false
|
||||
}
|
||||
|
||||
private isSafeToReclaim(browserPageId: string): boolean {
|
||||
@@ -145,8 +197,13 @@ export class OffscreenBrowserPageReclaimer {
|
||||
if (!page || this.deps.isReleasing(browserPageId)) {
|
||||
return false
|
||||
}
|
||||
return (
|
||||
!this.isPinned(page) && this.deps.now() - page.lastActivityAt >= this.deps.policy.parkGraceMs
|
||||
if (this.isPinned(page)) {
|
||||
return false
|
||||
}
|
||||
return !isBrowserRetentionCandidateInGrace(
|
||||
{ id: browserPageId, lastActivityAt: page.lastActivityAt },
|
||||
this.deps.now(),
|
||||
this.deps.budget
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+35
-19
@@ -1,11 +1,25 @@
|
||||
import type { BrowserPage, BrowserWorkspace } from '../../../../../shared/browser-workspace-types'
|
||||
import {
|
||||
BROWSER_RETENTION_LIMIT,
|
||||
selectBrowserRetentionEvictions
|
||||
} from '../../../../../shared/browser-retention-budget'
|
||||
|
||||
// Why 4: every hidden worktree that retains browser guests keeps one Electron
|
||||
// guest process per page alive purely for instant revisits, so guest memory
|
||||
// grew linearly with worktrees visited (#12137). Four hidden worktrees covers
|
||||
// the common switch-back working set; older ones are destroyed and rebuild
|
||||
// from persisted tab state on the next visit.
|
||||
export const BROWSER_GUEST_HIDDEN_WORKTREE_RETENTION_LIMIT = 4
|
||||
export const BROWSER_GUEST_HIDDEN_WORKTREE_RETENTION_LIMIT = BROWSER_RETENTION_LIMIT
|
||||
|
||||
// Why no clock: switching worktrees is the signal that a working set went cold,
|
||||
// so this host never needs to ask how long ago something was used. The headless
|
||||
// host has no such signal and configures the same budget with an idle window
|
||||
// (offscreen-browser-page-reclaim.ts).
|
||||
const HIDDEN_WORKTREE_BUDGET = {
|
||||
limit: BROWSER_GUEST_HIDDEN_WORKTREE_RETENTION_LIMIT,
|
||||
idleMs: Number.POSITIVE_INFINITY,
|
||||
graceMs: 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden worktrees whose retained guests fall beyond the budget, LRU-first.
|
||||
@@ -13,10 +27,10 @@ export const BROWSER_GUEST_HIDDEN_WORKTREE_RETENTION_LIMIT = 4
|
||||
* orderedWorktreeIds must be most-recently-activated first (LRU order =
|
||||
* worktree activation order). Only worktrees that actually hold live guests
|
||||
* count toward the limit. The active worktree never counts and is never
|
||||
* evicted. isEvictable is consulted lazily, only for worktrees beyond the
|
||||
* limit — a non-evictable one (a guest an automation/mobile controller is
|
||||
* actively driving, or one still writing a download) stays retained over
|
||||
* budget.
|
||||
* evicted, so it is left out of the budget entirely. isEvictable is consulted
|
||||
* lazily, only for worktrees beyond the limit — a non-evictable one (a guest an
|
||||
* automation/mobile controller is actively driving, or one still writing a
|
||||
* download) stays retained over budget.
|
||||
*/
|
||||
export function selectBrowserGuestEvictionWorktreeIds(args: {
|
||||
orderedWorktreeIds: readonly string[]
|
||||
@@ -26,30 +40,32 @@ export function selectBrowserGuestEvictionWorktreeIds(args: {
|
||||
isEvictable: (worktreeId: string) => boolean
|
||||
limit?: number
|
||||
}): string[] {
|
||||
const limit = args.limit ?? BROWSER_GUEST_HIDDEN_WORKTREE_RETENTION_LIMIT
|
||||
const evictedIds: string[] = []
|
||||
const seen = new Set<string>()
|
||||
let retained = 0
|
||||
const candidates: { id: string }[] = []
|
||||
for (const worktreeId of args.orderedWorktreeIds) {
|
||||
if (seen.has(worktreeId)) {
|
||||
continue
|
||||
}
|
||||
seen.add(worktreeId)
|
||||
if (
|
||||
seen.has(worktreeId) ||
|
||||
worktreeId === args.activeWorktreeId ||
|
||||
!args.isRetained(worktreeId) ||
|
||||
!args.holdsLiveGuests(worktreeId)
|
||||
) {
|
||||
seen.add(worktreeId)
|
||||
continue
|
||||
}
|
||||
seen.add(worktreeId)
|
||||
if (retained < limit) {
|
||||
retained += 1
|
||||
continue
|
||||
}
|
||||
if (args.isEvictable(worktreeId)) {
|
||||
evictedIds.push(worktreeId)
|
||||
}
|
||||
candidates.push({ id: worktreeId })
|
||||
}
|
||||
return evictedIds
|
||||
return selectBrowserRetentionEvictions({
|
||||
candidates,
|
||||
isPinned: (worktreeId) => !args.isEvictable(worktreeId),
|
||||
// Why 0: no candidate carries a timestamp, so the clock rules never read it.
|
||||
now: 0,
|
||||
budget: {
|
||||
...HIDDEN_WORKTREE_BUDGET,
|
||||
limit: args.limit ?? HIDDEN_WORKTREE_BUDGET.limit
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why the fallback: webviews key by page id, but legacy sessions persisted
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
BROWSER_RETENTION_LIMIT,
|
||||
selectBrowserRetentionEvictions,
|
||||
type BrowserRetentionBudget,
|
||||
type BrowserRetentionCandidate
|
||||
} from './browser-retention-budget'
|
||||
|
||||
const NOW = 1_000_000
|
||||
|
||||
/** The headless host's shape: a clock rule on top of the rank rule. */
|
||||
const HEADLESS: BrowserRetentionBudget = {
|
||||
limit: 2,
|
||||
idleMs: 60_000,
|
||||
graceMs: 5_000
|
||||
}
|
||||
/** The desktop host's shape: rank only, no clock. */
|
||||
const DESKTOP: BrowserRetentionBudget = {
|
||||
limit: 2,
|
||||
idleMs: Number.POSITIVE_INFINITY,
|
||||
graceMs: 0
|
||||
}
|
||||
|
||||
function used(id: string, msAgo: number): BrowserRetentionCandidate {
|
||||
return { id, lastActivityAt: NOW - msAgo }
|
||||
}
|
||||
|
||||
function select(
|
||||
candidates: readonly BrowserRetentionCandidate[],
|
||||
budget: BrowserRetentionBudget,
|
||||
pinned: readonly string[] = []
|
||||
): string[] {
|
||||
return selectBrowserRetentionEvictions({
|
||||
candidates,
|
||||
isPinned: (id) => pinned.includes(id),
|
||||
now: NOW,
|
||||
budget
|
||||
})
|
||||
}
|
||||
|
||||
describe('selectBrowserRetentionEvictions', () => {
|
||||
it('defaults to the same four renderers both hosts already kept', () => {
|
||||
expect(BROWSER_RETENTION_LIMIT).toBe(4)
|
||||
})
|
||||
|
||||
it('keeps everything within the limit', () => {
|
||||
expect(select([used('a', 10_000), used('b', 20_000)], HEADLESS)).toEqual([])
|
||||
})
|
||||
|
||||
it('evicts what is ranked beyond the limit', () => {
|
||||
expect(
|
||||
select([used('newest', 6_000), used('middle', 20_000), used('oldest', 40_000)], HEADLESS)
|
||||
).toEqual(['oldest'])
|
||||
})
|
||||
|
||||
it('evicts an idle candidate that is still within the limit', () => {
|
||||
expect(select([used('a', 10_000), used('stale', 90_000)], HEADLESS)).toEqual(['stale'])
|
||||
})
|
||||
|
||||
it('never evicts inside the grace window, even beyond the limit', () => {
|
||||
const fresh = [used('a', 100), used('b', 200), used('c', 300), used('d', 400)]
|
||||
expect(select(fresh, HEADLESS)).toEqual([])
|
||||
})
|
||||
|
||||
it('never evicts a pinned candidate, so the limit can be exceeded', () => {
|
||||
// Two pinned pages ranked beyond the limit hold their renderers rather than
|
||||
// interrupting the download/stream that pinned them.
|
||||
const candidates = [used('a', 10_000), used('b', 20_000), used('c', 30_000), used('d', 40_000)]
|
||||
expect(select(candidates, HEADLESS, ['c', 'd'])).toEqual([])
|
||||
})
|
||||
|
||||
it('consults isPinned only for candidates it would otherwise evict', () => {
|
||||
const asked: string[] = []
|
||||
selectBrowserRetentionEvictions({
|
||||
candidates: [used('a', 10_000), used('b', 20_000), used('c', 30_000)],
|
||||
isPinned: (id) => {
|
||||
asked.push(id)
|
||||
return false
|
||||
},
|
||||
now: NOW,
|
||||
budget: HEADLESS
|
||||
})
|
||||
expect(asked).toEqual(['c'])
|
||||
})
|
||||
|
||||
it('respects the caller-supplied order rather than re-sorting', () => {
|
||||
// The caller owns LRU ranking; a host with no timestamps ranks by activation.
|
||||
expect(
|
||||
select([used('oldest', 40_000), used('newest', 6_000), used('mid', 20_000)], HEADLESS)
|
||||
).toEqual(['mid'])
|
||||
})
|
||||
|
||||
it('ranks timestamp-free candidates by position alone', () => {
|
||||
const candidates = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }]
|
||||
expect(select(candidates, DESKTOP)).toEqual(['c', 'd'])
|
||||
})
|
||||
|
||||
it('never evicts a timestamp-free candidate by clock', () => {
|
||||
expect(select([{ id: 'a' }, { id: 'b' }], DESKTOP)).toEqual([])
|
||||
})
|
||||
|
||||
it('evicts nothing when both rules are disabled', () => {
|
||||
expect(
|
||||
select([used('a', 10_000_000), used('b', 10_000_000)], {
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
idleMs: Number.POSITIVE_INFINITY,
|
||||
graceMs: 0
|
||||
})
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
// Why (STA-4341): Orca bounds live browser renderers on two very different
|
||||
// hosts. The desktop app retains hidden worktrees' webview guests; a headless
|
||||
// `orca serve` retains agent-opened offscreen pages. The unit differs (worktree
|
||||
// vs page) and so does the way each host learns something changed, but the
|
||||
// budget itself is one decision: keep the most recently used, never evict
|
||||
// something in use, and optionally shed anything untouched for too long. Both
|
||||
// hosts call the selector below, so the policy can only diverge where a host
|
||||
// deliberately configures it to.
|
||||
|
||||
/** Renderers a host keeps warm. Matches TERMINAL_WORKTREE_HOT_RETAIN_LIMIT. */
|
||||
export const BROWSER_RETENTION_LIMIT = 4
|
||||
|
||||
export type BrowserRetentionCandidate = {
|
||||
id: string
|
||||
/**
|
||||
* Last use, epoch ms. Omitted by hosts that rank by activation order alone
|
||||
* and have no clock rule — such a candidate is never idle and never in grace,
|
||||
* so only its rank can evict it.
|
||||
*/
|
||||
lastActivityAt?: number
|
||||
}
|
||||
|
||||
export type BrowserRetentionBudget = {
|
||||
/** Candidates kept by rank. Pinned ones hold their slot rather than yield it. */
|
||||
limit: number
|
||||
/** Evict once untouched this long, even within the limit. Infinity disables. */
|
||||
idleMs: number
|
||||
/** Never evict something used more recently than this. 0 disables. */
|
||||
graceMs: number
|
||||
}
|
||||
|
||||
export type BrowserRetentionSelection = {
|
||||
/** Most-recently-used first. Anything omitted is invisible to the budget. */
|
||||
candidates: readonly BrowserRetentionCandidate[]
|
||||
/**
|
||||
* In use right now, so never evicted. Consulted only for candidates the rank
|
||||
* and clock rules would otherwise evict, because a host may pay real work to
|
||||
* answer it.
|
||||
*/
|
||||
isPinned: (id: string) => boolean
|
||||
now: number
|
||||
budget: BrowserRetentionBudget
|
||||
}
|
||||
|
||||
export function isBrowserRetentionCandidateIdle(
|
||||
candidate: BrowserRetentionCandidate,
|
||||
now: number,
|
||||
budget: BrowserRetentionBudget
|
||||
): boolean {
|
||||
return candidate.lastActivityAt !== undefined && now - candidate.lastActivityAt >= budget.idleMs
|
||||
}
|
||||
|
||||
export function isBrowserRetentionCandidateInGrace(
|
||||
candidate: BrowserRetentionCandidate,
|
||||
now: number,
|
||||
budget: BrowserRetentionBudget
|
||||
): boolean {
|
||||
return candidate.lastActivityAt !== undefined && now - candidate.lastActivityAt < budget.graceMs
|
||||
}
|
||||
|
||||
/**
|
||||
* The candidates to evict: everything not in use that is either ranked beyond
|
||||
* the limit or has gone idle.
|
||||
*
|
||||
* A pinned candidate keeps its slot instead of yielding it, so a host can sit
|
||||
* over budget while work is genuinely in flight — interrupting a download or a
|
||||
* streamed page to satisfy an accounting limit would break the work the budget
|
||||
* exists to protect.
|
||||
*/
|
||||
export function selectBrowserRetentionEvictions(args: BrowserRetentionSelection): string[] {
|
||||
const evicted: string[] = []
|
||||
args.candidates.forEach((candidate, rank) => {
|
||||
const overLimit = rank >= args.budget.limit
|
||||
if (!overLimit && !isBrowserRetentionCandidateIdle(candidate, args.now, args.budget)) {
|
||||
return
|
||||
}
|
||||
if (isBrowserRetentionCandidateInGrace(candidate, args.now, args.budget)) {
|
||||
return
|
||||
}
|
||||
if (args.isPinned(candidate.id)) {
|
||||
return
|
||||
}
|
||||
evicted.push(candidate.id)
|
||||
})
|
||||
return evicted
|
||||
}
|
||||
@@ -33,7 +33,6 @@ const ANIMATED_PAGE = `<!doctype html><meta charset="utf-8"><title>anim</title>
|
||||
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'
|
||||
}
|
||||
|
||||
@@ -67,7 +66,10 @@ async function resolveSeededWorktreeId(host: HeadlessHost, testRepoPath: string)
|
||||
return worktreeId
|
||||
}
|
||||
|
||||
async function startAnimatedPageServer(): Promise<{ url: string; close: () => void }> {
|
||||
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)
|
||||
@@ -108,7 +110,9 @@ 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 host = await launchHeadlessPairedRuntimeHost({
|
||||
extraEnv: FAST_RECLAIM_ENV
|
||||
})
|
||||
const pageServer = await startAnimatedPageServer()
|
||||
try {
|
||||
const worktreeId = await resolveSeededWorktreeId(host, testRepoPath)
|
||||
@@ -135,18 +139,19 @@ test('reclaims idle agent browser tab renderers without losing the tabs', async
|
||||
|
||||
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 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 }
|
||||
)
|
||||
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)
|
||||
@@ -173,7 +178,10 @@ test('bounds resident renderers by the cap while tabs are still in use', async (
|
||||
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' }
|
||||
extraEnv: {
|
||||
...FAST_RECLAIM_ENV,
|
||||
ORCA_HEADLESS_BROWSER_PARK_IDLE_MS: '600000'
|
||||
}
|
||||
})
|
||||
const pageServer = await startAnimatedPageServer()
|
||||
try {
|
||||
@@ -217,7 +225,10 @@ test('resolves --index against the listing that includes parked tabs', async ({
|
||||
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' }
|
||||
extraEnv: {
|
||||
...FAST_RECLAIM_ENV,
|
||||
ORCA_HEADLESS_BROWSER_PARK_IDLE_MS: '600000'
|
||||
}
|
||||
})
|
||||
const pageServer = await startAnimatedPageServer()
|
||||
try {
|
||||
@@ -249,7 +260,9 @@ test('resolves --index against the listing that includes parked tabs', async ({
|
||||
|
||||
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 host = await launchHeadlessPairedRuntimeHost({
|
||||
extraEnv: FAST_RECLAIM_ENV
|
||||
})
|
||||
const pageServer = await startAnimatedPageServer()
|
||||
try {
|
||||
const worktreeId = await resolveSeededWorktreeId(host, testRepoPath)
|
||||
|
||||
Reference in New Issue
Block a user