diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 4f3676c21d..6e65230fd2 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -56,6 +56,9 @@ import { type GhidraSessionInfo, } from "./GhidraSessionValues.js"; import { createGhidraTargetSnapshot } from "./GhidraTargetSnapshot.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.js"; import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js"; import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; import { @@ -113,6 +116,8 @@ export class GhidraClient { #endpointPath: string | undefined; #snapshotPath: string | undefined; readonly #snapshotOwners = new Set(); + readonly #fileResources = new ArtifactResourceScope(); + readonly #cleanupFailures = new Set(); #targetAdmission: JsonValue | undefined; #token: string | undefined; // Retain authentication identities for diagnostics from late request settlement. @@ -193,10 +198,6 @@ export class GhidraClient { const drained = await this.#drainOutputBounded( requestOptions.signal, ); - if (requestOptions.signal?.aborted === true) - return err( - this.#failure("cancelled", "Ghidra request was cancelled"), - ); return err( this.#refreshProcessFailure( result.error, @@ -213,7 +214,7 @@ export class GhidraClient { this.#activeOutputCheckpoint = undefined; } }, - (kind, message, cause) => this.#failure(kind, message, cause), + this.#failure, () => this.#stopAfterActiveCancellation(), ); } @@ -226,16 +227,12 @@ export class GhidraClient { if (progress !== undefined) return this.#reportStartupWait(this.start(signal), signal, progress); if (signal?.aborted === true) - return Promise.resolve( - err(this.#failure("cancelled", "Ghidra startup wait was cancelled")), - ); + return Promise.resolve(err(this.#startupWaitCancellation(signal))); if (this.#closePromise !== undefined) return waitForAbortable(this.#closePromise, signal).then( (closed) => closed === ABORTED - ? err( - this.#failure("cancelled", "Ghidra startup wait was cancelled"), - ) + ? err(this.#startupWaitCancellation(signal)) : this.start(signal), (cause: unknown) => err( @@ -284,7 +281,7 @@ export class GhidraClient { const waited = await attempt.wait(signal); if (waited.status === "completed") return waited.value; if (waited.status === "stopped" && !waited.value.ok) return waited.value; - return err(this.#failure("cancelled", "Ghidra startup wait was cancelled")); + return err(this.#startupWaitCancellation(signal)); } /** Wait for abandoned startup cleanup before retrying under the new caller. */ @@ -293,15 +290,10 @@ export class GhidraClient { signal: AbortSignal | undefined, ): Promise { const signalAborted = (): boolean => signal?.aborted === true; - if (signalAborted()) - return err( - this.#failure("cancelled", "Ghidra startup wait was cancelled"), - ); + if (signalAborted()) return err(this.#startupWaitCancellation(signal)); const waited = await waitForAbortable(attempt.promise, signal); if (waited === ABORTED || (waited.ok && signalAborted())) - return err( - this.#failure("cancelled", "Ghidra startup wait was cancelled"), - ); + return err(this.#startupWaitCancellation(signal)); if (waited.ok || waited.error.cleanupFailure !== undefined) return waited; return this.start(signal); } @@ -497,6 +489,18 @@ export class GhidraClient { } } + /** This waiter owns its cancellation reason, independently of shared startup. */ + #startupWaitCancellation( + signal: AbortSignal | undefined, + ): GhidraSessionError { + return this.#failure( + "cancelled", + "Ghidra startup wait was cancelled", + signal?.reason, + signal === undefined ? {} : { cancellation: { reason: signal.reason } }, + ); + } + /** Stop the owned process group and remove all project/runtime artifacts. */ async close(): Promise> { try { @@ -526,10 +530,16 @@ export class GhidraClient { #cleanupError(cause: unknown): AnalysisError { if (cause instanceof AnalysisError && cause.cleanupIncomplete) return cause; - return new ProviderCleanupError( + const failure = new ProviderCleanupError( "ghidra", [ ...(this.#process === undefined ? [] : ["ghidra-process"]), + ...(cause instanceof ArtifactReaderFailure + ? (cause.cleanup?.resources ?? []) + : []), + ...(this.#snapshotOwners.size === 0 + ? [] + : [this.#snapshotPath ?? "Ghidra snapshot descriptor"]), ...[this.#runtimeRoot, this.#socketRoot].flatMap((root) => root === undefined ? [] : [root.path], ), @@ -540,6 +550,8 @@ export class GhidraClient { }, { cause }, ); + this.#cleanupFailures.add(failure); + return failure; } async #startupFailure( @@ -547,37 +559,28 @@ export class GhidraClient { deadline?: ProviderStartupDeadline, ): Promise { await this.#drainOutputBounded(deadline?.signal); - if (deadline?.interruption === "cancelled") - primary = this.#interruptionFailure(deadline, primary.cause); - primary = this.#failure(primary.kind, primary.message, primary.cause, { - ...(primary.timeoutMs === undefined - ? {} - : { timeoutMs: primary.timeoutMs }), - ...(primary.remoteCode === undefined - ? {} - : { remoteCode: primary.remoteCode }), - }); + const refreshed = this.#failure( + primary.kind, + primary.message, + primary.cause, + { + ...(primary.cancellation === undefined + ? {} + : { cancellation: primary.cancellation }), + ...(primary.timeoutMs === undefined + ? {} + : { timeoutMs: primary.timeoutMs }), + ...(primary.remoteCode === undefined + ? {} + : { remoteCode: primary.remoteCode }), + }, + ); + primary = primary.withDiagnostics(refreshed.diagnostics, refreshed.message); try { await this.#cleanup(); return err(primary); } catch (cause: unknown) { - return err( - new GhidraSessionError( - primary.kind, - primary.message, - primary.diagnostics, - { - ...(primary.cause === undefined ? {} : { cause: primary.cause }), - ...(primary.timeoutMs === undefined - ? {} - : { timeoutMs: primary.timeoutMs }), - ...(primary.remoteCode === undefined - ? {} - : { remoteCode: primary.remoteCode }), - cleanupFailure: this.#cleanupError(cause), - }, - ), - ); + return err(primary.withCleanupFailure(this.#cleanupError(cause))); } } @@ -638,8 +641,7 @@ export class GhidraClient { } async #start(signal: AbortSignal): Promise { - if (signal.aborted) - return err(this.#failure("cancelled", "Ghidra startup was cancelled")); + if (signal.aborted) return err(this.#startupWaitCancellation(signal)); if (this.#hasOwnedResources()) return err(this.#failure("protocol", "Ghidra client is already started")); this.#lastDiagnostics = {}; @@ -673,7 +675,8 @@ export class GhidraClient { this.#socket !== undefined || this.#runtimeRoot !== undefined || this.#socketRoot !== undefined || - this.#snapshotOwners.size > 0 + this.#snapshotOwners.size > 0 || + this.#fileResources.hasPending ); } @@ -715,18 +718,29 @@ export class GhidraClient { this.#options.targetPath, this.#runtimeRoot.path, this.#options.targetSha256, - { signal: deadline.signal, platform: this.#options.platform }, + { + signal: deadline.signal, + platform: this.#options.platform, + resourceScope: this.#fileResources, + }, ); this.#snapshotPath = snapshot.path; this.#targetAdmission = snapshot.admission; } catch (cause: unknown) { - if (deadline.signal.aborted) return this.#startupInterrupted(deadline); - const failure = this.#failure( - "start", - "Ghidra target snapshot failed admission", - cause, - ); - return this.#startupFailure(failure, deadline); + const failure = + cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" + ? this.#interruptionFailure( + deadline, + artifactFileCancellationCause(cause), + ) + : this.#failure( + "start", + "Ghidra target snapshot failed admission", + cause, + ); + // Snapshot finalization has settled and no launcher exists yet. Preserve + // its producer outcome rather than reclassifying from a later deadline. + return this.#startupFailure(failure); } if (deadline.signal.aborted) return this.#startupInterrupted(deadline); this.#token = randomBytes(32).toString("hex"); @@ -838,6 +852,7 @@ export class GhidraClient { const connected = await connectGhidraSocket({ endpoint, deadline, + resourceScope: this.#fileResources, failure: this.#failure, isClosed: () => this.#closing, processExited: () => this.#process?.observedExit() !== undefined, @@ -960,6 +975,7 @@ export class GhidraClient { this.#lastDiagnostics = this.#diagnostics(); this.#process = undefined; this.#launch = undefined; + await this.#fileResources.retryCleanup(); for (const owner of this.#snapshotOwners) { await owner.close(); this.#snapshotOwners.delete(owner); @@ -976,6 +992,8 @@ export class GhidraClient { this.#runId = undefined; this.#responseBuffer.reset(); if (forcedStopFailure !== undefined) throw forcedStopFailure.cause; + for (const failure of this.#cleanupFailures) failure.resolveCleanup(); + this.#cleanupFailures.clear(); } finally { this.#closing = false; } @@ -1008,8 +1026,8 @@ export class GhidraClient { }); } } - if (leftovers.length > 0) - throw new ProviderCleanupError( + if (leftovers.length > 0) { + const failure = new ProviderCleanupError( "ghidra", leftovers.map(({ path }) => path), { @@ -1020,6 +1038,9 @@ export class GhidraClient { }, { cause: firstCause }, ); + this.#cleanupFailures.add(failure); + throw failure; + } } #redactAuthentication(value: string, incomplete = false): string { @@ -1088,7 +1109,9 @@ export class GhidraClient { cause, ); return deadline.interruption === "cancelled" - ? this.#failure("cancelled", "Ghidra startup was cancelled", cause) + ? this.#failure("cancelled", "Ghidra startup was cancelled", cause, { + cancellation: { reason: deadline.signal.reason }, + }) : this.#failure("timeout", "Ghidra startup deadline elapsed", cause, { timeoutMs: deadline.timeoutMs, }); @@ -1156,38 +1179,20 @@ export class GhidraClient { const snapshot = owner.snapshot(); const scoped = owner.snapshotOutputSince(checkpoint); const incomplete = drainIncomplete || !scoped.complete; - return new GhidraSessionError( - failure.kind, - failure.message, - { - ...failure.diagnostics, - exit_code: snapshot.exitCode ?? null, - exit_signal: snapshot.signal ?? null, - diagnostic_truncated: - snapshot.diagnosticTruncated === true || incomplete, - output_incomplete: - snapshot.outputIncomplete === true || drainIncomplete, - stdout: ghidraDiagnosticStream(snapshot.stdout, token), - stderr: ghidraDiagnosticStream(snapshot.stderr, token), - failure_output: { - stdout: ghidraDiagnosticStream(scoped.snapshot.stdout, token), - stderr: ghidraDiagnosticStream(scoped.snapshot.stderr, token), - complete: scoped.complete && !drainIncomplete, - }, + return failure.withDiagnostics({ + ...failure.diagnostics, + exit_code: snapshot.exitCode ?? null, + exit_signal: snapshot.signal ?? null, + diagnostic_truncated: snapshot.diagnosticTruncated === true || incomplete, + output_incomplete: snapshot.outputIncomplete === true || drainIncomplete, + stdout: ghidraDiagnosticStream(snapshot.stdout, token), + stderr: ghidraDiagnosticStream(snapshot.stderr, token), + failure_output: { + stdout: ghidraDiagnosticStream(scoped.snapshot.stdout, token), + stderr: ghidraDiagnosticStream(scoped.snapshot.stderr, token), + complete: scoped.complete && !drainIncomplete, }, - { - ...(failure.cause === undefined ? {} : { cause: failure.cause }), - ...(failure.timeoutMs === undefined - ? {} - : { timeoutMs: failure.timeoutMs }), - ...(failure.remoteCode === undefined - ? {} - : { remoteCode: failure.remoteCode }), - ...(failure.cleanupFailure === undefined - ? {} - : { cleanupFailure: failure.cleanupFailure }), - }, - ); + }); } #drainOutputBounded(signal?: AbortSignal): Promise { diff --git a/src/ghidra/GhidraClientStartup.ts b/src/ghidra/GhidraClientStartup.ts index e90a373feb..80267d1d70 100644 --- a/src/ghidra/GhidraClientStartup.ts +++ b/src/ghidra/GhidraClientStartup.ts @@ -4,8 +4,7 @@ import type { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; import type { GhidraRequestOptions } from "./GhidraClientTypes.js"; import type { GhidraSessionError, - GhidraSessionErrorOptions, - GhidraSessionFailureKind, + GhidraSessionFailureFactory, } from "./GhidraSessionError.js"; import type { GhidraSessionInfo } from "./GhidraSessionValues.js"; @@ -20,12 +19,7 @@ export interface GhidraStartupHandshakeOptions { readonly parseSessionInfo: ( value: JsonValue, ) => Result; - readonly failure: ( - kind: GhidraSessionFailureKind, - message: string, - cause?: unknown, - options?: Pick, - ) => GhidraSessionError; + readonly failure: GhidraSessionFailureFactory; readonly startupTimeoutMs: number; } @@ -42,9 +36,11 @@ export async function completeGhidraStartupHandshake( }, ); if (!ping.ok) { - const failure = options.deadline.signal.aborted - ? interruptionFailure(options) - : ping.error; + const failure = + ping.error.kind === "cancelled" && + options.deadline.interruption !== undefined + ? interruptionFailure(options) + : ping.error; return err(failure); } const parsed = options.parseSessionInfo(ping.value); @@ -64,7 +60,12 @@ const interruptionFailure = ( options: GhidraStartupHandshakeOptions, ): GhidraSessionError => options.deadline.interruption === "cancelled" - ? options.failure("cancelled", "Ghidra startup was cancelled") + ? options.failure( + "cancelled", + "Ghidra startup was cancelled", + options.deadline.signal.reason, + { cancellation: { reason: options.deadline.signal.reason } }, + ) : options.failure("timeout", "Ghidra startup deadline elapsed", undefined, { timeoutMs: options.startupTimeoutMs, }); diff --git a/src/ghidra/GhidraClientWire.ts b/src/ghidra/GhidraClientWire.ts index 77d0781a3c..86db6f14b4 100644 --- a/src/ghidra/GhidraClientWire.ts +++ b/src/ghidra/GhidraClientWire.ts @@ -7,8 +7,7 @@ import type { PendingOperations } from "../process/PendingOperations.js"; import type { GhidraRequestOptions } from "./GhidraClientTypes.js"; import type { GhidraSessionError, - GhidraSessionErrorOptions, - GhidraSessionFailureKind, + GhidraSessionFailureFactory, } from "./GhidraSessionError.js"; /** Dependencies needed to write one authenticated line and await its reply. */ @@ -21,12 +20,7 @@ export interface GhidraWireOptions { Result >; readonly logger: Logger; - readonly failure: ( - kind: GhidraSessionFailureKind, - message: string, - cause?: unknown, - options?: Pick, - ) => GhidraSessionError; + readonly failure: GhidraSessionFailureFactory; } /** Owns the socket write and pending-reply lifecycle for one bridge session. */ @@ -50,9 +44,7 @@ export class GhidraWire { this.#options.failure("process", "Ghidra bridge is unavailable"), ); if (options.signal?.aborted === true) - return err( - this.#options.failure("cancelled", "Ghidra request was cancelled"), - ); + return err(this.#cancelled(options.signal)); const id = this.#options.nextId(); if (options.timeoutMs !== undefined && options.timeoutMs <= 0) return err( @@ -80,8 +72,7 @@ export class GhidraWire { ), }), ...(options.signal === undefined ? {} : { signal: options.signal }), - cancelledValue: () => - err(this.#options.failure("cancelled", "Ghidra request was cancelled")), + cancelledValue: () => err(this.#cancelled(options.signal)), }); socket.write( `${JSON.stringify({ id, token, method, params })}\n`, @@ -106,4 +97,12 @@ export class GhidraWire { ); return result; } + #cancelled(signal: AbortSignal | undefined): GhidraSessionError { + return this.#options.failure( + "cancelled", + "Ghidra request was cancelled", + signal?.reason, + signal === undefined ? {} : { cancellation: { reason: signal.reason } }, + ); + } } diff --git a/src/ghidra/GhidraJavaLaunch.ts b/src/ghidra/GhidraJavaLaunch.ts index 94639d0158..5bd95303f5 100644 --- a/src/ghidra/GhidraJavaLaunch.ts +++ b/src/ghidra/GhidraJavaLaunch.ts @@ -2,6 +2,7 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { dirname, join } from "node:path"; import { execFileOutput, + ExecFileOutputCancelledError, execFileOutputFailure, type ExecFileOutputFailure, } from "../process/ExecFileOutput.js"; @@ -18,19 +19,24 @@ type LaunchSupportOutcome = Omit< export class GhidraLaunchSupportError extends Error { readonly outcome: LaunchSupportOutcome | undefined; + readonly cancellation: { readonly reason: unknown } | undefined; constructor(phase: LaunchSupportPhase, cause: unknown) { super(`Ghidra LaunchSupport ${phase} failed`, { cause }); this.name = "GhidraLaunchSupportError"; + this.cancellation = + cause instanceof ExecFileOutputCancelledError + ? { reason: cause.cause } + : undefined; const outcome = execFileOutputFailure(cause); this.outcome = outcome === undefined ? undefined : { ...outcome, - killed: outcome.code === "ABORT_ERR" ? null : outcome.killed, + killed: this.cancellation === undefined ? outcome.killed : null, outputTruncated: - outcome.code === "ABORT_ERR" ? null : outcome.outputTruncated, + this.cancellation === undefined ? outcome.outputTruncated : null, }; } } diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 125f40df5f..ac093c7161 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -17,6 +17,9 @@ import { WindowsBatchCommandError, } from "../process/WindowsBatchCommand.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { err, ok, type Result } from "../domain/result.js"; import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js"; @@ -27,6 +30,7 @@ import { cleanupOwnedProcessGroup, cleanupWindowsProcessTree, type ProcessCleanupResult, + type ProcessCleanupFailure, } from "../process/ProcessOwnership.js"; import { type ProviderProcessLaunch, @@ -88,27 +92,65 @@ export interface GhidraLauncher { ): Promise>; } +/** A rollback attempt failed without releasing its process owner. */ +export type GhidraLaunchRollbackFailure = + | { readonly kind: "incomplete"; readonly result: ProcessCleanupFailure } + | { readonly kind: "rejected"; readonly cause: unknown }; + /** Local launch failure retained as the cause of a provider-neutral error. */ export class GhidraLaunchError extends Error { readonly #partialLaunch: GhidraLaunch | undefined; readonly #capturedOutput: AnalysisCapturedOutput | undefined; readonly launchSupport: readonly GhidraLaunchSupportDiagnostic[]; + readonly rollbackFailure: GhidraLaunchRollbackFailure | undefined; + readonly cleanupFailure: AnalysisError | undefined; constructor( message: string, options?: ErrorOptions & { readonly partialLaunch?: GhidraLaunch; + readonly rollbackFailure?: GhidraLaunchRollbackFailure; + readonly cleanupFailure?: AnalysisError; readonly capturedOutput?: AnalysisCapturedOutput; readonly launchSupport?: readonly GhidraLaunchSupportDiagnostic[]; }, ) { - const { partialLaunch, capturedOutput, launchSupport, ...errorOptions } = - options ?? {}; + const { + partialLaunch, + capturedOutput, + launchSupport, + rollbackFailure, + cleanupFailure, + ...errorOptions + } = options ?? {}; super(message, errorOptions); this.name = "GhidraLaunchError"; this.#partialLaunch = partialLaunch; - this.#capturedOutput = - capturedOutput === undefined ? undefined : { ...capturedOutput }; + this.rollbackFailure = rollbackFailure; + const inherited = + errorOptions.cause instanceof DarwinProcessOwnershipInspectionError || + errorOptions.cause instanceof GhidraLaunchError + ? errorOptions.cause.cleanupFailure + : errorOptions.cause instanceof AnalysisError && + errorOptions.cause.cleanupIncomplete + ? errorOptions.cause + : undefined; + this.cleanupFailure = + cleanupFailure === undefined + ? inherited + : inherited === undefined || inherited === cleanupFailure + ? cleanupFailure + : analysisErrorWithCleanupFailure( + inherited, + cleanupFailure, + ).bindCleanupDependencies([inherited, cleanupFailure]); + const output = + capturedOutput ?? + (errorOptions.cause instanceof DarwinProcessOwnershipInspectionError || + errorOptions.cause instanceof GhidraLaunchError + ? errorOptions.cause.capturedOutput + : undefined); + this.#capturedOutput = output === undefined ? undefined : { ...output }; this.launchSupport = (launchSupport ?? []).map((output) => ({ ...output })); } @@ -133,12 +175,20 @@ export class GhidraLaunchError extends Error { const ghidraLaunchCommandOutput = ( cause: unknown, ): AnalysisCapturedOutput | undefined => { + if (cause instanceof DarwinProcessOwnershipInspectionError) + return cause.capturedOutput; const failure = execFileOutputFailure(cause); if (failure === undefined) return undefined; return { stdout: failure.stdout, stderr: failure.stderr, truncated: failure.outputTruncated, + ...(failure.stdoutBytes === undefined + ? {} + : { stdout_bytes: failure.stdoutBytes }), + ...(failure.stderrBytes === undefined + ? {} + : { stderr_bytes: failure.stderrBytes }), exit_code: typeof failure.code === "number" ? failure.code : null, signal: failure.signal, }; @@ -410,21 +460,34 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { }, ); const primary = isAborted(options.signal) - ? new AnalysisCancelledError("open_binary", { cause: launchFailure }) + ? new AnalysisCancelledError("open_binary", { + cause: launchFailure, + ...(launchFailure.cleanupFailure === undefined + ? {} + : { + cleanup: { + reason: launchFailure.cleanupFailure.message, + resources: launchFailure.cleanupFailure.cleanupResources, + }, + }), + }).bindCleanupDependencies( + launchFailure.cleanupFailure === undefined + ? [] + : [launchFailure.cleanupFailure], + ) : launchFailure; if (started === undefined) return err(primary); - let cleanupFailure: unknown; - try { - const cleanup = await cleanupStartedProcess(started, platform); - if (!cleanup.cleaned) - cleanupFailure = new Error( - `Ghidra process cleanup was incomplete: ${cleanup.reason}`, - ); - } catch (failure: unknown) { - cleanupFailure = failure; - } - if (cleanupFailure !== undefined) { + const cleanupOwner = started; + const rollback = await Promise.resolve() + .then(() => cleanupStartedProcess(cleanupOwner, platform)) + .then( + (result): Result => + result.cleaned ? ok(null) : err({ kind: "incomplete", result }), + (cause: unknown): Result => + err({ kind: "rejected", cause }), + ); + if (!rollback.ok) { const partialLaunch = ownedGhidraLaunch( started, paths, @@ -440,11 +503,20 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { `${primaryMessage}; process cleanup remains incomplete`, { cause: new AggregateError( - [primary, cleanupFailure], + [ + primary, + rollback.error.kind === "rejected" + ? rollback.error.cause + : rollback.error.result, + ], "Ghidra launch rollback did not release its process", { cause: primary }, ), partialLaunch, + rollbackFailure: rollback.error, + ...(launchFailure.cleanupFailure === undefined + ? {} + : { cleanupFailure: launchFailure.cleanupFailure }), launchSupport, ...(capturedOutput === undefined ? {} : { capturedOutput }), }, diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index 327fcd17cf..ab7124743a 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -1,4 +1,5 @@ import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { artifactFilePrimaryFailure } from "../artifacts/ArtifactFileOperation.js"; import { fileURLToPath } from "node:url"; import { GHIDRA_BRIDGE_SOURCES } from "./GhidraBridgeSources.js"; @@ -1059,7 +1060,7 @@ const projectSessionError = ( primary, failure.cleanupFailure, operation, - ); + ).bindCleanupDependencies([primary, failure.cleanupFailure]); }; const projectPrimarySessionError = ( @@ -1069,18 +1070,19 @@ const projectPrimarySessionError = ( health?: ProviderOperationHealth, ): AnalysisError => { const context = { cause: failure, diagnostics: failure.diagnostics }; - if (failure.cause instanceof AnalysisAccessDeniedError) + const physicalCause = artifactFilePrimaryFailure(failure.cause); + if (physicalCause instanceof AnalysisAccessDeniedError) return new AnalysisAccessDeniedError( operation, - failure.cause.path, - failure.cause.systemCode, + physicalCause.path, + physicalCause.systemCode, context, ); - if (failure.cause instanceof AnalysisArtifactChangedError) + if (physicalCause instanceof AnalysisArtifactChangedError) return new AnalysisArtifactChangedError( operation, - failure.cause.path, - failure.cause.reason, + physicalCause.path, + physicalCause.reason, context, ); if ( @@ -1095,7 +1097,13 @@ const projectPrimarySessionError = ( failure.kind === "cancelled" || (failure.kind === "remote" && failure.remoteCode === "decompile_cancelled") ) - return new AnalysisCancelledError(operation, context); + return new AnalysisCancelledError(operation, { + ...context, + cause: + failure.cancellation === undefined + ? failure + : failure.cancellation.reason, + }); if (failure.kind === "analysis_timeout") return new AnalysisTimeoutError( operation, diff --git a/src/ghidra/GhidraRequestQueue.test.ts b/src/ghidra/GhidraRequestQueue.test.ts index 1be5007e23..a8b470c3c1 100644 --- a/src/ghidra/GhidraRequestQueue.test.ts +++ b/src/ghidra/GhidraRequestQueue.test.ts @@ -1,9 +1,12 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { err, ok } from "../domain/result.js"; +import { err, ok, type Result } from "../domain/result.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { GhidraRequestQueue } from "./GhidraRequestQueue.js"; -import { bindGhidraSessionFailure } from "./GhidraSessionError.js"; +import { + bindGhidraSessionFailure, + GhidraSessionError, +} from "./GhidraSessionError.js"; const failure = bindGhidraSessionFailure(() => ({ target_path: "/tmp/queue-fixture", @@ -103,7 +106,11 @@ describe("Ghidra request queue cancellation", () => { const queue = new GhidraRequestQueue( async () => { await completed; - return err(failure("cancelled", "Request cancelled")); + return err( + failure("cancelled", "Request cancelled", undefined, { + cancellation: { reason: null }, + }), + ); }, failure, () => Promise.resolve(err(cleanupFailure)), @@ -122,6 +129,7 @@ describe("Ghidra request queue cancellation", () => { }); if (result.ok) throw new Error("Expected cancellation"); expect(result.error.cleanupFailure).toBe(cleanupFailure); + expect(result.error.cancellation).toEqual({ reason: null }); await expect(queued).resolves.toMatchObject({ ok: false, error: { kind: "process" }, @@ -143,6 +151,7 @@ describe("Ghidra request queue cancellation", () => { "cancelled", "Request cancelled", cancellationCause, + { cancellation: { reason: cancellationCause } }, ); let release: (() => void) | undefined; const completed = new Promise((resolve) => { @@ -175,7 +184,11 @@ describe("Ghidra request queue cancellation", () => { }); if (result.ok) throw new Error("Expected cancellation"); expect(result.error.cause).toBe(cancellationCause); - expect(result.error.cleanupFailure).toBeUndefined(); + expect(result.error.cancellation?.reason).toBe(cancellationCause); + expect(result.error.cleanupFailure).toMatchObject({ + cleanupIncomplete: true, + cause, + }); }, ); @@ -201,3 +214,242 @@ describe("Ghidra request queue cancellation", () => { await expect(active).resolves.toEqual(err(original)); }); }); + +const deferred = () => { + let resolve!: (value: Value) => void; + let reject!: (cause: unknown) => void; + const promise = new Promise((accept, fail) => { + resolve = accept; + reject = fail; + }); + return { promise, resolve, reject }; +}; +type ExecutionResult = Result; +type CleanupResult = Result; +const nextTurn = () => new Promise((resolve) => setImmediate(resolve)); + +it("joins a completed response with cancellation cleanup before reporting cancellation", async () => { + const execution = deferred(); + const cleanup = deferred(); + const stop = vi.fn(() => cleanup.promise); + const controller = new AbortController(); + const queue = new GhidraRequestQueue(() => execution.promise, failure, stop); + let settled = false; + const pending = queue + .run("active", {}, { signal: controller.signal }) + .then((result) => { + settled = true; + return result; + }); + execution.resolve(ok(null)); + controller.abort(null); + await nextTurn(); + expect(settled).toBe(false); + cleanup.resolve(ok(null)); + const result = await pending; + expect(result).toMatchObject({ + ok: false, + error: { kind: "cancelled", cancellation: { reason: null } }, + }); + expect(stop).toHaveBeenCalledTimes(1); +}); + +it("awaits active cancellation cleanup when execution rejects", async () => { + const execution = deferred(); + const cleanup = deferred(); + const cause = new Error("executor rejected"); + const cleanupFailure = new ProviderCleanupError( + "ghidra", + ["owned-runtime"], + {}, + ); + const controller = new AbortController(); + const queue = new GhidraRequestQueue( + () => execution.promise, + failure, + () => cleanup.promise, + ); + let settled = false; + const pending = queue + .run("active", {}, { signal: controller.signal }) + .then((result) => { + settled = true; + return result; + }); + controller.abort(null); + execution.reject(cause); + await nextTurn(); + expect(settled).toBe(false); + cleanup.resolve(err(cleanupFailure)); + const result = await pending; + if (result.ok) throw new Error("Expected execution failure"); + expect(result.error).toMatchObject({ kind: "protocol", cause }); + expect(result.error.cleanupFailure).toBe(cleanupFailure); +}); + +it("normalizes a synchronous executor rejection and drains the next request", async () => { + const cause = new Error("synchronous execution rejected"); + const queue = new GhidraRequestQueue( + (method) => { + if (method === "first") throw cause; + return Promise.resolve(ok(null)); + }, + failure, + () => Promise.resolve(ok(null)), + ); + const result: unknown = await queue + .run("first", {}, {}) + .catch((failure: unknown) => failure); + expect(result).toMatchObject({ + ok: false, + error: { kind: "protocol", cause }, + }); + await expect(queue.run("second", {}, {})).resolves.toEqual(ok(null)); +}); + +it.each([null, "caller stopped", new Error("caller stopped")])( + "retains the exact queued cancellation reason %j", + async (reason) => { + const controller = new AbortController(); + const execution = deferred(); + const queue = new GhidraRequestQueue( + () => execution.promise, + failure, + () => Promise.resolve(ok(null)), + ); + const active = queue.run("active", {}, {}); + const queued = queue.run("queued", {}, { signal: controller.signal }); + controller.abort(reason); + const result = await queued; + execution.resolve(ok(null)); + await active; + if (result.ok) throw new Error("Expected queued cancellation"); + expect(result.error.cancellation?.reason).toBe(reason); + }, +); + +it.each([null, "caller stopped", new Error("caller stopped")])( + "retains the exact pre-admission cancellation reason %j", + async (reason) => { + const controller = new AbortController(); + controller.abort(reason); + const execute = vi.fn(() => Promise.resolve(ok(null))); + const queue = new GhidraRequestQueue(execute, failure, () => + Promise.resolve(ok(null)), + ); + const result = await queue.run("active", {}, { signal: controller.signal }); + if (result.ok) throw new Error("Expected pre-admission cancellation"); + expect(result.error.cancellation?.reason).toBe(reason); + expect(execute).not.toHaveBeenCalled(); + }, +); + +it.each(["return", "throw"] as const)( + "captures cleanup rejection before execution can %s", + async (stage) => { + const execution = deferred(); + const cleanup = deferred(); + const cause = new Error("early cleanup rejection"); + const original = failure("remote", "Observed operation failure"); + const controller = new AbortController(); + const queue = new GhidraRequestQueue( + () => execution.promise, + failure, + () => cleanup.promise, + ); + let settled = false; + const pending = queue + .run("active", {}, { signal: controller.signal }) + .then((result) => { + settled = true; + return result; + }); + controller.abort(null); + cleanup.reject(cause); + await nextTurn(); + expect(settled).toBe(false); + if (stage === "return") execution.resolve(err(original)); + else execution.reject(original); + const result = await pending; + if (result.ok) throw new Error("Expected operation and cleanup failure"); + expect(result.error.kind).toBe("remote"); + expect(result.error.cleanupFailure).toMatchObject({ + cleanupIncomplete: true, + cause, + }); + }, +); + +it("retains cleanup uncertainty when the cancellation cleanup callback throws synchronously", async () => { + const execution = deferred(); + const cause = new Error("synchronous cleanup rejection"); + const controller = new AbortController(); + const queue = new GhidraRequestQueue( + () => execution.promise, + failure, + () => { + throw cause; + }, + ); + const pending = queue.run("active", {}, { signal: controller.signal }); + controller.abort(null); + execution.resolve(ok(null)); + const result = await pending; + if (result.ok) throw new Error("Expected unconfirmed cancellation cleanup"); + expect(result.error).toMatchObject({ + kind: "cancelled", + cancellation: { reason: null }, + cleanupFailure: { cleanupIncomplete: true, cause }, + }); +}); + +it.each([ + { order: "execution-first", execution: "returned" }, + { order: "cancellation-first", execution: "returned" }, + { order: "execution-first", execution: "rejected" }, + { order: "cancellation-first", execution: "rejected" }, +])( + "tracks both cleanup owners through $execution execution and $order retry", + async ({ order, execution }) => { + const executionCleanup = new ProviderCleanupError( + "ghidra", + ["execution-owner"], + {}, + ); + const cancellationCleanup = new ProviderCleanupError( + "ghidra", + ["cancellation-owner"], + {}, + ); + const primary = new GhidraSessionError( + "remote", + "Operation failed", + {}, + { cleanupFailure: executionCleanup }, + ); + const controller = new AbortController(); + const queue = new GhidraRequestQueue( + () => + execution === "returned" + ? Promise.resolve(err(primary)) + : Promise.reject(primary), + failure, + () => Promise.resolve(err(cancellationCleanup)), + ); + const pending = queue.run("active", {}, { signal: controller.signal }); + controller.abort(null); + const result = await pending; + if (result.ok || result.error.cleanupFailure === undefined) + throw new Error("Expected both cleanup owners"); + const aggregate = result.error.cleanupFailure; + expect(aggregate.cleanupIncomplete).toBe(true); + const owners = + order === "execution-first" + ? [executionCleanup, cancellationCleanup] + : [cancellationCleanup, executionCleanup]; + owners[0]?.resolveCleanup(); + expect(aggregate.cleanupIncomplete).toBe(true); + owners[1]?.resolveCleanup(); + expect(aggregate.cleanupIncomplete).toBe(false); + }, +); diff --git a/src/ghidra/GhidraRequestQueue.ts b/src/ghidra/GhidraRequestQueue.ts index 0b03d448ae..eca4b6924f 100644 --- a/src/ghidra/GhidraRequestQueue.ts +++ b/src/ghidra/GhidraRequestQueue.ts @@ -1,10 +1,11 @@ +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { err, type Result } from "../domain/result.js"; import type { GhidraRequestOptions } from "./GhidraClientTypes.js"; import { GhidraSessionError, - type GhidraSessionFailureKind, + type GhidraSessionFailureFactory, } from "./GhidraSessionError.js"; /** Result settled by one queued Ghidra Program request. */ @@ -15,13 +16,6 @@ export type GhidraRequestExecutor = ( parameters: JsonValue, options: GhidraRequestOptions, ) => Promise; -/** Bind queue failures to the owning client's live diagnostics. */ -export type GhidraQueueFailureFactory = ( - kind: GhidraSessionFailureKind, - message: string, - cause?: unknown, -) => GhidraSessionError; - /** Stop the provider session when an admitted operation is cancelled. */ export type GhidraActiveCancellationHandler = () => Promise< Result @@ -43,7 +37,7 @@ export class GhidraRequestQueue { constructor( private readonly execute: GhidraRequestExecutor, - private readonly failure: GhidraQueueFailureFactory, + private readonly failure: GhidraSessionFailureFactory, private readonly onActiveCancellation: GhidraActiveCancellationHandler, ) {} @@ -56,19 +50,14 @@ export class GhidraRequestQueue { if (this.#closedFailure !== undefined) return Promise.resolve(err(this.#closedFailure)); if (options.signal?.aborted === true) - return Promise.resolve( - err(this.failure("cancelled", "Ghidra request was cancelled")), - ); + return Promise.resolve(err(this.#cancelled(options.signal))); return new Promise((resolve) => { let entry: QueuedRequest; const onAbort = options.signal === undefined ? undefined : () => - this.#rejectQueued( - entry, - err(this.failure("cancelled", "Ghidra request was cancelled")), - ); + this.#rejectQueued(entry, err(this.#cancelled(options.signal))); entry = { method, parameters, @@ -103,67 +92,99 @@ export class GhidraRequestQueue { if (entry === undefined) return; this.#release(entry); if (entry.options.signal?.aborted === true) { - entry.resolve( - err(this.failure("cancelled", "Ghidra request was cancelled")), - ); + entry.resolve(err(this.#cancelled(entry.options.signal))); this.#drain(); return; } this.#active = true; - let executionSettled = false; - let activeCancellation: Promise> | undefined; - const onAbort = - entry.options.signal === undefined - ? undefined - : () => { - if (executionSettled) return; - this.failQueued( - this.failure( - "process", - "Ghidra session closed after active request cancellation", - ), - ); - activeCancellation = this.onActiveCancellation(); - }; - if (onAbort !== undefined) - entry.options.signal?.addEventListener("abort", onAbort, { once: true }); - void this.execute(entry.method, entry.parameters, entry.options) - .then(async (result) => { - if (activeCancellation !== undefined) { - const primary = result.ok - ? this.failure("cancelled", "Ghidra request was cancelled") - : result.error; - try { - const cleanup = await activeCancellation; - if (!cleanup.ok) - result = err(this.#withCleanupFailure(primary, cleanup.error)); - } catch (cause: unknown) { - result = err(this.#withUnexpectedCleanupRejection(primary, cause)); - } - } - executionSettled = true; - entry.resolve(result); - }) - .catch((cause: unknown) => { - executionSettled = true; - entry.resolve( - err( - this.failure( - "protocol", - "Ghidra serial request execution rejected unexpectedly", - cause, - ), - ), - ); - }) + void this.#executeEntry(entry) + .then(entry.resolve) .finally(() => { - if (entry.options.signal !== undefined && onAbort !== undefined) - entry.options.signal.removeEventListener("abort", onAbort); this.#active = false; if (this.#closedFailure === undefined) this.#drain(); }); } + async #executeEntry( + entry: QueuedRequest, + ): Promise { + let cancellation: + | { + readonly failure: GhidraSessionError; + readonly cleanup: Promise< + | { + readonly kind: "returned"; + readonly value: Result; + } + | { readonly kind: "rejected"; readonly cause: unknown } + >; + } + | undefined; + const onAbort = (): void => { + this.failQueued( + this.failure( + "process", + "Ghidra session closed after active request cancellation", + ), + ); + cancellation = { + failure: this.#cancelled(entry.options.signal), + // Capture rejection immediately, including a synchronous callback throw. + // Execution and finalization may settle in either order. + cleanup: Promise.resolve() + .then(() => this.onActiveCancellation()) + .then( + (value) => ({ kind: "returned", value }), + (cause: unknown) => ({ kind: "rejected", cause }), + ), + }; + }; + entry.options.signal?.addEventListener("abort", onAbort, { once: true }); + try { + let result: GhidraQueuedRequestResult; + try { + result = await this.execute( + entry.method, + entry.parameters, + entry.options, + ); + } catch (cause: unknown) { + result = err( + cause instanceof GhidraSessionError + ? cause + : this.failure( + "protocol", + "Ghidra serial request execution rejected unexpectedly", + cause, + ), + ); + } + if (cancellation === undefined) return result; + const primary = result.ok ? cancellation.failure : result.error; + const cleanup = await cancellation.cleanup; + if (cleanup.kind === "rejected") + return err( + this.#withUnexpectedCleanupRejection(primary, cleanup.cause), + ); + return err( + cleanup.value.ok + ? primary + : primary.withCleanupFailure(cleanup.value.error), + ); + } finally { + entry.options.signal?.removeEventListener("abort", onAbort); + } + } + + #cancelled(signal: AbortSignal | undefined): GhidraSessionError { + return this.failure( + "cancelled", + "Ghidra request was cancelled", + signal?.reason, + signal === undefined ? {} : { cancellation: { reason: signal.reason } }, + ); + } + #rejectQueued(entry: QueuedRequest, result: GhidraQueuedRequestResult): void { const index = this.#queue.indexOf(entry); if (index < 0) return; @@ -172,50 +193,25 @@ export class GhidraRequestQueue { entry.resolve(result); } - #withCleanupFailure( - primary: GhidraSessionError, - cleanupFailure: AnalysisError, - ): GhidraSessionError { - return new GhidraSessionError( - primary.kind, - primary.message, - primary.diagnostics, - { - ...(primary.cause === undefined ? {} : { cause: primary.cause }), - ...(primary.timeoutMs === undefined - ? {} - : { timeoutMs: primary.timeoutMs }), - ...(primary.remoteCode === undefined - ? {} - : { remoteCode: primary.remoteCode }), - cleanupFailure, - }, - ); - } - #withUnexpectedCleanupRejection( primary: GhidraSessionError, cause: unknown, ): GhidraSessionError { const described = this.failure(primary.kind, primary.message, cause); const rejection = described.diagnostics.failure_cause; - return new GhidraSessionError( - primary.kind, - primary.message, + const cleanupFailure = new ProviderCleanupError( + "ghidra", + ["Ghidra cancellation cleanup"], { - ...primary.diagnostics, + reason: "Cancellation cleanup rejected before release was confirmed", unexpected_cleanup_rejection: rejection ?? { type: typeof cause }, }, - { - ...(primary.cause === undefined ? {} : { cause: primary.cause }), - ...(primary.timeoutMs === undefined - ? {} - : { timeoutMs: primary.timeoutMs }), - ...(primary.remoteCode === undefined - ? {} - : { remoteCode: primary.remoteCode }), - }, + { cause }, ); + return primary.withCleanupFailure(cleanupFailure, { + ...primary.diagnostics, + unexpected_cleanup_rejection: rejection ?? { type: typeof cause }, + }); } #release(entry: QueuedRequest): void { diff --git a/src/ghidra/GhidraSessionError.test.ts b/src/ghidra/GhidraSessionError.test.ts index 874ef6a118..bede4f5edd 100644 --- a/src/ghidra/GhidraSessionError.test.ts +++ b/src/ghidra/GhidraSessionError.test.ts @@ -1,7 +1,11 @@ import { expect, it } from "vitest"; import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; -import { bindGhidraSessionFailure } from "./GhidraSessionError.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { + GhidraSessionError, + bindGhidraSessionFailure, +} from "./GhidraSessionError.js"; import { GhidraLaunchError } from "./GhidraLauncher.js"; it("reports truncated cyclic causes in serializable diagnostics", () => { @@ -115,3 +119,105 @@ it("bounds compiler stderr after explicit redaction at UTF-8 boundaries", () => expect(failureCause.compiler_outcome.stderr).not.toContain(sensitiveValue); expect(failureCause.compiler_outcome.stderr).not.toContain("\uFFFD"); }); + +it.each([null, undefined, new Error("original cause")])( + "keeps owned facts and cause omission distinct while diagnostics change (%j)", + (cause) => { + const cleanup = new ProviderCleanupError("ghidra", ["owned-file"], {}); + const original = new GhidraSessionError( + "remote", + "Observed failure", + { stage: "first" }, + { + cause, + timeoutMs: 19, + remoteCode: "original", + cancellation: { reason: null }, + cleanupFailure: cleanup, + }, + ); + const refreshed = original.withDiagnostics( + { stage: "last" }, + "Refreshed public failure", + ); + expect(refreshed.message).toBe("Refreshed public failure"); + expect(refreshed.cause).toBe(cause); + expect(Object.hasOwn(refreshed, "cause")).toBe(true); + expect(refreshed).toMatchObject({ + kind: "remote", + timeoutMs: 19, + remoteCode: "original", + cancellation: { reason: null }, + diagnostics: { stage: "last" }, + }); + expect(refreshed.cleanupFailure).toBe(cleanup); + cleanup.resolveCleanup(); + expect(refreshed.cleanupFailure?.cleanupIncomplete).toBe(false); + expect( + Object.hasOwn( + new GhidraSessionError("start", "No cause").withDiagnostics({}), + "cause", + ), + ).toBe(false); + }, +); + +it("preserves the compiler root cleanup dependency through launch and session projection", () => { + const cleanup = new ProviderCleanupError( + "process-ownership", + ["/owned/helper-root"], + { reason: "controlled cleanup failure" }, + ); + const compiler = new DarwinProcessOwnershipInspectionError( + "compiler failed", + { cleanupFailure: cleanup }, + ); + const launch = new GhidraLaunchError("launch failed", { cause: compiler }); + const session = bindGhidraSessionFailure(() => ({}))( + "start", + launch.message, + launch, + ); + expect(session.cleanupFailure).toBe(cleanup); + cleanup.resolveCleanup(); + expect(session.cleanupFailure?.cleanupIncomplete).toBe(false); +}); + +it("retains compiler raw output counts through nested launch errors without inferring missing counts", () => { + const capturedOutput = { + stdout: "compiler stdout", + stderr: "�", + stdout_bytes: 15, + stderr_bytes: 1, + truncated: false, + exit_code: 7, + signal: null, + }; + const compiler = new DarwinProcessOwnershipInspectionError( + "compiler failure", + { capturedOutput }, + ); + const launch = new GhidraLaunchError("launch failure", { cause: compiler }); + const rollback = new GhidraLaunchError("rollback failure", { cause: launch }); + const project = bindGhidraSessionFailure(() => ({})); + expect( + project("start", "startup failure", rollback).diagnostics, + ).toMatchObject({ + failure_cause: { + launch_outcome: capturedOutput, + cause: { launch_outcome: capturedOutput }, + }, + }); + const unknownCounts = new GhidraLaunchError("unknown counts", { + capturedOutput: { + stdout: "x".repeat(12000), + stderr: "�", + truncated: false, + }, + }); + const serialized = JSON.stringify( + project("start", "startup failure", unknownCounts).diagnostics, + ); + expect(serialized).not.toContain("stdout_bytes"); + expect(serialized).not.toContain("stderr_bytes"); +}); diff --git a/src/ghidra/GhidraSessionError.ts b/src/ghidra/GhidraSessionError.ts index 9f4abfb52d..4de4807c8d 100644 --- a/src/ghidra/GhidraSessionError.ts +++ b/src/ghidra/GhidraSessionError.ts @@ -1,4 +1,5 @@ -import type { AnalysisError } from "../domain/analysisErrorBase.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; +import { AnalysisError } from "../domain/analysisErrorBase.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js"; import { boundedUtf8Text } from "../process/boundedUtf8Text.js"; @@ -26,6 +27,7 @@ export type GhidraSessionFailureKind = /** Additional structured context for a Ghidra session failure. */ export interface GhidraSessionErrorOptions extends ErrorOptions { + readonly cancellation?: { readonly reason: unknown }; readonly timeoutMs?: number; readonly remoteCode?: string; readonly cleanupFailure?: AnalysisError; @@ -37,6 +39,7 @@ export class GhidraSessionError extends Error { readonly timeoutMs: number | undefined; readonly remoteCode: string | undefined; readonly cleanupFailure: AnalysisError | undefined; + readonly cancellation: { readonly reason: unknown } | undefined; constructor( readonly kind: GhidraSessionFailureKind, @@ -50,6 +53,46 @@ export class GhidraSessionError extends Error { this.timeoutMs = options.timeoutMs; this.remoteCode = options.remoteCode; this.cleanupFailure = options.cleanupFailure; + this.cancellation = options.cancellation; + } + + /** Refresh diagnostics without discarding owned failure or cleanup facts. */ + withDiagnostics( + diagnostics: Readonly>, + message = this.message, + ): GhidraSessionError { + return this.#copy(diagnostics, this.cleanupFailure, message); + } + + /** Join cleanup uncertainty, following both exact owners through later retry. */ + withCleanupFailure( + cleanup: AnalysisError, + diagnostics = this.diagnostics, + ): GhidraSessionError { + const combined = + this.cleanupFailure === undefined || this.cleanupFailure === cleanup + ? cleanup + : analysisErrorWithCleanupFailure( + this.cleanupFailure, + cleanup, + ).bindCleanupDependencies([this.cleanupFailure, cleanup]); + return this.#copy(diagnostics, combined); + } + + #copy( + diagnostics: Readonly>, + cleanupFailure: AnalysisError | undefined, + message = this.message, + ): GhidraSessionError { + return new GhidraSessionError(this.kind, message, diagnostics, { + ...(Object.hasOwn(this, "cause") ? { cause: this.cause } : {}), + ...(this.cancellation === undefined + ? {} + : { cancellation: this.cancellation }), + ...(this.timeoutMs === undefined ? {} : { timeoutMs: this.timeoutMs }), + ...(this.remoteCode === undefined ? {} : { remoteCode: this.remoteCode }), + ...(cleanupFailure === undefined ? {} : { cleanupFailure }), + }); } } @@ -61,6 +104,7 @@ const createGhidraSessionError = (failure: { readonly cause?: unknown; readonly timeoutMs?: number; readonly remoteCode?: string; + readonly cancellation?: { readonly reason: unknown }; readonly redact: (value: string, incomplete?: boolean) => string; }): GhidraSessionError => new GhidraSessionError( @@ -78,6 +122,9 @@ const createGhidraSessionError = (failure: { }), }, { + ...(failure.cancellation === undefined + ? {} + : { cancellation: failure.cancellation }), ...(failure.cause === undefined ? {} : { cause: failure.cause }), ...(failure.timeoutMs === undefined ? {} @@ -98,9 +145,12 @@ export const bindGhidraSessionFailure = kind: GhidraSessionFailureKind, message: string, cause?: unknown, - options: Pick = {}, - ): GhidraSessionError => - createGhidraSessionError({ + options: Pick< + GhidraSessionErrorOptions, + "timeoutMs" | "remoteCode" | "cancellation" + > = {}, + ): GhidraSessionError => { + const failure = createGhidraSessionError({ kind, message: redact(message), diagnostics: { @@ -113,6 +163,22 @@ export const bindGhidraSessionFailure = ...(cause === undefined ? {} : { cause }), ...options, }); + const cleanup = + cause instanceof DarwinProcessOwnershipInspectionError || + cause instanceof GhidraLaunchError + ? cause.cleanupFailure + : cause instanceof AnalysisError && cause.cleanupIncomplete + ? cause + : undefined; + return cleanup === undefined + ? failure + : failure.withCleanupFailure(cleanup); + }; + +/** Canonical factory contract shared by session lifecycle owners. */ +export type GhidraSessionFailureFactory = ReturnType< + typeof bindGhidraSessionFailure +>; const describeCause = ( cause: unknown, @@ -157,6 +223,7 @@ const describeErrorCause = ( ...causeCommandOutcome(cause, redact), ...causeCompilerOutcome(cause, redact), ...causeLaunchOutcome(cause, redact), + ...causeLaunchRollback(cause, redact, depth), ...nestedErrorCause(cause, redact, depth), }; }; @@ -257,6 +324,101 @@ const boundedCompilerField = ( return { value: bounded.text, truncated: bounded.truncated }; }; +/** Project the producer's tagged rollback facts independently of the primary cause. */ +const causeLaunchRollback = ( + cause: Error, + redact: (value: string, incomplete?: boolean) => string, + depth: number, +): Record => { + if ( + !(cause instanceof GhidraLaunchError) || + cause.rollbackFailure === undefined + ) + return {}; + const rollback = cause.rollbackFailure; + if (rollback.kind === "rejected") + return { + rollback_failure: { + kind: rollback.kind, + ...(depth >= MAX_CAUSE_DEPTH + ? { cause_truncated: true } + : { cause: describeCause(rollback.cause, redact, depth + 1) }), + }, + }; + const result = rollback.result; + const reason = boundedUtf8Text( + redact(result.reason), + MAX_CAUSE_MESSAGE_BYTES, + ); + const diagnostic = (value: string | undefined): Record => { + if (value === undefined) return {}; + const bounded = boundedUtf8Text(redact(value), MAX_CAUSE_MESSAGE_BYTES); + return { + diagnostic: bounded.text, + ...(bounded.truncated ? { diagnostic_truncated: true } : {}), + }; + }; + // Share one member-evidence budget, matching a retained launcher-command stream. + // Keep the complete typed result on GhidraLaunchError for the process owner. + let remainingBytes = GHIDRA_LAUNCH_COMMAND_OUTPUT_BYTES; + const members = ( + values: readonly Member[], + project: (value: Member) => JsonValue, + ): { readonly rows: JsonValue[]; readonly omitted: number } => { + const rows: JsonValue[] = []; + for (const value of values) { + const row = project(value); + const bytes = + Buffer.byteLength(JSON.stringify(row)) + (rows.length === 0 ? 0 : 1); + if (bytes > remainingBytes) break; + remainingBytes -= bytes; + rows.push(row); + } + return { rows, omitted: values.length - rows.length }; + }; + const failures = + result.failures === undefined + ? undefined + : members(result.failures, (failure) => ({ + pid: failure.pid, + reason: failure.reason, + ...diagnostic(failure.diagnostic), + })); + const unverified = + result.unverified === undefined + ? undefined + : members(result.unverified, (member) => ({ + pid: member.pid, + ...diagnostic(member.diagnostic), + })); + return { + rollback_failure: { + kind: rollback.kind, + result: { + cleaned: false, + reason: reason.text, + ...(reason.truncated ? { reason_truncated: true } : {}), + ...(failures === undefined + ? {} + : { + failures: failures.rows, + ...(failures.omitted === 0 + ? {} + : { failures_omitted: failures.omitted }), + }), + ...(unverified === undefined + ? {} + : { + unverified: unverified.rows, + ...(unverified.omitted === 0 + ? {} + : { unverified_omitted: unverified.omitted }), + }), + }, + }, + }; +}; + /** Retain a launch failure's launcher command outcome in its cause envelope. */ const causeLaunchOutcome = ( cause: Error, @@ -292,12 +454,12 @@ const causeLaunchOutcome = ( stdout: stdout.text, stderr: stderr.text, truncated, - ...(truncated - ? { - stdout_bytes: Buffer.byteLength(output.stdout), - stderr_bytes: Buffer.byteLength(output.stderr), - } - : {}), + ...(output.stdout_bytes === undefined + ? {} + : { stdout_bytes: output.stdout_bytes }), + ...(output.stderr_bytes === undefined + ? {} + : { stderr_bytes: output.stderr_bytes }), ...(output.exit_code === undefined ? {} : { exit_code: output.exit_code }), diff --git a/src/ghidra/GhidraSocketConnection.ts b/src/ghidra/GhidraSocketConnection.ts index ce204d5467..6124fd434c 100644 --- a/src/ghidra/GhidraSocketConnection.ts +++ b/src/ghidra/GhidraSocketConnection.ts @@ -1,11 +1,11 @@ +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; import { createConnection, type Socket } from "node:net"; import { err, ok, type Result } from "../domain/result.js"; import type { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; import { GhidraSessionError, - type GhidraSessionErrorOptions, - type GhidraSessionFailureKind, + type GhidraSessionFailureFactory, } from "./GhidraSessionError.js"; import { type GhidraConnectTarget, @@ -23,13 +23,9 @@ interface GhidraSocketHandlers { /** Inputs needed to poll and attach one authenticated bridge socket. */ export interface GhidraSocketConnectOptions { readonly endpoint: GhidraEndpoint; + readonly resourceScope: ArtifactResourceScope; readonly deadline: ProviderStartupDeadline; - readonly failure: ( - kind: GhidraSessionFailureKind, - message: string, - cause?: unknown, - options?: Pick, - ) => GhidraSessionError; + readonly failure: GhidraSessionFailureFactory; readonly isClosed: () => boolean; readonly processExited: () => boolean; readonly startupTimeoutMs: number; @@ -70,8 +66,18 @@ export const connectGhidraSocket = async ( return err( options.failure("process", "Ghidra exited before bridge startup"), ); - const observed = await observeGhidraEndpoint(options.endpoint); - if (!observed.ok) return observed; + const observed = await observeGhidraEndpoint(options.endpoint, { + signal: options.deadline.signal, + resourceScope: options.resourceScope, + }); + if (!observed.ok) { + if ( + observed.error.kind !== "cancelled" || + options.deadline.interruption === undefined + ) + return observed; + return err(interruptionFailure(options, observed.error)); + } if (observed.value === null) { if ((await options.deadline.wait(50)) === "aborted") return err(interruptionFailure(options)); @@ -135,6 +141,7 @@ const connectGhidraSocketOnce = ( "cancelled", "Ghidra bridge connection was cancelled", { endpoint: target }, + { cause: signal.reason, cancellation: { reason: signal.reason } }, ), ), ); @@ -147,9 +154,17 @@ const connectGhidraSocketOnce = ( const interruptionFailure = ( options: GhidraSocketConnectOptions, -): GhidraSessionError => - options.deadline.interruption === "cancelled" - ? options.failure("cancelled", "Ghidra startup was cancelled") - : options.failure("timeout", "Ghidra startup deadline elapsed", undefined, { - timeoutMs: options.startupTimeoutMs, - }); + cause?: GhidraSessionError, +): GhidraSessionError => { + const failure = + options.deadline.interruption === "cancelled" + ? options.failure("cancelled", "Ghidra startup was cancelled", cause, { + cancellation: { reason: options.deadline.signal.reason }, + }) + : options.failure("timeout", "Ghidra startup deadline elapsed", cause, { + timeoutMs: options.startupTimeoutMs, + }); + return cause?.cleanupFailure === undefined + ? failure + : failure.withCleanupFailure(cause.cleanupFailure); +}; diff --git a/src/ghidra/GhidraTargetSnapshot.ts b/src/ghidra/GhidraTargetSnapshot.ts index b6447bc757..c6846bfc5f 100644 --- a/src/ghidra/GhidraTargetSnapshot.ts +++ b/src/ghidra/GhidraTargetSnapshot.ts @@ -1,16 +1,29 @@ import { createHash } from "node:crypto"; -import { constants } from "node:fs"; -import { open, rm, type FileHandle } from "node:fs/promises"; +import { open } from "node:fs/promises"; import { extname, join } from "node:path"; -import { Transform } from "node:stream"; -import { pipeline } from "node:stream/promises"; +import { + withArtifactRegularFile, + withArtifactFileHandle, + assertArtifactReadActive, + mapArtifactFileFailure, +} from "../artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { readArtifactFileChunks } from "../artifacts/readArtifactFileChunks.js"; +import { + NonRegularFileReadError, + sameRegularFileState, +} from "../filesystem/RegularFile.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { AnalysisArtifactChangedError, AnalysisAccessDeniedError, } from "../domain/analysisErrorCore.js"; -import { windowsPrivateRuntime } from "../windows/WindowsPrivateRuntime.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + WindowsSnapshotCancelledError, + windowsPrivateRuntime, +} from "../windows/WindowsPrivateRuntime.js"; /** Immutable target copy admitted to one ephemeral Ghidra import. */ export interface GhidraTargetSnapshot { @@ -27,6 +40,7 @@ export const createGhidraTargetSnapshot = async ( options: { readonly signal?: AbortSignal; readonly platform?: NodeJS.Platform; + readonly resourceScope?: ArtifactResourceScope; } = {}, ): Promise => { if (!/^[a-f0-9]{64}$/u.test(expectedSha256)) @@ -37,11 +51,20 @@ export const createGhidraTargetSnapshot = async ( `target-${expectedSha256.slice(0, 12)}${suffix}`, ); if ((options.platform ?? process.platform) === "win32") { - const observed = await windowsPrivateRuntime(runtimeRoot).snapshot( - sourcePath, - `target-${expectedSha256.slice(0, 12)}${suffix}`, - options.signal, - ); + const observed = await windowsPrivateRuntime(runtimeRoot) + .snapshot( + sourcePath, + `target-${expectedSha256.slice(0, 12)}${suffix}`, + options.signal, + ) + .catch((cause: unknown) => { + if (cause instanceof WindowsSnapshotCancelledError) + throw new ArtifactReaderFailure("cancelled", cause.message, { + cause, + cancellation: { reason: cause.reason }, + }); + throw cause; + }); if (observed.sha256 !== expectedSha256) throw new AnalysisArtifactChangedError( "open_binary", @@ -54,73 +77,94 @@ export const createGhidraTargetSnapshot = async ( admission: { source: observed.source, snapshot: observed.snapshot }, }; } - let source: FileHandle | undefined; - let destination: FileHandle | undefined; + const resources = options.resourceScope ?? new ArtifactResourceScope(); try { - source = await openSnapshotSource(sourcePath); - if (!(await source.stat()).isFile()) - throw new AnalysisArtifactChangedError( - "open_binary", + return await resources.run(() => + withArtifactRegularFile( sourcePath, - "The selected Ghidra source is no longer a regular file", - ); - destination = await open(snapshotPath, "wx", 0o600); - await destination.chmod(0o600); - const hash = createHash("sha256"); - const digest = new Transform({ - transform(chunk: unknown, _encoding, done) { - if (!Buffer.isBuffer(chunk)) { - done(new TypeError("Ghidra snapshot stream must contain bytes")); - return; - } - hash.update(chunk); - done(null, chunk); - }, - }); - await pipeline( - source.createReadStream(), - digest, - destination.createWriteStream(), - options.signal === undefined ? {} : { signal: options.signal }, + resources, + options.signal, + async (source) => { + const initial = await source.stat({ bigint: true }); + assertArtifactReadActive(options.signal); + return await withArtifactFileHandle( + snapshotPath, + await open(snapshotPath, "wx", 0o600), + resources, + options.signal, + async (destination) => { + await destination.chmod(0o600); + const hash = createHash("sha256"); + const input = readArtifactFileChunks(source, { + end: initial.size, + signal: options.signal, + }); + for await (const chunk of input) { + assertArtifactReadActive(options.signal); + // writeFile completes the entire supplied chunk at the current + // descriptor offset. Only this owner closes either descriptor. + await destination.writeFile(chunk); + hash.update(chunk); + assertArtifactReadActive(options.signal); + } + const coverage = input.readCoverage(); + const observed = hash.digest("hex"); + const after = await source.stat({ bigint: true }); + if ( + coverage.kind !== "complete" || + coverage.bytes !== initial.size || + !sameRegularFileState(initial, after) || + observed !== expectedSha256 + ) + throw new AnalysisArtifactChangedError( + "open_binary", + sourcePath, + `Ghidra target snapshot digest or source identity changed: expected ${expectedSha256}, observed ${observed}`, + ); + assertArtifactReadActive(options.signal); + return { path: snapshotPath, sha256: observed }; + }, + ); + }, + "follow", + ), ); - const observedSha256 = hash.digest("hex"); - if (observedSha256 !== expectedSha256) - throw new AnalysisArtifactChangedError( - "open_binary", - sourcePath, - `Ghidra target snapshot digest mismatch: expected ${expectedSha256}, observed ${observedSha256}`, - ); - return { path: snapshotPath, sha256: observedSha256 }; } catch (cause: unknown) { - if (destination !== undefined) - await rm(snapshotPath, { force: true }).catch((cause: unknown) => { - // best-effort cleanup: snapshot removal must not mask the copy failure. - void cause; - }); - throw cause; - } finally { - await Promise.all([source?.close(), destination?.close()]); + // The private runtime owns partial snapshot entries. Its owner removes the + // tree after every retained descriptor settles; do not unlink a raw name. + throw mapArtifactFileFailure(cause, classifySnapshotFailure); } }; -const openSnapshotSource = async (path: string): Promise => { - try { - // A path replaced by a FIFO must not block startup before its type is checked. - return await open(path, constants.O_RDONLY | constants.O_NONBLOCK); - } catch (cause: unknown) { - const code = - cause instanceof Error && "code" in cause ? cause.code : undefined; - if (code === "EACCES" || code === "EPERM") - throw new AnalysisAccessDeniedError("open_binary", path, code, { cause }); - if (code === "ENOENT" || code === "ENOTDIR" || code === "ELOOP") - throw new AnalysisArtifactChangedError( - "open_binary", - path, - `The selected Ghidra source could not be opened (${code})`, - { cause }, - ); - throw cause; - } +const classifySnapshotFailure = (cause: unknown): unknown => { + if (cause instanceof NonRegularFileReadError) + return new AnalysisArtifactChangedError( + "open_binary", + cause.path, + "The selected Ghidra source is no longer a regular file", + { cause }, + ); + if (!(cause instanceof Error) || !("code" in cause)) return cause; + const path = + "path" in cause && typeof cause.path === "string" + ? cause.path + : "Ghidra snapshot file"; + if (cause.code === "EACCES" || cause.code === "EPERM") + return new AnalysisAccessDeniedError("open_binary", path, cause.code, { + cause, + }); + if ( + cause.code === "ENOENT" || + cause.code === "ENOTDIR" || + cause.code === "ELOOP" + ) + return new AnalysisArtifactChangedError( + "open_binary", + path, + `The selected Ghidra file could not be opened (${cause.code})`, + { cause }, + ); + return cause; }; const safeExtension = (path: string): string => { diff --git a/src/ghidra/GhidraTransport.ts b/src/ghidra/GhidraTransport.ts index 1315a61c6c..e59454af56 100644 --- a/src/ghidra/GhidraTransport.ts +++ b/src/ghidra/GhidraTransport.ts @@ -1,12 +1,25 @@ -import { access, open } from "node:fs/promises"; +import { access, lstat } from "node:fs/promises"; import { join } from "node:path"; +import { z } from "zod"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; - import { readWindowsPrivateRuntimeFile } from "../windows/WindowsPrivateRuntime.js"; - import { err, ok, type Result } from "../domain/result.js"; -import { GhidraSessionError } from "./GhidraSessionError.js"; -import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + assertArtifactReadActive, + artifactFilePrimaryFailure, + artifactFileCancellationCause, + bindArtifactFileCleanup, + withArtifactRegularFile, +} from "../artifacts/ArtifactFileOperation.js"; +import { readArtifactFileChunks } from "../artifacts/readArtifactFileChunks.js"; +import { sameRegularFileState } from "../filesystem/RegularFile.js"; +import { + GhidraSessionError, + bindGhidraSessionFailure, +} from "./GhidraSessionError.js"; /** Local bridge transports used by the current Ghidra session. */ export type GhidraTransportKind = "unix-socket" | "authenticated-loopback-tcp"; @@ -53,107 +66,144 @@ export type GhidraConnectTarget = | { readonly path: string } | { readonly host: "127.0.0.1"; readonly port: number }; -/** Observe a ready endpoint, returning null while its bridge is not listening. */ +const tcpEndpointSchema = z.strictObject({ + host: z.literal("127.0.0.1"), + port: z.number().int().min(1).max(65_535), +}); +// ReaGhidraBridge.serveTcp publishes compact Gson JSON followed by one newline. +// Both fields are fixed ASCII, and the bound includes the longest legal port. +const MAX_TCP_ENDPOINT_BYTES = Buffer.byteLength( + JSON.stringify({ host: "127.0.0.1", port: 65_535 }) + "\n", +); + +/** Observe one endpoint under the same file owner as its startup and finalization. */ export const observeGhidraEndpoint = async ( endpoint: GhidraEndpoint, + options: { + readonly signal?: AbortSignal; + readonly resourceScope?: ArtifactResourceScope; + } = {}, ): Promise> => { - if (endpoint.transport === "unix-socket") { - try { - await access(endpoint.path); - return ok({ path: endpoint.path }); - } catch (cause: unknown) { - return isMissing(cause) - ? ok(null) - : err( - endpointFailure( - endpoint, - "Ghidra socket observation failed", - cause, - ), - ); - } - } - if (process.platform === "win32") { - try { - return parseTcpEndpoint( - endpoint, - readWindowsPrivateRuntimeFile(endpoint.path), - ); - } catch (cause: unknown) { - return isMissing(cause) || isWindowsEndpointWriterBusy(cause) - ? ok(null) - : err( - endpointFailure( - endpoint, - "Ghidra endpoint observation failed", - cause, - ), - ); - } - } - let handle; + const resources = options.resourceScope ?? new ArtifactResourceScope(); try { - handle = await open(endpoint.path, "r"); - } catch (cause: unknown) { - return isMissing(cause) - ? ok(null) - : err( - endpointFailure( + const target = await resources.run(async () => { + assertArtifactReadActive(options.signal); + if (endpoint.transport === "unix-socket") { + await access(endpoint.path); + assertArtifactReadActive(options.signal); + return { path: endpoint.path }; + } + if (process.platform === "win32") { + const encoded = readWindowsPrivateRuntimeFile(endpoint.path); + assertArtifactReadActive(options.signal); + return parseTcpEndpoint(endpoint, encoded); + } + return withArtifactRegularFile( + endpoint.path, + resources, + options.signal, + async (file) => { + const initial = await file.stat({ bigint: true }); + assertArtifactReadActive(options.signal); + if (initial.size > BigInt(MAX_TCP_ENDPOINT_BYTES)) + throw endpointFailure( + endpoint, + "Ghidra TCP endpoint is invalid", + new ArtifactReaderFailure( + "limit", + `Ghidra endpoint record exceeds its compact publication bound (${initial.size} > ${MAX_TCP_ENDPOINT_BYTES} bytes)`, + ), + ); + const input = readArtifactFileChunks(file, { + end: initial.size, + signal: options.signal, + }); + const chunks: Buffer[] = []; + for await (const chunk of input) chunks.push(Buffer.from(chunk)); + const coverage = input.readCoverage(); + const after = await file.stat({ bigint: true }); + const named = await lstat(endpoint.path, { bigint: true }).catch( + (cause: unknown) => { + throw endpointFailure( + endpoint, + "Ghidra TCP endpoint changed during observation", + cause, + ); + }, + ); + if ( + coverage.kind !== "complete" || + coverage.bytes !== initial.size || + !sameRegularFileState(initial, after) || + !sameRegularFileState(initial, named) + ) + throw endpointFailure( + endpoint, + "Ghidra TCP endpoint changed during observation", + ); + assertArtifactReadActive(options.signal); + return parseTcpEndpoint( endpoint, - "Ghidra endpoint observation failed", - cause, - ), - ); - } - try { - const encoded = await handle.readFile("utf8"); - if (encoded.length === 0) - return err(endpointFailure(endpoint, "Ghidra TCP endpoint is invalid")); - return parseTcpEndpoint(endpoint, encoded); + Buffer.concat(chunks).toString("utf8"), + ); + }, + ); + }); + return ok(target); } catch (cause: unknown) { - return err(endpointFailure(endpoint, "Ghidra endpoint read failed", cause)); - } finally { - await handle.close(); + if ( + isMissing(cause) || + (process.platform === "win32" && isWindowsEndpointWriterBusy(cause)) + ) + return ok(null); + return err(observationFailure(endpoint, cause)); } }; const parseTcpEndpoint = ( endpoint: GhidraEndpoint, encoded: string, -): Result => { +): GhidraConnectTarget => { try { - const value: unknown = JSON.parse(encoded); - if ( - typeof value !== "object" || - value === null || - Array.isArray(value) || - !hasExactKeys(value, ["host", "port"]) || - value.host !== "127.0.0.1" || - !Number.isSafeInteger(value.port) || - typeof value.port !== "number" || - value.port < 1 || - value.port > 65_535 - ) - return err(endpointFailure(endpoint, "Ghidra TCP endpoint is invalid")); - return ok({ host: "127.0.0.1", port: value.port }); + return tcpEndpointSchema.parse(JSON.parse(encoded)); } catch (cause: unknown) { - return err( - endpointFailure(endpoint, "Ghidra TCP endpoint is invalid", cause), - ); + throw endpointFailure(endpoint, "Ghidra TCP endpoint is invalid", cause); } }; -const hasExactKeys = ( - value: object, - expected: readonly string[], -): value is Record => { - const keys = Object.keys(value).sort((left, right) => - compareUnicodeCodePoints(left, right), - ); - return ( - keys.length === expected.length && - keys.every((key, index) => key === expected[index]) - ); +const observationFailure = ( + endpoint: GhidraEndpoint, + cause: unknown, +): GhidraSessionError => { + const primary = artifactFilePrimaryFailure(cause); + let failure = + primary instanceof GhidraSessionError + ? primary + : cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" + ? bindGhidraSessionFailure(() => ({ + transport: endpoint.transport, + endpoint_path: endpoint.path, + }))("cancelled", "Ghidra endpoint observation was cancelled", cause, { + cancellation: { reason: artifactFileCancellationCause(cause) }, + }) + : endpointFailure( + endpoint, + "Ghidra endpoint observation failed", + primary, + ); + if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) { + const cleanup = new ProviderCleanupError( + "ghidra", + cause.cleanup.resources, + { + reason: "Ghidra endpoint descriptor cleanup remains incomplete", + }, + { cause }, + ); + bindArtifactFileCleanup(cause, cleanup); + failure = failure.withCleanupFailure(cleanup); + } + return failure; }; const endpointFailure = ( @@ -161,16 +211,10 @@ const endpointFailure = ( message: string, cause?: unknown, ): GhidraSessionError => - new GhidraSessionError( - "start", - message, - { - transport: endpoint.transport, - endpoint_path: endpoint.path, - ...(cause instanceof Error ? { error: cause.message } : {}), - }, - cause instanceof Error ? { cause } : undefined, - ); + bindGhidraSessionFailure(() => ({ + transport: endpoint.transport, + endpoint_path: endpoint.path, + }))("start", message, cause); const isMissing = (cause: unknown): boolean => cause instanceof Error && "code" in cause && cause.code === "ENOENT"; diff --git a/src/process/DarwinProcessRunTokenReader.ts b/src/process/DarwinProcessRunTokenReader.ts index 79e9d9c114..d8dce5bb37 100644 --- a/src/process/DarwinProcessRunTokenReader.ts +++ b/src/process/DarwinProcessRunTokenReader.ts @@ -9,6 +9,13 @@ import { DEFAULT_SWIFTC_COMPILE_TIMEOUT_MS, providerStartupTimeoutSchema, } from "../config/providerStartupTimeout.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import type { + AnalysisCapturedOutput, + AnalysisError, +} from "../domain/analysisErrorBase.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { safeParseJson } from "../domain/safeJson.js"; import { boundedUtf8Text } from "./boundedUtf8Text.js"; import { execFileOutput, execFileOutputFailure } from "./ExecFileOutput.js"; @@ -108,15 +115,56 @@ export class DarwinProcessOwnershipInspectionError extends Error { message: string, options?: ErrorOptions & { readonly compilerOutcome?: DarwinCompilerOutcome; + readonly capturedOutput?: AnalysisCapturedOutput; + readonly cleanupFailure?: AnalysisError; + readonly cancellation?: { readonly reason: unknown }; }, ) { - const { compilerOutcome, ...errorOptions } = options ?? {}; + const { + compilerOutcome, + capturedOutput, + cleanupFailure, + cancellation, + ...errorOptions + } = options ?? {}; super(message, errorOptions); this.name = "DarwinProcessOwnershipInspectionError"; this.compilerOutcome = compilerOutcome; + this.capturedOutput = capturedOutput; + this.cleanupFailure = cleanupFailure; + this.cancellation = cancellation; } readonly compilerOutcome: DarwinCompilerOutcome | undefined; + readonly capturedOutput: AnalysisCapturedOutput | undefined; + readonly cleanupFailure: AnalysisError | undefined; + readonly cancellation: { readonly reason: unknown } | undefined; + + /** Preserve the physical preparation failure while following the exact cleanup owner. */ + withCleanupFailure( + cleanup: AnalysisError, + ): DarwinProcessOwnershipInspectionError { + const combined = + this.cleanupFailure === undefined || this.cleanupFailure === cleanup + ? cleanup + : analysisErrorWithCleanupFailure( + this.cleanupFailure, + cleanup, + ).bindCleanupDependencies([this.cleanupFailure, cleanup]); + return new DarwinProcessOwnershipInspectionError(this.message, { + ...(Object.hasOwn(this, "cause") ? { cause: this.cause } : {}), + ...(this.compilerOutcome === undefined + ? {} + : { compilerOutcome: this.compilerOutcome }), + ...(this.capturedOutput === undefined + ? {} + : { capturedOutput: this.capturedOutput }), + cleanupFailure: combined, + ...(this.cancellation === undefined + ? {} + : { cancellation: this.cancellation }), + }); + } } /** Explicit, bounded outcome from compiling REA's Darwin ownership helper. */ @@ -130,22 +178,28 @@ export interface DarwinCompilerOutcome { readonly code: string | number | null; readonly signal: string | null; readonly stderr: string; - readonly stderrBytes: number; + readonly stderrBytes?: number; readonly stderrTruncated: boolean; readonly outputTruncated: boolean; readonly killed: boolean; readonly timeoutMs?: number; } -const MAX_COMPILER_STDERR_BYTES = 8 * 1024; +const MAX_COMPILER_OUTPUT_BYTES = 8 * 1024; -const compilerOutcome = ( +const compilerFailureFacts = ( cause: unknown, timeoutMs: number, -): DarwinCompilerOutcome | undefined => { +): + | { + readonly outcome: DarwinCompilerOutcome; + readonly capturedOutput: AnalysisCapturedOutput; + } + | undefined => { const failure = execFileOutputFailure(cause); if (failure === undefined) return undefined; - const stderr = boundedUtf8Text(failure.stderr, MAX_COMPILER_STDERR_BYTES); + const stderr = boundedUtf8Text(failure.stderr, MAX_COMPILER_OUTPUT_BYTES); + const stdout = boundedUtf8Text(failure.stdout, MAX_COMPILER_OUTPUT_BYTES); const outcome = failure.code === "ETIMEDOUT" ? "timed_out" @@ -157,15 +211,33 @@ const compilerOutcome = ( ? "spawn_failed" : "failed"; return { - outcome, - code: failure.code, - signal: failure.signal, - stderr: stderr.text, - stderrBytes: Buffer.byteLength(failure.stderr, "utf8"), - stderrTruncated: stderr.truncated, - outputTruncated: failure.outputTruncated, - killed: failure.killed, - ...(outcome === "timed_out" ? { timeoutMs } : {}), + outcome: { + outcome, + code: failure.code, + signal: failure.signal, + stderr: stderr.text, + ...(failure.stderrBytes === undefined + ? {} + : { stderrBytes: failure.stderrBytes }), + stderrTruncated: stderr.truncated, + outputTruncated: failure.outputTruncated, + killed: failure.killed, + ...(outcome === "timed_out" ? { timeoutMs } : {}), + }, + capturedOutput: { + stdout: stdout.text, + stderr: stderr.text, + truncated: + stdout.truncated || stderr.truncated || failure.outputTruncated, + ...(failure.stdoutBytes === undefined + ? {} + : { stdout_bytes: failure.stdoutBytes }), + ...(failure.stderrBytes === undefined + ? {} + : { stderr_bytes: failure.stderrBytes }), + exit_code: typeof failure.code === "number" ? failure.code : null, + signal: failure.signal, + }, }; }; @@ -188,7 +260,8 @@ const compilerInspectionError = ( timeoutMs: number, compiler: DarwinProcessReaderCompiler, ): DarwinProcessOwnershipInspectionError => { - const outcome = compilerOutcome(cause, timeoutMs); + const facts = compilerFailureFacts(cause, timeoutMs); + const outcome = facts?.outcome; const code = outcome?.code ?? "unknown"; const description = compilerOutcomeDescription(outcome, code); const launcher = @@ -214,14 +287,27 @@ const compilerInspectionError = ( `${prefix} (${description})${suffix}`, { cause, - ...(outcome === undefined ? {} : { compilerOutcome: outcome }), + ...(facts === undefined + ? {} + : { + compilerOutcome: facts.outcome, + capturedOutput: facts.capturedOutput, + }), }, ); }; +/** One allocated helper root, including its shared finalization and live cleanup status. */ +interface ProcessReaderRoot { + readonly path: string; + cleanupPromise?: Promise | undefined; + cleanupAnchor?: ProviderCleanupError | undefined; +} + interface ProcessReaderCompilation { readonly controller: AbortController; - promise: Promise; + promise: Promise>; + primaryFailure?: { readonly cause: unknown }; waiters: number; aborting: boolean; settled: boolean; @@ -239,25 +325,32 @@ const isExpectedAbort = (cause: unknown, signal: AbortSignal): boolean => (cause === signal.reason || (cause instanceof Error && cause.name === "AbortError")); +type CompilationWaitOutcome = + | { readonly kind: "completed"; readonly result: Result } + | { readonly kind: "cancelled"; readonly reason: unknown }; + const waitForCompilation = async ( - promise: Promise, + operation: ProcessReaderCompilation, signal: AbortSignal | undefined, -): Promise => { - if (signal === undefined) return promise; - if (signal.aborted) throw abortReason(signal); - return new Promise((resolve, reject) => { +): Promise => { + if (signal === undefined) + return { kind: "completed", result: await operation.promise }; + return new Promise((resolve) => { let completed = false; - const finish = (callback: () => void) => { + const finish = (outcome: CompilationWaitOutcome) => { if (completed) return; completed = true; signal.removeEventListener("abort", onAbort); - callback(); + resolve(outcome); + }; + const onAbort = () => { + // Finalization belongs to an already observed physical failure. + if (operation.primaryFailure !== undefined) return; + finish({ kind: "cancelled", reason: signal.reason }); }; - const onAbort = () => finish(() => reject(abortReason(signal))); signal.addEventListener("abort", onAbort, { once: true }); - promise.then( - (value) => finish(() => resolve(value)), - (cause: unknown) => finish(() => reject(cause)), + void operation.promise.then((result) => + finish({ kind: "completed", result }), ); if (signal.aborted) onAbort(); }); @@ -294,7 +387,7 @@ export const createDarwinProcessRunTokenReader = ( }; const compilerTimeoutMs = resolveSwiftcCompileTimeoutMs(options); const moduleCachePath = options.moduleCachePath ?? ""; - let root: string | undefined; + let root: ProcessReaderRoot | undefined; let executable: string | undefined; let compilation: ProcessReaderCompilation | undefined; let closePromise: Promise | undefined; @@ -302,19 +395,48 @@ export const createDarwinProcessRunTokenReader = ( let exitCleanupInstalled = false; const cleanupAtExit = () => { - if (root !== undefined) rmSync(root, { recursive: true, force: true }); + if (root !== undefined) rmSync(root.path, { recursive: true, force: true }); }; - const cleanupRoot = async (target = root): Promise => { - if (target === undefined) return; - await rm(target, { recursive: true, force: true }); - if (root !== target) return; - root = undefined; - executable = undefined; - if (exitCleanupInstalled) process.removeListener("exit", cleanupAtExit); - exitCleanupInstalled = false; + const cleanupRoot = (target = root): Promise => { + if (target === undefined) return Promise.resolve(); + target.cleanupPromise ??= Promise.resolve() + .then(async () => { + try { + await rm(target.path, { recursive: true, force: true }); + } catch (cause: unknown) { + const failure = new ProviderCleanupError( + "process-ownership", + [target.path], + { + reason: + "The Darwin ownership helper root remains owned until removal succeeds.", + }, + { cause, operation: "prepare_owned_process" }, + ); + if (target.cleanupAnchor === undefined) + target.cleanupAnchor = failure; + else failure.bindCleanupDependencies([target.cleanupAnchor]); + throw failure; + } + target.cleanupAnchor?.resolveCleanup(); + target.cleanupAnchor = undefined; + if (root !== target) return; + root = undefined; + executable = undefined; + if (exitCleanupInstalled) process.removeListener("exit", cleanupAtExit); + exitCleanupInstalled = false; + }) + .catch((cause: unknown) => { + target.cleanupPromise = undefined; + throw cause; + }); + return target.cleanupPromise; }; - const runCompilation = async (signal: AbortSignal): Promise => { - let operationRoot: string | undefined; + const runCompilation = async ( + operation: ProcessReaderCompilation, + ): Promise => { + const signal = operation.controller.signal; + let operationRoot: ProcessReaderRoot | undefined; try { if ( compiler.kind === "swiftc" && @@ -324,15 +446,17 @@ export const createDarwinProcessRunTokenReader = ( "Direct Darwin helper compilation requires absolute REA_DARWIN_SWIFTC_PATH and SDKROOT paths selecting a compatible installed Swift compiler and macOS SDK.", ); } - operationRoot = await mkdtemp(join(tmpdir(), "rea-process-token-")); + operationRoot = { + path: await mkdtemp(join(tmpdir(), "rea-process-token-")), + }; root = operationRoot; process.once("exit", cleanupAtExit); exitCleanupInstalled = true; if (signal.aborted || closed) throw abortReason(signal); - const output = join(operationRoot, "reader"); + const output = join(operationRoot.path, "reader"); // swiftc keeps intermediate objects under TMPDIR, so keep them inside // the root that cleanup removes. - const compilerTemporary = join(operationRoot, "tmp"); + const compilerTemporary = join(operationRoot.path, "tmp"); await mkdir(compilerTemporary); const compilerEnvironment: NodeJS.ProcessEnv = { ...process.env, @@ -367,10 +491,21 @@ export const createDarwinProcessRunTokenReader = ( executable = output; return output; } catch (cause: unknown) { - const ownsRoot = operationRoot !== undefined && root === operationRoot; + operation.primaryFailure = { cause }; if (operationRoot !== undefined) { - if (ownsRoot) await cleanupRoot(operationRoot); - else await rm(operationRoot, { recursive: true, force: true }); + try { + await cleanupRoot(operationRoot); + } catch (cleanup: unknown) { + if (!(cleanup instanceof ProviderCleanupError)) throw cleanup; + const primary = + cause instanceof DarwinProcessOwnershipInspectionError + ? cause + : new DarwinProcessOwnershipInspectionError( + "Darwin process ownership helper preparation failed", + { cause }, + ); + throw primary.withCleanupFailure(cleanup); + } } throw cause; } @@ -380,10 +515,13 @@ export const createDarwinProcessRunTokenReader = ( const controller = new AbortController(); const operation: ProcessReaderCompilation = { controller, - promise: runCompilation(controller.signal).finally(() => { - operation.settled = true; - if (compilation === operation) compilation = undefined; - }), + promise: Promise.resolve() + .then(() => runCompilation(operation)) + .then(ok, err) + .finally(() => { + operation.settled = true; + if (compilation === operation) compilation = undefined; + }), waiters: 0, aborting: false, settled: false, @@ -405,30 +543,48 @@ export const createDarwinProcessRunTokenReader = ( } let operation = compilation; if (operation?.aborting === true) { - try { - await waitForCompilation(operation.promise, signal); - } catch (cause: unknown) { - if (signal !== undefined && isExpectedAbort(cause, signal)) - throw cause; - } + const waited = await waitForCompilation(operation, signal); + if (waited.kind === "cancelled") throw waited.reason; + if ( + !waited.result.ok && + waited.result.error instanceof + DarwinProcessOwnershipInspectionError && + waited.result.error.cleanupFailure !== undefined + ) + throw waited.result.error; continue; } operation ??= beginCompilation(); operation.waiters += 1; - try { - return await waitForCompilation(operation.promise, signal); - } finally { - operation.waiters -= 1; + const waited = await waitForCompilation(operation, signal); + operation.waiters -= 1; + if ( + operation.waiters === 0 && + !operation.settled && + !operation.aborting + ) { + operation.aborting = true; + operation.controller.abort(); + const finalized = await operation.promise; if ( - operation.waiters === 0 && - !operation.settled && - !operation.aborting + waited.kind === "cancelled" && + !finalized.ok && + finalized.error instanceof DarwinProcessOwnershipInspectionError && + finalized.error.cleanupFailure !== undefined ) { - operation.aborting = true; - operation.controller.abort(); - await operation.promise.catch(() => undefined); + throw new DarwinProcessOwnershipInspectionError( + "Darwin process ownership helper preparation was cancelled", + { + cause: waited.reason, + cancellation: { reason: waited.reason }, + cleanupFailure: finalized.error.cleanupFailure, + }, + ); } } + if (waited.kind === "cancelled") throw waited.reason; + if (!waited.result.ok) throw waited.result.error; + return waited.result.value; } }; @@ -538,7 +694,7 @@ export const createDarwinProcessRunTokenReader = ( if (operation !== undefined && !operation.settled) { operation.aborting = true; operation.controller.abort(); - await operation.promise.catch(() => undefined); + await operation.promise; } await cleanupRoot(); })().catch((cause: unknown) => { diff --git a/src/process/ExecFileOutput.test.ts b/src/process/ExecFileOutput.test.ts index 7d7905cc1f..a385d80719 100644 --- a/src/process/ExecFileOutput.test.ts +++ b/src/process/ExecFileOutput.test.ts @@ -105,10 +105,18 @@ describe("execFileOutput", () => { }), ).rejects.toMatchObject({ killed: true }); }); +}); - it.skipIf(process.platform === "win32")( - "stops an aborted process with the caller's signal and settles after it exits", - async () => { +describe("execFileOutput cancellation lifecycle", () => { + it + .skipIf(process.platform === "win32") + .each([ + null, + "stop after readiness", + new Error("stop after readiness"), + ] as const)( + "stops an aborted process with exact %s reason and settles after it exits", + async (reason) => { const directory = await createTestTempDirectory("rea-exec-stop-"); const ready = join(directory, "ready"); const stopped = join(directory, "stopped"); @@ -134,11 +142,18 @@ describe("execFileOutput", () => { }), ); const readyObserved = await waitForFile(ready); - controller.abort(); + controller.abort(reason); const outcome = await execution; expect(readyObserved).toBe(true); - expect(outcome?.cause).toMatchObject({ name: "AbortError" }); + expect(outcome?.cause).toMatchObject({ + name: "AbortError", + _tag: "ExecFileOutputCancelledError", + }); + if (!(outcome?.cause instanceof Error)) + throw new Error("Expected cancellation failure"); + expect(outcome.cause.cause).toBe(reason); + expect(execFileOutputFailure(outcome.cause)).toBeDefined(); expect(outcome?.stoppedBeforeSettling).toBe("SIGINT"); }, ); @@ -313,3 +328,71 @@ describe("execFileOutput failure metadata", () => { }); }); }); + +describe("execFileOutput cancellation provenance", () => { + it.each([null, "caller stopped", new Error("caller stopped")] as const)( + "retains producer cancellation and exact %s reason", + async (reason) => { + const controller = new AbortController(); + controller.abort(reason); + const failure: unknown = await execFileOutput( + "/must-not-start-after-cancellation", + [], + { + signal: controller.signal, + stopSignal: "SIGTERM", + }, + ).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + _tag: "ExecFileOutputCancelledError", + code: "ABORT_ERR", + name: "AbortError", + }); + if (!(failure instanceof Error)) + throw new Error("Expected owned cancellation"); + expect(failure.cause).toBe(reason); + }, + ); + it("keeps a completed command failure when its consumer later aborts", async () => { + const controller = new AbortController(); + const failure: unknown = await execFileOutput( + process.execPath, + ["-e", "process.exitCode=7"], + { + signal: controller.signal, + stopSignal: "SIGTERM", + }, + ).catch((cause: unknown) => { + controller.abort(null); + return cause; + }); + expect(failure).toMatchObject({ code: 7 }); + expect(failure).not.toHaveProperty("_tag", "ExecFileOutputCancelledError"); + }); +}); + +it.each([ + null, + "native callback stopped", + new Error("native callback stopped"), +] as const)( + "normalizes Node callback cancellation with exact %s reason", + async (reason) => { + const controller = new AbortController(); + controller.abort(reason); + const failure: unknown = await execFileOutput( + process.execPath, + ["-e", "process.exit(0)"], + { + signal: controller.signal, + }, + ).catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + _tag: "ExecFileOutputCancelledError", + code: "ABORT_ERR", + }); + if (!(failure instanceof Error)) + throw new Error("Expected Node callback cancellation"); + expect(failure.cause).toBe(reason); + }, +); diff --git a/src/process/ExecFileOutput.ts b/src/process/ExecFileOutput.ts index b51f6e6ae1..2e80e337a0 100644 --- a/src/process/ExecFileOutput.ts +++ b/src/process/ExecFileOutput.ts @@ -21,14 +21,16 @@ type ExecFileOutputOptions = Omit< /** How long a stopped process may take to exit before it is killed. */ const STOP_GRACE_MS = 5_000; -const abortError = (signal: AbortSignal): Error => { - const error = new Error("The operation was aborted", { - cause: signal.reason, - }); - error.name = "AbortError"; - Reflect.set(error, "code", "ABORT_ERR"); - return error; -}; +/** Cancellation observed by the command owner, with the caller's exact reason. */ +export class ExecFileOutputCancelledError extends Error { + readonly _tag = "ExecFileOutputCancelledError"; + readonly code = "ABORT_ERR"; + + constructor(reason: unknown) { + super("The operation was aborted", { cause: reason }); + this.name = "AbortError"; + } +} /** * A command stopped at its timeout can still exit cleanly, so the timeout is @@ -133,7 +135,7 @@ export const execFileBytes = ( return; } if (stopSignal !== undefined && signal?.aborted === true) { - reject(abortError(signal)); + reject(new ExecFileOutputCancelledError(signal.reason)); return; } let stopped: "abort" | "timeout" | undefined; @@ -162,10 +164,12 @@ export const execFileBytes = ( clearTimeout(grace); const failure = stopped === "abort" && signal !== undefined - ? abortError(signal) + ? new ExecFileOutputCancelledError(signal.reason) : stopped === "timeout" && timeout !== undefined ? timeoutError(command, timeout, error) - : error; + : error?.name === "AbortError" && error.code === "ABORT_ERR" + ? new ExecFileOutputCancelledError(error.cause) + : error; if (failure !== null) { Reflect.set(failure, "stdout", stdout.toString("utf8")); Reflect.set(failure, "stderr", stderr.toString("utf8")); diff --git a/src/process/capture/ProcessCaptureError.test.ts b/src/process/capture/ProcessCaptureError.test.ts new file mode 100644 index 0000000000..2eaa56b6a6 --- /dev/null +++ b/src/process/capture/ProcessCaptureError.test.ts @@ -0,0 +1,157 @@ +import { expect, it } from "vitest"; +import { ProviderCleanupError } from "../../domain/providerCleanupError.js"; +import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; +import { DarwinProcessOwnershipInspectionError } from "../DarwinProcessRunTokenReader.js"; +import { + normalizeCaptureFailure, + ProcessCaptureError, +} from "./ProcessCaptureError.js"; +import { resolveProcessResult } from "./ProcessCaptureLifecycle.js"; + +const cleanupReport = { + owned_process_group: { state: "not_required" as const, reason: null }, + terminal_renderer: { state: "not_required" as const, reason: null }, + temporary_root: { state: "cleaned" as const, reason: null }, +}; +const fixture = () => { + const cleanup = new ProviderCleanupError( + "process-ownership", + ["/owned/helper-root"], + { reason: "controlled removal failure" }, + ); + const compiler = new DarwinProcessOwnershipInspectionError( + "compiler failed", + { + cleanupFailure: cleanup, + capturedOutput: { + stdout: "observed stdout", + stderr: "controlled compiler diagnostic", + truncated: false, + stdout_bytes: 15, + stderr_bytes: 30, + exit_code: 7, + signal: null, + }, + compilerOutcome: { + outcome: "exited", + code: 7, + signal: null, + stderr: "controlled compiler diagnostic", + stderrBytes: 30, + stderrTruncated: false, + outputTruncated: false, + killed: false, + }, + }, + ); + const error = normalizeCaptureFailure(compiler, undefined); + if (!(error instanceof ProcessCaptureError)) + throw new Error("Expected normalized capture failure"); + return { cleanup, compiler, error }; +}; + +it("preserves the compiler output and live cleanup owner at capture normalization", () => { + const { cleanup, compiler, error } = fixture(); + expect(error.cause).toBe(compiler); + expect(error).toMatchObject({ + cleanupIncomplete: true, + cleanupResources: ["/owned/helper-root"], + capturedOutput: { stderr: "controlled compiler diagnostic", exit_code: 7 }, + }); + expect(projectAnalysisError(error)).toMatchObject({ + code: "cleanup_incomplete", + }); + cleanup.resolveCleanup(); + expect(error.cleanupIncomplete).toBe(false); + expect(error.capturedOutput?.exit_code).toBe(7); +}); + +it("retains compiler facts and cleanup dependency through capture finalization", () => { + const { cleanup, error } = fixture(); + let finalized: unknown; + try { + resolveProcessResult(undefined, error, cleanupReport); + } catch (cause: unknown) { + finalized = cause; + } + expect(finalized).toBeInstanceOf(ProcessCaptureError); + expect(finalized).toMatchObject({ + cleanupIncomplete: true, + capturedOutput: { stderr: "controlled compiler diagnostic", exit_code: 7 }, + cleanupReport, + }); + cleanup.resolveCleanup(); + expect(finalized).toMatchObject({ cleanupIncomplete: false }); +}); + +it("does not recreate unresolved cleanup after the exact owner already confirmed retry", () => { + const { cleanup, compiler } = fixture(); + cleanup.resolveCleanup(); + const normalized = normalizeCaptureFailure(compiler, undefined); + expect(normalized).toMatchObject({ + cleanupIncomplete: false, + capturedOutput: { exit_code: 7 }, + }); +}); + +it("keeps later capture cleanup unresolved after compiler cleanup succeeds", () => { + const { cleanup, error } = fixture(); + let finalized: unknown; + try { + resolveProcessResult(undefined, error, { + ...cleanupReport, + temporary_root: { + state: "failed", + reason: "capture root removal denied", + }, + }); + } catch (cause: unknown) { + finalized = cause; + } + expect(finalized).toMatchObject({ + cleanupIncomplete: true, + capturedOutput: { exit_code: 7 }, + cleanupResources: expect.arrayContaining([ + "/owned/helper-root", + "temporary_root", + ]), + }); + cleanup.resolveCleanup(); + expect(finalized).toMatchObject({ cleanupIncomplete: true }); +}); + +it.each(["compiler first", "capture first"] as const)( + "keeps both cleanup owners through %s retry", + (order) => { + const { cleanup, error } = fixture(); + const captureCleanup = new ProviderCleanupError( + "process-capture", + ["/owned/capture-root"], + { reason: "controlled capture root failure" }, + ); + const finalized = error.withFinalization({ + cleanupFailure: captureCleanup, + cleanupReport, + }); + const first = order === "compiler first" ? cleanup : captureCleanup; + const second = order === "compiler first" ? captureCleanup : cleanup; + first.resolveCleanup(); + expect(finalized.cleanupIncomplete).toBe(true); + second.resolveCleanup(); + expect(finalized.cleanupIncomplete).toBe(false); + expect(finalized.capturedOutput?.exit_code).toBe(7); + }, +); + +it("does not infer captured stdout from partial compiler diagnostics", () => { + const { compiler } = fixture(); + const partial = new DarwinProcessOwnershipInspectionError( + "partial diagnostics", + { + compilerOutcome: compiler.compilerOutcome!, + }, + ); + expect(normalizeCaptureFailure(partial, undefined)).toMatchObject({ + capturedOutput: undefined, + }); +}); diff --git a/src/process/capture/ProcessCaptureError.ts b/src/process/capture/ProcessCaptureError.ts index 7cf6f27055..6d13c8f75a 100644 --- a/src/process/capture/ProcessCaptureError.ts +++ b/src/process/capture/ProcessCaptureError.ts @@ -12,7 +12,6 @@ interface ProcessCaptureErrorOptions extends AnalysisErrorOptions { readonly userMessage?: string; readonly userCategory?: "cancelled"; readonly reason?: "capture_failed" | "cleanup_incomplete" | "cancelled"; - readonly cleanupResources?: readonly string[]; readonly executionFailure?: string; readonly partialObservation?: PartialProcessCaptureObservation; readonly cleanupReport?: ProcessCaptureCleanupReport; @@ -25,8 +24,6 @@ export class ProcessCaptureError extends AnalysisError { override readonly userMessage: string | undefined; override readonly userCategory: "cancelled" | undefined; readonly reason: NonNullable; - override readonly cleanupIncomplete: boolean; - override readonly cleanupResources: readonly string[]; override readonly executionFailure: string | undefined; override readonly partialObservation: | PartialProcessCaptureObservation @@ -40,12 +37,58 @@ export class ProcessCaptureError extends AnalysisError { this.reason = options?.reason ?? (options?.userCategory === "cancelled" ? "cancelled" : "capture_failed"); - this.cleanupIncomplete = this.reason === "cleanup_incomplete"; - this.cleanupResources = options?.cleanupResources ?? []; + this.cleanupIncomplete ||= this.reason === "cleanup_incomplete"; this.executionFailure = options?.executionFailure ?? message; this.partialObservation = options?.partialObservation; this.cleanupReport = options?.cleanupReport; } + + /** Add final capture observations while preserving primary facts and cleanup ownership. */ + withFinalization( + options: Pick< + ProcessCaptureErrorOptions, + "executionFailure" | "partialObservation" | "cleanupReport" + > & { readonly cleanupFailure?: AnalysisError }, + ): ProcessCaptureError { + const secondary = options.cleanupFailure; + const resources = [ + ...new Set([ + ...this.cleanupResources, + ...(secondary?.cleanupResources ?? []), + ]), + ]; + const executionFailure = this.executionFailure ?? options.executionFailure; + const partialObservation = + this.partialObservation ?? options.partialObservation; + const cleanupReport = this.cleanupReport ?? options.cleanupReport; + return new ProcessCaptureError(this.message, { + ...(Object.hasOwn(this, "cause") ? { cause: this.cause } : {}), + ...(this.userMessage === undefined + ? {} + : { userMessage: this.userMessage }), + ...(this.userCategory === undefined + ? {} + : { userCategory: this.userCategory }), + reason: this.reason, + ...(this.diagnostics === undefined + ? {} + : { diagnostics: this.diagnostics }), + ...(this.capturedOutput === undefined + ? {} + : { capturedOutput: this.capturedOutput }), + ...(secondary === undefined + ? this.cleanup === undefined + ? {} + : { cleanup: this.cleanup } + : { cleanup: { reason: secondary.message, resources } }), + ...(executionFailure === undefined ? {} : { executionFailure }), + ...(partialObservation === undefined ? {} : { partialObservation }), + ...(cleanupReport === undefined ? {} : { cleanupReport }), + }).bindCleanupDependencies([ + this, + ...(secondary === undefined ? [] : [secondary]), + ]); + } } /** Return one non-recursive, domain-neutral summary of an execution failure. */ @@ -73,26 +116,26 @@ export const normalizeCaptureFailure = ( ): unknown => { if (cause instanceof ProcessCaptureError) return cause; if (cause instanceof DarwinProcessOwnershipInspectionError) { - const compiler = cause.compilerOutcome; - return new ProcessCaptureError(cause.message, { + const normalized = new ProcessCaptureError(cause.message, { cause, - ...(compiler === undefined + ...(cause.cancellation === undefined + ? {} + : { userCategory: "cancelled" as const, reason: "cancelled" as const }), + ...(cause.cleanupFailure === undefined ? {} : { - capturedOutput: { - stdout: "", - stderr: compiler.stderr, - truncated: compiler.stderrTruncated || compiler.outputTruncated, - stderr_bytes: compiler.stderrBytes, - exit_code: - compiler.outcome === "exited" && - typeof compiler.code === "number" - ? compiler.code - : null, - signal: compiler.signal, + cleanup: { + reason: cause.cleanupFailure.message, + resources: cause.cleanupFailure.cleanupResources, }, }), + ...(cause.capturedOutput === undefined + ? {} + : { capturedOutput: cause.capturedOutput }), }); + return cause.cleanupFailure === undefined + ? normalized + : normalized.bindCleanupDependencies([cause.cleanupFailure]); } if ( signal?.aborted === true && diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index d7ab6afbf7..b92a156dd7 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -1435,39 +1435,34 @@ export const resolveProcessResult = ( outcome.state === "failed" || outcome.state === "unverified", ) .map(([resource]) => resource); - throw new ProcessCaptureError(cleanupFailure, { + const cleanupError = new ProcessCaptureError(cleanupFailure, { cause: executionFailure, reason: "cleanup_incomplete", - cleanupResources, + cleanup: { reason: cleanupFailure, resources: cleanupResources }, ...(executionFailureReason === undefined ? {} : { executionFailure: executionFailureReason }), ...(partialObservation === undefined ? {} : { partialObservation }), cleanupReport: cleanup, }); + if (executionFailure instanceof ProcessCaptureError) + throw executionFailure.withFinalization({ + ...(executionFailureReason === undefined + ? {} + : { executionFailure: executionFailureReason }), + ...(partialObservation === undefined ? {} : { partialObservation }), + cleanupReport: cleanup, + cleanupFailure: cleanupError, + }); + throw cleanupError; } if (executionFailure instanceof ProcessCaptureError) { - const retainedExecutionFailure = - executionFailure.executionFailure ?? executionFailureReason; - const retainedPartialObservation = - executionFailure.partialObservation ?? partialObservation; - throw new ProcessCaptureError(executionFailure.message, { - cause: executionFailure, - ...(executionFailure.userMessage === undefined + throw executionFailure.withFinalization({ + ...(executionFailureReason === undefined ? {} - : { userMessage: executionFailure.userMessage }), - ...(executionFailure.userCategory === undefined - ? {} - : { userCategory: executionFailure.userCategory }), - reason: executionFailure.reason, - cleanupResources: executionFailure.cleanupResources, - ...(retainedExecutionFailure === undefined - ? {} - : { executionFailure: retainedExecutionFailure }), - ...(retainedPartialObservation === undefined - ? {} - : { partialObservation: retainedPartialObservation }), - cleanupReport: executionFailure.cleanupReport ?? cleanup, + : { executionFailure: executionFailureReason }), + ...(partialObservation === undefined ? {} : { partialObservation }), + cleanupReport: cleanup, }); } if (executionFailure !== undefined) { diff --git a/src/process/capture/ProcessHarness.ts b/src/process/capture/ProcessHarness.ts index 017050803a..5a1697d387 100644 --- a/src/process/capture/ProcessHarness.ts +++ b/src/process/capture/ProcessHarness.ts @@ -910,7 +910,10 @@ export const captureProcessScenario = async ( : new ProcessCaptureError("process capture lifecycle failed", { cause, reason: "cleanup_incomplete", - cleanupResources: ["process_capture_lifecycle"], + cleanup: { + reason: "process capture lifecycle failed", + resources: ["process_capture_lifecycle"], + }, ...(executionFailure === undefined ? {} : { executionFailure }), }), ); diff --git a/src/server/registerSessionTools.ts b/src/server/registerSessionTools.ts index 8053abf660..7c992112d3 100644 --- a/src/server/registerSessionTools.ts +++ b/src/server/registerSessionTools.ts @@ -314,7 +314,10 @@ export const registerSessionTools = ( { cause, reason: "cleanup_incomplete", - cleanupResources: ["process_capture_resources"], + cleanup: { + reason: "process capture cleanup remains incomplete", + resources: ["process_capture_resources"], + }, executionFailure: cause instanceof Error ? cause.message || cause.name diff --git a/tests/boundary/process/darwinProcessRunTokenReader.test.ts b/tests/boundary/process/darwinProcessRunTokenReader.test.ts index 9f377440cf..cd8df24369 100644 --- a/tests/boundary/process/darwinProcessRunTokenReader.test.ts +++ b/tests/boundary/process/darwinProcessRunTokenReader.test.ts @@ -1351,3 +1351,55 @@ const stopNodeChild = async (child: ChildProcess) => { child.kill("SIGKILL"); await exited; }; + +it.skipIf(process.platform === "win32").each([ + { + name: "invalid UTF-8", + stdout: "owned compiler stdout", + stderrHex: "ff", + stderrBytes: 1, + truncated: false, + }, + { + name: "bounded stdout", + stdout: "x".repeat(12000), + stderrHex: "61", + stderrBytes: 1, + truncated: true, + }, +])( + "retains original output facts for $name compiler failures", + async ({ stdout, stderrHex, stderrBytes, truncated }) => { + const directory = await createTestTempDirectory( + "rea-compiler-output-facts-", + ); + const executable = join(directory, "compiler"); + await writeFile( + executable, + `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(stdout)}); process.stderr.write(Buffer.from(${JSON.stringify(stderrHex)}, "hex")); process.exitCode=7;\n`, + { mode: 0o755 }, + ); + const reader = createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable }, + }); + try { + const failure = await preparationFailure(reader); + expect(failure.compilerOutcome?.stderrBytes).toBe(stderrBytes); + expect(failure.capturedOutput).toEqual({ + stdout: stdout.slice(0, 8192), + stderr: Buffer.from(stderrHex, "hex").toString("utf8"), + stdout_bytes: Buffer.byteLength(stdout), + stderr_bytes: stderrBytes, + truncated, + exit_code: 7, + signal: null, + }); + const normalized = normalizeCaptureFailure(failure, undefined); + if (!(normalized instanceof ProcessCaptureError)) + throw new Error("Expected capture failure"); + expect(normalized.capturedOutput).toEqual(failure.capturedOutput); + } finally { + await reader.close(); + } + }, +); diff --git a/tests/boundary/process/processCli.test.ts b/tests/boundary/process/processCli.test.ts index 3da9205f69..4d4987436c 100644 --- a/tests/boundary/process/processCli.test.ts +++ b/tests/boundary/process/processCli.test.ts @@ -158,7 +158,7 @@ describe("process CLI evidence validation", () => { const projected = projectProcessCliError( new ProcessCaptureError("cleanup incomplete", { reason: "cleanup_incomplete", - cleanupResources: ["owned_process_group"], + cleanup: { reason, resources: ["owned_process_group"] }, cleanupReport: { owned_process_group: { state: "unverified", reason }, terminal_renderer: { state: "cleaned", reason: null }, diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index 61967a9400..af14a7db86 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -11,13 +11,17 @@ import pino from "pino"; import type { Logger } from "pino"; import type { ProgressUpdate } from "../../../../src/application/ProgressReporter.js"; -import { ok } from "../../../../src/domain/result.js"; +import { err, ok } from "../../../../src/domain/result.js"; +import * as GhidraTransport from "../../../../src/ghidra/GhidraTransport.js"; import { GhidraClient } from "../../../../src/ghidra/GhidraClient.js"; import { ProviderRunLineage } from "../../../../src/process/ProviderRunLineage.js"; import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js"; import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js"; import { GHIDRA_PROCESS_DIAGNOSTIC_BYTES } from "../../../../src/ghidra/GhidraDiagnostics.js"; -import { GhidraHeadlessLauncher } from "../../../../src/ghidra/GhidraLauncher.js"; +import { + GhidraHeadlessLauncher, + GhidraLaunchError, +} from "../../../../src/ghidra/GhidraLauncher.js"; import type { GhidraDiagnostic } from "../../../../src/ghidra/GhidraClientTypes.js"; import type { GhidraLaunchSession, @@ -1258,7 +1262,7 @@ describe("GhidraClient process output drain before startup failure", () => { }); }); - it("keeps caller cancellation primary when cancellation wins during output drain", async () => { + it("preserves an observed process exit when cancellation interrupts diagnostic drain", async () => { const { launcher, finishOutput } = preExitedProcessLauncher({ output: oomeOutput, closeOutput: true, @@ -1279,7 +1283,11 @@ describe("GhidraClient process output drain before startup failure", () => { expect(result.ok).toBe(false); if (result.ok) return; - expect(result.error.kind).toBe("cancelled"); + expect(result.error.kind).toBe("process"); + expect(result.error.diagnostics).toMatchObject({ + exit_code: 73, + output_incomplete: true, + }); }); }); @@ -1598,3 +1606,163 @@ it("bounds retries when loopback preauthentication keeps failing", async () => { true, ); }); + +it.each([null, "caller stopped", new Error("caller stopped")])( + "preserves exact active and queued request cancellation reasons %j", + async (reason) => { + const launcher = new FixtureLauncher("hang_tools"); + const client = clientFor(launcher); + await expect(client.start()).resolves.toMatchObject({ ok: true }); + const activeController = new AbortController(); + const active = client.callTool( + "procedure_pseudo_code", + { document: null, procedure: "fixture_main" }, + { signal: activeController.signal }, + ); + await vi.waitFor(() => + expect(launcher.requests).toContain("procedure_pseudo_code"), + ); + const queuedController = new AbortController(); + const queued = client.callTool( + "procedure_info", + {}, + { signal: queuedController.signal }, + ); + queuedController.abort(reason); + const queuedResult = await queued; + activeController.abort(reason); + const activeResult = await active; + for (const result of [queuedResult, activeResult]) { + if (result.ok) throw new Error("Expected request cancellation"); + expect(result.error.kind).toBe("cancelled"); + expect(result.error.cancellation?.reason).toBe(reason); + } + expect(await waitForExit(launcher.processes[0])).toBe(true); + await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +it("preserves the observed protocol failure when cancellation interrupts request diagnostic drain", async () => { + const controller = new AbortController(); + let abortScheduled = false; + const logger = pino( + { + level: "warn", + hooks: { + logMethod(args, method) { + const fields = args[0]; + if ( + typeof fields === "object" && + fields !== null && + "method" in fields && + fields.method === "list_procedures" && + !abortScheduled + ) { + abortScheduled = true; + setImmediate(() => controller.abort(null)); + } + method.apply(this, args); + }, + }, + }, + { write: () => {} }, + ); + const launcher = new FixtureLauncher("old_oom_then_malformed"); + const client = clientFor(launcher, { logger }); + await expect(client.start()).resolves.toMatchObject({ ok: true }); + await expect(client.callTool("list_documents", {})).resolves.toMatchObject({ + ok: true, + }); + const result = await client.callTool( + "list_procedures", + {}, + { signal: controller.signal }, + ); + expect(abortScheduled).toBe(true); + if (result.ok) throw new Error("Expected protocol failure"); + expect(result.error.kind).toBe("protocol"); + expect(result.error.diagnostics).toMatchObject({ + failure_output: { complete: false }, + }); + expect(await waitForExit(launcher.processes[0])).toBe(true); + await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); +}); + +it.each([null, "caller stopped", new Error("caller stopped")])( + "retains the exact reason during startup endpoint polling %j", + async (reason) => { + const observed = vi.spyOn(GhidraTransport, "observeGhidraEndpoint"); + onTestFinished(() => observed.mockRestore()); + const launcher = new FixtureLauncher("silent"); + const client = clientFor(launcher, { startupTimeoutMs: 10000 }); + const controller = new AbortController(); + const pending = client.start(controller.signal); + await vi.waitFor(() => expect(observed).toHaveBeenCalled()); + controller.abort(reason); + const result = await pending; + if (result.ok) throw new Error("Expected startup cancellation"); + expect(result.error.kind).toBe("cancelled"); + expect(result.error.cancellation?.reason).toBe(reason); + expect(await waitForExit(launcher.processes[0])).toBe(true); + await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +it("preserves a handshake protocol error observed before the final startup caller cancels", async () => { + const controller = new AbortController(); + let cancelled = false; + const logger = pino( + { + level: "warn", + hooks: { + logMethod(args, method) { + const fields = args[0]; + if ( + typeof fields === "object" && + fields !== null && + "method" in fields && + fields.method === "ping" && + !cancelled + ) { + cancelled = true; + controller.abort(null); + } + method.apply(this, args); + }, + }, + }, + { write: () => {} }, + ); + const launcher = new FixtureLauncher("malformed"); + const client = clientFor(launcher, { logger }); + const result = await client.start(controller.signal); + expect(cancelled).toBe(true); + if (result.ok) throw new Error("Expected handshake protocol failure"); + expect(result.error.kind).toBe("protocol"); + expect(await waitForExit(launcher.processes[0])).toBe(true); + await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); +}); + +it("redacts the transport credential when refreshing startup diagnostics", async () => { + let token = ""; + const client = clientFor({ + launch: async (session) => { + token = session.token; + return err(new GhidraLaunchError(`Launcher refused ${token}`)); + }, + }); + const result = await client.start(); + if (result.ok) throw new Error("Expected launcher refusal"); + expect(token.length).toBeGreaterThan(0); + expect(result.error.message).toContain("[REDACTED]"); + expect(result.error.message).not.toContain(token); + expect(JSON.stringify(result.error.diagnostics)).not.toContain(token); +}); diff --git a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts index 3a5a7c7192..1c74da203f 100644 --- a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts +++ b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts @@ -505,3 +505,50 @@ describe.skipIf(process.platform === "win32")( ); }, ); + +it.each([null, "caller stopped", new Error("caller stopped")] as const)( + "retains the helper producer's exact %s cancellation reason", + async (reason) => { + const { ghidraJavaLaunch, GhidraLaunchSupportError } = + await import("../../../../src/ghidra/GhidraJavaLaunch.js"); + const controller = new AbortController(); + controller.abort(reason); + const failure: unknown = await ghidraJavaLaunch({ + platform: process.platform, + analyzeHeadlessPath: "/unused-ghidra/support/analyzeHeadless", + homeRoot: "/unused-runtime/home", + tempRoot: "/unused-runtime/tmp", + arguments: [], + environment: {}, + signal: controller.signal, + onStderr: () => { + throw new Error("An aborted probe must not produce output"); + }, + }).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(GhidraLaunchSupportError); + if (!(failure instanceof GhidraLaunchSupportError)) + throw new Error("Expected helper failure"); + expect(failure.cancellation?.reason).toBe(reason); + expect(failure.outcome).toBeUndefined(); + }, +); + +it("does not infer helper cancellation from an arbitrary failure's display code", async () => { + const { GhidraLaunchSupportError } = + await import("../../../../src/ghidra/GhidraJavaLaunch.js"); + const cause = Object.assign(new Error("ordinary failure"), { + code: "ABORT_ERR", + stdout: "out", + stderr: "err", + killed: true, + outputTruncated: true, + }); + const failure = new GhidraLaunchSupportError("-envvars", cause); + expect(failure.cancellation).toBeUndefined(); + expect(failure.outcome).toMatchObject({ + killed: true, + outputTruncated: true, + stdout: "out", + stderr: "err", + }); +}); diff --git a/tests/process-global/darwinCompilerCleanup.test.ts b/tests/process-global/darwinCompilerCleanup.test.ts new file mode 100644 index 0000000000..667d1c71c7 --- /dev/null +++ b/tests/process-global/darwinCompilerCleanup.test.ts @@ -0,0 +1,288 @@ +import { createHash } from "node:crypto"; +import { access, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const fault = vi.hoisted(() => ({ + roots: new Set(), + blocked: true, + failure: new Error("controlled helper-root removal failure"), + abort: undefined as AbortController | undefined, + attempts: 0, + gate: undefined as (() => Promise) | undefined, +})); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mkdtemp: async (...args: Parameters) => { + const root = await actual.mkdtemp(...args); + if ( + typeof root === "string" && + String(args[0]).includes("rea-process-token-") + ) + fault.roots.add(root); + return root; + }, + rm: async (...args: Parameters) => { + if (typeof args[0] === "string" && fault.roots.has(args[0])) { + fault.attempts += 1; + await fault.gate?.(); + if (!fault.blocked) return actual.rm(...args); + fault.abort?.abort(null); + throw fault.failure; + } + return actual.rm(...args); + }, + }; +}); + +import { + createDarwinProcessRunTokenReader, + DarwinProcessOwnershipInspectionError, +} from "../../src/process/DarwinProcessRunTokenReader.js"; +import { GhidraClient } from "../../src/ghidra/GhidraClient.js"; +import { GhidraHeadlessLauncher } from "../../src/ghidra/GhidraLauncher.js"; +import { captureProcessScenario } from "../../src/process/capture/ProcessHarness.js"; +import { processScenarioSchema } from "../../src/domain/process/processScenario.js"; +import { systemProcessOwnershipHost } from "../../src/process/ProcessOwnershipObservation.js"; +import { ghidraFixturePath } from "../boundary/providers/ghidra/ghidraClient.fixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +let releaseCleanup: (() => void) | undefined; +const clients: GhidraClient[] = []; +const readers: ReturnType[] = []; +beforeEach(() => { + fault.blocked = true; + fault.abort = undefined; + fault.roots.clear(); + fault.attempts = 0; + fault.gate = undefined; +}); +afterEach(async () => { + releaseCleanup?.(); + releaseCleanup = undefined; + fault.blocked = false; + fault.abort = undefined; + fault.gate = undefined; + vi.restoreAllMocks(); + for (const client of clients.splice(0)) await client.close(); + for (const reader of readers.splice(0)) await reader.close(); +}); + +const fixture = async (mode: "fail" | "active" = "fail") => { + const directory = await createTestTempDirectory( + "rea-compiler-cleanup-fixture-", + ); + const compiler = join(directory, "compiler"); + await writeFile( + compiler, + mode === "fail" + ? "#!/usr/bin/env node\nprocess.stdout.write('owned compiler stdout'); process.stderr.write('controlled compiler failure\\n'); process.exitCode=7;\n" + : `#!/usr/bin/env node +const fs = require('node:fs'); +process.on('SIGINT', () => process.exit(0)); +fs.writeFileSync(${JSON.stringify(join(directory, "ready"))}, 'ready'); +setInterval(() => {}, 1000); +`, + { mode: 0o755 }, + ); + const reader = createDarwinProcessRunTokenReader({ + compiler: { kind: "xcrun", executable: compiler }, + }); + readers.push(reader); + return { reader, readyPath: join(directory, "ready") }; +}; + +it + .skipIf(process.platform === "win32") + .each(["ordinary", "cancel during cleanup"] as const)( + "preserves the compiler failure through %s and resolves retained cleanup on retry", + async (mode) => { + const { reader } = await fixture(); + const controller = new AbortController(); + if (mode === "cancel during cleanup") fault.abort = controller; + const failure: unknown = await reader + .prepare(controller.signal) + .catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(DarwinProcessOwnershipInspectionError); + expect(failure).toMatchObject({ + compilerOutcome: { + outcome: "exited", + code: 7, + stderr: "controlled compiler failure\n", + }, + cleanupFailure: { cleanupIncomplete: true, cause: fault.failure }, + }); + if (!(failure instanceof DarwinProcessOwnershipInspectionError)) + throw new Error("Expected primary compiler failure"); + const root = [...fault.roots][0]; + if (root === undefined) throw new Error("Expected owned compiler root"); + await expect(access(root)).resolves.toBeUndefined(); + fault.blocked = false; + await reader.close(); + await expect(access(root)).rejects.toMatchObject({ code: "ENOENT" }); + expect(failure).toMatchObject({ + cleanupFailure: { cleanupIncomplete: false }, + compilerOutcome: { code: 7 }, + }); + }, +); + +it + .skipIf(process.platform === "win32") + .each(["two retries", "retry and close"] as const)( + "shares retained root cleanup during %s", + async (mode) => { + const { reader } = await fixture(); + const original: unknown = await reader + .prepare() + .catch((cause: unknown) => cause); + expect(original).toMatchObject({ + cleanupFailure: { cleanupIncomplete: true }, + }); + let release: (() => void) | undefined; + const gate = new Promise((resolve) => { + release = resolve; + releaseCleanup = resolve; + }); + fault.gate = () => gate; + fault.blocked = false; + const before = fault.attempts; + const first = reader.prepare().catch((cause: unknown) => cause); + const second = + mode === "two retries" + ? reader.prepare().catch((cause: unknown) => cause) + : reader.close(); + await vi.waitFor(() => expect(fault.attempts).toBe(before + 1)); + await new Promise((resolve) => setImmediate(resolve)); + expect(fault.attempts).toBe(before + 1); + release?.(); + await Promise.all([first, second]); + expect(original).toMatchObject({ + cleanupFailure: { cleanupIncomplete: false }, + }); + }, +); + +it + .skipIf(process.platform === "win32") + .each([null, "stop preparation", new Error("stop preparation")] as const)( + "joins final cleanup after the last waiter cancels with exact %s reason", + async (reason) => { + const { reader, readyPath } = await fixture("active"); + const controller = new AbortController(); + const preparation = reader + .prepare(controller.signal) + .catch((cause: unknown) => cause); + await vi.waitFor(async () => + expect(await readFile(readyPath, "utf8")).toBe("ready"), + ); + const root = [...fault.roots][0]; + if (root === undefined) throw new Error("Expected helper root"); + controller.abort(reason); + const failure: unknown = await preparation; + expect(failure).toBeInstanceOf(DarwinProcessOwnershipInspectionError); + if (!(failure instanceof DarwinProcessOwnershipInspectionError)) + throw new Error("Expected retained cleanup failure"); + expect(failure.cause).toBe(reason); + expect(failure.cancellation?.reason).toBe(reason); + expect(failure.cleanupFailure?.cleanupIncomplete).toBe(true); + fault.blocked = false; + await reader.close(); + expect(failure.cleanupFailure?.cleanupIncomplete).toBe(false); + await expect(access(root)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.skipIf(process.platform === "win32")( + "Ghidra startup retains the compiler root until that owner confirms cleanup", + async () => { + const { reader, readyPath } = await fixture(); + const fixtureRoot = dirname(readyPath); + const launcher = new GhidraHeadlessLauncher({ + configurationDirectoryName: "ghidra_12.1.4_PUBLIC", + environment: {}, + analyzeHeadlessPath: join(fixtureRoot, "support", "analyzeHeadless"), + javaHome: join(fixtureRoot, "jdk"), + bridgeScriptPath: join(fixtureRoot, "ReaGhidraBridge.java"), + javaLaunch: async () => ({ + command: process.execPath, + arguments: [], + environment: {}, + }), + spawnProcess: async () => { + await reader.prepare(); + throw new Error("unexpected compilation success"); + }, + }); + const client = new GhidraClient({ + launcher, + targetPath: ghidraFixturePath, + targetSha256: createHash("sha256") + .update(await readFile(ghidraFixturePath)) + .digest("hex"), + providerVersion: "test", + profileDigest: "b".repeat(64), + startupTimeoutMs: 10000, + }); + clients.push(client); + const result = await client.start(); + expect(result).toMatchObject({ + ok: false, + error: { kind: "start", cleanupFailure: { cleanupIncomplete: true } }, + }); + if (result.ok) throw new Error("Expected startup compiler failure"); + expect(result.error.diagnostics).toMatchObject({ + failure_cause: { + launch_outcome: { + stdout: "owned compiler stdout", + stdout_bytes: 21, + stderr_bytes: 28, + exit_code: 7, + }, + cause: { compiler_outcome: { code: 7 } }, + }, + }); + await client.close(); + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true); + fault.blocked = false; + await reader.close(); + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "capture boundary retains compiler output and follows the actual helper cleanup retry", + async () => { + const { reader } = await fixture(); + vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation( + async (signal) => { + await reader.prepare(signal); + }, + ); + const result = await captureProcessScenario( + processScenarioSchema.parse({ executable: process.execPath }), + undefined, + "linux", + {}, + ); + expect(result).toMatchObject({ + ok: false, + error: { + cleanupIncomplete: true, + capturedOutput: { + exit_code: 7, + stdout: "owned compiler stdout", + stdout_bytes: 21, + stderr_bytes: 28, + stderr: "controlled compiler failure\n", + }, + }, + }); + if (result.ok) throw new Error("Expected capture preparation failure"); + fault.blocked = false; + await reader.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.capturedOutput?.exit_code).toBe(7); + }, +); diff --git a/tests/process-global/ghidraEndpointLifetime.test.ts b/tests/process-global/ghidraEndpointLifetime.test.ts new file mode 100644 index 0000000000..c7807b9fc4 --- /dev/null +++ b/tests/process-global/ghidraEndpointLifetime.test.ts @@ -0,0 +1,359 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { promisify } from "node:util"; +import { pathToFileURL } from "node:url"; +import { + access, + readFile, + rename, + writeFile, + type FileHandle, +} from "node:fs/promises"; +import { basename, join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const fault = vi.hoisted(() => ({ + path: "", + matchEndpoints: false, + reads: 0, + handles: [] as FileHandle[], + blocked: false, + abortClose: undefined as AbortController | undefined, + readFailure: undefined as Error | undefined, + abortRead: undefined as AbortController | undefined, + readGate: undefined as (() => Promise) | undefined, +})); +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] !== fault.path && + !( + fault.matchEndpoints && + typeof args[0] === "string" && + basename(args[0]) === "bridge-endpoint.json" + ) + ) + return handle; + fault.handles.push(handle); + const close = handle.close.bind(handle); + handle.close = async () => { + fault.abortClose?.abort(null); + if (fault.blocked) throw new Error("controlled endpoint close failure"); + await close(); + }; + const read = handle.read.bind(handle); + handle.read = (async (...values: unknown[]) => { + fault.reads += 1; + await fault.readGate?.(); + if (fault.readFailure !== undefined) { + fault.abortRead?.abort(null); + throw fault.readFailure; + } + return await Reflect.apply(read, handle, values); + }) as FileHandle["read"]; + const readFile = handle.readFile.bind(handle); + handle.readFile = (async (...values: unknown[]) => { + fault.reads += 1; + await fault.readGate?.(); + if (fault.readFailure !== undefined) { + fault.abortRead?.abort(null); + throw fault.readFailure; + } + return await Reflect.apply(readFile, handle, values); + }) as FileHandle["readFile"]; + return handle; + }, + }; +}); + +import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; +import { observeGhidraEndpoint } from "../../src/ghidra/GhidraTransport.js"; +import { GhidraClient } from "../../src/ghidra/GhidraClient.js"; +import { + GhidraFixtureLauncher, + ghidraFixturePath, +} from "../boundary/providers/ghidra/ghidraClient.fixture.js"; +import { waitForExit } from "../support/process/processFixture.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +const clients: GhidraClient[] = []; +const operations: Promise[] = []; +let releaseRead: (() => void) | undefined; +beforeEach(() => { + fault.path = ""; + fault.matchEndpoints = false; + fault.reads = 0; + fault.handles = []; + fault.blocked = false; + fault.abortClose = undefined; + fault.readFailure = undefined; + fault.abortRead = undefined; + fault.readGate = undefined; +}); +afterEach(async () => { + fault.blocked = false; + fault.abortClose = undefined; + fault.readFailure = undefined; + fault.abortRead = undefined; + releaseRead?.(); + releaseRead = undefined; + await Promise.allSettled(operations.splice(0)); + for (const client of clients.splice(0)) await client.close(); + for (const handle of fault.handles) + if (handle.fd !== -1) await handle.close(); +}); + +const fixture = async (text = '{"host":"127.0.0.1","port":49152}\n') => { + const root = await createTestTempDirectory("rea-endpoint-lifetime-"); + fault.path = join(root, "bridge-endpoint.json"); + await writeFile(fault.path, text); + return { + endpoint: { + transport: "authenticated-loopback-tcp" as const, + path: fault.path, + }, + resources: new ArtifactResourceScope(), + }; +}; + +it.skipIf(process.platform === "win32").each(["valid", "invalid"] as const)( + "retains the exact descriptor and %s endpoint outcome after close failure", + async (kind) => { + const { endpoint, resources } = await fixture( + kind === "valid" ? undefined : "{", + ); + fault.blocked = true; + const result = await observeGhidraEndpoint(endpoint, { + resourceScope: resources, + }); + expect(result).toMatchObject({ + ok: false, + error: { kind: "start", cleanupFailure: { cleanupIncomplete: true } }, + }); + if (result.ok) throw new Error("Expected typed endpoint failure"); + const failure = result.error; + if (kind === "invalid") + expect(failure.message).toBe("Ghidra TCP endpoint is invalid"); + expect(fault.handles[0]?.fd).not.toBe(-1); + expect(resources.hasPending).toBe(true); + fault.blocked = false; + await resources.retryCleanup(); + expect(fault.handles[0]?.fd).toBe(-1); + expect(failure.cleanupFailure?.cleanupIncomplete).toBe(false); + }, +); + +it.skipIf(process.platform === "win32")( + "observes exact null cancellation during the endpoint's final close", + async () => { + const { endpoint, resources } = await fixture(); + const controller = new AbortController(); + fault.abortClose = controller; + const result = await observeGhidraEndpoint(endpoint, { + resourceScope: resources, + signal: controller.signal, + }); + expect(result).toMatchObject({ + ok: false, + error: { kind: "cancelled", cancellation: { reason: null } }, + }); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + }, +); + +it.skipIf(process.platform === "win32").each(["released", "failed"] as const)( + "preserves endpoint IO failure during cancellation and %s close", + async (closing) => { + const { endpoint, resources } = await fixture(); + const controller = new AbortController(); + const cause = Object.assign(new Error("controlled endpoint EIO"), { + code: "EIO", + }); + fault.readFailure = cause; + fault.abortRead = controller; + fault.blocked = closing === "failed"; + const result = await observeGhidraEndpoint(endpoint, { + resourceScope: resources, + signal: controller.signal, + }); + if (result.ok) throw new Error("Expected IO failure"); + expect(result.error.kind).toBe("start"); + expect(result.error.cause).toBe(cause); + if (closing === "failed") + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true); + fault.blocked = false; + await resources.retryCleanup(); + expect(result.error.cleanupFailure?.cleanupIncomplete ?? false).toBe(false); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "keeps endpoint observation admitted until its read and close finish", + async () => { + const { endpoint, resources } = await fixture(); + let entered: (() => void) | undefined; + const observed = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + releaseRead = resolve; + }); + fault.readGate = async () => { + entered?.(); + await gate; + }; + const pending = observeGhidraEndpoint(endpoint, { + resourceScope: resources, + }); + operations.push(pending); + await observed; + let closed = false; + const closing = resources.close().then(() => { + closed = true; + }); + operations.push(closing); + await new Promise((resolve) => setImmediate(resolve)); + expect(closed).toBe(false); + releaseRead?.(); + await expect(pending).resolves.toMatchObject({ ok: true }); + await closing; + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + }, +); + +it + .skipIf(process.platform === "win32") + .each(["completed", "cancelled"] as const)( + "preserves endpoint and provider cleanup owners through %s startup and retry", + async (kind) => { + const launcher = new GhidraFixtureLauncher(); + const controller = new AbortController(); + fault.matchEndpoints = true; + fault.blocked = true; + if (kind === "cancelled") fault.abortClose = controller; + const client = new GhidraClient({ + launcher, + targetPath: ghidraFixturePath, + targetSha256: createHash("sha256") + .update(await readFile(ghidraFixturePath)) + .digest("hex"), + transport: "authenticated-loopback-tcp", + providerVersion: "12.1.4", + profileDigest: "a".repeat(64), + startupTimeoutMs: 5000, + }); + clients.push(client); + const result = await client.start(controller.signal); + if (result.ok) throw new Error("Expected endpoint finalization failure"); + expect(result.error.kind).toBe( + kind === "cancelled" ? "cancelled" : "start", + ); + if (kind === "cancelled") + expect(result.error.cancellation?.reason).toBe(null); + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true); + expect(result.error.cleanupFailure?.cleanupResources).toContain( + launcher.endpointPaths[0], + ); + expect(fault.handles.some((handle) => handle.fd !== -1)).toBe(true); + fault.blocked = false; + fault.abortClose = undefined; + await expect(client.close()).resolves.toEqual({ ok: true, value: null }); + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + const child = launcher.processes[0]; + if (child === undefined) + throw new Error("Expected admitted fixture process"); + expect(await waitForExit(child, 5000)).toBe(true); + await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({ + code: "ENOENT", + }); + }, +); + +it.skipIf(process.platform === "win32")( + "rejects endpoint pathname replacement instead of reporting readiness", + async () => { + const { endpoint, resources } = await fixture(); + fault.readGate = async () => { + fault.readGate = undefined; + await rename(endpoint.path, endpoint.path + ".old"); + await writeFile(endpoint.path, '{"host":"127.0.0.1","port":49153}\n'); + }; + await expect( + observeGhidraEndpoint(endpoint, { resourceScope: resources }), + ).resolves.toMatchObject({ + ok: false, + error: { + kind: "start", + message: "Ghidra TCP endpoint changed during observation", + }, + }); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "rejects an oversized endpoint before reading its contents", + async () => { + const { endpoint, resources } = await fixture(" ".repeat(65537)); + await expect( + observeGhidraEndpoint(endpoint, { resourceScope: resources }), + ).resolves.toMatchObject({ + ok: false, + error: { kind: "start", cause: { reason: "limit" } }, + }); + expect(fault.reads).toBe(0); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + }, +); + +it.skipIf(process.platform === "win32")( + "rejects a FIFO endpoint through the compiled observer without a writer", + async () => { + const { endpoint } = await fixture(); + await rename(endpoint.path, endpoint.path + ".old"); + await promisify(execFile)("mkfifo", [endpoint.path]); + const module = pathToFileURL( + join(process.cwd(), "dist/ghidra/GhidraTransport.js"), + ).href; + const script = ` + const { observeGhidraEndpoint } = await import(${JSON.stringify(module)}); + const result = await observeGhidraEndpoint({ transport: "authenticated-loopback-tcp", path: process.argv[1] }); + if (result.ok) throw new Error("Unexpected FIFO readiness"); + process.stdout.write(JSON.stringify({ kind: result.error.kind, code: result.error.cause.code })); + `; + const result = await promisify(execFile)( + process.execPath, + ["--input-type=module", "--eval", script, endpoint.path], + { timeout: 5000 }, + ); + expect(JSON.parse(result.stdout)).toEqual({ + kind: "start", + code: "ENOTFILE", + }); + }, +); + +it + .skipIf(process.platform === "win32") + .each([null, "caller stopped", new Error("caller stopped")])( + "rejects pre-aborted endpoint admission with exact reason %j", + async (reason) => { + const { endpoint, resources } = await fixture(); + const controller = new AbortController(); + controller.abort(reason); + const result = await observeGhidraEndpoint(endpoint, { + resourceScope: resources, + signal: controller.signal, + }); + if (result.ok) throw new Error("Expected pre-admission cancellation"); + expect(result.error.kind).toBe("cancelled"); + expect(result.error.cancellation?.reason).toBe(reason); + expect(fault.handles).toEqual([]); + }, +); diff --git a/tests/process-global/ghidraImportSnapshotLifetime.test.ts b/tests/process-global/ghidraImportSnapshotLifetime.test.ts new file mode 100644 index 0000000000..c4e81fb5b8 --- /dev/null +++ b/tests/process-global/ghidraImportSnapshotLifetime.test.ts @@ -0,0 +1,390 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, writeFile, type FileHandle } from "node:fs/promises"; +import { basename, join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const fault = vi.hoisted(() => ({ + source: "", + handles: [] as FileHandle[], + blocked: new Set(), + blockSource: false, + controller: undefined as AbortController | undefined, + readFailure: undefined as Error | undefined, + writeFailure: undefined as Error | undefined, + blockDestination: false, + abortDestinationClose: false, + destinations: [] as FileHandle[], + onClose: undefined as ((handle: FileHandle) => Promise) | undefined, +})); +vi.mock("node:fs/promises", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + open: async (...args: Parameters) => { + const handle = await original.open(...args); + const source = args[0] === fault.source; + const destination = + fault.source !== "" && + args[1] === "wx" && + typeof args[0] === "string" && + basename(args[0]).startsWith("target-"); + if (!source && !destination) return handle; + if (source) { + fault.handles.push(handle); + if (fault.blockSource) fault.blocked.add(handle); + const read = handle.read.bind(handle); + handle.read = (async (...readArgs: unknown[]) => { + if (fault.readFailure !== undefined) { + fault.controller?.abort(null); + throw fault.readFailure; + } + return await Reflect.apply(read, handle, readArgs); + }) as FileHandle["read"]; + } else { + fault.destinations.push(handle); + if (fault.blockDestination) fault.blocked.add(handle); + const write = handle.writeFile.bind(handle); + handle.writeFile = async (...writeArgs) => { + if (fault.writeFailure !== undefined) { + fault.controller?.abort(null); + throw fault.writeFailure; + } + return await write(...writeArgs); + }; + } + const close = handle.close.bind(handle); + handle.close = async () => { + await fault.onClose?.(handle); + if (source || fault.abortDestinationClose) + fault.controller?.abort(null); + if (fault.blocked.has(handle)) + throw new Error("controlled Ghidra import descriptor close failure"); + await close(); + }; + return handle; + }, + }; +}); + +import { createGhidraTargetSnapshot } from "../../src/ghidra/GhidraTargetSnapshot.js"; +import { + ArtifactFileCleanupFailure, + bindArtifactFileCleanup, +} from "../../src/artifacts/ArtifactFileOperation.js"; +import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; +import { ProviderCleanupError } from "../../src/domain/providerCleanupError.js"; +import { GhidraClient } from "../../src/ghidra/GhidraClient.js"; +import { GhidraProvider } from "../../src/ghidra/GhidraProvider.js"; +import { ghidraProfileInstallationHost } from "../../src/ghidra/GhidraInstallation.fixture.js"; +import { parseConfig } from "../../src/config/parseConfig.js"; +import { parseBinaryTarget } from "../../src/application/BinaryTargetResolver.js"; +import { elf } from "../../src/domain/binaryTarget.fixture.js"; +import { silentLogger } from "../../src/logger.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import type { AnalysisClient } from "../../src/application/AnalysisProvider.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; + +beforeEach(() => { + fault.source = ""; + fault.handles = []; + fault.blocked.clear(); + fault.blockSource = false; + fault.controller = undefined; + fault.readFailure = undefined; + fault.writeFailure = undefined; + fault.blockDestination = false; + fault.abortDestinationClose = false; + fault.destinations = []; + fault.onClose = undefined; +}); + +it.each(["read", "write"] as const)( + "retains an existing %s IO failure when the final startup caller cancels", + async (stage) => { + const { client, launches } = await providerFixture(); + const controller = new AbortController(); + fault.controller = controller; + const cause = Object.assign(new Error(`controlled ${stage} EIO`), { + code: "EIO", + }); + if (stage === "read") fault.readFailure = cause; + else fault.writeFailure = cause; + const result = await client.execute( + "health", + {}, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected existing IO failure"); + expect(result.error).toMatchObject({ + _tag: "ProviderAdapterError", + cleanupIncomplete: false, + diagnostics: { failure_kind: "start" }, + }); + expect(result.error.cause).toMatchObject({ kind: "start", cause }); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + expect(fault.destinations.every((handle) => handle.fd === -1)).toBe(true); + expect(launches()).toBe(0); + }, +); +const clients: AnalysisClient[] = []; +afterEach(async () => { + fault.blocked.clear(); + fault.controller = undefined; + fault.onClose = undefined; + for (const client of clients.splice(0)) await client.close(); + for (const handle of [...fault.handles, ...fault.destinations]) + if (handle.fd !== -1) await handle.close(); +}); + +const fixture = async () => { + const root = await createTestTempDirectory("rea-ghidra-import-lifetime-"); + const path = join(root, "source.bin"); + const runtime = join(root, "runtime"); + await mkdir(runtime); + const bytes = Buffer.from("committed Ghidra import bytes"); + await writeFile(path, bytes); + fault.source = path; + const sha256 = createHash("sha256").update(bytes).digest("hex"); + return { path, runtime, sha256 }; +}; + +it.each(["completed", "mismatch"] as const)( + "retains the exact source descriptor and %s import outcome when close fails", + async (kind) => { + const { path, runtime, sha256 } = await fixture(); + fault.blockSource = true; + const failure = await createGhidraTargetSnapshot( + path, + runtime, + kind === "completed" ? sha256 : "0".repeat(64), + { platform: "linux" }, + ).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(ArtifactFileCleanupFailure); + if (!(failure instanceof ArtifactFileCleanupFailure)) + throw new Error("Expected exact descriptor outcome and cleanup owner"); + expect(failure.fileCleanupFailure.owner.handle).toBe(fault.handles[0]); + expect(failure.fileCleanupFailure.outcome).toMatchObject( + kind === "completed" + ? { kind: "completed", value: { sha256 } } + : { kind: "failed", cause: { _tag: "AnalysisArtifactChangedError" } }, + ); + fault.blocked.clear(); + await failure.cleanupLease.release(); + expect(fault.handles[0]?.fd).toBe(-1); + }, +); + +it("retains the exact caller reason when cancelled during import descriptor close", async () => { + const { path, runtime, sha256 } = await fixture(); + const controller = new AbortController(); + fault.controller = controller; + await expect( + createGhidraTargetSnapshot(path, runtime, sha256, { + platform: "linux", + signal: controller.signal, + }), + ).rejects.toMatchObject({ + reason: "cancelled", + cancellation: { reason: null }, + }); +}); + +it("waits for mixed finalization and resolves cleanup only after both exact descriptors release", async () => { + const { path, runtime, sha256 } = await fixture(); + fault.blockSource = true; + fault.blockDestination = true; + let release: () => void = () => {}; + const gate = new Promise((resolve) => { + release = resolve; + }); + let entered = false; + fault.onClose = async (handle) => { + if (fault.destinations.includes(handle)) { + entered = true; + await gate; + } + }; + const resources = new ArtifactResourceScope(); + let settled = false; + const running = createGhidraTargetSnapshot(path, runtime, sha256, { + platform: "linux", + resourceScope: resources, + }).catch((cause: unknown) => { + settled = true; + return cause; + }); + try { + await expect.poll(() => entered).toBe(true); + expect(settled).toBe(false); + release(); + const failure = await running; + expect(failure).toBeInstanceOf(ArtifactFileCleanupFailure); + if (!(failure instanceof ArtifactFileCleanupFailure)) + throw new Error("Expected outer source owner"); + const physical = failure.fileCleanupFailure.outcome; + if ( + physical.kind !== "failed" || + !(physical.cause instanceof ArtifactFileCleanupFailure) + ) + throw new Error("Expected nested destination owner"); + const destination = physical.cause; + expect(failure.fileCleanupFailure.owner.handle).toBe(fault.handles[0]); + expect(destination.fileCleanupFailure.owner.handle).toBe( + fault.destinations[0], + ); + expect(failure.cleanupLeases).toHaveLength(2); + const projected = new ProviderCleanupError( + "ghidra", + failure.cleanup?.resources ?? [], + { reason: "controlled mixed cleanup" }, + ); + bindArtifactFileCleanup(failure, projected); + fault.blocked.delete(fault.destinations[0]!); + await destination.cleanupLease.release(); + expect(projected.cleanupIncomplete).toBe(true); + fault.blocked.clear(); + await resources.close(); + expect(projected.cleanupIncomplete).toBe(false); + } finally { + release(); + await running; + fault.blocked.clear(); + await resources.close(); + } +}); + +const providerFixture = async () => { + const { path } = await fixture(); + fault.source = ""; + await writeFile(path, elf(2, 1, 62)); + const target = await parseBinaryTarget(path); + if (!target.ok) throw target.error; + const config = parseConfig({ GHIDRA_INSTALL_DIR: "/simulated-ghidra" }); + if (!config.ok) throw config.error; + let launches = 0; + const provider = new GhidraProvider( + config.value, + silentLogger, + {}, + ghidraProfileInstallationHost, + (options) => + new GhidraClient({ + ...options, + launcher: { + launch: async () => { + launches += 1; + throw new Error("Snapshot failure must prevent engine launch"); + }, + }, + }), + ); + const resolved = await provider.resolveAnalysisProfile(target.value); + if (!resolved.ok || resolved.value.profile === null) + throw new Error("Expected simulated profile admission"); + const client = provider.createClient(target.value, resolved.value.profile); + clients.push(client); + fault.source = path; + return { client, path, launches: () => launches }; +}; + +it.each([null, "caller reason", new Error("caller cancellation")])( + "preserves pre-admission startup cancellation reason: %s", + async (reason) => { + const { client, launches } = await providerFixture(); + const controller = new AbortController(); + controller.abort(reason); + const result = await client.execute( + "health", + {}, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected cancelled admission"); + expect(result.error).toMatchObject({ _tag: "AnalysisCancelledError" }); + expect(result.error.cause).toBe(reason); + expect(fault.handles).toHaveLength(0); + expect(launches()).toBe(0); + }, +); + +it.each([ + "mismatch", + "denied", + "cancelled", + "cancelled_after_failed_close", +] as const)( + "preserves public %s classification and live cleanup recovery", + async (kind) => { + const { client, path, launches } = await providerFixture(); + fault.blockSource = true; + fault.blockDestination = true; + const controller = new AbortController(); + if (kind === "mismatch") + await writeFile(path, "changed after target admission"); + if (kind === "denied") + fault.readFailure = Object.assign( + new Error("controlled source permission failure"), + { code: "EACCES", path }, + ); + if (kind.startsWith("cancelled")) fault.controller = controller; + if (kind === "cancelled") fault.abortDestinationClose = true; + const result = await client.execute( + "health", + {}, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected failed snapshot admission"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { + primary_error: { + code: + kind === "mismatch" + ? "artifact_changed" + : kind === "denied" + ? "access_denied" + : kind === "cancelled" + ? "cancelled" + : "execution_failure", + }, + }, + }, + }); + if (kind === "cancelled") { + expect(result.error.cause).toBeInstanceOf(AggregateError); + if (!(result.error.cause instanceof AggregateError)) + throw new Error("Expected composed primary"); + expect(result.error.cause.cause).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: null, + }); + } + const close = await client.close(); + expect(close.ok).toBe(false); + fault.blocked.clear(); + await expect(client.close()).resolves.toMatchObject({ ok: true }); + expect(result.error.cleanupIncomplete).toBe(false); + if (!close.ok) expect(close.error.cleanupIncomplete).toBe(false); + expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true); + expect(launches()).toBe(0); + }, +); + +it("copies every byte across chunk boundaries through the destination owner", async () => { + const { path, runtime } = await fixture(); + const bytes = Buffer.alloc(256 * 1024 + 37); + for (let index = 0; index < bytes.length; index++) + bytes[index] = (index * 37 + Math.floor(index / 65536)) % 251; + await writeFile(path, bytes); + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const snapshot = await createGhidraTargetSnapshot(path, runtime, sha256, { + platform: "linux", + }); + expect(await readFile(snapshot.path)).toEqual(bytes); + expect(snapshot.sha256).toBe(sha256); + expect( + [...fault.handles, ...fault.destinations].every( + (handle) => handle.fd === -1, + ), + ).toBe(true); +}); diff --git a/tests/process-global/ghidraLaunchRollback.test.ts b/tests/process-global/ghidraLaunchRollback.test.ts new file mode 100644 index 0000000000..f894f30ae9 --- /dev/null +++ b/tests/process-global/ghidraLaunchRollback.test.ts @@ -0,0 +1,304 @@ +import { createHash, randomUUID } from "node:crypto"; +import { access, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +const fault = vi.hoisted(() => ({ + failure: new Error("controlled manifest failure"), +})); +vi.mock("write-file-atomic", async (importOriginal) => { + const actual = await importOriginal<{ + default: typeof import("write-file-atomic"); + }>(); + return { + default: async (...args: Parameters) => { + if (typeof args[0] === "string" && args[0].endsWith("/ownership.json")) + throw fault.failure; + return actual.default(...args); + }, + }; +}); + +import { + GhidraHeadlessLauncher, + GhidraLaunchError, + type GhidraLaunchSession, +} from "../../src/ghidra/GhidraLauncher.js"; +import { bindGhidraSessionFailure } from "../../src/ghidra/GhidraSessionError.js"; +import { GhidraClient } from "../../src/ghidra/GhidraClient.js"; +import { + spawnOwnedProviderProcess, + type SpawnedOwnedProviderProcess, +} from "../../src/process/ProviderProcess.js"; +import { + cleanupOwnedProcessGroup, + type ProcessCleanupFailure, +} from "../../src/process/ProcessOwnership.js"; +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +import { ghidraFixturePath } from "../boundary/providers/ghidra/ghidraClient.fixture.js"; + +const owners: SpawnedOwnedProviderProcess[] = []; +const clients: GhidraClient[] = []; +let denyCleanup = true; +beforeEach(() => { + denyCleanup = true; +}); +afterEach(async () => { + denyCleanup = false; + for (const client of clients.splice(0)) await client.close(); + for (const owner of owners.splice(0)) + expect(await cleanupOwnedProcessGroup(owner.ownership)).toMatchObject({ + cleaned: true, + }); +}); + +const incomplete: ProcessCleanupFailure = { + cleaned: false, + reason: "mixed fixture outcomes", + failures: [ + { + pid: 123, + reason: "process-identity-unavailable", + diagnostic: "controlled identity uncertainty", + }, + ], + unverified: [{ pid: 456, diagnostic: "controlled observation unavailable" }], +}; +const fixture = async (rollback: () => Promise) => { + const root = await createTestTempDirectory("rea-launch-rollback-"); + const launcher = new GhidraHeadlessLauncher({ + configurationDirectoryName: "ghidra_12.1.4_PUBLIC", + environment: {}, + platform: process.platform, + analyzeHeadlessPath: join(root, "support", "analyzeHeadless"), + javaHome: join(root, "jdk"), + bridgeScriptPath: join(root, "ReaGhidraBridge.java"), + javaLaunch: async () => ({ + command: process.execPath, + arguments: ["-e", "setInterval(()=>{},1000)"], + environment: {}, + }), + spawnProcess: async (options) => { + const spawned = await spawnOwnedProviderProcess(options); + owners.push(spawned); + return { + ...spawned, + cleanup: () => + denyCleanup + ? rollback() + : cleanupOwnedProcessGroup(spawned.ownership), + }; + }, + }); + const session: GhidraLaunchSession = { + runtimeRoot: root, + transport: "unix-socket", + endpointPath: join(root, "bridge.sock"), + token: "fixture-token", + runId: randomUUID(), + targetPath: join(root, "unused-target"), + targetSha256: "a".repeat(64), + providerVersion: "test", + profileDigest: "b".repeat(64), + }; + return { launcher, session }; +}; + +it + .skipIf(process.platform === "win32") + .each([undefined, new Error("rollback rejected")] as const)( + "retains the process owner after rollback rejects with %s", + async (rejection) => { + const { launcher, session } = await fixture(async () => { + throw rejection; + }); + const result = await launcher.launch(session); + expect(result.ok).toBe(false); + if (result.ok || !(result.error instanceof GhidraLaunchError)) + throw new Error("Expected launch failure"); + expect(result.error.partialLaunch).toBeDefined(); + expect(result.error).toMatchObject({ + rollbackFailure: { kind: "rejected", cause: rejection }, + }); + expect(owners[0]?.process.exitCode).toBeNull(); + denyCleanup = false; + expect(await result.error.partialLaunch?.cleanup?.()).toMatchObject({ + cleaned: true, + }); + }, +); + +it.skipIf(process.platform === "win32")( + "retains structured mixed-member rollback evidence through session projection", + async () => { + const { launcher, session } = await fixture(async () => incomplete); + const result = await launcher.launch(session); + if (result.ok || !(result.error instanceof GhidraLaunchError)) + throw new Error("Expected launch failure"); + expect(result.error.partialLaunch).toBeDefined(); + expect(result.error).toMatchObject({ + rollbackFailure: { kind: "incomplete", result: incomplete }, + }); + const projected = bindGhidraSessionFailure(() => ({}))( + "start", + result.error.message, + result.error, + ); + expect(projected.diagnostics).toMatchObject({ + failure_cause: { + rollback_failure: { kind: "incomplete", result: incomplete }, + }, + }); + denyCleanup = false; + expect(await result.error.partialLaunch?.cleanup?.()).toMatchObject({ + cleaned: true, + }); + }, +); + +it.skipIf(process.platform === "win32")( + "client adopts a rejected rollback owner and retries before removing its runtime", + async () => { + const { launcher } = await fixture(async () => { + throw undefined; + }); + const target = await readFile(ghidraFixturePath); + let runtimeRoot: string | undefined; + const client = new GhidraClient({ + launcher: { + launch: (session, options) => { + runtimeRoot = session.runtimeRoot; + return launcher.launch(session, options); + }, + }, + targetPath: ghidraFixturePath, + targetSha256: createHash("sha256").update(target).digest("hex"), + providerVersion: "test", + profileDigest: "b".repeat(64), + startupTimeoutMs: 5000, + }); + clients.push(client); + const result = await client.start(); + expect(result).toMatchObject({ + ok: false, + error: { kind: "start", cleanupFailure: { cleanupIncomplete: true } }, + }); + if (result.ok) throw new Error("Expected failed startup"); + expect(owners[0]?.process.exitCode).toBeNull(); + if (runtimeRoot === undefined) + throw new Error("Expected admitted runtime root"); + await expect(access(runtimeRoot)).resolves.toBeUndefined(); + denyCleanup = false; + expect(await client.close()).toMatchObject({ ok: true }); + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false); + await expect(access(runtimeRoot)).rejects.toMatchObject({ code: "ENOENT" }); + }, +); + +it.skipIf(process.platform === "win32")( + "retains synchronous rollback rejection and the earlier failure during cancellation", + async () => { + const controller = new AbortController(); + const { launcher, session } = await fixture(() => { + controller.abort(null); + throw undefined; + }); + const result = await launcher.launch(session, { + signal: controller.signal, + }); + if (result.ok || !(result.error instanceof GhidraLaunchError)) + throw new Error("Expected launch failure"); + expect(result.error.rollbackFailure).toEqual({ + kind: "rejected", + cause: undefined, + }); + expect(result.error.cause).toBeInstanceOf(AggregateError); + if (!(result.error.cause instanceof AggregateError)) + throw new Error("Expected composite failure"); + expect(result.error.cause.errors[0]).toMatchObject({ + cause: fault.failure, + }); + expect(result.error.cause.errors[1]).toBeUndefined(); + expect(result.error.partialLaunch).toBeDefined(); + denyCleanup = false; + expect(await result.error.partialLaunch?.cleanup?.()).toMatchObject({ + cleaned: true, + }); + }, +); + +it("bounds and redacts rollback evidence with explicit truncation", () => { + const value = "sensitive-transport-token"; + const result: ProcessCleanupFailure = { + cleaned: false, + reason: value + "x".repeat(4096), + failures: [ + { + pid: 1, + reason: "run-token-mismatch", + diagnostic: value + "y".repeat(4096), + }, + ], + unverified: [{ pid: 2, diagnostic: value + "z".repeat(4096) }], + }; + const launch = new GhidraLaunchError("primary", { + rollbackFailure: { kind: "incomplete", result }, + }); + const projected = bindGhidraSessionFailure( + () => ({}), + (text) => text.replaceAll(value, "[REDACTED]"), + )("start", "primary", launch); + expect(JSON.stringify(projected.diagnostics)).not.toContain(value); + expect(projected.diagnostics).toMatchObject({ + failure_cause: { + rollback_failure: { + result: { + reason_truncated: true, + failures: [{ diagnostic_truncated: true }], + unverified: [{ diagnostic_truncated: true }], + }, + }, + }, + }); +}); + +it("bounds aggregate member evidence and retains complete producer facts", () => { + const rows = Array.from({ length: 500 }, (_, pid) => ({ + pid, + diagnostic: "\u0001".repeat(2000), + })); + const result: ProcessCleanupFailure = { + cleaned: false, + reason: "many controlled members", + failures: rows.map((row) => ({ + ...row, + reason: "process-identity-unavailable" as const, + })), + unverified: rows, + }; + const launch = new GhidraLaunchError("primary", { + rollbackFailure: { kind: "incomplete", result }, + }); + const projected = bindGhidraSessionFailure(() => ({}))( + "start", + "primary", + launch, + ); + const cause = projected.diagnostics["failure_cause"] as { + rollback_failure: { + result: { + failures: unknown[]; + unverified: unknown[]; + failures_omitted?: number; + unverified_omitted?: number; + }; + }; + }; + const output = cause.rollback_failure.result; + expect(output.failures.length + (output.failures_omitted ?? 0)).toBe(500); + expect(output.unverified.length + (output.unverified_omitted ?? 0)).toBe(500); + expect(Buffer.byteLength(JSON.stringify(projected.diagnostics))).toBeLessThan( + 10 * 1024, + ); + expect(launch.rollbackFailure).toEqual({ kind: "incomplete", result }); +});