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.
This commit is contained in:
Dery Ferdika
2026-10-25 05:34:26 +07:00
committed by GitHub
parent 6bcd243ca4
commit 1ff275fbf7
4 changed files with 202 additions and 3 deletions
+52 -3
View File
@@ -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,
});
};
+2
View File
@@ -12,6 +12,8 @@ export interface CdpCaptureEventsState {
rejectedSourceMapMetadataBudgetCount: number;
readonly scriptMetadataBytesById: Map<string, number>;
retainedScriptMetadataBytes: number;
/** Console-entry and websocket-frame bytes admitted against the shared retained-event budget. */
retainedEventBytes: number;
readonly executionContextFrames: Map<string, string>;
readonly network: Map<string, NetworkState>;
readonly networkRequestTimestamps: Map<string, number>;
@@ -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<string, unknown> = {}): 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);
});
+2
View File
@@ -26,6 +26,7 @@ export class CdpCaptureEvents implements CdpCaptureEventsState {
rejectedSourceMapMetadataBudgetCount = 0;
readonly scriptMetadataBytesById = new Map<string, number>();
retainedScriptMetadataBytes = 0;
retainedEventBytes = 0;
readonly executionContextFrames = new Map<string, string>();
readonly network = new Map<string, NetworkState>();
readonly networkRequestTimestamps = new Map<string, number>();
@@ -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();