From a82e37c8065cc200a0ca3e14e7c4f85e4e468580 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:44:25 +0800 Subject: [PATCH] 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. --- src/application/WebModulePorts.ts | 1 + src/application/WebModuleTraceService.ts | 4 + .../modules/NativeModuleResolver.test.ts | 158 +++++-- src/browser/modules/NativeModuleResolver.ts | 389 +++++++++++------- src/cli/webModuleCommands.ts | 54 ++- src/server/createServer.ts | 4 + tests/boundary/mcp/webModuleTraceMcp.test.ts | 29 ++ 7 files changed, 441 insertions(+), 198 deletions(-) diff --git a/src/application/WebModulePorts.ts b/src/application/WebModulePorts.ts index 2bb0a7ce1..81c94a161 100644 --- a/src/application/WebModulePorts.ts +++ b/src/application/WebModulePorts.ts @@ -38,6 +38,7 @@ export interface WebModuleResolutionBatch { /** Replaceable native resolution engine, with no provider protocol in callers. */ export interface WebModuleResolutionPort { + close?(): Promise; resolve( input: { readonly importerUrl: string; diff --git a/src/application/WebModuleTraceService.ts b/src/application/WebModuleTraceService.ts index 856185616..f8aa0f8b9 100644 --- a/src/application/WebModuleTraceService.ts +++ b/src/application/WebModuleTraceService.ts @@ -69,6 +69,10 @@ export class WebModuleTraceService { readonly resolver: WebModuleResolutionPort, ) {} + close(): Promise { + return this.resolver.close?.() ?? Promise.resolve(); + } + async trace( rawInput: unknown, options?: ExecutionOptions, diff --git a/src/browser/modules/NativeModuleResolver.test.ts b/src/browser/modules/NativeModuleResolver.test.ts index 72e12a5e2..44a13c0e2 100644 --- a/src/browser/modules/NativeModuleResolver.test.ts +++ b/src/browser/modules/NativeModuleResolver.test.ts @@ -7,48 +7,148 @@ const input = { importMap: null, specifiers: ["./dep.js"], }; -describe("optional native resolution acquisition", () => { - it("awaits owned cleanup when cancellation arrives during acquisition", async () => { - const controller = new AbortController(); - let closed = false; +describe("native module resolver cleanup ownership", () => { + it.each([false, true])( + "awaits owned cleanup when acquisition is cancelled (cleanup fails: %s)", + async (cleanupFails) => { + const controller = new AbortController(); + let closed = false; + const provider = new NativeModuleResolver( + { REA_BROWSER_EXECUTABLE: "/browser" }, + () => { + controller.abort(); + return Promise.resolve({ + version: () => "fixture", + newContext: () => { + throw new Error("must not acquire context"); + }, + close: () => { + closed = true; + if (cleanupFails) + return Promise.reject(new Error("fixture cleanup refusal")); + return Promise.resolve(); + }, + }); + }, + ); + const result = await provider.resolve(input, { + signal: controller.signal, + }); + expect(closed).toBe(true); + if (result.ok) throw new Error("expected cancelled result"); + const projected = projectAnalysisError(result.error); + expect(projected.code).toBe( + cleanupFails ? "cleanup_incomplete" : "cancelled", + ); + if (cleanupFails) { + expect(projected.details).toMatchObject({ + diagnostics: { primary_error: { code: "cancelled" } }, + }); + } + }, + ); + it("returns cleanup_incomplete when owned cleanup fails after an engine error", async () => { + let launches = 0; + let closes = 0; + let cleanupAllowed = false; const provider = new NativeModuleResolver( { REA_BROWSER_EXECUTABLE: "/browser" }, () => { - controller.abort(); + launches += 1; return Promise.resolve({ - version: () => "fixture", - newContext: () => { - throw new Error("must not acquire context"); - }, - close: () => { - closed = true; - return Promise.resolve(); - }, - }); - }, - ); - const result = await provider.resolve(input, { signal: controller.signal }); - expect(closed).toBe(true); - if (result.ok) throw new Error("expected cancelled result"); - expect(result.error._tag).toBe("AnalysisCancelledError"); - }); - it("returns cleanup_incomplete when owned cleanup fails after an engine error", async () => { - const provider = new NativeModuleResolver( - { REA_BROWSER_EXECUTABLE: "/browser" }, - () => - Promise.resolve({ version: () => "fixture", newContext: () => Promise.reject(new Error("fixture context failure")), - close: () => - Promise.reject(new Error("fixture owned cleanup refusal")), - }), + close: async () => { + closes += 1; + if (!cleanupAllowed) + throw new Error("fixture owned cleanup refusal"); + }, + }); + }, ); const result = await provider.resolve(input); if (result.ok) throw new Error("expected cleanup failure"); expect(projectAnalysisError(result.error).code).toBe("cleanup_incomplete"); expect(result.error.cleanupIncomplete).toBe(true); + expect(projectAnalysisError(result.error).details).toMatchObject({ + diagnostics: { + primary_error: { + details: { + diagnostics: { error_message: "fixture context failure" }, + }, + }, + cleanup_error: { + details: { diagnostics: { reason: "fixture owned cleanup refusal" } }, + }, + }, + }); + const blocked = await provider.resolve(input); + expect(blocked.ok).toBe(false); + if (!blocked.ok) expect(blocked.error.cleanupIncomplete).toBe(true); + expect(launches).toBe(1); + await expect(provider.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + cleanupAllowed = true; + await provider.close(); + await provider.close(); + expect(closes).toBe(4); + expect(launches).toBe(1); }); + it("awaits acquisition and cleanup while refusing queued work during shutdown", async () => { + let acquired: () => void = () => { + throw new Error("acquisition gate missing"); + }; + const acquiring = new Promise((resolve) => { + acquired = resolve; + }); + let release: () => void = () => { + throw new Error("release gate missing"); + }; + const released = new Promise((resolve) => { + release = resolve; + }); + let launches = 0; + let closes = 0; + const provider = new NativeModuleResolver( + { REA_BROWSER_EXECUTABLE: "/browser" }, + async () => { + launches += 1; + acquired(); + await released; + return { + version: () => "fixture", + newContext: () => + Promise.reject(new Error("fixture context failure")), + close: async () => { + closes += 1; + }, + }; + }, + ); + const first = provider.resolve(input); + await acquiring; + const queued = provider.resolve(input); + const closing = provider.close(); + let closed = false; + void closing.then(() => { + closed = true; + }); + await Promise.resolve(); + expect(closed).toBe(false); + release(); + await first; + const blocked = await queued; + expect(blocked.ok).toBe(false); + await closing; + expect(closed).toBe(true); + expect([launches, closes]).toEqual([1, 1]); + expect((await provider.resolve(input)).ok).toBe(false); + }); +}); + +describe("optional native resolution acquisition", () => { it("does not load the browser engine when the selected operation lacks its configuration", async () => { const provider = new NativeModuleResolver({}, () => { throw new Error("must not launch"); diff --git a/src/browser/modules/NativeModuleResolver.ts b/src/browser/modules/NativeModuleResolver.ts index 19541de53..c0fb8c19a 100644 --- a/src/browser/modules/NativeModuleResolver.ts +++ b/src/browser/modules/NativeModuleResolver.ts @@ -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; /** Production launch seam: only the adapter knows the browser API. */ export type ModuleResolverLauncher = ( options: LaunchOptions, -) => Promise>; +) => Promise; 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 = Promise.resolve(); + #closing = false; + #closePromise: Promise | undefined; + #pendingBrowser: ModuleResolverBrowser | undefined; + constructor( readonly environment: Readonly>, readonly launcher: ModuleResolverLauncher = launch, ) {} - async resolve( + resolve( + input: Parameters[0], + options?: ExecutionOptions, + ): ReturnType { + 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[0], options?: ExecutionOptions, ): ReturnType { 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 | undefined; + let browser: ModuleResolverBrowser | undefined; let cleanup: Promise | undefined; const close = (): Promise => { - 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; 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: `${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 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 { + 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 { + 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[0], + deadline: ProviderStartupDeadline, + executable: string, +): Promise => { + 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: `${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 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"); diff --git a/src/cli/webModuleCommands.ts b/src/cli/webModuleCommands.ts index 7e0518c8d..3297f3454 100644 --- a/src/cli/webModuleCommands.ts +++ b/src/cli/webModuleCommands.ts @@ -42,29 +42,39 @@ export const registerWebModuleCommands = ( .describe("HTTP(S) import-map base URL"), }), run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand(logger, CLI_COMMANDS.traceWebModuleImports, async () => { - const result = await service.trace( - { - manifest_path: args.manifestPath, - script_index: args.scriptIndex, - ...(options.importerUrl === undefined - ? {} - : { importer_url: options.importerUrl }), - ...(options.importMapPath === undefined && - options.importMapBaseUrl === undefined - ? {} - : { - import_map: { - path: options.importMapPath, - base_url: options.importMapBaseUrl, - }, - }), + withCommandCancellation(async (signal) => { + try { + return await logCliCommand( + logger, + CLI_COMMANDS.traceWebModuleImports, + async () => { + const result = await service.trace( + { + manifest_path: args.manifestPath, + script_index: args.scriptIndex, + ...(options.importerUrl === undefined + ? {} + : { importer_url: options.importerUrl }), + ...(options.importMapPath === undefined && + options.importMapBaseUrl === undefined + ? {} + : { + import_map: { + path: options.importMapPath, + base_url: options.importMapBaseUrl, + }, + }), + }, + { signal }, + ); + return result.ok + ? result.value + : projectAnalysisError(result.error); }, - { signal }, ); - return result.ok ? result.value : projectAnalysisError(result.error); - }), - ), + } finally { + await service.close(); + } + }), }); }; diff --git a/src/server/createServer.ts b/src/server/createServer.ts index ac29459f4..8318a2f60 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -216,6 +216,8 @@ export const createServer = ( options.evmInterface ?? createEvmInterfaceService(environment); const webSourceLocation = options.webSourceLocation ?? createWebSourceLocationService(environment); + const webModuleTrace = + options.webModuleTrace ?? createWebModuleTraceService(environment); const javascriptRecovery = options.javascriptRecovery ?? createJavaScriptRecoveryProvider(environment); const artifactResources = new ArtifactResourceScope(); @@ -228,6 +230,7 @@ export const createServer = ( delivery, evmInterface, webSourceLocation, + webModuleTrace, javascriptRecovery, }; const startedAt = new Date().toISOString(); @@ -257,6 +260,7 @@ export const createServer = ( adbDevice.close(), evmInterface.close(), webSourceLocation.close(), + webModuleTrace.close(), javascriptRecovery.close?.() ?? Promise.resolve(), artifactResources.close(), firmwareAnalysis.close(), diff --git a/tests/boundary/mcp/webModuleTraceMcp.test.ts b/tests/boundary/mcp/webModuleTraceMcp.test.ts index 9db421630..03c2cd94b 100644 --- a/tests/boundary/mcp/webModuleTraceMcp.test.ts +++ b/tests/boundary/mcp/webModuleTraceMcp.test.ts @@ -81,3 +81,32 @@ it("publishes valid SDK schemas and records the named module trace without a bin }); expect(invalid.isError).toBe(true); }); + +it("retains the module resolver cleanup owner across failed server shutdown", async () => { + let closes = 0; + const service = new WebModuleTraceService( + { load: () => Promise.resolve(ok(webModuleArtifactsFixture())) }, + { + ...webModuleResolverFixture, + close: async () => { + closes += 1; + if (closes === 1) throw new Error("fixture browser close failed"); + }, + }, + ); + const session = createTestBinarySession(() => { + throw new Error("binary provider must not start"); + }); + const server = createServer( + { kind: "session", session }, + { webModuleTrace: service }, + ); + onTestFinished(async () => { + await server.close(); + await session.close(); + }); + await expect(server.close()).rejects.toBeInstanceOf(AggregateError); + await server.close(); + await server.close(); + expect(closes).toBe(2); +});