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:
Selim 2026-10-09 01:21:36 +02:00 committed by GitHub
parent 65a809a0da
commit d84bc8f11a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 145 additions and 17 deletions

View File

@ -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.

View File

@ -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.

View File

@ -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`:

View File

@ -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)

View File

@ -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;
});

View File

@ -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 }),

View File

@ -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;

View File

@ -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",
};

View File

@ -1,2 +0,0 @@
/** Absolute import, analysis, bridge, and health startup deadline. */
export const GHIDRA_STARTUP_TIMEOUT_MS = 330_000;

View File

@ -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<{

View File

@ -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);