diff --git a/src/application/WebModulePorts.ts b/src/application/WebModulePorts.ts index 81c94a1617..2f03efce0f 100644 --- a/src/application/WebModulePorts.ts +++ b/src/application/WebModulePorts.ts @@ -22,6 +22,7 @@ export interface WebModuleArtifacts extends SelectedWebScriptArtifacts { /** Read selected local artifacts without refetching website assets. */ export interface WebModuleArtifactPort { + close?(): Promise; load( input: WebModuleTraceInput, options?: ExecutionOptions, diff --git a/src/application/WebModuleTraceService.ts b/src/application/WebModuleTraceService.ts index f8aa0f8b96..7913158f66 100644 --- a/src/application/WebModuleTraceService.ts +++ b/src/application/WebModuleTraceService.ts @@ -70,7 +70,13 @@ export class WebModuleTraceService { ) {} close(): Promise { - return this.resolver.close?.() ?? Promise.resolve(); + return closeOwnedWebResources( + [ + this.artifacts.close?.bind(this.artifacts), + this.resolver.close?.bind(this.resolver), + ], + "Web module trace cleanup failed", + ); } async trace( @@ -265,6 +271,21 @@ export class WebModuleTraceService { } } +const closeOwnedWebResources = async ( + close: readonly ((() => Promise) | undefined)[], + message: string, +): Promise => { + const results = await Promise.allSettled( + close.flatMap((owner) => + owner === undefined ? [] : [Promise.resolve().then(owner)], + ), + ); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length > 0) throw new AggregateError(failures, message); +}; + const admitContext = ( value: string, path: readonly string[], diff --git a/src/application/WebNetworkCapturePort.ts b/src/application/WebNetworkCapturePort.ts index 5f9cd5545f..2bd0f19687 100644 --- a/src/application/WebNetworkCapturePort.ts +++ b/src/application/WebNetworkCapturePort.ts @@ -12,4 +12,6 @@ export interface WebNetworkCapturePort { input: InspectWebNetworkCaptureInput, options?: ExecutionOptions, ): Promise>; + /** Retry retained reader/runtime cleanup when the owning service shuts down. */ + close?(): Promise; } diff --git a/src/application/WebNetworkCaptureService.ts b/src/application/WebNetworkCaptureService.ts index 8e29ebb10a..5a3d5cfeaf 100644 --- a/src/application/WebNetworkCaptureService.ts +++ b/src/application/WebNetworkCaptureService.ts @@ -27,6 +27,11 @@ const OPERATION = "inspect_web_network_capture"; export class WebNetworkCaptureService { constructor(readonly port: WebNetworkCapturePort) {} + /** Release resources retained by the selected capture adapter. */ + close(): Promise { + return this.port.close?.() ?? Promise.resolve(); + } + /** Return selected complete records inline, without inventing live browser attribution. */ async inspect( rawInput: unknown, diff --git a/src/application/WebScriptArtifactPort.ts b/src/application/WebScriptArtifactPort.ts index 02214d006f..77dcd9cfc9 100644 --- a/src/application/WebScriptArtifactPort.ts +++ b/src/application/WebScriptArtifactPort.ts @@ -8,6 +8,7 @@ import type { ExecutionOptions } from "./AnalysisProvider.js"; /** Read the caller-selected capture export and verify the selected source identity. */ export interface WebScriptArtifactPort { + close?(): Promise; load( input: SelectedWebScriptInput, options?: ExecutionOptions, diff --git a/src/application/WebSourceLocationPorts.ts b/src/application/WebSourceLocationPorts.ts index 04fe1742c6..1d21783ccf 100644 --- a/src/application/WebSourceLocationPorts.ts +++ b/src/application/WebSourceLocationPorts.ts @@ -20,6 +20,7 @@ export interface WebSourceLocationArtifacts extends SelectedWebScriptArtifacts { /** Replaceable artifact acquisition boundary for a caller-selected pairing. */ export interface WebSourceLocationArtifactPort { + close?(): Promise; load( input: WebSourceLocationInput, options?: ExecutionOptions, diff --git a/src/application/WebSourceLocationService.ts b/src/application/WebSourceLocationService.ts index 7be901311f..79b7d45fcd 100644 --- a/src/application/WebSourceLocationService.ts +++ b/src/application/WebSourceLocationService.ts @@ -71,7 +71,13 @@ export class WebSourceLocationService { ) {} close(): Promise { - return this.decoder.close?.() ?? Promise.resolve(); + return closeOwnedWebResources( + [ + this.artifacts.close?.bind(this.artifacts), + this.decoder.close?.bind(this.decoder), + ], + "Web source-location cleanup failed", + ); } /** Shared CLI/MCP workflow preserves raw declarations and every equal-position match. */ @@ -174,6 +180,21 @@ export class WebSourceLocationService { ); } } + +const closeOwnedWebResources = async ( + close: readonly ((() => Promise) | undefined)[], + message: string, +): Promise => { + const results = await Promise.allSettled( + close.flatMap((owner) => + owner === undefined ? [] : [Promise.resolve().then(owner)], + ), + ); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length > 0) throw new AggregateError(failures, message); +}; const invalidOutput = (message: string): Result => err(new AnalysisOutputError(OPERATION, message)); const invalid = ( diff --git a/src/application/binaryDiagnostics/PeResourcesPort.ts b/src/application/binaryDiagnostics/PeResourcesPort.ts index b1a7b53de4..ee36d34f3e 100644 --- a/src/application/binaryDiagnostics/PeResourcesPort.ts +++ b/src/application/binaryDiagnostics/PeResourcesPort.ts @@ -12,6 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Stable read and PE decoding boundary, independent of an active disassembler session. */ export interface PeResourcesPort { readonly identity: ProviderIdentity; + close?(): Promise; inspect( input: InspectPeResourcesInput, options?: ExecutionOptions, diff --git a/src/application/binaryDiagnostics/PeResourcesService.ts b/src/application/binaryDiagnostics/PeResourcesService.ts index 3c8a53704b..87de771a40 100644 --- a/src/application/binaryDiagnostics/PeResourcesService.ts +++ b/src/application/binaryDiagnostics/PeResourcesService.ts @@ -21,6 +21,10 @@ import { err, ok, type Result } from "../../domain/result.js"; export class PeResourcesService { constructor(readonly provider: PeResourcesPort) {} + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + async inspect( rawInput: unknown, options?: ExecutionOptions, diff --git a/src/application/go/GoBinaryPort.ts b/src/application/go/GoBinaryPort.ts index 2094e9109f..cef9259218 100644 --- a/src/application/go/GoBinaryPort.ts +++ b/src/application/go/GoBinaryPort.ts @@ -12,6 +12,7 @@ import type { Result } from "../../domain/result.js"; /** Replaceable offline build-information reader with no target-execution authority. */ export interface GoBinaryPort { readonly identity: ProviderIdentity; + close?(): Promise; inspect( input: InspectGoBinaryInput, options?: ExecutionOptions, diff --git a/src/application/go/GoBinaryService.ts b/src/application/go/GoBinaryService.ts index f5642c96c9..2b9928f820 100644 --- a/src/application/go/GoBinaryService.ts +++ b/src/application/go/GoBinaryService.ts @@ -34,6 +34,10 @@ const architectureForSubject = (architecture: string) => { export class GoBinaryService { constructor(readonly provider: GoBinaryPort) {} + close(): Promise { + return this.provider.close?.() ?? Promise.resolve(); + } + /** Inspect one explicitly selected file without acquiring a binary session. */ async inspect( rawInput: unknown, diff --git a/src/artifacts/ArtifactResourceScope.ts b/src/artifacts/ArtifactResourceScope.ts index 7c0bcf2c2b..8728996311 100644 --- a/src/artifacts/ArtifactResourceScope.ts +++ b/src/artifacts/ArtifactResourceScope.ts @@ -57,6 +57,10 @@ export class ArtifactResourceScope { #draining: Promise | undefined; #closed = false; + get hasPending(): boolean { + return this.#pending.size > 0; + } + /** Admit a resource operation while the scope remains open. */ async run(operation: () => Promise): Promise { while (true) { diff --git a/src/artifacts/readStableArtifact.ts b/src/artifacts/readStableArtifact.ts index e35a20783f..808133d895 100644 --- a/src/artifacts/readStableArtifact.ts +++ b/src/artifacts/readStableArtifact.ts @@ -38,6 +38,13 @@ const afterValidation = async ( } }; +const assertReadActive = (signal: AbortSignal | undefined): void => { + if (signal?.aborted) + throw new ArtifactReaderFailure("cancelled", "Artifact read cancelled", { + cause: signal.reason, + }); +}; + /** Read one bounded regular file and reject replacement or in-place changes. */ export const readStableArtifact = async ( path: string, @@ -46,7 +53,7 @@ export const readStableArtifact = async ( fileSystem: StableArtifactFileSystem = FILE_SYSTEM, resourceScope?: ArtifactResourceScope, ): Promise<{ readonly bytes: Buffer; readonly sha256: string }> => { - signal?.throwIfAborted(); + assertReadActive(signal); const before = await fileSystem.lstat(path); if (!before.isFile() || before.isSymbolicLink()) throw new ArtifactReaderFailure( @@ -84,7 +91,7 @@ export const readStableArtifact = async ( autoClose: false, highWaterMark: 65536, })) { - signal?.throwIfAborted(); + assertReadActive(signal); const value: unknown = chunk; if (!Buffer.isBuffer(value)) throw new ArtifactReaderFailure( @@ -120,6 +127,7 @@ export const readStableArtifact = async ( "integrity", `Artifact changed during read: ${path}`, ); + assertReadActive(signal); const bytes = Buffer.concat(chunks, count); return { bytes, sha256: createHash("sha256").update(bytes).digest("hex") }; } catch (cause: unknown) { diff --git a/src/browser/assets/LocalWebScriptArtifacts.ts b/src/browser/assets/LocalWebScriptArtifacts.ts index c5d6ea8ed0..51fc14c45b 100644 --- a/src/browser/assets/LocalWebScriptArtifacts.ts +++ b/src/browser/assets/LocalWebScriptArtifacts.ts @@ -1,6 +1,7 @@ import { lstat, realpath } from "node:fs/promises"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { normalizeArtifactPath } from "../../artifacts/ArtifactPaths.js"; import type { WebScriptArtifactPort } from "../../application/WebScriptArtifactPort.js"; @@ -19,10 +20,36 @@ import { /** Read one digest-verified captured source under the manifest's current location. */ export class LocalWebScriptArtifacts implements WebScriptArtifactPort { - constructor(readonly operation: WebArtifactReadContext["operation"]) {} + constructor( + readonly operation: WebArtifactReadContext["operation"], + readonly resources = new ArtifactResourceScope(), + ) {} + + close(): Promise { + return this.resources.close(); + } /** Read the selected source; relocated exports retain their reported original root. */ async load(input: SelectedWebScriptInput, options?: ExecutionOptions) { + try { + return await this.resources.run(() => this.#load(input, options)); + } catch (cause: unknown) { + return err( + webArtifactReadError( + cause, + { + operation: this.operation, + field: ["manifest_path"], + targetPath: input.manifest_path, + callerSelected: true, + }, + options?.signal, + ), + ); + } + } + + async #load(input: SelectedWebScriptInput, options?: ExecutionOptions) { let field: readonly (string | number)[] = ["manifest_path"]; let targetPath = input.manifest_path; let callerSelected = true; @@ -31,6 +58,7 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort { input.manifest_path, 32 * 1024 * 1024, options?.signal, + this.resources, ); const manifest = webScriptExportManifestSchema.parse(manifestBytes.value); field = ["script_index"]; @@ -65,6 +93,8 @@ export class LocalWebScriptArtifacts implements WebScriptArtifactPort { sourcePath, 16 * 1024 * 1024, options?.signal, + undefined, + this.resources, ); await assertContained(root, sourcePath); if ( diff --git a/src/browser/assets/WebArtifactReads.ts b/src/browser/assets/WebArtifactReads.ts index cd142f62fc..e045e1575b 100644 --- a/src/browser/assets/WebArtifactReads.ts +++ b/src/browser/assets/WebArtifactReads.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { @@ -9,6 +10,8 @@ import { } from "../../domain/analysisErrorCore.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { safeParseJson } from "../../domain/safeJson.js"; /** The selected artifact and input field owning a reader failure. */ @@ -42,8 +45,15 @@ export const readWebArtifactJson = async ( path: string, maximumBytes: number, signal?: AbortSignal, + resourceScope?: ArtifactResourceScope, ) => { - const data = await readStableArtifact(path, maximumBytes, signal); + const data = await readStableArtifact( + path, + maximumBytes, + signal, + undefined, + resourceScope, + ); const value = safeParseJson(decodeWebArtifact(data.bytes)); if (!value.ok) throw new WebArtifactFormatFailure(value.error); return { ...data, value: value.value }; @@ -55,8 +65,30 @@ export const webArtifactReadError = ( context: WebArtifactReadContext, signal?: AbortSignal, ): AnalysisError => { + if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) { + const primary = + signal?.aborted === true + ? new AnalysisCancelledError(context.operation, { cause }) + : new ArtifactOperationError( + context.operation, + cause.reason, + cause.details, + cause.message, + { cause }, + ); + return analysisErrorWithCleanupFailure( + primary, + new ProviderCleanupError( + "captured-web-artifacts", + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { cause, operation: context.operation }, + ), + context.operation, + ); + } if (signal?.aborted === true) - return new AnalysisCancelledError(context.operation); + return new AnalysisCancelledError(context.operation, { cause }); const selection = context.callerSelected ? selectedFileError(cause, context) : undefined; diff --git a/src/browser/assets/WebSourceLocationArtifacts.ts b/src/browser/assets/WebSourceLocationArtifacts.ts index 21c483bd81..7330694698 100644 --- a/src/browser/assets/WebSourceLocationArtifacts.ts +++ b/src/browser/assets/WebSourceLocationArtifacts.ts @@ -1,6 +1,7 @@ import type { WebSourceLocationArtifactPort } from "../../application/WebSourceLocationPorts.js"; import type { WebScriptArtifactPort } from "../../application/WebScriptArtifactPort.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { WEB_SOURCE_MAP_LIMITS, @@ -12,14 +13,64 @@ import { decodeWebArtifact, webArtifactReadError } from "./WebArtifactReads.js"; /** Read one verified capture source and the caller-selected map, without fetching. */ export class LocalWebSourceLocationArtifacts implements WebSourceLocationArtifactPort { + readonly scripts: WebScriptArtifactPort; + readonly #resources: ArtifactResourceScope; + constructor( - readonly scripts: WebScriptArtifactPort = new LocalWebScriptArtifacts( - "trace_web_source_location", - ), - ) {} + scripts?: WebScriptArtifactPort, + resources = scripts instanceof LocalWebScriptArtifacts + ? scripts.resources + : new ArtifactResourceScope(), + ) { + this.#resources = resources; + this.scripts = + scripts ?? + new LocalWebScriptArtifacts("trace_web_source_location", resources); + } + + async close(): Promise { + const closeScript = + this.scripts instanceof LocalWebScriptArtifacts && + this.scripts.resources === this.#resources + ? undefined + : this.scripts.close?.bind(this.scripts); + const results = await Promise.allSettled([ + Promise.resolve().then(() => this.#resources.close()), + ...(closeScript === undefined + ? [] + : [Promise.resolve().then(closeScript)]), + ]); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length > 0) + throw new AggregateError( + failures, + "Web source-location artifact cleanup failed", + ); + } /** Preserve actual map identity separately from the selected deployment URL. */ async load(input: WebSourceLocationInput, options?: ExecutionOptions) { + try { + return await this.#resources.run(() => this.#load(input, options)); + } catch (cause: unknown) { + return err( + webArtifactReadError( + cause, + { + operation: "trace_web_source_location", + field: ["source_map", "path"], + targetPath: input.source_map.path, + callerSelected: true, + }, + options?.signal, + ), + ); + } + } + + async #load(input: WebSourceLocationInput, options?: ExecutionOptions) { const loaded = await this.scripts.load(input, options); if (!loaded.ok) return loaded; try { @@ -27,6 +78,8 @@ export class LocalWebSourceLocationArtifacts implements WebSourceLocationArtifac input.source_map.path, WEB_SOURCE_MAP_LIMITS.mapBytes, options?.signal, + undefined, + this.#resources, ); return ok({ ...loaded.value, diff --git a/src/browser/history/HistoricalCaptureDecoder.ts b/src/browser/history/HistoricalCaptureDecoder.ts index 7f6e7b83a0..85a91ef76f 100644 --- a/src/browser/history/HistoricalCaptureDecoder.ts +++ b/src/browser/history/HistoricalCaptureDecoder.ts @@ -17,6 +17,7 @@ import { z } from "zod"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import type { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCapabilityUnavailableError, @@ -26,6 +27,7 @@ import { } from "../../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import { err, ok, type Result } from "../../domain/result.js"; import { WEB_NETWORK_CAPTURE_LIMITS, @@ -60,18 +62,52 @@ const replySchema = z.discriminatedUnion("ok", [ /** One owned snapshot/process/root lifecycle for replaceable historical format decoders. */ export class HistoricalCaptureDecoder { + readonly #resources: ArtifactResourceScope; + constructor( readonly adapters: readonly HistoricalCaptureFormatAdapter[], readonly environment: Readonly, readonly createRuntime: () => Promise< Pick > = () => PrivateRuntimeRoot.create({ prefix: "rea-web-capture-" }), - ) {} + resources = new ArtifactResourceScope(), + ) { + this.#resources = resources; + } + + /** Retry retained readers and private roots at service shutdown. */ + close(): Promise { + return this.#resources.close(); + } /** Snapshot the selected artifact, decode it offline, and release private credentials and processes. */ async inspect( input: InspectWebNetworkCaptureInput, options?: ExecutionOptions, + ): Promise> { + try { + return await this.#resources.run(() => + this.inspectAdmitted(input, options), + ); + } catch (cause: unknown) { + const error = historicalCaptureFailure( + input, + cause, + "capture-read", + options, + ); + return err( + redactExplicitFailure( + this.withReaderCleanup(error, cause), + input.sensitive_values, + ), + ); + } + } + + private async inspectAdmitted( + input: InspectWebNetworkCaptureInput, + options?: ExecutionOptions, ): Promise> { let runtime: Pick | undefined; let result: Result; @@ -90,6 +126,8 @@ export class HistoricalCaptureDecoder { input.capture_path, WEB_NETWORK_CAPTURE_LIMITS.inputBytes, options?.signal, + undefined, + this.#resources, ); phase = "decoder"; runtime = await this.createRuntime(); @@ -139,9 +177,16 @@ export class HistoricalCaptureDecoder { replyPath, WEB_NETWORK_CAPTURE_LIMITS.outputBytes, options?.signal, + undefined, + this.#resources, ); reply = replySchema.parse(JSON.parse(replyFile.bytes.toString("utf8"))); } catch (cause: unknown) { + if ( + cause instanceof ArtifactReaderFailure && + cause.cleanup !== undefined + ) + throw cause; if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION); throw new AnalysisOutputError( @@ -230,17 +275,21 @@ export class HistoricalCaptureDecoder { result = ok({ ...reply.value, ...common, format: input.format }); } } catch (cause: unknown) { + const error = historicalCaptureFailure(input, cause, phase, options); result = err( redactExplicitFailure( - historicalCaptureFailure(input, cause, phase, options), + this.withReaderCleanup(error, cause), input.sensitive_values, ), ); } if (runtime !== undefined) { - try { - await runtime.close(); - } catch (cause: unknown) { + const cleanup = await this.#resources.release({ + kind: "private-runtime-root", + resource: runtime.path, + root: runtime, + }); + if (cleanup.kind === "failed") { return err( redactExplicitFailure( new ProviderCleanupError( @@ -251,9 +300,12 @@ export class HistoricalCaptureDecoder { previous_error: result.ok ? null : projectAnalysisError(result.error), - reason: cause instanceof Error ? cause.message : String(cause), + reason: + cleanup.cause instanceof Error + ? cleanup.cause.message + : String(cleanup.cause), }, - { operation: OPERATION }, + { operation: OPERATION, cause: cleanup.cause }, ), input.sensitive_values, ), @@ -264,6 +316,34 @@ export class HistoricalCaptureDecoder { ? err(new AnalysisCancelledError(OPERATION)) : result; } + + private withReaderCleanup( + primary: AnalysisError, + cause: unknown, + ): AnalysisError { + if ( + !(cause instanceof ArtifactReaderFailure) || + cause.cleanup === undefined + ) + return primary; + if (cause.reason === "unavailable") + return new ProviderCleanupError( + "web-network-capture", + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { operation: OPERATION, cause }, + ); + const cleanup = new ProviderCleanupError( + "web-network-capture", + cause.cleanup.resources, + { + reason: cause.cleanup.reason, + previous_error: projectAnalysisError(primary), + }, + { operation: OPERATION, cause }, + ); + return analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); + } } /** The Node capture decoder runs on this executable; only Node.js enforces its V8 heap flags. */ diff --git a/src/browser/modules/WebModuleArtifacts.ts b/src/browser/modules/WebModuleArtifacts.ts index 287e6e8dea..928025f090 100644 --- a/src/browser/modules/WebModuleArtifacts.ts +++ b/src/browser/modules/WebModuleArtifacts.ts @@ -4,6 +4,7 @@ import type { } from "../../application/WebModulePorts.js"; import type { WebScriptArtifactPort } from "../../application/WebScriptArtifactPort.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { jsonObjectSchema } from "../../domain/jsonValue.js"; import { err, ok } from "../../domain/result.js"; import type { WebModuleTraceInput } from "../../domain/webModuleTrace.js"; @@ -15,14 +16,64 @@ import { /** Add an explicitly selected import map to a verified captured-script source. */ export class LocalWebModuleArtifacts implements WebModuleArtifactPort { + readonly scripts: WebScriptArtifactPort; + readonly #resources: ArtifactResourceScope; + constructor( - readonly scripts: WebScriptArtifactPort = new LocalWebScriptArtifacts( - "trace_web_module_imports", - ), - ) {} + scripts?: WebScriptArtifactPort, + resources = scripts instanceof LocalWebScriptArtifacts + ? scripts.resources + : new ArtifactResourceScope(), + ) { + this.#resources = resources; + this.scripts = + scripts ?? + new LocalWebScriptArtifacts("trace_web_module_imports", resources); + } + + async close(): Promise { + const closeScript = + this.scripts instanceof LocalWebScriptArtifacts && + this.scripts.resources === this.#resources + ? undefined + : this.scripts.close?.bind(this.scripts); + const results = await Promise.allSettled([ + Promise.resolve().then(() => this.#resources.close()), + ...(closeScript === undefined + ? [] + : [Promise.resolve().then(closeScript)]), + ]); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length > 0) + throw new AggregateError(failures, "Web module artifact cleanup failed"); + } /** Read selected source and optional map without website requests. */ async load(input: WebModuleTraceInput, options?: ExecutionOptions) { + try { + return await this.#resources.run(() => this.#load(input, options)); + } catch (cause: unknown) { + return err( + webArtifactReadError( + cause, + { + operation: "trace_web_module_imports", + field: + input.import_map === undefined + ? ["manifest_path"] + : ["import_map", "path"], + targetPath: input.import_map?.path ?? input.manifest_path, + callerSelected: input.import_map !== undefined, + }, + options?.signal, + ), + ); + } + } + + async #load(input: WebModuleTraceInput, options?: ExecutionOptions) { const loaded = await this.scripts.load(input, options); if (!loaded.ok) return loaded; if (input.import_map === undefined) @@ -33,6 +84,7 @@ export class LocalWebModuleArtifacts implements WebModuleArtifactPort { selected.path, 4 * 1024 * 1024, options?.signal, + this.#resources, ); const importMap: WebModuleArtifacts["importMap"] = { file: { diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index bddd16bed7..5cb9577bd4 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -7,11 +7,12 @@ import { } from "../composition/binaryDiagnostics.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; import { inspectPeResourcesInputSchema } from "../domain/native/peResources.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; /** Inspect operator-selected files through the same shared workflow as MCP. */ export const registerBinaryDiagnosticsCommands = ( @@ -19,7 +20,6 @@ export const registerBinaryDiagnosticsCommands = ( logger: Logger, environment: Readonly, ): void => { - const pe = createPeResourcesService(); cli.command(CLI_COMMANDS.inspectPeResources, { description: "Inspect PE resource identities, payload ranges, hashes and icon-group relationships", @@ -29,19 +29,28 @@ export const registerBinaryDiagnosticsCommands = ( maxEntries: inspectPeResourcesInputSchema.shape.max_entries, }), run: ({ args, options }) => - withCommandCancellation((signal) => - logCliCommand(logger, CLI_COMMANDS.inspectPeResources, async () => { - const result = await pe.inspect( - { - path: resolve(args.path), - max_file_bytes: options.maxFileBytes, - max_entries: options.maxEntries, - }, - { signal }, - ); - return result.ok ? result.value : projectAnalysisError(result.error); - }), - ), + withCommandCancellation(async (signal) => { + const pe = createPeResourcesService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectPeResources, + async () => + finishAnalysisCommand( + () => + pe.inspect( + { + path: resolve(args.path), + max_file_bytes: options.maxFileBytes, + max_entries: options.maxEntries, + }, + { signal }, + ), + pe, + "rea-pe-resources", + "inspect_pe_resources", + ), + ); + }), }); const service = createBinaryLayoutService(environment); cli.command(CLI_COMMANDS.inspectBinaryLayout, { diff --git a/src/cli/captureCommands.ts b/src/cli/captureCommands.ts index 2a3566703a..542361e906 100644 --- a/src/cli/captureCommands.ts +++ b/src/cli/captureCommands.ts @@ -1,9 +1,6 @@ import { inspectPacketFieldsInputSchema } from "../domain/captures/packetFields.js"; import { z } from "incur"; import type { PacketCaptureService } from "../application/captures/PacketCaptureService.js"; -import { AnalysisError } from "../domain/analysisErrorBase.js"; -import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; -import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { resolve } from "node:path"; import { createPacketCaptureService } from "../composition/captures.js"; import { @@ -13,10 +10,10 @@ import { } from "../domain/captures/packetCapture.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; +import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; export const registerCaptureCommands = ( cli: CliInstance, logger: Logger, @@ -26,32 +23,6 @@ export const registerCaptureCommands = ( "inspect" | "inspectFields" | "close" > = createPacketCaptureService(environment), ): void => { - const finish = async ( - result: Awaited>, - operation: string, - ) => { - // A one-shot command must retry retained cleanup before discarding its owner. - try { - await service.close(); - } catch (cause) { - const cleanup = - cause instanceof AnalysisError - ? cause - : new ProviderCleanupError( - "tshark-capture", - [], - { reason: String(cause) }, - { cause, operation: operation }, - ); - if (result.ok) cleanup.retainPartialObservation(result.value); - return projectAnalysisError( - result.ok - ? cleanup - : analysisErrorWithCleanupFailure(result.error, cleanup, operation), - ); - } - return result.ok ? result.value : projectAnalysisError(result.error); - }; cli.command(CLI_COMMANDS.inspectPacketFields, { description: "Search installed TShark protocol and field definitions", options: z @@ -80,11 +51,14 @@ export const registerCaptureCommands = ( run: ({ options: { all, ...options } }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectPacketFields, async () => - finish( - await service.inspectFields( - { ...options, ...(all ? { limit: null } : {}) }, - { signal }, - ), + finishAnalysisCommand( + () => + service.inspectFields( + { ...options, ...(all ? { limit: null } : {}) }, + { signal }, + ), + service, + "tshark-capture", "inspect_packet_fields", ), ), @@ -125,15 +99,20 @@ export const registerCaptureCommands = ( run: ({ args, options: { all_matches, ...options } }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectPacketCapture, async () => { - const result = await service.inspect( - { - ...options, - ...(all_matches ? { match_limit: null } : {}), - path: resolve(args.path), - }, - { signal }, + return finishAnalysisCommand( + () => + service.inspect( + { + ...options, + ...(all_matches ? { match_limit: null } : {}), + path: resolve(args.path), + }, + { signal }, + ), + service, + "tshark-capture", + "inspect_packet_capture", ); - return finish(result, "inspect_packet_capture"); }), ), }); diff --git a/src/cli/finishAnalysisCommand.ts b/src/cli/finishAnalysisCommand.ts new file mode 100644 index 0000000000..9394ba828b --- /dev/null +++ b/src/cli/finishAnalysisCommand.ts @@ -0,0 +1,69 @@ +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + AnalysisError, + type AnalysisPartialObservation, +} from "../domain/analysisErrorBase.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import type { Result } from "../domain/result.js"; + +/** Finish a one-shot CLI operation without discarding its primary result on close failure. */ +export const finishAnalysisCommand = async < + Value extends AnalysisPartialObservation, +>( + run: () => Promise>, + service: { close(): Promise }, + providerId: string, + canonicalOperation: string, + fallbackResource?: string, +): Promise> => { + let result: Result; + try { + result = await run(); + } catch (cause) { + result = { + ok: false, + error: + cause instanceof AnalysisError + ? cause + : new ProviderAdapterError(providerId, canonicalOperation, { cause }), + }; + } + try { + await service.close(); + } catch (cause) { + let cleanup: AnalysisError; + if (cause instanceof AnalysisError) cleanup = cause; + else { + const observation = + cause instanceof ArtifactReaderFailure + ? ArtifactReaderFailure.cleanupObservation( + cause, + fallbackResource ?? `${canonicalOperation} resources`, + ) + : { + reason: cause instanceof Error ? cause.message : String(cause), + resources: [], + }; + cleanup = new ProviderCleanupError( + providerId, + observation.resources, + { reason: observation.reason }, + { cause, operation: canonicalOperation }, + ); + } + if (result.ok) cleanup.retainPartialObservation(result.value); + return projectAnalysisError( + result.ok + ? cleanup + : analysisErrorWithCleanupFailure( + result.error, + cleanup, + canonicalOperation, + ), + ); + } + return result.ok ? result.value : projectAnalysisError(result.error); +}; diff --git a/src/cli/goCommands.ts b/src/cli/goCommands.ts index d67a344e0c..12b2b992a7 100644 --- a/src/cli/goCommands.ts +++ b/src/cli/goCommands.ts @@ -3,14 +3,13 @@ import { resolve } from "node:path"; import { createGoBinaryService } from "../composition/go.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; +import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; /** Resolve caller paths and inspect static build metadata through the shared workflow. */ export const registerGoCommands = (cli: CliInstance, logger: Logger): void => { - const service = createGoBinaryService(); cli.command(CLI_COMMANDS.inspectGoBinary, { description: "Inspect embedded Go compiler, modules and build settings without target execution", @@ -18,14 +17,19 @@ export const registerGoCommands = (cli: CliInstance, logger: Logger): void => { path: z.string().describe("Local ELF, PE or thin Mach-O image"), }), run: ({ args }) => - withCommandCancellation((signal) => - logCliCommand(logger, CLI_COMMANDS.inspectGoBinary, async () => { - const result = await service.inspect( - { path: resolve(args.path) }, - { signal }, - ); - return result.ok ? result.value : projectAnalysisError(result.error); - }), - ), + withCommandCancellation(async (signal) => { + const service = createGoBinaryService(); + return await logCliCommand( + logger, + CLI_COMMANDS.inspectGoBinary, + async () => + finishAnalysisCommand( + () => service.inspect({ path: resolve(args.path) }, { signal }), + service, + "go-build-info", + "inspect_go_binary", + ), + ); + }), }); }; diff --git a/src/cli/signalCommands.ts b/src/cli/signalCommands.ts index e7d254507d..63b7d9edf2 100644 --- a/src/cli/signalCommands.ts +++ b/src/cli/signalCommands.ts @@ -1,9 +1,6 @@ import { inspectSignalDecodersInputSchema } from "../domain/signals/signalDecoders.js"; import { z } from "incur"; import type { SignalRecordingService } from "../application/signals/SignalRecordingService.js"; -import { AnalysisError } from "../domain/analysisErrorBase.js"; -import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; -import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { resolve } from "node:path"; import { createSignalRecordingService } from "../composition/signals.js"; import { @@ -13,10 +10,10 @@ import { } from "../domain/signals/signalRecording.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; +import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; export const registerSignalCommands = ( cli: CliInstance, logger: Logger, @@ -26,32 +23,6 @@ export const registerSignalCommands = ( "decode" | "inspectDecoders" | "close" > = createSignalRecordingService(environment), ): void => { - const finish = async ( - result: Awaited>, - operation: string, - ) => { - // A one-shot command must retry retained cleanup before discarding its owner. - try { - await service.close(); - } catch (cause) { - const cleanup = - cause instanceof AnalysisError - ? cause - : new ProviderCleanupError( - "sigrok-recording", - [], - { reason: String(cause) }, - { cause, operation: operation }, - ); - if (result.ok) cleanup.retainPartialObservation(result.value); - return projectAnalysisError( - result.ok - ? cleanup - : analysisErrorWithCleanupFailure(result.error, cleanup, operation), - ); - } - return result.ok ? result.value : projectAnalysisError(result.error); - }; cli.command(CLI_COMMANDS.inspectSignalDecoders, { description: "Search installed sigrok decoders or selected decoder help", options: z @@ -82,11 +53,14 @@ export const registerSignalCommands = ( run: ({ options: { all, ...options } }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.inspectSignalDecoders, async () => - finish( - await service.inspectDecoders( - { ...options, ...(all ? { limit: null } : {}) }, - { signal }, - ), + finishAnalysisCommand( + () => + service.inspectDecoders( + { ...options, ...(all ? { limit: null } : {}) }, + { signal }, + ), + service, + "sigrok-recording", "inspect_signal_decoders", ), ), @@ -127,15 +101,20 @@ export const registerSignalCommands = ( run: ({ args, options: { all_lines, ...options } }) => withCommandCancellation((signal) => logCliCommand(logger, CLI_COMMANDS.decodeSignalRecording, async () => { - const result = await service.decode( - { - ...options, - ...(all_lines ? { line_limit: null } : {}), - path: resolve(args.path), - }, - { signal }, + return finishAnalysisCommand( + () => + service.decode( + { + ...options, + ...(all_lines ? { line_limit: null } : {}), + path: resolve(args.path), + }, + { signal }, + ), + service, + "sigrok-recording", + "decode_signal_recording", ); - return finish(result, "decode_signal_recording"); }), ), }); diff --git a/src/cli/webNetworkCaptureCommands.ts b/src/cli/webNetworkCaptureCommands.ts index 655d34bc1b..d49e39b0ea 100644 --- a/src/cli/webNetworkCaptureCommands.ts +++ b/src/cli/webNetworkCaptureCommands.ts @@ -3,10 +3,10 @@ import { WEB_NETWORK_CAPTURE_FORMATS } from "../domain/webNetworkCapture.js"; import { createWebNetworkCaptureService } from "../composition/webNetworkCaptures.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; -import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; +import { finishAnalysisCommand } from "./finishAnalysisCommand.js"; /** Expose offline historical capture inspection with the same selection contract as MCP. */ export const registerWebNetworkCaptureCommands = ( @@ -42,20 +42,24 @@ export const registerWebNetworkCaptureCommands = ( logger, CLI_COMMANDS.inspectWebNetworkCapture, async () => { - const result = await service.inspect( - { - capture_path: args.capturePath, - format: args.format, - sensitive_values: options.sensitiveValue, - ...(options.record === undefined - ? {} - : { record_ordinals: options.record }), - }, - { signal }, + return finishAnalysisCommand( + () => + service.inspect( + { + capture_path: args.capturePath, + format: args.format, + sensitive_values: options.sensitiveValue, + ...(options.record === undefined + ? {} + : { record_ordinals: options.record }), + }, + { signal }, + ), + service, + "web-network-capture", + "inspect_web_network_capture", + "Web network capture resources", ); - return result.ok - ? result.value - : projectAnalysisError(result.error); }, ), ), diff --git a/src/evm/EvmoleFailures.ts b/src/evm/EvmoleFailures.ts index 2677a3e719..63c6578aae 100644 --- a/src/evm/EvmoleFailures.ts +++ b/src/evm/EvmoleFailures.ts @@ -39,6 +39,9 @@ export const evmInterfaceFailure = ( limiter = "/usr/bin/prlimit", fileFailure?: EvmFileSizeFailureEvidence, ): AnalysisError => { + const cleanup = + cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; + const cleanupOptions = cleanup === undefined ? {} : { cleanup }; if (cause instanceof AnalysisError) return cause; if (cause instanceof OwnedCommandFailure) { const outputOptions = @@ -64,12 +67,15 @@ export const evmInterfaceFailure = ( { operation: "inspect_evm_interface", cause }, ); if (cause.reason === "cancelled") - return new AnalysisCancelledError("inspect_evm_interface", outputOptions); + return new AnalysisCancelledError("inspect_evm_interface", { + ...outputOptions, + ...cleanupOptions, + }); if (cause.reason === "timeout") return new AnalysisTimeoutError( "inspect_evm_interface", EVM_INTERFACE_LIMITS.timeoutMs, - outputOptions, + { ...outputOptions, ...cleanupOptions }, ); if (cause.reason === "output-limit") return new AnalysisOutputError( @@ -122,25 +128,33 @@ export const evmInterfaceFailure = ( } if (cause instanceof ArtifactReaderFailure) { if (cause.reason === "cancelled") - return new AnalysisCancelledError("inspect_evm_interface"); + return new AnalysisCancelledError("inspect_evm_interface", { + cause, + ...cleanupOptions, + }); if (cause.reason === "integrity") return new AnalysisArtifactChangedError( "inspect_evm_interface", path, cause.message, - { cause }, + { cause, ...cleanupOptions }, ); if ( phase === "artifact-read" && ["limit", "path", "format"].includes(cause.reason) ) - return new AnalysisInputError("inspect_evm_interface", { cause }, [ - { - path: ["path"], - reason: cause.reason === "limit" ? "out_of_range" : "invalid_format", - message: cause.message, - }, - ]); + return new AnalysisInputError( + "inspect_evm_interface", + { cause, ...cleanupOptions }, + [ + { + path: ["path"], + reason: + cause.reason === "limit" ? "out_of_range" : "invalid_format", + message: cause.message, + }, + ], + ); } if (phase === "artifact-read" && cause instanceof Error && "code" in cause) { if (cause.code === "EACCES" || cause.code === "EPERM") @@ -148,22 +162,27 @@ export const evmInterfaceFailure = ( "inspect_evm_interface", path, cause.code, - { cause }, + { cause, ...cleanupOptions }, ); if (cause.code === "ENOENT" || cause.code === "ENOTDIR") - return new AnalysisInputError("inspect_evm_interface", { cause }, [ - { - path: ["path"], - reason: "invalid_value", - message: `Selected bytecode carrier could not be read (${String(cause.code)}): ${path}.`, - }, - ]); + return new AnalysisInputError( + "inspect_evm_interface", + { cause, ...cleanupOptions }, + [ + { + path: ["path"], + reason: "invalid_value", + message: `Selected bytecode carrier could not be read (${String(cause.code)}): ${path}.`, + }, + ], + ); } return new ProviderAdapterError( EVMOLE_PROVIDER_IDENTITY.id, "inspect_evm_interface", { cause, + ...cleanupOptions, diagnostics: { phase, path, diff --git a/src/evm/EvmoleInterfaceProvider.ts b/src/evm/EvmoleInterfaceProvider.ts index 7437edc23f..c59427e6ac 100644 --- a/src/evm/EvmoleInterfaceProvider.ts +++ b/src/evm/EvmoleInterfaceProvider.ts @@ -13,6 +13,8 @@ import type { EvmInterfacePort, } from "../application/evm/EvmInterfacePort.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import { OwnedCommandFailure } from "../process/OwnedCommand.js"; import type { AnalysisError, @@ -78,6 +80,7 @@ type Launcher = NonNullable[2]>["launcher"]; /** Run unchanged EVMole/WASM in an owned worker; no target, chain or network execution. */ export class EvmoleInterfaceProvider implements EvmInterfacePort { + readonly #resources = new ArtifactResourceScope(); #tail: Promise = Promise.resolve(); #closing = false; #closePromise: Promise | undefined; @@ -102,9 +105,14 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { options?: ExecutionOptions, ): Promise> { if (this.#closing) return Promise.resolve(err(this.#closedError())); - const operation = this.#tail.then(() => - this.#closing ? err(this.#closedError()) : this.#inspect(input, options), - ); + const operation = this.#tail.then(async () => { + if (this.#closing) return err(this.#closedError()); + try { + return await this.#resources.run(() => this.#inspect(input, options)); + } catch (cause: unknown) { + return err(evmInterfaceFailure(cause, "artifact-read", input.path)); + } + }); this.#tail = operation.then( () => undefined, () => undefined, @@ -180,6 +188,8 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { input.path, EVM_INTERFACE_LIMITS.inputBytes, options?.signal, + undefined, + this.#resources, ); phase = "worker"; root = await this.createRuntime(); @@ -243,15 +253,27 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { replyPath, EVM_INTERFACE_LIMITS.outputBytes, options?.signal, + undefined, + this.#resources, ); reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8"))); } catch (cause: unknown) { + const cleanup = + cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; if (options?.signal?.aborted) - throw new AnalysisCancelledError(OPERATION, { capturedOutput }); + throw new AnalysisCancelledError(OPERATION, { + cause, + capturedOutput, + ...(cleanup === undefined ? {} : { cleanup }), + }); throw new AnalysisOutputError( OPERATION, `Owned interface worker reply failed for ${input.path}: ${cause instanceof Error ? cause.message : String(cause)}`, - { cause, capturedOutput }, + { + cause, + capturedOutput, + ...(cleanup === undefined ? {} : { cleanup }), + }, ); } if (!reply.ok) { @@ -355,6 +377,7 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { if (cause instanceof OwnedCommandFailure) processOwner = cause.cleanupOwner; let fileFailure: EvmFileSizeFailureEvidence | undefined; + let markerCleanupFailure: ArtifactReaderFailure | undefined; if ( cause instanceof OwnedCommandFailure && cause.snapshot?.exitCode === EVM_FILE_SIZE_FAILURE_EXIT && @@ -365,6 +388,9 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { const marker = await readStableArtifact( join(root.path, "resource.failure"), 1, + undefined, + undefined, + this.#resources, ); if (!marker.bytes.equals(Buffer.from("F"))) throw new Error( @@ -372,6 +398,14 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { ); fileFailure = { verified: true, failure: null }; } catch (markerFailure: unknown) { + if ( + markerFailure instanceof ArtifactReaderFailure && + markerFailure.cleanup !== undefined + ) + markerCleanupFailure = ArtifactReaderFailure.withCleanup( + cause, + markerFailure.cleanup, + ); fileFailure = { verified: false, failure: @@ -382,23 +416,36 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { } } result = err( - options?.signal?.aborted && - (cause === options.signal.reason || - (cause instanceof Error && cause.name === "AbortError")) - ? new AnalysisCancelledError( - OPERATION, - retainedOutput === undefined - ? undefined - : { capturedOutput: retainedOutput }, - ) - : evmInterfaceFailure( - cause, - phase, + markerCleanupFailure !== undefined + ? evmInterfaceFailure( + markerCleanupFailure, + "artifact-read", input.path, workerLimits, limiter, fileFailure, - ), + ) + : options?.signal?.aborted && + (cause === options.signal.reason || + (cause instanceof Error && cause.name === "AbortError")) + ? new AnalysisCancelledError(OPERATION, { + cause, + ...(retainedOutput === undefined + ? {} + : { capturedOutput: retainedOutput }), + ...(cause instanceof ArtifactReaderFailure && + cause.cleanup !== undefined + ? { cleanup: cause.cleanup } + : {}), + }) + : evmInterfaceFailure( + cause, + phase, + input.path, + workerLimits, + limiter, + fileFailure, + ), ); } if (processOwner !== undefined) { @@ -447,8 +494,37 @@ export class EvmoleInterfaceProvider implements EvmInterfacePort { this.#closing = true; this.#closePromise ??= this.#tail .then(async () => { - const failure = await this.#retire(); - if (failure !== undefined) throw failure; + const outcomes = await Promise.allSettled([ + this.#retire(), + this.#resources.close(), + ]); + const failures = outcomes.flatMap((outcome) => + outcome.status === "rejected" + ? [outcome.reason] + : outcome.value === undefined + ? [] + : [outcome.value], + ); + if (failures.length > 0) { + const resources = failures.flatMap((failure) => + failure instanceof ProviderCleanupError + ? failure.cleanupResources + : failure instanceof ArtifactReaderFailure && + failure.cleanup !== undefined + ? failure.cleanup.resources + : ["artifact-resources"], + ); + throw new ProviderCleanupError( + EVMOLE_PROVIDER_IDENTITY.id, + [...new Set(resources)], + { + failures: failures.map((failure) => + failure instanceof Error ? failure.message : String(failure), + ), + }, + { operation: OPERATION, cause: new AggregateError(failures) }, + ); + } }) .catch((cause: unknown) => { this.#closePromise = undefined; diff --git a/src/go/GoBinaryProvider.ts b/src/go/GoBinaryProvider.ts index a1c0cc6a36..466136f3f6 100644 --- a/src/go/GoBinaryProvider.ts +++ b/src/go/GoBinaryProvider.ts @@ -1,5 +1,7 @@ import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import type { ExecutionOptions } from "../application/AnalysisProvider.js"; import type { GoBinaryPort } from "../application/go/GoBinaryPort.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -18,6 +20,7 @@ import { type InspectGoBinaryInput, } from "../domain/go/goBinary.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; import { readGoBinaryImage, @@ -36,13 +39,19 @@ const OPERATION = "inspect_go_binary"; const INPUT_BYTES = 256 * 1024 * 1024; const OUTPUT_BYTES = 8 * 1024 * 1024; -const failureFor = ( +const failureForPrimary = ( cause: unknown, path: string, signal?: AbortSignal, ): AnalysisError => { if (cause instanceof AnalysisError) return cause; - if (signal?.aborted) return new AnalysisCancelledError(OPERATION); + if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { + cause, + }); + if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + return new AnalysisCancelledError(OPERATION, { cause }); + if (signal?.aborted) return new AnalysisCancelledError(OPERATION, { cause }); if (cause instanceof GoBinaryResourceFailure) return new AnalysisResourceConstraintError( OPERATION, @@ -64,12 +73,6 @@ const failureFor = ( }, ]); if (cause instanceof ArtifactReaderFailure) { - if (cause.reason === "integrity") - return new AnalysisArtifactChangedError(OPERATION, path, cause.message, { - cause, - }); - if (cause.reason === "cancelled") - return new AnalysisCancelledError(OPERATION); if (cause.reason === "limit") return new AnalysisResourceConstraintError( OPERATION, @@ -110,6 +113,23 @@ const failureFor = ( }); }; +const failureFor = ( + cause: unknown, + path: string, + signal?: AbortSignal, +): AnalysisError => { + const primary = failureForPrimary(cause, path, signal); + if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) + return primary; + const cleanup = new ProviderCleanupError( + GO_BINARY_PROVIDER_IDENTITY.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { operation: OPERATION, cause }, + ); + return analysisErrorWithCleanupFailure(primary, cleanup, OPERATION); +}; + const outputLimit = (path: string) => new AnalysisResourceConstraintError( OPERATION, @@ -121,6 +141,11 @@ const outputLimit = (path: string) => /** Stable in-process metadata inspection without subprocesses, temporary files or Go installation. */ export class GoBinaryProvider implements GoBinaryPort { readonly identity = GO_BINARY_PROVIDER_IDENTITY; + readonly #resources = new ArtifactResourceScope(); + + close(): Promise { + return this.#resources.close(); + } /** Read complete compiler/module metadata while preserving original byte identity and source ranges. */ async inspect( @@ -128,10 +153,14 @@ export class GoBinaryProvider implements GoBinaryPort { options?: ExecutionOptions, ): Promise> { try { - const snapshot = await readStableArtifact( - input.path, - INPUT_BYTES, - options?.signal, + const snapshot = await this.#resources.run(() => + readStableArtifact( + input.path, + INPUT_BYTES, + options?.signal, + undefined, + this.#resources, + ), ); options?.signal?.throwIfAborted(); const image = readGoBinaryImage(snapshot.bytes); diff --git a/src/native/pe/PeResourcesProvider.ts b/src/native/pe/PeResourcesProvider.ts index 4326cb3b7c..4f31256d95 100644 --- a/src/native/pe/PeResourcesProvider.ts +++ b/src/native/pe/PeResourcesProvider.ts @@ -1,5 +1,7 @@ import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js"; import type { PeResourcesPort } from "../../application/binaryDiagnostics/PeResourcesPort.js"; import type { ExecutionOptions } from "../../application/AnalysisProvider.js"; import { ArtifactOperationError } from "../../domain/artifactOperationError.js"; @@ -12,19 +14,29 @@ import { } from "../../domain/analysisErrorCore.js"; import type { InspectPeResourcesInput } from "../../domain/native/peResources.js"; import { err, ok } from "../../domain/result.js"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; import { PE_RESOURCES_PROVIDER } from "../../application/InvestigationProviders.js"; import { parsePeResources } from "./PeResourceParser.js"; /** Portable parser over one bounded, identity-checked regular file. */ export class PeResourcesProvider implements PeResourcesPort { readonly identity = PE_RESOURCES_PROVIDER; + readonly #resources = new ArtifactResourceScope(); + + close(): Promise { + return this.#resources.close(); + } async inspect(input: InspectPeResourcesInput, options?: ExecutionOptions) { try { - const snapshot = await readStableArtifact( - input.path, - input.max_file_bytes, - options?.signal, + const snapshot = await this.#resources.run(() => + readStableArtifact( + input.path, + input.max_file_bytes, + options?.signal, + undefined, + this.#resources, + ), ); return ok( await parsePeResources( @@ -44,12 +56,21 @@ export class PeResourcesProvider implements PeResourcesPort { } } -const peResourcesFailure = ( +const peResourcesFailurePrimary = ( cause: unknown, input: InspectPeResourcesInput, signal: AbortSignal | undefined, ) => { const operation = "inspect_pe_resources"; + if (cause instanceof ArtifactReaderFailure && cause.reason === "integrity") + return new AnalysisArtifactChangedError( + operation, + input.path, + cause.message, + { cause }, + ); + if (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") + return new AnalysisCancelledError(operation, { cause }); if (signal?.aborted) return new AnalysisCancelledError(operation, { cause }); if (cause instanceof ArtifactReaderFailure) { @@ -64,13 +85,6 @@ const peResourcesFailure = ( }, { cause }, ); - if (cause.reason === "integrity") - return new AnalysisArtifactChangedError( - operation, - input.path, - cause.message, - { cause }, - ); if (cause.reason === "path" || cause.reason === "format") return new AnalysisInputError(operation, { cause }, [ { @@ -79,8 +93,6 @@ const peResourcesFailure = ( message: cause.message, }, ]); - if (cause.reason === "cancelled") - return new AnalysisCancelledError(operation, { cause }); } if (cause instanceof Error && "code" in cause) { @@ -106,3 +118,24 @@ const peResourcesFailure = ( { cause }, ); }; + +const peResourcesFailure = ( + cause: unknown, + input: InspectPeResourcesInput, + signal: AbortSignal | undefined, +) => { + const primary = peResourcesFailurePrimary(cause, input, signal); + if (!(cause instanceof ArtifactReaderFailure) || cause.cleanup === undefined) + return primary; + const cleanup = new ProviderCleanupError( + PE_RESOURCES_PROVIDER.id, + cause.cleanup.resources, + { reason: cause.cleanup.reason }, + { operation: "inspect_pe_resources", cause }, + ); + return analysisErrorWithCleanupFailure( + primary, + cleanup, + "inspect_pe_resources", + ); +}; diff --git a/src/native/pwndbg/PwndbgCoreContext.ts b/src/native/pwndbg/PwndbgCoreContext.ts index 0ef29cd9be..db8386d26b 100644 --- a/src/native/pwndbg/PwndbgCoreContext.ts +++ b/src/native/pwndbg/PwndbgCoreContext.ts @@ -17,6 +17,7 @@ import { type RecordedCrash, } from "../../domain/native/recordedCrash.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { capturedPwntoolsOutput, @@ -46,6 +47,7 @@ const replySchema = z.discriminatedUnion("ok", [ export const inspectPwndbgCore = async ({ environment, rootPath, + resources, diagnostics, options, runCommand, @@ -53,6 +55,7 @@ export const inspectPwndbgCore = async ({ }: { readonly environment: Readonly; readonly rootPath: string; + readonly resources: ArtifactResourceScope; readonly diagnostics: AnalysisCapturedOutput; readonly options: ExecutionOptions | undefined; readonly runCommand: PwntoolsCommand; @@ -178,11 +181,14 @@ export const inspectPwndbgCore = async ({ replyPath, PWNTOOLS_LIMITS.outputBytes, options?.signal, + undefined, + resources, ); reply = replySchema.parse(JSON.parse(raw.bytes.toString("utf8"))); } catch (cause: unknown) { if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION, { + cause, capturedOutput: recordedCrashStageOutput(diagnostics, output), }); throw new AnalysisOutputError( @@ -226,22 +232,22 @@ export const inspectPwndbgCore = async ({ return { ...reply.value, diagnostics: output }; } catch (cause: unknown) { if (cause instanceof AnalysisError && cause.cleanupIncomplete) throw cause; - if ( + const primary = options?.signal?.aborted && (cause === options.signal.reason || (cause instanceof Error && cause.name === "AbortError")) - ) - throw new AnalysisCancelledError(OPERATION, { - capturedOutput: recordedCrashStageOutput(diagnostics, output), - }); - throw pwntoolsDecoderFailure( - cause, - "debugger", - gdb, - environment.REA_PWNTOOLS_PYTHON ?? "", - await readPwntoolsFailureEvidence(cause, stage), - failureContext, - ); + ? new AnalysisCancelledError(OPERATION, { + capturedOutput: recordedCrashStageOutput(diagnostics, output), + }) + : pwntoolsDecoderFailure( + cause, + "debugger", + gdb, + environment.REA_PWNTOOLS_PYTHON ?? "", + await readPwntoolsFailureEvidence(cause, stage, resources), + failureContext, + ); + throw primary; } }; diff --git a/src/native/pwntools/PwntoolsDecoder.ts b/src/native/pwntools/PwntoolsDecoder.ts index d8cad3d2ea..169f25601e 100644 --- a/src/native/pwntools/PwntoolsDecoder.ts +++ b/src/native/pwntools/PwntoolsDecoder.ts @@ -16,6 +16,8 @@ import type { AnalysisPartialObservation, } from "../../domain/analysisErrorBase.js"; import { readStableArtifact } from "../../artifacts/readStableArtifact.js"; +import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; import { AnalysisCancelledError, @@ -78,6 +80,7 @@ export interface PwntoolsDecoderProfile< readonly payload: Payload; readonly snapshot: { readonly bytes: Buffer; readonly sha256: string }; readonly rootPath: string; + readonly resources: ArtifactResourceScope; readonly diagnostics: AnalysisCapturedOutput; readonly options: ExecutionOptions | undefined; readonly runCommand: PwntoolsCommand; @@ -96,6 +99,7 @@ export class PwntoolsDecoder< symbol, "admitted" | "cancelled" >(); + readonly #resources = new ArtifactResourceScope(); #closing = false; #closePromise: Promise | undefined; #pendingCleanup: @@ -244,6 +248,8 @@ export class PwntoolsDecoder< input.path, PWNTOOLS_LIMITS.inputBytes, options?.signal, + undefined, + this.#resources, ); phase = "decoder"; root = await this.createRuntime(); @@ -298,11 +304,16 @@ export class PwntoolsDecoder< replyPath, PWNTOOLS_LIMITS.outputBytes, options?.signal, + undefined, + this.#resources, ); reply = replySchema.parse(JSON.parse(file.bytes.toString("utf8"))); } catch (cause: unknown) { if (options?.signal?.aborted) - throw new AnalysisCancelledError(OPERATION, { capturedOutput }); + throw new AnalysisCancelledError(OPERATION, { + cause, + capturedOutput, + }); throw new AnalysisOutputError( OPERATION, `Owned pwntools decoder reply failed for ${input.path}: ${cause instanceof z.ZodError ? cause.issues[0]?.message + " at " + (cause.issues[0]?.path.map(String).join(".") ?? "root") : cause instanceof Error ? cause.message : String(cause)}`, @@ -369,6 +380,7 @@ export class PwntoolsDecoder< payload: reply.value, snapshot, rootPath: root.path, + resources: this.#resources, diagnostics: capturedOutput, options, runCommand: (spawn, classifyFailure) => @@ -385,12 +397,18 @@ export class PwntoolsDecoder< } return err(cause); } - const evidence = await readPwntoolsFailureEvidence(cause, root?.path); - result = err( + const evidence = await readPwntoolsFailureEvidence( + cause, + root?.path, + this.#resources, + ); + const primary = options?.signal?.aborted && - (cause === options.signal.reason || - (cause instanceof Error && cause.name === "AbortError")) - ? new AnalysisCancelledError(OPERATION) + (cause === options.signal.reason || + (cause instanceof Error && cause.name === "AbortError") || + (cause instanceof ArtifactReaderFailure && + cause.cleanup !== undefined)) + ? new AnalysisCancelledError(OPERATION, { cause }) : pwntoolsDecoderFailure( cause, phase, @@ -398,8 +416,8 @@ export class PwntoolsDecoder< executablePath, evidence, context, - ), - ); + ); + result = err(primary); } if (this.#pendingCleanup !== undefined) { this.#pendingCleanup.failure = result.ok ? undefined : result.error; @@ -483,7 +501,39 @@ export class PwntoolsDecoder< this.#closing = true; this.#closePromise ??= this.#tail .then(async () => { - const failure = await this.#retire(); + let failure: AnalysisError | undefined; + try { + failure = await this.#retire(); + } catch (cause: unknown) { + failure = new ProviderCleanupError( + this.identity.id, + [this.#pendingCleanup?.root?.path ?? "pwntools decoder"], + { reason: cause instanceof Error ? cause.message : String(cause) }, + { operation: this.profile.operation, cause }, + ); + } + try { + await this.#resources.close(); + } catch (cause: unknown) { + const observation = ArtifactReaderFailure.cleanupObservation( + cause, + "pwntools artifact read", + ); + const resourceFailure = new ProviderCleanupError( + this.identity.id, + observation.resources, + { reason: observation.reason }, + { operation: this.profile.operation, cause }, + ); + failure = + failure === undefined + ? resourceFailure + : analysisErrorWithCleanupFailure( + failure, + resourceFailure, + this.profile.operation, + ); + } if (failure !== undefined) throw failure; }) .catch((cause: unknown) => { @@ -502,8 +552,8 @@ export class PwntoolsDecoder< } async #completeCleanup( - retire: () => Promise, - ): Promise { + retire: () => Promise, + ): Promise { const failure = await retire(); return failure !== undefined && this.#closing ? retire() : failure; } @@ -548,18 +598,46 @@ export class PwntoolsDecoder< return undefined; } - async #retire(): Promise { - const failure = await this.#retireProcess(); - if (failure !== undefined) return failure; + async #retire(): Promise { + let failure: AnalysisError | undefined; + try { + await this.#resources.retryCleanup(); + } catch (cause: unknown) { + const observation = ArtifactReaderFailure.cleanupObservation( + cause, + "pwntools artifact read", + ); + failure = new ProviderCleanupError( + this.identity.id, + observation.resources, + { reason: observation.reason }, + { + operation: this.profile.operation, + cause, + ...(this.#pendingCleanup?.observation === undefined + ? {} + : { partialObservation: this.#pendingCleanup.observation }), + }, + ); + } + const processFailure = await this.#retireProcess(); + if (processFailure !== undefined) { + return failure === undefined + ? processFailure + : analysisErrorWithCleanupFailure( + failure, + processFailure, + this.profile.operation, + ); + } const pending = this.#pendingCleanup; - if (pending === undefined) return undefined; - if (pending.root !== undefined) { + if (pending?.root !== undefined) { const root = pending.root; try { await root.close(); pending.root = undefined; } catch (cause: unknown) { - return new ProviderCleanupError( + const rootFailure = new ProviderCleanupError( this.identity.id, [root.path], { @@ -580,9 +658,18 @@ export class PwntoolsDecoder< : { partialObservation: pending.observation }), }, ); + failure = + failure === undefined + ? rootFailure + : analysisErrorWithCleanupFailure( + failure, + rootFailure, + this.profile.operation, + ); } } - this.#pendingCleanup = undefined; - return undefined; + if (!this.#resources.hasPending && pending?.root === undefined) + this.#pendingCleanup = undefined; + return failure; } } diff --git a/src/native/pwntools/PwntoolsRecordedCrashProvider.ts b/src/native/pwntools/PwntoolsRecordedCrashProvider.ts index ffc10d13bc..24cee73143 100644 --- a/src/native/pwntools/PwntoolsRecordedCrashProvider.ts +++ b/src/native/pwntools/PwntoolsRecordedCrashProvider.ts @@ -48,6 +48,7 @@ export class PwntoolsRecordedCrashProvider payload, snapshot, rootPath, + resources, diagnostics, options, runCommand, @@ -120,6 +121,7 @@ export class PwntoolsRecordedCrashProvider const context = await inspectPwndbgCore({ environment, rootPath, + resources, diagnostics, options, runCommand, diff --git a/src/native/pwntools/PwntoolsResourceLimits.ts b/src/native/pwntools/PwntoolsResourceLimits.ts index dcbe91d5ab..3b379779f8 100644 --- a/src/native/pwntools/PwntoolsResourceLimits.ts +++ b/src/native/pwntools/PwntoolsResourceLimits.ts @@ -7,6 +7,7 @@ import { } from "./PwntoolsRelease.js"; import { z } from "zod"; import { PWNTOOLS_LIMITS } from "./PwntoolsRelease.js"; +import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; /** Actual limits reported by the owned bridge after lowering inherited soft limits. */ export const pwntoolsResourceLimitsSchema = z.strictObject({ @@ -42,6 +43,7 @@ export interface PwntoolsFailureEvidence { export const readPwntoolsFailureEvidence = async ( cause: unknown, rootPath?: string, + resources?: ArtifactResourceScope, ): Promise => { let limitReport: PwntoolsLimitReport | undefined; let marker: PwntoolsFailureEvidence["marker"]; @@ -56,6 +58,9 @@ export const readPwntoolsFailureEvidence = async ( const reported = await readStableArtifact( join(rootPath, "resource.failure"), 1, + undefined, + undefined, + resources, ); const expected = cause.snapshot.exitCode === PWNTOOLS_MEMORY_FAILURE_EXIT ? "M" : "F"; @@ -90,6 +95,9 @@ export const readPwntoolsFailureEvidence = async ( const reported = await readStableArtifact( join(rootPath, "limits.json"), 4096, + undefined, + undefined, + resources, ); limitReport = { limits: pwntoolsResourceLimitsSchema.parse( diff --git a/src/server/createServer.ts b/src/server/createServer.ts index 777028cf82..c73071beaf 100644 --- a/src/server/createServer.ts +++ b/src/server/createServer.ts @@ -9,6 +9,7 @@ import { wasmArtifactModule } from "../composition/wasm.js"; import { registerWasmTools } from "./registerWasmTools.js"; import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { createPeResourcesService } from "../composition/binaryDiagnostics.js"; +import type { PeResourcesService } from "../application/binaryDiagnostics/PeResourcesService.js"; import { registerPeResourcesTool } from "./registerPeResourcesTool.js"; import type { EvmInterfaceService } from "../application/evm/EvmInterfaceService.js"; import { evmInterfaceModule } from "../composition/evm.js"; @@ -280,6 +281,10 @@ export const createServer = ( options.webSourceLocation ?? createWebSourceLocationService(environment); const webModuleTrace = options.webModuleTrace ?? createWebModuleTraceService(environment); + const goBinary = options.goBinary ?? createGoBinaryService(); + const peResources = createPeResourcesService(); + const webNetworkCapture = + options.webNetworkCapture ?? createWebNetworkCaptureService(environment); const javascriptRecovery = options.javascriptRecovery ?? createJavaScriptRecoveryProvider(environment); const artifactResources = new ArtifactResourceScope(); @@ -345,6 +350,9 @@ export const createServer = ( recordedCrash.close(), webSourceLocation.close(), webModuleTrace.close(), + goBinary.close(), + peResources.close(), + webNetworkCapture.close(), javascriptRecovery.close?.() ?? Promise.resolve(), artifactResources.close(), firmwareAnalysis.close(), @@ -388,6 +396,9 @@ export const createServer = ( recordEvidenceWithUnknown, withAdmittedAnalysis: analysisAdmission, artifactResources, + goBinary, + peResources, + webNetworkCapture, }; registerBinaryAnalysisTools(toolContext); registerFridaTools( @@ -517,6 +528,9 @@ const registerConfiguredAnalysisTools = ( evidenceById, recordEvidence, withAdmittedAnalysis, + goBinary, + peResources, + webNetworkCapture, }: ServerToolContext, android: AndroidAnalysisPort, wasmArtifact: Pick, @@ -556,14 +570,14 @@ const registerConfiguredAnalysisTools = ( ); registerPeResourcesTool( server, - createPeResourcesService(), + peResources, toolLogger, recordEvidence, withAdmittedAnalysis, ); registerGoTools( server, - options.goBinary ?? createGoBinaryService(), + goBinary, toolLogger, recordEvidence, withAdmittedAnalysis, @@ -628,7 +642,7 @@ const registerConfiguredAnalysisTools = ( ); registerWebNetworkCaptureTool( server, - options.webNetworkCapture ?? createWebNetworkCaptureService(environment), + webNetworkCapture, toolLogger, recordEvidence, withAdmittedAnalysis, @@ -673,6 +687,9 @@ const createSessionRecorders = ( }); interface ServerToolContext extends ReturnType { + readonly goBinary: GoBinaryService; + readonly peResources: PeResourcesService; + readonly webNetworkCapture: WebNetworkCaptureService; readonly artifactResources: ArtifactResourceScope; readonly server: EvidenceMcpServer; readonly session: BinarySessionPort | undefined; diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts index 94c4e0c95c..428cf8cbd1 100644 --- a/src/wasm/WabtArtifactProvider.test.ts +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -1,6 +1,6 @@ import { access, chmod, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { WabtArtifactProvider } from "./WabtArtifactProvider.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; @@ -9,6 +9,33 @@ import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js"; import { OwnedCommandFailure } from "../process/OwnedCommand.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +const fileCloseFailure = vi.hoisted(() => ({ + selectedPath: "", + attempts: 0, + failFirst: false, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + open: async (...args: Parameters) => { + const handle = await fs.open(...args); + if (String(args[0]) !== fileCloseFailure.selectedPath) return handle; + const close = handle.close.bind(handle); + handle.close = async () => { + fileCloseFailure.attempts += 1; + if (fileCloseFailure.failFirst) { + fileCloseFailure.failFirst = false; + throw new Error("injected WABT input close failure"); + } + return close(); + }; + return handle; + }, + }; +}); + const failureTags: Readonly> = { invalid: "AnalysisInputError", timeout: "AnalysisTimeoutError", @@ -20,6 +47,82 @@ const failureTags: Readonly> = { "wat-warning": "AnalysisOutputError", }; +const assertCleanupRecovery = async (options: { + readonly provider: WabtArtifactProvider; + readonly result: Awaited>; + readonly path: string; + readonly runtimePath: string; + readonly cleanupAttempts: () => number; + readonly runtimeCreations: () => number; +}): Promise => { + expect(options.result).toMatchObject({ + error: { + cleanupIncomplete: true, + cleanupResources: [options.runtimePath], + partialObservation: { normalized_result: { validation: "valid" } }, + }, + }); + await expect(access(options.runtimePath)).resolves.toBeUndefined(); + const blocked = await options.provider.inspect({ + path: options.path, + glue_paths: [], + candidate_paths: [], + }); + expect(blocked).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + cleanupResources: [options.runtimePath], + }, + }); + expect(options.cleanupAttempts()).toBe(2); + expect(options.runtimeCreations()).toBe(1); + await options.provider.close(); + expect(options.cleanupAttempts()).toBe(3); + await expect(access(options.runtimePath)).rejects.toMatchObject({ + code: "ENOENT", + }); +}; + +const assertModeOutcome = ( + mode: string, + result: Awaited>, + path: string, +): void => { + if (mode === "success") + expect(result).toMatchObject({ + ok: true, + value: { + result: { + artifact: { path, bytes: 76 }, + validation: "valid", + runtime_execution: "not-performed", + }, + }, + }); + else + expect(result).toMatchObject({ + ok: false, + error: { _tag: failureTags[mode] }, + }); + if (mode === "invalid") + expect(result).toMatchObject({ + error: { capturedOutput: { stderr: "module.wasm: invalid bytes\n" } }, + }); + if (mode === "malformed-wat" || mode === "wat-warning") + expect(result).toMatchObject({ + error: { + capturedOutput: { + stdout: + mode === "malformed-wat" + ? "unexpected decoder output\n" + : '(module (@custom "test" "payload"))\n', + stderr: mode === "wat-warning" ? "decoder warning\n" : "", + }, + }, + }); +}; + it("returns actionable unavailable without creating a workspace when tools are absent", async () => { const provider = new WabtArtifactProvider({}, undefined, () => { throw new Error("Must not allocate"); @@ -39,6 +142,88 @@ it("returns actionable unavailable without creating a workspace when tools are a }); }); +it("retries a retained input descriptor before admitting the next WABT operation", async () => { + const root = await createTestTempDirectory("rea-wabt-read-cleanup-"); + const path = join(root, "selected.wasm"); + await writeFile(path, Buffer.alloc(76, 1)); + for (const tool of ["wasm-validate", "wasm-objdump", "wasm2wat"]) + await writeFile(join(root, tool), "source-owned executable seam", { + mode: 0o700, + }); + const dump = await readFile( + new URL("../../tests/fixtures/wasm/objdump.txt", import.meta.url), + "utf8", + ); + const provider = new WabtArtifactProvider( + { REA_WABT_BIN_DIRECTORY: root }, + async (input) => { + const version = input.arguments[0] === "--version"; + const stdout = version + ? "1.0.42\n" + : input.command.includes("wasm-objdump") + ? dump + : input.command.includes("wasm2wat") + ? '(module (@custom "test" "payload"))\n' + : ""; + const launched = await spawnOwnedProviderProcess({ + ...input, + command: process.execPath, + arguments: [ + "-e", + `process.stdout.write(${JSON.stringify(stdout)}); process.exit(0);`, + ], + expectedCommand: null, + }); + return launched; + }, + ); + fileCloseFailure.selectedPath = path; + fileCloseFailure.attempts = 0; + fileCloseFailure.failFirst = true; + try { + const first = await provider.inspect({ + path, + candidate_paths: [], + glue_paths: [], + }); + expect(first).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + cleanupResources: [path], + cause: { message: "injected WABT input close failure" }, + }, + }); + expect(fileCloseFailure.attempts).toBe(1); + + const retried = await provider.inspect({ + path, + candidate_paths: [], + glue_paths: [], + }); + expect(retried.ok).toBe(true); + expect(fileCloseFailure.attempts).toBeGreaterThan(1); + + fileCloseFailure.failFirst = true; + const closeRetry = await provider.inspect({ + path, + candidate_paths: [], + glue_paths: [], + }); + expect(closeRetry).toMatchObject({ + ok: false, + error: { cleanupIncomplete: true, cleanupResources: [path] }, + }); + const beforeClose = fileCloseFailure.attempts; + await provider.close(); + expect(fileCloseFailure.attempts).toBeGreaterThan(beforeClose); + } finally { + fileCloseFailure.selectedPath = ""; + fileCloseFailure.failFirst = false; + await provider.close(); + } +}); + it.each([false, true])( "reports an unavailable private runtime root with retryable=%s without a misleading WABT remediation", async (retryable) => { @@ -110,6 +295,7 @@ it.each([ "utf8", ); let runtimePath = ""; + let runtimeCreations = 0; let acquiredPid: number | undefined; let cleanupAttempts = 0; const controller = new AbortController(); @@ -168,6 +354,7 @@ it.each([ return launched; }, async () => { + runtimeCreations += 1; const root = await PrivateRuntimeRoot.create({ prefix: "rea-wabt-owned-test-", }); @@ -186,53 +373,17 @@ it.each([ { path, glue_paths: [], candidate_paths: [] }, { signal: controller.signal }, ); - if (mode === "success") - expect(result).toMatchObject({ - ok: true, - value: { - result: { - artifact: { path, bytes: 76 }, - validation: "valid", - runtime_execution: "not-performed", - }, - }, - }); - else - expect(result).toMatchObject({ - ok: false, - error: { _tag: failureTags[mode] }, - }); - if (mode === "invalid") - expect(result).toMatchObject({ - error: { capturedOutput: { stderr: "module.wasm: invalid bytes\n" } }, - }); + assertModeOutcome(mode, result, path); if (mode === "cleanup") { - expect(result).toMatchObject({ - error: { - cleanupIncomplete: true, - cleanupResources: [runtimePath], - partialObservation: { normalized_result: { validation: "valid" } }, - }, + await assertCleanupRecovery({ + provider, + result, + path, + runtimePath, + cleanupAttempts: () => cleanupAttempts, + runtimeCreations: () => runtimeCreations, }); - await expect(access(runtimePath)).resolves.toBeUndefined(); - await expect(provider.close()).rejects.toMatchObject({ - cleanupIncomplete: true, - }); - await expect(access(runtimePath)).resolves.toBeUndefined(); - await provider.close(); } - if (mode === "malformed-wat" || mode === "wat-warning") - expect(result).toMatchObject({ - error: { - capturedOutput: { - stdout: - mode === "malformed-wat" - ? "unexpected decoder output\n" - : '(module (@custom "test" "payload"))\n', - stderr: mode === "wat-warning" ? "decoder warning\n" : "", - }, - }, - }); await expect(access(runtimePath)).rejects.toMatchObject({ code: "ENOENT" }); expect(await readFile(path)).toEqual(bytes); if (acquiredPid !== undefined) { diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index fe79914ca4..c5f0aef53c 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -9,6 +9,8 @@ import type { WasmArtifactPort, } from "../application/wasm/WasmArtifactPort.js"; import { readStableArtifact } from "../artifacts/readStableArtifact.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; import type { AnalysisError, AnalysisCapturedOutput, @@ -16,6 +18,7 @@ import type { import { AnalysisCancelledError, AnalysisArtifactChangedError, + AnalysisCapabilityUnavailableError, AnalysisInputError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; @@ -41,9 +44,7 @@ const digest = (text: string) => createHash("sha256").update(text).digest("hex"); /** Run caller-supplied WABT against private copies of exactly selected bytes. */ export class WabtArtifactProvider implements WasmArtifactPort { - readonly #pendingRoots = new Set< - Pick - >(); + readonly #resources = new ArtifactResourceScope(); constructor( readonly environment: Readonly = process.env, readonly launcher?: Launcher, @@ -53,29 +54,51 @@ export class WabtArtifactProvider implements WasmArtifactPort { ) {} /** Retry retained cleanup without discarding roots that still cannot be removed. */ async close(): Promise { - const failures: { path: string; reason: string }[] = []; - for (const root of this.#pendingRoots) { - try { - await root.close(); - this.#pendingRoots.delete(root); - } catch (cause: unknown) { - failures.push({ - path: root.path, - reason: cause instanceof Error ? cause.message : String(cause), - }); - } - } - if (failures.length > 0) + try { + await this.#resources.close(); + } catch (cause: unknown) { + const cleanup = + cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; throw new ProviderCleanupError( WABT_PROVIDER_IDENTITY.id, - failures.map(({ path }) => path), - { failures }, - { operation: OPERATION }, + cleanup?.resources ?? ["artifact-resources"], + { + reason: + cleanup?.reason ?? + (cause instanceof Error ? cause.message : String(cause)), + }, + { operation: OPERATION, cause }, ); + } } async inspect( input: InspectWasmArtifactInput, options?: ExecutionOptions, + ): Promise> { + try { + return await this.#resources.run(() => this.#inspect(input, options)); + } catch (cause: unknown) { + if ( + cause instanceof ArtifactReaderFailure && + cause.reason === "unavailable" && + cause.cleanup === undefined + ) + return err(this.#closedError()); + return err(wabtFailure(cause, "artifact", input.path)); + } + } + + #closedError(): AnalysisCapabilityUnavailableError { + return new AnalysisCapabilityUnavailableError( + WABT_PROVIDER_IDENTITY.id, + OPERATION, + "Provider is closing", + ); + } + + async #inspect( + input: InspectWasmArtifactInput, + options?: ExecutionOptions, ): Promise> { let root: Pick | undefined; let phase: "configuration" | "artifact" | "producer" = "configuration"; @@ -83,7 +106,6 @@ export class WabtArtifactProvider implements WasmArtifactPort { let capturedOutput: AnalysisCapturedOutput | undefined; try { if (options?.signal?.aborted) throw new AnalysisCancelledError(OPERATION); - await this.close(); const directory = this.environment.REA_WABT_BIN_DIRECTORY ?? ""; if (!isAbsolute(directory)) throw new Error( @@ -108,6 +130,8 @@ export class WabtArtifactProvider implements WasmArtifactPort { path, WABT_LIMITS.toolBytes, options?.signal, + undefined, + this.#resources, ); commands.push({ tool, @@ -130,17 +154,24 @@ export class WabtArtifactProvider implements WasmArtifactPort { snapshotPath, WABT_LIMITS.inputBytes, options?.signal, + undefined, + this.#resources, ); if (bytes.sha256 !== snapshotHash) throw new Error("Private WASM snapshot bytes changed."); } catch (cause: unknown) { + const cleanup = + cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; if (options?.signal?.aborted) - throw new AnalysisCancelledError(OPERATION); + throw new AnalysisCancelledError(OPERATION, { + cause, + ...(cleanup === undefined ? {} : { cleanup }), + }); throw new AnalysisArtifactChangedError( OPERATION, input.path, "Private WASM snapshot no longer matches selected byte identity.", - { cause }, + { cause, ...(cleanup === undefined ? {} : { cleanup }) }, ); } }; @@ -175,6 +206,8 @@ export class WabtArtifactProvider implements WasmArtifactPort { command.path, WABT_LIMITS.toolBytes, options?.signal, + undefined, + this.#resources, ); if (current.sha256 !== command.sha256) throw new AnalysisOutputError( @@ -200,6 +233,8 @@ export class WabtArtifactProvider implements WasmArtifactPort { input.path, WABT_LIMITS.inputBytes, options?.signal, + undefined, + this.#resources, ); const artifact = { path: input.path, @@ -215,6 +250,8 @@ export class WabtArtifactProvider implements WasmArtifactPort { path, WABT_LIMITS.inputBytes, options?.signal, + undefined, + this.#resources, ); candidates.push({ path, @@ -228,6 +265,8 @@ export class WabtArtifactProvider implements WasmArtifactPort { path, WABT_LIMITS.inputBytes, options?.signal, + undefined, + this.#resources, ); const source = new TextDecoder("utf-8", { fatal: true }).decode( bytes.bytes, @@ -395,10 +434,13 @@ export class WabtArtifactProvider implements WasmArtifactPort { ); } if (root !== undefined) { - try { - await root.close(); - } catch (cause: unknown) { - this.#pendingRoots.add(root); + const cleanup = await this.#resources.release({ + kind: "private-runtime-root", + resource: root.path, + root, + }); + if (cleanup.kind === "failed") { + const cause = cleanup.cause; const partialObservation = result.ok ? createEvidence( result.value.subject ?? undefined, diff --git a/src/wasm/WabtFailures.ts b/src/wasm/WabtFailures.ts index 4f386998f5..7b68752032 100644 --- a/src/wasm/WabtFailures.ts +++ b/src/wasm/WabtFailures.ts @@ -20,12 +20,19 @@ export const wabtFailure = ( path: string, ): AnalysisError => { if (cause instanceof AnalysisError) return cause; + const artifactCleanup = + cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; + const cleanupOptions = + artifactCleanup === undefined ? {} : { cleanup: artifactCleanup }; const reason = cause instanceof Error ? cause.message : String(cause); if ( (cause instanceof ArtifactReaderFailure && cause.reason === "cancelled") || (cause instanceof Error && cause.name === "AbortError") ) - return new AnalysisCancelledError("inspect_wasm_artifact", { cause }); + return new AnalysisCancelledError("inspect_wasm_artifact", { + cause, + ...cleanupOptions, + }); if (cause instanceof OwnedCommandFailure) { const capturedOutput = cause.snapshot === null @@ -85,6 +92,7 @@ export const wabtFailure = ( reason, { cause, + ...cleanupOptions, userMessage: `${reason} Supply WABT 1.0.42 via absolute REA_WABT_BIN_DIRECTORY; REA does not install tools.`, }, ); @@ -97,7 +105,7 @@ export const wabtFailure = ( "inspect_wasm_artifact", path, reason, - { cause }, + { cause, ...cleanupOptions }, ); if ( phase === "artifact" && @@ -109,11 +117,16 @@ export const wabtFailure = ( "inspect_wasm_artifact", path, cause.code, - { cause }, + { cause, ...cleanupOptions }, ); if (phase === "artifact") - return new AnalysisInputError("inspect_wasm_artifact", { cause }, [ - { path: ["path"], reason: "invalid_value", message: reason }, - ]); - return new AnalysisOutputError("inspect_wasm_artifact", reason, { cause }); + return new AnalysisInputError( + "inspect_wasm_artifact", + { cause, ...cleanupOptions }, + [{ path: ["path"], reason: "invalid_value", message: reason }], + ); + return new AnalysisOutputError("inspect_wasm_artifact", reason, { + cause, + ...cleanupOptions, + }); }; diff --git a/tests/boundary/browser/historicalCaptureDecoder.test.ts b/tests/boundary/browser/historicalCaptureDecoder.test.ts index 92b26d5df5..e02524f8d4 100644 --- a/tests/boundary/browser/historicalCaptureDecoder.test.ts +++ b/tests/boundary/browser/historicalCaptureDecoder.test.ts @@ -11,7 +11,7 @@ import { } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { expect, it, onTestFinished } from "vitest"; +import { expect, it, onTestFinished, vi } from "vitest"; import { HistoricalCaptureDecoder } from "../../../src/browser/history/HistoricalCaptureDecoder.js"; import { MitmproxyCaptureAdapter } from "../../../src/browser/history/MitmproxyCaptureAdapter.js"; import { HAR_CAPTURE_PROVIDER_IDENTITY } from "../../../src/browser/history/CaptureRelease.js"; @@ -22,6 +22,7 @@ import { import { historicalHar } from "../../fixtures/historicalHar.js"; import { decodeHarCapture } from "../../../src/browser/history/HarCapture.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js"; const linux = it.skipIf(process.platform !== "linux"); @@ -138,6 +139,66 @@ it("classifies an oversized selected capture before creating an owned decoder", }); }); +it("retains a failed capture reader and retries it before a fresh load", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-capture-reader-close-")); + onTestFinished(() => rm(root, { recursive: true, force: true })); + const path = join(root, "input.har"); + await writeFile(path, JSON.stringify(historicalHar())); + const originalClose = OwnedFileHandle.prototype.close; + const close = vi.spyOn(OwnedFileHandle.prototype, "close"); + let allowClose = false; + let closeAttempts = 0; + close.mockImplementation(async function (this: OwnedFileHandle) { + closeAttempts += 1; + if (!allowClose) throw new Error("injected capture reader close failure"); + await originalClose.call(this); + }); + let decoderCalls = 0; + const decoder = new HistoricalCaptureDecoder( + [ + { + format: "har", + identity: HAR_CAPTURE_PROVIDER_IDENTITY, + command: async () => { + decoderCalls += 1; + return { + command: process.execPath, + arguments: ["-e", "process.exit(0)"], + }; + }, + }, + ], + process.env, + ); + const input = inspectWebNetworkCaptureInputSchema.parse({ + capture_path: path, + format: "har", + }); + try { + const first = await decoder.inspect(input); + expect(first.ok).toBe(false); + if (first.ok) throw new Error("Reader close failure must fail inspection"); + expect(first.error.cleanupResources).toContain(path); + expect(decoderCalls).toBe(0); + + const refused = await decoder.inspect(input); + expect(refused.ok).toBe(false); + if (refused.ok) throw new Error("Fresh read must wait for owner cleanup"); + expect(refused.error.cleanupIncomplete).toBe(true); + expect(refused.error.cleanupResources).toContain(path); + expect(decoderCalls).toBe(0); + expect(closeAttempts).toBe(2); + + allowClose = true; + await decoder.close(); + expect(closeAttempts).toBe(3); + } finally { + allowClose = true; + await decoder.close().catch(() => undefined); + close.mockRestore(); + } +}); + it("does not misclassify decoder filesystem failures as missing selected input", async () => { const root = await mkdtemp(join(tmpdir(), "rea-historical-command-")); onTestFinished(() => rm(root, { recursive: true, force: true })); diff --git a/tests/boundary/cli/captureCleanup.test.ts b/tests/boundary/cli/captureCleanup.test.ts index 8f34b68395..6e5882cd49 100644 --- a/tests/boundary/cli/captureCleanup.test.ts +++ b/tests/boundary/cli/captureCleanup.test.ts @@ -5,6 +5,7 @@ import { silentLogger } from "../../../src/logger.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; import { err } from "../../../src/domain/result.js"; +import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; it.each([false, true])( "awaits cleanup retry and preserves completed output when retry failure is %s", @@ -82,6 +83,111 @@ it.each([false, true])( }, ); +it("keeps the primary CLI failure when close throws an untyped error", async () => { + const cli = Cli.create("capture-cleanup-primary-test", { sync: false }); + registerCaptureCommands( + cli, + silentLogger, + {}, + { + inspectFields: async () => { + throw new Error("Unexpected field discovery call"); + }, + inspect: async () => + err( + new AnalysisInputError("inspect_packet_capture", { + userMessage: "The selected capture input was rejected.", + }), + ), + close: async () => { + throw new Error("raw close failure"); + }, + }, + ); + let stdout = ""; + await cli.serve( + [ + "inspect-packet-capture", + "recording.pcap", + "--fields", + "ip.addr", + "--format", + "json", + ], + { + stdout: (text) => { + stdout += text; + }, + exit: () => {}, + }, + ); + expect(JSON.parse(stdout)).toMatchObject({ + code: "cleanup_incomplete", + message: "The selected capture input was rejected.", + details: { + diagnostics: { + primary_error: { code: "invalid_request" }, + }, + resources: [], + }, + }); +}); + +it("closes the service and retains an unexpected producer rejection", async () => { + const cli = Cli.create("capture-producer-rejection-test", { sync: false }); + let closed = false; + registerCaptureCommands( + cli, + silentLogger, + {}, + { + inspectFields: async () => { + throw new Error("Unexpected field discovery call"); + }, + inspect: async () => { + throw new Error("raw producer failure"); + }, + close: async () => { + closed = true; + throw new Error("raw close failure"); + }, + }, + ); + let stdout = ""; + await cli.serve( + [ + "inspect-packet-capture", + "recording.pcap", + "--fields", + "ip.addr", + "--format", + "json", + ], + { + stdout: (text) => { + stdout += text; + }, + exit: () => {}, + }, + ); + expect(closed).toBe(true); + expect(JSON.parse(stdout)).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { + primary_error: { + code: "execution_failure", + details: { + operation: "inspect_packet_capture", + provider_id: "tshark-capture", + }, + }, + cleanup_error: { code: "cleanup_incomplete" }, + }, + }, + }); +}); + it("rejects an oversized selector budget before the CLI invokes its service", async () => { const cli = Cli.create("capture-selector-test", { sync: false }); let called = false; diff --git a/tests/boundary/filesystem/stableArtifact.test.ts b/tests/boundary/filesystem/stableArtifact.test.ts index 454d9064b8..3bd587ad0d 100644 --- a/tests/boundary/filesystem/stableArtifact.test.ts +++ b/tests/boundary/filesystem/stableArtifact.test.ts @@ -31,9 +31,12 @@ it("reads exact binary and empty bytes and rejects symlink/size/cancellation bou }); const controller = new AbortController(); controller.abort(); - await expect(readStableArtifact(real, 3, controller.signal)).rejects.toBe( - controller.signal.reason, - ); + await expect( + readStableArtifact(real, 3, controller.signal), + ).rejects.toMatchObject({ + reason: "cancelled", + cause: controller.signal.reason, + }); }); const checkChange = async ( diff --git a/tests/boundary/filesystem/webArtifactReadOwnership.test.ts b/tests/boundary/filesystem/webArtifactReadOwnership.test.ts new file mode 100644 index 0000000000..0a87913667 --- /dev/null +++ b/tests/boundary/filesystem/webArtifactReadOwnership.test.ts @@ -0,0 +1,96 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it, onTestFinished, vi } from "vitest"; + +const injected = vi.hoisted(() => ({ + sourcePath: undefined as string | undefined, + abortController: undefined as AbortController | undefined, + failClose: false, + closeAttempts: 0, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + open: async (...args: Parameters) => { + const handle = await actual.open(...args); + if (args[0] !== injected.sourcePath) return handle; + const createReadStream = handle.createReadStream.bind(handle); + handle.createReadStream = (options) => { + injected.abortController?.abort(); + return createReadStream(options); + }; + const close = handle.close.bind(handle); + handle.close = async () => { + injected.closeAttempts += 1; + if (injected.failClose) throw new Error("fixture close refusal"); + await close(); + }; + return handle; + }, + }; +}); + +import { WebModuleTraceService } from "../../../src/application/WebModuleTraceService.js"; +import { LocalWebModuleArtifacts } from "../../../src/browser/modules/WebModuleArtifacts.js"; +import { webModuleArtifactsFixture } from "../../fixtures/webModuleTrace.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it("retains cancelled source read ownership until service close retries it", async () => { + const root = await createTestTempDirectory("rea-web-artifact-read-owner-"); + onTestFinished(async () => { + injected.sourcePath = undefined; + injected.abortController = undefined; + injected.failClose = false; + injected.closeAttempts = 0; + await rm(root, { recursive: true, force: true }); + }); + const source = 'import "./dep.js";'; + const fixture = webModuleArtifactsFixture(source); + const manifestPath = join(root, "manifest.json"); + const sourcePath = join(root, "files", "modules", "main.js"); + await mkdir(join(root, "files", "modules"), { recursive: true }); + await writeFile(sourcePath, source); + await writeFile(manifestPath, JSON.stringify(fixture.manifest)); + + injected.sourcePath = sourcePath; + injected.failClose = true; + const controller = new AbortController(); + injected.abortController = controller; + const resolver = { + resolve: vi.fn(), + close: vi.fn(async () => undefined), + }; + const service = new WebModuleTraceService( + new LocalWebModuleArtifacts(), + resolver, + ); + const input = { manifest_path: manifestPath, script_index: 0 }; + + const cancelled = await service.trace(input, { signal: controller.signal }); + expect(cancelled.ok).toBe(false); + if (cancelled.ok) throw new Error("Expected cancelled source read"); + expect(cancelled.error.cleanupIncomplete).toBe(true); + expect(cancelled.error.cleanupResources).toContain(sourcePath); + expect(resolver.resolve).not.toHaveBeenCalled(); + + injected.abortController = undefined; + const blocked = await service.trace(input); + expect(blocked.ok).toBe(false); + if (blocked.ok) throw new Error("Expected retained cleanup to block load"); + expect(blocked.error.cleanupIncomplete).toBe(true); + expect(injected.closeAttempts).toBe(2); + + await expect(service.close()).rejects.toThrow( + "Web module trace cleanup failed", + ); + expect(resolver.close).toHaveBeenCalledTimes(1); + expect(injected.closeAttempts).toBe(3); + + injected.failClose = false; + const attemptsBeforeRetry = injected.closeAttempts; + await service.close(); + expect(injected.closeAttempts).toBeGreaterThan(attemptsBeforeRetry); + expect(resolver.close).toHaveBeenCalledTimes(2); +}); diff --git a/tests/boundary/mcp/serverLifecycleMcp.test.ts b/tests/boundary/mcp/serverLifecycleMcp.test.ts index c841e08ede..cdec09a3ef 100644 --- a/tests/boundary/mcp/serverLifecycleMcp.test.ts +++ b/tests/boundary/mcp/serverLifecycleMcp.test.ts @@ -34,6 +34,9 @@ import { } from "../../support/workspace/workspaceFixture.js"; import { z } from "zod"; 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"; const resources: Array<{ close(): Promise }> = []; @@ -334,6 +337,68 @@ it("awaits SQLite cleanup and retries its retained owner after failed server shu expect(attempts).toBe(2); }); +it("closes the bound Go and web-capture services and retries failed shutdown", async () => { + let goCalls = 0; + let goCloseAttempts = 0; + let captureCloseAttempts = 0; + let cleanupAllowed = false; + const goBinary = new GoBinaryService({ + identity: { id: "go-fixture", name: "Go fixture", version: "1" }, + inspect: async ({ path }) => { + goCalls += 1; + return err( + new AnalysisUnsupportedTargetError( + "inspect_go_binary", + path, + "fixture rejection", + ), + ); + }, + close: async () => { + goCloseAttempts += 1; + if (!cleanupAllowed) throw new Error("Go cleanup refused"); + }, + }); + const webNetworkCapture = new WebNetworkCaptureService({ + inspect: async () => { + throw new Error("No capture inspection requested"); + }, + close: async () => { + captureCloseAttempts += 1; + if (!cleanupAllowed) throw new Error("Capture cleanup refused"); + }, + }); + const server = createServer( + { kind: "fixed", analysis: { execute: async () => ok(null) } }, + { goBinary, webNetworkCapture }, + ); + const client = new Client({ name: "bound-service-lifecycle", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + resources.push(client, server); + try { + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "inspect_go_binary", + arguments: { path: "/tmp/go-fixture" }, + }); + expect(result.isError).toBe(true); + expect(goCalls).toBe(1); + + await expect(server.close()).rejects.toBeInstanceOf(AggregateError); + expect(goCloseAttempts).toBe(1); + expect(captureCloseAttempts).toBe(1); + + cleanupAllowed = true; + await server.close(); + expect(goCloseAttempts).toBe(2); + expect(captureCloseAttempts).toBe(2); + } finally { + cleanupAllowed = true; + } +}); + afterEach(async () => { await Promise.all( resources.splice(0).map(async (resource) => resource.close()), diff --git a/tests/boundary/native/PwndbgCoreContext.test.ts b/tests/boundary/native/PwndbgCoreContext.test.ts index e8f007a9bc..d38b01ac3f 100644 --- a/tests/boundary/native/PwndbgCoreContext.test.ts +++ b/tests/boundary/native/PwndbgCoreContext.test.ts @@ -2,6 +2,7 @@ import { access } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, onTestFinished } from "vitest"; import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js"; +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; import { createTestWorkspace, removeTestWorkspace, @@ -40,6 +41,7 @@ it.each(["pre-aborted", "aborted-during-probe"] as const)( REA_PWNDBG_VENV_PATH: join(workspace.root, "missing-venv"), }, rootPath: workspace.root, + resources: new ArtifactResourceScope(), diagnostics, options: { signal: controller.signal }, probePath, diff --git a/tests/boundary/native/pwntoolsArtifactCleanup.test.ts b/tests/boundary/native/pwntoolsArtifactCleanup.test.ts new file mode 100644 index 0000000000..82760cfd27 --- /dev/null +++ b/tests/boundary/native/pwntoolsArtifactCleanup.test.ts @@ -0,0 +1,147 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { z } from "zod"; + +const closeState = vi.hoisted(() => ({ + fail: false, + failOnce: false, + opened: 0, + closes: 0, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + open: async (...args: Parameters) => { + const handle = await fs.open(...args); + closeState.opened++; + const close = handle.close.bind(handle); + handle.close = async () => { + closeState.closes++; + if (closeState.fail || closeState.failOnce) { + closeState.failOnce = false; + throw new Error("injected stable artifact close failure"); + } + return close(); + }; + return handle; + }, + }; +}); + +import { PwntoolsDecoder } from "../../../src/native/pwntools/PwntoolsDecoder.js"; +import { PWNTOOLS_PROVIDER_IDENTITY } from "../../../src/native/pwntools/PwntoolsRelease.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; + +const makeDecoder = () => + new PwntoolsDecoder( + { + identity: PWNTOOLS_PROVIDER_IDENTITY, + operation: "test_pwntools_artifact_read", + bridge: new URL("file:///unused-bridge.py"), + payloadSchema: z.string(), + project: () => { + throw new Error("input read should fail before decoder launch"); + }, + }, + { REA_PWNTOOLS_PYTHON: process.execPath }, + undefined, + async () => { + throw new Error("runtime should not be created for failed input read"); + }, + ); + +const emulateSupportedHost = async ( + run: () => Promise, +): Promise => { + const platform = Object.getOwnPropertyDescriptor(process, "platform"); + const arch = Object.getOwnPropertyDescriptor(process, "arch"); + Object.defineProperty(process, "platform", { ...platform, value: "linux" }); + Object.defineProperty(process, "arch", { ...arch, value: "x64" }); + try { + await run(); + } finally { + if (platform !== undefined) + Object.defineProperty(process, "platform", platform); + if (arch !== undefined) Object.defineProperty(process, "arch", arch); + } +}; + +it("reports the read failure without stale incomplete-cleanup state when close retry succeeds", async () => + emulateSupportedHost(async () => { + const root = await mkdtemp(join(tmpdir(), "rea-pwntools-close-retry-")); + const path = join(root, "selected.bin"); + await writeFile(path, "selected evidence"); + closeState.fail = false; + closeState.failOnce = true; + closeState.opened = 0; + closeState.closes = 0; + const decoder = makeDecoder(); + try { + const result = await decoder.inspect({ path }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("Injected close failure was not returned"); + expect(result.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(result.error).code).not.toBe( + "cleanup_incomplete", + ); + expect(result.error.cause).toMatchObject({ + cleanup: { + resources: [path], + reason: expect.stringContaining( + "injected stable artifact close failure", + ), + }, + }); + expect(closeState.closes).toBeGreaterThanOrEqual(2); + } finally { + closeState.fail = false; + closeState.failOnce = false; + await decoder.close(); + await rm(root, { recursive: true, force: true }); + } + })); + +it("retains a persistent failed input descriptor and refuses the next acquisition", async () => + emulateSupportedHost(async () => { + const root = await mkdtemp(join(tmpdir(), "rea-pwntools-held-close-")); + const path = join(root, "selected.bin"); + await writeFile(path, "selected evidence"); + closeState.fail = true; + closeState.failOnce = false; + closeState.opened = 0; + closeState.closes = 0; + const decoder = makeDecoder(); + try { + const first = await decoder.inspect({ path }); + expect(first.ok).toBe(false); + if (first.ok) + throw new Error("Persistent close failure was not returned"); + expect(projectAnalysisError(first.error).code).toBe("cleanup_incomplete"); + const openedAfterFailure = closeState.opened; + + const retry = await decoder.inspect({ path }); + expect(retry.ok).toBe(false); + if (retry.ok) throw new Error("Retained descriptor was not retried"); + expect(projectAnalysisError(retry.error).code).toBe("cleanup_incomplete"); + expect(closeState.opened).toBe(openedAfterFailure); + + await expect(decoder.close()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + closeState.fail = false; + await decoder.close(); + } finally { + closeState.fail = false; + closeState.failOnce = false; + try { + await decoder.close(); + } catch { + // The test already asserts persistent cleanup failure before releasing it. + } + await rm(root, { recursive: true, force: true }); + } + })); diff --git a/tests/boundary/providers/artifactReadCleanupRetry.test.ts b/tests/boundary/providers/artifactReadCleanupRetry.test.ts new file mode 100644 index 0000000000..ebaba673ab --- /dev/null +++ b/tests/boundary/providers/artifactReadCleanupRetry.test.ts @@ -0,0 +1,100 @@ +import type { FileHandle } from "node:fs/promises"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; + +const cleanupState = vi.hoisted(() => ({ + failFirstClose: false, + abortController: undefined as AbortController | undefined, + firstHandle: undefined as FileHandle | undefined, + firstHandleCloseAttempts: 0, +})); + +vi.mock("node:fs/promises", async (importOriginal) => { + const fs = await importOriginal(); + return { + ...fs, + open: async (...args: Parameters) => { + const handle = await fs.open(...args); + const close = handle.close.bind(handle); + if (cleanupState.abortController !== undefined) + setImmediate(() => { + cleanupState.abortController?.abort(); + cleanupState.abortController = undefined; + }); + handle.close = async () => { + if (handle === cleanupState.firstHandle) + cleanupState.firstHandleCloseAttempts += 1; + else if (cleanupState.firstHandle === undefined) + cleanupState.firstHandle = handle; + if ( + cleanupState.failFirstClose && + handle === cleanupState.firstHandle + ) { + cleanupState.failFirstClose = false; + throw new Error("injected descriptor close failure"); + } + return close(); + }; + return handle; + }, + }; +}); + +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { GoBinaryProvider } from "../../../src/go/GoBinaryProvider.js"; +import { PeResourcesProvider } from "../../../src/native/pe/PeResourcesProvider.js"; +import { createGoBinaryFixture } from "../../fixtures/go/image.js"; +import { peResourceFixture } from "../../../src/native/pe/PeResources.fixture.js"; + +it.each(["go", "pe"] as const)( + "%s provider retains a failed descriptor close and retries that owner before the next read", + async (kind) => { + const root = await mkdtemp(join(tmpdir(), "rea-read-cleanup-")); + const path = join(root, kind === "go" ? "program.elf" : "program.exe"); + const bytes = + kind === "go" ? createGoBinaryFixture().bytes : peResourceFixture().bytes; + await writeFile(path, bytes); + const provider = + kind === "go" ? new GoBinaryProvider() : new PeResourcesProvider(); + cleanupState.failFirstClose = true; + const controller = new AbortController(); + cleanupState.abortController = controller; + cleanupState.firstHandle = undefined; + cleanupState.firstHandleCloseAttempts = 0; + try { + const input = { + path, + max_file_bytes: 8 * 1024 * 1024, + max_entries: 4096, + }; + const first = await provider.inspect(input, { + signal: controller.signal, + }); + expect(first.ok).toBe(false); + if (first.ok) throw new Error("Injected close failure was not returned"); + expect(projectAnalysisError(first.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { diagnostics: { primary_error: { code: "cancelled" } } }, + }); + expect(first.error.cause).toBeInstanceOf(AggregateError); + if (!(first.error.cause instanceof AggregateError)) + throw first.error.cause; + expect(first.error.cause.errors[1]).toMatchObject({ + cleanupResources: [path], + }); + + const retry = await provider.inspect(input); + expect(retry.ok).toBe(true); + expect(cleanupState.firstHandleCloseAttempts).toBe(2); + } finally { + await provider.close(); + cleanupState.failFirstClose = false; + cleanupState.abortController = undefined; + cleanupState.firstHandle = undefined; + cleanupState.firstHandleCloseAttempts = 0; + await rm(root, { recursive: true, force: true }); + } + }, +);