fix(inspector): bound retained metadata and preserve cancellation

This commit is contained in:
morluto
2026-10-09 05:15:23 +08:00
parent f983c718c3
commit e714fb027a
9 changed files with 296 additions and 76 deletions
+12 -6
View File
@@ -119,12 +119,18 @@ targets belong to the same Electron application.
## Capture and determinism
Every observation records its time window and returns all valid script and
execution-context events received during it. Per-location protocol validation
remains in force. Scripts are validated after capture, deduplicated by stable
metadata, and canonically sorted. Wall-clock timestamps and protocol script IDs
are excluded from the durable result, so identical inputs and captured metadata
produce the same Evidence ID.
Capture retains valid script and execution-context metadata within an 8 MiB
aggregate budget, including conservative estimates for UTF-16 strings, record
and collection overhead, and projected URL fields. Each CDP message also has a
16 MiB transport limit. A message over that limit fails with `payload_limit`;
when the aggregate budget is reached, later metadata events are dropped and
the partial result reports `events_dropped`, `truncated: true`, and
`retained_metadata_budget_exceeded`. Repeated context transitions update the
current context record instead of accumulating duplicate objects. Scripts are
validated after capture, deduplicated by stable metadata, and canonically
sorted. Wall-clock timestamps and protocol script IDs are excluded from the
durable result, so identical inputs and captured metadata produce the same
Evidence ID.
Inspector attach is inherently incomplete. Enabling `Debugger` reports known
and uncollected scripts, but scripts collected before attachment may be
@@ -2,7 +2,10 @@ import type { ExecutionOptions } from "../AnalysisProvider.js";
import type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js";
import { createJavaScriptRuntimeObservationEvidence } from "./JavaScriptRuntimeObservationEvidence.js";
import type { Evidence } from "../../domain/evidence.js";
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
} from "../../domain/analysisErrorCore.js";
import { type AnalysisError } from "../../domain/analysisErrorBase.js";
import type {
ListJavaScriptRuntimeTargetsInput,
@@ -40,6 +43,8 @@ export const observeJavaScriptRuntime = async (
const ready = requireProvider(provider, "observe_javascript_runtime");
if (!ready.ok) return ready;
const result = await ready.value.observe(input, options);
if (options.signal?.aborted === true)
return err(new AnalysisCancelledError("observe_javascript_runtime"));
return result.ok
? ok(
createJavaScriptRuntimeObservationEvidence(
+7 -4
View File
@@ -25,7 +25,9 @@ interface PendingCommand {
export class CdpConnection {
readonly #pending = new Map<number, PendingCommand>();
readonly #listeners = new Set<(event: CdpEvent) => void>();
readonly #disconnectListeners = new Set<() => void>();
readonly #disconnectListeners = new Set<
(error: BrowserObservationError) => void
>();
readonly #protocolFailureListeners = new Set<
(error: BrowserObservationError) => void
>();
@@ -74,9 +76,9 @@ export class CdpConnection {
}
/** Subscribe to an unexpected transport loss while an operation is active. */
onDisconnect(listener: () => void): () => void {
onDisconnect(listener: (error: BrowserObservationError) => void): () => void {
if (this.#closed) {
listener();
listener(this.#transportError(this.#transportFailure));
return () => undefined;
}
this.#disconnectListeners.add(listener);
@@ -281,7 +283,8 @@ export class CdpConnection {
if (!wasClosed) this.#transportFailure = reason;
this.#failPending(reason);
if (wasClosed) return;
for (const listener of this.#disconnectListeners) listener();
const error = this.#transportError(reason);
for (const listener of this.#disconnectListeners) listener(error);
this.#disconnectListeners.clear();
}
+12
View File
@@ -9,6 +9,7 @@ import type {
JavaScriptRuntimeLocation,
JavaScriptRuntimeTargetLocation,
} from "../domain/javascript/javascriptRuntimeObservation.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { authorizedElectronFile } from "../browser/ElectronFileScope.js";
export type RuntimeLocationDecision =
@@ -18,6 +19,14 @@ export type RuntimeLocationDecision =
readonly reason: BrowserExclusionReason;
};
/** Throw the operation's typed cancellation error whenever its signal is aborted. */
export const throwIfRuntimeObservationCancelled = (
signal?: AbortSignal,
): void => {
if (signal?.aborted === true)
throw new AnalysisCancelledError("observe_javascript_runtime");
};
/**
* Wire bucket for inspector script exclusions. The V8 observation schemas
* fix the `unsupported_location` key, so every canonical denial reason maps
@@ -35,7 +44,9 @@ export const inspectorExclusionKey = (
/** Resolve a protocol location exposed by the explicitly selected Inspector endpoint. */
export const authorizeRuntimeLocation = async (
value: string,
signal?: AbortSignal,
): Promise<RuntimeLocationDecision> => {
throwIfRuntimeObservationCancelled(signal);
if (value.startsWith("node:") && value.length > 0)
return {
allowed: true,
@@ -43,6 +54,7 @@ export const authorizeRuntimeLocation = async (
};
if (value.startsWith("file:")) {
const filePath = await authorizedElectronFile(value);
throwIfRuntimeObservationCancelled(signal);
return filePath === undefined
? { allowed: false, reason: "not_approved" }
: { allowed: true, location: { kind: "file", file_path: filePath } };
@@ -5,6 +5,7 @@ import { pathToFileURL } from "node:url";
import { afterAll, beforeAll, expect, it } from "vitest";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { finalizeInspectorCapture } from "./V8InspectorCaptureProjection.js";
import type { CaptureState } from "./V8InspectorProvider.js";
@@ -214,3 +215,48 @@ it("waits for active authorization workers to settle before returning a failure"
expect(new Set(startedUrls)).toEqual(initialStarted);
expect(active).toBe(0);
});
it("stops finalization when cancellation arrives during location authorization", async () => {
const controller = new AbortController();
let authorizationStarted = false;
const state = stateFor([
{
rawUrl: "https://example.test/script.js",
executionContextKey: null,
cdpHash: null,
length: 0,
isModule: false,
},
]);
const finalization = finalizeInspectorCapture({
input: {
inspector_endpoint: "http://127.0.0.1:9222",
target_id: target.id,
observation_ms: 100,
},
runtime: {
product: "Node.js/v24.18.0",
protocol_version: "1.3",
v8_version: null,
},
target,
state,
signal: controller.signal,
authorizeLocation: async () => {
authorizationStarted = true;
controller.abort();
return {
allowed: true,
location: {
kind: "url",
origin: "https://example.test",
sanitized_url: "https://example.test/script.js",
},
};
},
});
await expect(finalization).rejects.toBeInstanceOf(AnalysisCancelledError);
expect(authorizationStarted).toBe(true);
});
+13 -2
View File
@@ -9,6 +9,7 @@ import type {
import {
authorizeRuntimeLocation,
inspectorExclusionKey,
throwIfRuntimeObservationCancelled,
} from "./JavaScriptRuntimeScope.js";
import type { CaptureState, ScriptDraft } from "./V8InspectorProvider.js";
import type { AuthorizedV8InspectorTarget } from "./V8InspectorEndpoint.js";
@@ -38,7 +39,11 @@ interface FinalizeCaptureInput {
readonly runtime: JavaScriptRuntimeTargetList["runtime"];
readonly target: AuthorizedV8InspectorTarget;
readonly state: CaptureState;
readonly authorizeLocation?: typeof authorizeRuntimeLocation;
readonly authorizeLocation?: (
rawUrl: string,
signal?: AbortSignal,
) => ReturnType<typeof authorizeRuntimeLocation>;
readonly signal?: AbortSignal;
}
const LOCATION_AUTHORIZATION_WORKERS = 8;
@@ -55,7 +60,9 @@ export const finalizeInspectorCapture = async ({
target,
state,
authorizeLocation = authorizeRuntimeLocation,
signal,
}: FinalizeCaptureInput): Promise<JavaScriptRuntimeObservation> => {
throwIfRuntimeObservationCancelled(signal);
const exclusions = createInspectorExclusionCounts();
const scripts = new Map<
string,
@@ -74,6 +81,7 @@ export const finalizeInspectorCapture = async ({
const worker = async (): Promise<void> => {
try {
for (;;) {
throwIfRuntimeObservationCancelled(signal);
if (stopScheduling) return;
const index = nextGroup;
nextGroup += 1;
@@ -82,8 +90,9 @@ export const finalizeInspectorCapture = async ({
const [rawUrl, drafts] = group;
authorizedGroups[index] = {
drafts,
decision: await authorizeLocation(rawUrl),
decision: await authorizeLocation(rawUrl, signal),
};
throwIfRuntimeObservationCancelled(signal);
}
} catch (cause: unknown) {
stopScheduling = true;
@@ -99,6 +108,7 @@ export const finalizeInspectorCapture = async ({
const failedWorker = workerResults.find(
(result) => result.status === "rejected",
);
throwIfRuntimeObservationCancelled(signal);
if (failedWorker?.status === "rejected") throw failedWorker.reason;
for (const { drafts, decision } of authorizedGroups) {
for (const draft of drafts) {
@@ -131,6 +141,7 @@ export const finalizeInspectorCapture = async ({
? 1
: 0,
);
throwIfRuntimeObservationCancelled(signal);
return {
runtime,
target: {
+53 -18
View File
@@ -42,6 +42,12 @@ import {
export { V8_INSPECTOR_PROVIDER_IDENTITY } from "./providerIdentity.js";
import { V8_INSPECTOR_PROVIDER_IDENTITY } from "./providerIdentity.js";
/** Maximum decoded CDP message accepted while observing one Inspector target. */
export const INSPECTOR_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024;
/** Aggregate retained metadata budget, including an estimate of JS object overhead. */
export const INSPECTOR_MAX_RETAINED_METADATA_BYTES = 8 * 1024 * 1024;
const RETAINED_OBJECT_OVERHEAD_BYTES = 512;
export interface ScriptDraft {
readonly rawUrl: string;
readonly executionContextKey: string | null;
@@ -147,6 +153,7 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
target.webSocketUrl,
"observe_javascript_runtime",
options.signal,
{ maxPayloadBytes: INSPECTOR_MAX_PAYLOAD_BYTES },
);
const state = emptyCaptureState();
const removeListener = connection.onEvent((event) =>
@@ -164,6 +171,7 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
runtime: discovery.runtime,
target,
state,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
outcome = ok(javascriptRuntimeObservationSchema.parse(result));
} catch (cause: unknown) {
@@ -344,8 +352,8 @@ const ingestScript = (
length: nonnegativeInteger(value?.length),
isModule: value?.isModule === true,
};
const bytes = metadataBytes(draft);
retainEvent(state, bytes);
const bytes = retainedMetadataBytes(draft);
if (!retainEvent(state, bytes)) return;
state.scripts.push(draft);
};
@@ -355,8 +363,16 @@ const ingestContext = (
state: CaptureState,
): void => {
if (event.method === "Runtime.executionContextsCleared") {
for (const context of state.contexts.values()) context.state = "cleared";
retainEvent(state, 0);
let byteDelta = 0;
for (const context of state.contexts.values()) {
byteDelta +=
retainedMetadataBytes({ ...context, state: "cleared" }) -
retainedMetadataBytes(context);
}
if (replaceRetainedMetadata(state, byteDelta)) {
for (const context of state.contexts.values()) context.state = "cleared";
retainEvent(state, 0);
}
return;
}
const parameters = recordValue(event.params);
@@ -385,14 +401,32 @@ const ingestContext = (
: "destroyed",
origin: origin === "" ? null : origin,
};
const bytes = metadataBytes(draft);
retainEvent(state, bytes);
const previous = state.contexts.get(key);
const bytes =
retainedMetadataBytes(draft) -
(previous === undefined ? 0 : retainedMetadataBytes(previous));
if (!retainEvent(state, bytes)) return;
state.contexts.set(key, draft);
};
const retainEvent = (state: CaptureState, bytes: number): void => {
state.metadataBytes += bytes;
const retainEvent = (state: CaptureState, bytes: number): boolean => {
if (!replaceRetainedMetadata(state, bytes)) return false;
state.eventsRetained += 1;
return true;
};
const replaceRetainedMetadata = (
state: CaptureState,
byteDelta: number,
): boolean => {
if (state.metadataBytes + byteDelta > INSPECTOR_MAX_RETAINED_METADATA_BYTES) {
state.eventsDropped += 1;
state.truncated = true;
state.truncationReasons.add("retained_metadata_budget_exceeded");
return false;
}
state.metadataBytes += byteDelta;
return true;
};
const waitForCapture = async (
@@ -402,14 +436,7 @@ const waitForCapture = async (
): Promise<void> => {
let removeDisconnect = (): void => undefined;
const disconnected = new Promise<never>((_resolve, reject) => {
removeDisconnect = connection.onDisconnect(() =>
reject(
new BrowserObservationError(
"observe_javascript_runtime",
"disconnected",
),
),
);
removeDisconnect = connection.onDisconnect((error) => reject(error));
});
try {
await Promise.race([
@@ -425,8 +452,16 @@ const waitForCapture = async (
}
};
const metadataBytes = (value: object): number =>
Buffer.byteLength(JSON.stringify(value));
const retainedMetadataBytes = (value: ScriptDraft | ContextDraft): number => {
const strings =
"rawUrl" in value
? [value.rawUrl, value.rawUrl, value.executionContextKey, value.cdpHash]
: [value.contextKey, value.origin];
return (
RETAINED_OBJECT_OVERHEAD_BYTES +
strings.reduce((total, text) => total + (text?.length ?? 0) * 2, 0)
);
};
const nonnegativeInteger = (value: unknown): number => {
const parsed = numberValue(value);
@@ -175,9 +175,10 @@ describe("complete Inspector script hashes", () => {
scriptHashes,
});
try {
const result = await new V8InspectorProvider().observe(
observeInput(fake.endpoint, fake.targetId, "node"),
);
const result = await new V8InspectorProvider().observe({
...observeInput(fake.endpoint, fake.targetId, "node"),
observation_ms: 1_000,
});
if (!result.ok) throw result.error;
expect(result.value.scripts.items).toHaveLength(scriptHashes.length);
expect(
@@ -212,7 +213,7 @@ describe("undeclared V8 runtime role", () => {
});
});
describe("passive V8 Inspector evidence", () => {
describe("V8 Inspector script retention", () => {
test("retains authorized script locations longer than the former byte ceiling", async () => {
const longUrl = `https://example.test/${"a".repeat(20_000)}`;
const fake = await startFakeV8Inspector({
@@ -261,7 +262,69 @@ describe("passive V8 Inspector evidence", () => {
await fake.close();
}
});
});
describe("bounded V8 Inspector capture", () => {
test("bounds aggregate script and context metadata while retaining a useful partial capture", async () => {
const fixture = await runtimeFixture();
const url = pathToFileURL(fixture.entry).href;
const scriptHashes = Array.from(
{ length: 2_300 },
(_, index) => `${"h".repeat(4_096)}${String(index)}`,
);
const fake = await startFakeV8Inspector({
targetUrl: url,
scriptUrls: scriptHashes.map(() => url),
scriptHashes,
contextTransitionCount: 2_000,
});
try {
const result = await new V8InspectorProvider().observe({
...observeInput(fake.endpoint, fake.targetId, "node"),
observation_ms: 1_000,
});
if (!result.ok) throw result.error;
expect(result.value.capture.truncated).toBe(true);
expect(result.value.capture.truncation_reasons).toEqual([
"retained_metadata_budget_exceeded",
]);
expect(result.value.capture.events_dropped).toBeGreaterThan(0);
expect(result.value.capture.metadata_bytes_retained).toBeLessThanOrEqual(
8 * 1024 * 1024,
);
expect(result.value.capture.events_observed).toBeGreaterThan(
result.value.capture.events_retained,
);
expect(result.value.execution_contexts).toHaveLength(1);
} finally {
await fake.close();
}
});
test("rejects a CDP event larger than the configured transport payload budget", async () => {
const fixture = await runtimeFixture();
const fake = await startFakeV8Inspector({
targetUrl: pathToFileURL(fixture.entry).href,
oversizedEventBytes: 16 * 1024 * 1024,
});
try {
const result = await new V8InspectorProvider().observe(
observeInput(fake.endpoint, fake.targetId, "node"),
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toMatchObject({
_tag: "BrowserObservationError",
reason: "payload_limit",
});
expect(result.error.userMessage).toContain("protocol budget");
} finally {
await fake.close();
}
});
});
describe("passive V8 Inspector evidence", () => {
test("produces deterministic Evidence through the service without grants", async () => {
const fixture = await runtimeFixture();
const fake = await startFakeV8Inspector({
+80 -41
View File
@@ -14,6 +14,8 @@ interface FakeV8InspectorOptions {
readonly runtimeProduct?: string;
readonly scriptUrls?: readonly string[];
readonly scriptHashes?: readonly string[];
readonly oversizedEventBytes?: number;
readonly contextTransitionCount?: number;
readonly additionalTargetUrl?: string;
readonly additionalTargetCount?: number;
readonly closeOnMethod?: string;
@@ -94,47 +96,7 @@ export const startFakeV8Inspector = async (
);
});
webSockets.on("connection", (socket: WebSocket) => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.on("message", (raw) => {
const command = parseCommand(raw.toString());
commands.push(command);
if (options.closeOnMethod === command.method) {
socket.close();
return;
}
socket.send(JSON.stringify({ id: command.id, result: {} }));
if (command.method === "Runtime.enable")
socket.send(
JSON.stringify({
method: "Runtime.executionContextCreated",
params: {
context: {
id: 1,
origin: "",
name: "node[fixture]",
},
},
}),
);
if (command.method === "Debugger.enable")
for (const [index, url] of (
options.scriptUrls ?? [options.targetUrl]
).entries())
socket.send(
JSON.stringify({
method: "Debugger.scriptParsed",
params: {
scriptId: String(index + 1),
url,
executionContextId: 1,
hash: options.scriptHashes?.[index] ?? `hash-${String(index)}`,
length: 100 + index,
isModule: index % 2 === 0,
},
}),
);
});
registerInspectorSocket(socket, options, commands, sockets);
});
await new Promise<void>((resolve, reject) => {
http.once("error", reject);
@@ -160,6 +122,83 @@ export const startFakeV8Inspector = async (
};
};
const registerInspectorSocket = (
socket: WebSocket,
options: FakeV8InspectorOptions,
commands: InspectorCommand[],
sockets: Set<WebSocket>,
): void => {
sockets.add(socket);
socket.on("close", () => sockets.delete(socket));
socket.on("message", (raw) => {
const command = parseCommand(raw.toString());
commands.push(command);
if (options.closeOnMethod === command.method) {
socket.close();
return;
}
socket.send(JSON.stringify({ id: command.id, result: {} }));
respondToInspectorCommand(socket, command, options);
});
};
const respondToInspectorCommand = (
socket: WebSocket,
command: InspectorCommand,
options: FakeV8InspectorOptions,
): void => {
if (command.method === "Runtime.enable") sendRuntimeEvents(socket, options);
if (command.method === "Debugger.enable") sendScriptEvents(socket, options);
};
const sendRuntimeEvents = (
socket: WebSocket,
options: FakeV8InspectorOptions,
): void => {
socket.send(
JSON.stringify({
method: "Runtime.executionContextCreated",
params: { context: { id: 1, origin: "", name: "node[fixture]" } },
}),
);
for (let index = 0; index < (options.contextTransitionCount ?? 0); index += 1)
socket.send(
JSON.stringify({
method: "Runtime.executionContextDestroyed",
params: { executionContextId: 1 },
}),
);
if (options.oversizedEventBytes !== undefined)
socket.send(
JSON.stringify({
method: "Runtime.noisyFixtureEvent",
params: { padding: "x".repeat(options.oversizedEventBytes) },
}),
);
};
const sendScriptEvents = (
socket: WebSocket,
options: FakeV8InspectorOptions,
): void => {
for (const [index, url] of (
options.scriptUrls ?? [options.targetUrl]
).entries())
socket.send(
JSON.stringify({
method: "Debugger.scriptParsed",
params: {
scriptId: String(index + 1),
url,
executionContextId: 1,
hash: options.scriptHashes?.[index] ?? `hash-${String(index)}`,
length: 100 + index,
isModule: index % 2 === 0,
},
}),
);
};
const target = (
id: string,
url: string,