diff --git a/docs/installation.md b/docs/installation.md index 16993b4087..7dca3450b0 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -925,6 +925,10 @@ ownership inspection, so retaining those wrappers would prevent verified process-group cancellation during startup. If ownership remains unverifiable, it reports the reason and retains the process supervisor and private runtime instead of removing files beneath a live provider. +Startup preserves an observed launch failure when cancellation arrives during +rollback. Producer cancellation retains the caller's exact reason separately +from LaunchSupport warnings and cleanup failures. Failed rollback keeps the +original process owner for a later cleanup retry. The experimental Windows transport uses authenticated IPv4 loopback with a private native-owned bearer descriptor and Job Object process ownership. diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 64788e8f64..c699691c2c 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -792,18 +792,19 @@ export class GhidraClient { this.#startupOutputCheckpoint = this.#process.checkpointOutput(); } const producerFailure = - launched.error instanceof GhidraLaunchError - ? launched.error.primaryFailure - : undefined; - const interrupted = - launched.error instanceof AnalysisCancelledError || - producerFailure instanceof AnalysisCancelledError || - (producerFailure === undefined && deadline.signal.aborted); - const failure = interrupted - ? this.#interruptionFailure(deadline, launched.error) - : launched.error instanceof GhidraSessionError + launched.error instanceof AnalysisCancelledError ? launched.error - : this.#failure("start", launched.error.message, launched.error); + : launched.error instanceof GhidraLaunchError + ? launched.error.primaryFailure + : undefined; + const failure = + producerFailure instanceof AnalysisCancelledError + ? this.#interruptionFailure(deadline, launched.error, { + reason: producerFailure.cause, + }) + : launched.error instanceof GhidraSessionError + ? launched.error + : this.#failure("start", launched.error.message, launched.error); return this.#startupFailure(failure, deadline); } this.#launch = launched.value; @@ -1122,7 +1123,18 @@ export class GhidraClient { #interruptionFailure( deadline: ProviderStartupDeadline, cause?: unknown, + cancellation?: { readonly reason: unknown }, ): GhidraSessionError { + if ( + cancellation !== undefined && + !( + deadline.signal.aborted && + cancellation.reason === deadline.signal.reason + ) + ) + return this.#failure("cancelled", "Ghidra startup was cancelled", cause, { + cancellation, + }); if (deadline.signal.reason === STARTUP_OWNER_CLOSED) return this.#failure( "process", diff --git a/src/ghidra/GhidraLauncher.ts b/src/ghidra/GhidraLauncher.ts index 186a7e4755..ca10a16e8f 100644 --- a/src/ghidra/GhidraLauncher.ts +++ b/src/ghidra/GhidraLauncher.ts @@ -39,7 +39,10 @@ import { spawnOwnedProviderProcess, } from "../process/ProviderProcess.js"; import { ghidraJavaEnvironment } from "./GhidraInstallation.js"; -import { ghidraJavaLaunch } from "./GhidraJavaLaunch.js"; +import { + ghidraJavaLaunch, + GhidraLaunchSupportError, +} from "./GhidraJavaLaunch.js"; import type { GhidraTransportKind } from "./GhidraTransport.js"; import { snapshotGhidraExtensions, @@ -117,6 +120,7 @@ export class GhidraLaunchError extends Error { readonly partialLaunch?: GhidraLaunch; readonly rollbackFailure?: GhidraLaunchRollbackFailure; readonly cleanupFailure?: AnalysisError; + readonly primaryFailure?: AnalysisError; readonly capturedOutput?: AnalysisCapturedOutput; readonly launchSupport?: readonly GhidraLaunchSupportDiagnostic[]; }, @@ -127,6 +131,7 @@ export class GhidraLaunchError extends Error { launchSupport, rollbackFailure, cleanupFailure, + primaryFailure, ...errorOptions } = options ?? {}; super(message, errorOptions); @@ -134,11 +139,12 @@ export class GhidraLaunchError extends Error { this.#partialLaunch = partialLaunch; this.rollbackFailure = rollbackFailure; this.primaryFailure = - errorOptions.cause instanceof AnalysisError + primaryFailure ?? + (errorOptions.cause instanceof AnalysisError ? errorOptions.cause : errorOptions.cause instanceof GhidraLaunchError ? errorOptions.cause.primaryFailure - : undefined; + : undefined); const inherited = errorOptions.cause instanceof DarwinProcessOwnershipInspectionError || errorOptions.cause instanceof GhidraLaunchError @@ -350,7 +356,11 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { platform, ); if (isAborted(options.signal)) - return err(new AnalysisCancelledError("open_binary")); + return err( + new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }), + ); const headlessArguments = ghidraHeadlessArguments({ platform, ...(psp === undefined ? {} : { psp: true as const }), @@ -457,6 +467,12 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { env: command.environment, hostEnvironment: {}, ...(options.signal === undefined ? {} : { signal: options.signal }), + }).catch((cause: unknown) => { + // The spawn boundary throws the exact opaque reason at preparation + // checkpoints. Wrap it before interpreting any typed provider facts. + if (options.signal?.aborted === true && cause === options.signal.reason) + throw new AnalysisCancelledError("open_binary", { cause }); + throw cause; }); const partialLaunch = ownedGhidraLaunch( started, @@ -483,41 +499,35 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { platform, ); if (isAborted(options.signal)) { - throw new AnalysisCancelledError("open_binary"); + throw new AnalysisCancelledError("open_binary", { + cause: options.signal?.reason, + }); } return ok(partialLaunch); } catch (cause: unknown) { - if (cause instanceof AnalysisCancelledError && started === undefined) - return err(cause); + const cancellation = + cause instanceof AnalysisCancelledError + ? cause + : (cause instanceof GhidraLaunchSupportError || + cause instanceof DarwinProcessOwnershipInspectionError) && + cause.cancellation !== undefined + ? new AnalysisCancelledError("open_binary", { + cause: cause.cancellation.reason, + }) + : undefined; const capturedOutput = ghidraLaunchCommandOutput(cause); const launchFailure = new GhidraLaunchError( "Ghidra headless launch failed", { cause, + ...(cancellation === undefined + ? {} + : { primaryFailure: cancellation }), launchSupport, ...(capturedOutput === undefined ? {} : { capturedOutput }), }, ); - const primary = - cause instanceof AnalysisCancelledError || - (!(cause instanceof AnalysisError) && isAborted(options.signal)) - ? 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); + if (started === undefined) return err(launchFailure); const cleanupOwner = started; const rollback = await Promise.resolve() @@ -536,24 +546,28 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { launchSupport, configuredMaxHeap, ); - const primaryMessage = isAborted(options.signal) - ? primary.message - : `Ghidra headless launch failed: ${cause instanceof Error ? cause.message : String(cause)}`; + const primaryMessage = + cancellation !== undefined + ? cancellation.message + : `Ghidra headless launch failed: ${cause instanceof Error ? cause.message : String(cause)}`; return err( new GhidraLaunchError( `${primaryMessage}; process cleanup remains incomplete`, { cause: new AggregateError( [ - primary, + launchFailure, rollback.error.kind === "rejected" ? rollback.error.cause : rollback.error.result, ], "Ghidra launch rollback did not release its process", - { cause: primary }, + { cause: launchFailure }, ), partialLaunch, + ...(launchFailure.primaryFailure === undefined + ? {} + : { primaryFailure: launchFailure.primaryFailure }), rollbackFailure: rollback.error, ...(launchFailure.cleanupFailure === undefined ? {} @@ -564,7 +578,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher { ), ); } - return err(primary); + return err(launchFailure); } } } diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index af14a7db86..86a9b26c68 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -1,3 +1,4 @@ +import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js"; import { spawn, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; @@ -1766,3 +1767,74 @@ it("redacts the transport credential when refreshing startup diagnostics", async expect(result.error.message).not.toContain(token); expect(JSON.stringify(result.error.diagnostics)).not.toContain(token); }); + +it("preserves a failed launcher result when the final caller aborts before consumption", async () => { + const controller = new AbortController(); + const physical = new GhidraLaunchError("controlled earlier launch failure", { + cause: new Error("physical failure"), + }); + const client = clientFor({ + launch: async (_session, options) => { + controller.abort(null); + if (options?.signal?.aborted !== true) + await new Promise((resolve) => + options?.signal?.addEventListener("abort", () => resolve(), { + once: true, + }), + ); + return err(physical); + }, + }); + const result = await client.start(controller.signal); + expect(result).toMatchObject({ + ok: false, + error: { kind: "start", cause: physical }, + }); +}); + +it.each([null, "helper owner stopped"])( + "consumes a producer cancellation without an aborted client deadline %j", + async (reason) => { + const cancellation = new AnalysisCancelledError("open_binary", { + cause: reason, + }); + const client = clientFor({ + launch: async () => + err( + new GhidraLaunchError("producer helper cancelled", { + primaryFailure: cancellation, + }), + ), + }); + const result = await client.start(); + expect(result).toMatchObject({ ok: false, error: { kind: "cancelled" } }); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error.cancellation?.reason).toBe(reason); + }, +); + +it("preserves a producer failure while its result waits for the startup deadline", async () => { + const physical = new GhidraLaunchError( + "controlled physical failure before finalization", + ); + const client = clientFor( + { + launch: async (_session, options) => { + if (options?.signal === undefined) + throw new Error("Expected owned startup signal"); + if (!options.signal.aborted) + await new Promise((resolve) => + options.signal?.addEventListener("abort", () => resolve(), { + once: true, + }), + ); + return err(physical); + }, + }, + { startupTimeoutMs: 500 }, + ); + expect(await client.start()).toMatchObject({ + ok: false, + error: { kind: "start", cause: physical }, + }); +}); diff --git a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts index 05bd255ed2..9a48b29123 100644 --- a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts +++ b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts @@ -488,18 +488,16 @@ describe.skipIf(process.platform === "win32")( details: { diagnostics: { failure_cause: { - cause: { - launch_support: expect.arrayContaining([ - expect.objectContaining({ - mode: "-envvars", - stderr: `-envvars: ${warning}\n`, - }), - expect.objectContaining({ - mode: "-vmargs", - stderr: `-vmargs: ${warning}\n`, - }), - ]), - }, + launch_support: expect.arrayContaining([ + expect.objectContaining({ + mode: "-envvars", + stderr: `-envvars: ${warning}\n`, + }), + expect.objectContaining({ + mode: "-vmargs", + stderr: `-vmargs: ${warning}\n`, + }), + ]), }, }, }, diff --git a/tests/boundary/providers/ghidra/ghidraLaunchOutcome.test.ts b/tests/boundary/providers/ghidra/ghidraLaunchOutcome.test.ts new file mode 100644 index 0000000000..66e8ca29cb --- /dev/null +++ b/tests/boundary/providers/ghidra/ghidraLaunchOutcome.test.ts @@ -0,0 +1,155 @@ +import { join } from "node:path"; +import { expect, it, vi } from "vitest"; +import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js"; +import { + GhidraHeadlessLauncher, + GhidraLaunchError, +} from "../../../../src/ghidra/GhidraLauncher.js"; +import { GhidraLaunchSupportError } from "../../../../src/ghidra/GhidraJavaLaunch.js"; +import { ExecFileOutputCancelledError } from "../../../../src/process/ExecFileOutput.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; + +const launch = async ( + javaLaunch: NonNullable< + ConstructorParameters[0]["javaLaunch"] + >, + signal: AbortSignal, +) => { + const root = await createTestTempDirectory("rea-ghidra-launch-outcome-"); + const spawnProcess = vi.fn(async () => { + throw new Error("Unexpected spawn after Java failure"); + }); + const launcher = new GhidraHeadlessLauncher({ + environment: {}, + platform: "linux", + analyzeHeadlessPath: join(root, "support", "analyzeHeadless"), + configurationDirectoryName: "ghidra_12.1.4_PUBLIC", + javaHome: join(root, "jdk"), + bridgeScriptPath: join(root, "bridge.java"), + javaLaunch, + spawnProcess, + }); + const result = await launcher.launch( + { + runtimeRoot: root, + transport: "unix-socket", + endpointPath: join(root, "bridge.sock"), + token: "fixture-token", + runId: "fixture-launch-outcome", + targetPath: join(root, "unused-target"), + targetSha256: "a".repeat(64), + providerVersion: "test", + profileDigest: "b".repeat(64), + }, + { signal }, + ); + expect(spawnProcess).not.toHaveBeenCalled(); + return result; +}; + +it.each(["physical", "support"] as const)( + "preserves %s Java launch failure when cancellation arrives before catch", + async (kind) => { + const controller = new AbortController(); + const physical = new Error("controlled earlier physical failure"); + const failure = + kind === "support" + ? new GhidraLaunchSupportError("-envvars", physical) + : physical; + const result = await launch(async () => { + controller.abort(null); + throw failure; + }, controller.signal); + if (result.ok) throw new Error("Expected launch failure"); + expect(result.error).toBeInstanceOf(GhidraLaunchError); + expect(result.error.cause).toBe(failure); + }, +); + +it.each([null, "caller stopped helper", new Error("opaque caller reason")])( + "preserves the LaunchSupport producer's cancellation reason %j", + async (reason) => { + const result = await launch(async () => { + throw new GhidraLaunchSupportError( + "-envvars", + new ExecFileOutputCancelledError(reason), + ); + }, new AbortController().signal); + if (result.ok) throw new Error("Expected helper cancellation"); + const primary = + result.error instanceof GhidraLaunchError + ? result.error.primaryFailure + : result.error; + expect(primary).toBeInstanceOf(AnalysisCancelledError); + expect(primary?.cause).toBe(reason); + }, +); + +it("does not adopt a caller error's cleanup resources at initial cancellation", async () => { + const controller = new AbortController(); + const reason = new ProviderCleanupError("foreign", ["caller-resource"], { + reason: "caller-owned", + }); + controller.abort(reason); + const result = await launch(async () => { + throw new Error("Unexpected Java launch"); + }, controller.signal); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error).toMatchObject({ + _tag: "AnalysisCancelledError", + cause: reason, + cleanupIncomplete: false, + }); + expect(reason.cleanupIncomplete).toBe(true); +}); + +it("wraps an opaque typed caller reason at the real spawn preparation checkpoint", async () => { + const root = await createTestTempDirectory("rea-ghidra-preparation-cancel-"); + const controller = new AbortController(); + const reason = new ProviderCleanupError("foreign", ["foreign-resource"], { + reason: "caller-owned", + }); + const launcher = new GhidraHeadlessLauncher({ + environment: {}, + platform: process.platform, + analyzeHeadlessPath: join(root, "support", "analyzeHeadless"), + configurationDirectoryName: "ghidra_12.1.4_PUBLIC", + javaHome: join(root, "jdk"), + bridgeScriptPath: join(root, "bridge.java"), + javaLaunch: async () => { + controller.abort(reason); + return { + command: join(root, "must-not-spawn"), + arguments: [], + environment: {}, + }; + }, + }); + const result = await launcher.launch( + { + runtimeRoot: root, + transport: "unix-socket", + endpointPath: join(root, "bridge.sock"), + token: "fixture-token", + runId: "fixture-preparation-cancel", + targetPath: join(root, "unused-target"), + targetSha256: "a".repeat(64), + providerVersion: "test", + profileDigest: "b".repeat(64), + }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { + primaryFailure: { + _tag: "AnalysisCancelledError", + cause: reason, + cleanupIncomplete: false, + }, + cleanupFailure: undefined, + }, + }); + expect(reason.cleanupIncomplete).toBe(true); +}); diff --git a/tests/process-global/ghidraLaunchRollback.test.ts b/tests/process-global/ghidraLaunchRollback.test.ts index f894f30ae9..c38993e100 100644 --- a/tests/process-global/ghidraLaunchRollback.test.ts +++ b/tests/process-global/ghidraLaunchRollback.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; const fault = vi.hoisted(() => ({ failure: new Error("controlled manifest failure"), + failManifest: true, })); vi.mock("write-file-atomic", async (importOriginal) => { const actual = await importOriginal<{ @@ -12,7 +13,11 @@ vi.mock("write-file-atomic", async (importOriginal) => { }>(); return { default: async (...args: Parameters) => { - if (typeof args[0] === "string" && args[0].endsWith("/ownership.json")) + if ( + fault.failManifest && + typeof args[0] === "string" && + args[0].endsWith("/ownership.json") + ) throw fault.failure; return actual.default(...args); }, @@ -25,6 +30,7 @@ import { type GhidraLaunchSession, } from "../../src/ghidra/GhidraLauncher.js"; import { bindGhidraSessionFailure } from "../../src/ghidra/GhidraSessionError.js"; +import { AnalysisCancelledError } from "../../src/domain/analysisErrorCore.js"; import { GhidraClient } from "../../src/ghidra/GhidraClient.js"; import { spawnOwnedProviderProcess, @@ -42,6 +48,7 @@ const clients: GhidraClient[] = []; let denyCleanup = true; beforeEach(() => { denyCleanup = true; + fault.failManifest = true; }); afterEach(async () => { denyCleanup = false; @@ -64,7 +71,10 @@ const incomplete: ProcessCleanupFailure = { ], unverified: [{ pid: 456, diagnostic: "controlled observation unavailable" }], }; -const fixture = async (rollback: () => Promise) => { +const fixture = async ( + rollback: () => Promise, + afterSpawn?: () => void, +) => { const root = await createTestTempDirectory("rea-launch-rollback-"); const launcher = new GhidraHeadlessLauncher({ configurationDirectoryName: "ghidra_12.1.4_PUBLIC", @@ -81,6 +91,7 @@ const fixture = async (rollback: () => Promise) => { spawnProcess: async (options) => { const spawned = await spawnOwnedProviderProcess(options); owners.push(spawned); + afterSpawn?.(); return { ...spawned, cleanup: () => @@ -302,3 +313,64 @@ it("bounds aggregate member evidence and retains complete producer facts", () => ); expect(launch.rollbackFailure).toEqual({ kind: "incomplete", result }); }); + +it.skipIf(process.platform === "win32").each([null, "stop after spawn"])( + "retains producer cancellation and exact process cleanup through failed rollback %j", + async (reason) => { + fault.failManifest = false; + const controller = new AbortController(); + const { launcher, session } = await fixture( + async () => incomplete, + () => controller.abort(reason), + ); + const result = await launcher.launch(session, { + signal: controller.signal, + }); + if (result.ok || !(result.error instanceof GhidraLaunchError)) + throw new Error("Expected incomplete rollback"); + expect(result.error.primaryFailure).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.primaryFailure?.cause).toBe(reason); + expect(result.error.rollbackFailure).toEqual({ + kind: "incomplete", + result: incomplete, + }); + expect(result.error.partialLaunch).toBeDefined(); + denyCleanup = false; + expect(await result.error.partialLaunch?.cleanup?.()).toMatchObject({ + cleaned: true, + }); + }, +); + +it.skipIf(process.platform === "win32")( + "client preserves an earlier manifest failure when the final caller cancels during rollback", + async () => { + const controller = new AbortController(); + const { launcher } = await fixture(async () => { + controller.abort(null); + return incomplete; + }); + const target = await readFile(ghidraFixturePath); + const client = new GhidraClient({ + launcher, + 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(controller.signal); + expect(result).toMatchObject({ + ok: false, + error: { kind: "start", cleanupFailure: { cleanupIncomplete: true } }, + }); + if (result.ok) throw new Error("Expected original startup failure"); + expect(JSON.stringify(result.error.diagnostics)).toContain( + fault.failure.message, + ); + denyCleanup = false; + expect(await client.close()).toMatchObject({ ok: true }); + expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false); + }, +);