fix(browser): retain module resolution through finalization

This commit is contained in:
morluto
2026-10-18 09:07:53 +08:00
parent 52b73ee857
commit 94bc7ed322
9 changed files with 923 additions and 222 deletions
+3 -17
View File
@@ -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<Result<WebModuleArtifacts, AnalysisError>>;
}
/** 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<void>;
resolve(
input: {
readonly importerUrl: string;
readonly importMap: {
readonly baseUrl: string;
readonly value: JsonObject;
} | null;
readonly specifiers: readonly string[];
},
input: WebModuleResolutionRequest,
options?: ExecutionOptions,
): Promise<Result<WebModuleResolutionBatch, AnalysisError>>;
}
+223 -3
View File
@@ -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" } },
],
},
});
});
+85 -65
View File
@@ -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<Result<Evidence, AnalysisError>> {
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, [
@@ -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<void>((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();
}
},
);
+263 -135
View File
@@ -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<Browser, "newContext" | "version" | "close">;
type ObserveResolverCall = <T>(call: () => Promise<T>) => Promise<T>;
interface ModuleResolverPage {
on(event: "console", listener: (message: ConsoleMessage) => void): unknown;
goto: Page["goto"];
evaluate(expression: string | (() => unknown)): Promise<unknown>;
waitForFunction(
expression: () => unknown,
argument: undefined,
options: { timeout: number },
): Promise<unknown>;
}
interface ModuleResolverBrowser extends Pick<Browser, "version" | "close"> {
newContext(
options: Parameters<Browser["newContext"]>[0],
): Promise<
Pick<BrowserContext, "route"> & { newPage(): Promise<ModuleResolverPage> }
>;
}
/** 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<void> = Promise.resolve();
#closing = false;
#closePromise: Promise<void> | undefined;
#pendingBrowser: ModuleResolverBrowser | undefined;
#pendingCleanup: AnalysisCleanupLease | undefined;
constructor(
readonly environment: Readonly<Record<string, string | undefined>>,
@@ -74,7 +103,9 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
options?: ExecutionOptions,
): ReturnType<WebModuleResolutionPort["resolve"]> {
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<typeof activeCall> = { 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<void> | undefined;
const close = (): Promise<void> => {
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<WebModuleResolutionBatch, AnalysisError>;
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<AnalysisError | undefined> {
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<WebModuleResolutionPort["resolve"]>[0],
deadline: ProviderStartupDeadline,
executable: string,
assertActive: () => void,
observe: ObserveResolverCall,
): Promise<WebModuleResolutionBatch> => {
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: `<!doctype html>${input.importMap === null ? "" : `<base href="${htmlAttribute(new URL(input.importMap.baseUrl).href)}"><script type="importmap">${scriptJson(input.importMap.value)}</script>`}`,
});
} 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: `<!doctype html>${input.importMap === null ? "" : `<base href="${htmlAttribute(new URL(input.importMap.baseUrl).href)}"><script type="importmap">${scriptJson(input.importMap.value)}</script>`}`,
});
} 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<WebModuleResolutionPort["resolve"]>[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 =>
+3
View File
@@ -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
+34
View File
@@ -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<typeof webModuleResolutionSchema>;
/** 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"]),
@@ -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);
},
);
+33
View File
@@ -79,3 +79,36 @@ export const webModuleResolverFixture: WebModuleResolutionPort = {
}),
),
};
/** Controlled SDK seam for provider finalization, not real Chromium qualification. */
export const completedModuleResolverLauncher =
(
request: Parameters<WebModuleResolutionPort["resolve"]>[0],
close: () => Promise<void>,
): 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,
};
};