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 ## Capture and determinism
Every observation records its time window and returns all valid script and Capture retains valid script and execution-context metadata within an 8 MiB
execution-context events received during it. Per-location protocol validation aggregate budget, including conservative estimates for UTF-16 strings, record
remains in force. Scripts are validated after capture, deduplicated by stable and collection overhead, and projected URL fields. Each CDP message also has a
metadata, and canonically sorted. Wall-clock timestamps and protocol script IDs 16 MiB transport limit. A message over that limit fails with `payload_limit`;
are excluded from the durable result, so identical inputs and captured metadata when the aggregate budget is reached, later metadata events are dropped and
produce the same Evidence ID. 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 Inspector attach is inherently incomplete. Enabling `Debugger` reports known
and uncollected scripts, but scripts collected before attachment may be 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 type { JavaScriptRuntimeObservationPort } from "./JavaScriptRuntimeObservationPort.js";
import { createJavaScriptRuntimeObservationEvidence } from "./JavaScriptRuntimeObservationEvidence.js"; import { createJavaScriptRuntimeObservationEvidence } from "./JavaScriptRuntimeObservationEvidence.js";
import type { Evidence } from "../../domain/evidence.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 AnalysisError } from "../../domain/analysisErrorBase.js";
import type { import type {
ListJavaScriptRuntimeTargetsInput, ListJavaScriptRuntimeTargetsInput,
@@ -40,6 +43,8 @@ export const observeJavaScriptRuntime = async (
const ready = requireProvider(provider, "observe_javascript_runtime"); const ready = requireProvider(provider, "observe_javascript_runtime");
if (!ready.ok) return ready; if (!ready.ok) return ready;
const result = await ready.value.observe(input, options); const result = await ready.value.observe(input, options);
if (options.signal?.aborted === true)
return err(new AnalysisCancelledError("observe_javascript_runtime"));
return result.ok return result.ok
? ok( ? ok(
createJavaScriptRuntimeObservationEvidence( createJavaScriptRuntimeObservationEvidence(
+7 -4
View File
@@ -25,7 +25,9 @@ interface PendingCommand {
export class CdpConnection { export class CdpConnection {
readonly #pending = new Map<number, PendingCommand>(); readonly #pending = new Map<number, PendingCommand>();
readonly #listeners = new Set<(event: CdpEvent) => void>(); readonly #listeners = new Set<(event: CdpEvent) => void>();
readonly #disconnectListeners = new Set<() => void>(); readonly #disconnectListeners = new Set<
(error: BrowserObservationError) => void
>();
readonly #protocolFailureListeners = new Set< readonly #protocolFailureListeners = new Set<
(error: BrowserObservationError) => void (error: BrowserObservationError) => void
>(); >();
@@ -74,9 +76,9 @@ export class CdpConnection {
} }
/** Subscribe to an unexpected transport loss while an operation is active. */ /** Subscribe to an unexpected transport loss while an operation is active. */
onDisconnect(listener: () => void): () => void { onDisconnect(listener: (error: BrowserObservationError) => void): () => void {
if (this.#closed) { if (this.#closed) {
listener(); listener(this.#transportError(this.#transportFailure));
return () => undefined; return () => undefined;
} }
this.#disconnectListeners.add(listener); this.#disconnectListeners.add(listener);
@@ -281,7 +283,8 @@ export class CdpConnection {
if (!wasClosed) this.#transportFailure = reason; if (!wasClosed) this.#transportFailure = reason;
this.#failPending(reason); this.#failPending(reason);
if (wasClosed) return; 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(); this.#disconnectListeners.clear();
} }
+12
View File
@@ -9,6 +9,7 @@ import type {
JavaScriptRuntimeLocation, JavaScriptRuntimeLocation,
JavaScriptRuntimeTargetLocation, JavaScriptRuntimeTargetLocation,
} from "../domain/javascript/javascriptRuntimeObservation.js"; } from "../domain/javascript/javascriptRuntimeObservation.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { authorizedElectronFile } from "../browser/ElectronFileScope.js"; import { authorizedElectronFile } from "../browser/ElectronFileScope.js";
export type RuntimeLocationDecision = export type RuntimeLocationDecision =
@@ -18,6 +19,14 @@ export type RuntimeLocationDecision =
readonly reason: BrowserExclusionReason; 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 * Wire bucket for inspector script exclusions. The V8 observation schemas
* fix the `unsupported_location` key, so every canonical denial reason maps * 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. */ /** Resolve a protocol location exposed by the explicitly selected Inspector endpoint. */
export const authorizeRuntimeLocation = async ( export const authorizeRuntimeLocation = async (
value: string, value: string,
signal?: AbortSignal,
): Promise<RuntimeLocationDecision> => { ): Promise<RuntimeLocationDecision> => {
throwIfRuntimeObservationCancelled(signal);
if (value.startsWith("node:") && value.length > 0) if (value.startsWith("node:") && value.length > 0)
return { return {
allowed: true, allowed: true,
@@ -43,6 +54,7 @@ export const authorizeRuntimeLocation = async (
}; };
if (value.startsWith("file:")) { if (value.startsWith("file:")) {
const filePath = await authorizedElectronFile(value); const filePath = await authorizedElectronFile(value);
throwIfRuntimeObservationCancelled(signal);
return filePath === undefined return filePath === undefined
? { allowed: false, reason: "not_approved" } ? { allowed: false, reason: "not_approved" }
: { allowed: true, location: { kind: "file", file_path: filePath } }; : { 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 { afterAll, beforeAll, expect, it } from "vitest";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { finalizeInspectorCapture } from "./V8InspectorCaptureProjection.js"; import { finalizeInspectorCapture } from "./V8InspectorCaptureProjection.js";
import type { CaptureState } from "./V8InspectorProvider.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(new Set(startedUrls)).toEqual(initialStarted);
expect(active).toBe(0); 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 { import {
authorizeRuntimeLocation, authorizeRuntimeLocation,
inspectorExclusionKey, inspectorExclusionKey,
throwIfRuntimeObservationCancelled,
} from "./JavaScriptRuntimeScope.js"; } from "./JavaScriptRuntimeScope.js";
import type { CaptureState, ScriptDraft } from "./V8InspectorProvider.js"; import type { CaptureState, ScriptDraft } from "./V8InspectorProvider.js";
import type { AuthorizedV8InspectorTarget } from "./V8InspectorEndpoint.js"; import type { AuthorizedV8InspectorTarget } from "./V8InspectorEndpoint.js";
@@ -38,7 +39,11 @@ interface FinalizeCaptureInput {
readonly runtime: JavaScriptRuntimeTargetList["runtime"]; readonly runtime: JavaScriptRuntimeTargetList["runtime"];
readonly target: AuthorizedV8InspectorTarget; readonly target: AuthorizedV8InspectorTarget;
readonly state: CaptureState; readonly state: CaptureState;
readonly authorizeLocation?: typeof authorizeRuntimeLocation; readonly authorizeLocation?: (
rawUrl: string,
signal?: AbortSignal,
) => ReturnType<typeof authorizeRuntimeLocation>;
readonly signal?: AbortSignal;
} }
const LOCATION_AUTHORIZATION_WORKERS = 8; const LOCATION_AUTHORIZATION_WORKERS = 8;
@@ -55,7 +60,9 @@ export const finalizeInspectorCapture = async ({
target, target,
state, state,
authorizeLocation = authorizeRuntimeLocation, authorizeLocation = authorizeRuntimeLocation,
signal,
}: FinalizeCaptureInput): Promise<JavaScriptRuntimeObservation> => { }: FinalizeCaptureInput): Promise<JavaScriptRuntimeObservation> => {
throwIfRuntimeObservationCancelled(signal);
const exclusions = createInspectorExclusionCounts(); const exclusions = createInspectorExclusionCounts();
const scripts = new Map< const scripts = new Map<
string, string,
@@ -74,6 +81,7 @@ export const finalizeInspectorCapture = async ({
const worker = async (): Promise<void> => { const worker = async (): Promise<void> => {
try { try {
for (;;) { for (;;) {
throwIfRuntimeObservationCancelled(signal);
if (stopScheduling) return; if (stopScheduling) return;
const index = nextGroup; const index = nextGroup;
nextGroup += 1; nextGroup += 1;
@@ -82,8 +90,9 @@ export const finalizeInspectorCapture = async ({
const [rawUrl, drafts] = group; const [rawUrl, drafts] = group;
authorizedGroups[index] = { authorizedGroups[index] = {
drafts, drafts,
decision: await authorizeLocation(rawUrl), decision: await authorizeLocation(rawUrl, signal),
}; };
throwIfRuntimeObservationCancelled(signal);
} }
} catch (cause: unknown) { } catch (cause: unknown) {
stopScheduling = true; stopScheduling = true;
@@ -99,6 +108,7 @@ export const finalizeInspectorCapture = async ({
const failedWorker = workerResults.find( const failedWorker = workerResults.find(
(result) => result.status === "rejected", (result) => result.status === "rejected",
); );
throwIfRuntimeObservationCancelled(signal);
if (failedWorker?.status === "rejected") throw failedWorker.reason; if (failedWorker?.status === "rejected") throw failedWorker.reason;
for (const { drafts, decision } of authorizedGroups) { for (const { drafts, decision } of authorizedGroups) {
for (const draft of drafts) { for (const draft of drafts) {
@@ -131,6 +141,7 @@ export const finalizeInspectorCapture = async ({
? 1 ? 1
: 0, : 0,
); );
throwIfRuntimeObservationCancelled(signal);
return { return {
runtime, runtime,
target: { target: {
+53 -18
View File
@@ -42,6 +42,12 @@ import {
export { V8_INSPECTOR_PROVIDER_IDENTITY } from "./providerIdentity.js"; export { V8_INSPECTOR_PROVIDER_IDENTITY } from "./providerIdentity.js";
import { 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 { export interface ScriptDraft {
readonly rawUrl: string; readonly rawUrl: string;
readonly executionContextKey: string | null; readonly executionContextKey: string | null;
@@ -147,6 +153,7 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
target.webSocketUrl, target.webSocketUrl,
"observe_javascript_runtime", "observe_javascript_runtime",
options.signal, options.signal,
{ maxPayloadBytes: INSPECTOR_MAX_PAYLOAD_BYTES },
); );
const state = emptyCaptureState(); const state = emptyCaptureState();
const removeListener = connection.onEvent((event) => const removeListener = connection.onEvent((event) =>
@@ -164,6 +171,7 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort {
runtime: discovery.runtime, runtime: discovery.runtime,
target, target,
state, state,
...(options.signal === undefined ? {} : { signal: options.signal }),
}); });
outcome = ok(javascriptRuntimeObservationSchema.parse(result)); outcome = ok(javascriptRuntimeObservationSchema.parse(result));
} catch (cause: unknown) { } catch (cause: unknown) {
@@ -344,8 +352,8 @@ const ingestScript = (
length: nonnegativeInteger(value?.length), length: nonnegativeInteger(value?.length),
isModule: value?.isModule === true, isModule: value?.isModule === true,
}; };
const bytes = metadataBytes(draft); const bytes = retainedMetadataBytes(draft);
retainEvent(state, bytes); if (!retainEvent(state, bytes)) return;
state.scripts.push(draft); state.scripts.push(draft);
}; };
@@ -355,8 +363,16 @@ const ingestContext = (
state: CaptureState, state: CaptureState,
): void => { ): void => {
if (event.method === "Runtime.executionContextsCleared") { if (event.method === "Runtime.executionContextsCleared") {
for (const context of state.contexts.values()) context.state = "cleared"; let byteDelta = 0;
retainEvent(state, 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; return;
} }
const parameters = recordValue(event.params); const parameters = recordValue(event.params);
@@ -385,14 +401,32 @@ const ingestContext = (
: "destroyed", : "destroyed",
origin: origin === "" ? null : origin, origin: origin === "" ? null : origin,
}; };
const bytes = metadataBytes(draft); const previous = state.contexts.get(key);
retainEvent(state, bytes); const bytes =
retainedMetadataBytes(draft) -
(previous === undefined ? 0 : retainedMetadataBytes(previous));
if (!retainEvent(state, bytes)) return;
state.contexts.set(key, draft); state.contexts.set(key, draft);
}; };
const retainEvent = (state: CaptureState, bytes: number): void => { const retainEvent = (state: CaptureState, bytes: number): boolean => {
state.metadataBytes += bytes; if (!replaceRetainedMetadata(state, bytes)) return false;
state.eventsRetained += 1; 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 ( const waitForCapture = async (
@@ -402,14 +436,7 @@ const waitForCapture = async (
): Promise<void> => { ): Promise<void> => {
let removeDisconnect = (): void => undefined; let removeDisconnect = (): void => undefined;
const disconnected = new Promise<never>((_resolve, reject) => { const disconnected = new Promise<never>((_resolve, reject) => {
removeDisconnect = connection.onDisconnect(() => removeDisconnect = connection.onDisconnect((error) => reject(error));
reject(
new BrowserObservationError(
"observe_javascript_runtime",
"disconnected",
),
),
);
}); });
try { try {
await Promise.race([ await Promise.race([
@@ -425,8 +452,16 @@ const waitForCapture = async (
} }
}; };
const metadataBytes = (value: object): number => const retainedMetadataBytes = (value: ScriptDraft | ContextDraft): number => {
Buffer.byteLength(JSON.stringify(value)); 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 nonnegativeInteger = (value: unknown): number => {
const parsed = numberValue(value); const parsed = numberValue(value);
@@ -175,9 +175,10 @@ describe("complete Inspector script hashes", () => {
scriptHashes, scriptHashes,
}); });
try { try {
const result = await new V8InspectorProvider().observe( const result = await new V8InspectorProvider().observe({
observeInput(fake.endpoint, fake.targetId, "node"), ...observeInput(fake.endpoint, fake.targetId, "node"),
); observation_ms: 1_000,
});
if (!result.ok) throw result.error; if (!result.ok) throw result.error;
expect(result.value.scripts.items).toHaveLength(scriptHashes.length); expect(result.value.scripts.items).toHaveLength(scriptHashes.length);
expect( 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 () => { test("retains authorized script locations longer than the former byte ceiling", async () => {
const longUrl = `https://example.test/${"a".repeat(20_000)}`; const longUrl = `https://example.test/${"a".repeat(20_000)}`;
const fake = await startFakeV8Inspector({ const fake = await startFakeV8Inspector({
@@ -261,7 +262,69 @@ describe("passive V8 Inspector evidence", () => {
await fake.close(); 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 () => { test("produces deterministic Evidence through the service without grants", async () => {
const fixture = await runtimeFixture(); const fixture = await runtimeFixture();
const fake = await startFakeV8Inspector({ const fake = await startFakeV8Inspector({
+80 -41
View File
@@ -14,6 +14,8 @@ interface FakeV8InspectorOptions {
readonly runtimeProduct?: string; readonly runtimeProduct?: string;
readonly scriptUrls?: readonly string[]; readonly scriptUrls?: readonly string[];
readonly scriptHashes?: readonly string[]; readonly scriptHashes?: readonly string[];
readonly oversizedEventBytes?: number;
readonly contextTransitionCount?: number;
readonly additionalTargetUrl?: string; readonly additionalTargetUrl?: string;
readonly additionalTargetCount?: number; readonly additionalTargetCount?: number;
readonly closeOnMethod?: string; readonly closeOnMethod?: string;
@@ -94,47 +96,7 @@ export const startFakeV8Inspector = async (
); );
}); });
webSockets.on("connection", (socket: WebSocket) => { webSockets.on("connection", (socket: WebSocket) => {
sockets.add(socket); registerInspectorSocket(socket, options, commands, sockets);
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,
},
}),
);
});
}); });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
http.once("error", 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 = ( const target = (
id: string, id: string,
url: string, url: string,