fix(browser): retain module resolver cleanup ownership
Serialize resolution with shutdown, retain the owned browser after close failures, and retry cleanup before another launch. Wire cleanup through the shared service and CLI/MCP lifecycles, preserving primary failures and completed native reports alongside cleanup diagnostics. Separate the browser resolution workflow from lifecycle ownership. Validation: seven resolver source tests, eleven application tests, MCP shutdown retry, public CLI parity, and full static/architecture checks passed. Real Chromium behavior remains unverified.
This commit is contained in:
1 parent
c2b56e3e1c
commit
a82e37c806
7 files changed
+441
-198
No files matched your search
@@ -7,6 +7,7 @@ import type {
|
||||
WebModuleResolutionBatch,
|
||||
} from "../../application/WebModulePorts.js";
|
||||
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
import { BrowserObservationError } from "../../domain/browserObservationError.js";
|
||||
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 { webModuleResolutionSchema } from "../../domain/webModuleTrace.js";
|
||||
import { ProviderStartupDeadline } from "../../process/ProviderDeadline.js";
|
||||
@@ -32,26 +34,49 @@ const reportSchema = z.strictObject({
|
||||
),
|
||||
});
|
||||
|
||||
type ModuleResolverBrowser = Pick<Browser, "newContext" | "version" | "close">;
|
||||
/** Production launch seam: only the adapter knows the browser API. */
|
||||
export type ModuleResolverLauncher = (
|
||||
options: LaunchOptions,
|
||||
) => Promise<Pick<Browser, "newContext" | "version" | "close">>;
|
||||
) => Promise<ModuleResolverBrowser>;
|
||||
const launch: ModuleResolverLauncher = async (options) =>
|
||||
(await import("playwright-core")).chromium.launch(options);
|
||||
|
||||
/** Use native import.meta.resolve in an owned context with locally fulfilled requests. */
|
||||
export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
#tail: Promise<void> = Promise.resolve();
|
||||
#closing = false;
|
||||
#closePromise: Promise<void> | undefined;
|
||||
#pendingBrowser: ModuleResolverBrowser | undefined;
|
||||
|
||||
constructor(
|
||||
readonly environment: Readonly<Record<string, string | undefined>>,
|
||||
readonly launcher: ModuleResolverLauncher = launch,
|
||||
) {}
|
||||
|
||||
async resolve(
|
||||
resolve(
|
||||
input: Parameters<WebModuleResolutionPort["resolve"]>[0],
|
||||
options?: ExecutionOptions,
|
||||
): ReturnType<WebModuleResolutionPort["resolve"]> {
|
||||
if (this.#closing) return Promise.resolve(err(this.#closedError()));
|
||||
const operation = this.#tail.then(() =>
|
||||
this.#closing ? err(this.#closedError()) : this.#resolve(input, options),
|
||||
);
|
||||
this.#tail = operation.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return operation;
|
||||
}
|
||||
|
||||
async #resolve(
|
||||
input: Parameters<WebModuleResolutionPort["resolve"]>[0],
|
||||
options?: ExecutionOptions,
|
||||
): ReturnType<WebModuleResolutionPort["resolve"]> {
|
||||
if (options?.signal?.aborted === true)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
const pending = await this.#retryCleanup();
|
||||
if (pending !== undefined) return err(pending);
|
||||
const executable = this.environment.REA_BROWSER_EXECUTABLE;
|
||||
if (executable === undefined || !isAbsolute(executable))
|
||||
return err(
|
||||
@@ -66,11 +91,16 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
),
|
||||
);
|
||||
const deadline = new ProviderStartupDeadline(TIMEOUT_MS, options?.signal);
|
||||
let browser: Pick<Browser, "newContext" | "version" | "close"> | undefined;
|
||||
let browser: ModuleResolverBrowser | undefined;
|
||||
let cleanup: Promise<void> | undefined;
|
||||
const close = (): Promise<void> => {
|
||||
if (cleanup === undefined && browser !== undefined)
|
||||
cleanup = browser.close();
|
||||
if (cleanup === undefined && browser !== undefined) {
|
||||
const owned = browser;
|
||||
cleanup = Promise.resolve().then(async () => {
|
||||
await owned.close();
|
||||
this.#pendingBrowser = undefined;
|
||||
});
|
||||
}
|
||||
return cleanup ?? Promise.resolve();
|
||||
};
|
||||
const onAbort = (): void => {
|
||||
@@ -82,6 +112,7 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
deadline.signal.addEventListener("abort", onAbort, { once: true });
|
||||
let result: Result<WebModuleResolutionBatch, AnalysisError>;
|
||||
try {
|
||||
deadline.signal.throwIfAborted();
|
||||
browser = await this.launcher({
|
||||
executablePath: executable,
|
||||
headless: true,
|
||||
@@ -99,142 +130,9 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
: []),
|
||||
],
|
||||
});
|
||||
this.#pendingBrowser = browser;
|
||||
deadline.signal.throwIfAborted();
|
||||
const context = await browser.newContext({
|
||||
serviceWorkers: "block",
|
||||
acceptDownloads: false,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const diagnostics: string[] = [];
|
||||
let diagnosticBytes = 0;
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
diagnosticBytes += Buffer.byteLength(text);
|
||||
if (diagnosticBytes <= 1024 * 1024)
|
||||
diagnostics.push(`${message.type()}: ${text}`);
|
||||
});
|
||||
const requests: {
|
||||
url: string;
|
||||
resource_type: string;
|
||||
effect: "fulfilled" | "blocked";
|
||||
}[] = [];
|
||||
const importerUrl = new URL(input.importerUrl).href;
|
||||
const documentUrl = new URL("/.rea-module-resolver/document", importerUrl)
|
||||
.href;
|
||||
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 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 page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleUnsupported") as unknown,
|
||||
);
|
||||
if (unsupported === true)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"native-module-resolver",
|
||||
OPERATION,
|
||||
"native_resolution_unsupported",
|
||||
{
|
||||
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 failure: unknown = await page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleFailure") as unknown,
|
||||
);
|
||||
if (typeof failure === "string")
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
`${failure} Native diagnostics: ${diagnostics.join("\n")}`,
|
||||
);
|
||||
const parsed = reportSchema.safeParse(raw);
|
||||
if (
|
||||
!parsed.success ||
|
||||
parsed.data.importer_url !== importerUrl ||
|
||||
parsed.data.entries.length !== input.specifiers.length ||
|
||||
parsed.data.entries.some(
|
||||
(entry, index) => entry.specifier !== input.specifiers[index],
|
||||
)
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Native resolver report has invalid shape or does not match the selected importer/specifiers.",
|
||||
);
|
||||
if (diagnosticBytes > 1024 * 1024)
|
||||
throw new BrowserObservationError(OPERATION, "payload_limit");
|
||||
deadline.signal.throwIfAborted();
|
||||
const engine = {
|
||||
id: "chromium-native-module-resolver",
|
||||
version: browser.version(),
|
||||
};
|
||||
result = ok({
|
||||
engine,
|
||||
resolutions: parsed.data.entries.map((entry) => entry.resolution),
|
||||
diagnostics,
|
||||
rawResult: jsonObjectSchema.parse({
|
||||
native_report: parsed.data,
|
||||
requests,
|
||||
diagnostics,
|
||||
engine,
|
||||
executable_path: executable,
|
||||
}),
|
||||
});
|
||||
result = ok(await resolveInBrowser(browser, input, deadline, executable));
|
||||
} catch (cause: unknown) {
|
||||
result = err(
|
||||
deadline.interruption === "cancelled"
|
||||
@@ -257,20 +155,217 @@ export class NativeModuleResolver implements WebModuleResolutionPort {
|
||||
} finally {
|
||||
deadline.signal.removeEventListener("abort", onAbort);
|
||||
deadline.dispose();
|
||||
try {
|
||||
await close();
|
||||
} catch (cause: unknown) {
|
||||
result = err(
|
||||
new BrowserObservationError(OPERATION, "cleanup_failed", { cause }),
|
||||
);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await close();
|
||||
} catch (cause: unknown) {
|
||||
const failure = cleanupFailure(
|
||||
cause,
|
||||
result.ok ? result.value.rawResult : undefined,
|
||||
);
|
||||
return err(
|
||||
result.ok
|
||||
? failure
|
||||
: analysisErrorWithCleanupFailure(result.error, failure, OPERATION),
|
||||
);
|
||||
}
|
||||
if (result.ok && options?.signal?.aborted)
|
||||
return err(new AnalysisCancelledError(OPERATION));
|
||||
return result;
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.#closing = true;
|
||||
this.#closePromise ??= this.#tail
|
||||
.then(async () => {
|
||||
const failure = await this.#retryCleanup();
|
||||
if (failure !== undefined) throw failure;
|
||||
})
|
||||
.catch((cause: unknown) => {
|
||||
this.#closePromise = undefined;
|
||||
throw cause;
|
||||
});
|
||||
return this.#closePromise;
|
||||
}
|
||||
|
||||
#closedError(): AnalysisError {
|
||||
return new BrowserObservationError(OPERATION, "cleanup_failed", {
|
||||
detail: "Native module resolver is closing.",
|
||||
});
|
||||
}
|
||||
|
||||
async #retryCleanup(): Promise<AnalysisError | undefined> {
|
||||
const browser = this.#pendingBrowser;
|
||||
if (browser === undefined) return undefined;
|
||||
try {
|
||||
await browser.close();
|
||||
this.#pendingBrowser = undefined;
|
||||
return undefined;
|
||||
} catch (cause: unknown) {
|
||||
return cleanupFailure(cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resolveInBrowser = async (
|
||||
browser: ModuleResolverBrowser,
|
||||
input: Parameters<WebModuleResolutionPort["resolve"]>[0],
|
||||
deadline: ProviderStartupDeadline,
|
||||
executable: string,
|
||||
): Promise<WebModuleResolutionBatch> => {
|
||||
const context = await browser.newContext({
|
||||
serviceWorkers: "block",
|
||||
acceptDownloads: false,
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const diagnostics: string[] = [];
|
||||
let diagnosticBytes = 0;
|
||||
page.on("console", (message) => {
|
||||
const text = message.text();
|
||||
diagnosticBytes += Buffer.byteLength(text);
|
||||
if (diagnosticBytes <= 1024 * 1024)
|
||||
diagnostics.push(`${message.type()}: ${text}`);
|
||||
});
|
||||
const requests: {
|
||||
url: string;
|
||||
resource_type: string;
|
||||
effect: "fulfilled" | "blocked";
|
||||
}[] = [];
|
||||
const importerUrl = new URL(input.importerUrl).href;
|
||||
const documentUrl = new URL("/.rea-module-resolver/document", importerUrl)
|
||||
.href;
|
||||
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 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 page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleUnsupported") as unknown,
|
||||
);
|
||||
if (unsupported === true)
|
||||
throw new AnalysisCapabilityUnavailableError(
|
||||
"native-module-resolver",
|
||||
OPERATION,
|
||||
"native_resolution_unsupported",
|
||||
{
|
||||
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 failure: unknown = await page.evaluate(
|
||||
() => Reflect.get(globalThis, "__reaModuleFailure") as unknown,
|
||||
);
|
||||
if (typeof failure === "string")
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
`${failure} Native diagnostics: ${diagnostics.join("\n")}`,
|
||||
);
|
||||
const parsed = reportSchema.safeParse(raw);
|
||||
if (
|
||||
!parsed.success ||
|
||||
parsed.data.importer_url !== importerUrl ||
|
||||
parsed.data.entries.length !== input.specifiers.length ||
|
||||
parsed.data.entries.some(
|
||||
(entry, index) => entry.specifier !== input.specifiers[index],
|
||||
)
|
||||
)
|
||||
throw new AnalysisOutputError(
|
||||
OPERATION,
|
||||
"Native resolver report has invalid shape or does not match the selected importer/specifiers.",
|
||||
);
|
||||
if (diagnosticBytes > 1024 * 1024)
|
||||
throw new BrowserObservationError(OPERATION, "payload_limit");
|
||||
deadline.signal.throwIfAborted();
|
||||
const engine = {
|
||||
id: "chromium-native-module-resolver",
|
||||
version: browser.version(),
|
||||
};
|
||||
return {
|
||||
engine,
|
||||
resolutions: parsed.data.entries.map((entry) => entry.resolution),
|
||||
diagnostics,
|
||||
rawResult: jsonObjectSchema.parse({
|
||||
native_report: parsed.data,
|
||||
requests,
|
||||
diagnostics,
|
||||
engine,
|
||||
executable_path: executable,
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const cleanupFailure = (
|
||||
cause: unknown,
|
||||
observation?: WebModuleResolutionBatch["rawResult"],
|
||||
): ProviderCleanupError =>
|
||||
new ProviderCleanupError(
|
||||
"native-module-resolver",
|
||||
["chromium-browser"],
|
||||
{
|
||||
reason: cause instanceof Error ? cause.message : String(cause),
|
||||
...(observation === undefined
|
||||
? {}
|
||||
: { partial_observation: observation }),
|
||||
},
|
||||
{ cause, operation: OPERATION },
|
||||
);
|
||||
|
||||
const scriptJson = (value: unknown): string =>
|
||||
JSON.stringify(value).replaceAll("<", "\\u003c");
|
||||
|
||||
|
||||
Reference in new issue
Block a user