From d84bc8f11abd6cbf5612daf5c8f7e1805d23c16e Mon Sep 17 00:00:00 2001 From: Selim <36766571+LiquidSnake0@users.noreply.github.com> Date: Fri, 9 Oct 2026 01:21:36 +0200 Subject: [PATCH] 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> --- README.md | 2 + docs/installation.md | 8 ++- docs/mcp-contracts.md | 10 +-- src/config/environment.ts | 2 + src/config/ghidraStartupTimeout.ts | 20 ++++++ src/config/parseConfig.ts | 1 + src/config/types.ts | 1 + src/ghidra/GhidraClient.ts | 5 +- src/ghidra/GhidraDefaults.ts | 2 - src/ghidra/GhidraProvider.behavior.test.ts | 74 ++++++++++++++++++++++ src/ghidra/GhidraProviderClient.ts | 37 +++++++++-- 11 files changed, 145 insertions(+), 17 deletions(-) create mode 100644 src/config/ghidraStartupTimeout.ts delete mode 100644 src/ghidra/GhidraDefaults.ts diff --git a/README.md b/README.md index d5b22d365..8a38c7cfb 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/installation.md b/docs/installation.md index 5dcf4d99a..6ee14c8cc 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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. diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index fe7a4df8a..5a8b92de3 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -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`: diff --git a/src/config/environment.ts b/src/config/environment.ts index aebcd5be5..fe7e1245e 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -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) diff --git a/src/config/ghidraStartupTimeout.ts b/src/config/ghidraStartupTimeout.ts new file mode 100644 index 000000000..e476321ea --- /dev/null +++ b/src/config/ghidraStartupTimeout.ts @@ -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; + }); diff --git a/src/config/parseConfig.ts b/src/config/parseConfig.ts index 4ab6e02ee..36ddb4544 100644 --- a/src/config/parseConfig.ts +++ b/src/config/parseConfig.ts @@ -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 }), diff --git a/src/config/types.ts b/src/config/types.ts index 2e27260b5..149a77f4c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -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; diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 4930795ad..a7225df6e 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -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", }; diff --git a/src/ghidra/GhidraDefaults.ts b/src/ghidra/GhidraDefaults.ts deleted file mode 100644 index d04bc3e9d..000000000 --- a/src/ghidra/GhidraDefaults.ts +++ /dev/null @@ -1,2 +0,0 @@ -/** Absolute import, analysis, bridge, and health startup deadline. */ -export const GHIDRA_STARTUP_TIMEOUT_MS = 330_000; diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts index cff1ba04a..cc1181cbd 100644 --- a/src/ghidra/GhidraProvider.behavior.test.ts +++ b/src/ghidra/GhidraProvider.behavior.test.ts @@ -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<{ diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts index aed08e04b..e4456e31a 100644 --- a/src/ghidra/GhidraProviderClient.ts +++ b/src/ghidra/GhidraProviderClient.ts @@ -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);