mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
cc2e18a265
Replace synchronous Java execution with bounded owned commands. Keep task-scoped readiness observations fresh across later tasks, defer target client prerequisites behind a closeable owner, and join doctor cancellation and cleanup. Preserve completed physical failures across late cancellation, exact cleanup dependencies and partial observations. Admit target/profile inputs before probing and return typed factory failures instead of unavailable client wrappers. Validation: 405 focused tests across 24 files, 2 explicit skips; 109 provider/doctor controls rerun after final admission refinement; check:fast and docs:check. Controlled macOS Java-shaped commands and public CLI/MCP workflows; no new JVM/Ghidra or Windows/Linux lifecycle verification. Measured warm public doctor baseline/candidate: elapsed 0.447/1.223s, CPU 0.624/1.237s, sampled family RSS 379/529MB, reply tokens 3133/3133. Ownership adds cost; no performance improvement claimed. True cold, whole-agent and MCP costs unmeasured. Refs #4561. Remaining installation file admission tracked in #4672.
449 lines
16 KiB
TypeScript
449 lines
16 KiB
TypeScript
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
|
|
import { randomUUID } from "node:crypto";
|
|
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
|
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
|
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
|
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
|
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
|
import { jsonValueSchema } from "../domain/jsonValue.js";
|
|
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
|
import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js";
|
|
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
|
|
import {
|
|
OwnedCommandFailure,
|
|
runOwnedCommand,
|
|
} from "../process/OwnedCommand.js";
|
|
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
|
|
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
|
|
import { constants, accessSync, statSync } from "node:fs";
|
|
import { posix, win32 } from "node:path";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import type { AnalysisCapturedOutput } from "../domain/analysisErrorBase.js";
|
|
|
|
/**
|
|
* Bound the Java version probe so a chatty or hostile JVM cannot force an
|
|
* unbounded in-memory capture. Well-behaved JVMs print a few kilobytes for
|
|
* `-XshowSettings:properties`; the same 1 MiB budget used for other Java
|
|
* listings leaves ample headroom. A probe that exceeds the budget yields no
|
|
* observation and is reported through the existing failed-probe path.
|
|
*/
|
|
const JAVA_PROBE_MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
|
|
/** Parsed identity of the JDK selected for Ghidra. */
|
|
export interface GhidraJavaObservation {
|
|
readonly version: string;
|
|
readonly major: number;
|
|
readonly home: string;
|
|
readonly bits: number | null;
|
|
/** Present when Java reported a bitness value that could not be parsed exactly. */
|
|
readonly unparsedBits?: string;
|
|
readonly runtime: "jdk" | "jre";
|
|
readonly vmName?: string;
|
|
/**
|
|
* Observed JVM os.arch property, which Ghidra uses to select its native
|
|
* decompiler directory. Omitted when the probe cannot establish it.
|
|
*/
|
|
readonly osArch?: string;
|
|
/**
|
|
* Observed JVM os.name property, which Ghidra uses with os.arch to select
|
|
* its native decompiler OS directory. Omitted when the probe cannot
|
|
* establish it.
|
|
*/
|
|
readonly osName?: string;
|
|
}
|
|
|
|
/** Expected probe failures retain their producing process facts. */
|
|
export interface GhidraJavaProbeFailure {
|
|
readonly _tag: "GhidraJavaProbeFailure";
|
|
readonly code:
|
|
| "runtime_missing"
|
|
| "runtime_unavailable"
|
|
| "version_unresolved";
|
|
readonly detail: string;
|
|
/** Non-enumerable caller reason observed during finalization; the probe failure stays primary. */
|
|
readonly cancellation?: { readonly reason: unknown };
|
|
readonly processErrorCode?: string;
|
|
readonly output?: AnalysisCapturedOutput;
|
|
readonly properties?: {
|
|
readonly version: string | null;
|
|
readonly home: string | null;
|
|
readonly vmName: string | null;
|
|
readonly osArch: string | null;
|
|
readonly osName: string | null;
|
|
readonly bits: number | null;
|
|
};
|
|
}
|
|
|
|
const JAVA_DIAGNOSTIC_STREAM_BYTES = 8192;
|
|
const capturedProbeOutput = (
|
|
observed: ProviderProcessSnapshot,
|
|
): AnalysisCapturedOutput => {
|
|
const stdout = boundedUtf8Text(
|
|
observed.stdout.text,
|
|
JAVA_DIAGNOSTIC_STREAM_BYTES,
|
|
);
|
|
const stderr = boundedUtf8Text(
|
|
observed.stderr.text,
|
|
JAVA_DIAGNOSTIC_STREAM_BYTES,
|
|
);
|
|
return {
|
|
stdout: stdout.text,
|
|
stderr: stderr.text,
|
|
truncated:
|
|
stdout.truncated ||
|
|
stderr.truncated ||
|
|
observed.diagnosticTruncated === true ||
|
|
observed.outputIncomplete === true,
|
|
stdout_bytes: observed.stdout.observedBytes,
|
|
stderr_bytes: observed.stderr.observedBytes,
|
|
exit_code: observed.exitCode ?? null,
|
|
signal: observed.signal ?? null,
|
|
};
|
|
};
|
|
|
|
const executablePath = (path: string): boolean => {
|
|
try {
|
|
accessSync(path, constants.X_OK);
|
|
return statSync(path).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
export interface GhidraJavaProbeOptions {
|
|
readonly resourceOwner: FileCloseResourceOwner;
|
|
readonly signal?: AbortSignal;
|
|
readonly launcher?: NonNullable<
|
|
Parameters<typeof runOwnedCommand>[2]
|
|
>["launcher"];
|
|
/** Tests can shorten the production five-second deadline. */
|
|
readonly timeoutMs?: number;
|
|
}
|
|
|
|
/** Admit one complete probe into its caller's durable cleanup scope. */
|
|
export const probeGhidraJava = async (
|
|
command: string,
|
|
environment: NodeJS.ProcessEnv,
|
|
options: GhidraJavaProbeOptions,
|
|
): Promise<
|
|
Result<GhidraJavaObservation, GhidraJavaProbeFailure | AnalysisError>
|
|
> => {
|
|
try {
|
|
return await options.resourceOwner.run(() =>
|
|
observeGhidraJava(command, environment, options),
|
|
);
|
|
} catch (cause: unknown) {
|
|
return err(
|
|
analysisClientFailureForOperation(cause, "ghidra", "ghidra:java"),
|
|
);
|
|
}
|
|
};
|
|
|
|
/** Own a bounded prerequisite process, its interpretation, and exact cleanup retries. */
|
|
const observeGhidraJava = async (
|
|
command: string,
|
|
environment: NodeJS.ProcessEnv,
|
|
options: GhidraJavaProbeOptions,
|
|
): Promise<
|
|
Result<GhidraJavaObservation, GhidraJavaProbeFailure | AnalysisError>
|
|
> => {
|
|
let interpreted:
|
|
| Result<GhidraJavaObservation, GhidraJavaProbeFailure>
|
|
| undefined;
|
|
try {
|
|
await runOwnedCommand(
|
|
{
|
|
command,
|
|
arguments: [
|
|
"-Dstdout.encoding=UTF-8",
|
|
"-Dstderr.encoding=UTF-8",
|
|
"-Dsun.stdout.encoding=UTF-8",
|
|
"-Dsun.stderr.encoding=UTF-8",
|
|
"-XX:-UsePerfData",
|
|
"-XshowSettings:properties",
|
|
"-version",
|
|
],
|
|
runId: randomUUID(),
|
|
expectedCommand: null,
|
|
hostEnvironment: environment,
|
|
sweepTokenOwnedProcesses: true,
|
|
},
|
|
{
|
|
timeoutMs: options.timeoutMs ?? 5000,
|
|
diagnosticBytes: JAVA_PROBE_MAX_OUTPUT_BYTES,
|
|
acceptNonZeroExit: true,
|
|
observeCompleted: (snapshot) => {
|
|
interpreted = parseGhidraJavaOutput(command, snapshot);
|
|
},
|
|
},
|
|
{
|
|
signal: options.signal,
|
|
...(options.launcher === undefined
|
|
? {}
|
|
: { launcher: options.launcher }),
|
|
},
|
|
);
|
|
if (interpreted === undefined)
|
|
throw new Error("Java command completed without an output observation");
|
|
return interpreted;
|
|
} catch (cause: unknown) {
|
|
const commandFailure =
|
|
cause instanceof OwnedCommandFailure ? cause : undefined;
|
|
const preparation =
|
|
commandFailure?.preparationFailure ??
|
|
(cause instanceof DarwinProcessOwnershipInspectionError
|
|
? cause
|
|
: undefined);
|
|
const output =
|
|
commandFailure?.snapshot === undefined || commandFailure.snapshot === null
|
|
? preparation?.capturedOutput
|
|
: capturedProbeOutput(commandFailure.snapshot);
|
|
if (
|
|
interpreted !== undefined &&
|
|
!interpreted.ok &&
|
|
commandFailure?.cancellation !== undefined
|
|
)
|
|
Object.defineProperty(interpreted.error, "cancellation", {
|
|
value: commandFailure.cancellation,
|
|
});
|
|
let primary: AnalysisError | undefined;
|
|
if (interpreted !== undefined && !interpreted.ok) {
|
|
// A completed, invalid report remains the primary fact across cleanup-time cancellation.
|
|
if (
|
|
commandFailure?.cleanupOwner === undefined &&
|
|
preparation?.cleanupFailure === undefined
|
|
)
|
|
return interpreted;
|
|
primary = probeFailureError(interpreted.error, cause);
|
|
} else if (
|
|
commandFailure?.reason === "cancelled" &&
|
|
commandFailure.cancellation !== undefined
|
|
) {
|
|
primary = new AnalysisCancelledError("ghidra:java", {
|
|
cause: commandFailure.cancellation.reason,
|
|
...(output === undefined ? {} : { capturedOutput: output }),
|
|
...(interpreted?.ok === true
|
|
? {
|
|
diagnostics: {
|
|
java_observation: jsonValueSchema.parse(interpreted.value),
|
|
},
|
|
}
|
|
: {}),
|
|
});
|
|
} else if (
|
|
interpreted?.ok === true &&
|
|
commandFailure?.cleanupOwner !== undefined
|
|
) {
|
|
primary = new ProviderAdapterError("ghidra", "ghidra:java", {
|
|
cause,
|
|
...(output === undefined ? {} : { capturedOutput: output }),
|
|
diagnostics: {
|
|
java_observation: jsonValueSchema.parse(interpreted.value),
|
|
stage: "cleanup",
|
|
...(commandFailure.cancellation === undefined
|
|
? {}
|
|
: { cancellation_observed: true }),
|
|
},
|
|
});
|
|
} else if (preparation !== undefined) {
|
|
primary = new ProviderAdapterError("ghidra", "ghidra:java", {
|
|
cause,
|
|
...(output === undefined ? {} : { capturedOutput: output }),
|
|
diagnostics: {
|
|
stage: "ownership_preparation",
|
|
...(preparation.compilerOutcome === undefined
|
|
? {}
|
|
: {
|
|
compiler_outcome: jsonValueSchema.parse(
|
|
preparation.compilerOutcome,
|
|
),
|
|
}),
|
|
},
|
|
});
|
|
} else if (cause instanceof AnalysisError) {
|
|
primary = cause;
|
|
}
|
|
const cleanups: AnalysisError[] = [];
|
|
if (preparation?.cleanupFailure !== undefined) {
|
|
const cleanup = preparation.cleanupFailure;
|
|
const lease = new AnalysisCleanupLease(async () => {
|
|
if (!cleanup.cleanupIncomplete) return;
|
|
if (preparation.cleanupOwner === undefined) throw cleanup;
|
|
await preparation.cleanupOwner.close();
|
|
if (cleanup.cleanupIncomplete) throw cleanup;
|
|
}, cleanup.cleanupResources);
|
|
options.resourceOwner.retain(
|
|
{ close: () => lease.release() },
|
|
cleanup.cleanupResources[0] ??
|
|
"Java prerequisite ownership preparation",
|
|
);
|
|
cleanups.push(cleanup);
|
|
}
|
|
if (commandFailure?.cleanupOwner !== undefined) {
|
|
const supervisor = commandFailure.cleanupOwner;
|
|
const cleanup = new ProviderCleanupError(
|
|
"ghidra",
|
|
commandFailure.resources,
|
|
{
|
|
reason:
|
|
commandFailure.cleanupFailure ??
|
|
"Java prerequisite cleanup remains incomplete",
|
|
...(commandFailure.cleanupResult === undefined
|
|
? {}
|
|
: {
|
|
process_cleanup: jsonValueSchema.parse(
|
|
commandFailure.cleanupResult,
|
|
),
|
|
}),
|
|
},
|
|
{ cause, operation: "ghidra:java" },
|
|
);
|
|
const lease = new AnalysisCleanupLease(async () => {
|
|
const stopped = await supervisor.stop();
|
|
if (stopped.status === "incomplete") throw cleanup;
|
|
}, commandFailure.resources);
|
|
cleanup.bindCleanupResourceOwner(lease);
|
|
lease.bindCleanupError(cleanup);
|
|
options.resourceOwner.retain(
|
|
{ close: () => lease.release() },
|
|
commandFailure.resources[0] ?? "Java prerequisite process",
|
|
);
|
|
cleanups.push(cleanup);
|
|
}
|
|
if (primary === undefined) {
|
|
const code =
|
|
commandFailure?.reason === "timeout"
|
|
? "ETIMEDOUT"
|
|
: commandFailure?.reason === "output-limit"
|
|
? "ENOBUFS"
|
|
: systemCode(commandFailure?.processError ?? cause);
|
|
const failed: GhidraJavaProbeFailure = {
|
|
_tag: "GhidraJavaProbeFailure",
|
|
code: code === "ENOENT" ? "runtime_missing" : "runtime_unavailable",
|
|
detail: `${command}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
...(code === undefined ? {} : { processErrorCode: code }),
|
|
...(output === undefined ? {} : { output }),
|
|
};
|
|
if (commandFailure?.cancellation !== undefined)
|
|
Object.defineProperty(failed, "cancellation", {
|
|
value: commandFailure.cancellation,
|
|
});
|
|
if (cleanups.length === 0) return err(failed);
|
|
primary = probeFailureError(failed, cause);
|
|
}
|
|
for (const cleanup of cleanups)
|
|
primary = analysisErrorWithCleanupFailure(
|
|
primary,
|
|
cleanup,
|
|
"ghidra:java",
|
|
).bindCleanupDependencies([primary, cleanup]);
|
|
return err(primary);
|
|
}
|
|
};
|
|
|
|
const systemCode = (cause: unknown): string | undefined =>
|
|
cause instanceof Error && "code" in cause && typeof cause.code === "string"
|
|
? cause.code
|
|
: undefined;
|
|
const probeFailureError = (
|
|
failure: GhidraJavaProbeFailure,
|
|
cause: unknown,
|
|
): AnalysisError => {
|
|
const { output, ...facts } = failure;
|
|
return new ProviderAdapterError("ghidra", "ghidra:java", {
|
|
cause,
|
|
...(output === undefined ? {} : { capturedOutput: output }),
|
|
diagnostics: {
|
|
java_probe_failure: jsonValueSchema.parse(facts),
|
|
...(failure.cancellation === undefined
|
|
? {}
|
|
: { cancellation_observed: true }),
|
|
},
|
|
});
|
|
};
|
|
|
|
/** Parse only complete command output; callers retain interruption separately. */
|
|
export const parseGhidraJavaOutput = (
|
|
command: string,
|
|
observed: ProviderProcessSnapshot,
|
|
): Result<GhidraJavaObservation, GhidraJavaProbeFailure> => {
|
|
if (observed.exitCode !== 0 || observed.signal !== null)
|
|
return err({
|
|
_tag: "GhidraJavaProbeFailure",
|
|
code: "runtime_unavailable",
|
|
detail: `${command}: Java exited ${String(observed.exitCode)}${observed.signal == null ? "" : ` (${observed.signal})`}`,
|
|
output: capturedProbeOutput(observed),
|
|
});
|
|
const output = `${observed.stdout.text}\n${observed.stderr.text}`;
|
|
const version = javaProperty(output, "java.version") || null;
|
|
const home = javaProperty(output, "java.home") || null;
|
|
const vmName = javaProperty(output, "java.vm.name");
|
|
const osArch = javaProperty(output, "os.arch");
|
|
const osName = javaProperty(output, "os.name");
|
|
const reportedBits = javaProperty(output, "sun.arch.data.model");
|
|
const parsedBits =
|
|
reportedBits !== null && /^\d+$/.test(reportedBits)
|
|
? Number(reportedBits)
|
|
: null;
|
|
const bits =
|
|
parsedBits !== null && Number.isSafeInteger(parsedBits) ? parsedBits : null;
|
|
const major = version === null ? undefined : javaMajor(version);
|
|
if (version === null || home === null || major === undefined)
|
|
return err({
|
|
_tag: "GhidraJavaProbeFailure",
|
|
code: "version_unresolved",
|
|
detail: `${command}: successful Java probe did not establish a valid java.version and nonempty java.home`,
|
|
output: capturedProbeOutput(observed),
|
|
properties: { version, home, vmName, osArch, osName, bits },
|
|
});
|
|
return ok({
|
|
version,
|
|
major,
|
|
home,
|
|
...(vmName === null ? {} : { vmName }),
|
|
...(osArch === null ? {} : { osArch }),
|
|
...(osName === null ? {} : { osName }),
|
|
bits,
|
|
...(reportedBits === null || bits !== null
|
|
? {}
|
|
: { unparsedBits: reportedBits }),
|
|
runtime: executablePath(
|
|
(process.platform === "win32" ? win32 : posix).join(
|
|
home,
|
|
"bin",
|
|
process.platform === "win32" ? "javac.exe" : "javac",
|
|
),
|
|
)
|
|
? "jdk"
|
|
: "jre",
|
|
});
|
|
};
|
|
const javaProperty = (output: string, name: string): string | null =>
|
|
new RegExp(
|
|
`^[ \\t]*${name.replaceAll(".", "\\.")}[ \\t]*=[ \\t]*([^\\r\\n]*?)[ \\t]*\\r?$`,
|
|
"mu",
|
|
).exec(output)?.[1] ?? null;
|
|
|
|
// Runtime.Version's numeric/pre-release/build grammar; Java 8 used 1.8.0_... .
|
|
// https://docs.oracle.com/en/java/javase/24/docs/api/java.base/java/lang/Runtime.Version.html
|
|
const javaMajor = (version: string): number | undefined => {
|
|
const legacy =
|
|
/^1\.([1-8])(?:\.[0-9]+)*(?:_[0-9]+)?(?:-[-a-zA-Z0-9.]+)?$/u.exec(version);
|
|
if (legacy?.[1] !== undefined) return Number(legacy[1]);
|
|
const modern =
|
|
/^(?<number>[1-9][0-9]*(?:(?:\.0)*\.[1-9][0-9]*)*)(?:(?:-[a-zA-Z0-9]+)?\+(?<build>0|[1-9][0-9]*)(?:-[-a-zA-Z0-9.]+)?|-[a-zA-Z0-9]+(?:-[-a-zA-Z0-9.]+)?|\+-[-a-zA-Z0-9.]+)?$/u.exec(
|
|
version,
|
|
);
|
|
const number = modern?.groups?.number;
|
|
if (number === undefined) return undefined;
|
|
const parts = number.split(".").map(Number);
|
|
const build = modern?.groups?.build;
|
|
// Runtime.Version parses numeric components and the build as Java integers.
|
|
if (
|
|
[...parts, ...(build === undefined ? [] : [Number(build)])].some(
|
|
(value) => !Number.isSafeInteger(value) || value > 0x7fffffff,
|
|
)
|
|
)
|
|
return undefined;
|
|
return parts[0];
|
|
};
|