fix(browser): bind correlated replies to their selected session

This commit is contained in:
morluto 2026-10-09 03:48:45 +08:00
parent ee043e2cbf
commit 96d3cafb07
3 changed files with 33 additions and 23 deletions

View File

@ -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.

View File

@ -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");

View File

@ -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",