diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index 73d05f4a09..667bb6344f 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -30,6 +30,7 @@ const MAX_PENDING_COMMANDS = 128; export class CdpConnection { readonly #pending = new Map(); readonly #listeners = new Set<(event: CdpEvent) => void>(); + readonly #disconnectListeners = new Set<() => void>(); #nextId = 1; #closed = false; #protocolFailed = false; @@ -39,8 +40,8 @@ export class CdpConnection { private readonly operation: BrowserObservationOperation, ) { socket.on("message", (data) => this.#receive(data)); - socket.on("close", () => this.#failPending("disconnected")); - socket.on("error", () => this.#failPending("disconnected")); + socket.on("close", () => this.#disconnect()); + socket.on("error", () => this.#disconnect()); } /** Connect to one already-validated loopback CDP WebSocket. */ @@ -65,6 +66,16 @@ export class CdpConnection { return () => this.#listeners.delete(listener); } + /** Subscribe to an unexpected transport loss while an operation is active. */ + onDisconnect(listener: () => void): () => void { + if (this.#closed) { + listener(); + return () => undefined; + } + this.#disconnectListeners.add(listener); + return () => this.#disconnectListeners.delete(listener); + } + /** Execute one bounded command, optionally within a flat target session. */ async send( method: string, @@ -214,6 +225,14 @@ export class CdpConnection { pending.reject(new BrowserObservationError(this.operation, reason)); } } + + #disconnect(): void { + const wasClosed = this.#closed; + this.#failPending("disconnected"); + if (wasClosed) return; + for (const listener of this.#disconnectListeners) listener(); + this.#disconnectListeners.clear(); + } } const rawText = (data: RawData): string => diff --git a/src/browser/CdpObservationSession.ts b/src/browser/CdpObservationSession.ts index 21171f1316..ff55925359 100644 --- a/src/browser/CdpObservationSession.ts +++ b/src/browser/CdpObservationSession.ts @@ -51,8 +51,12 @@ export const observeCdpSession = async ( initialUrl, ); const removeListener = context.connection.onEvent((event) => { - if (event.sessionId === context.sessionId) capture.ingest(event); + if (observationEventMatches(event, context.sessionId)) + capture.ingest(event); }); + const removeDisconnectListener = context.connection.onDisconnect(() => + capture.targetTerminated(), + ); try { await context.connection.send( "Page.enable", @@ -118,6 +122,7 @@ export const observeCdpSession = async ( }; } finally { removeListener(); + removeDisconnectListener(); } }; @@ -165,8 +170,7 @@ class TimelineCapture { break; case "Inspector.targetCrashed": case "Target.detachedFromTarget": - this.#add("target_terminated", params, null, null, "target_terminated"); - this.end("target_terminated"); + this.targetTerminated(); break; } } @@ -184,6 +188,12 @@ class TimelineCapture { this.#listeners.clear(); } + targetTerminated(): void { + if (this.#endReason !== undefined) return; + this.#add("target_terminated", {}, null, null, "target_terminated"); + this.end("target_terminated"); + } + setFinalUrl(rawUrl: string): void { const destination = scopedUrl(rawUrl, this.allowedOrigins); this.finalUrl = destination.scope === "approved" ? destination.url : null; @@ -303,6 +313,18 @@ class TimelineCapture { } } +const observationEventMatches = ( + event: CdpEvent, + sessionId: string | undefined, +): boolean => { + if (event.method !== "Target.detachedFromTarget") + return event.sessionId === sessionId; + return ( + sessionId !== undefined && + stringValue(recordValue(event.params)?.sessionId) === sessionId + ); +}; + const waitForWindow = async ( durationMs: number, capture: TimelineCapture, diff --git a/tests/cdpBrowserProvider.test.ts b/tests/cdpBrowserProvider.test.ts index b705f1324d..3a8ce3a5ed 100644 --- a/tests/cdpBrowserProvider.test.ts +++ b/tests/cdpBrowserProvider.test.ts @@ -720,6 +720,58 @@ describe("CdpBrowserProvider", () => { ); }); + it("ends a browser session when its flat target session detaches", async () => { + const browser = await startFakeCdpBrowser({ + sessionTimeline: "target_detached", + }); + browsers.push(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_terminated"); + expect(result.value.timeline.at(-1)).toMatchObject({ + type: "target_terminated", + detail: "target_terminated", + }); + }); + + it("ends a direct page session when its transport disconnects", async () => { + const browser = await startFakeCdpBrowser({ + pageScopedVersionWebSocket: true, + omitTargetWebSocket: true, + closeAfterMethod: "Page.setLifecycleEventsEnabled", + }); + browsers.push(browser); + const result = await new CdpBrowserProvider().observeSession( + observeWebSessionInputSchema.parse({ + cdp_endpoint: browser.endpoint, + allowed_origins: [browser.allowedOrigin], + target_id: "allowed-page", + approved: true, + observation_ms: 1_000, + }), + ); + + if (!result.ok) throw result.error; + expect(result.value.window.end_reason).toBe("target_terminated"); + expect(result.value.timeline.at(-1)).toMatchObject({ + type: "target_terminated", + detail: "target_terminated", + }); + const methods = browser.commands.map(({ method }) => method); + expect(methods).not.toContain("Target.attachToTarget"); + expect(methods).not.toContain("Target.detachFromTarget"); + expect(methods).not.toContain("Target.closeTarget"); + }); + it("discovers untrusted WebMCP declarations without registering or invoking them", async () => { const browser = await startFakeCdpBrowser({ webMcpTools: true }); browsers.push(browser); diff --git a/tests/fixtures/fakeCdpBrowser.ts b/tests/fixtures/fakeCdpBrowser.ts index 321d62d5a8..25f6c56129 100644 --- a/tests/fixtures/fakeCdpBrowser.ts +++ b/tests/fixtures/fakeCdpBrowser.ts @@ -51,7 +51,11 @@ interface FakeOptions { readonly binaryWebSocketEvent?: boolean; readonly invalidBinaryWebSocketEvent?: boolean; readonly sourceMapBody?: string; - readonly sessionTimeline?: "same_origin" | "outside_policy"; + readonly sessionTimeline?: + | "same_origin" + | "outside_policy" + | "target_detached"; + readonly closeAfterMethod?: string; readonly sensitiveShapes?: boolean; readonly invalidResponseBodyBase64?: boolean; readonly webMcpTools?: boolean; @@ -183,6 +187,8 @@ export const startFakeCdpBrowser = async ( if (options.malformedEventShapeOnMethod === command.method) socket.send(JSON.stringify({ method: 42 })); emitEvents(socket, command, port, options); + if (options.closeAfterMethod === command.method) + setImmediate(() => socket.close()); }); }); await new Promise((resolve, reject) => { @@ -963,6 +969,11 @@ const emitEvents = ( name: "networkIdle", timestamp: 15, }); + if (options.sessionTimeline === "target_detached") + event(socket, "Target.detachedFromTarget", undefined, { + sessionId: command.sessionId, + targetId: "allowed-page", + }); } };