diff --git a/docs/electron-observation.md b/docs/electron-observation.md index d0b07dd2f0..8248237d12 100644 --- a/docs/electron-observation.md +++ b/docs/electron-observation.md @@ -33,6 +33,12 @@ encoded path separators, ASAR directories or links, and nonexistent paths are rejected. The endpoint exposes every eligible target and its local path metadata. +Cancellation stops further file authorization after an already-started filesystem +operation settles. ASAR descriptor-close failures remain typed incomplete cleanup +with an exact retryable owner; they are not treated as a missing or unauthorized +page. A later authorization and provider shutdown retry retained descriptors +before reporting their release. + ## Workflow `list_electron_targets` returns every eligible target served by the supplied endpoint in one inline array. diff --git a/docs/javascript-runtime-observation.md b/docs/javascript-runtime-observation.md index 2e41929c64..2cb3cbff1e 100644 --- a/docs/javascript-runtime-observation.md +++ b/docs/javascript-runtime-observation.md @@ -152,6 +152,15 @@ and uncollected scripts, but scripts collected before attachment may be missing. Absence from the observation window never proves that a script or behavior does not occur. +Cancellation stops new target-file authorization after the current filesystem +operation settles. Cancellation diagnostics retain the examined target prefix; +unexamined targets remain unknown. Observation cancellation keeps its original +reason and any completed Evidence. Finalization waits for REA's bounded socket +close (up to two seconds for graceful and forced confirmation). A failed close +retains its exact transport owner and blocks replacement connections until +cleanup succeeds; CLI finalization and MCP shutdown also retry retained cleanup. +Closing REA's connection does not stop the caller-owned runtime. + ## Static correlation Pass the resulting observation Evidence and an diff --git a/src/application/javascript/ElectronObservationService.test.ts b/src/application/javascript/ElectronObservationService.test.ts index 4222e62027..0d055e1f09 100644 --- a/src/application/javascript/ElectronObservationService.test.ts +++ b/src/application/javascript/ElectronObservationService.test.ts @@ -205,3 +205,42 @@ it("retains completed target facts when cancellation arrives before Evidence pub }); } }); + +it.each(["list", "inspect"] as const)( + "preserves a pre-abort reason before Electron %s provider work", + async (kind) => { + const reason = new Error(`stop Electron ${kind}`); + const controller = new AbortController(); + controller.abort(reason); + let providerCalls = 0; + const provider: ElectronObservationPort = { + identity: () => identity, + listTargets: async () => { + providerCalls += 1; + return err(new AnalysisCancelledError("list_electron_targets")); + }, + inspectPage: async () => { + providerCalls += 1; + return err(new AnalysisCancelledError("inspect_electron_page")); + }, + }; + + const result = + kind === "list" + ? await listElectronTargets( + provider, + { cdp_endpoint: input.cdp_endpoint }, + { signal: controller.signal }, + ) + : await inspectElectronPage(provider, input, { + signal: controller.signal, + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + } + expect(providerCalls).toBe(0); + }, +); diff --git a/src/application/javascript/ElectronObservationService.ts b/src/application/javascript/ElectronObservationService.ts index f658bf7ccb..67a0776cb4 100644 --- a/src/application/javascript/ElectronObservationService.ts +++ b/src/application/javascript/ElectronObservationService.ts @@ -29,7 +29,11 @@ export const listElectronTargets = async ( const ready = requireProvider(provider, "list_electron_targets"); if (!ready.ok) return ready; if (options.signal?.aborted) - return err(new AnalysisCancelledError("list_electron_targets")); + return err( + new AnalysisCancelledError("list_electron_targets", { + cause: options.signal.reason, + }), + ); const result = await ready.value.listTargets(input, options); if (!result.ok) return result; const report = electronTargetListSchema.safeParse(result.value); @@ -61,7 +65,11 @@ export const inspectElectronPage = async ( const ready = requireProvider(provider, "inspect_electron_page"); if (!ready.ok) return ready; if (options.signal?.aborted) - return err(new AnalysisCancelledError("inspect_electron_page")); + return err( + new AnalysisCancelledError("inspect_electron_page", { + cause: options.signal.reason, + }), + ); const result = await ready.value.inspectPage(input, options); if (!result.ok) { const partial = result.error.partialObservation; diff --git a/src/application/javascript/JavaScriptRuntimeObservationPort.ts b/src/application/javascript/JavaScriptRuntimeObservationPort.ts index 44aa9afc26..928fe65565 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationPort.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationPort.ts @@ -13,6 +13,8 @@ import type { Result } from "../../domain/result.js"; /** Provider-neutral boundary for passive Node/Electron Inspector observation. */ export interface JavaScriptRuntimeObservationPort { + /** Retry retained Inspector and associated file cleanup during shutdown. */ + close?(): Promise; identity(): ProviderIdentity; listTargets( input: ListJavaScriptRuntimeTargetsInput, diff --git a/src/application/javascript/JavaScriptRuntimeObservationService.test.ts b/src/application/javascript/JavaScriptRuntimeObservationService.test.ts index 770c210e8d..b9351db166 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationService.test.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationService.test.ts @@ -271,3 +271,49 @@ it("reports non-JSON Inspector output before late cancellation", async () => { error: { _tag: "AnalysisOutputError", partialObservation: undefined }, }); }); + +it.each(["list", "observe"] as const)( + "preserves a pre-abort reason before Inspector %s provider work", + async (kind) => { + const reason = new Error(`stop Inspector ${kind}`); + const controller = new AbortController(); + controller.abort(reason); + let providerCalls = 0; + const provider: JavaScriptRuntimeObservationPort = { + identity: () => ({ id: "test", name: "Test", version: "1" }), + listTargets: async () => { + providerCalls += 1; + return err( + new AnalysisCancelledError("list_javascript_runtime_targets"), + ); + }, + observe: async () => { + providerCalls += 1; + return err(new AnalysisCancelledError("observe_javascript_runtime")); + }, + }; + const result = + kind === "list" + ? await listJavaScriptRuntimeTargets( + provider, + { inspector_endpoint: "http://127.0.0.1:9229" }, + { signal: controller.signal }, + ) + : await observeJavaScriptRuntime( + provider, + { + inspector_endpoint: "http://127.0.0.1:9229", + target_id: "target", + observation_ms: 0, + }, + { signal: controller.signal }, + ); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + } + expect(providerCalls).toBe(0); + }, +); diff --git a/src/application/javascript/JavaScriptRuntimeObservationService.ts b/src/application/javascript/JavaScriptRuntimeObservationService.ts index bdb3dcd988..398995f139 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationService.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationService.ts @@ -28,7 +28,11 @@ export const listJavaScriptRuntimeTargets = async ( const ready = requireProvider(provider, "list_javascript_runtime_targets"); if (!ready.ok) return ready; if (options.signal?.aborted) - return err(new AnalysisCancelledError("list_javascript_runtime_targets")); + return err( + new AnalysisCancelledError("list_javascript_runtime_targets", { + cause: options.signal.reason, + }), + ); const result = await ready.value.listTargets(input, options); if (!result.ok) return result; const report = javascriptRuntimeTargetListSchema.safeParse(result.value); @@ -58,7 +62,11 @@ export const observeJavaScriptRuntime = async ( const ready = requireProvider(provider, "observe_javascript_runtime"); if (!ready.ok) return ready; if (options.signal?.aborted) - return err(new AnalysisCancelledError("observe_javascript_runtime")); + return err( + new AnalysisCancelledError("observe_javascript_runtime", { + cause: options.signal.reason, + }), + ); const result = await ready.value.observe(input, options); if (!result.ok) { const partial = result.error.partialObservation; diff --git a/src/browser/CdpElectronInspection.ts b/src/browser/CdpElectronInspection.ts index bb8c2d1f86..b7e0597b5f 100644 --- a/src/browser/CdpElectronInspection.ts +++ b/src/browser/CdpElectronInspection.ts @@ -5,6 +5,7 @@ import type { InspectElectronPageInput, } from "../domain/javascript/electronObservation.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import type { CdpConnection, CdpEvent } from "./CdpConnection.js"; import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; @@ -153,7 +154,11 @@ const authorizeInitialFrames = async ( context.sessionId, context.signal, ); - const frames = await captureFrames(result, state.completeness); + const frames = await captureFrames( + result, + state.completeness, + context.signal, + ); if (frames[0] === undefined) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); return frames; @@ -219,7 +224,7 @@ const assertStableMainFrame = async ( context.sessionId, context.signal, ); - const afterPath = await mainFilePath(result); + const afterPath = await mainFilePath(result, context.signal); if (afterPath === undefined) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); if (state.navigationDuringCapture || afterPath !== expectedPath) @@ -239,6 +244,7 @@ const navigatedFrameId = (event: CdpEvent): string | undefined => { export const captureFrames = async ( result: unknown, completeness: CdpCaptureCompleteness, + signal?: AbortSignal, ): Promise => { const frameTree = recordValue(requiredRecord(result).frameTree); if (frameTree === undefined) @@ -247,13 +253,18 @@ export const captureFrames = async ( for (const tree of walkFrameTrees(frameTree, () => completeness.exclude("frames", "invalid_protocol_value"), )) { + throwIfElectronInspectionCancelled(signal); const frame = recordValue(tree.frame); const frameId = cdpStringValue(frame?.id); if (frame === undefined || frameId === undefined) { completeness.exclude("frames", "invalid_protocol_value"); continue; } - const path = await authorizedElectronFile(cdpStringValue(frame.url) ?? ""); + const path = await authorizedElectronFile( + cdpStringValue(frame.url) ?? "", + signal, + "inspect_electron_page", + ); if (path === undefined) { completeness.exclude("frames", "out_of_target_scope"); continue; @@ -285,8 +296,11 @@ const captureResources = async ( for (const resource of frameTreeResources(root, (count) => completeness.exclude("resources", "invalid_protocol_value", count), )) { + throwIfElectronInspectionCancelled(context.signal); const path = await authorizedElectronFile( cdpStringValue(resource.url) ?? "", + context.signal, + "inspect_electron_page", ); if (path === undefined) { completeness.exclude("resources", "out_of_target_scope"); @@ -336,12 +350,19 @@ const captureDom = async ( const nodes: ElectronPageInspection["dom"]["nodes"] = []; let total = 0; for (const document of snapshotDocuments(result.documents, invalid)) { + throwIfElectronInspectionCancelled(context.signal); const documentUrl = cdpSnapshotString(strings, document.documentURL); if (documentUrl === undefined) { invalid(1); continue; } - if ((await authorizedElectronFile(documentUrl)) === undefined) { + if ( + (await authorizedElectronFile( + documentUrl, + context.signal, + "inspect_electron_page", + )) === undefined + ) { completeness.exclude("dom", "out_of_target_scope"); continue; } @@ -376,10 +397,24 @@ const captureDom = async ( return { total_nodes: total, nodes }; }; -const mainFilePath = async (result: unknown): Promise => { +const mainFilePath = async ( + result: unknown, + signal?: AbortSignal, +): Promise => { const frameTree = recordValue(requiredRecord(result).frameTree); const frame = recordValue(frameTree?.frame); - return await authorizedElectronFile(cdpStringValue(frame?.url) ?? ""); + return await authorizedElectronFile( + cdpStringValue(frame?.url) ?? "", + signal, + "inspect_electron_page", + ); +}; + +const throwIfElectronInspectionCancelled = (signal?: AbortSignal): void => { + if (signal?.aborted === true) + throw new AnalysisCancelledError("inspect_electron_page", { + cause: signal.reason, + }); }; const report = async ( diff --git a/src/browser/CdpElectronProvider.ts b/src/browser/CdpElectronProvider.ts index 8ca468dffe..7b8cb7a67f 100644 --- a/src/browser/CdpElectronProvider.ts +++ b/src/browser/CdpElectronProvider.ts @@ -15,11 +15,11 @@ import { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; -import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; import { err, ok, type Result } from "../domain/result.js"; import { discoverCdpEndpoint, hasCdpTargetWebSocket, + type CdpEndpointDiscovery, type CdpEndpointTarget, } from "./CdpEndpoint.js"; import { @@ -28,7 +28,11 @@ import { type CdpTargetSession, } from "./CdpTargetSession.js"; import { inspectCdpElectronPage } from "./CdpElectronInspection.js"; -import { authorizedElectronFile } from "./ElectronFileScope.js"; +import { + authorizedElectronFile, + mapElectronFileAuthorizationFailure, + retryElectronFileCleanup, +} from "./ElectronFileScope.js"; import { CDP_ELECTRON_PROVIDER_IDENTITY } from "./providerIdentities.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; @@ -41,9 +45,58 @@ import { /** Passive Electron provider for local file pages exposed by loopback CDP. */ export class CdpElectronProvider implements ElectronObservationPort { readonly #owner = new CdpTargetSessionOwner(); + #activeOperations = 0; + #closing = false; + #closePromise: Promise | undefined; + readonly #idleWaiters = new Set<() => void>(); - close(): Promise { - return this.#owner.close(); + async close(): Promise { + if (this.#closePromise === undefined) { + this.#closing = true; + const attempt = this.#closeAfterOperations(); + let tracked: Promise; + tracked = attempt.catch((cause: unknown) => { + if (this.#closePromise === tracked) this.#closePromise = undefined; + throw cause; + }); + this.#closePromise = tracked; + } + await this.#closePromise; + } + + async #closeAfterOperations(): Promise { + while (this.#activeOperations > 0) + await new Promise((resolve) => this.#idleWaiters.add(resolve)); + const outcomes = await Promise.allSettled([ + this.#owner.close(), + retryElectronFileCleanup(), + ]); + const failures = outcomes.flatMap((outcome) => + outcome.status === "rejected" ? [outcome.reason as unknown] : [], + ); + const mapped = failures.map( + (failure) => + mapElectronFileAuthorizationFailure( + failure, + CDP_ELECTRON_PROVIDER_IDENTITY.id, + "inspect_electron_page", + ) ?? failure, + ); + if (mapped.length === 1) throw mapped[0]; + if (mapped.length > 1) { + const typed = mapped.filter( + (failure): failure is AnalysisError => failure instanceof AnalysisError, + ); + throw new ProviderCleanupError( + CDP_ELECTRON_PROVIDER_IDENTITY.id, + typed.flatMap((failure) => failure.cleanupResources), + { reason: "Electron transport and file cleanup remain incomplete" }, + { + operation: "inspect_electron_page", + cause: new AggregateError(mapped), + }, + ).bindCleanupDependencies(typed); + } } identity(): ProviderIdentity { @@ -54,30 +107,58 @@ export class CdpElectronProvider implements ElectronObservationPort { input: ListElectronTargetsInput, options: ExecutionOptions = {}, ): Promise> { + return await this.#withAdmission( + "list_electron_targets", + async () => await this.#listTargets(input, options), + ); + } + + async #listTargets( + input: ListElectronTargetsInput, + options: ExecutionOptions, + ): Promise> { + const allowed: ElectronTargetList["targets"] = []; + let discovery: CdpEndpointDiscovery | undefined; + let unsupportedUrl = 0; + let nonPage = 0; + let unconnectable = 0; + let examined = 0; try { - const discovery = await discoverCdpEndpoint( + discovery = await discoverCdpEndpoint( input.cdp_endpoint, "list_electron_targets", options.signal, ); - const allowed = []; - let unsupportedUrl = 0; - let nonPage = 0; - let unconnectable = 0; + throwIfElectronOperationCancelled( + options.signal, + "list_electron_targets", + ); for (const target of discovery.targets) { + throwIfElectronOperationCancelled( + options.signal, + "list_electron_targets", + ); if (target.type !== "page") { + examined += 1; nonPage += 1; continue; } - const path = await authorizedElectronFile(target.url); + const path = await authorizedElectronFile( + target.url, + options.signal, + "list_electron_targets", + ); if (path === undefined) { + examined += 1; unsupportedUrl += 1; continue; } if (!hasCdpTargetWebSocket(discovery, target)) { + examined += 1; unconnectable += 1; continue; } + examined += 1; allowed.push({ target_id: target.id, type: target.type, @@ -89,6 +170,10 @@ export class CdpElectronProvider implements ElectronObservationPort { allowed.sort((left, right) => compareUnicodeCodePoints(left.target_id, right.target_id), ); + throwIfElectronOperationCancelled( + options.signal, + "list_electron_targets", + ); return ok( electronTargetListSchema.parse({ browser: discovery.version, @@ -113,13 +198,65 @@ export class CdpElectronProvider implements ElectronObservationPort { }), ); } catch (cause: unknown) { - return err(providerError(cause, "list_electron_targets")); + const fileFailure = mapElectronFileAuthorizationFailure( + cause, + CDP_ELECTRON_PROVIDER_IDENTITY.id, + "list_electron_targets", + { + completed_prefix: { + browser_version: discovery?.version ?? null, + examined_targets: examined, + discovered_targets: discovery?.targets.length ?? null, + allowed_targets: allowed, + excluded: { + unsupported_url: unsupportedUrl, + non_page: nonPage, + }, + unconnectable_targets: unconnectable, + coverage: + "partial: target authorization stopped; remaining targets are unknown", + }, + }, + ); + if (fileFailure !== undefined) return err(fileFailure); + return err( + options.signal?.aborted === true + ? new AnalysisCancelledError("list_electron_targets", { + cause: options.signal.reason, + diagnostics: { + completed_prefix: { + browser_version: discovery?.version ?? null, + examined_targets: examined, + discovered_targets: discovery?.targets.length ?? null, + allowed_targets: allowed, + excluded: { + unsupported_url: unsupportedUrl, + non_page: nonPage, + }, + unconnectable_targets: unconnectable, + coverage: + "partial: target authorization stopped; remaining targets are unknown", + }, + }, + }) + : providerError(cause, "list_electron_targets"), + ); } } async inspectPage( input: InspectElectronPageInput, options: ExecutionOptions = {}, + ): Promise> { + return await this.#withAdmission( + "inspect_electron_page", + async () => await this.#inspectPage(input, options), + ); + } + + async #inspectPage( + input: InspectElectronPageInput, + options: ExecutionOptions, ): Promise> { let targetSession: CdpTargetSession | undefined; let cleanupFailure: AnalysisError | undefined; @@ -130,7 +267,11 @@ export class CdpElectronProvider implements ElectronObservationPort { "inspect_electron_page", options.signal, ); - const target = await authorizeTarget(discovery.targets, input.target_id); + const target = await authorizeTarget( + discovery.targets, + input.target_id, + options.signal, + ); targetSession = await openCdpTargetSession( discovery, target, @@ -241,31 +382,79 @@ export class CdpElectronProvider implements ElectronObservationPort { if (finalCancellation !== undefined) return err(finalCancellation); return result; } + + async #withAdmission( + operation: "list_electron_targets" | "inspect_electron_page", + work: () => Promise>, + ): Promise> { + if (this.#closing) + return err( + new ProviderAdapterError(CDP_ELECTRON_PROVIDER_IDENTITY.id, operation, { + diagnostics: { reason: "provider_closed" }, + userMessage: "Electron provider is closed to new observations.", + }), + ); + this.#activeOperations += 1; + try { + return await work(); + } catch (cause: unknown) { + return err(providerError(cause, operation)); + } finally { + this.#activeOperations -= 1; + if (this.#activeOperations === 0) { + for (const resolve of this.#idleWaiters) resolve(); + this.#idleWaiters.clear(); + } + } + } } const authorizeTarget = async ( targets: readonly CdpEndpointTarget[], targetId: string, + signal?: AbortSignal, ): Promise => { + throwIfElectronOperationCancelled(signal, "inspect_electron_page"); const target = targets.find(({ id }) => id === targetId); if (target === undefined) throw new BrowserObservationError("inspect_web_page", "target_not_found"); - if ( - target.type !== "page" || - (await authorizedElectronFile(target.url)) === undefined - ) + if (target.type !== "page") + throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); + const path = await authorizedElectronFile( + target.url, + signal, + "inspect_electron_page", + ); + throwIfElectronOperationCancelled(signal, "inspect_electron_page"); + if (path === undefined) throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); return target; }; +const throwIfElectronOperationCancelled = ( + signal: AbortSignal | undefined, + operation: "list_electron_targets" | "inspect_electron_page", +): void => { + if (signal?.aborted === true) + throw new AnalysisCancelledError(operation, { cause: signal.reason }); +}; + const providerError = ( cause: unknown, - operation: BrowserObservationOperation, -): AnalysisError => - cause instanceof BrowserObservationError && cause.operation !== operation + operation: "list_electron_targets" | "inspect_electron_page", +): AnalysisError => { + const fileFailure = mapElectronFileAuthorizationFailure( + cause, + CDP_ELECTRON_PROVIDER_IDENTITY.id, + operation, + ); + if (fileFailure !== undefined) return fileFailure; + return cause instanceof BrowserObservationError && + cause.operation !== operation ? new BrowserObservationError(operation, cause.reason, { cause }) : cause instanceof AnalysisError ? cause : new ProviderAdapterError(CDP_ELECTRON_PROVIDER_IDENTITY.id, operation, { cause, }); +}; diff --git a/src/browser/CdpElectronScripts.ts b/src/browser/CdpElectronScripts.ts index d648bc0c16..9cab78b58b 100644 --- a/src/browser/CdpElectronScripts.ts +++ b/src/browser/CdpElectronScripts.ts @@ -40,7 +40,12 @@ export const captureElectronScripts = async ( : undefined; try { for (const script of input.scripts) { - const path = await authorizedElectronFile(script.rawUrl); + throwIfCallerCancelled(input.signal); + const path = await authorizedElectronFile( + script.rawUrl, + input.signal, + "inspect_electron_page", + ); if (path === undefined) { input.completeness.exclude("scripts", "out_of_target_scope"); continue; diff --git a/src/browser/CdpElectronWorkers.ts b/src/browser/CdpElectronWorkers.ts index 06a26eaf3a..b6e17fdb7a 100644 --- a/src/browser/CdpElectronWorkers.ts +++ b/src/browser/CdpElectronWorkers.ts @@ -9,6 +9,7 @@ import type { CdpEndpointTarget } from "./CdpEndpoint.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; import { authorizedElectronFile } from "./ElectronFileScope.js"; import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; /** Inventory local worker targets without attaching to them. */ export const captureElectronWorkers = async (input: { @@ -42,6 +43,7 @@ export const captureElectronWorkers = async (input: { return []; } for (const entry of targetInfos) { + throwIfElectronWorkerCaptureCancelled(input.signal); const target = recordValue(entry); if (target === undefined) { input.completeness.exclude("workers", "invalid_protocol_value"); @@ -58,7 +60,11 @@ export const captureElectronWorkers = async (input: { input.completeness.exclude("workers", "out_of_target_scope"); continue; } - const path = await authorizedElectronFile(cdpStringValue(target.url) ?? ""); + const path = await authorizedElectronFile( + cdpStringValue(target.url) ?? "", + input.signal, + "inspect_electron_page", + ); if (path === undefined) { input.completeness.exclude("workers", "out_of_target_scope"); continue; @@ -86,6 +92,13 @@ export const captureElectronWorkers = async (input: { ); }; +const throwIfElectronWorkerCaptureCancelled = (signal?: AbortSignal): void => { + if (signal?.aborted === true) + throw new AnalysisCancelledError("inspect_electron_page", { + cause: signal.reason, + }); +}; + const boundedTargetField = (value: unknown): string | null => { const field = cdpStringValue(value); return field === undefined || field === "" ? null : field; diff --git a/src/browser/ElectronFileScope.cleanup.test.ts b/src/browser/ElectronFileScope.cleanup.test.ts new file mode 100644 index 0000000000..bb2a7b8016 --- /dev/null +++ b/src/browser/ElectronFileScope.cleanup.test.ts @@ -0,0 +1,297 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { afterEach, expect, it, vi } from "vitest"; + +const handleState = vi.hoisted(() => ({ + archive: undefined as string | undefined, + failCloseCount: 0, + closeController: undefined as AbortController | undefined, + closeReason: undefined as unknown, + readController: undefined as AbortController | undefined, + readReason: undefined as unknown, + statController: undefined as AbortController | undefined, + statReason: undefined as unknown, + readFailure: undefined as Error | undefined, + events: [] as string[], +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + open: async (...args: Parameters) => { + const handle = await actual.open(...args); + if (String(args[0]) !== handleState.archive) return handle; + handleState.events.push("open"); + return new Proxy(handle, { + get(target, property, receiver) { + if (property === "stat" && handleState.statController !== undefined) + return async () => { + const result = await target.stat(); + handleState.statController?.abort(handleState.statReason); + handleState.statController = undefined; + return result; + }; + if (property === "read" && handleState.readFailure !== undefined) + return async () => { + const failure = handleState.readFailure; + handleState.readFailure = undefined; + throw failure; + }; + if (property === "read" && handleState.readController !== undefined) + return async (...args: Parameters) => { + const response = await target.read(...args); + handleState.readController?.abort(handleState.readReason); + handleState.readController = undefined; + return response; + }; + if (property === "close") + return async () => { + handleState.events.push("close"); + if (handleState.closeController !== undefined) { + handleState.closeController.abort(handleState.closeReason); + handleState.closeController = undefined; + } + if (handleState.failCloseCount > 0) { + handleState.failCloseCount -= 1; + throw new Error("controlled ASAR descriptor close failure"); + } + return await target.close(); + }; + return Reflect.get(target, property, receiver) as unknown; + }, + }); + }, + }; +}); + +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { CdpElectronProvider } from "./CdpElectronProvider.js"; +import { + authorizedElectronFile, + mapElectronFileAuthorizationFailure, + retryElectronFileCleanup, +} from "./ElectronFileScope.js"; + +const roots: string[] = []; + +afterEach(async () => { + await retryElectronFileCleanup(); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); + handleState.archive = undefined; + handleState.failCloseCount = 0; + handleState.closeController = undefined; + handleState.closeReason = undefined; + handleState.readController = undefined; + handleState.readReason = undefined; + handleState.statController = undefined; + handleState.statReason = undefined; + handleState.readFailure = undefined; + handleState.events = []; +}); + +const asarPage = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), "rea-electron-file-scope-")); + roots.push(root); + const archive = join(root, "app.asar"); + const json = Buffer.from( + JSON.stringify({ files: { "index.html": { size: 1, offset: "0" } } }), + ); + const archiveBytes = Buffer.alloc(16 + json.length + 1); + const headerBytes = 8 + json.length; + archiveBytes.writeUInt32LE(4, 0); + archiveBytes.writeUInt32LE(headerBytes, 4); + archiveBytes.writeUInt32LE(headerBytes - 4, 8); + archiveBytes.writeUInt32LE(json.length, 12); + json.copy(archiveBytes, 16); + archiveBytes[16 + json.length] = 0; + await writeFile(archive, archiveBytes); + handleState.archive = archive; + return pathToFileURL(join(archive, "index.html")).href; +}; + +it("retains a failed ASAR close through denial and retries before reopening", async () => { + const location = await asarPage(); + const missing = pathToFileURL( + join(dirname(fileURLToPath(location)), "missing.js"), + ).href; + handleState.failCloseCount = 1; + + let failure: unknown; + try { + await authorizedElectronFile(missing, undefined, "list_electron_targets"); + } catch (cause: unknown) { + failure = cause; + } + + if (!(failure instanceof ArtifactReaderFailure)) + throw new Error("Expected an ASAR descriptor cleanup failure"); + expect(failure.cleanup?.resources).toEqual([handleState.archive]); + const mapped = mapElectronFileAuthorizationFailure( + failure, + "rea-cdp-electron", + "list_electron_targets", + ); + expect(mapped?.cleanupIncomplete).toBe(true); + expect(mapped?.cleanupPrimary).toBeUndefined(); + + await expect( + authorizedElectronFile(missing, undefined, "list_electron_targets"), + ).resolves.toBeUndefined(); + expect(handleState.events).toEqual([ + "open", + "close", + "close", + "open", + "close", + ]); + expect(mapped?.cleanupIncomplete).toBe(false); +}); + +it("keeps cancellation primary when ASAR close fails, then retries the same handle", async () => { + const location = await asarPage(); + const controller = new AbortController(); + const reason = new Error("cancel during ASAR close"); + handleState.closeController = controller; + handleState.closeReason = reason; + handleState.failCloseCount = 1; + + let failure: unknown; + try { + await authorizedElectronFile( + location, + controller.signal, + "inspect_electron_page", + ); + } catch (cause: unknown) { + failure = cause; + } + expect(failure).toBeInstanceOf(ArtifactReaderFailure); + const mapped = mapElectronFileAuthorizationFailure( + failure, + "rea-cdp-electron", + "inspect_electron_page", + ); + expect(mapped?.cleanupIncomplete).toBe(true); + expect(mapped?.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); + expect(mapped?.cleanupPrimary?.cause).toBe(reason); + + await expect(authorizedElectronFile(location)).resolves.toBe( + `${handleState.archive}/index.html`, + ); + expect(handleState.events).toEqual([ + "open", + "close", + "close", + "open", + "close", + ]); + expect(mapped?.cleanupIncomplete).toBe(false); +}); + +it("keeps an earlier read failure primary over a later abort and close failure", async () => { + const location = await asarPage(); + const controller = new AbortController(); + const reason = new Error("late cancellation after read failure"); + const readFailure = new Error("controlled ASAR header read failure"); + handleState.closeController = controller; + handleState.closeReason = reason; + handleState.failCloseCount = 1; + handleState.readFailure = readFailure; + + let failure: unknown; + try { + await authorizedElectronFile(location, controller.signal); + } catch (cause: unknown) { + failure = cause; + } + const mapped = mapElectronFileAuthorizationFailure( + failure, + "rea-cdp-electron", + "inspect_electron_page", + ); + expect(mapped?.cleanupIncomplete).toBe(true); + expect(mapped?.cleanupPrimary).not.toBeInstanceOf(AnalysisCancelledError); + expect(mapped?.cleanupPrimary?.cause).toBe(readFailure); + + await expect(authorizedElectronFile(location)).resolves.toBe( + `${handleState.archive}/index.html`, + ); + expect(mapped?.cleanupIncomplete).toBe(false); +}); + +it("preserves an arbitrary cancellation reason observed during ASAR read", async () => { + const location = await asarPage(); + const controller = new AbortController(); + const reason = { source: "caller", token: 17 }; + handleState.readController = controller; + handleState.readReason = reason; + handleState.failCloseCount = 1; + + let failure: unknown; + try { + await authorizedElectronFile(location, controller.signal); + } catch (cause: unknown) { + failure = cause; + } + const mapped = mapElectronFileAuthorizationFailure( + failure, + "rea-cdp-electron", + "inspect_electron_page", + ); + expect(mapped?.cleanupIncomplete).toBe(true); + expect(mapped?.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); + expect(mapped?.cleanupPrimary?.cause).toBe(reason); + + await expect(authorizedElectronFile(location)).resolves.toBe( + `${handleState.archive}/index.html`, + ); + expect(mapped?.cleanupIncomplete).toBe(false); +}); + +it("preserves cancellation observed after ASAR stat when final close fails", async () => { + const location = await asarPage(); + const controller = new AbortController(); + const reason = { source: "caller", stage: "descriptor-stat" }; + handleState.statController = controller; + handleState.statReason = reason; + handleState.failCloseCount = 1; + + let failure: unknown; + try { + await authorizedElectronFile(location, controller.signal); + } catch (cause: unknown) { + failure = cause; + } + const mapped = mapElectronFileAuthorizationFailure( + failure, + "rea-cdp-electron", + "inspect_electron_page", + ); + expect(mapped?.cleanupIncomplete).toBe(true); + expect(mapped?.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); + expect(mapped?.cleanupPrimary?.cause).toBe(reason); + + await expect(authorizedElectronFile(location)).resolves.toBe( + `${handleState.archive}/index.html`, + ); + expect(mapped?.cleanupIncomplete).toBe(false); +}); + +it("retries a failed ASAR descriptor lease during provider shutdown", async () => { + const location = await asarPage(); + handleState.failCloseCount = 1; + await expect(authorizedElectronFile(location)).rejects.toBeInstanceOf( + ArtifactReaderFailure, + ); + + await new CdpElectronProvider().close(); + + expect(handleState.events).toEqual(["open", "close", "close"]); +}); diff --git a/src/browser/ElectronFileScope.ts b/src/browser/ElectronFileScope.ts index 74062fd8d3..6dd3adba9c 100644 --- a/src/browser/ElectronFileScope.ts +++ b/src/browser/ElectronFileScope.ts @@ -2,16 +2,117 @@ import type { Stats } from "node:fs"; import { open, realpath, stat } from "node:fs/promises"; import { isAbsolute, join, sep } from "node:path"; import { fileURLToPath } from "node:url"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import type { JsonValue } from "../domain/jsonValue.js"; +import { + ArtifactFileCleanupFailure, + assertArtifactReadActive, + bindArtifactFileCleanup, + withArtifactFileHandle, +} from "../artifacts/ArtifactFileOperation.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { admitAsarHeader, MAX_ASAR_HEADER_BYTES, } from "../artifacts/AsarHeader.js"; +type ElectronFileOperation = + | "inspect_electron_page" + | "list_electron_targets" + | "observe_javascript_runtime" + | "list_javascript_runtime_targets"; + +// ASAR descriptor ownership outlives an individual provider call; every later +// authorization retries retained close leases before admitting new work. +const resources = new ArtifactResourceScope(); + /** Resolve one local file URL while rejecting remote hosts and encoded separators. */ export const authorizedElectronFile = async ( value: string, + signal?: AbortSignal, + operation: ElectronFileOperation = "inspect_electron_page", ): Promise => { + try { + return await resources.run( + async () => await resolveAuthorizedElectronFile(value, signal), + ); + } catch (cause: unknown) { + if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) + throw cause; + if (signal?.aborted === true) + throw new AnalysisCancelledError(operation, { cause: signal.reason }); + throw cause; + } +}; + +/** Retry retained ASAR descriptor cleanup during provider shutdown. */ +export const retryElectronFileCleanup = async (): Promise => + await resources.retryCleanup(); + +/** Translate a retained ASAR descriptor failure at its provider boundary. */ +export const mapElectronFileAuthorizationFailure = ( + cause: unknown, + providerId: string, + operation: ElectronFileOperation, + diagnostics?: Readonly>, +): AnalysisError | undefined => { + if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) + return undefined; + const physical = + cause instanceof ArtifactFileCleanupFailure + ? cause.fileCleanupFailure + : undefined; + const physicalPrimary = + physical?.outcome.kind === "failed" ? physical.outcome.cause : undefined; + const cancellationReason = + physical?.completionCancellation === undefined + ? physicalPrimary instanceof ArtifactReaderFailure && + physicalPrimary.reason === "cancelled" + ? physicalPrimary.cancellation === undefined + ? physicalPrimary.cause + : physicalPrimary.cancellation.reason + : undefined + : physical.completionCancellation.reason; + const hasCancellation = + physical?.completionCancellation !== undefined || + (physicalPrimary instanceof ArtifactReaderFailure && + physicalPrimary.reason === "cancelled"); + const primary = hasCancellation + ? new AnalysisCancelledError(operation, { + cause: cancellationReason, + ...(diagnostics === undefined ? {} : { diagnostics }), + }) + : physicalPrimary instanceof AnalysisError + ? physicalPrimary + : physicalPrimary === undefined + ? undefined + : new ProviderAdapterError(providerId, operation, { + cause: physicalPrimary, + }); + const cleanup = new ProviderCleanupError( + providerId, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { operation, cause }, + ); + bindArtifactFileCleanup(cause, cleanup); + if (primary === undefined) return cleanup; + const composed = analysisErrorWithCleanupFailure(primary, cleanup, operation); + bindArtifactFileCleanup(cause, composed); + return composed; +}; + +const resolveAuthorizedElectronFile = async ( + value: string, + signal?: AbortSignal, +): Promise => { + throwIfAborted(signal); if (/%(?:2f|5c)/iu.test(value)) return undefined; let url: URL; try { @@ -38,13 +139,21 @@ export const authorizedElectronFile = async ( } if (!isAbsolute(path) || path.includes("\0")) return undefined; try { - if ((await stat(path)).isFile()) return await realpath(path); + const metadata = await stat(path); + throwIfAborted(signal); + if (metadata.isFile()) { + const canonical = await realpath(path); + throwIfAborted(signal); + return canonical; + } } catch (cause: unknown) { + throwIfAborted(signal); // Electron serves packaged pages from inside app.asar, which the host // filesystem cannot stat; resolve those through the archive header below. void cause; } - return await authorizedAsarMember(path); + throwIfAborted(signal); + return await authorizedAsarMember(path, signal); }; /** @@ -54,9 +163,11 @@ export const authorizedElectronFile = async ( */ const authorizedAsarMember = async ( path: string, + signal?: AbortSignal, ): Promise => { const segments = path.split(sep); for (let index = 1; index < segments.length - 1; index += 1) { + throwIfAborted(signal); if (!(segments[index] ?? "").toLowerCase().endsWith(".asar")) continue; const archive = segments.slice(0, index + 1).join(sep); const member = segments.slice(index + 1); @@ -64,13 +175,20 @@ const authorizedAsarMember = async ( return undefined; try { const metadata = await stat(archive); + throwIfAborted(signal); if (!metadata.isFile()) continue; const canonical = await realpath(archive); - const files = await asarFiles(canonical, metadata); + throwIfAborted(signal); + const files = await asarFiles(canonical, metadata, signal, resources); + throwIfAborted(signal); return isAsarRegularFile(files, member) ? join(canonical, ...member) : undefined; } catch (cause: unknown) { + if (cause instanceof ArtifactFileCleanupFailure) throw cause; + if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) + throw cause; + throwIfAborted(signal); // An unreadable or malformed archive cannot authorize its members. void cause; return undefined; @@ -98,44 +216,62 @@ const archiveIdentity = (metadata: ArchiveIdentity): string => const asarFiles = async ( archive: string, metadata: ArchiveIdentity, + signal?: AbortSignal, + resources?: ArtifactResourceScope, ): Promise => { + throwIfAborted(signal); const identity = archiveIdentity(metadata); const cached = headerCache.get(archive); if (cached?.identity === identity) return cached.files; const handle = await open(archive, "r"); - let files: unknown; - let jsonBytes: number; - try { - const opened = await handle.stat(); - if (!opened.isFile() || archiveIdentity(opened) !== identity) - throw new Error("ASAR identity changed before header read"); - const admitted = await admitAsarHeader(handle); - jsonBytes = admitted.jsonBytes; - const json = Buffer.alloc(jsonBytes); - let offset = 0; - while (offset < json.length) { - const part = await handle.read( - json, - offset, - json.length - offset, - 16 + offset, + const admitted = await withArtifactFileHandle( + archive, + handle, + resources, + signal, + async (file) => { + let jsonBytes: number; + throwIfAborted(signal); + const opened = await file.stat(); + throwIfAborted(signal); + if (!opened.isFile() || archiveIdentity(opened) !== identity) + throw new Error("ASAR identity changed before header read"); + const header = await admitAsarHeader(file); + throwIfAborted(signal); + jsonBytes = header.jsonBytes; + const json = Buffer.alloc(jsonBytes); + let offset = 0; + while (offset < json.length) { + throwIfAborted(signal); + const part = await file.read( + json, + offset, + json.length - offset, + 16 + offset, + ); + throwIfAborted(signal); + if (part.bytesRead === 0) throw new Error("Truncated ASAR JSON header"); + offset += part.bytesRead; + } + const afterRead = await file.stat(); + throwIfAborted(signal); + if (archiveIdentity(afterRead) !== identity) + throw new Error("ASAR identity changed during header read"); + const parsed: unknown = JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(json), ); - if (part.bytesRead === 0) throw new Error("Truncated ASAR JSON header"); - offset += part.bytesRead; - } - if (archiveIdentity(await handle.stat()) !== identity) - throw new Error("ASAR identity changed during header read"); - const parsed: unknown = JSON.parse( - new TextDecoder("utf-8", { fatal: true }).decode(json), - ); - if (!isRecord(parsed) || !isRecord(parsed.files)) - throw new Error("ASAR header has no file tree"); - files = parsed.files; - } finally { - await handle.close(); - } + if (!isRecord(parsed) || !isRecord(parsed.files)) + throw new Error("ASAR header has no file tree"); + return { files: parsed.files, jsonBytes }; + }, + ); + throwIfAborted(signal); headerCache.delete(archive); - headerCache.set(archive, { identity, files, bytes: jsonBytes }); + headerCache.set(archive, { + identity, + files: admitted.files, + bytes: admitted.jsonBytes, + }); const retainedBytes = () => [...headerCache.values()].reduce((sum, entry) => sum + entry.bytes, 0); while ( @@ -143,7 +279,7 @@ const asarFiles = async ( retainedBytes() > MAX_ASAR_HEADER_BYTES ) headerCache.delete(headerCache.keys().next().value ?? archive); - return files; + return admitted.files; }; const isAsarRegularFile = ( @@ -166,3 +302,7 @@ const isAsarRegularFile = ( const isRecord = (value: unknown): value is Readonly> => typeof value === "object" && value !== null && !Array.isArray(value); + +const throwIfAborted = (signal?: AbortSignal): void => { + assertArtifactReadActive(signal); +}; diff --git a/src/cliJavaScriptRuntimeCommands.ts b/src/cliJavaScriptRuntimeCommands.ts index ee8d504428..49e4907b07 100644 --- a/src/cliJavaScriptRuntimeCommands.ts +++ b/src/cliJavaScriptRuntimeCommands.ts @@ -14,6 +14,7 @@ import { } from "./domain/javascript/javascriptRuntimeObservation.js"; import { analysisInputErrorFromIssues } from "./domain/inputIssueProjection.js"; import { projectAnalysisError } from "./domain/analysisErrorProjection.js"; +import { finishAnalysisCommand } from "./cli/finishAnalysisCommand.js"; import type { JsonValue } from "./domain/jsonValue.js"; import type { Logger } from "pino"; import { withCommandCancellation } from "./cli/commandCancellation.js"; @@ -62,12 +63,17 @@ export const registerJavaScriptRuntimeObservationCommands = ( input, ), ); - const result = await listJavaScriptRuntimeTargets( - context.provider, - parsed.data, - { signal }, + return await finishAnalysisCommand( + () => + listJavaScriptRuntimeTargets(context.provider, parsed.data, { + signal, + }), + { close: () => context.provider.close?.() ?? Promise.resolve() }, + context.provider.identity().id, + "list_javascript_runtime_targets", + signal, + "V8 Inspector resources", ); - return result.ok ? result.value : cliError(result.error); }, ), ), @@ -104,12 +110,17 @@ export const registerJavaScriptRuntimeObservationCommands = ( input, ), ); - const result = await observeJavaScriptRuntime( - context.provider, - parsed.data, - { signal }, + return await finishAnalysisCommand( + () => + observeJavaScriptRuntime(context.provider, parsed.data, { + signal, + }), + { close: () => context.provider.close?.() ?? Promise.resolve() }, + context.provider.identity().id, + "observe_javascript_runtime", + signal, + "V8 Inspector resources", ); - return result.ok ? result.value : cliError(result.error); }, ), ), diff --git a/src/inspector/JavaScriptRuntimeScope.cancellation.test.ts b/src/inspector/JavaScriptRuntimeScope.cancellation.test.ts new file mode 100644 index 0000000000..4dd0057176 --- /dev/null +++ b/src/inspector/JavaScriptRuntimeScope.cancellation.test.ts @@ -0,0 +1,290 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { afterEach, expect, it, vi } from "vitest"; + +const fileAccess = vi.hoisted(() => ({ + path: undefined as string | undefined, + extraPath: undefined as string | undefined, + controller: undefined as AbortController | undefined, + reason: undefined as unknown, + abortOnStatCall: undefined as number | undefined, + statCalls: 0, + afterAuthorization: undefined as (() => void) | undefined, + discoveryGate: undefined as + | { readonly started: () => void; readonly wait: Promise } + | undefined, + calls: [] as string[], +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + stat: async (...args: Parameters) => { + const result = await actual.stat(...args); + if ( + String(args[0]) === fileAccess.path || + String(args[0]) === fileAccess.extraPath + ) { + fileAccess.calls.push("stat"); + fileAccess.statCalls += 1; + if (fileAccess.abortOnStatCall === fileAccess.statCalls) + fileAccess.controller?.abort(fileAccess.reason); + } + return result; + }, + realpath: async (...args: Parameters) => { + if ( + String(args[0]) === fileAccess.path || + String(args[0]) === fileAccess.extraPath + ) + fileAccess.calls.push("realpath"); + return await actual.realpath(...args); + }, + }; +}); + +vi.mock("../browser/CdpEndpoint.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + discoverCdpEndpoint: async ( + ...args: Parameters + ) => { + const gate = fileAccess.discoveryGate; + if (gate !== undefined) { + fileAccess.discoveryGate = undefined; + gate.started(); + await gate.wait; + } + return await actual.discoverCdpEndpoint(...args); + }, + hasCdpTargetWebSocket: ( + ...args: Parameters + ) => { + const available = actual.hasCdpTargetWebSocket(...args); + fileAccess.afterAuthorization?.(); + fileAccess.afterAuthorization = undefined; + return available; + }, + }; +}); + +import { CdpElectronProvider } from "../browser/CdpElectronProvider.js"; +import { authorizedElectronFile } from "../browser/ElectronFileScope.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { listElectronTargetsInputSchema } from "../domain/javascript/electronObservation.js"; +import { V8InspectorProvider } from "./V8InspectorProvider.js"; +import { startFakeCdpBrowser } from "../../tests/fixtures/fakeCdpBrowser.js"; +import { startFakeV8Inspector } from "../../tests/fixtures/inspector/fakeV8Inspector.js"; + +const roots: string[] = []; +const fixtures: { close(): Promise }[] = []; + +const record = (value: unknown): Record | undefined => + typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : undefined; + +afterEach(async () => { + fileAccess.path = undefined; + fileAccess.extraPath = undefined; + fileAccess.controller = undefined; + fileAccess.reason = undefined; + fileAccess.abortOnStatCall = undefined; + fileAccess.statCalls = 0; + fileAccess.afterAuthorization = undefined; + fileAccess.discoveryGate = undefined; + fileAccess.calls = []; + await Promise.all(fixtures.splice(0).map((fixture) => fixture.close())); + await Promise.all( + roots.splice(0).map((root) => rm(root, { recursive: true, force: true })), + ); +}); + +const targetFile = async (): Promise => { + const root = await mkdtemp(join(tmpdir(), "rea-inspector-cancel-")); + roots.push(root); + const path = join(root, "entry.js"); + await writeFile(path, "// inert target file\n"); + fileAccess.path = path; + return path; +}; + +it("stops Inspector target authorization after the current stat settles", async () => { + const path = await targetFile(); + const extraPath = join(roots[0]!, "second.js"); + await writeFile(extraPath, "// second inert target file\n"); + fileAccess.extraPath = extraPath; + const fake = await startFakeV8Inspector({ + targetUrl: pathToFileURL(path).href, + additionalTargetUrl: pathToFileURL(extraPath).href, + additionalTargetCount: 1, + }); + fixtures.push(fake); + const controller = new AbortController(); + const reason = new Error("cancel after target stat"); + fileAccess.reason = reason; + fileAccess.controller = controller; + fileAccess.abortOnStatCall = 2; + + const result = await new V8InspectorProvider().listTargets( + { inspector_endpoint: fake.endpoint }, + { signal: controller.signal }, + ); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected Inspector listing cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.diagnostics).toMatchObject({ + completed_target_inventory: { + complete: false, + targets_examined: 1, + limitations: expect.arrayContaining([ + "Cancellation stopped target authorization; unexamined targets are unknown, not excluded.", + ]), + }, + }); + const inventory = record( + record(result.error.diagnostics)?.completed_target_inventory, + ); + expect(inventory?.targets_discovered).toBeGreaterThan(0); + expect(inventory?.targets).toHaveLength(1); + expect(inventory?.targets_discovered).toBe(3); + expect(fileAccess.calls).toEqual(["stat", "realpath", "stat"]); +}); + +it("stops Electron target authorization after the current stat settles", async () => { + const path = await targetFile(); + const fake = await startFakeCdpBrowser({ + electronFileUrl: pathToFileURL(path).href, + additionalElectronPageCount: 2, + }); + fixtures.push(fake); + const controller = new AbortController(); + const reason = new Error("cancel after Electron target stat"); + fileAccess.reason = reason; + fileAccess.controller = controller; + fileAccess.abortOnStatCall = 1; + + const result = await new CdpElectronProvider().listTargets( + listElectronTargetsInputSchema.parse({ cdp_endpoint: fake.endpoint }), + { signal: controller.signal }, + ); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected Electron listing cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.diagnostics).toMatchObject({ + completed_prefix: { + coverage: + "partial: target authorization stopped; remaining targets are unknown", + }, + }); + const prefix = record(record(result.error.diagnostics)?.completed_prefix); + const examined = prefix?.examined_targets; + const discovered = prefix?.discovered_targets; + expect(typeof examined).toBe("number"); + expect(typeof discovered).toBe("number"); + if (typeof examined !== "number" || typeof discovered !== "number") + throw new Error("Expected target-prefix coverage diagnostics"); + expect(examined).toBeGreaterThan(0); + expect(discovered).toBeGreaterThan(examined); + expect(fileAccess.calls).toEqual(["stat"]); +}); + +it("retains a target whose authorization completes before the next abort check", async () => { + const path = await targetFile(); + const fake = await startFakeCdpBrowser({ + electronFileUrl: pathToFileURL(path).href, + additionalElectronPageCount: 2, + }); + fixtures.push(fake); + const controller = new AbortController(); + const reason = new Error("cancel after complete target authorization"); + fileAccess.afterAuthorization = () => controller.abort(reason); + + const result = await new CdpElectronProvider().listTargets( + listElectronTargetsInputSchema.parse({ cdp_endpoint: fake.endpoint }), + { signal: controller.signal }, + ); + + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected Electron listing cancellation"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + const prefix = record(record(result.error.diagnostics)?.completed_prefix); + const allowed = prefix?.allowed_targets; + expect(Array.isArray(allowed)).toBe(true); + if (!Array.isArray(allowed)) + throw new Error("Expected completed target authorization fact"); + expect(allowed).toHaveLength(1); + expect(fileAccess.afterAuthorization).toBeUndefined(); +}); + +it("drains pending Electron discovery before shutdown cleanup completes", async () => { + const path = await targetFile(); + const fake = await startFakeCdpBrowser({ + electronFileUrl: pathToFileURL(path).href, + }); + fixtures.push(fake); + let markStarted: () => void = () => undefined; + let releaseDiscovery: () => void = () => undefined; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const discoveryWait = new Promise((resolve) => { + releaseDiscovery = resolve; + }); + fileAccess.discoveryGate = { started: markStarted, wait: discoveryWait }; + + const provider = new CdpElectronProvider(); + const listing = provider.listTargets( + listElectronTargetsInputSchema.parse({ cdp_endpoint: fake.endpoint }), + ); + await started; + let closed = false; + const closing = provider.close().then(() => { + closed = true; + }); + const closedBeforeDiscovery = await Promise.race([ + closing.then(() => true), + new Promise((resolve) => setImmediate(() => resolve(false))), + ]); + expect(closedBeforeDiscovery).toBe(false); + expect(closed).toBe(false); + releaseDiscovery(); + + const [listed] = await Promise.all([listing, closing]); + expect(closed).toBe(true); + expect(listed.ok).toBe(true); + expect(fileAccess.calls).toContain("stat"); + const rejected = await provider.listTargets( + listElectronTargetsInputSchema.parse({ cdp_endpoint: fake.endpoint }), + ); + expect(rejected.ok).toBe(false); + if (rejected.ok) + throw new Error("Expected closed provider admission failure"); + expect(rejected.error.cleanupIncomplete).toBe(false); + expect(rejected.error.cleanupResources).toEqual([]); +}); + +it("keeps ordinary and malformed file-location authorization unchanged", async () => { + const path = await targetFile(); + + await expect(authorizedElectronFile(pathToFileURL(path).href)).resolves.toBe( + path, + ); + await expect( + authorizedElectronFile("file://remote.example/share/entry.js"), + ).resolves.toBeUndefined(); + await expect( + authorizedElectronFile("file:///tmp/encoded%2fseparator.js"), + ).resolves.toBeUndefined(); +}); diff --git a/src/inspector/JavaScriptRuntimeScope.ts b/src/inspector/JavaScriptRuntimeScope.ts index db8c13ec05..a91041bf3d 100644 --- a/src/inspector/JavaScriptRuntimeScope.ts +++ b/src/inspector/JavaScriptRuntimeScope.ts @@ -22,9 +22,12 @@ export type RuntimeLocationDecision = /** Throw the operation's typed cancellation error whenever its signal is aborted. */ export const throwIfRuntimeObservationCancelled = ( signal?: AbortSignal, + operation: + | "observe_javascript_runtime" + | "list_javascript_runtime_targets" = "observe_javascript_runtime", ): void => { if (signal?.aborted === true) - throw new AnalysisCancelledError("observe_javascript_runtime"); + throw new AnalysisCancelledError(operation, { cause: signal.reason }); }; /** @@ -45,11 +48,14 @@ export const inspectorExclusionKey = ( export const authorizeRuntimeLocation = async ( value: string, signal?: AbortSignal, + operation: + | "observe_javascript_runtime" + | "list_javascript_runtime_targets" = "observe_javascript_runtime", ): Promise => { - throwIfRuntimeObservationCancelled(signal); + throwIfRuntimeObservationCancelled(signal, operation); if (value.startsWith("file:")) { - const filePath = await authorizedElectronFile(value); - throwIfRuntimeObservationCancelled(signal); + const filePath = await authorizedElectronFile(value, signal, operation); + throwIfRuntimeObservationCancelled(signal, operation); return filePath === undefined ? { allowed: false, reason: "not_approved" } : { allowed: true, location: { kind: "file", file_path: filePath } }; @@ -93,6 +99,10 @@ export const projectReportedRuntimeUrl = ( export const authorizeRuntimeTargetLocation = async ( value: string, context: { readonly type: string; readonly product: string }, + signal?: AbortSignal, + operation: + | "observe_javascript_runtime" + | "list_javascript_runtime_targets" = "list_javascript_runtime_targets", ): Promise< | { readonly allowed: true; @@ -100,15 +110,16 @@ export const authorizeRuntimeTargetLocation = async ( } | { readonly allowed: false; readonly reason: BrowserExclusionReason } > => { + throwIfRuntimeObservationCancelled(signal, operation); if ( context.type !== "node" || !/^node\.js\/v/iu.test(context.product) || !value.startsWith("file://") ) - return authorizeRuntimeLocation(value); + return authorizeRuntimeLocation(value, signal, operation); const reportedPath = value.slice("file://".length); if (!reportedPath.startsWith("/") && !/^[a-z]:[/\\_]/iu.test(reportedPath)) - return authorizeRuntimeLocation(value); + return authorizeRuntimeLocation(value, signal, operation); // Node concatenates the pathname, then replaces quotes and backslashes with // underscores. A remaining underscore cannot prove which pathname was used. @@ -116,6 +127,8 @@ export const authorizeRuntimeTargetLocation = async ( if (reportedPath.startsWith("/")) { const decision = await authorizeRuntimeLocation( pathToFileURL(reportedPath).href, + signal, + operation, ); if (decision.allowed) return decision; } diff --git a/src/inspector/V8InspectorProvider.cleanup.test.ts b/src/inspector/V8InspectorProvider.cleanup.test.ts deleted file mode 100644 index ac3f429c71..0000000000 --- a/src/inspector/V8InspectorProvider.cleanup.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { expect, it } from "vitest"; - -import { - closeInspectorConnection, - inspectorCleanupError, -} from "./V8InspectorProvider.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; -import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js"; -import { javascriptRuntimeObservationSchema } from "../domain/javascript/javascriptRuntimeObservation.js"; - -it("returns promptly on cancellation while a V8 socket close is hung", async () => { - const controller = new AbortController(); - let closeStarted = false; - const closing = closeInspectorConnection( - { - close: () => { - closeStarted = true; - return new Promise(() => undefined); - }, - }, - controller.signal, - ); - - controller.abort(); - expect(closeStarted).toBe(true); - await expect(closing).resolves.toBeUndefined(); -}); - -it("maps a rejected close to incomplete cleanup and preserves the primary failure", () => { - const primary = new Error("observation failed"); - const cleanup = new Error("socket close failed"); - const error = inspectorCleanupError(primary, cleanup, true); - - expect(error.reason).toBe("cleanup_failed"); - expect(error.cleanupIncomplete).toBe(true); - expect(error.cleanupResources).toEqual(["browser_transport"]); - expect(error.cause).toBeInstanceOf(AggregateError); - expect(error.cause).toMatchObject({ errors: [primary, cleanup] }); -}); - -it("projects a completed runtime observation with the cleanup failure", () => { - const observation = javascriptRuntimeObservationSchema.parse({ - runtime: { - product: "Node.js", - protocol_version: "1.3", - v8_version: "12.0", - }, - target: { - target_id: "target-1", - protocol_type: "node", - attached: false, - location: { kind: "file", file_path: "/fixture.js" }, - runtime_kind: "node", - runtime_kind_authority: "caller-declared-unverified", - }, - capture: { - observation_ms: 0, - events_observed: 0, - events_retained: 0, - events_dropped: 0, - metadata_bytes_retained: 0, - truncated: false, - truncation_reasons: [], - }, - scripts: { - items: [], - observed_total: 0, - excluded: { unsupported_location: 0, invalid_protocol_value: 0 }, - }, - execution_contexts: [], - directly_observed: ["Inspector target attached and queried."], - unavailable_without_instrumentation: [], - unknowns: [], - limitations: [], - }); - const primary = new Error("observation failed"); - const cleanup = new Error("socket close failed"); - const error = inspectorCleanupError(primary, cleanup, true, observation); - const cause = error.cause; - expect(cause).toBeInstanceOf(AggregateError); - expect(cause).toMatchObject({ errors: [primary, cleanup] }); - - const projected = projectAnalysisError(error); - expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(true); - expect(projected).toMatchObject({ - code: "cleanup_incomplete", - details: { - cleanup_reason: "socket close failed", - resources: ["browser_transport"], - partial_observation: observation, - }, - }); -}); diff --git a/src/inspector/V8InspectorProvider.closeOwnership.test.ts b/src/inspector/V8InspectorProvider.closeOwnership.test.ts new file mode 100644 index 0000000000..f4bf286cef --- /dev/null +++ b/src/inspector/V8InspectorProvider.closeOwnership.test.ts @@ -0,0 +1,237 @@ +import { createServer } from "node:http"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +import { expect, it, onTestFinished, vi } from "vitest"; +import { WebSocketServer } from "ws"; + +import { CdpConnection } from "../browser/CdpConnection.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { observeJavaScriptRuntime } from "../application/javascript/JavaScriptRuntimeObservationService.js"; +import { observeJavaScriptRuntimeInputSchema } from "../domain/javascript/javascriptRuntimeObservation.js"; +import { V8InspectorProvider } from "./V8InspectorProvider.js"; + +const inspectorCloseFixture = async () => { + const root = await mkdtemp(join(tmpdir(), "rea-inspector-close-owner-")); + const scriptPath = join(root, "target.js"); + await writeFile(scriptPath, "globalThis.fixture = true;\n"); + const scriptUrl = pathToFileURL(scriptPath).href; + let endpoint = ""; + const server = createServer((request, response) => { + response.setHeader("content-type", "application/json"); + if (request.url === "/json/version") + response.end( + JSON.stringify({ + Browser: "Node.js/v24.18.0", + "Protocol-Version": "1.3", + "V8-Version": "fixture", + }), + ); + else if (request.url === "/json/list") + response.end( + JSON.stringify([ + { + id: "fixture", + type: "node", + url: scriptUrl, + attached: false, + webSocketDebuggerUrl: endpoint.replace("http:", "ws:") + "/fixture", + }, + ]), + ); + else response.writeHead(404).end(); + }); + const sockets = new WebSocketServer({ server, path: "/fixture" }); + let acceptedConnections = 0; + const connectionWaiters = new Map void>(); + sockets.on("connection", (socket) => { + acceptedConnections += 1; + connectionWaiters.get(acceptedConnections)?.(); + socket.on("message", (raw) => { + const command = JSON.parse(raw.toString()) as { id: number }; + socket.send(JSON.stringify({ id: command.id, result: {} })); + }); + }); + const waitForConnections = (count: number): Promise => + acceptedConnections >= count + ? Promise.resolve() + : new Promise((resolve) => connectionWaiters.set(count, resolve)); + + let resolveCloseStarted: (() => void) | undefined; + const closeStarted = new Promise((resolve) => { + resolveCloseStarted = resolve; + }); + let rejectFirstClose: ((cause: Error) => void) | undefined; + let closeCalls = 0; + const closeFailures = new Set([2]); + const firstClose = new Promise((_resolve, reject) => { + rejectFirstClose = reject; + }); + const originalClose = CdpConnection.prototype.close; + const closeSpy = vi + .spyOn(CdpConnection.prototype, "close") + .mockImplementation(function (this: CdpConnection): Promise { + closeCalls += 1; + if (closeCalls === 1) { + resolveCloseStarted?.(); + return firstClose; + } + if (closeFailures.delete(closeCalls)) + return Promise.reject(new Error("controlled retry remains pending")); + return originalClose.call(this); + }); + + onTestFinished(async () => { + closeSpy.mockRestore(); + if (closeCalls > 0) rejectFirstClose?.(new Error("fixture finalization")); + await new V8InspectorProvider().close(); + for (const socket of sockets.clients) socket.terminate(); + await new Promise((resolve) => sockets.close(() => resolve())); + if (server.listening) + await new Promise((resolve) => server.close(() => resolve())); + await rm(root, { recursive: true, force: true }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (address === null || typeof address === "string") + throw new Error("Inspector fixture did not bind a TCP port"); + endpoint = `http://127.0.0.1:${String(address.port)}`; + const input = observeJavaScriptRuntimeInputSchema.parse({ + inspector_endpoint: endpoint, + target_id: "fixture", + observation_ms: 0, + }); + return { + input, + endpoint, + closeStarted, + closeFailures, + closeSpy, + waitForConnections, + rejectFirstClose: (cause: Error) => rejectFirstClose?.(cause), + get acceptedConnections() { + return acceptedConnections; + }, + }; +}; + +it("retains cancelled final-close ownership and retries the exact transport before replacement observation", async () => { + const fixture = await inspectorCloseFixture(); + const { + input, + endpoint, + closeStarted, + closeFailures, + closeSpy, + waitForConnections, + rejectFirstClose, + } = fixture; + const controller = new AbortController(); + const cancellationReason = new Error("cancelled during transport close"); + const observing = observeJavaScriptRuntime(new V8InspectorProvider(), input, { + signal: controller.signal, + }); + await closeStarted; + controller.abort(cancellationReason); + let firstObservationSettled = false; + void observing.finally(() => { + firstObservationSettled = true; + }); + await Promise.resolve(); + expect(firstObservationSettled).toBe(false); + rejectFirstClose?.(new Error("controlled final socket close failure")); + + const result = await observing; + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Expected cleanup-incomplete cancellation"); + const error = result.error; + expect(error.cleanupIncomplete).toBe(true); + expect(error.cleanupResources).toEqual(["browser_transport"]); + expect(error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError); + expect(error.cleanupPrimary?.cause).toBe(cancellationReason); + expect(error.partialObservation).toMatchObject({ + predicate_type: "rea.javascript-runtime-observation", + operation: "observe_javascript_runtime", + normalized_result: { + target: { target_id: "fixture" }, + }, + }); + expect(projectAnalysisError(error).code).toBe("cleanup_incomplete"); + + const blocked = await observeJavaScriptRuntime( + new V8InspectorProvider(), + input, + ); + expect(blocked.ok).toBe(false); + if (blocked.ok) + throw new Error("Expected unresolved transport admission refusal"); + expect(blocked.error.cleanupIncomplete).toBe(true); + expect(fixture.acceptedConnections).toBe(1); + + const recovered = await observeJavaScriptRuntime( + new V8InspectorProvider(), + input, + ); + expect(recovered.ok).toBe(true); + expect(error.cleanupIncomplete).toBe(false); + expect(error.cleanupResources).toEqual([]); + expect(fixture.acceptedConnections).toBe(2); + + closeFailures.add(5); + const cleanupOnly = await observeJavaScriptRuntime( + new V8InspectorProvider(), + input, + ); + expect(cleanupOnly.ok).toBe(false); + if (cleanupOnly.ok) throw new Error("Expected close-only cleanup failure"); + expect(cleanupOnly.error.cleanupIncomplete).toBe(true); + expect(cleanupOnly.error.cleanupResources).toEqual(["browser_transport"]); + expect(cleanupOnly.error.partialObservation).toMatchObject({ + predicate_type: "rea.javascript-runtime-observation", + operation: "observe_javascript_runtime", + }); + expect(fixture.acceptedConnections).toBe(3); + + const activeInput = observeJavaScriptRuntimeInputSchema.parse({ + inspector_endpoint: endpoint, + target_id: "fixture", + observation_ms: 30_000, + }); + const activeController = new AbortController(); + const activeObservation = observeJavaScriptRuntime( + new V8InspectorProvider(), + activeInput, + { signal: activeController.signal }, + ); + try { + await waitForConnections(4); + let shutdownSettled = false; + const shutdown = new V8InspectorProvider().close().then(() => { + shutdownSettled = true; + }); + const queuedObservation = observeJavaScriptRuntime( + new V8InspectorProvider(), + input, + ); + await new Promise((resolve) => setImmediate(resolve)); + expect(shutdownSettled).toBe(false); + expect(fixture.acceptedConnections).toBe(4); + expect(cleanupOnly.error.cleanupIncomplete).toBe(false); + expect(cleanupOnly.error.cleanupResources).toEqual([]); + activeController.abort(new Error("finish the admitted observation")); + const activeResult = await activeObservation; + expect(activeResult.ok).toBe(false); + await shutdown; + expect(shutdownSettled).toBe(true); + await waitForConnections(5); + const queuedResult = await queuedObservation; + expect(queuedResult.ok).toBe(true); + expect(fixture.acceptedConnections).toBe(5); + expect(closeSpy).toHaveBeenCalledTimes(8); + } finally { + activeController.abort(new Error("fixture finalization")); + } +}); diff --git a/src/inspector/V8InspectorProvider.partial.test.ts b/src/inspector/V8InspectorProvider.partial.test.ts index 6b1ac23c85..71cd0b6573 100644 --- a/src/inspector/V8InspectorProvider.partial.test.ts +++ b/src/inspector/V8InspectorProvider.partial.test.ts @@ -9,6 +9,7 @@ import { WebSocketServer } from "ws"; import { z } from "zod"; import { safeParseJson } from "../domain/safeJson.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { V8InspectorProvider } from "./V8InspectorProvider.js"; const failureCases = [ @@ -184,29 +185,34 @@ it.each(failureCases)( expect(result.ok).toBe(false); if (result.ok) throw new Error("Expected the Inspector operation to fail"); - expect(result.error).toMatchObject({ - _tag: "BrowserObservationError", - reason, - partialObservation: { - capture: { events_observed: 2 }, - scripts: { - observed_total: 1, - items: [ - { - location: { - kind: "unresolved", - reported_url: reportedScriptUrl, - reason: "location-authorization-not-attempted", - }, - cdp_hash: "captured-before-failure", - length: 24, + if (failure === "cancellation") { + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(controller.signal.reason); + } else { + expect(result.error).toMatchObject({ + _tag: "BrowserObservationError", + reason, + }); + } + expect(result.error.partialObservation).toMatchObject({ + capture: { events_observed: 2 }, + scripts: { + observed_total: 1, + items: [ + { + location: { + kind: "unresolved", + reported_url: reportedScriptUrl, + reason: "location-authorization-not-attempted", }, - ], - }, - execution_contexts: [ - { context_key: "1", name: "main", origin: reportedScriptUrl }, + cdp_hash: "captured-before-failure", + length: 24, + }, ], }, + execution_contexts: [ + { context_key: "1", name: "main", origin: reportedScriptUrl }, + ], }); } finally { vi.restoreAllMocks(); diff --git a/src/inspector/V8InspectorProvider.ts b/src/inspector/V8InspectorProvider.ts index 93c6a8b4dc..d6a78d726f 100644 --- a/src/inspector/V8InspectorProvider.ts +++ b/src/inspector/V8InspectorProvider.ts @@ -12,9 +12,11 @@ import { type ObserveJavaScriptRuntimeInput, } from "../domain/javascript/javascriptRuntimeObservation.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; -import type { AnalysisPartialObservation } from "../domain/analysisErrorBase.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; import { err, ok, type Result } from "../domain/result.js"; import { @@ -24,10 +26,19 @@ import { scriptMetadataValues, delayWithCancellation, } from "../browser/CdpCaptureValues.js"; -import { CdpConnection, type CdpEvent } from "../browser/CdpConnection.js"; +import type { CdpConnection, CdpEvent } from "../browser/CdpConnection.js"; +import { + mapElectronFileAuthorizationFailure, + retryElectronFileCleanup, +} from "../browser/ElectronFileScope.js"; +import { + CdpTargetSessionOwner, + bindCdpTransportCleanup, +} from "../browser/CdpTargetSessionOwner.js"; import { authorizeRuntimeTargetLocation, inspectorExclusionKey, + throwIfRuntimeObservationCancelled, } from "./JavaScriptRuntimeScope.js"; import { createInspectorExclusionCounts, @@ -42,6 +53,78 @@ import { import { V8_INSPECTOR_PROVIDER_IDENTITY } from "./providerIdentity.js"; +// Providers are created per application operation, so exact transport cleanup +// ownership must survive their return and gate the next Inspector connection. +const inspectorConnections = new CdpTargetSessionOwner(); +let inspectorActiveOperations = 0; +let inspectorClosing = false; +let inspectorClosePromise: Promise | undefined; +const inspectorIdleWaiters = new Set<() => void>(); + +const withInspectorAdmission = async ( + operation: BrowserObservationOperation, + work: () => Promise, +): Promise => { + if (inspectorClosing && inspectorClosePromise !== undefined) + await inspectorClosePromise; + if (inspectorClosing) + throw new ProviderAdapterError( + V8_INSPECTOR_PROVIDER_IDENTITY.id, + operation, + { + diagnostics: { reason: "provider_closing" }, + userMessage: "Inspector provider cleanup is in progress.", + }, + ); + inspectorActiveOperations += 1; + try { + return await work(); + } finally { + inspectorActiveOperations -= 1; + if (inspectorActiveOperations === 0) { + for (const resolve of inspectorIdleWaiters) resolve(); + inspectorIdleWaiters.clear(); + } + } +}; + +const closeInspectorResources = async (): Promise => { + inspectorClosing = true; + try { + while (inspectorActiveOperations > 0) + await new Promise((resolve) => inspectorIdleWaiters.add(resolve)); + const failures: AnalysisError[] = []; + try { + await inspectorConnections.retryPending(); + } catch (cause: unknown) { + failures.push(providerError(cause, "observe_javascript_runtime")); + } + try { + await retryElectronFileCleanup(); + } catch (cause: unknown) { + failures.push(providerError(cause, "observe_javascript_runtime")); + } + if (failures.length === 0) return; + if (failures.length === 1) throw failures[0]!; + const cleanup = new ProviderCleanupError( + V8_INSPECTOR_PROVIDER_IDENTITY.id, + failures.flatMap(({ cleanupResources }) => cleanupResources), + { reason: "Inspector provider cleanup remains incomplete." }, + { + operation: "observe_javascript_runtime", + cause: new AggregateError( + failures, + "Inspector provider cleanup owners remain unresolved.", + ), + }, + ); + cleanup.bindCleanupDependencies(failures); + throw cleanup; + } finally { + inspectorClosing = false; + } +}; + /** Maximum decoded CDP message accepted while observing one Inspector target. */ export const INSPECTOR_MAX_PAYLOAD_BYTES = 16 * 1024 * 1024; /** Aggregate retained metadata budget, including an estimate of JS object overhead. */ @@ -83,34 +166,75 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { return V8_INSPECTOR_PROVIDER_IDENTITY; } + async close(): Promise { + if (inspectorClosePromise !== undefined) return await inspectorClosePromise; + const attempt = closeInspectorResources(); + inspectorClosePromise = attempt; + try { + await attempt; + } finally { + if (inspectorClosePromise === attempt) inspectorClosePromise = undefined; + } + } + async listTargets( input: ListJavaScriptRuntimeTargetsInput, options: ExecutionOptions = {}, ): Promise> { try { - const discovery = await discoverV8Inspector( + return await withInspectorAdmission( + "list_javascript_runtime_targets", + () => this.#listTargets(input, options), + ); + } catch (cause: unknown) { + return err(providerError(cause, "list_javascript_runtime_targets")); + } + } + + async #listTargets( + input: ListJavaScriptRuntimeTargetsInput, + options: ExecutionOptions, + ): Promise> { + let discovery: Awaited> | undefined; + const allowed: AuthorizedV8InspectorTarget[] = []; + const excluded = createInspectorExclusionCounts(); + let unconnectable = 0; + let targetsExamined = 0; + try { + throwIfRuntimeObservationCancelled( + options.signal, + "list_javascript_runtime_targets", + ); + await retryElectronFileCleanup(); + discovery = await discoverV8Inspector( input.inspector_endpoint, "list_javascript_runtime_targets", options.signal, ); - const allowed: AuthorizedV8InspectorTarget[] = []; - const excluded = createInspectorExclusionCounts(); - let unconnectable = 0; for (const target of discovery.targets) { - const decision = await authorizeRuntimeTargetLocation(target.url, { - type: target.type, - product: discovery.runtime.product, - }); + throwIfRuntimeObservationCancelled( + options.signal, + "list_javascript_runtime_targets", + ); + const decision = await authorizeRuntimeTargetLocation( + target.url, + { type: target.type, product: discovery.runtime.product }, + options.signal, + "list_javascript_runtime_targets", + ); if (!decision.allowed) { excluded[inspectorExclusionKey(decision.reason)] += 1; + targetsExamined += 1; continue; } if (decision.location.kind === "builtin") { excluded.unsupported_location += 1; + targetsExamined += 1; continue; } if (target.webSocketUrl === null) { unconnectable += 1; + targetsExamined += 1; continue; } allowed.push({ @@ -121,10 +245,15 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { webSocketUrl: target.webSocketUrl, location: decision.location, }); + targetsExamined += 1; } allowed.sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0, ); + throwIfRuntimeObservationCancelled( + options.signal, + "list_javascript_runtime_targets", + ); return ok( javascriptRuntimeTargetListSchema.parse({ runtime: discovery.runtime, @@ -136,6 +265,37 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { }), ); } catch (cause: unknown) { + if (cause instanceof AnalysisCancelledError && discovery !== undefined) { + const complete = targetsExamined === discovery.targets.length; + const limitations = describeInspectorTargetLimitations( + allowed.some((target) => target.attached === null), + ); + return err( + new AnalysisCancelledError("list_javascript_runtime_targets", { + cause: cause.cause, + diagnostics: { + completed_target_inventory: { + runtime: discovery.runtime, + targets: allowed + .sort((left, right) => + left.id < right.id ? -1 : left.id > right.id ? 1 : 0, + ) + .map(projectTarget), + excluded: { ...excluded, unconnectable }, + targets_examined: targetsExamined, + targets_discovered: discovery.targets.length, + complete, + limitations: complete + ? limitations + : [ + ...limitations, + "Cancellation stopped target authorization; unexamined targets are unknown, not excluded.", + ], + }, + }, + }), + ); + } return err(providerError(cause, "list_javascript_runtime_targets")); } } @@ -143,13 +303,24 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { async observe( input: ObserveJavaScriptRuntimeInput, options: ExecutionOptions = {}, + ): Promise> { + try { + return await withInspectorAdmission("observe_javascript_runtime", () => + this.#observe(input, options), + ); + } catch (cause: unknown) { + return err(providerError(cause, "observe_javascript_runtime")); + } + } + + async #observe( + input: ObserveJavaScriptRuntimeInput, + options: ExecutionOptions, ): Promise> { let connection: CdpConnection | undefined; let discovery: Awaited> | undefined; let target: AuthorizedV8InspectorTarget | undefined; let state: CaptureState | undefined; - let primaryFailure: unknown; - let failed = false; let cleanupFailure: unknown; let cleanupFailed = false; let connectionClosePromise: Promise | undefined; @@ -158,13 +329,14 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { | undefined; const closeConnection = (): Promise => { if (connection === undefined) return Promise.resolve(); - connectionClosePromise ??= closeInspectorConnection( - connection, - options.signal, - ); + const ownedConnection = connection; + connectionClosePromise ??= inspectorConnections.finish(ownedConnection); return connectionClosePromise; }; try { + throwIfRuntimeObservationCancelled(options.signal); + await inspectorConnections.retryPending(); + await retryElectronFileCleanup(); discovery = await discoverV8Inspector( input.inspector_endpoint, "observe_javascript_runtime", @@ -174,13 +346,16 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { discovery.targets, input, discovery.runtime.product, + options.signal, ); if (input.runtime_kind !== undefined) assertRuntimeKind(target, input.runtime_kind); - connection = await CdpConnection.connect( + throwIfRuntimeObservationCancelled(options.signal); + connection = await inspectorConnections.connect( target.webSocketUrl, "observe_javascript_runtime", - options.signal, + options, + "browser_transport", { maxPayloadBytes: INSPECTOR_MAX_PAYLOAD_BYTES }, ); const captureState = emptyCaptureState(); @@ -204,28 +379,14 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { }); outcome = ok(javascriptRuntimeObservationSchema.parse(result)); } catch (cause: unknown) { - failed = true; - primaryFailure = cause; let error = providerError(cause, "observe_javascript_runtime"); if ( state !== undefined && discovery !== undefined && target !== undefined && (cause instanceof BrowserObservationError || - options.signal?.aborted === true) + cause instanceof AnalysisCancelledError) ) { - const reason = - options.signal?.aborted === true - ? "cancelled" - : cause instanceof BrowserObservationError - ? cause.reason - : "protocol_error"; - try { - await closeConnection(); - } catch (cause: unknown) { - cleanupFailed = true; - cleanupFailure = cause; - } try { const partialObservation = await finalizeInspectorCapture({ input, @@ -234,29 +395,35 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { state, locationMode: "reported", }); - error = new BrowserObservationError( - "observe_javascript_runtime", - reason, - { - cause: error, - ...(error.userMessage === undefined - ? {} - : { detail: error.userMessage }), - partialObservation, - }, - ); + if (cause instanceof AnalysisCancelledError) { + cause.retainPartialObservation(partialObservation); + error = cause; + } else { + error = new BrowserObservationError( + "observe_javascript_runtime", + cause.reason, + { + cause: error, + ...(error.userMessage === undefined + ? {} + : { detail: error.userMessage }), + partialObservation, + }, + ); + } } catch (projectionFailure: unknown) { - error = new BrowserObservationError( - "observe_javascript_runtime", - reason, - { - cause: new AggregateError( - [error, projectionFailure], - "Inspector failure and partial-observation projection both failed", - ), - detail: `${error.message} Partial observations could not be projected.`, - }, - ); + if (!(cause instanceof AnalysisCancelledError)) + error = new ProviderAdapterError( + V8_INSPECTOR_PROVIDER_IDENTITY.id, + "observe_javascript_runtime", + { + cause: new AggregateError( + [error, projectionFailure], + "Inspector failure and partial-observation projection both failed", + ), + userMessage: `${error.message} Partial observations could not be projected.`, + }, + ); } } outcome = err(error); @@ -269,19 +436,42 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { cleanupFailure = cause; } } - if (cleanupFailed) { - return err( - inspectorCleanupError( - primaryFailure, - cleanupFailure, - failed, - outcome?.ok === true - ? outcome.value - : outcome?.ok === false - ? outcome.error.partialObservation - : undefined, - ), + // Cancellation can arrive after capture has completed while the owner is + // waiting for its bounded transport close. Preserve it as the primary + // result even when cleanup completed normally. + if (options.signal?.aborted === true && outcome?.ok === true) { + const cancellation = new AnalysisCancelledError( + "observe_javascript_runtime", + { + cause: options.signal.reason, + partialObservation: outcome.value, + }, ); + outcome = err(cancellation); + } + if (cleanupFailed) { + const cleanup = + cleanupFailure instanceof AnalysisError + ? cleanupFailure + : providerError(cleanupFailure, "observe_javascript_runtime"); + const primary = outcome?.ok === false ? outcome.error : undefined; + const partialObservation = + outcome?.ok === true + ? outcome.value + : outcome?.ok === false + ? outcome.error.partialObservation + : undefined; + if (primary === undefined) { + if (partialObservation !== undefined) + cleanup.retainPartialObservation(partialObservation); + return err(bindCdpTransportCleanup(cleanupFailure, cleanup)); + } + const composed = analysisErrorWithCleanupFailure( + primary, + cleanup, + "observe_javascript_runtime", + ); + return err(bindCdpTransportCleanup(cleanupFailure, composed)); } return ( outcome ?? @@ -295,60 +485,6 @@ export class V8InspectorProvider implements JavaScriptRuntimeObservationPort { } } -export const closeInspectorConnection = async ( - connection: Pick, - signal?: AbortSignal, -): Promise => { - const closing = connection.close(); - // best-effort cleanup: the race below observes the close; this only prevents - // unhandled rejection when the caller abandons the close via cancellation. - void closing.catch(() => undefined); - // CdpConnection.close has its own one second transport bound. Do not make a - // cancelled caller wait for that fallback. - if (signal === undefined) return await closing; - let onAbort: (() => void) | undefined; - const cancelled = new Promise((resolve) => { - onAbort = () => resolve(); - signal.addEventListener("abort", onAbort, { once: true }); - if (signal.aborted) onAbort(); - }); - try { - await Promise.race([closing, cancelled]); - } finally { - if (onAbort !== undefined) signal.removeEventListener("abort", onAbort); - } -}; - -export const inspectorCleanupError = ( - primaryFailure: unknown, - cleanupFailure: unknown, - hasPrimaryFailure: boolean, - partialObservation?: AnalysisPartialObservation, -): BrowserObservationError => { - const cause = hasPrimaryFailure - ? new AggregateError( - [primaryFailure, cleanupFailure], - "Inspector observation and cleanup both failed", - ) - : cleanupFailure; - const cleanupReason = - cleanupFailure instanceof Error - ? cleanupFailure.message - : String(cleanupFailure); - return new BrowserObservationError( - "observe_javascript_runtime", - "cleanup_failed", - { - cause, - cleanup: { - reason: cleanupReason, - resources: ["browser_transport"], - }, - ...(partialObservation === undefined ? {} : { partialObservation }), - }, - ); -}; - const projectTarget = (target: AuthorizedV8InspectorTarget) => ({ target_id: target.id, protocol_type: target.type, @@ -360,17 +496,22 @@ const authorizedTarget = async ( targets: readonly V8InspectorTarget[], input: ObserveJavaScriptRuntimeInput, product: string, + signal?: AbortSignal, ): Promise => { + throwIfRuntimeObservationCancelled(signal); const target = targets.find(({ id }) => id === input.target_id); if (target === undefined) throw new BrowserObservationError( "observe_javascript_runtime", "target_not_found", ); - const decision = await authorizeRuntimeTargetLocation(target.url, { - type: target.type, - product, - }); + const decision = await authorizeRuntimeTargetLocation( + target.url, + { type: target.type, product }, + signal, + "observe_javascript_runtime", + ); + throwIfRuntimeObservationCancelled(signal); if ( target.attached === true || !decision.allowed || @@ -600,6 +741,16 @@ const providerError = ( cause: unknown, operation: BrowserObservationOperation, ): AnalysisError => { + const fileFailure = + operation === "observe_javascript_runtime" || + operation === "list_javascript_runtime_targets" + ? mapElectronFileAuthorizationFailure( + cause, + V8_INSPECTOR_PROVIDER_IDENTITY.id, + operation, + ) + : undefined; + if (fileFailure !== undefined) return fileFailure; if (cause instanceof AnalysisError) return cause; return new ProviderAdapterError( V8_INSPECTOR_PROVIDER_IDENTITY.id, diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 9889dc72bd..1407a95c31 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -405,6 +405,8 @@ export const createServer = ( options.browserScenarioCapture?.close?.() ?? Promise.resolve(), options.electronObservation?.close?.() ?? Promise.resolve(), options.electronActiveObservation?.close?.() ?? Promise.resolve(), + options.javascriptRuntimeObservation?.close?.() ?? + Promise.resolve(), ]), webRuntime.provider.close?.() ?? Promise.resolve(), artifactResources.close(), diff --git a/tests/boundary/cli/javascriptRuntimeObservationCli.test.ts b/tests/boundary/cli/javascriptRuntimeObservationCli.test.ts index 0ce317f65e..ae2c29c8cb 100644 --- a/tests/boundary/cli/javascriptRuntimeObservationCli.test.ts +++ b/tests/boundary/cli/javascriptRuntimeObservationCli.test.ts @@ -4,13 +4,31 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { promisify } from "node:util"; -import { afterEach, describe, expect, test } from "vitest"; +import { Cli } from "incur"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { observeJavaScriptRuntime } from "../../../src/application/javascript/JavaScriptRuntimeObservationService.js"; +import type { JavaScriptRuntimeObservationPort } from "../../../src/application/javascript/JavaScriptRuntimeObservationPort.js"; import { V8InspectorProvider } from "../../../src/inspector/V8InspectorProvider.js"; import { observeJavaScriptRuntimeInputSchema } from "../../../src/domain/javascript/javascriptRuntimeObservation.js"; import { startFakeV8Inspector } from "../../fixtures/inspector/fakeV8Inspector.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { registerJavaScriptRuntimeObservationCommands } from "../../../src/cliJavaScriptRuntimeCommands.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { ok } from "../../../src/domain/result.js"; +import { silentLogger } from "../../../src/logger.js"; + +const runtimeFactory = vi.hoisted(() => ({ + provider: undefined as JavaScriptRuntimeObservationPort | undefined, +})); + +vi.mock("../../../src/composition/javascriptRuntimeObservation.js", () => ({ + createJavaScriptRuntimeObservationProvider: () => { + if (runtimeFactory.provider === undefined) + throw new Error("No runtime observation provider was configured"); + return runtimeFactory.provider; + }, +})); const execute = promisify(execFile); const TIMEOUT_MS = 20_000; @@ -20,6 +38,7 @@ describe("JavaScript runtime observation CLI parity", () => { const temporary: string[] = []; afterEach(async () => { + runtimeFactory.provider = undefined; await Promise.all(resources.splice(0).map((item) => item.close())); await Promise.all( temporary @@ -95,6 +114,62 @@ describe("JavaScript runtime observation CLI parity", () => { }, TIMEOUT_MS, ); + + test("waits for provider cleanup and retains completed Evidence on failure", async () => { + let closeAttempts = 0; + runtimeFactory.provider = { + identity: () => ({ + id: "rea-v8-inspector", + name: "V8 Inspector", + version: "1", + }), + listTargets: async () => + ok({ + runtime: { + product: "node.js/v24", + protocol_version: "1.3", + v8_version: "13.0", + }, + targets: [], + excluded: { unsupported_location: 0, unconnectable: 0 }, + limitations: [], + }), + observe: async () => { + throw new Error("No runtime observation requested"); + }, + close: async () => { + closeAttempts += 1; + throw new ProviderCleanupError( + "rea-v8-inspector", + ["browser_transport"], + { reason: "Inspector transport remains retained" }, + { operation: "list_javascript_runtime_targets" }, + ); + }, + }; + const cli = Cli.create("rea-runtime-cleanup-test", { sync: false }); + registerJavaScriptRuntimeObservationCommands(cli, silentLogger); + let stdout = ""; + await cli.serve( + ["list-javascript-runtime-targets", "http://127.0.0.1:9229", "--json"], + { exit: () => {}, stdout: (chunk) => (stdout += chunk) }, + ); + + expect(closeAttempts).toBe(1); + expect(JSON.parse(stdout)).toMatchObject({ + code: "cleanup_incomplete", + details: { + operation: "list_javascript_runtime_targets", + partial_observation: { + operation: "list_javascript_runtime_targets", + normalized_result: { + runtime: { product: "node.js/v24" }, + targets: [], + }, + }, + }, + }); + }); }); const runCli = async ( diff --git a/tests/boundary/mcp/serverLifecycleMcp.test.ts b/tests/boundary/mcp/serverLifecycleMcp.test.ts index c2d63f46a8..c2299e096a 100644 --- a/tests/boundary/mcp/serverLifecycleMcp.test.ts +++ b/tests/boundary/mcp/serverLifecycleMcp.test.ts @@ -37,6 +37,8 @@ import { createDeferred } from "../../fixtures/binarySession.js"; import { GoBinaryService } from "../../../src/application/go/GoBinaryService.js"; import { WebNetworkCaptureService } from "../../../src/application/WebNetworkCaptureService.js"; import { AnalysisUnsupportedTargetError } from "../../../src/domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import type { JavaScriptRuntimeObservationPort } from "../../../src/application/javascript/JavaScriptRuntimeObservationPort.js"; const resources: Array<{ close(): Promise }> = []; @@ -423,6 +425,95 @@ it("closes the bound Go and web-capture services and retries failed shutdown", a } }); +it("closes the Inspector owner with peers and retries its retained cleanup", async () => { + let inspectorCloseAttempts = 0; + let peerCloseAttempts = 0; + const cleanupFailure = new ProviderCleanupError( + "rea-v8-inspector", + ["browser_transport"], + { reason: "Inspector transport close is still pending" }, + { operation: "observe_javascript_runtime" }, + ); + const runtime: JavaScriptRuntimeObservationPort = { + identity: () => ({ + id: "rea-v8-inspector", + name: "V8 Inspector", + version: "1", + }), + listTargets: async () => { + throw new Error("No target listing requested"); + }, + observe: async () => { + throw new Error("No runtime observation requested"); + }, + close: async () => { + inspectorCloseAttempts += 1; + if (inspectorCloseAttempts === 1) throw cleanupFailure; + }, + }; + const server = createServer( + { kind: "fixed", analysis: { execute: async () => ok(null) } }, + { + javascriptRuntimeObservation: runtime, + browserScenarioCapture: { + identity: () => ({ + id: "scenario-fixture", + name: "Scenario fixture", + version: "1", + }), + captureScenario: async () => { + throw new Error("No scenario capture requested"); + }, + close: async () => { + peerCloseAttempts += 1; + }, + }, + }, + ); + resources.push(server); + + await expect(server.close()).rejects.toMatchObject({ + errors: expect.arrayContaining([cleanupFailure]), + }); + expect(inspectorCloseAttempts).toBe(1); + expect(peerCloseAttempts).toBe(1); + + await server.close(); + expect(inspectorCloseAttempts).toBe(2); + expect(peerCloseAttempts).toBe(2); +}); + +it("does not close a caller-owned Inspector observation provider", async () => { + let closeAttempts = 0; + const runtime: JavaScriptRuntimeObservationPort = { + identity: () => ({ + id: "rea-v8-inspector", + name: "V8 Inspector", + version: "1", + }), + listTargets: async () => { + throw new Error("No target listing requested"); + }, + observe: async () => { + throw new Error("No runtime observation requested"); + }, + close: async () => { + closeAttempts += 1; + }, + }; + const server = createServer( + { kind: "fixed", analysis: { execute: async () => ok(null) } }, + { + javascriptRuntimeObservation: runtime, + optionalObservationProviderOwner: "caller", + }, + ); + resources.push(server); + + await server.close(); + expect(closeAttempts).toBe(0); +}); + afterEach(async () => { await Promise.all( resources.splice(0).map(async (resource) => resource.close()),