fix(browser): keep CDP reload from closing Orca browser tabs

Fix the per-tab CDP proxy reload path so raw Page.reload keeps the Orca browser tab alive. Lifecycle priming is bounded and root reloads use the same safer webContents reload behavior as Orca's own browser reload path.
This commit is contained in:
Rod Boev
2026-07-02 23:24:24 -07:00
committed by GitHub
parent f0278c116e
commit 09fc57e113
2 changed files with 287 additions and 14 deletions
+183 -1
View File
@@ -21,7 +21,9 @@ function createMockWebContents() {
detach: vi.fn(() => {
debuggerAttached = false
}),
sendCommand: vi.fn(async () => ({})),
sendCommand: vi.fn(
async (_method?: string, _params?: Record<string, unknown>, _sessionId?: string) => ({})
),
on: vi.fn((event: string, handler: DebuggerListener) => {
const arr = listeners.get(event) ?? []
arr.push(handler)
@@ -41,6 +43,8 @@ function createMockWebContents() {
debugger: debuggerObj,
isDestroyed: () => destroyed,
focus: vi.fn(),
reload: vi.fn(),
reloadIgnoringCache: vi.fn(),
getTitle: vi.fn(() => 'Example'),
getURL: vi.fn(() => 'https://example.com')
},
@@ -89,6 +93,22 @@ describe('CdpWsProxy', () => {
})
}
type SendCommandCall = [string, Record<string, unknown>?, string?]
function getSendCommandCalls(): SendCommandCall[] {
const calls = mock.webContents.debugger.sendCommand.mock.calls as unknown as [
string,
Record<string, unknown>?,
string?
][]
return calls
}
function getSendCommandMethods(): string[] {
const calls = getSendCommandCalls()
return calls.map((call) => call[0])
}
it('starts on a random port and returns ws:// URL', () => {
expect(endpoint).toMatch(/^ws:\/\/127\.0\.0\.1:\d+$/)
expect(proxy.getPort()).toBeGreaterThan(0)
@@ -311,6 +331,168 @@ describe('CdpWsProxy', () => {
client.close()
})
it('primes lifecycle events for Page.navigate', async () => {
const client = await connect()
const response = await sendAndReceive(client, {
id: 11,
method: 'Page.navigate',
params: { url: 'https://example.com/next' }
})
expect(response.id).toBe(11)
expect(response.result).toEqual({})
expect(getSendCommandMethods()).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Network.enable',
'Page.enable',
'Page.setLifecycleEventsEnabled',
'Page.navigate'
])
client.close()
})
it('primes lifecycle events for Page.reload and preserves response id', async () => {
const client = await connect()
const response = await sendAndReceive(client, {
id: 12,
method: 'Page.reload'
})
expect(response.id).toBe(12)
expect(response.result).toEqual({})
expect(getSendCommandMethods()).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Network.enable',
'Page.enable',
'Page.setLifecycleEventsEnabled'
])
expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
expect(getSendCommandMethods()).not.toContain('Page.reload')
client.close()
})
it('preserves explicit Page.navigate session during lifecycle priming', async () => {
const client = await connect()
await sendAndReceive(client, {
id: 14,
method: 'Page.navigate',
params: { url: 'https://example.com/frame' },
sessionId: 'iframe-session-123'
})
expect(getSendCommandCalls().slice(2)).toEqual([
['Network.enable', {}, 'iframe-session-123'],
['Page.enable', {}, 'iframe-session-123'],
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
['Page.navigate', { url: 'https://example.com/frame' }, 'iframe-session-123']
])
client.close()
})
it('forwards explicit Page.reload session after lifecycle priming', async () => {
const client = await connect()
await sendAndReceive(client, {
id: 15,
method: 'Page.reload',
params: { ignoreCache: true },
sessionId: 'iframe-session-123'
})
expect(getSendCommandCalls().slice(2)).toEqual([
['Network.enable', {}, 'iframe-session-123'],
['Page.enable', {}, 'iframe-session-123'],
['Page.setLifecycleEventsEnabled', { enabled: true }, 'iframe-session-123'],
['Page.reload', { ignoreCache: true }, 'iframe-session-123']
])
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
expect(mock.webContents.reload).not.toHaveBeenCalled()
client.close()
})
it('rejects root Page.reload params that direct webContents reload cannot honor', async () => {
const client = await connect()
const response = await sendAndReceive(client, {
id: 16,
method: 'Page.reload',
params: { loaderId: 'stale-loader' }
})
expect(response).toEqual({
id: 16,
error: {
code: -32000,
message: 'Page.reload parameter "loaderId" is not supported for Orca tab reloads'
}
})
expect(mock.webContents.reload).not.toHaveBeenCalled()
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
expect(getSendCommandMethods()).not.toContain('Network.enable')
client.close()
})
it('still reloads when lifecycle priming stalls', async () => {
const client = await connect()
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
if (method === 'Network.enable') {
return new Promise(() => {})
}
return Promise.resolve({})
})
const responsePromise = sendAndReceive(client, {
id: 17,
method: 'Page.reload'
})
await expect(responsePromise).resolves.toEqual({ id: 17, result: {} })
expect(mock.webContents.reload).toHaveBeenCalledTimes(1)
client.close()
})
it('does not reload after the requesting client disconnects during priming', async () => {
const client = await connect()
mock.webContents.debugger.sendCommand.mockImplementation((method?: string) => {
if (method === 'Network.enable') {
return new Promise(() => {})
}
return Promise.resolve({})
})
client.send(JSON.stringify({ id: 18, method: 'Page.reload' }))
client.close()
await new Promise((resolve) => setTimeout(resolve, 1_100))
expect(mock.webContents.reload).not.toHaveBeenCalled()
expect(mock.webContents.reloadIgnoringCache).not.toHaveBeenCalled()
})
it('forwards Runtime.evaluate without lifecycle priming', async () => {
const client = await connect()
const response = await sendAndReceive(client, {
id: 13,
method: 'Runtime.evaluate',
params: { expression: 'document.readyState' }
})
expect(response.id).toBe(13)
expect(response.result).toEqual({})
expect(getSendCommandMethods()).toEqual([
'Page.enable',
'Page.addScriptToEvaluateOnNewDocument',
'Runtime.evaluate'
])
client.close()
})
// ── Page.frameNavigated interception ──
// ── Cleanup ──
+104 -13
View File
@@ -6,6 +6,8 @@ import { captureScreenshot } from './cdp-screenshot'
import { ANTI_DETECTION_SCRIPT } from './anti-detection'
import { acquireElectronDebugger, type ElectronDebuggerLease } from './electron-debugger-lease'
const LIFECYCLE_PRIMING_TIMEOUT_MS = 1_000
export class CdpWsProxy {
private httpServer: Server | null = null
private wss: WebSocketServer | null = null
@@ -298,13 +300,39 @@ export class CdpWsProxy {
}
// Why: agent-browser waits for network idle to detect navigation completion.
// Electron webview CDP subscriptions silently lapse after cross-process swaps.
// Page.reload needs the same priming: forwarding it unprimed closed the tab (#7031).
if (msg.method === 'Page.navigate' && !this.webContents.isDestroyed()) {
void this.navigateWithLifecycleEnsured(client, clientId, msg.params ?? {})
void this.navigateWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId)
return
}
// Why: CDP Page.reload can destroy Electron webview targets during process swaps.
// Use the same direct webContents reload path as Orca's own browser.reload.
if (msg.method === 'Page.reload' && !this.webContents.isDestroyed()) {
void this.reloadWithLifecycle(client, clientId, msg.params ?? {}, msg.sessionId)
return
}
this.forwardCommand(client, clientId, msg.method, msg.params ?? {}, msg.sessionId)
}
private resolveDebuggerSessionId(msgSessionId?: string): string | undefined {
return msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
}
private isActiveClient(client: WebSocket): boolean {
return this.client === client && client.readyState === WebSocket.OPEN
}
private sendDebuggerCommand(
method: string,
params: Record<string, unknown>,
sessionId?: string
): Promise<unknown> {
const command = sessionId
? this.webContents.debugger.sendCommand(method, params, sessionId)
: this.webContents.debugger.sendCommand(method, params)
return Promise.resolve(command)
}
private forwardCommand(
client: WebSocket,
clientId: number,
@@ -316,10 +344,9 @@ export class CdpWsProxy {
this.sendError(clientId, 'Browser tab is no longer available', client)
return
}
const sessionId =
msgSessionId && msgSessionId !== this.clientSessionId ? msgSessionId : undefined
const sessionId = this.resolveDebuggerSessionId(msgSessionId)
try {
Promise.resolve(this.webContents.debugger.sendCommand(method, params, sessionId))
this.sendDebuggerCommand(method, params, sessionId)
.then((result) => {
this.sendResult(clientId, result, client)
})
@@ -331,21 +358,85 @@ export class CdpWsProxy {
}
}
private async navigateWithLifecycleEnsured(
private async navigateWithLifecycle(
client: WebSocket,
clientId: number,
params: Record<string, unknown>
params: Record<string, unknown>,
msgSessionId?: string
): Promise<void> {
await this.primePageLifecycle(this.resolveDebuggerSessionId(msgSessionId))
if (!this.isActiveClient(client)) {
return
}
this.forwardCommand(client, clientId, 'Page.navigate', params, msgSessionId)
}
private async reloadWithLifecycle(
client: WebSocket,
clientId: number,
params: Record<string, unknown>,
msgSessionId?: string
): Promise<void> {
const sessionId = this.resolveDebuggerSessionId(msgSessionId)
const unsupportedParam = sessionId ? null : this.getUnsupportedRootReloadParam(params)
if (unsupportedParam) {
this.sendError(
clientId,
`Page.reload parameter "${unsupportedParam}" is not supported for Orca tab reloads`,
client
)
return
}
await this.primePageLifecycle(sessionId)
if (!this.isActiveClient(client)) {
return
}
if (sessionId) {
this.forwardCommand(client, clientId, 'Page.reload', params, msgSessionId)
return
}
if (this.webContents.isDestroyed()) {
this.sendError(clientId, 'Browser tab is no longer available', client)
return
}
try {
const dbg = this.webContents.debugger
if (params.ignoreCache === true) {
this.webContents.reloadIgnoringCache()
} else {
this.webContents.reload()
}
this.sendResult(clientId, {}, client)
} catch (err) {
this.sendError(clientId, err instanceof Error ? err.message : String(err), client)
}
}
private getUnsupportedRootReloadParam(params: Record<string, unknown>): string | null {
return Object.keys(params).find((key) => key !== 'ignoreCache') ?? null
}
private async primePageLifecycle(sessionId?: string): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | null = null
const priming = (async (): Promise<void> => {
// Why: without Network.enable, agent-browser never sees network idle → goto times out.
await dbg.sendCommand('Network.enable', {})
await dbg.sendCommand('Page.enable', {})
await dbg.sendCommand('Page.setLifecycleEventsEnabled', { enabled: true })
} catch {
/* best-effort */
await this.sendDebuggerCommand('Network.enable', {}, sessionId)
await this.sendDebuggerCommand('Page.enable', {}, sessionId)
await this.sendDebuggerCommand('Page.setLifecycleEventsEnabled', { enabled: true }, sessionId)
})().catch(() => {})
try {
await Promise.race([
priming,
new Promise<void>((resolve) => {
timeout = setTimeout(resolve, LIFECYCLE_PRIMING_TIMEOUT_MS)
timeout.unref?.()
})
])
} finally {
if (timeout) {
clearTimeout(timeout)
}
}
this.forwardCommand(client, clientId, 'Page.navigate', params)
}
private handleScreenshot(