feat(ghidra): make the startup deadline configurable (#974)
* feat(ghidra): make the startup deadline configurable Large binaries (an 8 MB PE with about 20,000 functions) need more than the fixed 330 s for import and full auto-analysis, so every first request timed out. REA_GHIDRA_STARTUP_TIMEOUT_MS overrides the deadline in milliseconds; absent, empty or invalid values keep the 330 s default. * docs(ghidra): document REA_GHIDRA_STARTUP_TIMEOUT_MS * fix(ghidra): parse startup deadlines through runtime configuration --------- Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
parent
65a809a0da
commit
d84bc8f11a
|
|
@ -173,6 +173,8 @@ Native formats and host support vary by provider. See
|
|||
[IDA guide](docs/ida-provider.md), and
|
||||
[experimental Windows Ghidra support](docs/windows-ghidra-p0.md).
|
||||
Ghidra also supports [16-bit DOS analysis](docs/ghidra-dos.md).
|
||||
For large binaries, raise its startup deadline with
|
||||
`REA_GHIDRA_STARTUP_TIMEOUT_MS`.
|
||||
For provider selection, see the [CLI guide](docs/cli.md#choose-a-provider).
|
||||
Check [release availability](docs/installation.md#released-package-and-main)
|
||||
for features added since the latest npm release.
|
||||
|
|
|
|||
|
|
@ -495,8 +495,12 @@ private native-owned bearer descriptor and Job Object process ownership.
|
|||
Operations begin only after default auto-analysis completes. `open_binary`
|
||||
selects and validates the target/provider binding; it does not wait for Ghidra
|
||||
import and auto-analysis. The first Ghidra-backed query starts that work lazily.
|
||||
The provider startup deadline is 330,000 ms for import, analysis, bridge, and
|
||||
health readiness; a startup failure is returned by the query that triggered it,
|
||||
The provider startup deadline is 330,000 ms by default for import, analysis,
|
||||
bridge, and health readiness. Large binaries can need more: set
|
||||
`REA_GHIDRA_STARTUP_TIMEOUT_MS` to an integer between 1 and 2,147,483,647
|
||||
milliseconds in the server environment. An absent, empty, invalid or out-of-range
|
||||
value keeps the default. REA parses the supplied configuration environment and
|
||||
passes the deadline to each provider client; a startup failure is returned by the query that triggered it,
|
||||
without exposing partial analysis. This deadline is separate from MCP transport
|
||||
initialization and the client's deadline for that individual tool call. A client
|
||||
can time out earlier even when Ghidra would complete within its startup deadline.
|
||||
|
|
|
|||
|
|
@ -111,11 +111,11 @@ auto-analysis, bridge connection, and health readiness before returning analysis
|
|||
|
||||
These deadlines have different owners:
|
||||
|
||||
| Deadline | Owner and effect |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MCP initialize | The client bounds transport/REA connection startup, before any target query. |
|
||||
| Ghidra startup | REA allows 330,000 ms for engine readiness from the first provider query; startup failure is reported by that query. |
|
||||
| Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. |
|
||||
| Deadline | Owner and effect |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MCP initialize | The client bounds transport/REA connection startup, before any target query. |
|
||||
| Ghidra startup | REA allows 330,000 ms by default; `REA_GHIDRA_STARTUP_TIMEOUT_MS` accepts 1–2,147,483,647 ms. Invalid values keep the default. Startup failure is reported by the first provider query. |
|
||||
| Individual tool call | The client bounds its wait, including cold engine startup. The pinned client SDK 2.3.1 defaults to 60,000 ms and can cancel earlier than REA's startup deadline. |
|
||||
|
||||
For an already connected client using the pinned SDK, request options are the
|
||||
**second** argument of `callTool`:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { isAbsolute } from "node:path";
|
|||
import { ConfigurationError } from "../domain/configurationErrors.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { analysisProviderSelectorSchema } from "../contracts/providerSelection.js";
|
||||
import { ghidraStartupTimeoutSchema } from "./ghidraStartupTimeout.js";
|
||||
import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js";
|
||||
|
||||
const environmentSchema = z.object({
|
||||
|
|
@ -29,6 +30,7 @@ const environmentSchema = z.object({
|
|||
.min(1)
|
||||
.refine(isAbsolute, "REA_GHIDRA_NATIVEAOT_JAR must be absolute")
|
||||
.optional(),
|
||||
REA_GHIDRA_STARTUP_TIMEOUT_MS: ghidraStartupTimeoutSchema,
|
||||
REA_ILSPY_CMD_PATH: z
|
||||
.string()
|
||||
.min(1)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/** Default absolute deadline for Ghidra import, analysis, bridge, and readiness. */
|
||||
export const DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS = 330_000;
|
||||
|
||||
// Node schedules delays above this signed 32-bit boundary as a 1 ms timer.
|
||||
const MAX_TIMER_DELAY_MS = 2_147_483_647;
|
||||
|
||||
/** Parse the optional setting without reading the ambient process environment. */
|
||||
export const ghidraStartupTimeoutSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((raw) => {
|
||||
const value = Number(raw);
|
||||
return Number.isSafeInteger(value) &&
|
||||
value > 0 &&
|
||||
value <= MAX_TIMER_DELAY_MS
|
||||
? value
|
||||
: DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS;
|
||||
});
|
||||
|
|
@ -53,6 +53,7 @@ export const parseConfig = (
|
|||
: { idaMcpConfigPath: env.REA_IDA_MCP_CONFIG }),
|
||||
ghidraInstallDir: env.GHIDRA_INSTALL_DIR,
|
||||
ghidraJavaHome: env.JAVA_HOME,
|
||||
ghidraStartupTimeoutMs: env.REA_GHIDRA_STARTUP_TIMEOUT_MS,
|
||||
...(env.REA_GHIDRA_NATIVEAOT_JAR === undefined
|
||||
? {}
|
||||
: { ghidraNativeAotJar: env.REA_GHIDRA_NATIVEAOT_JAR }),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export interface AppConfig {
|
|||
readonly idaMcpConfigPath?: string;
|
||||
readonly ghidraInstallDir: string | undefined;
|
||||
readonly ghidraJavaHome: string | undefined;
|
||||
readonly ghidraStartupTimeoutMs: number;
|
||||
readonly ghidraNativeAotJar?: string;
|
||||
readonly ilspyCmdPath: string | undefined;
|
||||
readonly hopperLauncherPath: string;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import {
|
|||
type ProviderProcessSnapshot,
|
||||
ProviderProcessSupervisor,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import { GHIDRA_STARTUP_TIMEOUT_MS } from "./GhidraDefaults.js";
|
||||
import { DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS } from "../config/ghidraStartupTimeout.js";
|
||||
import type {
|
||||
GhidraClientOptions,
|
||||
GhidraRequestOptions,
|
||||
|
|
@ -124,7 +124,8 @@ export class GhidraClient {
|
|||
constructor(options: GhidraClientOptions) {
|
||||
this.#options = {
|
||||
...options,
|
||||
startupTimeoutMs: options.startupTimeoutMs ?? GHIDRA_STARTUP_TIMEOUT_MS,
|
||||
startupTimeoutMs:
|
||||
options.startupTimeoutMs ?? DEFAULT_GHIDRA_STARTUP_TIMEOUT_MS,
|
||||
platform: options.platform ?? process.platform,
|
||||
transport: options.transport ?? "unix-socket",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
/** Absolute import, analysis, bridge, and health startup deadline. */
|
||||
export const GHIDRA_STARTUP_TIMEOUT_MS = 330_000;
|
||||
|
|
@ -26,6 +26,7 @@ import { GHIDRA_SESSION_CAPABILITIES } from "./GhidraSessionValues.js";
|
|||
import { err, ok } from "../domain/result.js";
|
||||
import { GhidraSessionError } from "./GhidraSessionError.js";
|
||||
import { silentLogger } from "../logger.js";
|
||||
import { ProviderStartupDeadline } from "../process/ProviderDeadline.js";
|
||||
|
||||
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
|
||||
const installationHost = (): GhidraInstallationHost => ({
|
||||
|
|
@ -383,6 +384,79 @@ describe("Ghidra platform support", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Ghidra startup configuration", () => {
|
||||
it("carries supplied startup settings through configuration, the provider, the timer and timeout diagnostics", async () => {
|
||||
const target = executableTarget("elf", "x86_64");
|
||||
for (const [raw, expected] of [
|
||||
[undefined, 330_000],
|
||||
["", 330_000],
|
||||
[" ", 330_000],
|
||||
["900000", 900_000],
|
||||
["1260000", 1_260_000],
|
||||
["2147483647", 2_147_483_647],
|
||||
["2147483648", 330_000],
|
||||
["9007199254740991", 330_000],
|
||||
["0", 330_000],
|
||||
["-5", 330_000],
|
||||
["abc", 330_000],
|
||||
["1.5", 330_000],
|
||||
] as const) {
|
||||
const config = parseConfig({
|
||||
GHIDRA_INSTALL_DIR: INSTALL,
|
||||
...(raw === undefined ? {} : { REA_GHIDRA_STARTUP_TIMEOUT_MS: raw }),
|
||||
});
|
||||
if (!config.ok) throw config.error;
|
||||
let timeout: number | undefined;
|
||||
const ghidra = new GhidraProvider(
|
||||
config.value,
|
||||
silentLogger,
|
||||
installationHost(),
|
||||
(options) => {
|
||||
timeout = options.startupTimeoutMs;
|
||||
return {
|
||||
start: () =>
|
||||
Promise.resolve(
|
||||
err(
|
||||
new GhidraSessionError(
|
||||
"timeout",
|
||||
"Startup deadline elapsed",
|
||||
{},
|
||||
),
|
||||
),
|
||||
),
|
||||
callTool: () => Promise.resolve(ok([])),
|
||||
close: () => Promise.resolve(),
|
||||
};
|
||||
},
|
||||
);
|
||||
const profile = await ghidra.resolveAnalysisProfile(target);
|
||||
if (!profile.ok) throw profile.error;
|
||||
if (profile.value.profile === null)
|
||||
throw new Error("Expected a bound profile");
|
||||
const client = ghidra.createClient(target, profile.value.profile);
|
||||
try {
|
||||
const failure = await client.execute("health", {});
|
||||
expect(timeout, String(raw)).toBe(expected);
|
||||
expect(failure).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisTimeoutError", timeoutMs: expected },
|
||||
});
|
||||
if (timeout === undefined)
|
||||
throw new Error("Provider omitted startup deadline");
|
||||
const deadline = new ProviderStartupDeadline(timeout);
|
||||
try {
|
||||
expect(await deadline.wait(20)).toBe("elapsed");
|
||||
expect(deadline.signal.aborted).toBe(false);
|
||||
} finally {
|
||||
deadline.dispose();
|
||||
}
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Ghidra client projection", () => {
|
||||
it("commits exact provider, isolation, and resource semantics", async () => {
|
||||
const toolCalls: Array<{
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ import { err, ok, type Result } from "../domain/result.js";
|
|||
import type { Logger } from "../logger.js";
|
||||
import { GhidraClient } from "./GhidraClient.js";
|
||||
import type { GhidraClientOptions } from "./GhidraClientTypes.js";
|
||||
import { GHIDRA_STARTUP_TIMEOUT_MS } from "./GhidraDefaults.js";
|
||||
import {
|
||||
isGhidraFunctionOperation,
|
||||
parseGhidraFunctionInput,
|
||||
|
|
@ -132,6 +131,7 @@ export const createGhidraProviderClient = (input: {
|
|||
]
|
||||
: [];
|
||||
const client = clientFactory({
|
||||
startupTimeoutMs: config.ghidraStartupTimeoutMs,
|
||||
platform: installation.platform,
|
||||
launcher: new GhidraHeadlessLauncher({
|
||||
analyzeHeadlessPath: prerequisites.value.analyzeHeadlessPath,
|
||||
|
|
@ -204,7 +204,13 @@ export const createGhidraProviderClient = (input: {
|
|||
if (operation === "health") {
|
||||
const started = await client.start(options?.signal);
|
||||
if (!started.ok)
|
||||
return err(projectSessionError(operation, started.error));
|
||||
return err(
|
||||
projectSessionError(
|
||||
operation,
|
||||
started.error,
|
||||
config.ghidraStartupTimeoutMs,
|
||||
),
|
||||
);
|
||||
const failed = await checkExtensions(operation, started.value);
|
||||
if (failed !== undefined) return err(failed);
|
||||
return ok(
|
||||
|
|
@ -221,7 +227,13 @@ export const createGhidraProviderClient = (input: {
|
|||
if (extensions.length > 0) {
|
||||
const started = await client.start(options?.signal);
|
||||
if (!started.ok)
|
||||
return err(projectSessionError(operation, started.error));
|
||||
return err(
|
||||
projectSessionError(
|
||||
operation,
|
||||
started.error,
|
||||
config.ghidraStartupTimeoutMs,
|
||||
),
|
||||
);
|
||||
const failed = await checkExtensions(operation, started.value);
|
||||
if (failed !== undefined) return err(failed);
|
||||
}
|
||||
|
|
@ -230,7 +242,14 @@ export const createGhidraProviderClient = (input: {
|
|||
input.value,
|
||||
options?.signal === undefined ? {} : { signal: options.signal },
|
||||
);
|
||||
if (!called.ok) return err(projectSessionError(operation, called.error));
|
||||
if (!called.ok)
|
||||
return err(
|
||||
projectSessionError(
|
||||
operation,
|
||||
called.error,
|
||||
config.ghidraStartupTimeoutMs,
|
||||
),
|
||||
);
|
||||
const result = isGhidraFunctionOperation(operation)
|
||||
? parseGhidraFunctionResult(operation, called.value)
|
||||
: parseGhidraInventoryResult(operation, called.value);
|
||||
|
|
@ -242,7 +261,12 @@ export const createGhidraProviderClient = (input: {
|
|||
operation,
|
||||
result.value,
|
||||
client,
|
||||
(failure) => projectSessionError(operation, failure),
|
||||
(failure) =>
|
||||
projectSessionError(
|
||||
operation,
|
||||
failure,
|
||||
config.ghidraStartupTimeoutMs,
|
||||
),
|
||||
);
|
||||
if (!attested.ok) return attested;
|
||||
normalized = attested.value;
|
||||
|
|
@ -320,6 +344,7 @@ const unavailableClient = (failure: AnalysisError): AnalysisClient => ({
|
|||
const projectSessionError = (
|
||||
operation: AnalysisOperation,
|
||||
failure: GhidraSessionError,
|
||||
startupTimeoutMs: number,
|
||||
): AnalysisError => {
|
||||
if (failure.cause instanceof AnalysisAccessDeniedError)
|
||||
return new AnalysisAccessDeniedError(
|
||||
|
|
@ -348,7 +373,7 @@ const projectSessionError = (
|
|||
if (failure.kind === "timeout" || failure.kind === "analysis_timeout")
|
||||
return new AnalysisTimeoutError(
|
||||
operation,
|
||||
failure.timeoutMs ?? GHIDRA_STARTUP_TIMEOUT_MS,
|
||||
failure.timeoutMs ?? startupTimeoutMs,
|
||||
);
|
||||
if (failure.kind === "remote" && failure.remoteCode === "decompile_cancelled")
|
||||
return new AnalysisCancelledError(operation);
|
||||
|
|
|
|||
Loading…
Reference in New Issue