diff --git a/docs/reference/headless-linux-server.md b/docs/reference/headless-linux-server.md index d8104586d20..afa59ab4e1d 100644 --- a/docs/reference/headless-linux-server.md +++ b/docs/reference/headless-linux-server.md @@ -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 diff --git a/src/main/browser/offscreen-browser-backend-reclaim.test.ts b/src/main/browser/offscreen-browser-backend-reclaim.test.ts index c4b36f5b761..1259bf1512e 100644 --- a/src/main/browser/offscreen-browser-backend-reclaim.test.ts +++ b/src/main/browser/offscreen-browser-backend-reclaim.test.ts @@ -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 diff --git a/src/main/browser/offscreen-browser-backend.ts b/src/main/browser/offscreen-browser-backend.ts index 3387c874987..7af2a0c2949 100644 --- a/src/main/browser/offscreen-browser-backend.ts +++ b/src/main/browser/offscreen-browser-backend.ts @@ -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() } diff --git a/src/main/browser/offscreen-browser-page-reclaim.test.ts b/src/main/browser/offscreen-browser-page-reclaim.test.ts index 17a83da58f5..9a85f47aa32 100644 --- a/src/main/browser/offscreen-browser-page-reclaim.test.ts +++ b/src/main/browser/offscreen-browser-page-reclaim.test.ts @@ -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([]) + } + } }) }) diff --git a/src/main/browser/offscreen-browser-page-reclaim.ts b/src/main/browser/offscreen-browser-page-reclaim.ts index 3dd69682139..8bd5d69a4f9 100644 --- a/src/main/browser/offscreen-browser-page-reclaim.ts +++ b/src/main/browser/offscreen-browser-page-reclaim.ts @@ -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() - 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 } diff --git a/src/main/browser/offscreen-browser-page-reclaimer-scheduling.test.ts b/src/main/browser/offscreen-browser-page-reclaimer-scheduling.test.ts new file mode 100644 index 00000000000..9e51ef29b98 --- /dev/null +++ b/src/main/browser/offscreen-browser-page-reclaimer-scheduling.test.ts @@ -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) => Promise + } = {} +) { + const live = new Map( + (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) + }) +}) diff --git a/src/main/browser/offscreen-browser-page-reclaimer.ts b/src/main/browser/offscreen-browser-page-reclaimer.ts index 93ef228783e..aff3e1f3d1e 100644 --- a/src/main/browser/offscreen-browser-page-reclaimer.ts +++ b/src/main/browser/offscreen-browser-page-reclaimer.ts @@ -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 | 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 { - 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 { + 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 ) } } diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-guest-worktree-retention.ts b/src/renderer/src/components/browser-pane/host-guest/browser-guest-worktree-retention.ts index ca0d0b56dcd..d0a07f78679 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-guest-worktree-retention.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-guest-worktree-retention.ts @@ -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() - 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 diff --git a/src/shared/browser-retention-budget.test.ts b/src/shared/browser-retention-budget.test.ts new file mode 100644 index 00000000000..3691d726862 --- /dev/null +++ b/src/shared/browser-retention-budget.test.ts @@ -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([]) + }) +}) diff --git a/src/shared/browser-retention-budget.ts b/src/shared/browser-retention-budget.ts new file mode 100644 index 00000000000..e9a960eaafb --- /dev/null +++ b/src/shared/browser-retention-budget.ts @@ -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 +} diff --git a/tests/e2e/headless-serve-browser-tab-reclaim.spec.ts b/tests/e2e/headless-serve-browser-tab-reclaim.spec.ts index 2beb01d42d7..5b200e734e1 100644 --- a/tests/e2e/headless-serve-browser-tab-reclaim.spec.ts +++ b/tests/e2e/headless-serve-browser-tab-reclaim.spec.ts @@ -33,7 +33,6 @@ const ANIMATED_PAGE = `anim 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)