fix(ghidra): retain launcher command outcome in launch failure diagnostics (#2011)

* fix(ghidra): retain launcher command outcome in launch failure diagnostics

Ghidra headless launch failures surfaced only the GhidraLaunchError name
and a generic message, dropping the launcher command's exit code/signal
and JVM stderr. Retain the failed command outcome on GhidraLaunchError in
the shared captured-output envelope and project it as launch_outcome in
the session failure_cause, mirroring the compiler_outcome diagnostics.

Fixes #1992

* test(ghidra): skip POSIX JVM fixture tests on Windows

The bin/java fixture is an extensionless POSIX shell script handed to
execFile, which Windows cannot execute. Follow the repo pattern
(test.skipIf) so the suite is a named Windows skip.
This commit is contained in:
Saim Shafique authored and GitHub committed 2026-10-12 14:59:18 +08:00
1 parent 9b780c47bc
commit d4e8ef7499
3 files changed
+247 -3

No files matched your search

+166
View File
@@ -0,0 +1,166 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import {
GhidraHeadlessLauncher,
GhidraLaunchError,
type GhidraLaunchSession,
} from "./GhidraLauncher.js";
import { bindGhidraSessionFailure } from "./GhidraSessionError.js";
// The POSIX `bin/java` fixture cannot execute on Windows (extensionless sh script
// handed to execFile), so these tests only run on POSIX hosts.
const it = test.skipIf(process.platform === "win32");
/** Projection bound per launcher-command stream asserted by the tests. */
const EXPECTED_OUTPUT_BOUND_BYTES = 8 * 1024;
const launchSession = (runtimeRoot: string): GhidraLaunchSession => ({
runtimeRoot,
transport: "unix-socket",
endpointPath: join(runtimeRoot, "endpoint.sock"),
token: "test-token",
runId: "test-run",
targetPath: join(runtimeRoot, "target.bin"),
targetSha256: "0".repeat(64),
providerVersion: "test",
profileDigest: "test",
});
/** Stand-in "java" whose LaunchSupport probe fails the way a broken JVM does. */
const failingJavaHome = async (script: string): Promise<string> => {
const javaHome = await mkdtemp(join(tmpdir(), "rea-fake-java-"));
await mkdir(join(javaHome, "bin"), { recursive: true });
await writeFile(join(javaHome, "bin", "java"), script, { mode: 0o755 });
return javaHome;
};
const probeLauncher = (javaHome: string, runtimeRoot: string) =>
new GhidraHeadlessLauncher({
environment: {},
platform: "darwin",
analyzeHeadlessPath: join(runtimeRoot, "support", "analyzeHeadless"),
javaHome,
bridgeScriptPath: join(runtimeRoot, "bridge", "ReaGhidraBridge.java"),
});
const launchOutcomeOf = (error: Error): Record<string, unknown> | undefined => {
const sessionError = bindGhidraSessionFailure(() => ({}))(
"start",
error.message,
error,
);
const cause = sessionError.diagnostics["failure_cause"] as Record<
string,
unknown
>;
const outcome = cause["launch_outcome"] as Record<string, unknown>;
return outcome === undefined ? undefined : outcome;
};
describe("Ghidra launch failure diagnostics", () => {
it("retains the failed launcher command outcome on the launch error", async () => {
const javaHome = await failingJavaHome(
"#!/bin/sh\necho 'jvm probe exploded' >&2\nexit 3\n",
);
const runtimeRoot = await mkdtemp(join(tmpdir(), "rea-ghidra-launch-"));
try {
const launched = await probeLauncher(javaHome, runtimeRoot).launch(
launchSession(runtimeRoot),
);
expect(launched.ok).toBe(false);
if (launched.ok) throw new Error("expected the launch to fail");
expect(launched.error).toBeInstanceOf(GhidraLaunchError);
expect(launched.error.message).toBe("Ghidra headless launch failed");
const captured =
launched.error instanceof GhidraLaunchError
? launched.error.capturedOutput
: undefined;
expect(captured).toBeDefined();
expect(captured?.exit_code).toBe(3);
expect(captured?.signal).toBeNull();
expect(captured?.stdout).toBe("");
expect(captured?.stderr).toContain("jvm probe exploded");
expect(captured?.truncated).toBe(false);
} finally {
await rm(javaHome, { recursive: true, force: true });
await rm(runtimeRoot, { recursive: true, force: true });
}
});
it("surfaces the retained launcher outcome in the session failure_cause", async () => {
const javaHome = await failingJavaHome(
"#!/bin/sh\necho 'jvm probe exploded' >&2\nexit 3\n",
);
const runtimeRoot = await mkdtemp(join(tmpdir(), "rea-ghidra-launch-"));
try {
const launched = await probeLauncher(javaHome, runtimeRoot).launch(
launchSession(runtimeRoot),
);
expect(launched.ok).toBe(false);
if (launched.ok) throw new Error("expected the launch to fail");
const outcome = launchOutcomeOf(launched.error);
expect(outcome).toBeDefined();
expect(outcome?.["exit_code"]).toBe(3);
expect(outcome?.["signal"]).toBeNull();
expect(outcome?.["stderr"]).toContain("jvm probe exploded");
expect(outcome?.["truncated"]).toBe(false);
} finally {
await rm(javaHome, { recursive: true, force: true });
await rm(runtimeRoot, { recursive: true, force: true });
}
});
it("bounds projected launcher output and marks truncation explicitly", async () => {
const oversized = `${"x".repeat(EXPECTED_OUTPUT_BOUND_BYTES)}UNRETAINED-TAIL`;
const javaHome = await failingJavaHome(
`#!/bin/sh\nprintf '%s' '${oversized}' >&2\nexit 1\n`,
);
const runtimeRoot = await mkdtemp(join(tmpdir(), "rea-ghidra-launch-"));
try {
const launched = await probeLauncher(javaHome, runtimeRoot).launch(
launchSession(runtimeRoot),
);
expect(launched.ok).toBe(false);
if (launched.ok) throw new Error("expected the launch to fail");
const outcome = launchOutcomeOf(launched.error);
expect(outcome?.["truncated"]).toBe(true);
expect(outcome?.["exit_code"]).toBe(1);
expect(outcome?.["stderr_bytes"]).toBe(oversized.length);
expect((outcome?.["stderr"] as string).length).toBeLessThanOrEqual(
EXPECTED_OUTPUT_BOUND_BYTES,
);
expect(outcome?.["stderr"] as string).not.toContain("UNRETAINED-TAIL");
} finally {
await rm(javaHome, { recursive: true, force: true });
await rm(runtimeRoot, { recursive: true, force: true });
}
});
it("leaves non-command launch failures without retained output", async () => {
const runtimeRoot = await mkdtemp(join(tmpdir(), "rea-ghidra-launch-"));
try {
const launcher = new GhidraHeadlessLauncher({
environment: {},
platform: "darwin",
analyzeHeadlessPath: join(runtimeRoot, "support", "analyzeHeadless"),
bridgeScriptPath: join(runtimeRoot, "bridge", "ReaGhidraBridge.java"),
});
const launched = await launcher.launch(launchSession(runtimeRoot));
expect(launched.ok).toBe(false);
if (launched.ok) throw new Error("expected the launch to fail");
expect(launched.error).toBeInstanceOf(GhidraLaunchError);
expect(launched.error.message).toBe("Ghidra headless launch failed");
expect(
launched.error instanceof GhidraLaunchError
? launched.error.capturedOutput
: undefined,
).toBeUndefined();
expect(launchOutcomeOf(launched.error)).toBeUndefined();
} finally {
await rm(runtimeRoot, { recursive: true, force: true });
}
});
});
+42 -3
View File
@@ -19,6 +19,8 @@ import {
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { err, ok, type Result } from "../domain/result.js";
import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js";
import { execFileOutputFailure } from "../process/ExecFileOutput.js";
import {
cleanupOwnedProcessGroup,
cleanupWindowsProcessTree,
@@ -73,23 +75,55 @@ export interface GhidraLauncher {
/** 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;
constructor(
message: string,
options?: ErrorOptions & { readonly partialLaunch?: GhidraLaunch },
options?: ErrorOptions & {
readonly partialLaunch?: GhidraLaunch;
readonly capturedOutput?: AnalysisCapturedOutput;
},
) {
const { partialLaunch, ...errorOptions } = options ?? {};
const { partialLaunch, capturedOutput, ...errorOptions } = options ?? {};
super(message, errorOptions);
this.name = "GhidraLaunchError";
this.#partialLaunch = partialLaunch;
this.#capturedOutput =
capturedOutput === undefined ? undefined : { ...capturedOutput };
}
/** Process ownership retained for the client to retry an incomplete rollback. */
get partialLaunch(): GhidraLaunch | undefined {
return this.#partialLaunch;
}
/** Launcher command outcome retained when a launch command reported one. */
get capturedOutput(): AnalysisCapturedOutput | undefined {
return this.#capturedOutput === undefined
? undefined
: { ...this.#capturedOutput };
}
}
/**
* Retain a failed launcher command's exit/signal and output in the shared
* captured-output envelope. Bounds apply at projection; truncation there stays
* explicit, matching the compiler-outcome diagnostics.
*/
const ghidraLaunchCommandOutput = (
cause: unknown,
): AnalysisCapturedOutput | undefined => {
const failure = execFileOutputFailure(cause);
if (failure === undefined) return undefined;
return {
stdout: failure.stdout,
stderr: failure.stderr,
truncated: failure.outputTruncated,
exit_code: typeof failure.code === "number" ? failure.code : null,
signal: failure.signal,
};
};
/** Static coordinates for an extracted Ghidra release and packaged script. */
export interface GhidraHeadlessLauncherOptions {
readonly environment: Readonly<NodeJS.ProcessEnv>;
@@ -295,9 +329,13 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
}
return ok(partialLaunch);
} catch (cause: unknown) {
const capturedOutput = ghidraLaunchCommandOutput(cause);
const primary = isAborted(options.signal)
? new AnalysisCancelledError("open_binary", { cause })
: new GhidraLaunchError("Ghidra headless launch failed", { cause });
: new GhidraLaunchError("Ghidra headless launch failed", {
cause,
...(capturedOutput === undefined ? {} : { capturedOutput }),
});
if (started === undefined) return err(primary);
let cleanupFailure: unknown;
@@ -325,6 +363,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
{ cause: primary },
),
partialLaunch,
...(capturedOutput === undefined ? {} : { capturedOutput }),
},
),
);
+39
View File
@@ -2,11 +2,13 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
import { GhidraLaunchError } from "./GhidraLauncher.js";
const MAX_CAUSE_DEPTH = 3;
const MAX_CAUSE_MESSAGE_BYTES = 2 * 1024;
const MAX_CAUSE_FIELD_BYTES = 1024;
const MAX_COMPILER_STDERR_BYTES = 8 * 1024;
const MAX_LAUNCH_COMMAND_OUTPUT_BYTES = 8 * 1024;
/** Stable failure phase for one private Ghidra headless session. */
export type GhidraSessionFailureKind =
@@ -149,6 +151,7 @@ const describeErrorCause = (
...causeCode(cause, redact),
...causeSignal(cause, redact),
...causeCompilerOutcome(cause, redact),
...causeLaunchOutcome(cause, redact),
...nestedErrorCause(cause, redact, depth),
};
};
@@ -220,6 +223,42 @@ const boundedCompilerField = (
return { value: bounded.text, truncated: bounded.truncated };
};
/** Retain a launch failure's launcher command outcome in its cause envelope. */
const causeLaunchOutcome = (
cause: Error,
redact: (value: string) => string,
): Record<string, JsonValue> => {
if (!(cause instanceof GhidraLaunchError)) return {};
const output = cause.capturedOutput;
if (output === undefined) return {};
const stdout = boundedUtf8Text(
redact(output.stdout),
MAX_LAUNCH_COMMAND_OUTPUT_BYTES,
);
const stderr = boundedUtf8Text(
redact(output.stderr),
MAX_LAUNCH_COMMAND_OUTPUT_BYTES,
);
const truncated = output.truncated || stdout.truncated || stderr.truncated;
return {
launch_outcome: {
stdout: stdout.text,
stderr: stderr.text,
truncated,
...(truncated
? {
stdout_bytes: Buffer.byteLength(output.stdout),
stderr_bytes: Buffer.byteLength(output.stderr),
}
: {}),
...(output.exit_code === undefined
? {}
: { exit_code: output.exit_code }),
...(output.signal === undefined ? {} : { signal: output.signal }),
},
};
};
const nestedErrorCause = (
cause: Error,
redact: (value: string) => string,