From 1ff275fbf752b117575b5726dc86a1ff82cc011e Mon Sep 17 00:00:00 2001 From: Dery Ferdika <114888155+DeryFerd@users.noreply.github.com> Date: Sun, 25 Oct 2026 05:34:26 +0700 Subject: [PATCH] fix(browser): admit passive capture events against the shared retained budget (#7043) Passive page capture retained console entries and websocket frames with no aggregate ceiling. Every sibling retention surface already enforces WEB_RUNTIME_LIMITS.retainedEventBytes (8 MiB): the observation session's pre-arm buffer and document-request identities, the browser runtime sources, requests, and metadata, the V8 inspector's retained metadata, and this same capture's script metadata, which has its own count-and-bytes budget. A page that logs a few hundred megabytes of console text, or sends text frames whose JSON shapes carry hundreds of thousands of keys, grew the capture without limit until the process ran out of memory; the MCP response budget runs after the handler, so the growth happened before any typed refusal could exist. Admit each console entry and websocket frame against the same shared budget before retaining it. A refused entry is counted through the completeness tracker's dropped-events vocabulary, which already carries console_events and websocket_frames kinds and was so far used only for late scripts, so the inspection reports a truncated inventory with the dropped count instead of silently overflowing. Console entry bytes measure the retained representation: the entry's type, url, argument-type strings, and the text capture's retained bytes, which the handler already computed per entry. A websocket frame retains its shape summary rather than the payload, and the shape grows with the payload's own keys, so the frame is charged the serialized size of the shape it retains. beginAuthorizedFrame resets the counter together with the rest of the per-frame state. Out of scope: response metadata and agent hints, whose rows are fixed-shape facts already bounded per response, and any new setting. The shared 8 MiB constant is reused as-is. Validation: RED then green in src/browser/CdpCaptureEvents.eventBudget.test.ts, where the console case previously retained every oversized entry and the websocket case retained the over-budget frame; 26 tests across the neighboring console, websocket, coverage, and completeness suites pass. On this Windows host the wider browser project has 26 pre-existing failures in three Electron/WebBundle worker-spawn files, reproduced identically with this change stashed (26 failed, 644 passed on the pristine base; 26 failed, 647 passed with it on the same file set). tsc --noEmit over the changed set exits 0, oxlint reports 0 errors, oxfmt --check is clean, and verify:module-boundaries passes. No real browser session was driven on this host; the browser CI lane owns that coverage. Each retained entry now carries a conservative fixed overhead, and each captured console text value a wrapper cost, so a stream of minimal entries cannot admit millions of objects into the budget when shapes are disabled; a mixed-kind test locks that console entries and websocket frames spend one shared allowance. --- src/browser/CdpCaptureEventHandlers.ts | 55 ++++++- src/browser/CdpCaptureEventState.ts | 2 + .../CdpCaptureEvents.eventBudget.test.ts | 146 ++++++++++++++++++ src/browser/CdpCaptureEvents.ts | 2 + 4 files changed, 202 insertions(+), 3 deletions(-) create mode 100644 src/browser/CdpCaptureEvents.eventBudget.test.ts diff --git a/src/browser/CdpCaptureEventHandlers.ts b/src/browser/CdpCaptureEventHandlers.ts index d11652b188..a3d6bd7b84 100644 --- a/src/browser/CdpCaptureEventHandlers.ts +++ b/src/browser/CdpCaptureEventHandlers.ts @@ -2,6 +2,7 @@ import type { WebPageInspection } from "../domain/browserObservationSchemas.js"; import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; import { inferJsonShape } from "../domain/jsonShape.js"; import { paddedBase64ByteLength } from "../domain/paddedBase64.js"; +import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js"; import { safeResponseMetadata } from "./CdpSafeMetadata.js"; import { allowedSanitizedUrl, @@ -151,6 +152,13 @@ const scriptMetadataStringBytes = (script: { const stringByteLength = (value: string | null): number => value === null ? 0 : Buffer.byteLength(value, "utf8"); +/** Approximate fixed bytes of one retained console entry or frame object, so a + * stream of minimal entries cannot admit millions of objects into the budget. */ +const RETAINED_EVENT_ENTRY_OVERHEAD_BYTES = 64; +/** Approximate fixed bytes of one captured console text value wrapper beyond + * its retained text. */ +const RETAINED_EVENT_TEXT_VALUE_BYTES = 32; + const sourceMapForScript = ( value: unknown, scriptUrl: string, @@ -473,15 +481,38 @@ export const handleConsoleAPICalled = ( argumentTypes.includes(undefined) ) state.completeness.exclude("console_events", "invalid_protocol_value"); + const type = cdpStringValue(params.type) ?? "unknown"; + const textCapture = captureConsoleText(state, arguments_); + // The shared retained-event budget bounds the whole console inventory, so a + // page cannot grow the capture without limit; a refused entry stays counted + // as a dropped event instead of vanishing. + const entryBytes = + RETAINED_EVENT_ENTRY_OVERHEAD_BYTES + + stringByteLength(type) + + stringByteLength(source.url) + + (argumentTypes ?? []).reduce( + (sum, argumentType) => sum + stringByteLength(argumentType ?? "unknown"), + 0, + ) + + textCapture.retained_bytes + + textCapture.values.length * RETAINED_EVENT_TEXT_VALUE_BYTES; + if ( + entryBytes > + WEB_RUNTIME_LIMITS.retainedEventBytes - state.retainedEventBytes + ) { + state.completeness.drop("console_events"); + return; + } + state.retainedEventBytes += entryBytes; state.console.push({ - type: cdpStringValue(params.type) ?? "unknown", + type, timestamp: timestamp ?? null, argument_types: argumentTypes?.map((argumentType) => argumentType ?? "unknown") ?? null, url: source.url, line: integerOrNull(frame.lineNumber), column: integerOrNull(frame.columnNumber), - text_capture: captureConsoleText(state, arguments_), + text_capture: textCapture, }); }; @@ -551,11 +582,29 @@ export const handleWebSocketFrame = ( : (paddedBase64ByteLength(payload) ?? null); if (opcode === null || payloadBytes === null) state.completeness.exclude("websocket_frames", "invalid_protocol_value"); + const payloadShape = captureWebSocketShape(state, payload, opcode); + // The frame retains its shape summary, not the payload, and the shape grows + // with the payload's own keys; the shared retained-event budget bounds the + // retained frames and a refused frame stays counted as a dropped event. + const entryBytes = + RETAINED_EVENT_ENTRY_OVERHEAD_BYTES + + stringByteLength(direction) + + (payloadShape === null + ? 0 + : Buffer.byteLength(JSON.stringify(payloadShape), "utf8")); + if ( + entryBytes > + WEB_RUNTIME_LIMITS.retainedEventBytes - state.retainedEventBytes + ) { + state.completeness.drop("websocket_frames"); + return; + } + state.retainedEventBytes += entryBytes; connection.events.push({ direction, opcode, payload_bytes: payloadBytes, - payload_shape: captureWebSocketShape(state, payload, opcode), + payload_shape: payloadShape, }); }; diff --git a/src/browser/CdpCaptureEventState.ts b/src/browser/CdpCaptureEventState.ts index 778c54812c..331fc2807e 100644 --- a/src/browser/CdpCaptureEventState.ts +++ b/src/browser/CdpCaptureEventState.ts @@ -12,6 +12,8 @@ export interface CdpCaptureEventsState { rejectedSourceMapMetadataBudgetCount: number; readonly scriptMetadataBytesById: Map; retainedScriptMetadataBytes: number; + /** Console-entry and websocket-frame bytes admitted against the shared retained-event budget. */ + retainedEventBytes: number; readonly executionContextFrames: Map; readonly network: Map; readonly networkRequestTimestamps: Map; diff --git a/src/browser/CdpCaptureEvents.eventBudget.test.ts b/src/browser/CdpCaptureEvents.eventBudget.test.ts new file mode 100644 index 0000000000..496aec5537 --- /dev/null +++ b/src/browser/CdpCaptureEvents.eventBudget.test.ts @@ -0,0 +1,146 @@ +import { expect, it } from "vitest"; + +import { inspectWebPageInputSchema } from "../domain/browserObservation.js"; +import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js"; +import { CdpCaptureEvents } from "./CdpCaptureEvents.js"; + +const origin = "http://127.0.0.1:43127"; + +const createEvents = (extra: Record = {}): CdpCaptureEvents => + new CdpCaptureEvents( + inspectWebPageInputSchema.parse({ + cdp_endpoint: origin, + allowed_origins: [origin], + target_id: "page", + include_console_text: true, + include_websocket_shapes: true, + ...extra, + }), + new Set([origin]), + ); + +it("admits console events until the shared retained-event budget and reports the dropped count", () => { + const events = createEvents(); + const text = "x".repeat(1024 * 1024); + const total = + Math.ceil(WEB_RUNTIME_LIMITS.retainedEventBytes / text.length) + 2; + for (let index = 0; index < total; index += 1) + events.ingest({ + method: "Runtime.consoleAPICalled", + params: { + type: "log", + timestamp: index, + args: [{ type: "string", value: text }], + stackTrace: { + callFrames: [ + { url: `${origin}/app.js`, lineNumber: 0, columnNumber: 0 }, + ], + }, + }, + }); + expect(events.console.length).toBeLessThan(total); + expect(events.console.length).toBeGreaterThan(0); + const snapshot = events.completeness.snapshot(); + const dropped = total - events.console.length; + expect(snapshot.dropped_events.console_events).toBe(dropped); + expect(snapshot.dropped_events.total).toBe(dropped); +}); + +it("keeps a small console inventory admitted under the budget untouched", () => { + const events = createEvents(); + for (let index = 0; index < 16; index += 1) + events.ingest({ + method: "Runtime.consoleAPICalled", + params: { + type: "log", + timestamp: index, + args: [{ type: "string", value: `entry ${String(index)}` }], + stackTrace: { + callFrames: [ + { url: `${origin}/app.js`, lineNumber: 0, columnNumber: 0 }, + ], + }, + }, + }); + expect(events.console).toHaveLength(16); + expect(events.completeness.snapshot().dropped_events.total).toBe(0); +}); + +it("drops a websocket frame whose retained shape alone exceeds the shared budget", () => { + const events = createEvents(); + events.ingest({ + method: "Network.webSocketCreated", + params: { requestId: "socket", url: `ws://${origin.slice(7)}/live` }, + }); + events.ingest({ + method: "Network.webSocketFrameReceived", + params: { + requestId: "socket", + response: { opcode: 1, payloadData: '{"small":"frame"}' }, + }, + }); + const keys = Array.from( + { length: 900_000 }, + (_value, index) => `key${String(index)}`, + ); + events.ingest({ + method: "Network.webSocketFrameReceived", + params: { + requestId: "socket", + response: { + opcode: 1, + payloadData: `{${keys.map((key) => `"${key}":"v"`).join(",")}}`, + }, + }, + }); + const connection = events.webSocketConnectionsById.get("socket"); + expect(connection?.events).toHaveLength(1); + const snapshot = events.completeness.snapshot(); + expect(snapshot.dropped_events.websocket_frames).toBe(1); + expect(snapshot.dropped_events.total).toBe(1); +}); + +it("spends one shared allowance across console entries and websocket frames", () => { + const events = createEvents(); + const text = "x".repeat(1024 * 1024); + // Seven megabyte console entries leave roughly one megabyte of the shared + // allowance, so a shape that fits an empty allowance no longer fits. + for (let index = 0; index < 7; index += 1) + events.ingest({ + method: "Runtime.consoleAPICalled", + params: { + type: "log", + timestamp: index, + args: [{ type: "string", value: text }], + stackTrace: { + callFrames: [ + { url: `${origin}/app.js`, lineNumber: 0, columnNumber: 0 }, + ], + }, + }, + }); + expect(events.console).toHaveLength(7); + events.ingest({ + method: "Network.webSocketCreated", + params: { requestId: "socket", url: `ws://${origin.slice(7)}/live` }, + }); + const keys = Array.from( + { length: 200_000 }, + (_value, index) => `key${String(index)}`, + ); + events.ingest({ + method: "Network.webSocketFrameReceived", + params: { + requestId: "socket", + response: { + opcode: 1, + payloadData: `{${keys.map((key) => `"${key}":"v"`).join(",")}}`, + }, + }, + }); + expect(events.webSocketConnectionsById.get("socket")?.events).toHaveLength(0); + const snapshot = events.completeness.snapshot(); + expect(snapshot.dropped_events.console_events).toBe(0); + expect(snapshot.dropped_events.websocket_frames).toBe(1); + expect(snapshot.dropped_events.total).toBe(1); +}); diff --git a/src/browser/CdpCaptureEvents.ts b/src/browser/CdpCaptureEvents.ts index 02f06cfce5..b97e114334 100644 --- a/src/browser/CdpCaptureEvents.ts +++ b/src/browser/CdpCaptureEvents.ts @@ -26,6 +26,7 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { rejectedSourceMapMetadataBudgetCount = 0; readonly scriptMetadataBytesById = new Map(); retainedScriptMetadataBytes = 0; + retainedEventBytes = 0; readonly executionContextFrames = new Map(); readonly network = new Map(); readonly networkRequestTimestamps = new Map(); @@ -72,6 +73,7 @@ export class CdpCaptureEvents implements CdpCaptureEventsState { this.rejectedSourceMapMetadataBudgetCount = 0; this.scriptMetadataBytesById.clear(); this.retainedScriptMetadataBytes = 0; + this.retainedEventBytes = 0; this.#scriptMetadataBudgetExclusionsRecorded = false; this.executionContextFrames.clear(); this.network.clear();