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:
morlutoandSyed Rishad committed 2026-10-16 07:25:23 +08:00
1 parent 43bc6e1d23
commit d52a488476
16 files changed
+1187 -75

No files matched your search

+18
View File
@@ -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
View File
@@ -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;
}
} }
+23
View File
@@ -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 -5
View File
@@ -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 } : {}),
}); });
+6 -1
View File
@@ -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",
+12
View File
@@ -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] }),
}); });
+201 -1
View File
@@ -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");
});
});
+97 -1
View File
@@ -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,
+89 -9
View File
@@ -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));
});
}); });
+60 -14
View File
@@ -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);
+137 -3
View File
@@ -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);
} }
}; };
+96
View File
@@ -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) => {
+23
View File
@@ -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({