diff --git a/docs/guides/javascript/javascript-runtime-observation.md b/docs/guides/javascript/javascript-runtime-observation.md index e07e3852b1..fa4a4bd614 100644 --- a/docs/guides/javascript/javascript-runtime-observation.md +++ b/docs/guides/javascript/javascript-runtime-observation.md @@ -114,7 +114,8 @@ caller cancellation still reports cancellation. The direct observations are: - `Debugger.scriptParsed`, establishing that a bounded script location was - parsed in the observed execution context; + parsed with the reported execution-context ID; association with a particular + context lifetime is known only when its creation was observed; - `Debugger.scriptFailedToParse`, retained only as a bounded invalid-script count in `scripts.parse_failed_total`. A well-formed parse-failure event is distinct from malformed protocol data and does not make loaded @@ -163,8 +164,13 @@ 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 +`retained_metadata_budget_exceeded`. Context records retain each observed lifetime, and scripts refer to the context +lifetime active when parsed. The reported numeric execution-context ID is +retained separately on both records; a script with no observed context creation +has an unknown context key. Repeated transitions within one lifetime update its +record; the state records the last observed lifecycle event and does not prove +that the context is still live. This changes context keys and Evidence identities from older captures; +recapture observations to obtain lifetime associations. 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 diff --git a/src/domain/javascript/javascriptRuntimeObservation.ts b/src/domain/javascript/javascriptRuntimeObservation.ts index 749d158495..603ad6fe4c 100644 --- a/src/domain/javascript/javascriptRuntimeObservation.ts +++ b/src/domain/javascript/javascriptRuntimeObservation.ts @@ -121,7 +121,18 @@ const javascriptRuntimeScriptSchema = z.strictObject({ .describe( "Exact Inspector URL for an authorized file script, retained separately from its verified filesystem path.", ), - execution_context_key: z.string().nullable(), + execution_context_key: z + .string() + .nullable() + .describe( + "Capture-local observed context lifetime associated with this script; null when no context creation was observed.", + ), + execution_context_id: z + .int() + .nullable() + .describe( + "Numeric execution-context ID reported for the script, independent of whether its lifetime was observed.", + ), cdp_hash: z.string().nullable(), length: z .int() @@ -138,7 +149,17 @@ const javascriptRuntimeScriptSchema = z.strictObject({ }); const javascriptRuntimeContextSchema = z.strictObject({ - context_key: z.string().min(1), + context_key: z + .string() + .min(1) + .describe( + "Capture-local context lifetime key used to associate scripts with this observed context.", + ), + execution_context_id: z + .int() + .describe( + "Numeric execution-context ID reported by the protocol; IDs can be reused across lifetimes.", + ), state: z.enum(["created", "destroyed", "cleared"]), name: z.string().nullable(), origin: z.string().nullable(), diff --git a/src/inspector/V8InspectorCaptureProjection.test.ts b/src/inspector/V8InspectorCaptureProjection.test.ts index 8b88d6565f..1e5f027531 100644 --- a/src/inspector/V8InspectorCaptureProjection.test.ts +++ b/src/inspector/V8InspectorCaptureProjection.test.ts @@ -28,6 +28,7 @@ const target = { const stateFor = (scripts: CaptureState["scripts"]): CaptureState => ({ scripts, contexts: new Map(), + latestContexts: new Map(), eventsObserved: scripts.length, eventsRetained: scripts.length, eventsDropped: 0, @@ -75,6 +76,7 @@ it("authorizes distinct Inspector locations and preserves stable deduplication", { rawUrl: firstUrl, executionContextKey: "1", + executionContextId: 1, cdpHash: "first", length: 7, isModule: false, @@ -82,6 +84,7 @@ it("authorizes distinct Inspector locations and preserves stable deduplication", { rawUrl: firstUrl, executionContextKey: "1", + executionContextId: 1, cdpHash: "first", length: 7, isModule: false, @@ -89,6 +92,7 @@ it("authorizes distinct Inspector locations and preserves stable deduplication", { rawUrl: secondUrl, executionContextKey: "1", + executionContextId: 1, cdpHash: "second", length: 7, isModule: false, @@ -96,6 +100,7 @@ it("authorizes distinct Inspector locations and preserves stable deduplication", { rawUrl: "ftp://example.test/unapproved.js", executionContextKey: null, + executionContextId: null, cdpHash: null, length: 0, isModule: false, @@ -127,6 +132,7 @@ it("authorizes each unique script location through a bounded worker pool", async const drafts = Array.from({ length: 200 }, (_, index) => ({ rawUrl: `file:///fixture/script-${String(index)}.js`, executionContextKey: "1", + executionContextId: 1, cdpHash: `hash-${String(index)}`, length: 7, isModule: false, @@ -137,6 +143,7 @@ it("authorizes each unique script location through a bounded worker pool", async { rawUrl: "ftp://example.test/unapproved.js", executionContextKey: null, + executionContextId: null, cdpHash: null, length: 0, isModule: false, @@ -174,6 +181,7 @@ it("preserves raw script facts without authorizing locations in partial projecti { rawUrl: "file:///slow-or-unavailable.js?token=retained", executionContextKey: "1", + executionContextId: 1, cdpHash: "observed", length: 7, isModule: false, @@ -181,6 +189,7 @@ it("preserves raw script facts without authorizing locations in partial projecti { rawUrl: "https://example.test/script.js?caller-value=kept", executionContextKey: "1", + executionContextId: 1, cdpHash: "remote", length: 12, isModule: true, @@ -188,6 +197,7 @@ it("preserves raw script facts without authorizing locations in partial projecti { rawUrl: "", executionContextKey: "1", + executionContextId: 1, cdpHash: "anonymous", length: 0, isModule: false, @@ -197,6 +207,7 @@ it("preserves raw script facts without authorizing locations in partial projecti state.eventsRetained += 1; state.contexts.set("1", { contextKey: "1", + executionContextId: 1, state: "created", name: "main", origin: "file:///slow-or-unavailable.js?token=retained", @@ -261,6 +272,7 @@ it("preserves raw script facts without authorizing locations in partial projecti expect(result.execution_contexts).toEqual([ { context_key: "1", + execution_context_id: 1, state: "created", name: "main", origin: "file:///slow-or-unavailable.js?token=retained", @@ -277,6 +289,7 @@ it("counts retained unresolved script locations after deduplication in partial c const draft = { rawUrl: "file:///fixture/duplicate.js", executionContextKey: "1", + executionContextId: 1, cdpHash: "same", length: 7, isModule: false, @@ -305,6 +318,7 @@ it("waits for active authorization workers to settle before returning a failure" const scripts = Array.from({ length: 12 }, (_, index) => ({ rawUrl: `file:///fixture/failure-${String(index)}.js`, executionContextKey: null, + executionContextId: null, cdpHash: null, length: 0, isModule: false, @@ -365,6 +379,7 @@ it("stops finalization when cancellation arrives during location authorization", { rawUrl: "https://example.test/script.js", executionContextKey: null, + executionContextId: null, cdpHash: null, length: 0, isModule: false, diff --git a/src/inspector/V8InspectorCaptureProjection.ts b/src/inspector/V8InspectorCaptureProjection.ts index 916477de96..979ec7ffd1 100644 --- a/src/inspector/V8InspectorCaptureProjection.ts +++ b/src/inspector/V8InspectorCaptureProjection.ts @@ -158,6 +158,7 @@ export const finalizeInspectorCapture = async ({ const contexts = [...state.contexts.values()] .map((context) => ({ context_key: context.contextKey, + execution_context_id: context.executionContextId, state: context.state, name: context.name, origin: context.origin, @@ -251,6 +252,7 @@ const scriptFromDraft = ( location, ...(location.kind === "file" ? { reported_url: draft.rawUrl } : {}), execution_context_key: draft.executionContextKey, + execution_context_id: draft.executionContextId, cdp_hash: draft.cdpHash, length: draft.length, is_module: draft.isModule, @@ -260,6 +262,7 @@ const scriptFromDraft = ( location, ...(location.kind === "file" ? { reported_url: draft.rawUrl } : {}), execution_context_key: draft.executionContextKey, + execution_context_id: draft.executionContextId, cdp_hash: draft.cdpHash, length: draft.length, is_module: draft.isModule, diff --git a/src/inspector/V8InspectorProvider.partial.test.ts b/src/inspector/V8InspectorProvider.partial.test.ts index 71cd0b6573..fee7a4ade1 100644 --- a/src/inspector/V8InspectorProvider.partial.test.ts +++ b/src/inspector/V8InspectorProvider.partial.test.ts @@ -211,7 +211,12 @@ it.each(failureCases)( ], }, execution_contexts: [ - { context_key: "1", name: "main", origin: reportedScriptUrl }, + { + context_key: "context_0", + execution_context_id: 1, + name: "main", + origin: reportedScriptUrl, + }, ], }); } finally { diff --git a/src/inspector/V8InspectorProvider.ts b/src/inspector/V8InspectorProvider.ts index 7571713151..faa1c19a7a 100644 --- a/src/inspector/V8InspectorProvider.ts +++ b/src/inspector/V8InspectorProvider.ts @@ -156,6 +156,7 @@ const RETAINED_OBJECT_OVERHEAD_BYTES = 512; export interface ScriptDraft { readonly rawUrl: string; readonly executionContextKey: string | null; + readonly executionContextId: number | null; readonly cdpHash: string | null; readonly length: number | null; readonly isModule: boolean | null; @@ -163,6 +164,7 @@ export interface ScriptDraft { export interface ContextDraft { readonly contextKey: string; + readonly executionContextId: number; state: "created" | "destroyed" | "cleared"; readonly name: string | null; readonly origin: string | null; @@ -171,6 +173,7 @@ export interface ContextDraft { export interface CaptureState { readonly scripts: ScriptDraft[]; readonly contexts: Map; + readonly latestContexts: Map; eventsObserved: number; eventsRetained: number; eventsDropped: number; @@ -621,6 +624,7 @@ const assertRuntimeKind = ( const emptyCaptureState = (): CaptureState => ({ scripts: [], contexts: new Map(), + latestContexts: new Map(), eventsObserved: 0, eventsRetained: 0, eventsDropped: 0, @@ -671,9 +675,16 @@ const ingestScript = (event: CdpEvent, state: CaptureState): void => { return; } const { metadata } = facts; + const context = + facts.contextKey === null + ? undefined + : state.latestContexts.get(facts.contextKey); const draft: ScriptDraft = { rawUrl, - executionContextKey: facts.contextKey, + executionContextKey: + context?.state === "created" ? context.contextKey : null, + executionContextId: + facts.contextKey === null ? null : (facts.contextId ?? null), cdpHash: metadata.hash, length: metadata.length, isModule: metadata.isModule, @@ -685,49 +696,45 @@ const ingestScript = (event: CdpEvent, state: CaptureState): void => { const ingestContext = (event: CdpEvent, state: CaptureState): void => { if (event.method === "Runtime.executionContextsCleared") { - 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); + for (const context of state.latestContexts.values()) { + if (context.state === "created") context.state = "cleared"; } + state.latestContexts.clear(); + retainEvent(state, 0); return; } const created = event.method === "Runtime.executionContextCreated"; const createdContext = created ? readCdpExecutionContextCreated(event.params) : undefined; - const key = - createdContext === undefined - ? readCdpExecutionContextDestroyed(event.params).contextKey - : createdContext.contextKey; - if (key === null) { + const identity = + createdContext ?? readCdpExecutionContextDestroyed(event.params); + const key = identity.contextKey; + if (key === null || identity.id === undefined) { state.invalidContexts += 1; retainEvent(state, 0); return; } - const previous = state.contexts.get(key); + const previous = state.latestContexts.get(key); + // A lifecycle transition invalidates the old binding even if the new + // record cannot be retained within the metadata budget. + state.latestContexts.delete(key); + if (!created && previous !== undefined) { + previous.state = "destroyed"; + state.latestContexts.set(key, previous); + retainEvent(state, 0); + return; + } const draft: ContextDraft = { - contextKey: key, + contextKey: `context_${String(state.contexts.size)}`, + executionContextId: identity.id, state: created ? "created" : "destroyed", - name: - createdContext === undefined - ? (previous?.name ?? null) - : (createdContext.name ?? null), - origin: - createdContext === undefined - ? (previous?.origin ?? null) - : (createdContext.origin ?? null), + name: createdContext?.name ?? null, + origin: createdContext?.origin ?? null, }; - const bytes = - retainedMetadataBytes(draft) - - (previous === undefined ? 0 : retainedMetadataBytes(previous)); - if (!retainEvent(state, bytes)) return; - state.contexts.set(key, draft); + if (!retainEvent(state, retainedMetadataBytes(draft))) return; + state.contexts.set(draft.contextKey, draft); + state.latestContexts.set(key, draft); }; const retainEvent = (state: CaptureState, bytes: number): boolean => { diff --git a/tests/boundary/inspector/javascriptRuntimeContextReuse.test.ts b/tests/boundary/inspector/javascriptRuntimeContextReuse.test.ts new file mode 100644 index 0000000000..5f050c310a --- /dev/null +++ b/tests/boundary/inspector/javascriptRuntimeContextReuse.test.ts @@ -0,0 +1,172 @@ +import { expect, test } from "vitest"; + +import { V8InspectorProvider } from "../../../src/inspector/V8InspectorProvider.js"; +import { startFakeV8Inspector } from "../../fixtures/inspector/fakeV8Inspector.js"; + +test("retains each script's context when a page reuses a context ID", async () => { + const oldOrigin = "http://127.0.0.1:8080"; + const newOrigin = "http://localhost:8080"; + const scriptUrl = `${oldOrigin}/shared.js`; + const script = { + method: "Debugger.scriptParsed", + params: { + scriptId: "3", + url: scriptUrl, + executionContextId: 1, + hash: "same-source", + length: 24, + isModule: false, + }, + }; + const fake = await startFakeV8Inspector({ + targetUrl: `${oldOrigin}/page`, + targetType: "page", + runtimeProduct: "Electron/fixture", + runtimeEvents: [ + { + method: "Runtime.executionContextCreated", + params: { + context: { id: 1, uniqueId: "old", origin: oldOrigin, name: "" }, + }, + }, + ], + scriptEvents: [ + script, + { method: "Runtime.executionContextsCleared", params: {} }, + { + method: "Runtime.executionContextCreated", + params: { + context: { id: 1, uniqueId: "new", origin: newOrigin, name: "" }, + }, + }, + script, + ], + }); + const provider = new V8InspectorProvider(); + try { + const result = await provider.observe({ + inspector_endpoint: fake.endpoint, + target_id: fake.targetId, + runtime_kind: "electron-renderer", + observation_ms: 0, + }); + if (!result.ok) throw result.error; + expect(result.value.scripts.observed_total).toBe(2); + expect(result.value.scripts.items).toHaveLength(2); + const scriptsByContext = new Map( + result.value.scripts.items.map((item) => [ + item.execution_context_key, + item, + ]), + ); + expect(scriptsByContext.size).toBe(2); + expect(result.value.execution_contexts).toHaveLength(2); + for (const [origin, state] of [ + [oldOrigin, "cleared"], + [newOrigin, "created"], + ] as const) { + const context = result.value.execution_contexts.find( + (item) => item.origin === origin, + ); + expect(context).toMatchObject({ origin, state }); + expect(scriptsByContext.has(context?.context_key ?? null)).toBe(true); + } + } finally { + await provider.close(); + await fake.close(); + } +}); + +test("retains a script's reported context ID when its context was not observed", async () => { + const fake = await startFakeV8Inspector({ + targetUrl: "http://127.0.0.1:8080/page", + targetType: "page", + runtimeProduct: "Electron/fixture", + runtimeEvents: [], + scriptEvents: [ + { + method: "Debugger.scriptParsed", + params: { + scriptId: "3", + url: "http://127.0.0.1:8080/shared.js", + executionContextId: 17, + hash: "source", + }, + }, + ], + }); + const provider = new V8InspectorProvider(); + try { + const result = await provider.observe({ + inspector_endpoint: fake.endpoint, + target_id: fake.targetId, + runtime_kind: "electron-renderer", + observation_ms: 0, + }); + if (!result.ok) throw result.error; + expect(result.value.scripts.items).toEqual([ + expect.objectContaining({ + execution_context_id: 17, + execution_context_key: null, + }), + ]); + expect(result.value.execution_contexts).toEqual([]); + } finally { + await provider.close(); + await fake.close(); + } +}); + +test("does not attribute a script to a superseded context when replacement metadata exceeds the budget", async () => { + const fake = await startFakeV8Inspector({ + targetUrl: "http://127.0.0.1:8080/page", + targetType: "page", + runtimeProduct: "Electron/fixture", + runtimeEvents: [ + { + method: "Runtime.executionContextCreated", + params: { context: { id: 1, origin: "http://old.test", name: "" } }, + }, + ], + scriptEvents: [ + { + method: "Runtime.executionContextCreated", + params: { + context: { id: 1, origin: "x".repeat(4 * 1024 * 1024), name: "" }, + }, + }, + { + method: "Debugger.scriptParsed", + params: { + scriptId: "3", + url: "http://127.0.0.1:8080/shared.js", + executionContextId: 1, + hash: "source", + }, + }, + ], + }); + const provider = new V8InspectorProvider(); + try { + const result = await provider.observe({ + inspector_endpoint: fake.endpoint, + target_id: fake.targetId, + runtime_kind: "electron-renderer", + observation_ms: 0, + }); + if (!result.ok) throw result.error; + expect(result.value.capture).toMatchObject({ + truncated: true, + events_dropped: 1, + }); + expect(result.value.scripts.items).toEqual([ + expect.objectContaining({ + execution_context_id: 1, + execution_context_key: null, + }), + ]); + } finally { + await provider.close(); + await fake.close(); + } +}); diff --git a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts index c2aa0a5913..988e1e9ef5 100644 --- a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts +++ b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts @@ -156,7 +156,8 @@ describe("Inspector execution-context lifecycle metadata", () => { if (!result.ok) throw result.error; expect(result.value.execution_contexts).toEqual([ { - context_key: "1", + context_key: "context_0", + execution_context_id: 1, state, name: "worker#one", origin: "https://context.example", @@ -168,7 +169,7 @@ describe("Inspector execution-context lifecycle metadata", () => { }, ); - test("replaces context metadata only when a new context creation is observed", async () => { + test("retains each context lifetime after an ID is reused", async () => { const fixture = await runtimeFixture(); const fake = await startFakeV8Inspector({ targetUrl: pathToFileURL(fixture.entry).href, @@ -203,14 +204,35 @@ describe("Inspector execution-context lifecycle metadata", () => { ); if (!result.ok) throw result.error; expect(result.value.execution_contexts).toEqual([ - { context_key: "1", state: "created", name: "", origin: "" }, - { context_key: "2", state: "destroyed", name: null, origin: null }, + { + context_key: "context_0", + execution_context_id: 1, + state: "destroyed", + name: "old context", + origin: "https://old.example", + }, + { + context_key: "context_1", + execution_context_id: 1, + state: "created", + name: "", + origin: "", + }, + { + context_key: "context_2", + execution_context_id: 2, + state: "destroyed", + name: null, + origin: null, + }, ]); } finally { await fake.close(); } }); +}); +describe("Inspector execution-context ID validation", () => { test.each([ ["malformed", "2", "1"], ["valid", 2, 1], @@ -252,7 +274,13 @@ describe("Inspector execution-context lifecycle metadata", () => { }); if (mode === "malformed") { expect(result.value.execution_contexts).toEqual([ - { context_key: "1", state: "created", name: "main", origin: "" }, + { + context_key: "context_0", + execution_context_id: 1, + state: "created", + name: "main", + origin: "", + }, ]); expect(invalidContextUnknowns).toEqual([ "2 execution-context lifecycle events with a missing or malformed execution-context ID were excluded; the affected contexts' lifecycle state is unknown.", @@ -260,8 +288,20 @@ describe("Inspector execution-context lifecycle metadata", () => { return; } expect(result.value.execution_contexts).toEqual([ - { context_key: "1", state: "destroyed", name: "main", origin: "" }, - { context_key: "2", state: "created", name: "worker", origin: "" }, + { + context_key: "context_0", + execution_context_id: 1, + state: "destroyed", + name: "main", + origin: "", + }, + { + context_key: "context_1", + execution_context_id: 2, + state: "created", + name: "worker", + origin: "", + }, ]); expect(invalidContextUnknowns).toEqual([]); } finally { @@ -402,7 +442,8 @@ test("captures complete metadata with two enable commands", async () => { expect(result.value.scripts.excluded.unsupported_location).toBe(0); expect(result.value.execution_contexts).toEqual([ { - context_key: "1", + context_key: "context_0", + execution_context_id: 1, state: "created", name: "node[fixture]", origin: "", @@ -975,6 +1016,7 @@ const runtimeObservation = ( script_key: `v8_script_${"4".repeat(64)}`, location: { kind: "file", file_path: scriptPath }, execution_context_key: "1", + execution_context_id: 1, cdp_hash: "v8-hash", length: 29, is_module: true, diff --git a/tests/fixtures/nodeRuntimeReconciliation.ts b/tests/fixtures/nodeRuntimeReconciliation.ts index cf6e75ba23..a85647f2ea 100644 --- a/tests/fixtures/nodeRuntimeReconciliation.ts +++ b/tests/fixtures/nodeRuntimeReconciliation.ts @@ -39,6 +39,7 @@ export const nodeReconciliationFixture = (scriptObserved: boolean) => { script_key: `v8_script_${"4".repeat(64)}`, location, execution_context_key: null, + execution_context_id: null, cdp_hash: null, length: null, is_module: null,