mirror of
https://github.com/morluto/rea.git
synced 2026-10-12 02:19:24 +00:00
fix(inspector): bound retained metadata and preserve cancellation
This commit is contained in:
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user