diff --git a/docs/browser-observation.md b/docs/browser-observation.md index e1ce74fd1..c0840111d 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -157,7 +157,7 @@ coverage. It reports excluded items and any values omitted or truncated by validation; it does not drop records to meet an internal collection quota. Disallowed-origin entries are filtered before retention. -CDP discovery, WebSocket connection, and commands continue until the peer responds or the caller cancels the operation; REA has no internal byte, message-count, or pending-command quota for these exchanges. Without caller cancellation, a stalled peer can leave an operation waiting. On cancellation, REA closes only its own WebSocket, waiting up to one second before terminating it. Malformed unsolicited events and malformed replies to pending commands poison the connection so later commands fail closed. An optional method is reported unavailable only when the browser rejects it as unknown; other command rejections retain their reported failure. +CDP discovery, WebSocket connection, and commands continue until the peer responds or the caller cancels the operation; REA has no internal byte, message-count, or pending-command quota for these exchanges. Without caller cancellation, a stalled peer can leave an operation waiting. On cancellation, REA closes only its own WebSocket, waiting up to one second before terminating it. Malformed unsolicited events and malformed replies to pending commands poison the connection so later commands fail closed. When a correlated reply reports a session ID, it must match the command's selected session. An optional method is reported unavailable only when the browser rejects it as unknown; other command rejections retain their reported failure. Network and console coverage starts only after REA attaches and enables the relevant CDP domains. `prior_activity_available` is always `false`; absence from these arrays is not evidence that an event never occurred. If CDP reuses a request ID without supplying redirect metadata, REA retains the predecessor observation and marks network coverage incomplete. diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index c547e20ba..fae6046ed 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -15,6 +15,7 @@ export interface CdpEvent { interface PendingCommand { readonly method: string; + readonly sessionId?: string; readonly resolve: (value: unknown) => void; readonly reject: (error: AnalysisError) => void; readonly removeAbort: () => void; @@ -120,6 +121,7 @@ export class CdpConnection { signal?.addEventListener("abort", onAbort, { once: true }); this.#pending.set(id, { method, + ...(sessionId === undefined ? {} : { sessionId }), resolve, reject, removeAbort: () => signal?.removeEventListener("abort", onAbort), @@ -214,7 +216,9 @@ export class CdpConnection { const hasResult = "result" in message; const hasError = "error" in message; if ( - ("sessionId" in message && typeof message.sessionId !== "string") || + ("sessionId" in message && + (typeof message.sessionId !== "string" || + message.sessionId !== pending.sessionId)) || hasResult === hasError ) { this.#failPending("protocol_error"); diff --git a/tests/boundary/browser/cdpConnection.test.ts b/tests/boundary/browser/cdpConnection.test.ts index 2b2bb0c08..5ce4195a7 100644 --- a/tests/boundary/browser/cdpConnection.test.ts +++ b/tests/boundary/browser/cdpConnection.test.ts @@ -6,6 +6,31 @@ import { type FakeCdpBrowser, } from "../../fixtures/fakeCdpBrowser.js"; +const INVALID_REPLIES = [ + ["missing result and error", (id: number) => ({ id })], + [ + "both result and error", + (id: number) => ({ + id, + result: {}, + error: { code: -32_602, message: "Invalid params" }, + }), + ], + [ + "malformed error", + (id: number) => ({ + id, + error: { code: "bad", message: "Invalid params" }, + }), + ], + ["invalid result", (id: number) => ({ id, result: [] })], + ["invalid session id", (id: number) => ({ id, result: {}, sessionId: 7 })], + [ + "foreign session id", + (id: number) => ({ id, result: {}, sessionId: "foreign-session" }), + ], +] as const; + describe("CDP connection", () => { const browsers: FakeCdpBrowser[] = []; @@ -86,26 +111,7 @@ describe("CDP connection", () => { } }); - it.each([ - ["missing result and error", (id: number) => ({ id })], - [ - "both result and error", - (id: number) => ({ - id, - result: {}, - error: { code: -32_602, message: "Invalid params" }, - }), - ], - [ - "malformed error", - (id: number) => ({ - id, - error: { code: "bad", message: "Invalid params" }, - }), - ], - ["invalid result", (id: number) => ({ id, result: [] })], - ["invalid session id", (id: number) => ({ id, result: {}, sessionId: 7 })], - ])( + it.each(INVALID_REPLIES)( "fails the connection for a correlated reply with %s", async (_case, reply) => { const browser = await startFakeCdpBrowser({ @@ -117,7 +123,7 @@ describe("CDP connection", () => { "observe_web_session", ); try { - const pending = connection.send("Page.enable"); + const pending = connection.send("Page.enable", {}, "selected-session"); const rejection = expect(pending).rejects.toMatchObject({ _tag: "BrowserObservationError", reason: "protocol_error",