Make parked browser tabs paint before automation (#4411)

This commit is contained in:
Neil
2026-06-01 15:27:36 -07:00
committed by GitHub
parent f7fb6ef686
commit 163cf8da6f
3 changed files with 63 additions and 15 deletions
@@ -102,3 +102,17 @@ run with `--json`, including host memory, Orca app process buckets, worktree
terminal memory, per-session process roots, and history samples. Text output
prints a compact point-in-time summary and the top worktrees by retained
terminal memory.
## Follow-up: Agent-Browser Paintability Guard
The browser parking fix depends on automation-visible panes staying paintable
without activating the user's worktree. The renderer bridge previously waited
for two animation frames before creating the automation visibility lease, so the
paint wait happened while the parked webview was still hidden. Non-screenshot
agent-browser commands could therefore start immediately after the lease was
created, before React had made the hidden pane paintable.
The follow-up changes the order: create the automation visibility lease first,
then wait for paint while the pane is actually visible to automation. A
renderer-side timeout releases the lease if paint never arrives, so a hung RAF
does not pin an inactive browser pane indefinitely.
@@ -34,7 +34,7 @@ describe('browser automation visibility leases', () => {
expect(isBrowserAutomationVisible('page-1')).toBe(false)
})
it('installs a main-process bridge that waits for paint before returning a token', async () => {
it('installs a main-process bridge that keeps the page visible while waiting for paint', async () => {
const animationFrameCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('window', {
requestAnimationFrame: (callback: FrameRequestCallback) => {
@@ -50,13 +50,13 @@ describe('browser automation visibility leases', () => {
const acquirePromise = bridge?.acquire('page-2')
await Promise.resolve()
expect(isBrowserAutomationVisible('page-2')).toBe(false)
expect(isBrowserAutomationVisible('page-2')).toBe(true)
expect(animationFrameCallbacks).toHaveLength(1)
animationFrameCallbacks.shift()?.(0)
await Promise.resolve()
expect(isBrowserAutomationVisible('page-2')).toBe(false)
expect(isBrowserAutomationVisible('page-2')).toBe(true)
expect(animationFrameCallbacks).toHaveLength(1)
animationFrameCallbacks.shift()?.(16)
@@ -68,18 +68,28 @@ describe('browser automation visibility leases', () => {
expect(isBrowserAutomationVisible('page-2')).toBe(false)
})
it('does not allocate a main-process bridge lease when the paint wait hangs', async () => {
it('releases the main-process bridge lease when the paint wait hangs', async () => {
vi.useFakeTimers()
vi.stubGlobal('window', {
requestAnimationFrame: () => 1
})
const { isBrowserAutomationVisible } = await import('./browser-automation-visibility')
try {
const { isBrowserAutomationVisible } = await import('./browser-automation-visibility')
const bridge = window.__orcaBrowserAutomationVisibility
expect(bridge).toBeTruthy()
const bridge = window.__orcaBrowserAutomationVisibility
expect(bridge).toBeTruthy()
void bridge?.acquire('page-hung-paint')
await Promise.resolve()
const acquirePromise = bridge?.acquire('page-hung-paint')
await Promise.resolve()
expect(isBrowserAutomationVisible('page-hung-paint')).toBe(false)
expect(isBrowserAutomationVisible('page-hung-paint')).toBe(true)
await vi.advanceTimersByTimeAsync(2_000)
await expect(acquirePromise).resolves.toBeNull()
expect(isBrowserAutomationVisible('page-hung-paint')).toBe(false)
} finally {
vi.useRealTimers()
}
})
})
@@ -18,6 +18,7 @@ const listeners = new Set<() => void>()
let version = 0
let nextLeaseId = 0
const AUTOMATION_VISIBILITY_PAINT_TIMEOUT_MS = 2_000
function emitChange(): void {
version += 1
@@ -48,6 +49,25 @@ function nextAnimationFrame(): Promise<void> {
return new Promise((resolve) => window.requestAnimationFrame(() => resolve()))
}
async function waitForAutomationVisiblePaint(): Promise<boolean> {
let timeout: ReturnType<typeof setTimeout> | null = null
const paint = (async () => {
await nextAnimationFrame()
await nextAnimationFrame()
return true
})()
const timedOut = new Promise<false>((resolve) => {
timeout = setTimeout(() => resolve(false), AUTOMATION_VISIBILITY_PAINT_TIMEOUT_MS)
})
try {
return await Promise.race([paint, timedOut])
} finally {
if (timeout !== null) {
clearTimeout(timeout)
}
}
}
export function isBrowserAutomationVisible(browserPageId: string): boolean {
return (leaseCountsByPageId.get(browserPageId) ?? 0) > 0
}
@@ -107,11 +127,15 @@ async function acquireForMainProcess(browserPageId: string): Promise<string | nu
if (typeof browserPageId !== 'string' || browserPageId.length === 0) {
return null
}
// Why: only allocate the lease after the paint wait succeeds. If RAF hangs,
// main times out without leaving a permanently visible hidden webview.
await nextAnimationFrame()
await nextAnimationFrame()
return acquireBrowserAutomationVisibility(browserPageId)
const token = acquireBrowserAutomationVisibility(browserPageId)
// Why: the hidden pane only becomes paintable after the visibility lease
// exists. Wait after acquiring it so agent-browser commands do not race a
// still-hidden parked webview; release locally if paint never arrives.
if (await waitForAutomationVisiblePaint()) {
return token
}
releaseBrowserAutomationVisibility(token)
return null
}
export function installBrowserAutomationVisibilityBridge(): void {