From 9d940c2f284cc6f75b27900336e52ceeba4aa2af Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:33:13 +0800 Subject: [PATCH] fix(ghidra): preserve lifecycle ownership and failure provenance Refs #4502, #4506, #4508, #4518, #4522, #4526. Retain snapshot, endpoint, launch rollback and compiler cleanup owners. Join request finalization and cleanup cancellation. Preserve bounded compiler output and original byte counts through capture and Ghidra failures. Normalize execFile cancellation once at its producer. Validation includes real macOS Ghidra TCP MCP success workflows, controlled Node compiler/process fault fixtures, focused regressions and static checks. Native Swift compiler faults, Windows execution and stdio transport were not verified. Broader startup primary classification in #4517 and native adapter propagation remain unfinished. --- src/ghidra/GhidraClient.ts | 189 ++++----- src/ghidra/GhidraClientStartup.ts | 25 +- src/ghidra/GhidraClientWire.ts | 25 +- src/ghidra/GhidraJavaLaunch.ts | 10 +- src/ghidra/GhidraLauncher.ts | 106 ++++- src/ghidra/GhidraProviderClient.ts | 24 +- src/ghidra/GhidraRequestQueue.test.ts | 262 +++++++++++- src/ghidra/GhidraRequestQueue.ts | 198 +++++---- src/ghidra/GhidraSessionError.test.ts | 108 ++++- src/ghidra/GhidraSessionError.ts | 182 +++++++- src/ghidra/GhidraSocketConnection.ts | 47 ++- src/ghidra/GhidraTargetSnapshot.ts | 184 +++++---- src/ghidra/GhidraTransport.ts | 242 ++++++----- src/process/DarwinProcessRunTokenReader.ts | 292 ++++++++++--- src/process/ExecFileOutput.test.ts | 93 ++++- src/process/ExecFileOutput.ts | 26 +- .../capture/ProcessCaptureError.test.ts | 157 +++++++ src/process/capture/ProcessCaptureError.ts | 81 +++- .../capture/ProcessCaptureLifecycle.ts | 39 +- src/process/capture/ProcessHarness.ts | 5 +- src/server/registerSessionTools.ts | 5 +- .../darwinProcessRunTokenReader.test.ts | 52 +++ tests/boundary/process/processCli.test.ts | 2 +- .../providers/ghidra/ghidraClient.test.ts | 176 +++++++- .../providers/ghidra/ghidraJavaLaunch.test.ts | 47 +++ .../darwinCompilerCleanup.test.ts | 288 +++++++++++++ .../ghidraEndpointLifetime.test.ts | 359 ++++++++++++++++ .../ghidraImportSnapshotLifetime.test.ts | 390 ++++++++++++++++++ .../ghidraLaunchRollback.test.ts | 304 ++++++++++++++ 29 files changed, 3340 insertions(+), 578 deletions(-) create mode 100644 src/process/capture/ProcessCaptureError.test.ts create mode 100644 tests/process-global/darwinCompilerCleanup.test.ts create mode 100644 tests/process-global/ghidraEndpointLifetime.test.ts create mode 100644 tests/process-global/ghidraImportSnapshotLifetime.test.ts create mode 100644 tests/process-global/ghidraLaunchRollback.test.ts 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 }); +});