mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 08:02:35 +00:00
fix(browser): keep the desktop drawing while a phone streams a browser tab (#26284)
While the desktop was in the screen saver or minimized, a paired phone opening a browser tab spun forever: the throttled main window stopped compositing, so the guest's captures hung. Each page stream now holds the existing renderer-throttle lease for its lifetime, released by the stream's own cleanup on every exit. A 10 s no-frame deadline reports the existing timeout error so a viewer never spins forever.
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* A hidden, throttled embedder stops compositing: capturePage never settles and no live frame
|
||||
* comes. The stream says so once, 10 s after it started, and only if no frame arrived by then.
|
||||
*/
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { startBrowserScreencast } from './browser-screencast-stream'
|
||||
import type { BrowserScreencastOptions } from './browser-screencast-stream-types'
|
||||
import { createMockScreencastWebContents } from './browser-screencast-web-contents-test-double'
|
||||
|
||||
function never<T>(): Promise<T> {
|
||||
return new Promise<T>(() => {})
|
||||
}
|
||||
|
||||
function start(
|
||||
webContents: ReturnType<typeof createMockScreencastWebContents>,
|
||||
options: BrowserScreencastOptions
|
||||
) {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream touches only the debugger, isDestroyed and capturePage the doubles provide.
|
||||
return startBrowserScreencast(webContents as never, options)
|
||||
}
|
||||
|
||||
function liveFrame(webContents: ReturnType<typeof createMockScreencastWebContents>): void {
|
||||
webContents.debugger.emit('message', {}, 'Page.screencastFrame', {
|
||||
data: Buffer.from('live').toString('base64'),
|
||||
sessionId: 1,
|
||||
metadata: { deviceWidth: 390, deviceHeight: 844, pageScaleFactor: 1 }
|
||||
})
|
||||
}
|
||||
|
||||
function startOptions(viewport: boolean) {
|
||||
return {
|
||||
format: 'jpeg' as const,
|
||||
quality: 70,
|
||||
maxWidth: 1440,
|
||||
maxHeight: 1200,
|
||||
everyNthFrame: 2,
|
||||
minFrameIntervalMs: 0,
|
||||
...(viewport ? { viewportWidth: 390, viewportHeight: 844 } : {}),
|
||||
onFrame: vi.fn(() => true),
|
||||
onError: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('browser screencast first-frame deadline', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('reports no frame at exactly 10 s when the captures hang', async () => {
|
||||
const webContents = Object.assign(createMockScreencastWebContents(), {
|
||||
capturePage: vi.fn(() => never())
|
||||
})
|
||||
webContents.debugger.sendCommand.mockImplementation(async (method: string) =>
|
||||
method === 'Page.captureScreenshot' ? never() : {}
|
||||
)
|
||||
const options = startOptions(true)
|
||||
const session = await start(webContents, options)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(9_999)
|
||||
expect(options.onError).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
|
||||
expect(options.onError).toHaveBeenCalledExactlyOnceWith('Browser stream timed out.')
|
||||
session.stop()
|
||||
// The stop queues behind the capture: capturePage's 10 s bound, then the fallback's 8 s.
|
||||
await vi.advanceTimersByTimeAsync(8_000)
|
||||
await session.done
|
||||
})
|
||||
|
||||
it.each(['live', 'snapshot'] as const)('stays quiet once a %s frame arrives', async (kind) => {
|
||||
const webContents = Object.assign(createMockScreencastWebContents(), {
|
||||
capturePage: vi.fn(() => never())
|
||||
})
|
||||
webContents.debugger.sendCommand.mockImplementation(async (method: string) =>
|
||||
method === 'Page.captureScreenshot' ? { data: Buffer.from('frame').toString('base64') } : {}
|
||||
)
|
||||
const options = startOptions(kind === 'live')
|
||||
const session = await start(webContents, options)
|
||||
if (kind === 'live') {
|
||||
liveFrame(webContents)
|
||||
}
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000)
|
||||
|
||||
expect(options.onFrame).toHaveBeenCalled()
|
||||
expect(options.onError).not.toHaveBeenCalled()
|
||||
session.stop()
|
||||
await session.done
|
||||
})
|
||||
|
||||
it('drops a capture that settles after the limit', async () => {
|
||||
let settleCapture!: (image: unknown) => void
|
||||
const webContents = Object.assign(createMockScreencastWebContents(), {
|
||||
capturePage: vi.fn(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
settleCapture = resolve
|
||||
})
|
||||
)
|
||||
})
|
||||
const options = startOptions(true)
|
||||
const session = await start(webContents, options)
|
||||
await vi.advanceTimersByTimeAsync(10_000)
|
||||
|
||||
settleCapture({
|
||||
getSize: () => ({ width: 390, height: 844 }),
|
||||
toJPEG: () => Buffer.from('late'),
|
||||
toPNG: () => Buffer.from('late')
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(1_000)
|
||||
|
||||
expect(options.onFrame).not.toHaveBeenCalled()
|
||||
session.stop()
|
||||
await session.done
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,10 @@ import type {
|
||||
PendingScreencastFrame
|
||||
} from './browser-screencast-stream-types'
|
||||
import { positiveInteger, scaleSnapshotToFit } from './browser-screencast-viewport-fit'
|
||||
import { withTimeout } from '../../shared/promise-timeout-fallback'
|
||||
|
||||
// Why: a hung capture would also wedge the viewport updates and stop queued behind it.
|
||||
const SNAPSHOT_CAPTURE_TIMEOUT_MS = 10_000
|
||||
|
||||
type BrowserScreencastSnapshotCaptureDeps = {
|
||||
webContents: WebContents
|
||||
@@ -76,19 +80,19 @@ export function createBrowserScreencastSnapshotCapture(
|
||||
}
|
||||
if (viewportWidth && viewportHeight && typeof webContents.capturePage === 'function') {
|
||||
try {
|
||||
// Why: CDP captureScreenshot can tile BrowserView surfaces under
|
||||
// mobile emulation; Electron captures the actual visible viewport.
|
||||
const nativeImage = await webContents.capturePage({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: viewportWidth,
|
||||
height: viewportHeight
|
||||
})
|
||||
const capture = scaleSnapshotToFit(nativeImage, options)
|
||||
const buffer =
|
||||
options.format === 'png' ? capture.toPNG() : capture.toJPEG(options.quality)
|
||||
if (buffer.byteLength > 0) {
|
||||
image = new Uint8Array(buffer)
|
||||
// Why: captures the real viewport (CDP can tile it) but never settles once compositing stops.
|
||||
const nativeImage = await withTimeout(
|
||||
webContents.capturePage({ x: 0, y: 0, width: viewportWidth, height: viewportHeight }),
|
||||
SNAPSHOT_CAPTURE_TIMEOUT_MS,
|
||||
null
|
||||
)
|
||||
if (nativeImage) {
|
||||
const capture = scaleSnapshotToFit(nativeImage, options)
|
||||
const buffer =
|
||||
options.format === 'png' ? capture.toPNG() : capture.toJPEG(options.quality)
|
||||
if (buffer.byteLength > 0) {
|
||||
image = new Uint8Array(buffer)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
image = null
|
||||
|
||||
@@ -13,6 +13,9 @@ import type {
|
||||
BrowserScreencastViewport
|
||||
} from './browser-screencast-stream-types'
|
||||
|
||||
// Why: long enough for a slow first paint, short enough that a frameless viewer is told soon.
|
||||
const NO_FRAME_TIMEOUT_MS = 10_000
|
||||
|
||||
export async function startBrowserScreencast(
|
||||
webContents: WebContents,
|
||||
options: BrowserScreencastOptions
|
||||
@@ -108,8 +111,9 @@ export async function startBrowserScreencast(
|
||||
}
|
||||
|
||||
const handleDetach = (): void => {
|
||||
options.onError?.('Browser debugger detached while streaming.')
|
||||
// Why: finish first so an owner that stops the stream on this error finds it already closed.
|
||||
finish()
|
||||
options.onError?.('Browser debugger detached while streaming.')
|
||||
}
|
||||
|
||||
dbg.on('message', handleMessage as never)
|
||||
@@ -120,6 +124,13 @@ export async function startBrowserScreencast(
|
||||
await deviceMetrics.apply()
|
||||
await startScreencast()
|
||||
pendingUpdate = snapshotCapture.emitSnapshotFrame(true)
|
||||
// Why: a page whose embedder stopped compositing never yields a frame; the owner ends on this.
|
||||
const noFrameTimer = setTimeout(() => {
|
||||
if (!closed && !stopping && framePacer.getSeq() === 0) {
|
||||
options.onError?.('Browser stream timed out.')
|
||||
}
|
||||
}, NO_FRAME_TIMEOUT_MS)
|
||||
void done.then(() => clearTimeout(noFrameTimer))
|
||||
} catch (error) {
|
||||
if (deviceMetrics.isOverridden()) {
|
||||
await deviceMetrics.clear().catch(() => {})
|
||||
@@ -188,7 +199,7 @@ export async function startBrowserScreencast(
|
||||
return pendingUpdate
|
||||
},
|
||||
stop: () => {
|
||||
if (closed) {
|
||||
if (closed || stopping) {
|
||||
return
|
||||
}
|
||||
stopping = true
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* A remote browser stream keeps the desktop window drawing for exactly as long as it lives: guest
|
||||
* frames come from the embedder's compositor, which a throttled hidden window stops running.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { BrowserScreencastOptions } from '../browser/browser-screencast-stream-types'
|
||||
import { createScreencastHarness } from './browser-screencast-subscriber-test-harness'
|
||||
import { RuntimeBrowserCommands } from './orca-runtime-browser'
|
||||
import { createSinglePageBrowserCommandsHost } from './single-page-browser-commands-host-test-double'
|
||||
|
||||
const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({
|
||||
webContentsFromId: vi.fn(),
|
||||
startBrowserScreencast: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: { on: vi.fn(), removeListener: vi.fn(), handle: vi.fn(), removeHandler: vi.fn() },
|
||||
webContents: { fromId: webContentsFromId }
|
||||
}))
|
||||
vi.mock('../browser/browser-screencast-stream', () => ({ startBrowserScreencast }))
|
||||
|
||||
type PageStream = { options: BrowserScreencastOptions; close: () => void }
|
||||
|
||||
function createRig() {
|
||||
const { runtime } = createScreencastHarness()
|
||||
const setBackgroundThrottling = vi.fn()
|
||||
const window = { webContents: { isDestroyed: () => false, setBackgroundThrottling } }
|
||||
Object.assign(runtime, {
|
||||
browserCommands: new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost(window))
|
||||
})
|
||||
const pageStreams: PageStream[] = []
|
||||
// Models Chromium's asynchronous teardown: a held stop leaves the stream open until `close()`.
|
||||
const stopControl = { hold: false }
|
||||
startBrowserScreencast.mockImplementation(
|
||||
async (_guest: unknown, options: BrowserScreencastOptions) => {
|
||||
let close!: () => void
|
||||
const done = new Promise<void>((resolve) => {
|
||||
close = resolve
|
||||
})
|
||||
pageStreams.push({ options, close })
|
||||
return {
|
||||
stop: () => {
|
||||
if (!stopControl.hold) {
|
||||
close()
|
||||
}
|
||||
},
|
||||
done,
|
||||
updateViewport: vi.fn(async () => {}),
|
||||
updateFrameBudget: vi.fn(async () => {})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const subscribe = (connectionId: string, signal?: AbortSignal) => {
|
||||
const emit = vi.fn()
|
||||
const done = runtime.browserScreencast(
|
||||
{ worktree: 'id:wt-1', page: 'page-1', format: 'jpeg' },
|
||||
{ connectionId, clientKind: 'mobile', sendBinary: vi.fn(() => true), signal, emit }
|
||||
)
|
||||
const ready = async (): Promise<string> => {
|
||||
await vi.waitFor(() =>
|
||||
expect(emit).toHaveBeenCalledWith(expect.objectContaining({ type: 'ready' }))
|
||||
)
|
||||
return emit.mock.calls.find(([event]) => event.type === 'ready')?.[0].subscriptionId
|
||||
}
|
||||
const eventTypes = (): string[] => emit.mock.calls.map(([event]) => event.type)
|
||||
return { done, ready, eventTypes }
|
||||
}
|
||||
|
||||
return {
|
||||
runtime,
|
||||
subscribe,
|
||||
pageStreams,
|
||||
stopControl,
|
||||
throttleCalls: () => setBackgroundThrottling.mock.calls.map(([allowed]) => allowed)
|
||||
}
|
||||
}
|
||||
|
||||
describe('remote browser screencast renderer throttle lease', () => {
|
||||
beforeEach(() => {
|
||||
webContentsFromId.mockReset()
|
||||
webContentsFromId.mockReturnValue({ isDestroyed: () => false })
|
||||
startBrowserScreencast.mockReset()
|
||||
})
|
||||
|
||||
it('lifts the window throttle before the stream starts capturing', async () => {
|
||||
const rig = createRig()
|
||||
let callsAtStart: boolean[] = []
|
||||
const started = startBrowserScreencast.getMockImplementation()
|
||||
startBrowserScreencast.mockImplementation(async (...args: unknown[]) => {
|
||||
callsAtStart = rig.throttleCalls()
|
||||
return started?.(...args)
|
||||
})
|
||||
|
||||
const phone = rig.subscribe('conn-phone')
|
||||
await phone.ready()
|
||||
|
||||
expect(callsAtStart).toEqual([false])
|
||||
})
|
||||
|
||||
// Unsubscribe, connection close, ghost eviction and a desktop page close all end through the
|
||||
// page stream's `done`, so one of them stands for all.
|
||||
it('restores it when the stream ends', async () => {
|
||||
const rig = createRig()
|
||||
const phone = rig.subscribe('conn-phone')
|
||||
const subscriptionId = await phone.ready()
|
||||
|
||||
rig.runtime.cleanupSubscription(subscriptionId)
|
||||
await phone.done
|
||||
|
||||
await vi.waitFor(() => expect(rig.throttleCalls()).toEqual([false, true]))
|
||||
})
|
||||
|
||||
it('restores it when the stream fails to start', async () => {
|
||||
const rig = createRig()
|
||||
startBrowserScreencast.mockRejectedValue(new Error('Could not attach debugger.'))
|
||||
const phone = rig.subscribe('conn-phone')
|
||||
|
||||
await expect(phone.done).rejects.toThrow('Could not attach debugger.')
|
||||
|
||||
await vi.waitFor(() => expect(rig.throttleCalls()).toEqual([false, true]))
|
||||
})
|
||||
|
||||
it('ends the stream on an error, then restores it', async () => {
|
||||
const rig = createRig()
|
||||
const phone = rig.subscribe('conn-phone')
|
||||
await phone.ready()
|
||||
|
||||
rig.pageStreams[0].options.onError?.('Browser stream timed out.')
|
||||
await phone.done
|
||||
|
||||
expect(phone.eventTypes()).toEqual(['ready', 'error', 'end'])
|
||||
await vi.waitFor(() => expect(rig.throttleCalls()).toEqual([false, true]))
|
||||
})
|
||||
|
||||
it('starts a fresh stream for a viewer that joins while the errored one tears down', async () => {
|
||||
const rig = createRig()
|
||||
const phone = rig.subscribe('conn-phone')
|
||||
await phone.ready()
|
||||
rig.stopControl.hold = true
|
||||
|
||||
rig.pageStreams[0].options.onError?.('Browser stream timed out.')
|
||||
const guestLookups = webContentsFromId.mock.calls.length
|
||||
const tablet = rig.subscribe('conn-tablet')
|
||||
// Past its guest lookup, the joiner reaches the page record with no further await.
|
||||
await vi.waitFor(() =>
|
||||
expect(webContentsFromId.mock.calls.length).toBeGreaterThan(guestLookups)
|
||||
)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
rig.pageStreams[0].close()
|
||||
await tablet.ready()
|
||||
|
||||
expect(startBrowserScreencast).toHaveBeenCalledTimes(2)
|
||||
expect(tablet.eventTypes()).toEqual(['ready'])
|
||||
await phone.done
|
||||
})
|
||||
|
||||
it('restores it when the subscription is aborted before ready', async () => {
|
||||
const rig = createRig()
|
||||
let releaseStart!: () => void
|
||||
const started = startBrowserScreencast.getMockImplementation()
|
||||
startBrowserScreencast.mockImplementation(async (...args: unknown[]) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseStart = resolve
|
||||
})
|
||||
return started?.(...args)
|
||||
})
|
||||
const abort = new AbortController()
|
||||
const phone = rig.subscribe('conn-phone', abort.signal)
|
||||
await vi.waitFor(() => expect(releaseStart).toBeTypeOf('function'))
|
||||
|
||||
abort.abort()
|
||||
releaseStart()
|
||||
await phone.done
|
||||
|
||||
expect(phone.eventTypes()).not.toContain('ready')
|
||||
await vi.waitFor(() => expect(rig.throttleCalls()).toEqual([false, true]))
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,8 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
|
||||
import { REMOTE_RUNTIME_MAX_OUTBOUND_BINARY_FRAME_BYTES } from '../../shared/remote-runtime-memory-limits'
|
||||
import { BROWSER_SCREENCAST_GHOST_SUBSCRIBER_REFUSAL_LIMIT } from './browser-screencast-ghost-subscriber-eviction'
|
||||
import type { RuntimeBrowserCommandHost, RuntimeBrowserCommands } from './orca-runtime-browser'
|
||||
import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
|
||||
import type { RuntimeBrowserCommands } from './orca-runtime-browser'
|
||||
import { createSinglePageBrowserCommandsHost } from './single-page-browser-commands-host-test-double'
|
||||
|
||||
const { webContentsFromId, startBrowserScreencast } = vi.hoisted(() => ({
|
||||
webContentsFromId: vi.fn(),
|
||||
@@ -24,32 +23,6 @@ function deferred() {
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function createCommandsHost(): RuntimeBrowserCommandHost {
|
||||
const runtimeBrowserPages = new RuntimeBrowserPageRegistry()
|
||||
const bridge = {
|
||||
getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])),
|
||||
getActivePageId: vi.fn(() => 'page-1'),
|
||||
tabList: vi.fn(() => ({
|
||||
tabs: [
|
||||
{
|
||||
browserPageId: 'page-1',
|
||||
index: 0,
|
||||
url: 'about:blank',
|
||||
title: 'Browser',
|
||||
active: true
|
||||
}
|
||||
]
|
||||
}))
|
||||
} as unknown as AgentBrowserBridge
|
||||
return {
|
||||
resolveWorktreeSelector: async () => ({ id: 'wt-1' }),
|
||||
getAgentBrowserBridge: () => bridge,
|
||||
getRuntimeBrowserPageRegistry: () => runtimeBrowserPages,
|
||||
getAvailableAuthoritativeWindow: vi.fn(() => null),
|
||||
getOffscreenBrowserBackend: vi.fn(() => null)
|
||||
} as unknown as RuntimeBrowserCommandHost
|
||||
}
|
||||
|
||||
describe('RuntimeBrowserCommands screencast fanout', () => {
|
||||
beforeEach(() => {
|
||||
webContentsFromId.mockReset()
|
||||
@@ -63,7 +36,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
|
||||
const stop = vi.fn(() => done.resolve())
|
||||
const updateViewport = vi.fn(async () => {})
|
||||
startBrowserScreencast.mockResolvedValue({ stop, done: done.promise, updateViewport })
|
||||
const commands = new RuntimeBrowserCommands(createCommandsHost())
|
||||
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
|
||||
const firstSend = vi.fn(() => false)
|
||||
const secondSend = vi.fn(() => true)
|
||||
const first = await commands.browserScreencast(
|
||||
@@ -112,7 +85,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
|
||||
const stop = vi.fn(() => done.resolve())
|
||||
const updateViewport = vi.fn(async () => {})
|
||||
startBrowserScreencast.mockResolvedValue({ stop, done: done.promise, updateViewport })
|
||||
const commands = new RuntimeBrowserCommands(createCommandsHost())
|
||||
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
|
||||
const sized = await commands.browserScreencast(
|
||||
{
|
||||
worktree: 'id:wt-1',
|
||||
@@ -160,7 +133,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
|
||||
updateFrameBudget: vi.fn(async () => {})
|
||||
}
|
||||
})
|
||||
const commands = new RuntimeBrowserCommands(createCommandsHost())
|
||||
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
|
||||
const creatorSend = vi.fn(() => true)
|
||||
await commands.browserScreencast(
|
||||
{
|
||||
@@ -210,7 +183,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
|
||||
updateViewport: vi.fn(async () => {}),
|
||||
updateFrameBudget
|
||||
})
|
||||
const commands = new RuntimeBrowserCommands(createCommandsHost())
|
||||
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
|
||||
const desktop = await commands.browserScreencast(
|
||||
{
|
||||
worktree: 'id:wt-1',
|
||||
@@ -271,7 +244,7 @@ describe('RuntimeBrowserCommands screencast fanout', () => {
|
||||
updateViewport: vi.fn(async () => {})
|
||||
})
|
||||
const sendBinary = vi.fn(() => true)
|
||||
const commands = new RuntimeBrowserCommands(createCommandsHost())
|
||||
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
|
||||
const started = await commands.browserScreencast(
|
||||
{ worktree: 'id:wt-1', page: 'page-1', format: 'jpeg' },
|
||||
{ sendBinary }
|
||||
@@ -316,7 +289,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
|
||||
const stop = vi.fn(() => done.resolve())
|
||||
const updateViewport = vi.fn(async () => {})
|
||||
startBrowserScreencast.mockResolvedValue({ stop, done: done.promise, updateViewport })
|
||||
const commands = new RuntimeBrowserCommands(createCommandsHost())
|
||||
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
|
||||
const survivorSend = vi.fn(() => true)
|
||||
const survivor: Subscription = await commands.browserScreencast(
|
||||
{
|
||||
@@ -374,7 +347,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
|
||||
done: done.promise,
|
||||
updateViewport: vi.fn(async () => {})
|
||||
})
|
||||
const commands = new RuntimeBrowserCommands(createCommandsHost())
|
||||
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
|
||||
const only: Subscription = await commands.browserScreencast(
|
||||
{ worktree: 'id:wt-1', page: 'page-1', format: 'jpeg' },
|
||||
{ sendBinary: sendUntilQuit(1) }
|
||||
@@ -394,7 +367,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
|
||||
done: done.promise,
|
||||
updateViewport: vi.fn(async () => {})
|
||||
})
|
||||
const commands = new RuntimeBrowserCommands(createCommandsHost())
|
||||
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
|
||||
let sends = 0
|
||||
// Why: this is the backpressure shape — a link that drains one frame per window must never
|
||||
// be mistaken for a socket that is gone.
|
||||
@@ -422,7 +395,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
|
||||
done: done.promise,
|
||||
updateViewport: vi.fn(async () => {})
|
||||
})
|
||||
const commands = new RuntimeBrowserCommands(createCommandsHost())
|
||||
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
|
||||
let gateOpen = false
|
||||
const joinerSend = vi.fn(() => gateOpen)
|
||||
const joiner: Subscription = await commands.browserScreencast(
|
||||
@@ -451,7 +424,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
|
||||
const stop = vi.fn(() => done.resolve())
|
||||
const updateViewport = vi.fn(async () => {})
|
||||
startBrowserScreencast.mockResolvedValue({ stop, done: done.promise, updateViewport })
|
||||
const commands = new RuntimeBrowserCommands(createCommandsHost())
|
||||
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
|
||||
const ghostSend = vi.fn(() => true)
|
||||
const ghost: Subscription = await commands.browserScreencast(
|
||||
{
|
||||
@@ -504,7 +477,7 @@ describe('RuntimeBrowserCommands screencast ghost eviction', () => {
|
||||
done: done.promise,
|
||||
updateViewport: vi.fn(async () => {})
|
||||
})
|
||||
const commands = new RuntimeBrowserCommands(createCommandsHost())
|
||||
const commands = new RuntimeBrowserCommands(createSinglePageBrowserCommandsHost())
|
||||
const anonymousFirst = vi.fn(() => true)
|
||||
const anonymousSecond = vi.fn(() => true)
|
||||
const identified = vi.fn(() => true)
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { BrowserEvalResult, BrowserScreencastResult } from '../../shared/ru
|
||||
import { BrowserError } from '../browser/browser-error'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { startBrowserScreencast } from '../browser/browser-screencast-stream'
|
||||
import { rendererPublicationThrottle } from '../window/renderer-publication-throttle'
|
||||
import { sendRemoteBrowserScreencastFrame } from './remote-browser-screencast-frame-admission'
|
||||
import {
|
||||
INITIAL_SCREENCAST_SUBSCRIBER_DELIVERY,
|
||||
@@ -69,6 +70,9 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC
|
||||
viewportOwnerSubscriptionId: null,
|
||||
appliedBudget: budget
|
||||
} as ActiveBrowserScreencastPage
|
||||
// Why: guest frames come from the window's compositor, which a throttled hidden window stops.
|
||||
const win = this.host.getAvailableAuthoritativeWindow()
|
||||
const releaseThrottle = win ? rendererPublicationThrottle.acquire(win.webContents) : () => {}
|
||||
record.started = startBrowserScreencast(guest, {
|
||||
format: params.format,
|
||||
...budget,
|
||||
@@ -103,6 +107,12 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC
|
||||
for (const subscriber of record.subscribers.values()) {
|
||||
subscriber.emit?.({ type: 'error', message })
|
||||
}
|
||||
// Why: every stream error is terminal; marking it stopping makes a newcomer start a fresh
|
||||
// stream instead of joining this one and waiting on frames that will never come.
|
||||
if (record.session) {
|
||||
record.stopping = true
|
||||
record.session.stop()
|
||||
}
|
||||
}
|
||||
})
|
||||
active = record
|
||||
@@ -113,6 +123,7 @@ export class RuntimeBrowserCommandsWithBrowserScreencast extends RuntimeBrowserC
|
||||
return session.done
|
||||
})
|
||||
.finally(() => {
|
||||
releaseThrottle()
|
||||
if (this.activeScreencastsByPageId.get(browserPageId) === record) {
|
||||
this.activeScreencastsByPageId.delete(browserPageId)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { vi } from 'vitest'
|
||||
import type { AgentBrowserBridge } from '../browser/agent-browser-bridge'
|
||||
import type { RuntimeBrowserCommandHost } from './orca-runtime-browser'
|
||||
import { RuntimeBrowserPageRegistry } from './runtime-browser-page-registry'
|
||||
|
||||
// The part of the authoritative window a browser stream touches: its renderer's throttle.
|
||||
export type AuthoritativeWindowDouble = {
|
||||
webContents: { isDestroyed: () => boolean; setBackgroundThrottling: (allowed: boolean) => void }
|
||||
}
|
||||
|
||||
/** A browser command host whose worktree `wt-1` has one registered page, `page-1`. */
|
||||
export function createSinglePageBrowserCommandsHost(
|
||||
window: AuthoritativeWindowDouble | null = null
|
||||
): RuntimeBrowserCommandHost {
|
||||
const runtimeBrowserPages = new RuntimeBrowserPageRegistry()
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: page commands read only these bridge members.
|
||||
const bridge = {
|
||||
getRegisteredTabs: vi.fn(() => new Map([['page-1', 100]])),
|
||||
getActivePageId: vi.fn(() => 'page-1'),
|
||||
tabList: vi.fn(() => ({
|
||||
tabs: [
|
||||
{ browserPageId: 'page-1', index: 0, url: 'about:blank', title: 'Browser', active: true }
|
||||
]
|
||||
}))
|
||||
} as unknown as AgentBrowserBridge
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: page commands read only these host members.
|
||||
return {
|
||||
resolveWorktreeSelector: async () => ({ id: 'wt-1' }),
|
||||
getAgentBrowserBridge: () => bridge,
|
||||
getRuntimeBrowserPageRegistry: () => runtimeBrowserPages,
|
||||
getAvailableAuthoritativeWindow: vi.fn(() => window),
|
||||
getOffscreenBrowserBackend: vi.fn(() => null)
|
||||
} as unknown as RuntimeBrowserCommandHost
|
||||
}
|
||||
Reference in New Issue
Block a user