diff --git a/src/application/WebModulePorts.ts b/src/application/WebModulePorts.ts index 2f03efce0f..d799f1aff3 100644 --- a/src/application/WebModulePorts.ts +++ b/src/application/WebModulePorts.ts @@ -3,7 +3,8 @@ import type { JsonValue } from "../domain/jsonValue.js"; import type { Result } from "../domain/result.js"; import type { WebModuleFile, - WebModuleResolution, + WebModuleResolutionBatch, + WebModuleResolutionRequest, WebModuleTraceInput, } from "../domain/webModuleTrace.js"; import type { SelectedWebScriptArtifacts } from "../domain/webScriptArtifacts.js"; @@ -29,26 +30,11 @@ export interface WebModuleArtifactPort { ): Promise>; } -/** Resolve literal specifiers in a selected URL/map context without executing the selected application. */ -export interface WebModuleResolutionBatch { - readonly engine: { readonly id: string; readonly version: string }; - readonly resolutions: readonly WebModuleResolution[]; - readonly diagnostics: readonly string[]; - readonly rawResult: JsonObject; -} - /** Replaceable native resolution engine, with no provider protocol in callers. */ export interface WebModuleResolutionPort { close?(): Promise; resolve( - input: { - readonly importerUrl: string; - readonly importMap: { - readonly baseUrl: string; - readonly value: JsonObject; - } | null; - readonly specifiers: readonly string[]; - }, + input: WebModuleResolutionRequest, options?: ExecutionOptions, ): Promise>; } diff --git a/src/application/WebModuleTraceService.test.ts b/src/application/WebModuleTraceService.test.ts index e9bf0f6c80..73b44fd1f9 100644 --- a/src/application/WebModuleTraceService.test.ts +++ b/src/application/WebModuleTraceService.test.ts @@ -1,16 +1,23 @@ import { describe, expect, it } from "vitest"; import { WebModuleTraceService } from "./WebModuleTraceService.js"; -import { ok } from "../domain/result.js"; +import { err, ok } from "../domain/result.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { webModuleTraceResultSchema, type WebModuleResolution, + type WebModuleResolutionRequest, } from "../domain/webModuleTrace.js"; import { webModuleArtifactsFixture, webModuleResolverFixture, } from "../../tests/fixtures/webModuleTrace.js"; +import { + AnalysisCancelledError, + AnalysisOutputError, +} from "../domain/analysisErrorCore.js"; +import { parseEvidence } from "../domain/evidence.js"; + const args = { manifest_path: "/analysis/manifest.json", script_index: 0 }; it.each([ { ...args, importer_url: "file:///tmp/main.js" }, @@ -135,7 +142,7 @@ describe("selected module trace workflow", () => { }); it("honors cancellation before any artifacts are acquired", async () => { const controller = new AbortController(); - controller.abort(); + controller.abort(null); const service = new WebModuleTraceService( { load: () => { @@ -146,6 +153,219 @@ describe("selected module trace workflow", () => { ); const result = await service.trace(args, { signal: controller.signal }); expect(result.ok).toBe(false); - if (!result.ok) expect(result.error._tag).toBe("AnalysisCancelledError"); + if (!result.ok) { + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBeNull(); + } + }); +}); + +it.each([null, { request: "opaque cancellation" }])( + "retains completed module Evidence and the exact late abort reason: %j", + async (reason) => { + const controller = new AbortController(); + const artifacts = webModuleArtifactsFixture(); + const service = new WebModuleTraceService( + { load: async () => ok(artifacts) }, + { + resolve: async (input) => { + const result = await webModuleResolverFixture.resolve(input); + controller.abort(reason); + return result; + }, + }, + ); + const result = await service.trace(args, { signal: controller.signal }); + if (result.ok) throw new Error("expected cancelled completion"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "trace_web_module_imports", + confidence: "derived", + normalized_result: { + source: artifacts.sourceFile, + imports: [ + { + resolution: { state: "resolved", url: "https://app.test/dep.js" }, + execution: "unknown", + }, + { + resolution: { state: "unknown", reason: "computed-specifier" }, + execution: "unknown", + }, + ], + }, + raw_result: { fixture: true }, + }); + }, +); + +it("does not start the native resolver after cancellation during artifact admission", async () => { + const controller = new AbortController(); + const reason = { request: "cancelled local artifact read" }; + const result = await new WebModuleTraceService( + { + load: async () => { + controller.abort(reason); + return ok(webModuleArtifactsFixture()); + }, + }, + { + resolve: async () => { + throw new Error("must not start resolver"); + }, + }, + ).trace(args, { signal: controller.signal }); + if (result.ok) throw new Error("expected cancelled admission"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); +}); + +it.each(["incomplete", "provider-failure"])( + "keeps resolver %s ahead of late cancellation", + async (kind) => { + const controller = new AbortController(); + const primary = new AnalysisOutputError( + "trace_web_module_imports", + "controlled resolver failure", + ); + const result = await new WebModuleTraceService( + { load: async () => ok(webModuleArtifactsFixture()) }, + { + resolve: async () => { + controller.abort({ request: "late cancellation" }); + return kind === "provider-failure" + ? err(primary) + : ok({ + engine: { id: "fixture", version: "1" }, + resolutions: [], + diagnostics: [], + rawResult: {}, + }); + }, + }, + ).trace(args, { signal: controller.signal }); + if (result.ok) throw new Error("expected original failure"); + expect(result.error).toBeInstanceOf(AnalysisOutputError); + if (kind === "provider-failure") expect(result.error).toBe(primary); + expect(result.error.partialObservation).toBeUndefined(); + }, +); + +it.each([ + "matching", + "importer", + "map-base", + "map-value", + "specifiers", + "missing-resolution", +])( + "adopts only a complete resolution for the exact selected request: %s", + async (kind) => { + const artifacts = webModuleArtifactsFixture(); + const importMap = { + file: { path: "/analysis/map.json", sha256: "c".repeat(64), bytes: 2 }, + baseUrl: "https://app.test/maps/", + value: { imports: { dep: "./dep.js" } }, + }; + let failure: AnalysisCancelledError | undefined; + const result = await new WebModuleTraceService( + { load: async () => ok({ ...artifacts, importMap }) }, + { + resolve: async (request) => { + const resolved = await webModuleResolverFixture.resolve(request); + if (!resolved.ok) throw resolved.error; + let observed: WebModuleResolutionRequest = request; + if (kind === "importer") + observed = { ...request, importerUrl: "https://other.test/" }; + if (kind === "map-base") + observed = { + ...request, + importMap: { + baseUrl: "https://other.test/", + value: importMap.value, + }, + }; + if (kind === "map-value") + observed = { + ...request, + importMap: { + baseUrl: importMap.baseUrl, + value: { imports: { dep: "./other.js" } }, + }, + }; + if (kind === "specifiers") + observed = { ...request, specifiers: ["./other.js"] }; + failure = new AnalysisCancelledError("trace_web_module_imports", { + cause: null, + partialObservation: { + kind: "web-module-resolution", + request: observed, + batch: + kind === "missing-resolution" + ? { ...resolved.value, resolutions: [] } + : resolved.value, + }, + }); + return err(failure); + }, + }, + ).trace({ + ...args, + import_map: { path: importMap.file.path, base_url: importMap.baseUrl }, + }); + if (result.ok) throw new Error("expected original cancellation"); + expect(result.error).toBe(failure); + expect(result.error.cause).toBeNull(); + if (kind === "matching") { + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "trace_web_module_imports", + normalized_result: { + import_map: { + path: importMap.file.path, + base_url: importMap.baseUrl, + }, + imports: [ + { + resolution: { state: "resolved", url: "https://app.test/dep.js" }, + }, + { resolution: { state: "unknown", reason: "computed-specifier" } }, + ], + }, + }); + } else + expect(result.error.partialObservation).toHaveProperty( + "kind", + "web-module-resolution", + ); + }, +); + +it("retains a completed engine-free trace when artifact loading finishes under cancellation", async () => { + const controller = new AbortController(); + const reason = { request: "engine-free completion" }; + const result = await new WebModuleTraceService( + { + load: async () => { + controller.abort(reason); + return ok(webModuleArtifactsFixture("import(name)")); + }, + }, + { + resolve: async () => { + throw new Error("no literal imports to resolve"); + }, + }, + ).trace(args, { signal: controller.signal }); + if (result.ok) throw new Error("expected cancelled completion"); + expect(result.error.cause).toBe(reason); + expect(parseEvidence(result.error.partialObservation)).toMatchObject({ + operation: "trace_web_module_imports", + normalized_result: { + engine: null, + imports: [ + { resolution: { state: "unknown", reason: "computed-specifier" } }, + ], + }, }); }); diff --git a/src/application/WebModuleTraceService.ts b/src/application/WebModuleTraceService.ts index be0936e1c4..c421cbe3a6 100644 --- a/src/application/WebModuleTraceService.ts +++ b/src/application/WebModuleTraceService.ts @@ -1,4 +1,5 @@ import { closeOwnedWebResources } from "./closeOwnedWebResources.js"; +import { isDeepStrictEqual } from "node:util"; import { isAbsolute } from "node:path"; import { z } from "zod"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -19,7 +20,9 @@ import { import { webModuleTraceInputSchema, webModuleFileSchema, - webModuleResolutionSchema, + webModuleResolutionBatchSchema, + webModuleResolutionObservationSchema, + type WebModuleResolutionBatch, webModuleTraceResultSchema, type WebModuleResolution, type WebModuleTraceResult, @@ -33,9 +36,10 @@ import type { ExecutionOptions } from "./AnalysisProvider.js"; import type { WebModuleArtifactPort, WebModuleResolutionPort, - WebModuleResolutionBatch, } from "./WebModulePorts.js"; +import { completeProviderEvidence } from "./ProviderOperationExecution.js"; + const OPERATION = "trace_web_module_imports"; const artifactsSchema = selectedWebScriptArtifactsSchema.extend({ importMap: z @@ -46,12 +50,6 @@ const artifactsSchema = selectedWebScriptArtifactsSchema.extend({ }) .nullable(), }); -const resolutionBatchSchema = z.strictObject({ - engine: z.strictObject({ id: z.string().min(1), version: z.string().min(1) }), - resolutions: z.array(webModuleResolutionSchema), - diagnostics: z.array(z.string()), - rawResult: jsonObjectSchema, -}); const LIMITATIONS = [ "Resolution is derived under the selected importer URL and optional import map; it does not establish that the map was installed in the captured page.", "Reported source URLs do not establish an inline script's document base. Select importer_url explicitly when that context differs or is unknown.", @@ -84,8 +82,10 @@ export class WebModuleTraceService { rawInput: unknown, options?: ExecutionOptions, ): Promise> { - if (isAborted(options?.signal)) - return err(new AnalysisCancelledError(OPERATION)); + if (options?.signal?.aborted) + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); const parsed = webModuleTraceInputSchema.safeParse(rawInput); if (!parsed.success) return err( @@ -144,31 +144,51 @@ export class WebModuleTraceService { .filter((reference) => reference.specifier !== null) .map((reference) => reference.specifier ?? ""); let resolution: WebModuleResolutionBatch | null = null; + let resolutionFailure: AnalysisError | undefined; if (literals.length > 0) { - const execution = await this.resolver.resolve( - { - importerUrl, - importMap: - data.importMap === null - ? null - : { - baseUrl: data.importMap.baseUrl, - value: data.importMap.value, - }, - specifiers: literals, - }, - options, - ); - if (!execution.ok) return execution; - const report = resolutionBatchSchema.safeParse(execution.value); - if (!report.success) + if (options?.signal?.aborted) return err( - new AnalysisOutputError( - OPERATION, - "Resolver returned malformed or incomplete native evidence.", - ), + new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + }), ); - resolution = report.data; + const request = { + importerUrl, + importMap: + data.importMap === null + ? null + : { + baseUrl: data.importMap.baseUrl, + value: data.importMap.value, + }, + specifiers: literals, + }; + const execution = await this.resolver.resolve(request, options); + if (execution.ok) { + const report = webModuleResolutionBatchSchema.safeParse( + execution.value, + ); + if (!report.success) + return err( + new AnalysisOutputError( + OPERATION, + "Resolver returned malformed or incomplete native evidence.", + ), + ); + resolution = report.data; + } else { + const observation = webModuleResolutionObservationSchema.safeParse( + execution.error.partialObservation, + ); + if ( + !observation.success || + !isDeepStrictEqual(observation.data.request, request) || + observation.data.batch.resolutions.length !== literals.length + ) + return execution; + resolution = observation.data.batch; + resolutionFailure = execution.error; + } if (resolution.resolutions.length !== literals.length) return err( new AnalysisOutputError( @@ -177,8 +197,6 @@ export class WebModuleTraceService { ), ); } - if (isAborted(options?.signal)) - return err(new AnalysisCancelledError(OPERATION)); let literalIndex = 0; const imports: WebModuleTraceResult["imports"] = []; for (const reference of syntax.imports) { @@ -238,37 +256,40 @@ export class WebModuleTraceService { }); if (!normalized.success) return err(new AnalysisOutputError(OPERATION, normalized.error.message)); - return ok( - createEvidence( - { - path: data.sourceFile.path, - sha256: data.sourceFile.sha256, - format: "file", - }, - { - id: "rea-web-module-trace", - name: "REA captured module relationship trace", - version: "1", - }, - { - operation: OPERATION, - parameters: jsonObjectSchema.parse(input), - result: normalized.data, - rawResult: resolution?.rawResult ?? null, - confidence: "derived", - authority: "historical-reference", - limitations: LIMITATIONS, - locations: [ - { kind: "artifact-path", path: data.sourceFile.path }, - { kind: "artifact-path", path: input.manifest_path }, - ], - evidenceLinks: - data.manifest.source_evidence_id === null - ? [] - : [data.manifest.source_evidence_id], - }, - ), + const evidence = createEvidence( + { + path: data.sourceFile.path, + sha256: data.sourceFile.sha256, + format: "file", + }, + { + id: "rea-web-module-trace", + name: "REA captured module relationship trace", + version: "1", + }, + { + operation: OPERATION, + parameters: jsonObjectSchema.parse(input), + result: normalized.data, + rawResult: resolution?.rawResult ?? null, + confidence: "derived", + authority: "historical-reference", + limitations: LIMITATIONS, + locations: [ + { kind: "artifact-path", path: data.sourceFile.path }, + { kind: "artifact-path", path: input.manifest_path }, + ], + evidenceLinks: + data.manifest.source_evidence_id === null + ? [] + : [data.manifest.source_evidence_id], + }, ); + if (resolutionFailure !== undefined) { + resolutionFailure.partialObservation = evidence; + return err(resolutionFailure); + } + return completeProviderEvidence(evidence, options?.signal); } } @@ -304,7 +325,6 @@ const admitContext = ( ); return ok(null); }; -const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true; const invalid = (path: readonly string[], message: string) => err( new AnalysisInputError(OPERATION, undefined, [ diff --git a/src/browser/modules/NativeModuleResolver.test.ts b/src/browser/modules/NativeModuleResolver.test.ts index 44a13c0e2a..044576ac13 100644 --- a/src/browser/modules/NativeModuleResolver.test.ts +++ b/src/browser/modules/NativeModuleResolver.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { NativeModuleResolver } from "./NativeModuleResolver.js"; +import { webModuleResolutionObservationSchema } from "../../domain/webModuleTrace.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { completedModuleResolverLauncher } from "../../../tests/fixtures/webModuleTrace.js"; + const input = { importerUrl: "https://app.test/main.js", importMap: null, @@ -95,6 +98,9 @@ describe("native module resolver cleanup ownership", () => { await provider.close(); expect(closes).toBe(4); expect(launches).toBe(1); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); + if (!blocked.ok) expect(blocked.error.cleanupIncomplete).toBe(false); }); it("awaits acquisition and cleanup while refusing queued work during shutdown", async () => { let acquired: () => void = () => { @@ -163,7 +169,8 @@ describe("optional native resolution acquisition", () => { }); it("does not acquire a browser after caller cancellation", async () => { const controller = new AbortController(); - controller.abort(); + const reason = { request: "opaque admission cancellation" }; + controller.abort(reason); const provider = new NativeModuleResolver( { REA_BROWSER_EXECUTABLE: "/browser" }, () => { @@ -173,6 +180,7 @@ describe("optional native resolution acquisition", () => { const result = await provider.resolve(input, { signal: controller.signal }); if (result.ok) throw new Error("expected cancellation"); expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(reason); }); it("retains the selected executable and actual startup diagnostic", async () => { const provider = new NativeModuleResolver( @@ -189,3 +197,155 @@ describe("optional native resolution acquisition", () => { }); }); }); + +describe("completed native resolution finalization", () => { + it.each([false, true])( + "retains the completed batch when final close is cancelled (close fails: %s)", + async (cleanupFails) => { + const controller = new AbortController(); + const reason = { request: "cancelled during final browser close" }; + let allowClose = !cleanupFails; + let closes = 0; + const report = { + importer_url: input.importerUrl, + entries: [ + { + specifier: "./dep.js", + resolution: { state: "resolved", url: "https://app.test/dep.js" }, + }, + ], + }; + const launcher = completedModuleResolverLauncher(input, async () => { + closes += 1; + controller.abort(reason); + if (!allowClose) throw new Error("controlled close refusal"); + }); + const provider = new NativeModuleResolver( + { REA_BROWSER_EXECUTABLE: "/browser" }, + launcher, + ); + const result = await provider.resolve(input, { + signal: controller.signal, + }); + if (result.ok) throw new Error("expected cancelled finalization"); + expect(projectAnalysisError(result.error).code).toBe( + cleanupFails ? "cleanup_incomplete" : "cancelled", + ); + expect((result.error.cleanupPrimary ?? result.error).cause).toBe(reason); + expect( + webModuleResolutionObservationSchema.parse( + result.error.partialObservation, + ), + ).toMatchObject({ + kind: "web-module-resolution", + request: input, + batch: { + engine: { version: "controlled-browser" }, + resolutions: report.entries.map((entry) => entry.resolution), + rawResult: { native_report: report, executable_path: "/browser" }, + }, + }); + if (cleanupFails) { + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + expect(result.error.cleanupIncomplete).toBe(true); + allowClose = true; + } + await provider.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + expect(closes).toBe(cleanupFails ? 3 : 1); + }, + ); +}); + +it.each([ + { stage: "launch", reason: null }, + { stage: "launch", reason: new Error("opaque cancellation reason") }, + { stage: "context", reason: null }, + { stage: "context", reason: new Error("opaque cancellation reason") }, +])( + "preserves an independent $stage rejection when cancellation arrives before catch: $reason", + async ({ stage, reason }) => { + const controller = new AbortController(); + const physicalFailure = new Error("controlled SDK EIO"); + let closes = 0; + const reject = () => { + const rejected = Promise.reject(physicalFailure); + queueMicrotask(() => controller.abort(reason)); + return rejected; + }; + const provider = new NativeModuleResolver( + { REA_BROWSER_EXECUTABLE: "/browser" }, + () => + stage === "launch" + ? reject() + : Promise.resolve({ + version: () => "controlled-browser", + newContext: reject, + close: async () => { + closes += 1; + }, + }), + ); + const result = await provider.resolve(input, { signal: controller.signal }); + if (result.ok) throw new Error("expected independent rejection"); + expect(result.error).toMatchObject({ + _tag: "ProviderAdapterError", + cause: physicalFailure, + }); + expect(projectAnalysisError(result.error).code).not.toBe("cancelled"); + await provider.close(); + expect(closes).toBe(stage === "launch" ? 0 : 1); + }, +); + +it.each(["caller", "deadline"] as const)( + "attributes an in-flight SDK rejection to the owned %s stop", + async (control) => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] }); + try { + const controller = new AbortController(); + const reason = { request: "cancel active browser operation" }; + let started!: () => void; + const active = new Promise((resolve) => { + started = resolve; + }); + let rejectCall!: (cause: unknown) => void; + let closes = 0; + const provider = new NativeModuleResolver( + { REA_BROWSER_EXECUTABLE: "/browser" }, + async () => ({ + version: () => "controlled-browser", + newContext: () => { + started(); + return new Promise((_resolve, reject) => { + rejectCall = reject; + }); + }, + close: async () => { + closes += 1; + rejectCall(new Error("controlled SDK target closed")); + }, + }), + ); + const pending = provider.resolve(input, { signal: controller.signal }); + await active; + if (control === "caller") controller.abort(reason); + else await vi.advanceTimersByTimeAsync(20001); + const result = await pending; + if (result.ok) throw new Error("expected interrupted active call"); + expect(projectAnalysisError(result.error).code).toBe( + control === "caller" ? "cancelled" : "provider_timeout", + ); + if (control === "caller") expect(result.error.cause).toBe(reason); + expect(result.error.partialObservation).toBeUndefined(); + await provider.close(); + expect(closes).toBe(1); + } finally { + vi.useRealTimers(); + } + }, +); diff --git a/src/browser/modules/NativeModuleResolver.ts b/src/browser/modules/NativeModuleResolver.ts index c0fb8c19a2..6247003502 100644 --- a/src/browser/modules/NativeModuleResolver.ts +++ b/src/browser/modules/NativeModuleResolver.ts @@ -1,11 +1,14 @@ import { isAbsolute } from "node:path"; import { z } from "zod"; -import type { Browser, LaunchOptions } from "playwright-core"; -import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import type { - WebModuleResolutionPort, - WebModuleResolutionBatch, -} from "../../application/WebModulePorts.js"; + Browser, + BrowserContext, + Page, + ConsoleMessage, + LaunchOptions, +} from "playwright-core"; +import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; +import type { WebModuleResolutionPort } from "../../application/WebModulePorts.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { @@ -19,6 +22,14 @@ import { jsonObjectSchema } from "../../domain/jsonValue.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { err, ok, type Result } from "../../domain/result.js"; +import { + AnalysisCleanupLease, + AnalysisCleanupLeaseGroup, +} from "../../artifacts/AnalysisCleanupLease.js"; +import type { + WebModuleResolutionBatch, + WebModuleResolutionObservation, +} from "../../domain/webModuleTrace.js"; import { webModuleResolutionSchema } from "../../domain/webModuleTrace.js"; import { ProviderStartupDeadline } from "../../process/ProviderDeadline.js"; @@ -34,7 +45,25 @@ const reportSchema = z.strictObject({ ), }); -type ModuleResolverBrowser = Pick; +type ObserveResolverCall = (call: () => Promise) => Promise; + +interface ModuleResolverPage { + on(event: "console", listener: (message: ConsoleMessage) => void): unknown; + goto: Page["goto"]; + evaluate(expression: string | (() => unknown)): Promise; + waitForFunction( + expression: () => unknown, + argument: undefined, + options: { timeout: number }, + ): Promise; +} +interface ModuleResolverBrowser extends Pick { + newContext( + options: Parameters[0], + ): Promise< + Pick & { newPage(): Promise } + >; +} /** Production launch seam: only the adapter knows the browser API. */ export type ModuleResolverLauncher = ( options: LaunchOptions, @@ -47,7 +76,7 @@ export class NativeModuleResolver implements WebModuleResolutionPort { #tail: Promise = Promise.resolve(); #closing = false; #closePromise: Promise | undefined; - #pendingBrowser: ModuleResolverBrowser | undefined; + #pendingCleanup: AnalysisCleanupLease | undefined; constructor( readonly environment: Readonly>, @@ -74,7 +103,9 @@ export class NativeModuleResolver implements WebModuleResolutionPort { options?: ExecutionOptions, ): ReturnType { if (options?.signal?.aborted === true) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); const pending = await this.#retryCleanup(); if (pending !== undefined) return err(pending); const executable = this.environment.REA_BROWSER_EXECUTABLE; @@ -91,86 +122,153 @@ export class NativeModuleResolver implements WebModuleResolutionPort { ), ); const deadline = new ProviderStartupDeadline(TIMEOUT_MS, options?.signal); - let browser: ModuleResolverBrowser | undefined; + let activeCall: + | { settled: boolean; interruption?: AnalysisError } + | undefined; + let interruption: AnalysisError | undefined; + const interrupted = (): AnalysisError => + deadline.interruption === "cancelled" + ? new AnalysisCancelledError(OPERATION, { + cause: deadline.signal.reason, + }) + : new AnalysisTimeoutError(OPERATION, TIMEOUT_MS); + const assertActive = (): void => { + if (!deadline.signal.aborted) return; + interruption = interrupted(); + throw interruption; + }; + const observe: ObserveResolverCall = (call) => { + assertActive(); + const observed: NonNullable = { settled: false }; + activeCall = observed; + return call().then( + (value) => { + observed.settled = true; + return value; + }, + (cause: unknown) => { + observed.settled = true; + throw observed.interruption === undefined + ? cause + : observed.interruption; + }, + ); + }; + let lease: AnalysisCleanupLease | undefined; let cleanup: Promise | undefined; const close = (): Promise => { - if (cleanup === undefined && browser !== undefined) { - const owned = browser; + if (cleanup === undefined && lease !== undefined) { + const owned = lease; cleanup = Promise.resolve().then(async () => { - await owned.close(); - this.#pendingBrowser = undefined; + await owned.release(); + this.#pendingCleanup = undefined; }); } return cleanup ?? Promise.resolve(); }; const onAbort = (): void => { - if (browser !== undefined) { + if (lease !== undefined) { const pending = close(); void pending.catch(() => undefined); } }; deadline.signal.addEventListener("abort", onAbort, { once: true }); + let result: Result; try { - deadline.signal.throwIfAborted(); - browser = await this.launcher({ - executablePath: executable, - headless: true, - timeout: Math.max(1, deadline.remainingMs()), - handleSIGINT: false, - handleSIGTERM: false, - handleSIGHUP: false, - args: [ - "--disable-dev-shm-usage", - "--disable-background-networking", - "--renderer-process-limit=1", - "--js-flags=--max-old-space-size=128", - ...(this.environment.REA_BROWSER_NO_SANDBOX === "true" - ? ["--no-sandbox"] - : []), - ], - }); - this.#pendingBrowser = browser; - deadline.signal.throwIfAborted(); - result = ok(await resolveInBrowser(browser, input, deadline, executable)); + assertActive(); + const browser = await observe(() => + this.launcher({ + executablePath: executable, + headless: true, + timeout: Math.max(1, deadline.remainingMs()), + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, + args: [ + "--disable-dev-shm-usage", + "--disable-background-networking", + "--renderer-process-limit=1", + "--js-flags=--max-old-space-size=128", + ...(this.environment.REA_BROWSER_NO_SANDBOX === "true" + ? ["--no-sandbox"] + : []), + ], + }), + ); + lease = new AnalysisCleanupLease(async () => { + if ( + deadline.signal.aborted && + activeCall !== undefined && + !activeCall.settled + ) { + interruption = interrupted(); + activeCall.interruption = interruption; + } + await browser.close(); + }, ["chromium-browser"]); + this.#pendingCleanup = lease; + assertActive(); + result = ok( + await resolveInBrowser( + browser, + input, + deadline, + executable, + assertActive, + observe, + ), + ); } catch (cause: unknown) { result = err( - deadline.interruption === "cancelled" - ? new AnalysisCancelledError(OPERATION) - : deadline.interruption === "timeout" - ? new AnalysisTimeoutError(OPERATION, TIMEOUT_MS) - : cause instanceof AnalysisOutputError || - cause instanceof AnalysisCapabilityUnavailableError || - cause instanceof BrowserObservationError - ? cause - : new ProviderAdapterError("native-module-resolver", OPERATION, { - cause, - diagnostics: { - executable, - error_message: - cause instanceof Error ? cause.message : String(cause), - }, - }), + cause === interruption && interruption !== undefined + ? interruption + : cause instanceof AnalysisOutputError || + cause instanceof AnalysisCapabilityUnavailableError || + cause instanceof BrowserObservationError + ? cause + : new ProviderAdapterError("native-module-resolver", OPERATION, { + cause, + diagnostics: { + executable, + error_message: + cause instanceof Error ? cause.message : String(cause), + }, + }), ); } finally { deadline.signal.removeEventListener("abort", onAbort); deadline.dispose(); } + let cleanupError: AnalysisError | undefined; try { await close(); } catch (cause: unknown) { - const failure = cleanupFailure( + cleanupError = cleanupFailure( cause, - result.ok ? result.value.rawResult : undefined, - ); - return err( - result.ok - ? failure - : analysisErrorWithCleanupFailure(result.error, failure, OPERATION), + result.ok ? completedObservation(input, result.value) : undefined, ); + if (lease !== undefined) + new AnalysisCleanupLeaseGroup(cleanupError, [lease]); } if (result.ok && options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + result = err( + new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + partialObservation: completedObservation(input, result.value), + }), + ); + if (cleanupError !== undefined) + return err( + result.ok + ? cleanupError + : analysisErrorWithCleanupFailure( + result.error, + cleanupError, + OPERATION, + ).bindCleanupDependencies([result.error, cleanupError]), + ); + return result; } @@ -195,14 +293,16 @@ export class NativeModuleResolver implements WebModuleResolutionPort { } async #retryCleanup(): Promise { - const browser = this.#pendingBrowser; - if (browser === undefined) return undefined; + const lease = this.#pendingCleanup; + if (lease === undefined) return undefined; try { - await browser.close(); - this.#pendingBrowser = undefined; + await lease.release(); + this.#pendingCleanup = undefined; return undefined; } catch (cause: unknown) { - return cleanupFailure(cause); + const failure = cleanupFailure(cause); + new AnalysisCleanupLeaseGroup(failure, [lease]); + return failure; } } } @@ -212,12 +312,16 @@ const resolveInBrowser = async ( input: Parameters[0], deadline: ProviderStartupDeadline, executable: string, + assertActive: () => void, + observe: ObserveResolverCall, ): Promise => { - const context = await browser.newContext({ - serviceWorkers: "block", - acceptDownloads: false, - }); - const page = await context.newPage(); + const context = await observe(() => + browser.newContext({ + serviceWorkers: "block", + acceptDownloads: false, + }), + ); + const page = await observe(() => context.newPage()); const diagnostics: string[] = []; let diagnosticBytes = 0; page.on("console", (message) => { @@ -237,64 +341,74 @@ const resolveInBrowser = async ( const transportUrl = new URL(importerUrl); transportUrl.hash = ""; let stubSent = false; - await context.route("**/*", async (route) => { - const request = route.request(); - if ( - request.resourceType() === "document" && - request.url() === documentUrl.split("#")[0] - ) { - requests.push({ - url: request.url(), - resource_type: "document", - effect: "fulfilled", - }); - await route.fulfill({ - status: 200, - contentType: "text/html", - body: `${input.importMap === null ? "" : ``}`, - }); - } else if ( - !stubSent && - request.resourceType() === "script" && - request.url() === transportUrl.href - ) { - stubSent = true; - requests.push({ - url: request.url(), - resource_type: "script", - effect: "fulfilled", - }); - await route.fulfill({ - status: 200, - contentType: "text/javascript", - headers: { "Access-Control-Allow-Origin": "*" }, - body: `if (typeof import.meta.resolve !== "function") {globalThis.__reaModuleUnsupported = true;} else {globalThis.__reaModuleResult = {importer_url: import.meta.url, entries: ${scriptJson(input.specifiers)}.map(specifier => {try {return {specifier, resolution: {state: "resolved", url: import.meta.resolve(specifier)}}} catch (error) {return {specifier, resolution: {state: "rejected", message: String(error)}}}})};}`, - }); - } else { - requests.push({ - url: request.url(), - resource_type: request.resourceType(), - effect: "blocked", - }); - await route.abort(); - } - }); - await page.goto(documentUrl, { - timeout: Math.max(1, deadline.remainingMs()), - }); - await page.evaluate( - `{const script = document.createElement("script"); script.type = "module"; script.src = ${scriptJson(importerUrl)}; script.onerror = () => {globalThis.__reaModuleFailure = "Trusted resolver stub could not be loaded in the selected context.";}; document.head.append(script);}`, + await observe(() => + context.route("**/*", async (route) => { + const request = route.request(); + if ( + request.resourceType() === "document" && + request.url() === documentUrl.split("#")[0] + ) { + requests.push({ + url: request.url(), + resource_type: "document", + effect: "fulfilled", + }); + await route.fulfill({ + status: 200, + contentType: "text/html", + body: `${input.importMap === null ? "" : ``}`, + }); + } else if ( + !stubSent && + request.resourceType() === "script" && + request.url() === transportUrl.href + ) { + stubSent = true; + requests.push({ + url: request.url(), + resource_type: "script", + effect: "fulfilled", + }); + await route.fulfill({ + status: 200, + contentType: "text/javascript", + headers: { "Access-Control-Allow-Origin": "*" }, + body: `if (typeof import.meta.resolve !== "function") {globalThis.__reaModuleUnsupported = true;} else {globalThis.__reaModuleResult = {importer_url: import.meta.url, entries: ${scriptJson(input.specifiers)}.map(specifier => {try {return {specifier, resolution: {state: "resolved", url: import.meta.resolve(specifier)}}} catch (error) {return {specifier, resolution: {state: "rejected", message: String(error)}}}})};}`, + }); + } else { + requests.push({ + url: request.url(), + resource_type: request.resourceType(), + effect: "blocked", + }); + await route.abort(); + } + }), ); - await page.waitForFunction( - () => - Reflect.has(globalThis, "__reaModuleResult") || - Reflect.has(globalThis, "__reaModuleUnsupported") || - Reflect.has(globalThis, "__reaModuleFailure"), - undefined, - { timeout: Math.max(1, deadline.remainingMs()) }, + await observe(() => + page.goto(documentUrl, { + timeout: Math.max(1, deadline.remainingMs()), + }), ); - const unsupported: unknown = await page.evaluate( - () => Reflect.get(globalThis, "__reaModuleUnsupported") as unknown, + await observe(() => + page.evaluate( + `{const script = document.createElement("script"); script.type = "module"; script.src = ${scriptJson(importerUrl)}; script.onerror = () => {globalThis.__reaModuleFailure = "Trusted resolver stub could not be loaded in the selected context.";}; document.head.append(script);}`, + ), + ); + await observe(() => + page.waitForFunction( + () => + Reflect.has(globalThis, "__reaModuleResult") || + Reflect.has(globalThis, "__reaModuleUnsupported") || + Reflect.has(globalThis, "__reaModuleFailure"), + undefined, + { timeout: Math.max(1, deadline.remainingMs()) }, + ), + ); + const unsupported: unknown = await observe(() => + page.evaluate( + () => Reflect.get(globalThis, "__reaModuleUnsupported") as unknown, + ), ); if (unsupported === true) throw new AnalysisCapabilityUnavailableError( @@ -305,11 +419,15 @@ const resolveInBrowser = async ( userMessage: `Configured Chromium ${browser.version()} does not supply import.meta.resolve; use a browser supporting native module resolution.`, }, ); - const raw: unknown = await page.evaluate( - () => Reflect.get(globalThis, "__reaModuleResult") as unknown, + const raw: unknown = await observe(() => + page.evaluate( + () => Reflect.get(globalThis, "__reaModuleResult") as unknown, + ), ); - const failure: unknown = await page.evaluate( - () => Reflect.get(globalThis, "__reaModuleFailure") as unknown, + const failure: unknown = await observe(() => + page.evaluate( + () => Reflect.get(globalThis, "__reaModuleFailure") as unknown, + ), ); if (typeof failure === "string") throw new AnalysisOutputError( @@ -331,7 +449,7 @@ const resolveInBrowser = async ( ); if (diagnosticBytes > 1024 * 1024) throw new BrowserObservationError(OPERATION, "payload_limit"); - deadline.signal.throwIfAborted(); + assertActive(); const engine = { id: "chromium-native-module-resolver", version: browser.version(), @@ -350,20 +468,30 @@ const resolveInBrowser = async ( }; }; +const completedObservation = ( + input: Parameters[0], + batch: WebModuleResolutionBatch, +): WebModuleResolutionObservation => ({ + kind: "web-module-resolution", + request: input, + batch, +}); + const cleanupFailure = ( cause: unknown, - observation?: WebModuleResolutionBatch["rawResult"], + observation?: WebModuleResolutionObservation, ): ProviderCleanupError => new ProviderCleanupError( "native-module-resolver", ["chromium-browser"], { reason: cause instanceof Error ? cause.message : String(cause), - ...(observation === undefined - ? {} - : { partial_observation: observation }), }, - { cause, operation: OPERATION }, + { + cause, + operation: OPERATION, + ...(observation === undefined ? {} : { partialObservation: observation }), + }, ); const scriptJson = (value: unknown): string => diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 4c4b0e0718..86e6a99395 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -236,8 +236,11 @@ import type { NativeLoadImagePartialObservation } from "./native/nativeLoadImage import type { NativeAotSnapshotObservation } from "./native/nativeAotSnapshotObservation.js"; import type { FileSnapshotPartialObservation } from "./fileSnapshotObservation.js"; +import type { WebModuleResolutionObservation } from "./webModuleTrace.js"; + /** Provider-neutral evidence collected before a typed analysis failure. */ export type AnalysisPartialObservation = + | WebModuleResolutionObservation | NativeLoadImagePartialObservation | NativeAotSnapshotObservation | FileSnapshotPartialObservation diff --git a/src/domain/webModuleTrace.ts b/src/domain/webModuleTrace.ts index 059aa9a7fd..09343ef332 100644 --- a/src/domain/webModuleTrace.ts +++ b/src/domain/webModuleTrace.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { jsonObjectSchema } from "./jsonValue.js"; import { digestSchema } from "./digests.js"; import { localPathStringSchema } from "./localPath.js"; import { @@ -69,6 +70,39 @@ export const webModuleResolutionSchema = z.discriminatedUnion("state", [ ]); export type WebModuleResolution = z.output; +/** Completed native resolution, shared by success and interrupted finalization. */ +export const webModuleResolutionBatchSchema = z.strictObject({ + engine: z.strictObject({ id: z.string().min(1), version: z.string().min(1) }), + resolutions: z.array(webModuleResolutionSchema), + diagnostics: z.array(z.string()), + rawResult: jsonObjectSchema, +}); +export type WebModuleResolutionBatch = z.output< + typeof webModuleResolutionBatchSchema +>; + +/** Result-affecting context of one local native resolver request. */ +export const webModuleResolutionRequestSchema = z.strictObject({ + importerUrl: z.string().min(1), + importMap: z + .strictObject({ baseUrl: z.string().min(1), value: jsonObjectSchema }) + .nullable(), + specifiers: z.array(z.string()).readonly(), +}); +export type WebModuleResolutionRequest = z.output< + typeof webModuleResolutionRequestSchema +>; + +/** A completed batch remains bound to the exact resolver request on failure. */ +export const webModuleResolutionObservationSchema = z.strictObject({ + kind: z.literal("web-module-resolution"), + request: webModuleResolutionRequestSchema, + batch: webModuleResolutionBatchSchema, +}); +export type WebModuleResolutionObservation = z.output< + typeof webModuleResolutionObservationSchema +>; + const candidateSchema = z.strictObject({ script_index: z.number().int().min(0), match: z.enum(["exact-reported-url", "response-url-without-fragment"]), diff --git a/tests/boundary/mcp/webModuleTraceMcp.test.ts b/tests/boundary/mcp/webModuleTraceMcp.test.ts index 03c2cd94b2..89923cbed6 100644 --- a/tests/boundary/mcp/webModuleTraceMcp.test.ts +++ b/tests/boundary/mcp/webModuleTraceMcp.test.ts @@ -1,3 +1,8 @@ +import type { AnalysisError } from "../../../src/domain/analysisErrorBase.js"; +import type { ExecutionOptions } from "../../../src/application/AnalysisProvider.js"; +import { NativeModuleResolver } from "../../../src/browser/modules/NativeModuleResolver.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it, onTestFinished } from "vitest"; @@ -9,6 +14,7 @@ import { createServer } from "../../../src/server/createServer.js"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { webModuleArtifactsFixture, + completedModuleResolverLauncher, webModuleResolverFixture, } from "../../fixtures/webModuleTrace.js"; @@ -110,3 +116,114 @@ it("retains the module resolver cleanup owner across failed server shutdown", as await server.close(); expect(closes).toBe(2); }); + +it.each([false, true])( + "retains completed module Evidence over SDK MCP through final close cancellation (close fails: %s)", + async (cleanupFails) => { + const artifacts = webModuleArtifactsFixture(); + const importerUrl = artifacts.manifest.scripts[0]?.url; + if (importerUrl === undefined) + throw new Error("fixture source URL missing"); + const controller = new AbortController(); + const reason = { request: "controlled finalization cancellation" }; + let allowClose = !cleanupFails; + let launches = 0; + const launcher = completedModuleResolverLauncher( + { importerUrl, importMap: null, specifiers: ["./dep.js"] }, + async () => { + controller.abort(reason); + if (!allowClose) throw new Error("controlled close refusal"); + }, + ); + const resolver = new NativeModuleResolver( + { REA_BROWSER_EXECUTABLE: "/browser" }, + (options) => { + launches += 1; + return launcher(options); + }, + ); + let original: AnalysisError | undefined; + class ControlledCancellationService extends WebModuleTraceService { + override async trace(input: unknown, options?: ExecutionOptions) { + const result = await super.trace(input, { + ...options, + signal: controller.signal, + }); + if (!result.ok) original = result.error; + return result; + } + } + const service = new ControlledCancellationService( + { load: async () => ok(artifacts) }, + resolver, + ); + const session = createTestBinarySession(() => { + throw new Error("no binary acquisition"); + }); + const server = createServer( + { kind: "session", session }, + { webModuleTrace: service }, + ); + const client = new Client({ name: "module-finalization", version: "1" }); + onTestFinished(async () => { + allowClose = true; + await client.close(); + await server.close(); + await session.close(); + }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const response = await client.callTool({ + name: "trace_web_module_imports", + arguments: { + manifest_path: "/analysis/manifest.json", + script_index: 0, + }, + }); + expect(response.isError).toBe(true); + if (original === undefined) throw new Error("expected original failure"); + const evidence = parseEvidence(original.partialObservation); + expect(parseMcpToolError(response)).toMatchObject({ + error: { + code: cleanupFails ? "cleanup_incomplete" : "cancelled", + details: { + partial_observation: { + kind: "retained-evidence", + evidence_id: evidence.evidence_id, + }, + }, + }, + }); + expect(evidence).toMatchObject({ + operation: "trace_web_module_imports", + confidence: "derived", + normalized_result: { + source: artifacts.sourceFile, + imports: [ + { + resolution: { state: "resolved", url: "https://app.test/dep.js" }, + execution: "unknown", + }, + { + resolution: { state: "unknown", reason: "computed-specifier" }, + execution: "unknown", + }, + ], + }, + }); + expect(session.evidenceById(evidence.evidence_id)).toEqual(evidence); + expect((original.cleanupPrimary ?? original).cause).toBe(reason); + if (cleanupFails) { + await expect(server.close()).rejects.toBeInstanceOf(AggregateError); + expect(original.cleanupIncomplete).toBe(true); + allowClose = true; + } + await server.close(); + expect(original.cleanupIncomplete).toBe(false); + expect(original.cleanupResources).toEqual([]); + expect(projectAnalysisError(original).code).toBe("cancelled"); + expect(launches).toBe(1); + }, +); diff --git a/tests/fixtures/webModuleTrace.ts b/tests/fixtures/webModuleTrace.ts index cf10d55cc9..ef52dc31c6 100644 --- a/tests/fixtures/webModuleTrace.ts +++ b/tests/fixtures/webModuleTrace.ts @@ -79,3 +79,36 @@ export const webModuleResolverFixture: WebModuleResolutionPort = { }), ), }; + +/** Controlled SDK seam for provider finalization, not real Chromium qualification. */ +export const completedModuleResolverLauncher = + ( + request: Parameters[0], + close: () => Promise, + ): import("../../src/browser/modules/NativeModuleResolver.js").ModuleResolverLauncher => + async () => { + const report = { + importer_url: request.importerUrl, + entries: request.specifiers.map((specifier) => ({ + specifier, + resolution: { + state: "resolved", + url: new URL(specifier, request.importerUrl).href, + }, + })), + }; + const evaluations: unknown[] = [undefined, false, report, undefined]; + return { + version: () => "controlled-browser", + newContext: async () => ({ + route: async () => {}, + newPage: async () => ({ + on: () => {}, + goto: async () => null, + evaluate: async () => evaluations.shift(), + waitForFunction: async () => {}, + }), + }), + close, + }; + };