mirror of
https://github.com/morluto/rea.git
synced 2026-10-19 04:19:54 -05:00
fix(ghidra): retain scoped failures through process finalization
Drain output within caller bounds at asynchronous completion owners and classify JVM exhaustion from the failing operation only. Preserve raw-byte scope, credential redaction, configured heap provenance, cancellation and exact cleanup failures. Validation uses controlled processes and provider fixtures; native Ghidra OOM execution was not repeated. Refs #4241, #4275 Co-authored-by: Syed Rishad <rishadzzzz@gmail.com>
This commit is contained in:
1 parent
43bc6e1d23
commit
d52a488476
16 files changed
+1187
-75
No files matched your search
@@ -147,6 +147,24 @@ received signal alone does not establish its exact cause. Dynamic tags require
|
|||||||
a complete DT_NULL inside PT_DYNAMIC; interpreter names and ranges end at the
|
a complete DT_NULL inside PT_DYNAMIC; interpreter names and ranges end at the
|
||||||
first NUL, with any padding still represented by the original segment range.
|
first NUL, with any padding still represented by the original segment range.
|
||||||
|
|
||||||
|
## Ghidra process failures
|
||||||
|
|
||||||
|
When output from the failing Ghidra startup or operation reports
|
||||||
|
`java.lang.OutOfMemoryError`, REA returns `resource_constraint`. Java heap or GC
|
||||||
|
overhead exhaustion includes heap-specific recovery advice. Metaspace and array
|
||||||
|
size failures do not recommend increasing the heap; native-thread exhaustion is
|
||||||
|
reported as a capacity constraint with unknown limits.
|
||||||
|
|
||||||
|
`configured_jvm_max_heap` records the explicit `-Xmx` value selected by REA's
|
||||||
|
launch builder. It is a configuration fact, not measured memory use or proof of
|
||||||
|
the effective JVM limit; JVM environment options may affect that limit.
|
||||||
|
When a Windows script wrapper selects the JVM, this value remains unknown.
|
||||||
|
Historical stdout and stderr remain available, but classification uses only the
|
||||||
|
failing operation's `failure_output`. Output draining waits at most one second,
|
||||||
|
subject to earlier cancellation or the startup deadline. `output_incomplete`
|
||||||
|
marks a snapshot taken before stream closure; cleanup ownership remains with
|
||||||
|
the session until release succeeds.
|
||||||
|
|
||||||
## Portable PE resources
|
## Portable PE resources
|
||||||
|
|
||||||
`inspect_pe_resources` / `inspect-pe-resources` inspects an explicit PE32 or
|
`inspect_pe_resources` / `inspect-pe-resources` inspects an explicit PE32 or
|
||||||
|
|||||||
+174
-27
@@ -20,6 +20,7 @@ import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
|
|||||||
import { ProviderRunLineage } from "../process/ProviderRunLineage.js";
|
import { ProviderRunLineage } from "../process/ProviderRunLineage.js";
|
||||||
import {
|
import {
|
||||||
type ProviderProcessDiagnostic,
|
type ProviderProcessDiagnostic,
|
||||||
|
type ProviderProcessOutputCheckpoint,
|
||||||
type ProviderProcessSnapshot,
|
type ProviderProcessSnapshot,
|
||||||
ProviderProcessSupervisor,
|
ProviderProcessSupervisor,
|
||||||
} from "../process/ProviderProcess.js";
|
} from "../process/ProviderProcess.js";
|
||||||
@@ -33,6 +34,7 @@ import type { GhidraInventoryOperation } from "./GhidraInventoryValues.js";
|
|||||||
import type { GhidraFunctionOperation } from "./GhidraFunctionValues.js";
|
import type { GhidraFunctionOperation } from "./GhidraFunctionValues.js";
|
||||||
import {
|
import {
|
||||||
createGhidraDiagnostics,
|
createGhidraDiagnostics,
|
||||||
|
ghidraDiagnosticStream,
|
||||||
GHIDRA_PROCESS_DIAGNOSTIC_BYTES,
|
GHIDRA_PROCESS_DIAGNOSTIC_BYTES,
|
||||||
} from "./GhidraDiagnostics.js";
|
} from "./GhidraDiagnostics.js";
|
||||||
import { GhidraLaunchError, type GhidraLaunch } from "./GhidraLauncher.js";
|
import { GhidraLaunchError, type GhidraLaunch } from "./GhidraLauncher.js";
|
||||||
@@ -113,6 +115,10 @@ export class GhidraClient {
|
|||||||
#nextId = 1;
|
#nextId = 1;
|
||||||
#closing = false;
|
#closing = false;
|
||||||
#processSnapshot: ProviderProcessSnapshot | undefined;
|
#processSnapshot: ProviderProcessSnapshot | undefined;
|
||||||
|
#startupOutputCheckpoint: ProviderProcessOutputCheckpoint | undefined;
|
||||||
|
#activeOutputCheckpoint: ProviderProcessOutputCheckpoint | undefined;
|
||||||
|
#outputDrainPromise: Promise<boolean> | undefined;
|
||||||
|
#outputDrainIncomplete = false;
|
||||||
#lastDiagnostics: Readonly<Record<string, JsonValue>> = {};
|
#lastDiagnostics: Readonly<Record<string, JsonValue>> = {};
|
||||||
#startupController: AbortController | undefined;
|
#startupController: AbortController | undefined;
|
||||||
#startPromise: Promise<GhidraStartResult> | undefined;
|
#startPromise: Promise<GhidraStartResult> | undefined;
|
||||||
@@ -145,7 +151,6 @@ export class GhidraClient {
|
|||||||
if (!this.#closing)
|
if (!this.#closing)
|
||||||
this.#failAll(this.#failure("process", "Ghidra bridge socket closed"));
|
this.#failAll(this.#failure("process", "Ghidra bridge socket closed"));
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(options: GhidraClientOptions) {
|
constructor(options: GhidraClientOptions) {
|
||||||
this.#options = {
|
this.#options = {
|
||||||
...options,
|
...options,
|
||||||
@@ -164,8 +169,45 @@ export class GhidraClient {
|
|||||||
failure: this.#failure,
|
failure: this.#failure,
|
||||||
});
|
});
|
||||||
this.#requestQueue = new GhidraRequestQueue(
|
this.#requestQueue = new GhidraRequestQueue(
|
||||||
(method, parameters, requestOptions) =>
|
async (method, parameters, requestOptions) => {
|
||||||
this.#wire.request(method, parameters, requestOptions),
|
const owner = this.#process;
|
||||||
|
const checkpoint = owner?.checkpointOutput();
|
||||||
|
const token = this.#token;
|
||||||
|
this.#activeOutputCheckpoint = checkpoint;
|
||||||
|
try {
|
||||||
|
const result = await this.#wire.request(
|
||||||
|
method,
|
||||||
|
parameters,
|
||||||
|
requestOptions,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!result.ok &&
|
||||||
|
(result.error.kind === "process" ||
|
||||||
|
result.error.kind === "protocol")
|
||||||
|
) {
|
||||||
|
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,
|
||||||
|
owner,
|
||||||
|
checkpoint,
|
||||||
|
token,
|
||||||
|
!drained,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} finally {
|
||||||
|
if (this.#activeOutputCheckpoint === checkpoint)
|
||||||
|
this.#activeOutputCheckpoint = undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
(kind, message, cause) => this.#failure(kind, message, cause),
|
(kind, message, cause) => this.#failure(kind, message, cause),
|
||||||
() => this.#stopAfterActiveCancellation(),
|
() => this.#stopAfterActiveCancellation(),
|
||||||
);
|
);
|
||||||
@@ -273,7 +315,7 @@ export class GhidraClient {
|
|||||||
): Promise<Result<GhidraSessionInfo, GhidraSessionError>> {
|
): Promise<Result<GhidraSessionInfo, GhidraSessionError>> {
|
||||||
const started = await this.start(options.signal);
|
const started = await this.start(options.signal);
|
||||||
if (!started.ok) return started;
|
if (!started.ok) return started;
|
||||||
const result = await this.#wire.request("ping", {}, options);
|
const result = await this.#requestQueue.run("ping", {}, options);
|
||||||
if (!result.ok) return result;
|
if (!result.ok) return result;
|
||||||
return this.#parseSessionInfo(result.value);
|
return this.#parseSessionInfo(result.value);
|
||||||
}
|
}
|
||||||
@@ -463,7 +505,19 @@ export class GhidraClient {
|
|||||||
|
|
||||||
async #startupFailure(
|
async #startupFailure(
|
||||||
primary: GhidraSessionError,
|
primary: GhidraSessionError,
|
||||||
|
deadline?: ProviderStartupDeadline,
|
||||||
): Promise<GhidraStartResult> {
|
): Promise<GhidraStartResult> {
|
||||||
|
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 }),
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await this.#cleanup();
|
await this.#cleanup();
|
||||||
return err(primary);
|
return err(primary);
|
||||||
@@ -499,14 +553,17 @@ export class GhidraClient {
|
|||||||
return this.#closePromise;
|
return this.#closePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
#stopAfterActiveCancellation(): Promise<void> {
|
#stopAfterActiveCancellation(): Promise<Result<null, AnalysisError>> {
|
||||||
return this.#requestClose(true).catch((cause: unknown) => {
|
return this.#requestClose(true).then(
|
||||||
this.#logger.error(
|
() => ok(null),
|
||||||
{ error: cause instanceof Error ? cause.message : String(cause) },
|
(cause: unknown) => {
|
||||||
"Ghidra cleanup after active request cancellation failed",
|
this.#logger.error(
|
||||||
);
|
{ error: cause instanceof Error ? cause.message : String(cause) },
|
||||||
throw cause;
|
"Ghidra cleanup after active request cancellation failed",
|
||||||
});
|
);
|
||||||
|
return err(this.#cleanupError(cause));
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Observed failures that prevent a retained session from being reused. */
|
/** Observed failures that prevent a retained session from being reused. */
|
||||||
@@ -546,11 +603,18 @@ export class GhidraClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async #start(signal: AbortSignal): Promise<GhidraStartResult> {
|
async #start(signal: AbortSignal): Promise<GhidraStartResult> {
|
||||||
|
if (this.#socket === undefined && this.#runtimeRoot === undefined) {
|
||||||
|
this.#lastDiagnostics = {};
|
||||||
|
this.#processSnapshot = undefined;
|
||||||
|
this.#startupOutputCheckpoint = undefined;
|
||||||
|
this.#activeOutputCheckpoint = undefined;
|
||||||
|
this.#outputDrainPromise = undefined;
|
||||||
|
this.#outputDrainIncomplete = false;
|
||||||
|
}
|
||||||
if (signal.aborted)
|
if (signal.aborted)
|
||||||
return err(this.#failure("cancelled", "Ghidra startup was cancelled"));
|
return err(this.#failure("cancelled", "Ghidra startup was cancelled"));
|
||||||
if (this.#socket !== undefined || this.#runtimeRoot !== undefined)
|
if (this.#socket !== undefined || this.#runtimeRoot !== undefined)
|
||||||
return err(this.#failure("protocol", "Ghidra client is already started"));
|
return err(this.#failure("protocol", "Ghidra client is already started"));
|
||||||
this.#processSnapshot = undefined;
|
|
||||||
const deadline = new ProviderStartupDeadline(
|
const deadline = new ProviderStartupDeadline(
|
||||||
this.#options.startupTimeoutMs,
|
this.#options.startupTimeoutMs,
|
||||||
signal,
|
signal,
|
||||||
@@ -596,7 +660,7 @@ export class GhidraClient {
|
|||||||
"Ghidra private endpoint allocation failed",
|
"Ghidra private endpoint allocation failed",
|
||||||
cause,
|
cause,
|
||||||
);
|
);
|
||||||
return this.#startupFailure(failure);
|
return this.#startupFailure(failure, deadline);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const snapshot = await createGhidraTargetSnapshot(
|
const snapshot = await createGhidraTargetSnapshot(
|
||||||
@@ -614,7 +678,7 @@ export class GhidraClient {
|
|||||||
"Ghidra target snapshot failed admission",
|
"Ghidra target snapshot failed admission",
|
||||||
cause,
|
cause,
|
||||||
);
|
);
|
||||||
return this.#startupFailure(failure);
|
return this.#startupFailure(failure, deadline);
|
||||||
}
|
}
|
||||||
if (deadline.signal.aborted) return this.#startupInterrupted(deadline);
|
if (deadline.signal.aborted) return this.#startupInterrupted(deadline);
|
||||||
this.#token = randomBytes(32).toString("hex");
|
this.#token = randomBytes(32).toString("hex");
|
||||||
@@ -650,21 +714,24 @@ export class GhidraClient {
|
|||||||
maxDiagnosticBytes: GHIDRA_PROCESS_DIAGNOSTIC_BYTES,
|
maxDiagnosticBytes: GHIDRA_PROCESS_DIAGNOSTIC_BYTES,
|
||||||
onDiagnostic: (event) => this.#onProcessDiagnostic(event),
|
onDiagnostic: (event) => this.#onProcessDiagnostic(event),
|
||||||
});
|
});
|
||||||
|
this.#startupOutputCheckpoint = this.#process.checkpointOutput();
|
||||||
}
|
}
|
||||||
const failure = deadline.signal.aborted
|
const failure = deadline.signal.aborted
|
||||||
? this.#interruptionFailure(deadline, launched.error)
|
? this.#interruptionFailure(deadline, launched.error)
|
||||||
: launched.error instanceof GhidraSessionError
|
: launched.error instanceof GhidraSessionError
|
||||||
? launched.error
|
? launched.error
|
||||||
: this.#failure("start", launched.error.message, launched.error);
|
: this.#failure("start", launched.error.message, launched.error);
|
||||||
return this.#startupFailure(failure);
|
return this.#startupFailure(failure, deadline);
|
||||||
}
|
}
|
||||||
this.#launch = launched.value;
|
this.#launch = launched.value;
|
||||||
this.#process = new ProviderProcessSupervisor(launched.value, {
|
this.#process = new ProviderProcessSupervisor(launched.value, {
|
||||||
maxDiagnosticBytes: GHIDRA_PROCESS_DIAGNOSTIC_BYTES,
|
maxDiagnosticBytes: GHIDRA_PROCESS_DIAGNOSTIC_BYTES,
|
||||||
onDiagnostic: (event) => this.#onProcessDiagnostic(event),
|
onDiagnostic: (event) => this.#onProcessDiagnostic(event),
|
||||||
});
|
});
|
||||||
|
this.#startupOutputCheckpoint = this.#process.checkpointOutput();
|
||||||
const completed = await this.#connectAndHandshake(endpoint, deadline);
|
const completed = await this.#connectAndHandshake(endpoint, deadline);
|
||||||
if (!completed.ok) return this.#startupFailure(completed.error);
|
if (!completed.ok) return this.#startupFailure(completed.error, deadline);
|
||||||
|
this.#startupOutputCheckpoint = undefined;
|
||||||
await this.#lineage.observe(this.#launch);
|
await this.#lineage.observe(this.#launch);
|
||||||
this.#requestQueue.reopen();
|
this.#requestQueue.reopen();
|
||||||
return completed;
|
return completed;
|
||||||
@@ -777,7 +844,7 @@ export class GhidraClient {
|
|||||||
|
|
||||||
async #cleanup(forceStop = false): Promise<void> {
|
async #cleanup(forceStop = false): Promise<void> {
|
||||||
this.#closing = true;
|
this.#closing = true;
|
||||||
let forcedStopFailure: GhidraSessionError | undefined;
|
let forcedStopFailure: { readonly cause: unknown } | undefined;
|
||||||
try {
|
try {
|
||||||
this.#requestQueue.failQueued(
|
this.#requestQueue.failQueued(
|
||||||
this.#failure("process", "Ghidra session closed"),
|
this.#failure("process", "Ghidra session closed"),
|
||||||
@@ -843,12 +910,7 @@ export class GhidraClient {
|
|||||||
try {
|
try {
|
||||||
await this.#removeRuntimeRoots();
|
await this.#removeRuntimeRoots();
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
if (forceStop)
|
if (forceStop) forcedStopFailure = { cause };
|
||||||
forcedStopFailure ??= this.#failure(
|
|
||||||
"process",
|
|
||||||
"Ghidra runtime cleanup after cancellation failed",
|
|
||||||
cause,
|
|
||||||
);
|
|
||||||
else throw cause;
|
else throw cause;
|
||||||
}
|
}
|
||||||
this.#snapshotPath = undefined;
|
this.#snapshotPath = undefined;
|
||||||
@@ -856,7 +918,7 @@ export class GhidraClient {
|
|||||||
this.#token = undefined;
|
this.#token = undefined;
|
||||||
this.#runId = undefined;
|
this.#runId = undefined;
|
||||||
this.#responseBuffer.reset();
|
this.#responseBuffer.reset();
|
||||||
if (forcedStopFailure !== undefined) throw forcedStopFailure;
|
if (forcedStopFailure !== undefined) throw forcedStopFailure.cause;
|
||||||
} finally {
|
} finally {
|
||||||
this.#closing = false;
|
this.#closing = false;
|
||||||
}
|
}
|
||||||
@@ -955,7 +1017,7 @@ export class GhidraClient {
|
|||||||
deadline: ProviderStartupDeadline,
|
deadline: ProviderStartupDeadline,
|
||||||
): Promise<GhidraStartResult> {
|
): Promise<GhidraStartResult> {
|
||||||
const failure = this.#interruptionFailure(deadline);
|
const failure = this.#interruptionFailure(deadline);
|
||||||
return this.#startupFailure(failure);
|
return this.#startupFailure(failure, deadline);
|
||||||
}
|
}
|
||||||
|
|
||||||
#interruptionFailure(
|
#interruptionFailure(
|
||||||
@@ -970,7 +1032,29 @@ export class GhidraClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#diagnostics(): Readonly<Record<string, JsonValue>> {
|
#diagnostics(): Readonly<Record<string, JsonValue>> {
|
||||||
const snapshot = this.#process?.snapshot() ?? this.#processSnapshot;
|
const currentSnapshot = this.#process?.snapshot() ?? this.#processSnapshot;
|
||||||
|
const snapshot: ProviderProcessSnapshot | undefined =
|
||||||
|
this.#outputDrainIncomplete && currentSnapshot !== undefined
|
||||||
|
? {
|
||||||
|
...currentSnapshot,
|
||||||
|
diagnosticTruncated: true,
|
||||||
|
outputIncomplete: true,
|
||||||
|
}
|
||||||
|
: currentSnapshot;
|
||||||
|
const checkpoint =
|
||||||
|
this.#activeOutputCheckpoint ?? this.#startupOutputCheckpoint;
|
||||||
|
const scoped =
|
||||||
|
checkpoint === undefined
|
||||||
|
? undefined
|
||||||
|
: this.#process?.snapshotOutputSince(checkpoint);
|
||||||
|
const failureOutput: JsonValue | undefined =
|
||||||
|
scoped === undefined
|
||||||
|
? undefined
|
||||||
|
: {
|
||||||
|
stdout: ghidraDiagnosticStream(scoped.snapshot.stdout, this.#token),
|
||||||
|
stderr: ghidraDiagnosticStream(scoped.snapshot.stderr, this.#token),
|
||||||
|
complete: scoped.complete && !this.#outputDrainIncomplete,
|
||||||
|
};
|
||||||
return createGhidraDiagnostics({
|
return createGhidraDiagnostics({
|
||||||
targetPath: this.#options.targetPath,
|
targetPath: this.#options.targetPath,
|
||||||
targetSha256: this.#options.targetSha256,
|
targetSha256: this.#options.targetSha256,
|
||||||
@@ -994,6 +1078,69 @@ export class GhidraClient {
|
|||||||
...(snapshot === undefined ? {} : { snapshot }),
|
...(snapshot === undefined ? {} : { snapshot }),
|
||||||
...(this.#token === undefined ? {} : { token: this.#token }),
|
...(this.#token === undefined ? {} : { token: this.#token }),
|
||||||
previous: this.#lastDiagnostics,
|
previous: this.#lastDiagnostics,
|
||||||
|
...(failureOutput === undefined ? {} : { failure_output: failureOutput }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#refreshProcessFailure(
|
||||||
|
failure: GhidraSessionError,
|
||||||
|
owner: ProviderProcessSupervisor | undefined,
|
||||||
|
checkpoint: ProviderProcessOutputCheckpoint | undefined,
|
||||||
|
token: string | undefined,
|
||||||
|
drainIncomplete: boolean,
|
||||||
|
): GhidraSessionError {
|
||||||
|
if (owner === undefined || checkpoint === undefined) return failure;
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...(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<boolean> {
|
||||||
|
if (this.#outputDrainPromise !== undefined) return this.#outputDrainPromise;
|
||||||
|
const process = this.#process;
|
||||||
|
if (process === undefined) return Promise.resolve(true);
|
||||||
|
const pending = process.waitForOutputClose(1_000, signal).then((closed) => {
|
||||||
|
if (this.#process !== process) return;
|
||||||
|
if (!closed) this.#outputDrainIncomplete = true;
|
||||||
|
const snapshot = process.snapshot();
|
||||||
|
this.#processSnapshot = this.#outputDrainIncomplete
|
||||||
|
? { ...snapshot, diagnosticTruncated: true, outputIncomplete: true }
|
||||||
|
: snapshot;
|
||||||
|
return closed;
|
||||||
|
});
|
||||||
|
this.#outputDrainPromise = pending.then((closed) => closed ?? false);
|
||||||
|
return this.#outputDrainPromise;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { expect, it } from "vitest";
|
||||||
|
import { createGhidraDiagnostics } from "./GhidraDiagnostics.js";
|
||||||
|
|
||||||
|
it("does not reuse historical operation-scoped failure output without a current owner", () => {
|
||||||
|
const diagnostics = createGhidraDiagnostics({
|
||||||
|
targetPath: "/tmp/target",
|
||||||
|
targetSha256: "a".repeat(64),
|
||||||
|
transport: "unix-socket",
|
||||||
|
providerVersion: "11.4",
|
||||||
|
profileDigest: "b".repeat(64),
|
||||||
|
previous: {
|
||||||
|
stdout: { text: "historical OOME" },
|
||||||
|
failure_output: {
|
||||||
|
stdout: { text: "historical OOME" },
|
||||||
|
stderr: { text: "" },
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(diagnostics.stdout).toEqual({ text: "historical OOME" });
|
||||||
|
expect(diagnostics).not.toHaveProperty("failure_output");
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ export interface GhidraDiagnosticsOptions {
|
|||||||
readonly snapshot?: ProviderProcessSnapshot;
|
readonly snapshot?: ProviderProcessSnapshot;
|
||||||
readonly token?: string;
|
readonly token?: string;
|
||||||
readonly previous: Readonly<Record<string, JsonValue>>;
|
readonly previous: Readonly<Record<string, JsonValue>>;
|
||||||
|
readonly failure_output?: JsonValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Preserve actionable runtime coordinates after cleanup and redact bearer data. */
|
/** Preserve actionable runtime coordinates after cleanup and redact bearer data. */
|
||||||
@@ -33,8 +34,11 @@ export const createGhidraDiagnostics = (
|
|||||||
options.runtimeRoot === undefined &&
|
options.runtimeRoot === undefined &&
|
||||||
options.launch === undefined &&
|
options.launch === undefined &&
|
||||||
Object.keys(options.previous).length > 0
|
Object.keys(options.previous).length > 0
|
||||||
)
|
) {
|
||||||
return options.previous;
|
const history: Record<string, JsonValue> = { ...options.previous };
|
||||||
|
delete history.failure_output;
|
||||||
|
return history;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
target_path: options.targetPath,
|
target_path: options.targetPath,
|
||||||
target_sha256: options.targetSha256,
|
target_sha256: options.targetSha256,
|
||||||
@@ -69,19 +73,32 @@ export const createGhidraDiagnostics = (
|
|||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
...(options.launch?.configuredMaxHeap === undefined
|
||||||
|
? {}
|
||||||
|
: { configured_jvm_max_heap: options.launch.configuredMaxHeap }),
|
||||||
...(options.snapshot === undefined
|
...(options.snapshot === undefined
|
||||||
? {}
|
? {}
|
||||||
: {
|
: {
|
||||||
exit_code: options.snapshot.exitCode ?? null,
|
exit_code: options.snapshot.exitCode ?? null,
|
||||||
exit_signal: options.snapshot.signal ?? null,
|
exit_signal: options.snapshot.signal ?? null,
|
||||||
diagnostic_truncated: options.snapshot.diagnosticTruncated === true,
|
diagnostic_truncated: options.snapshot.diagnosticTruncated === true,
|
||||||
stdout: boundedStream(options.snapshot.stdout, options.token),
|
output_incomplete: options.snapshot.outputIncomplete === true,
|
||||||
stderr: boundedStream(options.snapshot.stderr, options.token),
|
stdout: ghidraDiagnosticStream(
|
||||||
|
options.snapshot.stdout,
|
||||||
|
options.token,
|
||||||
|
),
|
||||||
|
stderr: ghidraDiagnosticStream(
|
||||||
|
options.snapshot.stderr,
|
||||||
|
options.token,
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
|
...(options.failure_output === undefined
|
||||||
|
? {}
|
||||||
|
: { failure_output: options.failure_output }),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const boundedStream = (
|
export const ghidraDiagnosticStream = (
|
||||||
stream: ProviderProcessSnapshot["stdout"],
|
stream: ProviderProcessSnapshot["stdout"],
|
||||||
token: string | undefined,
|
token: string | undefined,
|
||||||
): JsonValue => ({
|
): JsonValue => ({
|
||||||
@@ -89,4 +106,5 @@ const boundedStream = (
|
|||||||
// Preserve the historical total-byte counter; text is now bounded separately.
|
// Preserve the historical total-byte counter; text is now bounded separately.
|
||||||
bytes: stream.observedBytes,
|
bytes: stream.observedBytes,
|
||||||
retained_bytes: stream.bytes,
|
retained_bytes: stream.bytes,
|
||||||
|
...(stream.invalidUtf8 === true ? { invalid_utf8: true } : {}),
|
||||||
});
|
});
|
||||||
@@ -83,6 +83,8 @@ export const ghidraJavaLaunch = async (options: {
|
|||||||
readonly command: string;
|
readonly command: string;
|
||||||
readonly arguments: readonly string[];
|
readonly arguments: readonly string[];
|
||||||
readonly environment: NodeJS.ProcessEnv;
|
readonly environment: NodeJS.ProcessEnv;
|
||||||
|
/** REA-configured -Xmx value, not effective or observed JVM memory. */
|
||||||
|
readonly configuredMaxHeap?: string;
|
||||||
}> => {
|
}> => {
|
||||||
const supportRoot = dirname(options.analyzeHeadlessPath);
|
const supportRoot = dirname(options.analyzeHeadlessPath);
|
||||||
const installRoot = dirname(supportRoot);
|
const installRoot = dirname(supportRoot);
|
||||||
@@ -146,6 +148,8 @@ export const ghidraJavaLaunch = async (options: {
|
|||||||
if ((environment[key] ?? "") === "")
|
if ((environment[key] ?? "") === "")
|
||||||
environment[key] = line.slice(separator + 1);
|
environment[key] = line.slice(separator + 1);
|
||||||
}
|
}
|
||||||
|
const configuredMaxHeap =
|
||||||
|
environment.GHIDRA_HEADLESS_MAXMEM || environment.GHIDRA_MAXMEM || "2G";
|
||||||
// The merged environment becomes the analysis JVM's environment, and the
|
// The merged environment becomes the analysis JVM's environment, and the
|
||||||
// JVM applies these options variables automatically. An injected
|
// JVM applies these options variables automatically. An injected
|
||||||
// -Dos.arch/-Dos.name there selects an unvalidated native directory without
|
// -Dos.arch/-Dos.name there selects an unvalidated native directory without
|
||||||
@@ -172,10 +176,11 @@ export const ghidraJavaLaunch = async (options: {
|
|||||||
return {
|
return {
|
||||||
command,
|
command,
|
||||||
environment,
|
environment,
|
||||||
|
configuredMaxHeap,
|
||||||
arguments: [
|
arguments: [
|
||||||
...vmArguments,
|
...vmArguments,
|
||||||
...(options.platform === "darwin" ? ["-Xdock:name=Ghidra-Headless"] : []),
|
...(options.platform === "darwin" ? ["-Xdock:name=Ghidra-Headless"] : []),
|
||||||
`-Xmx${environment.GHIDRA_HEADLESS_MAXMEM || environment.GHIDRA_MAXMEM || "2G"}`,
|
`-Xmx${configuredMaxHeap}`,
|
||||||
"-XX:ParallelGCThreads=2",
|
"-XX:ParallelGCThreads=2",
|
||||||
"-XX:CICompilerCount=2",
|
"-XX:CICompilerCount=2",
|
||||||
"-Djava.awt.headless=true",
|
"-Djava.awt.headless=true",
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export type GhidraLaunch = ProviderProcessLaunch & {
|
|||||||
readonly projectRoot: string;
|
readonly projectRoot: string;
|
||||||
readonly ghidraLogPath: string;
|
readonly ghidraLogPath: string;
|
||||||
readonly scriptLogPath: string;
|
readonly scriptLogPath: string;
|
||||||
|
/** Value supplied to the JVM as -Xmx, not a measurement of effective memory. */
|
||||||
|
readonly configuredMaxHeap?: string;
|
||||||
readonly launchSupport?: readonly GhidraLaunchSupportDiagnostic[];
|
readonly launchSupport?: readonly GhidraLaunchSupportDiagnostic[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,6 +197,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
|||||||
const paths = ghidraRuntimePaths(session.runtimeRoot);
|
const paths = ghidraRuntimePaths(session.runtimeRoot);
|
||||||
const platform = this.options.platform ?? process.platform;
|
const platform = this.options.platform ?? process.platform;
|
||||||
let started: SpawnedOwnedProviderProcess | undefined;
|
let started: SpawnedOwnedProviderProcess | undefined;
|
||||||
|
let configuredMaxHeap: string | undefined;
|
||||||
const launchSupport: GhidraLaunchSupportDiagnostic[] = [];
|
const launchSupport: GhidraLaunchSupportDiagnostic[] = [];
|
||||||
try {
|
try {
|
||||||
const psp = this.options.pspExtension;
|
const psp = this.options.pspExtension;
|
||||||
@@ -341,6 +344,11 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
|||||||
},
|
},
|
||||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||||
});
|
});
|
||||||
|
// Windows starts the script wrapper rather than this inspected JVM command.
|
||||||
|
configuredMaxHeap =
|
||||||
|
platform === "win32"
|
||||||
|
? undefined
|
||||||
|
: inspectedJavaLaunch.configuredMaxHeap;
|
||||||
const command =
|
const command =
|
||||||
platform === "win32"
|
platform === "win32"
|
||||||
? { ...scriptCommand, environment }
|
? { ...scriptCommand, environment }
|
||||||
@@ -363,6 +371,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
|||||||
paths,
|
paths,
|
||||||
platform,
|
platform,
|
||||||
launchSupport,
|
launchSupport,
|
||||||
|
configuredMaxHeap,
|
||||||
);
|
);
|
||||||
await writeGhidraRuntimeFile(
|
await writeGhidraRuntimeFile(
|
||||||
paths.ownershipPath,
|
paths.ownershipPath,
|
||||||
@@ -416,6 +425,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
|||||||
paths,
|
paths,
|
||||||
platform,
|
platform,
|
||||||
launchSupport,
|
launchSupport,
|
||||||
|
configuredMaxHeap,
|
||||||
);
|
);
|
||||||
const primaryMessage = isAborted(options.signal)
|
const primaryMessage = isAborted(options.signal)
|
||||||
? primary.message
|
? primary.message
|
||||||
@@ -446,11 +456,13 @@ const ownedGhidraLaunch = (
|
|||||||
paths: ReturnType<typeof ghidraRuntimePaths>,
|
paths: ReturnType<typeof ghidraRuntimePaths>,
|
||||||
platform: NodeJS.Platform,
|
platform: NodeJS.Platform,
|
||||||
launchSupport: readonly GhidraLaunchSupportDiagnostic[],
|
launchSupport: readonly GhidraLaunchSupportDiagnostic[],
|
||||||
|
configuredMaxHeap?: string,
|
||||||
): GhidraLaunch => ({
|
): GhidraLaunch => ({
|
||||||
...ownedGhidraProcess(spawned, platform),
|
...ownedGhidraProcess(spawned, platform),
|
||||||
projectRoot: paths.projectRoot,
|
projectRoot: paths.projectRoot,
|
||||||
ghidraLogPath: paths.ghidraLogPath,
|
ghidraLogPath: paths.ghidraLogPath,
|
||||||
scriptLogPath: paths.scriptLogPath,
|
scriptLogPath: paths.scriptLogPath,
|
||||||
|
...(configuredMaxHeap === undefined ? {} : { configuredMaxHeap }),
|
||||||
...(launchSupport.length === 0 ? {} : { launchSupport: [...launchSupport] }),
|
...(launchSupport.length === 0 ? {} : { launchSupport: [...launchSupport] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
nativeAotPeDigest,
|
nativeAotPeDigest,
|
||||||
nativeAotPeFixture,
|
nativeAotPeFixture,
|
||||||
} from "../../tests/fixtures/nativeaotPe.js";
|
} from "../../tests/fixtures/nativeaotPe.js";
|
||||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||||
|
|
||||||
import { parseConfig } from "../config/parseConfig.js";
|
import { parseConfig } from "../config/parseConfig.js";
|
||||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||||
@@ -2743,3 +2743,203 @@ describe("Ghidra queries after the owned provider is lost", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const output = (text: string) => ({
|
||||||
|
text,
|
||||||
|
bytes: Buffer.byteLength(text),
|
||||||
|
retained_bytes: Buffer.byteLength(text),
|
||||||
|
});
|
||||||
|
const failureOutput = (stdout = "", stderr = "") => ({
|
||||||
|
stdout: output(stdout),
|
||||||
|
stderr: output(stderr),
|
||||||
|
complete: true,
|
||||||
|
});
|
||||||
|
const projectFailure = async (
|
||||||
|
kind: "cancelled" | "process" | "protocol" | "remote" | "timeout",
|
||||||
|
diagnostics: Readonly<Record<string, JsonValue>>,
|
||||||
|
) => {
|
||||||
|
const ghidra = provider(installationHost(), () => ({
|
||||||
|
start: async () =>
|
||||||
|
err(new GhidraSessionError("start", "startup is not exercised")),
|
||||||
|
callTool: async () =>
|
||||||
|
err(
|
||||||
|
new GhidraSessionError(
|
||||||
|
kind,
|
||||||
|
"Ghidra request failed",
|
||||||
|
{ failure_kind: kind, ...diagnostics },
|
||||||
|
kind === "remote" ? { remoteCode: "internal" } : {},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
close: async () => ok(null),
|
||||||
|
}));
|
||||||
|
const target = executableTarget("elf", "x86_64");
|
||||||
|
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||||
|
if (!profile.ok || profile.value.profile === null)
|
||||||
|
throw new Error("Expected Ghidra analysis profile");
|
||||||
|
const result = await ghidra
|
||||||
|
.createClient(target, profile.value.profile)
|
||||||
|
.execute("list_documents", {});
|
||||||
|
if (result.ok) throw new Error("Expected a Ghidra failure");
|
||||||
|
return projectAnalysisError(result.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const issueOutput = [
|
||||||
|
"INFO ANALYZING all memory and code: /hugo (HeadlessAnalyzer)",
|
||||||
|
"ERROR Abort due to Headless analyzer error: Java heap space (HeadlessAnalyzer) java.lang.OutOfMemoryError:",
|
||||||
|
"\tat ghidra.app.plugin.core.analysis.GolangSymbolAnalyzer$PropagateRttiBackgroundCommand.applyTo",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
describe("Ghidra OOME error projection", () => {
|
||||||
|
it("recognizes the headless analyzer's real detail-before-class output", async () => {
|
||||||
|
const projected = await projectFailure("process", {
|
||||||
|
stdout: output(issueOutput),
|
||||||
|
stderr: output(""),
|
||||||
|
failure_output: failureOutput(issueOutput),
|
||||||
|
configured_jvm_max_heap: "4G",
|
||||||
|
});
|
||||||
|
expect(projected).toMatchObject({
|
||||||
|
code: "resource_constraint",
|
||||||
|
details: {
|
||||||
|
resource: "memory",
|
||||||
|
reported_limits: { configured_jvm_max_heap: "4G" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(projected.message).toContain("Java heap space");
|
||||||
|
expect(projected.remediation.action).toContain("GHIDRA_HEADLESS_MAXMEM");
|
||||||
|
expect(projected.remediation.action).toContain(
|
||||||
|
"REA_GHIDRA_DISABLED_ANALYZERS",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains an unknown heap for GC overhead when the launch did not report one", async () => {
|
||||||
|
const projected = await projectFailure("timeout", {
|
||||||
|
stdout: output(""),
|
||||||
|
stderr: output(""),
|
||||||
|
failure_output: failureOutput(
|
||||||
|
"",
|
||||||
|
'Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded',
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(projected).toMatchObject({
|
||||||
|
code: "resource_constraint",
|
||||||
|
details: {
|
||||||
|
resource: "memory",
|
||||||
|
reported_limits: { configured_jvm_max_heap: null },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(projected.remediation.action).toContain("GHIDRA_HEADLESS_MAXMEM");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Metaspace", "java.lang.OutOfMemoryError: Metaspace"],
|
||||||
|
[
|
||||||
|
"array VM limit",
|
||||||
|
"java.lang.OutOfMemoryError: Requested array size exceeds VM limit",
|
||||||
|
],
|
||||||
|
])("does not recommend a larger Java heap for %s", async (_name, text) => {
|
||||||
|
const projected = await projectFailure("process", {
|
||||||
|
stdout: output(""),
|
||||||
|
stderr: output(""),
|
||||||
|
failure_output: failureOutput("", text),
|
||||||
|
configured_jvm_max_heap: "4G",
|
||||||
|
});
|
||||||
|
expect(projected.code).toBe("resource_constraint");
|
||||||
|
expect(projected.message).toContain(text);
|
||||||
|
expect(projected.remediation.action).not.toContain(
|
||||||
|
"GHIDRA_HEADLESS_MAXMEM",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports native-thread exhaustion as capacity without inventing a limit", async () => {
|
||||||
|
const projected = await projectFailure("process", {
|
||||||
|
stdout: output(""),
|
||||||
|
stderr: output(""),
|
||||||
|
failure_output: failureOutput(
|
||||||
|
"",
|
||||||
|
"java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached",
|
||||||
|
),
|
||||||
|
configured_jvm_max_heap: "4G",
|
||||||
|
});
|
||||||
|
expect(projected).toMatchObject({
|
||||||
|
code: "resource_constraint",
|
||||||
|
details: { resource: "capacity", reported_limits: null },
|
||||||
|
});
|
||||||
|
expect(projected.remediation.action).not.toContain(
|
||||||
|
"GHIDRA_HEADLESS_MAXMEM",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a bare OOME class at unknown subtype without heap advice", async () => {
|
||||||
|
const projected = await projectFailure("process", {
|
||||||
|
failure_output: failureOutput("", "java.lang.OutOfMemoryError"),
|
||||||
|
});
|
||||||
|
expect(projected).toMatchObject({
|
||||||
|
code: "resource_constraint",
|
||||||
|
details: { resource: "memory", reported_limits: null },
|
||||||
|
});
|
||||||
|
expect(projected.remediation.action).not.toContain(
|
||||||
|
"GHIDRA_HEADLESS_MAXMEM",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reclassify a later failure from OOME retained after an earlier success", async () => {
|
||||||
|
let calls = 0;
|
||||||
|
const ghidra = provider(installationHost(), () => ({
|
||||||
|
start: async () =>
|
||||||
|
err(new GhidraSessionError("start", "startup is not exercised")),
|
||||||
|
callTool: async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls === 1) return ok(["fixture"]);
|
||||||
|
return err(
|
||||||
|
new GhidraSessionError(
|
||||||
|
"protocol",
|
||||||
|
"Ghidra returned an invalid later response",
|
||||||
|
{
|
||||||
|
stdout: output(issueOutput),
|
||||||
|
stderr: output(""),
|
||||||
|
// The OOME remains visible in full process history above, but
|
||||||
|
// the current operation produced no such error.
|
||||||
|
failure_output: failureOutput(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
close: async () => ok(null),
|
||||||
|
}));
|
||||||
|
const target = executableTarget("elf", "x86_64");
|
||||||
|
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||||
|
if (!profile.ok || profile.value.profile === null)
|
||||||
|
throw new Error("Expected Ghidra analysis profile");
|
||||||
|
const client = ghidra.createClient(target, profile.value.profile);
|
||||||
|
const earlier = await client.execute("list_documents", {});
|
||||||
|
expect(earlier.ok).toBe(true);
|
||||||
|
const later = await client.execute("list_procedures", {});
|
||||||
|
expect(later.ok).toBe(false);
|
||||||
|
if (later.ok) return;
|
||||||
|
const projected = projectAnalysisError(later.error);
|
||||||
|
expect(projected.code).toBe("execution_failure");
|
||||||
|
expect(projected.details?.diagnostics).toMatchObject({
|
||||||
|
stdout: { text: issueOutput },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps cancellation primary even when the current operation reports an OOME", async () => {
|
||||||
|
const projected = await projectFailure("cancelled", {
|
||||||
|
stdout: output(""),
|
||||||
|
stderr: output(""),
|
||||||
|
failure_output: failureOutput(issueOutput),
|
||||||
|
configured_jvm_max_heap: "4G",
|
||||||
|
});
|
||||||
|
expect(projected.code).toBe("cancelled");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not reinterpret a bridge-reported OOME as a local process failure", async () => {
|
||||||
|
const projected = await projectFailure("remote", {
|
||||||
|
stdout: output(""),
|
||||||
|
stderr: output(""),
|
||||||
|
failure_output: failureOutput(issueOutput),
|
||||||
|
configured_jvm_max_heap: "4G",
|
||||||
|
});
|
||||||
|
expect(projected.code).not.toBe("resource_constraint");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1092,7 +1092,7 @@ const projectPrimarySessionError = (
|
|||||||
(failure.kind === "remote" && failure.remoteCode === "decompile_cancelled")
|
(failure.kind === "remote" && failure.remoteCode === "decompile_cancelled")
|
||||||
)
|
)
|
||||||
return new AnalysisCancelledError(operation, context);
|
return new AnalysisCancelledError(operation, context);
|
||||||
if (failure.kind === "timeout" || failure.kind === "analysis_timeout")
|
if (failure.kind === "analysis_timeout")
|
||||||
return new AnalysisTimeoutError(
|
return new AnalysisTimeoutError(
|
||||||
operation,
|
operation,
|
||||||
failure.timeoutMs ?? startupTimeoutMs,
|
failure.timeoutMs ?? startupTimeoutMs,
|
||||||
@@ -1129,6 +1129,18 @@ const projectPrimarySessionError = (
|
|||||||
failure.message,
|
failure.message,
|
||||||
context,
|
context,
|
||||||
);
|
);
|
||||||
|
const memoryFailure = projectObservedJvmMemoryFailure(
|
||||||
|
operation,
|
||||||
|
failure,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
if (memoryFailure !== undefined) return memoryFailure;
|
||||||
|
if (failure.kind === "timeout")
|
||||||
|
return new AnalysisTimeoutError(
|
||||||
|
operation,
|
||||||
|
failure.timeoutMs ?? startupTimeoutMs,
|
||||||
|
context,
|
||||||
|
);
|
||||||
const exited = exitedProviderMessage(operation, failure, health);
|
const exited = exitedProviderMessage(operation, failure, health);
|
||||||
if (exited !== undefined)
|
if (exited !== undefined)
|
||||||
return new ProviderAdapterError("ghidra", operation, {
|
return new ProviderAdapterError("ghidra", operation, {
|
||||||
@@ -1138,6 +1150,90 @@ const projectPrimarySessionError = (
|
|||||||
return new ProviderAdapterError("ghidra", operation, context);
|
return new ProviderAdapterError("ghidra", operation, context);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Project only OOME evidence captured during the operation that failed. */
|
||||||
|
const projectObservedJvmMemoryFailure = (
|
||||||
|
operation: AnalysisOperation,
|
||||||
|
failure: GhidraSessionError,
|
||||||
|
context: {
|
||||||
|
readonly cause: GhidraSessionError;
|
||||||
|
readonly diagnostics: Readonly<Record<string, JsonValue>>;
|
||||||
|
},
|
||||||
|
): AnalysisResourceConstraintError | undefined => {
|
||||||
|
if (
|
||||||
|
failure.kind !== "start" &&
|
||||||
|
failure.kind !== "process" &&
|
||||||
|
failure.kind !== "protocol" &&
|
||||||
|
failure.kind !== "timeout"
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
const scoped = failure.diagnostics.failure_output;
|
||||||
|
if (
|
||||||
|
scoped === null ||
|
||||||
|
typeof scoped !== "object" ||
|
||||||
|
Array.isArray(scoped) ||
|
||||||
|
typeof scoped.stdout !== "object" ||
|
||||||
|
scoped.stdout === null ||
|
||||||
|
typeof scoped.stderr !== "object" ||
|
||||||
|
scoped.stderr === null
|
||||||
|
)
|
||||||
|
return undefined;
|
||||||
|
const streamText = (value: JsonValue): string =>
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
!Array.isArray(value) &&
|
||||||
|
typeof value.text === "string"
|
||||||
|
? value.text
|
||||||
|
: "";
|
||||||
|
const output = `${streamText(scoped.stdout)}\n${streamText(scoped.stderr)}`;
|
||||||
|
if (!/java\.lang\.OutOfMemoryError(?:\s*:|\b)/u.test(output))
|
||||||
|
return undefined;
|
||||||
|
const threadCapacity = /unable to create native thread/iu.test(output);
|
||||||
|
const heapExhaustion = /Java heap space|GC overhead limit exceeded/iu.test(
|
||||||
|
output,
|
||||||
|
);
|
||||||
|
const detail = [
|
||||||
|
"Java heap space",
|
||||||
|
"GC overhead limit exceeded",
|
||||||
|
"Metaspace",
|
||||||
|
"Requested array size exceeds VM limit",
|
||||||
|
"unable to create native thread",
|
||||||
|
].find((candidate) => output.includes(candidate));
|
||||||
|
if (threadCapacity)
|
||||||
|
return new AnalysisResourceConstraintError(
|
||||||
|
operation,
|
||||||
|
"capacity",
|
||||||
|
"Ghidra's process output reports an OutOfMemoryError while creating a native thread. This is observed capacity evidence; it does not establish a process limit or the precise cause.",
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
remediationAction:
|
||||||
|
"Reduce concurrent analysis processes or review the host's available thread and process capacity, then retry.",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const configuredHeap = failure.diagnostics.configured_jvm_max_heap;
|
||||||
|
const reportedLimits = heapExhaustion
|
||||||
|
? {
|
||||||
|
configured_jvm_max_heap:
|
||||||
|
typeof configuredHeap === "string" ? configuredHeap : null,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return new AnalysisResourceConstraintError(
|
||||||
|
operation,
|
||||||
|
"memory",
|
||||||
|
`Ghidra's operation-scoped process output reports java.lang.OutOfMemoryError${detail === undefined ? "" : `: ${detail}`}. This is reported evidence and does not prove the exact allocation failure.`,
|
||||||
|
reportedLimits,
|
||||||
|
{
|
||||||
|
...context,
|
||||||
|
...(heapExhaustion
|
||||||
|
? {
|
||||||
|
remediationAction:
|
||||||
|
"If the host has available memory, consider increasing GHIDRA_HEADLESS_MAXMEM or disabling expensive analyzers with REA_GHIDRA_DISABLED_ANALYZERS, then retry.",
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/** A provider already observed as running can be restarted by reopening. */
|
/** A provider already observed as running can be restarted by reopening. */
|
||||||
const exitedProviderMessage = (
|
const exitedProviderMessage = (
|
||||||
operation: AnalysisOperation,
|
operation: AnalysisOperation,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { err, ok } from "../domain/result.js";
|
import { err, ok } from "../domain/result.js";
|
||||||
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||||
import { GhidraRequestQueue } from "./GhidraRequestQueue.js";
|
import { GhidraRequestQueue } from "./GhidraRequestQueue.js";
|
||||||
import { bindGhidraSessionFailure } from "./GhidraSessionError.js";
|
import { bindGhidraSessionFailure } from "./GhidraSessionError.js";
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ describe("Ghidra request queue failure settlement", () => {
|
|||||||
return ok({ method });
|
return ok({ method });
|
||||||
},
|
},
|
||||||
failure,
|
failure,
|
||||||
() => Promise.resolve(),
|
() => Promise.resolve(ok(null)),
|
||||||
);
|
);
|
||||||
const first = queue.run("first", {}, {});
|
const first = queue.run("first", {}, {});
|
||||||
const second = queue.run("second", {}, {});
|
const second = queue.run("second", {}, {});
|
||||||
@@ -46,16 +47,24 @@ describe("Ghidra request queue failure settlement", () => {
|
|||||||
const queue = new GhidraRequestQueue(
|
const queue = new GhidraRequestQueue(
|
||||||
() => Promise.resolve(err(original)),
|
() => Promise.resolve(err(original)),
|
||||||
failure,
|
failure,
|
||||||
() => Promise.resolve(),
|
() => Promise.resolve(ok(null)),
|
||||||
);
|
);
|
||||||
const result = await queue.run("first", {}, {});
|
const result = await queue.run("first", {}, {});
|
||||||
expect(result).toEqual(err(original));
|
expect(result).toEqual(err(original));
|
||||||
if (!result.ok) expect(result.error).toBe(original);
|
if (!result.ok) expect(result.error).toBe(original);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Ghidra request queue cancellation", () => {
|
||||||
it("preserves cancellation cleanup failures and rejects queued work", async () => {
|
it("preserves cancellation cleanup failures and rejects queued work", async () => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const cause = new Error("Runtime removal failed");
|
const cause = new Error("Runtime removal failed");
|
||||||
|
const cleanupFailure = new ProviderCleanupError(
|
||||||
|
"ghidra",
|
||||||
|
["/tmp/runtime"],
|
||||||
|
{ reason: cause.message },
|
||||||
|
{ cause },
|
||||||
|
);
|
||||||
let release: (() => void) | undefined;
|
let release: (() => void) | undefined;
|
||||||
const completed = new Promise<void>((resolve) => {
|
const completed = new Promise<void>((resolve) => {
|
||||||
release = resolve;
|
release = resolve;
|
||||||
@@ -66,9 +75,7 @@ describe("Ghidra request queue failure settlement", () => {
|
|||||||
return err(failure("cancelled", "Request cancelled"));
|
return err(failure("cancelled", "Request cancelled"));
|
||||||
},
|
},
|
||||||
failure,
|
failure,
|
||||||
async () => {
|
() => Promise.resolve(err(cleanupFailure)),
|
||||||
throw cause;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
const active = queue.run("active", {}, { signal: controller.signal });
|
const active = queue.run("active", {}, { signal: controller.signal });
|
||||||
const queued = queue.run("queued", {}, {});
|
const queued = queue.run("queued", {}, {});
|
||||||
@@ -78,15 +85,88 @@ describe("Ghidra request queue failure settlement", () => {
|
|||||||
expect(result).toMatchObject({
|
expect(result).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: {
|
error: {
|
||||||
kind: "process",
|
kind: "cancelled",
|
||||||
diagnostics: { failure_cause: { message: cause.message } },
|
message: "Request cancelled",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (result.ok) throw new Error("Expected cleanup failure");
|
if (result.ok) throw new Error("Expected cancellation");
|
||||||
expect(result.error.cause).toBe(cause);
|
expect(result.error.cleanupFailure).toBe(cleanupFailure);
|
||||||
await expect(queued).resolves.toMatchObject({
|
await expect(queued).resolves.toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { kind: "process" },
|
error: { kind: "process" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
cause: new Error("Unexpected cleanup rejection"),
|
||||||
|
diagnostic: { message: "Unexpected cleanup rejection" },
|
||||||
|
},
|
||||||
|
{ cause: undefined, diagnostic: { type: "undefined" } },
|
||||||
|
])(
|
||||||
|
"keeps cancellation primary when cleanup rejects with $diagnostic",
|
||||||
|
async ({ cause, diagnostic }) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const cancellationCause = new Error("Caller cancelled");
|
||||||
|
const original = failure(
|
||||||
|
"cancelled",
|
||||||
|
"Request cancelled",
|
||||||
|
cancellationCause,
|
||||||
|
);
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
const completed = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const queue = new GhidraRequestQueue(
|
||||||
|
async () => {
|
||||||
|
await completed;
|
||||||
|
return err(original);
|
||||||
|
},
|
||||||
|
failure,
|
||||||
|
async () => {
|
||||||
|
throw cause;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const active = queue.run("active", {}, { signal: controller.signal });
|
||||||
|
controller.abort();
|
||||||
|
release?.();
|
||||||
|
|
||||||
|
const result = await active;
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
kind: "cancelled",
|
||||||
|
message: "Request cancelled",
|
||||||
|
diagnostics: {
|
||||||
|
unexpected_cleanup_rejection: diagnostic,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (result.ok) throw new Error("Expected cancellation");
|
||||||
|
expect(result.error.cause).toBe(cancellationCause);
|
||||||
|
expect(result.error.cleanupFailure).toBeUndefined();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("preserves the cancellation result when cleanup succeeds", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const original = failure("cancelled", "Request cancelled");
|
||||||
|
let release: (() => void) | undefined;
|
||||||
|
const completed = new Promise<void>((resolve) => {
|
||||||
|
release = resolve;
|
||||||
|
});
|
||||||
|
const queue = new GhidraRequestQueue(
|
||||||
|
async () => {
|
||||||
|
await completed;
|
||||||
|
return err(original);
|
||||||
|
},
|
||||||
|
failure,
|
||||||
|
() => Promise.resolve(ok(null)),
|
||||||
|
);
|
||||||
|
const active = queue.run("active", {}, { signal: controller.signal });
|
||||||
|
controller.abort();
|
||||||
|
release?.();
|
||||||
|
|
||||||
|
await expect(active).resolves.toEqual(err(original));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||||
import type { JsonValue } from "../domain/jsonValue.js";
|
import type { JsonValue } from "../domain/jsonValue.js";
|
||||||
import { err, type Result } from "../domain/result.js";
|
import { err, type Result } from "../domain/result.js";
|
||||||
import type { GhidraRequestOptions } from "./GhidraClientTypes.js";
|
import type { GhidraRequestOptions } from "./GhidraClientTypes.js";
|
||||||
import type {
|
import {
|
||||||
GhidraSessionError,
|
GhidraSessionError,
|
||||||
GhidraSessionFailureKind,
|
type GhidraSessionFailureKind,
|
||||||
} from "./GhidraSessionError.js";
|
} from "./GhidraSessionError.js";
|
||||||
|
|
||||||
/** Result settled by one queued Ghidra Program request. */
|
/** Result settled by one queued Ghidra Program request. */
|
||||||
@@ -22,7 +23,9 @@ export type GhidraQueueFailureFactory = (
|
|||||||
) => GhidraSessionError;
|
) => GhidraSessionError;
|
||||||
|
|
||||||
/** Stop the provider session when an admitted operation is cancelled. */
|
/** Stop the provider session when an admitted operation is cancelled. */
|
||||||
export type GhidraActiveCancellationHandler = () => Promise<void>;
|
export type GhidraActiveCancellationHandler = () => Promise<
|
||||||
|
Result<null, AnalysisError>
|
||||||
|
>;
|
||||||
|
|
||||||
interface QueuedRequest {
|
interface QueuedRequest {
|
||||||
readonly method: string;
|
readonly method: string;
|
||||||
@@ -108,7 +111,7 @@ export class GhidraRequestQueue {
|
|||||||
}
|
}
|
||||||
this.#active = true;
|
this.#active = true;
|
||||||
let executionSettled = false;
|
let executionSettled = false;
|
||||||
let activeCancellation: Promise<void> | undefined;
|
let activeCancellation: Promise<Result<null, AnalysisError>> | undefined;
|
||||||
const onAbort =
|
const onAbort =
|
||||||
entry.signal === undefined
|
entry.signal === undefined
|
||||||
? undefined
|
? undefined
|
||||||
@@ -131,18 +134,15 @@ export class GhidraRequestQueue {
|
|||||||
)
|
)
|
||||||
.then(async (result) => {
|
.then(async (result) => {
|
||||||
if (activeCancellation !== undefined) {
|
if (activeCancellation !== undefined) {
|
||||||
|
const primary = result.ok
|
||||||
|
? this.failure("cancelled", "Ghidra request was cancelled")
|
||||||
|
: result.error;
|
||||||
try {
|
try {
|
||||||
await activeCancellation;
|
const cleanup = await activeCancellation;
|
||||||
|
if (!cleanup.ok)
|
||||||
|
result = err(this.#withCleanupFailure(primary, cleanup.error));
|
||||||
} catch (cause: unknown) {
|
} catch (cause: unknown) {
|
||||||
result = err(
|
result = err(this.#withUnexpectedCleanupRejection(primary, cause));
|
||||||
this.failure(
|
|
||||||
"process",
|
|
||||||
cause instanceof Error
|
|
||||||
? `Ghidra cleanup after cancellation failed: ${cause.message}`
|
|
||||||
: "Ghidra cleanup after cancellation failed",
|
|
||||||
cause,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
executionSettled = true;
|
executionSettled = true;
|
||||||
@@ -176,6 +176,52 @@ export class GhidraRequestQueue {
|
|||||||
entry.resolve(result);
|
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,
|
||||||
|
{
|
||||||
|
...primary.diagnostics,
|
||||||
|
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 }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#release(entry: QueuedRequest): void {
|
#release(entry: QueuedRequest): void {
|
||||||
if (entry.signal !== undefined && entry.onAbort !== undefined)
|
if (entry.signal !== undefined && entry.onAbort !== undefined)
|
||||||
entry.signal.removeEventListener("abort", entry.onAbort);
|
entry.signal.removeEventListener("abort", entry.onAbort);
|
||||||
|
|||||||
@@ -126,6 +126,28 @@ export interface ProviderProcessSnapshot {
|
|||||||
readonly outputIncomplete?: true;
|
readonly outputIncomplete?: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare const processOutputCheckpointBrand: unique symbol;
|
||||||
|
/** Opaque cursor into one supervisor's raw, byte-counted output history. */
|
||||||
|
export interface ProviderProcessOutputCheckpoint {
|
||||||
|
readonly [processOutputCheckpointBrand]: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Output produced after a checkpoint; incomplete when bytes were not retained or drained. */
|
||||||
|
export interface ProviderProcessOutputSince {
|
||||||
|
readonly snapshot: ProviderProcessSnapshot;
|
||||||
|
readonly complete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const outputCheckpoints = new WeakMap<
|
||||||
|
ProviderProcessOutputCheckpoint,
|
||||||
|
{
|
||||||
|
readonly owner: ProviderProcessSupervisor;
|
||||||
|
readonly epoch: number;
|
||||||
|
readonly stdout: number;
|
||||||
|
readonly stderr: number;
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
/** Resource-safe events emitted without imposing provider protocol semantics. */
|
/** Resource-safe events emitted without imposing provider protocol semantics. */
|
||||||
export type ProviderProcessDiagnostic =
|
export type ProviderProcessDiagnostic =
|
||||||
| {
|
| {
|
||||||
@@ -313,6 +335,7 @@ export class ProviderProcessSupervisor {
|
|||||||
#stopPromise: Promise<ProviderProcessStopResult> | undefined;
|
#stopPromise: Promise<ProviderProcessStopResult> | undefined;
|
||||||
#disposed = false;
|
#disposed = false;
|
||||||
#maxDiagnosticBytes: number | undefined;
|
#maxDiagnosticBytes: number | undefined;
|
||||||
|
#outputEpoch = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
readonly launch: ProviderProcessLaunch,
|
readonly launch: ProviderProcessLaunch,
|
||||||
@@ -358,8 +381,75 @@ export class ProviderProcessSupervisor {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mark the current raw-byte position without copying or retaining output. */
|
||||||
|
checkpointOutput(): ProviderProcessOutputCheckpoint {
|
||||||
|
const checkpoint = Object.freeze({}) as ProviderProcessOutputCheckpoint;
|
||||||
|
outputCheckpoints.set(checkpoint, {
|
||||||
|
owner: this,
|
||||||
|
epoch: this.#outputEpoch,
|
||||||
|
stdout: this.#stdoutObservedBytes,
|
||||||
|
stderr: this.#stderrObservedBytes,
|
||||||
|
});
|
||||||
|
return checkpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot only bytes produced after a valid checkpoint on this supervisor. */
|
||||||
|
snapshotOutputSince(
|
||||||
|
checkpoint: ProviderProcessOutputCheckpoint,
|
||||||
|
): ProviderProcessOutputSince {
|
||||||
|
const position =
|
||||||
|
typeof checkpoint === "object" && checkpoint !== null
|
||||||
|
? outputCheckpoints.get(checkpoint)
|
||||||
|
: undefined;
|
||||||
|
if (
|
||||||
|
position === undefined ||
|
||||||
|
position.owner !== this ||
|
||||||
|
position.epoch !== this.#outputEpoch
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
snapshot: {
|
||||||
|
stdout: { text: "", bytes: 0, observedBytes: 0 },
|
||||||
|
stderr: { text: "", bytes: 0, observedBytes: 0 },
|
||||||
|
exitCode: this.#exitObservation?.code,
|
||||||
|
signal: this.#exitObservation?.signal,
|
||||||
|
outputIncomplete: true,
|
||||||
|
diagnosticTruncated: true,
|
||||||
|
},
|
||||||
|
complete: false,
|
||||||
|
};
|
||||||
|
const stdout = this.#stdout.snapshotRange(
|
||||||
|
position.stdout,
|
||||||
|
this.#stdoutObservedBytes,
|
||||||
|
);
|
||||||
|
const stderr = this.#stderr.snapshotRange(
|
||||||
|
position.stderr,
|
||||||
|
this.#stderrObservedBytes,
|
||||||
|
);
|
||||||
|
const incomplete =
|
||||||
|
stdout.complete === false ||
|
||||||
|
stderr.complete === false ||
|
||||||
|
this.launch.process.outputIncomplete === true;
|
||||||
|
return {
|
||||||
|
snapshot: {
|
||||||
|
...(incomplete || this.#diagnosticTruncated
|
||||||
|
? { diagnosticTruncated: true }
|
||||||
|
: {}),
|
||||||
|
stdout: stdout.stream,
|
||||||
|
stderr: stderr.stream,
|
||||||
|
exitCode: this.#exitObservation?.code,
|
||||||
|
signal: this.#exitObservation?.signal,
|
||||||
|
...(this.launch.process.memoryLimitReached === true
|
||||||
|
? { memoryLimitReached: true }
|
||||||
|
: {}),
|
||||||
|
...(incomplete ? { outputIncomplete: true } : {}),
|
||||||
|
},
|
||||||
|
complete: !incomplete && !this.#diagnosticTruncated,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Begin a new observation on a retained process without retaining earlier output. */
|
/** Begin a new observation on a retained process without retaining earlier output. */
|
||||||
resetOutput(): void {
|
resetOutput(): void {
|
||||||
|
this.#outputEpoch += 1;
|
||||||
this.#stdout.reset();
|
this.#stdout.reset();
|
||||||
this.#stderr.reset();
|
this.#stderr.reset();
|
||||||
this.#stdoutObservedBytes = 0;
|
this.#stdoutObservedBytes = 0;
|
||||||
@@ -377,11 +467,15 @@ export class ProviderProcessSupervisor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Wait until output streams close; WindowsOwnedProcess closes only after its whole Job Object settles. */
|
/** Wait until output streams close; WindowsOwnedProcess closes only after its whole Job Object settles. */
|
||||||
async waitForOutputClose(timeoutMs: number): Promise<boolean> {
|
async waitForOutputClose(
|
||||||
|
timeoutMs: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<boolean> {
|
||||||
return waitForProcessEvent(
|
return waitForProcessEvent(
|
||||||
this.#outputClosed,
|
this.#outputClosed,
|
||||||
this.#outputClose.promise,
|
this.#outputClose.promise,
|
||||||
timeoutMs,
|
timeoutMs,
|
||||||
|
signal,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -627,7 +721,39 @@ class ProcessOutputCapture {
|
|||||||
}
|
}
|
||||||
|
|
||||||
snapshot(observedBytes: number): ProviderProcessSnapshot["stdout"] {
|
snapshot(observedBytes: number): ProviderProcessSnapshot["stdout"] {
|
||||||
const buffer = Buffer.concat(this.#chunks, this.#bytes);
|
return this.#snapshotBuffer(
|
||||||
|
Buffer.concat(this.#chunks, this.#bytes),
|
||||||
|
observedBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotRange(
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
): { stream: ProviderProcessSnapshot["stdout"]; complete: boolean } {
|
||||||
|
const availableEnd = Math.min(end, this.#bytes);
|
||||||
|
const availableStart = Math.min(start, availableEnd);
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of this.#chunks) {
|
||||||
|
const chunkEnd = offset + chunk.byteLength;
|
||||||
|
const from = Math.max(availableStart, offset);
|
||||||
|
const to = Math.min(availableEnd, chunkEnd);
|
||||||
|
if (to > from) chunks.push(chunk.subarray(from - offset, to - offset));
|
||||||
|
offset = chunkEnd;
|
||||||
|
if (offset >= availableEnd) break;
|
||||||
|
}
|
||||||
|
const buffer = Buffer.concat(chunks, availableEnd - availableStart);
|
||||||
|
return {
|
||||||
|
stream: this.#snapshotBuffer(buffer, Math.max(0, end - start)),
|
||||||
|
complete: start <= this.#bytes && end <= this.#bytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#snapshotBuffer(
|
||||||
|
buffer: Buffer,
|
||||||
|
observedBytes: number,
|
||||||
|
): ProviderProcessSnapshot["stdout"] {
|
||||||
const text = buffer.toString("utf8");
|
const text = buffer.toString("utf8");
|
||||||
let invalidUtf8 = false;
|
let invalidUtf8 = false;
|
||||||
try {
|
try {
|
||||||
@@ -637,7 +763,7 @@ class ProcessOutputCapture {
|
|||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
text,
|
text,
|
||||||
bytes: this.#bytes,
|
bytes: buffer.byteLength,
|
||||||
observedBytes,
|
observedBytes,
|
||||||
...(invalidUtf8 ? { invalidUtf8: true } : {}),
|
...(invalidUtf8 ? { invalidUtf8: true } : {}),
|
||||||
};
|
};
|
||||||
@@ -680,17 +806,25 @@ const waitForProcessEvent = async (
|
|||||||
observed: boolean,
|
observed: boolean,
|
||||||
event: Promise<unknown>,
|
event: Promise<unknown>,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
|
signal?: AbortSignal,
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
if (observed) return true;
|
if (observed) return true;
|
||||||
|
if (signal?.aborted === true) return false;
|
||||||
let timer: NodeJS.Timeout | undefined;
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
let onAbort: (() => void) | undefined;
|
||||||
try {
|
try {
|
||||||
return await Promise.race([
|
return await Promise.race([
|
||||||
event.then(() => true),
|
event.then(() => true),
|
||||||
new Promise<boolean>((resolve) => {
|
new Promise<boolean>((resolve) => {
|
||||||
timer = setTimeout(() => resolve(false), Math.max(0, timeoutMs));
|
timer = setTimeout(() => resolve(false), Math.max(0, timeoutMs));
|
||||||
|
if (signal !== undefined) {
|
||||||
|
onAbort = () => resolve(false);
|
||||||
|
signal.addEventListener("abort", onAbort, { once: true });
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
if (timer !== undefined) clearTimeout(timer);
|
if (timer !== undefined) clearTimeout(timer);
|
||||||
|
if (onAbort !== undefined) signal?.removeEventListener("abort", onAbort);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@ import { EventEmitter } from "node:events";
|
|||||||
import { PassThrough } from "node:stream";
|
import { PassThrough } from "node:stream";
|
||||||
import { expect, it, vi } from "vitest";
|
import { expect, it, vi } from "vitest";
|
||||||
import { ProviderProcessSupervisor } from "./ProviderProcess.js";
|
import { ProviderProcessSupervisor } from "./ProviderProcess.js";
|
||||||
|
import { redactCapturedTransportCredential } from "./ProviderDiagnosticRedaction.js";
|
||||||
|
|
||||||
it("exit does not establish complete output when a producer closes its streams later", async () => {
|
it("exit does not establish complete output when a producer closes its streams later", async () => {
|
||||||
const producer = Object.assign(new EventEmitter(), {
|
const producer = Object.assign(new EventEmitter(), {
|
||||||
@@ -66,6 +67,101 @@ it("reads an observed exit without materializing stream diagnostics", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("scopes output checkpoints to raw bytes on one supervisor and epoch", () => {
|
||||||
|
const first = Object.assign(new EventEmitter(), {
|
||||||
|
stdout: new PassThrough(),
|
||||||
|
stderr: new PassThrough(),
|
||||||
|
exitCode: null,
|
||||||
|
signalCode: null,
|
||||||
|
kill: () => false,
|
||||||
|
});
|
||||||
|
const second = Object.assign(new EventEmitter(), {
|
||||||
|
stdout: new PassThrough(),
|
||||||
|
stderr: new PassThrough(),
|
||||||
|
exitCode: null,
|
||||||
|
signalCode: null,
|
||||||
|
kill: () => false,
|
||||||
|
});
|
||||||
|
const owner = new ProviderProcessSupervisor(
|
||||||
|
{ process: first, ownsProcessLifetime: false },
|
||||||
|
{ maxDiagnosticBytes: 128 },
|
||||||
|
);
|
||||||
|
const other = new ProviderProcessSupervisor({
|
||||||
|
process: second,
|
||||||
|
ownsProcessLifetime: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
first.stdout.write(Buffer.from("prior: 🧭\n", "utf8"));
|
||||||
|
const checkpoint = owner.checkpointOutput();
|
||||||
|
first.stdout.write(Buffer.from("current: é\n", "utf8"));
|
||||||
|
first.stderr.write(Buffer.from([0xff, 0x61]));
|
||||||
|
const scoped = owner.snapshotOutputSince(checkpoint);
|
||||||
|
expect(scoped.complete).toBe(true);
|
||||||
|
expect(scoped.snapshot.stdout.text).toBe("current: é\n");
|
||||||
|
expect(scoped.snapshot.stdout.bytes).toBe(
|
||||||
|
Buffer.byteLength("current: é\n"),
|
||||||
|
);
|
||||||
|
expect(scoped.snapshot.stderr).toEqual({
|
||||||
|
text: "\ufffda",
|
||||||
|
bytes: 2,
|
||||||
|
observedBytes: 2,
|
||||||
|
invalidUtf8: true,
|
||||||
|
});
|
||||||
|
expect(other.snapshotOutputSince(checkpoint).complete).toBe(false);
|
||||||
|
owner.resetOutput();
|
||||||
|
expect(owner.snapshotOutputSince(checkpoint).complete).toBe(false);
|
||||||
|
} finally {
|
||||||
|
owner.dispose();
|
||||||
|
other.dispose();
|
||||||
|
first.stdout.destroy();
|
||||||
|
first.stderr.destroy();
|
||||||
|
second.stdout.destroy();
|
||||||
|
second.stderr.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["stdout", "stderr"] as const)(
|
||||||
|
"redacts a budget-cut credential in scoped %s after prior output",
|
||||||
|
(stream) => {
|
||||||
|
const producer = Object.assign(new EventEmitter(), {
|
||||||
|
stdout: new PassThrough(),
|
||||||
|
stderr: new PassThrough(),
|
||||||
|
exitCode: null,
|
||||||
|
signalCode: null,
|
||||||
|
kill: () => false,
|
||||||
|
});
|
||||||
|
const supervisor = new ProviderProcessSupervisor(
|
||||||
|
{ process: producer, ownsProcessLifetime: false },
|
||||||
|
{ maxDiagnosticBytes: 40 },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
producer.stdout.write("p".repeat(32));
|
||||||
|
const checkpoint = supervisor.checkpointOutput();
|
||||||
|
const token = "private-transport-token";
|
||||||
|
producer[stream].write(token);
|
||||||
|
const scoped = supervisor.snapshotOutputSince(checkpoint);
|
||||||
|
expect(scoped.complete).toBe(false);
|
||||||
|
expect(scoped.snapshot.outputIncomplete).toBe(true);
|
||||||
|
expect(scoped.snapshot[stream]).toMatchObject({
|
||||||
|
bytes: 8,
|
||||||
|
observedBytes: Buffer.byteLength(token),
|
||||||
|
text: token.slice(0, 8),
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
redactCapturedTransportCredential(
|
||||||
|
scoped.snapshot[stream],
|
||||||
|
token,
|
||||||
|
"[REDACTED]",
|
||||||
|
),
|
||||||
|
).toBe("[REDACTED]");
|
||||||
|
} finally {
|
||||||
|
supervisor.dispose();
|
||||||
|
producer.stdout.destroy();
|
||||||
|
producer.stderr.destroy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it("observes an exit that completed before supervision", () => {
|
it("observes an exit that completed before supervision", () => {
|
||||||
const producer = Object.assign(new EventEmitter(), {
|
const producer = Object.assign(new EventEmitter(), {
|
||||||
stdout: new PassThrough(),
|
stdout: new PassThrough(),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { expect, it } from "vitest";
|
|||||||
|
|
||||||
import { logCliCommand } from "../../../src/cliLogging.js";
|
import { logCliCommand } from "../../../src/cliLogging.js";
|
||||||
import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js";
|
import { analysisErrorProjectionSchema } from "../../../src/contracts/errorSchemas.js";
|
||||||
|
import { ok } from "../../../src/domain/result.js";
|
||||||
import { GhidraRequestQueue } from "../../../src/ghidra/GhidraRequestQueue.js";
|
import { GhidraRequestQueue } from "../../../src/ghidra/GhidraRequestQueue.js";
|
||||||
import { bindGhidraSessionFailure } from "../../../src/ghidra/GhidraSessionError.js";
|
import { bindGhidraSessionFailure } from "../../../src/ghidra/GhidraSessionError.js";
|
||||||
import { silentLogger } from "../../../src/logger.js";
|
import { silentLogger } from "../../../src/logger.js";
|
||||||
@@ -28,7 +29,7 @@ it("preserves queue failure diagnostics through the provider, CLI adapter and SD
|
|||||||
throw cause;
|
throw cause;
|
||||||
},
|
},
|
||||||
failure,
|
failure,
|
||||||
() => Promise.resolve(),
|
() => Promise.resolve(ok(null)),
|
||||||
);
|
);
|
||||||
const harness = await connectGhidraMcp(
|
const harness = await connectGhidraMcp(
|
||||||
"ghidra-failure-diagnostics",
|
"ghidra-failure-diagnostics",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { spawn, type ChildProcess } from "node:child_process";
|
import { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { access, chmod, mkdir, writeFile } from "node:fs/promises";
|
import { access, chmod, mkdir, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
import { afterEach, describe, expect, it, onTestFinished, vi } from "vitest";
|
||||||
@@ -20,6 +22,7 @@ import type {
|
|||||||
GhidraLaunchSession,
|
GhidraLaunchSession,
|
||||||
GhidraLauncher,
|
GhidraLauncher,
|
||||||
} from "../../../../src/ghidra/GhidraLauncher.js";
|
} from "../../../../src/ghidra/GhidraLauncher.js";
|
||||||
|
import type { ProviderProcessHandle } from "../../../../src/process/ProviderProcess.js";
|
||||||
import type { GhidraTransportKind } from "../../../../src/ghidra/GhidraTransport.js";
|
import type { GhidraTransportKind } from "../../../../src/ghidra/GhidraTransport.js";
|
||||||
import {
|
import {
|
||||||
GHIDRA_MUTATING_OPERATIONS,
|
GHIDRA_MUTATING_OPERATIONS,
|
||||||
@@ -59,6 +62,7 @@ type FixtureMode =
|
|||||||
| "exit_tools"
|
| "exit_tools"
|
||||||
| "silent"
|
| "silent"
|
||||||
| "exit"
|
| "exit"
|
||||||
|
| "old_oom_then_malformed"
|
||||||
| "diagnostic_output";
|
| "diagnostic_output";
|
||||||
|
|
||||||
class FixtureLauncher implements GhidraLauncher {
|
class FixtureLauncher implements GhidraLauncher {
|
||||||
@@ -117,6 +121,58 @@ class FixtureLauncher implements GhidraLauncher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const preExitedProcessLauncher = (options: {
|
||||||
|
readonly output: string;
|
||||||
|
readonly closeOutput: boolean;
|
||||||
|
readonly outputDelayMs?: number;
|
||||||
|
}) => {
|
||||||
|
const stdout = new PassThrough();
|
||||||
|
const stderr = new PassThrough();
|
||||||
|
const emitter = new EventEmitter();
|
||||||
|
const process_: ProviderProcessHandle = {
|
||||||
|
on: emitter.on.bind(emitter),
|
||||||
|
once: emitter.once.bind(emitter),
|
||||||
|
off: emitter.off.bind(emitter),
|
||||||
|
pid: undefined,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
exitCode: 73,
|
||||||
|
signalCode: null,
|
||||||
|
kill: () => false,
|
||||||
|
};
|
||||||
|
const finishOutput = () => {
|
||||||
|
if (!stdout.writableEnded) stdout.end();
|
||||||
|
if (!stderr.writableEnded) stderr.end();
|
||||||
|
};
|
||||||
|
let endedStreams = 0;
|
||||||
|
const maybeClose = () => {
|
||||||
|
endedStreams += 1;
|
||||||
|
if (endedStreams === 2) emitter.emit("close", 73, null);
|
||||||
|
};
|
||||||
|
stdout.once("end", maybeClose);
|
||||||
|
stderr.once("end", maybeClose);
|
||||||
|
const launcher: GhidraLauncher = {
|
||||||
|
launch: async (session) => {
|
||||||
|
const projectRoot = join(session.runtimeRoot, "project");
|
||||||
|
await mkdir(projectRoot, { recursive: true });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (options.closeOutput) {
|
||||||
|
if (!stdout.writableEnded) stdout.end(options.output);
|
||||||
|
if (!stderr.writableEnded) stderr.end();
|
||||||
|
} else if (!stdout.writableEnded) stdout.write(options.output);
|
||||||
|
}, options.outputDelayMs ?? 15);
|
||||||
|
return ok({
|
||||||
|
process: process_,
|
||||||
|
ownsProcessLifetime: true as const,
|
||||||
|
projectRoot,
|
||||||
|
ghidraLogPath: join(session.runtimeRoot, "ghidra.log"),
|
||||||
|
scriptLogPath: join(session.runtimeRoot, "script.log"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { launcher, stdout, stderr, finishOutput };
|
||||||
|
};
|
||||||
|
|
||||||
const clients: GhidraClient[] = [];
|
const clients: GhidraClient[] = [];
|
||||||
const clientFor = (
|
const clientFor = (
|
||||||
launcher: GhidraLauncher,
|
launcher: GhidraLauncher,
|
||||||
@@ -604,24 +660,60 @@ describe("GhidraClient established requests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("GhidraClient runtime removal failures", () => {
|
describe("GhidraClient runtime removal failures", () => {
|
||||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
it
|
||||||
"reports a runtime root it could not remove and removes it on a later close",
|
.skipIf(process.platform === "win32" || process.getuid?.() === 0)
|
||||||
async () => {
|
.each(["close", "active cancellation"] as const)(
|
||||||
const launcher = new FixtureLauncher();
|
"retains a runtime root after %s cleanup failure and removes it on retry",
|
||||||
|
async (stage) => {
|
||||||
|
const launcher = new FixtureLauncher(
|
||||||
|
stage === "active cancellation" ? "hang_tools" : "success",
|
||||||
|
);
|
||||||
const client = clientFor(launcher);
|
const client = clientFor(launcher);
|
||||||
await expect(client.start()).resolves.toMatchObject({ ok: true });
|
await expect(client.start()).resolves.toMatchObject({ ok: true });
|
||||||
const runtimeRoot = launcher.runtimeRoots[0] ?? "";
|
const runtimeRoot = launcher.runtimeRoots[0] ?? "";
|
||||||
// Without write permission its entries cannot be removed.
|
// Without write permission its entries cannot be removed.
|
||||||
await chmod(runtimeRoot, 0o500);
|
await chmod(runtimeRoot, 0o500);
|
||||||
try {
|
try {
|
||||||
await expect(client.close()).resolves.toMatchObject({
|
if (stage === "close") {
|
||||||
ok: false,
|
await expect(client.close()).resolves.toMatchObject({
|
||||||
error: {
|
ok: false,
|
||||||
cleanupIncomplete: true,
|
error: {
|
||||||
cleanupResources: [runtimeRoot],
|
cleanupIncomplete: true,
|
||||||
diagnostics: { leftover_paths: [runtimeRoot] },
|
cleanupResources: [runtimeRoot],
|
||||||
},
|
diagnostics: { leftover_paths: [runtimeRoot] },
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const active = client.callTool(
|
||||||
|
"procedure_pseudo_code",
|
||||||
|
{ document: null, procedure: "fixture_main" },
|
||||||
|
{ signal: controller.signal },
|
||||||
|
);
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(launcher.requests).toContain("procedure_pseudo_code"),
|
||||||
|
);
|
||||||
|
const queued = client.callTool("procedure_info", {
|
||||||
|
document: null,
|
||||||
|
procedure: "fixture_main",
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
await expect(active).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: {
|
||||||
|
kind: "cancelled",
|
||||||
|
cleanupFailure: {
|
||||||
|
cleanupIncomplete: true,
|
||||||
|
cleanupResources: [runtimeRoot],
|
||||||
|
diagnostics: { leftover_paths: [runtimeRoot] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(queued).resolves.toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { kind: "process" },
|
||||||
|
});
|
||||||
|
}
|
||||||
await expect(access(runtimeRoot)).resolves.toBeUndefined();
|
await expect(access(runtimeRoot)).resolves.toBeUndefined();
|
||||||
expect(client.operationHealthSnapshot()).toMatchObject({
|
expect(client.operationHealthSnapshot()).toMatchObject({
|
||||||
state: "unknown",
|
state: "unknown",
|
||||||
@@ -871,6 +963,102 @@ describe("GhidraClient rejected requests", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("GhidraClient process output drain before startup failure", () => {
|
||||||
|
const oomeOutput = [
|
||||||
|
"ERROR Abort due to Headless analyzer error: Java heap space (HeadlessAnalyzer) java.lang.OutOfMemoryError:",
|
||||||
|
"\tat ghidra.app.plugin.core.analysis.GolangSymbolAnalyzer.applyTo",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
it("captures deferred stdio from a child that exited before supervision", async () => {
|
||||||
|
const { launcher, finishOutput } = preExitedProcessLauncher({
|
||||||
|
output: oomeOutput,
|
||||||
|
closeOutput: true,
|
||||||
|
});
|
||||||
|
onTestFinished(finishOutput);
|
||||||
|
const client = clientFor(launcher, { startupTimeoutMs: 5_000 });
|
||||||
|
const result = await client.start();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.error.kind).toBe("process");
|
||||||
|
expect(result.error.diagnostics).toMatchObject({
|
||||||
|
exit_code: 73,
|
||||||
|
stdout: { text: oomeOutput },
|
||||||
|
failure_output: { complete: true, stdout: { text: oomeOutput } },
|
||||||
|
});
|
||||||
|
expect(result.error.diagnostics).not.toHaveProperty(
|
||||||
|
"output_incomplete",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds an output stream that never closes and marks its snapshot incomplete", async () => {
|
||||||
|
const { launcher, finishOutput } = preExitedProcessLauncher({
|
||||||
|
output: oomeOutput,
|
||||||
|
closeOutput: false,
|
||||||
|
});
|
||||||
|
onTestFinished(finishOutput);
|
||||||
|
const client = clientFor(launcher, { startupTimeoutMs: 5_000 });
|
||||||
|
const result = await client.start();
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.error.diagnostics).toMatchObject({
|
||||||
|
exit_code: 73,
|
||||||
|
output_incomplete: true,
|
||||||
|
stdout: { text: oomeOutput },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps caller cancellation primary when cancellation wins during output drain", async () => {
|
||||||
|
const { launcher, finishOutput } = preExitedProcessLauncher({
|
||||||
|
output: oomeOutput,
|
||||||
|
closeOutput: true,
|
||||||
|
outputDelayMs: 150,
|
||||||
|
});
|
||||||
|
onTestFinished(finishOutput);
|
||||||
|
const controller = new AbortController();
|
||||||
|
let abortScheduled = false;
|
||||||
|
const client = clientFor(launcher, {
|
||||||
|
startupTimeoutMs: 5_000,
|
||||||
|
onDiagnostic: (event) => {
|
||||||
|
if (event.type !== "launcher-exit" || abortScheduled) return;
|
||||||
|
abortScheduled = true;
|
||||||
|
setTimeout(() => controller.abort(), 25);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await client.start(controller.signal);
|
||||||
|
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (result.ok) return;
|
||||||
|
expect(result.error.kind).toBe("cancelled");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("GhidraClient operation-scoped failure output", () => {
|
||||||
|
it("keeps earlier successful-operation OOME in history but out of later failure evidence", async () => {
|
||||||
|
const client = clientFor(new FixtureLauncher("old_oom_then_malformed"));
|
||||||
|
const started = await client.start();
|
||||||
|
if (!started.ok) throw started.error;
|
||||||
|
|
||||||
|
const earlier = await client.callTool("list_documents", {});
|
||||||
|
expect(earlier.ok).toBe(true);
|
||||||
|
const later = await client.callTool("list_procedures", {});
|
||||||
|
expect(later.ok).toBe(false);
|
||||||
|
if (later.ok) return;
|
||||||
|
|
||||||
|
expect(later.error.kind).toBe("protocol");
|
||||||
|
expect(later.error.diagnostics.stderr).toMatchObject({
|
||||||
|
text: expect.stringContaining("java.lang.OutOfMemoryError:"),
|
||||||
|
});
|
||||||
|
expect(later.error.diagnostics.failure_output).toMatchObject({
|
||||||
|
complete: false,
|
||||||
|
stdout: { text: "" },
|
||||||
|
stderr: { text: "Current operation diagnostic: 雪\n" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("GhidraClient shutdown diagnostics", () => {
|
describe("GhidraClient shutdown diagnostics", () => {
|
||||||
it("logs an unexpected shutdown rejection and still removes its process and runtime", async () => {
|
it("logs an unexpected shutdown rejection and still removes its process and runtime", async () => {
|
||||||
const launcher = new FixtureLauncher();
|
const launcher = new FixtureLauncher();
|
||||||
|
|||||||
@@ -58,7 +58,9 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) {
|
|||||||
? "-Dos.arch=x86_64\\n"
|
? "-Dos.arch=x86_64\\n"
|
||||||
: mode === "os-name-override"
|
: mode === "os-name-override"
|
||||||
? "-Dos.name=Windows\\n"
|
? "-Dos.name=Windows\\n"
|
||||||
: "-Dfile.encoding=UTF8\\n";
|
: mode === "heap-override"
|
||||||
|
? "-Xmx128M\\n"
|
||||||
|
: "-Dfile.encoding=UTF8\\n";
|
||||||
process.stdout.write(vmArguments);
|
process.stdout.write(vmArguments);
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
@@ -111,6 +113,29 @@ if (mode === "env-failure" || (probe === "-vmargs" && mode === "vm-failure")) {
|
|||||||
describe.skipIf(process.platform === "win32")(
|
describe.skipIf(process.platform === "win32")(
|
||||||
"Ghidra LaunchSupport JVM compatibility",
|
"Ghidra LaunchSupport JVM compatibility",
|
||||||
() => {
|
() => {
|
||||||
|
it("reports the selected heap after LaunchSupport's earlier heap argument", async () => {
|
||||||
|
const { session, launcher, analysisArguments } =
|
||||||
|
await fixture("heap-override");
|
||||||
|
const launched = await launcher.launch(session);
|
||||||
|
if (!launched.ok) throw launched.error;
|
||||||
|
expect(
|
||||||
|
analysisArguments[0]?.filter((argument) => argument.startsWith("-Xmx")),
|
||||||
|
).toEqual(["-Xmx128M", "-Xmx1G"]);
|
||||||
|
expect(launched.value.configuredMaxHeap).toBe("1G");
|
||||||
|
expect(
|
||||||
|
createGhidraDiagnostics({
|
||||||
|
targetPath: session.targetPath,
|
||||||
|
targetSha256: session.targetSha256,
|
||||||
|
transport: session.transport,
|
||||||
|
providerVersion: session.providerVersion,
|
||||||
|
profileDigest: session.profileDigest,
|
||||||
|
launch: launched.value,
|
||||||
|
token: session.token,
|
||||||
|
previous: {},
|
||||||
|
}),
|
||||||
|
).toMatchObject({ configured_jvm_max_heap: "1G" });
|
||||||
|
});
|
||||||
|
|
||||||
it.each(["Eclipse OpenJ9 VM", "unrecognized VM", ""])(
|
it.each(["Eclipse OpenJ9 VM", "unrecognized VM", ""])(
|
||||||
"keeps HotSpot-only flags off a VM identified as %s",
|
"keeps HotSpot-only flags off a VM identified as %s",
|
||||||
async (javaVmName) => {
|
async (javaVmName) => {
|
||||||
|
|||||||
Vendored
+23
@@ -47,6 +47,7 @@ else {
|
|||||||
const state = {
|
const state = {
|
||||||
buffer: { value: "" },
|
buffer: { value: "" },
|
||||||
pings: { value: 0 },
|
pings: { value: 0 },
|
||||||
|
emittedOome: false,
|
||||||
mode,
|
mode,
|
||||||
token,
|
token,
|
||||||
runId,
|
runId,
|
||||||
@@ -108,6 +109,28 @@ const handleRequest = (socket, server, request, state) => {
|
|||||||
handlePing(socket, request, state);
|
handlePing(socket, request, state);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
state.mode === "old_oom_then_malformed" &&
|
||||||
|
request.method === "list_documents" &&
|
||||||
|
!state.emittedOome
|
||||||
|
) {
|
||||||
|
state.emittedOome = true;
|
||||||
|
process.stderr.write(
|
||||||
|
"ERROR Abort due to Headless analyzer error: Java heap space (HeadlessAnalyzer) java.lang.OutOfMemoryError:\n",
|
||||||
|
() => setTimeout(() => handleRequest(socket, server, request, state), 10),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
state.mode === "old_oom_then_malformed" &&
|
||||||
|
request.method === "list_procedures" &&
|
||||||
|
state.emittedOome
|
||||||
|
) {
|
||||||
|
process.stderr.write("Current operation diagnostic: 雪\n", () =>
|
||||||
|
setTimeout(() => socket.write("{invalid\n"), 10),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (state.mode === "remote_error" && request.method === "list_procedures") {
|
if (state.mode === "remote_error" && request.method === "list_procedures") {
|
||||||
socket.write(
|
socket.write(
|
||||||
`${JSON.stringify({
|
`${JSON.stringify({
|
||||||
|
|||||||
Reference in new issue
Block a user