diff --git a/docs/javascript-runtime-observation.md b/docs/javascript-runtime-observation.md index 6553023af..ecfed708f 100644 --- a/docs/javascript-runtime-observation.md +++ b/docs/javascript-runtime-observation.md @@ -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 diff --git a/src/application/javascript/JavaScriptRuntimeObservationService.ts b/src/application/javascript/JavaScriptRuntimeObservationService.ts index cfbdbb05e..655d16f2d 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationService.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationService.ts @@ -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( diff --git a/src/browser/CdpConnection.ts b/src/browser/CdpConnection.ts index fae6046ed..3ab7f9476 100644 --- a/src/browser/CdpConnection.ts +++ b/src/browser/CdpConnection.ts @@ -25,7 +25,9 @@ interface PendingCommand { export class CdpConnection { readonly #pending = new Map(); 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(); } diff --git a/src/inspector/JavaScriptRuntimeScope.ts b/src/inspector/JavaScriptRuntimeScope.ts index 4396d7fe5..47e501f0c 100644 --- a/src/inspector/JavaScriptRuntimeScope.ts +++ b/src/inspector/JavaScriptRuntimeScope.ts @@ -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 => { + 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 } }; diff --git a/src/inspector/V8InspectorCaptureProjection.test.ts b/src/inspector/V8InspectorCaptureProjection.test.ts index 124745e67..4a6eb6922 100644 --- a/src/inspector/V8InspectorCaptureProjection.test.ts +++ b/src/inspector/V8InspectorCaptureProjection.test.ts @@ -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); +}); diff --git a/src/inspector/V8InspectorCaptureProjection.ts b/src/inspector/V8InspectorCaptureProjection.ts index 102247afb..9c8d8ab70 100644 --- a/src/inspector/V8InspectorCaptureProjection.ts +++ b/src/inspector/V8InspectorCaptureProjection.ts @@ -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; + readonly signal?: AbortSignal; } const LOCATION_AUTHORIZATION_WORKERS = 8; @@ -55,7 +60,9 @@ export const finalizeInspectorCapture = async ({ target, state, authorizeLocation = authorizeRuntimeLocation, + signal, }: FinalizeCaptureInput): Promise => { + throwIfRuntimeObservationCancelled(signal); const exclusions = createInspectorExclusionCounts(); const scripts = new Map< string, @@ -74,6 +81,7 @@ export const finalizeInspectorCapture = async ({ const worker = async (): Promise => { 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: { diff --git a/src/inspector/V8InspectorProvider.ts b/src/inspector/V8InspectorProvider.ts index 01189ec79..6dc9f7ffc 100644 --- a/src/inspector/V8InspectorProvider.ts +++ b/src/inspector/V8InspectorProvider.ts @@ -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 => { let removeDisconnect = (): void => undefined; const disconnected = new Promise((_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); diff --git a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts index 314a12682..bb4169956 100644 --- a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts +++ b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts @@ -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({ diff --git a/tests/fixtures/inspector/fakeV8Inspector.ts b/tests/fixtures/inspector/fakeV8Inspector.ts index 13445313f..ac698d2da 100644 --- a/tests/fixtures/inspector/fakeV8Inspector.ts +++ b/tests/fixtures/inspector/fakeV8Inspector.ts @@ -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((resolve, reject) => { http.once("error", reject); @@ -160,6 +122,83 @@ export const startFakeV8Inspector = async ( }; }; +const registerInspectorSocket = ( + socket: WebSocket, + options: FakeV8InspectorOptions, + commands: InspectorCommand[], + sockets: Set, +): 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,