From b0541c80b318ed360ba9f2d58b2e40a27466e9ce Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 10 Nov 2026 22:27:22 +0000 Subject: [PATCH] feat(settings): read per-call REA_ limits from the operation snapshot Part of #9881. Move the limits checked on each call into update_analysis_settings, keep their REA_ seeds and bounds, and reject updates outside the allowed range. --- src/application/AnalysisSettings.test.ts | 86 +++++++++- src/application/AnalysisSettings.ts | 5 +- src/config/analysisSettings.ts | 23 +++ src/config/bridgeMaxRequestBytes.ts | 57 +++++-- src/config/debuggerSessionLimits.ts | 2 +- src/config/hopperCallerScanBudget.test.ts | 30 ++-- src/config/hopperCallerScanBudget.ts | 19 ++- src/config/runtimeAnalysisSettings.ts | 27 +++ src/config/windowsChangeTimeSettle.ts | 46 +++++- src/domain/analysisOperationSnapshot.ts | 19 +++ src/domain/analysisResourceLimits.ts | 25 +++ src/domain/analysisSettingDefinitions.ts | 155 ++++++++++++++++++ src/domain/analysisSettings.ts | 11 ++ src/domain/numericLimitRegistry.test.ts | 14 +- src/domain/numericLimitRegistry.ts | 97 +---------- src/filesystem/ChangeTimeBaseline.ts | 3 +- src/gdb/GdbSessionManager.ts | 37 ++++- src/jeb/JebMcpConnection.ts | 19 ++- src/jeb/JebMcpDeadline.ts | 27 ++- src/kuna/KunaCommand.ts | 10 +- src/kuna/KunaConfiguration.ts | 9 +- src/kuna/KunaProjectExport.ts | 2 +- src/kuna/KunaSessionClient.ts | 16 +- src/native/pwntools/PwntoolsFailures.ts | 8 +- .../pwntools/PwntoolsInputLimit.test.ts | 3 +- src/native/pwntools/PwntoolsInputLimit.ts | 19 ++- src/process/ownedDebuggerSession.ts | 88 +++++++--- src/rizin/RizinDebugSessionManager.ts | 38 ++++- .../mcp/capabilityRemediationMcp.test.ts | 2 +- .../GdbSessionManagerCoverage.test.ts | 13 +- .../kuna/kunaIndependentDecompileMcp.test.ts | 14 +- .../providers/kuna/kunaProvider.test.ts | 2 +- .../kuna/kunaSingleFunctionRuns.test.ts | 16 +- .../providers/kuna/kunaStrings.test.ts | 16 +- 34 files changed, 704 insertions(+), 254 deletions(-) create mode 100644 src/domain/analysisOperationSnapshot.ts diff --git a/src/application/AnalysisSettings.test.ts b/src/application/AnalysisSettings.test.ts index ab6afcf4ff..1c1e5c81cf 100644 --- a/src/application/AnalysisSettings.test.ts +++ b/src/application/AnalysisSettings.test.ts @@ -1,5 +1,11 @@ import { expect, it } from "vitest"; import { AnalysisSettings } from "./AnalysisSettings.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + applyWindowsChangeTimeSnapshot, + resolveWindowsChangeTimeSettle, +} from "../config/windowsChangeTimeSettle.js"; +import { pwntoolsInputBytes } from "../native/pwntools/PwntoolsInputLimit.js"; import { runtimeAnalysisConfig, runtimeAsarHeaderAdmission, @@ -159,7 +165,7 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp store .inspect({ provider: "ghidra", operation: "search_strings" }) .settings.map(({ setting }) => setting), - ).toEqual(["ghidra.startup_timeout_ms"]); + ).toEqual(["ghidra.startup_timeout_ms", "bridge.max_request_bytes"]); expect( store .inspect({ provider: "ida", operation: "search_strings" }) @@ -169,7 +175,13 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp store .inspect({ operation: "inventory_artifact" }) .settings.map(({ setting }) => setting), - ).toEqual(["asar.max_listing_bytes", "asar.max_header_bytes"]); + ).toEqual([ + "asar.max_listing_bytes", + "asar.max_header_bytes", + "windows.change_time_drift_ms", + "windows.change_time_settle_poll_ms", + "windows.change_time_settle_max_polls", + ]); expect( store.update({ changes: [ @@ -217,3 +229,73 @@ it("exposes shared provider controls and preserves the explicit unbounded decomp ).toBe(true); expect(config.ghidraDecompileTimeoutSeconds).toBe(7); }); + +it("rejects an out-of-range per-call limit and reads an accepted value from the operation snapshot", () => { + const store = new AnalysisSettings({}); + const rejected = store.update({ + changes: [ + { setting: "windows.change_time_drift_ms", action: "set", value: 0 }, + ], + }); + expect(rejected.ok).toBe(false); + if (!rejected.ok) + expect(projectAnalysisError(rejected.error)).toMatchObject({ + code: "invalid_request", + }); + expect( + store.update({ + changes: [ + { setting: "bridge.max_request_bytes", action: "set", value: 1 }, + ], + }).ok, + ).toBe(false); + expect( + store.update({ + changes: [ + { + setting: "windows.change_time_settle_max_polls", + action: "set", + value: 1001, + }, + ], + }).ok, + ).toBe(false); + const accepted = store.update({ + changes: [ + { setting: "windows.change_time_drift_ms", action: "set", value: 5000 }, + { setting: "pwntools.max_input_bytes", action: "set", value: 65536 }, + { setting: "bridge.max_request_bytes", action: "set", value: 33554432 }, + ], + }); + expect(accepted.ok).toBe(true); + if (!accepted.ok) return; + expect(accepted.value).toMatchObject({ + effect: "immediate", + rea_restart_required: false, + }); + const parsed = parseConfig({}); + if (!parsed.ok) throw parsed.error; + const live = runtimeAnalysisConfig(parsed.value, store.read); + store.run(() => { + const windows = resolveWindowsChangeTimeSettle({}); + if (!windows.ok) throw windows.error; + expect(applyWindowsChangeTimeSnapshot(windows.value)).toMatchObject({ + driftMs: 5000, + driftSource: "runtime", + pollSource: "default", + }); + expect(pwntoolsInputBytes({})).toMatchObject({ + configured: true, + bytes: 65536, + source: "runtime", + }); + expect(live.bridgeMaxRequestBytes).toMatchObject({ + maximumBytes: 33554432, + source: "runtime", + }); + }); + expect(pwntoolsInputBytes({})).toMatchObject({ + bytes: 32 * 1024 * 1024, + source: "default", + }); +}); diff --git a/src/application/AnalysisSettings.ts b/src/application/AnalysisSettings.ts index 4b3c875a2f..554b8f0316 100644 --- a/src/application/AnalysisSettings.ts +++ b/src/application/AnalysisSettings.ts @@ -1,3 +1,4 @@ +import { runWithAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import { launchAnalysisSetting } from "../config/analysisSettings.js"; import { AsyncLocalStorage } from "node:async_hooks"; @@ -42,7 +43,9 @@ export class AnalysisSettings { readonly read = (): AnalysisSettingsSnapshot => this.#accepted.getStore() ?? this.#current; run(operation: () => Value): Value { - return this.#accepted.run(this.#current, operation); + return this.#accepted.run(this.#current, () => + runWithAnalysisOperationSnapshot(this.#current, operation), + ); } inspect(input: z.infer = {}) { return { diff --git a/src/config/analysisSettings.ts b/src/config/analysisSettings.ts index ad4e974218..72b3319746 100644 --- a/src/config/analysisSettings.ts +++ b/src/config/analysisSettings.ts @@ -17,6 +17,17 @@ import { regexMatchingDeadlineMsSchema } from "./regexMatchingDeadline.js"; import { regexWorkerHeapRequestMiBSchema } from "./regexWorkerHeapRequest.js"; import { captureDecoderOldGenerationMiBSchema } from "./captureDecoderHeap.js"; import { parseNativeMatchingAllowance } from "./nativeMatchingAllowance.js"; +import { bridgeMaxRequestBytesSchema } from "./bridgeMaxRequestBytes.js"; +import { + debuggerCommandDeadlineMsSchema, + debuggerMaxCommandOutputBytesSchema, +} from "./debuggerSessionLimits.js"; +import { + windowsChangeTimeDriftMsSchema, + windowsChangeTimeSettleMaxPollsSchema, + windowsChangeTimeSettlePollMsSchema, +} from "./windowsChangeTimeSettle.js"; +import { pwntoolsInputBytesSchema } from "../native/pwntools/PwntoolsInputLimit.js"; const environmentSchemas: Readonly>> = { REA_ASAR_MAX_HEADER_BYTES: asarMaxHeaderBytesSchema, @@ -33,6 +44,18 @@ const environmentSchemas: Readonly>> = REA_REGEX_WORKER_HEAP_REQUEST_MIB: regexWorkerHeapRequestMiBSchema, REA_CAPTURE_DECODER_OLD_GENERATION_MIB: captureDecoderOldGenerationMiBSchema, + REA_BRIDGE_MAX_REQUEST_BYTES: bridgeMaxRequestBytesSchema, + REA_DEBUGGER_COMMAND_DEADLINE_MS: debuggerCommandDeadlineMsSchema, + REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES: debuggerMaxCommandOutputBytesSchema, + REA_HOPPER_CALLER_SCAN_BUDGET_MS: providerStartupTimeoutSchema, + REA_JEB_MCP_TIMEOUT_MS: providerStartupTimeoutSchema, + REA_KUNA_PROJECT_TIMEOUT_MS: providerStartupTimeoutSchema, + REA_KUNA_TIMEOUT_MS: providerStartupTimeoutSchema, + REA_PWNTOOLS_MAX_INPUT_BYTES: pwntoolsInputBytesSchema, + REA_WINDOWS_CHANGE_TIME_DRIFT_MS: windowsChangeTimeDriftMsSchema, + REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS: windowsChangeTimeSettlePollMsSchema, + REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS: + windowsChangeTimeSettleMaxPollsSchema, }; export const launchAnalysisSetting = ( id: AnalysisSettingId, diff --git a/src/config/bridgeMaxRequestBytes.ts b/src/config/bridgeMaxRequestBytes.ts index 7eecd7f1d4..e92f080b6b 100644 --- a/src/config/bridgeMaxRequestBytes.ts +++ b/src/config/bridgeMaxRequestBytes.ts @@ -1,13 +1,23 @@ import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js"; +import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; import { err, ok, type Result } from "../domain/result.js"; +import { + DEFAULT_BRIDGE_MAX_REQUEST_BYTES, + MAXIMUM_BRIDGE_MAX_REQUEST_BYTES, + MINIMUM_BRIDGE_MAX_REQUEST_BYTES, +} from "../domain/analysisResourceLimits.js"; + export const BRIDGE_MAX_REQUEST_BYTES_SETTING = "REA_BRIDGE_MAX_REQUEST_BYTES"; -/** Request payload ceiling used when the setting is unset. Today's fixed constant. */ -export const DEFAULT_BRIDGE_MAX_REQUEST_BYTES = 16 * 1024 * 1024; +export { + DEFAULT_BRIDGE_MAX_REQUEST_BYTES, + MAXIMUM_BRIDGE_MAX_REQUEST_BYTES, + MINIMUM_BRIDGE_MAX_REQUEST_BYTES, +}; /** * Smallest ceiling that still admits the largest bounded bridge request. @@ -22,10 +32,7 @@ export const DEFAULT_BRIDGE_MAX_REQUEST_BYTES = 16 * 1024 * 1024; * Comments, search text, and Cutter command text are unbounded; this ceiling * is what limits them. The floor stays below the 16 MiB default so a caller * can lower the ceiling. - */ -export const MINIMUM_BRIDGE_MAX_REQUEST_BYTES = 7464; - -/** + * * Largest configurable request, in encoded payload bytes. * * The ceiling is the final JSON payload, not the raw command. Callers measure @@ -39,15 +46,11 @@ export const MINIMUM_BRIDGE_MAX_REQUEST_BYTES = 7464; * then parses that string. A fitting response does not keep the paged wire * frame, a second concatenated copy, and an outer JSON string. */ -const NODE_MAX_STRING_LENGTH = 536_870_888; -const FRAMING_LF_CODE_UNITS = 1; -export const MAXIMUM_BRIDGE_MAX_REQUEST_BYTES = - NODE_MAX_STRING_LENGTH - FRAMING_LF_CODE_UNITS; /** Effective bridge request ceiling and where its value came from. */ export interface BridgeMaxRequestBytes { readonly maximumBytes: number; - readonly source: "default" | "setting"; + readonly source: "default" | "setting" | "runtime"; } export const DEFAULT_BRIDGE_MAX_REQUEST: BridgeMaxRequestBytes = { @@ -107,16 +110,22 @@ export const resolveBridgeMaxRequestBytes = ( }; /** How to read and change the effective bridge request ceiling. */ +export const bridgeRequestLimit = (limit: BridgeMaxRequestBytes) => + analysisSettingLimit( + "bridge.max_request_bytes", + limit.maximumBytes, + limit.source, + ); + export const bridgeMaxRequestBytesRecovery = ( limit: BridgeMaxRequestBytes, peer: BridgePeer = "Hopper", ): string => [ - `The effective ${BRIDGE_MAX_REQUEST_BYTES_SETTING} value is ${String(limit.maximumBytes)} bytes (source ${limit.source}; default ${String(DEFAULT_BRIDGE_MAX_REQUEST_BYTES)}, floor ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)}, maximum ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}).`, - `Set ${BRIDGE_MAX_REQUEST_BYTES_SETTING} in the environment that starts REA to a decimal integer from ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)} to ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}, then restart REA.`, + `The effective bridge.max_request_bytes value is ${String(limit.maximumBytes)} bytes (source ${limit.source}; default ${String(DEFAULT_BRIDGE_MAX_REQUEST_BYTES)}, floor ${String(MINIMUM_BRIDGE_MAX_REQUEST_BYTES)}, maximum ${String(MAXIMUM_BRIDGE_MAX_REQUEST_BYTES)}). It can be changed at runtime with update_analysis_settings. The launch seed is ${BRIDGE_MAX_REQUEST_BYTES_SETTING}.`, peer === "Ghidra" - ? `The Ghidra bridge enforces no request ceiling of its own, so only the REA client applies ${BRIDGE_MAX_REQUEST_BYTES_SETTING} to Ghidra requests.` - : "Restart Hopper or Cutter from that same environment so the bridge uses this ceiling.", + ? `The Ghidra bridge enforces no request ceiling of its own, so only the REA client applies bridge.max_request_bytes to Ghidra requests.` + : "The Hopper or Cutter bridge keeps the ceiling it started with until that bridge starts again.", ].join(" "); const limitFacts = ( @@ -142,7 +151,11 @@ export const bridgeRequestOversizeError = ( "transport", `${refusal} ${recovery}`, { ...limitFacts(limit), payload_bytes: payloadBytes }, - { remediationAction: recovery, retryable: false }, + { + remediationAction: recovery, + retryable: false, + limit: bridgeRequestLimit(limit), + }, ); }; @@ -163,7 +176,11 @@ export const bridgeRequestLimitMismatchError = ( bridge_max_request_bytes: bridgeBytes, client_max_request_bytes: limit.maximumBytes, }, - { remediationAction: recovery, retryable: false }, + { + remediationAction: recovery, + retryable: false, + limit: bridgeRequestLimit(limit), + }, ); }; @@ -177,6 +194,10 @@ export const bridgeResponseOversizeError = ( "transport", `Cutter bridge response exceeded its framed transport limit of ${String(limit.maximumBytes)} bytes. ${recovery}`, limitFacts(limit), - { remediationAction: recovery, retryable: false }, + { + remediationAction: recovery, + retryable: false, + limit: bridgeRequestLimit(limit), + }, ); }; diff --git a/src/config/debuggerSessionLimits.ts b/src/config/debuggerSessionLimits.ts index 9a360822b6..6b9df708d0 100644 --- a/src/config/debuggerSessionLimits.ts +++ b/src/config/debuggerSessionLimits.ts @@ -34,7 +34,7 @@ export const DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING = export interface DebuggerAdmissionLimit { readonly setting: string; readonly value: number; - readonly source: "default" | "setting"; + readonly source: "default" | "setting" | "runtime"; readonly defaultValue: number; readonly minimum: number; readonly maximum: number; diff --git a/src/config/hopperCallerScanBudget.test.ts b/src/config/hopperCallerScanBudget.test.ts index f59ebb0f17..367c30344a 100644 --- a/src/config/hopperCallerScanBudget.test.ts +++ b/src/config/hopperCallerScanBudget.test.ts @@ -13,6 +13,7 @@ import { import { parseConfig } from "./parseConfig.js"; import { HOPPER_CALLER_SCAN_BUDGET_SETTING, + hopperCallerScanLimit, hopperCallerScanRecovery, } from "./hopperCallerScanBudget.js"; @@ -57,21 +58,16 @@ it("explains the time budget and that fixed byte allowances have no override", ( const parsed = parseConfig({ [HOPPER_CALLER_SCAN_BUDGET_SETTING]: "250" }); if (!parsed.ok) throw parsed.error; const budget = parsed.value.hopperCallerScanBudget; - const time = hopperCallerScanRecovery( - facts({ stop: { code: "work_budget", budget_ms: 250 } }), - budget, - ); - expect(time).toContain(`${HOPPER_CALLER_SCAN_BUDGET_SETTING} is 250 ms`); - expect(time).toContain( - `The default is ${String(DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS)} ms`, - ); - expect(time).toContain( - `from ${String(MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS)} to ${String(MAX_NODE_TIMER_DELAY_MS)} ms`, - ); - expect(time).toContain("no runtime override"); - expect(time).toContain( - `${HOPPER_CALLER_SCAN_BUDGET_SETTING} does not raise them`, - ); + expect(hopperCallerScanLimit(budget)).toMatchObject({ + setting: "hopper.caller_scan_budget_ms", + effective_value: 250, + source: "setting", + minimum: MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS, + maximum: MAX_NODE_TIMER_DELAY_MS, + effect: "immediate", + rea_restart_required: false, + }); + expect(DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS).toBe(2000); const page = hopperCallerScanRecovery( facts({ stop: { @@ -82,9 +78,7 @@ it("explains the time budget and that fixed byte allowances have no override", ( budget, ); expect(page).toContain("no runtime override"); - expect(page).toContain( - `${HOPPER_CALLER_SCAN_BUDGET_SETTING} does not raise them`, - ); + expect(page).toContain("hopper.caller_scan_budget_ms does not raise them"); expect(page).not.toContain("is 250 ms"); expect( hopperCallerScanRecovery(facts({ stop: { code: "complete" } }), budget), diff --git a/src/config/hopperCallerScanBudget.ts b/src/config/hopperCallerScanBudget.ts index 68af956a91..2f35abeebf 100644 --- a/src/config/hopperCallerScanBudget.ts +++ b/src/config/hopperCallerScanBudget.ts @@ -1,3 +1,4 @@ +import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; import { DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS, MAX_NODE_TIMER_DELAY_MS, @@ -16,7 +17,7 @@ export const HOPPER_CALLER_SCAN_BUDGET_SETTING = export interface HopperCallerScanBudget { readonly setting: typeof HOPPER_CALLER_SCAN_BUDGET_SETTING; readonly milliseconds: number; - readonly source: "default" | "setting"; + readonly source: "default" | "setting" | "runtime"; readonly defaultMilliseconds: number; readonly floorMilliseconds: number; readonly maximumMilliseconds: number; @@ -52,7 +53,15 @@ export const hopperCallerScanBudgetForMilliseconds = ( milliseconds, ); -const fixedAllowanceRecovery = `This release has no runtime override for the ${String(HOPPER_CALLER_PAGE_ALLOWANCE_BYTES)} byte instruction, native-caller, and page allowances or the ${String(HOPPER_CALLER_SCAN_RETENTION_BYTES)} byte retention budget. ${HOPPER_CALLER_SCAN_BUDGET_SETTING} does not raise them.`; +const fixedAllowanceRecovery = `This release has no runtime override for the ${String(HOPPER_CALLER_PAGE_ALLOWANCE_BYTES)} byte instruction, native-caller, and page allowances or the ${String(HOPPER_CALLER_SCAN_RETENTION_BYTES)} byte retention budget. hopper.caller_scan_budget_ms does not raise them.`; + +/** Structured facts for the scan time budget. The sentence is display text. */ +export const hopperCallerScanLimit = (budget: HopperCallerScanBudget) => + analysisSettingLimit( + "hopper.caller_scan_budget_ms", + budget.milliseconds, + budget.source, + ); /** Environment recovery for a caller-scan limit. Domain facts stay separate. */ export const hopperCallerScanRecovery = ( @@ -64,7 +73,9 @@ export const hopperCallerScanRecovery = ( facts.stop.code === "retention_budget" || !facts.instructions_complete || facts.native_callers_complete === false; - if (facts.stop.code === "work_budget") - return `${budget.setting} is ${String(budget.milliseconds)} ms (source: ${budget.source}). The default is ${String(budget.defaultMilliseconds)} ms. Allowed values are integers from ${String(budget.floorMilliseconds)} to ${String(budget.maximumMilliseconds)} ms. Set it in the environment that starts REA and restart REA. ${fixedAllowanceRecovery}`; + if (facts.stop.code === "work_budget") { + const limit = hopperCallerScanLimit(budget); + return `hopper.caller_scan_budget_ms is ${String(limit.effective_value)} ms (source ${limit.source ?? "unknown"}; default ${String(budget.defaultMilliseconds)} ms, floor ${String(limit.minimum)} ms, maximum ${String(limit.maximum)} ms). It can be changed at runtime with update_analysis_settings. The launch seed is ${budget.setting}. ${fixedAllowanceRecovery}`; + } return fixedLimit ? fixedAllowanceRecovery : ""; }; diff --git a/src/config/runtimeAnalysisSettings.ts b/src/config/runtimeAnalysisSettings.ts index 437ac745ae..e32ed794f4 100644 --- a/src/config/runtimeAnalysisSettings.ts +++ b/src/config/runtimeAnalysisSettings.ts @@ -29,6 +29,8 @@ export const runtimeAnalysisConfig = ( if (read === undefined) return config; const deadline = config.regexMatchingDeadline; const heap = config.regexWorkerHeapRequest; + const scan = config.hopperCallerScanBudget; + const bridge = config.bridgeMaxRequestBytes; return { ...config, get ghidraStartupTimeoutMs() { @@ -104,6 +106,31 @@ export const runtimeAnalysisConfig = ( : "runtime"; }, }, + hopperCallerScanBudget: { + ...scan, + get milliseconds() { + return ( + read().overrides["hopper.caller_scan_budget_ms"] ?? scan.milliseconds + ); + }, + get source() { + return read().overrides["hopper.caller_scan_budget_ms"] === undefined + ? scan.source + : "runtime"; + }, + }, + bridgeMaxRequestBytes: { + get maximumBytes() { + return ( + read().overrides["bridge.max_request_bytes"] ?? bridge.maximumBytes + ); + }, + get source() { + return read().overrides["bridge.max_request_bytes"] === undefined + ? bridge.source + : "runtime"; + }, + }, }; }; export const runtimeAsarHeaderAdmission = ( diff --git a/src/config/windowsChangeTimeSettle.ts b/src/config/windowsChangeTimeSettle.ts index 65d872fb7b..2cd57c5617 100644 --- a/src/config/windowsChangeTimeSettle.ts +++ b/src/config/windowsChangeTimeSettle.ts @@ -1,5 +1,14 @@ import { z } from "zod"; +import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js"; +import { + DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS, + DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, + DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, + MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS, + MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, + MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, +} from "../domain/analysisResourceLimits.js"; import { ConfigurationError } from "../domain/configurationErrors.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -19,18 +28,18 @@ interface WindowsChangeTimeBound { */ export const WINDOWS_CHANGE_TIME_DRIFT_MS = { setting: "REA_WINDOWS_CHANGE_TIME_DRIFT_MS", - defaultValue: 2_000, + defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS, floor: 1, - maximum: 60_000, + maximum: MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS, unit: "ms", } as const satisfies WindowsChangeTimeBound; /** Delay between ChangeTime observations. Unset keeps the 25 ms poll. */ export const WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = { setting: "REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS", - defaultValue: 25, + defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, floor: 1, - maximum: 1_000, + maximum: MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, unit: "ms", } as const satisfies WindowsChangeTimeBound; @@ -40,9 +49,9 @@ export const WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = { */ export const WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS = { setting: "REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS", - defaultValue: 40, + defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, floor: 1, - maximum: 1_000, + maximum: MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, unit: "polls", } as const satisfies WindowsChangeTimeBound; @@ -61,11 +70,11 @@ export interface WindowsChangeTimeSettingFailure { /** Effective settle budget and whether each part came from the default or a setting. */ export interface WindowsChangeTimeSettleLimits { readonly driftMs: number; - readonly driftSource: "default" | "setting"; + readonly driftSource: "default" | "setting" | "runtime"; readonly pollMs: number; - readonly pollSource: "default" | "setting"; + readonly pollSource: "default" | "setting" | "runtime"; readonly maxPolls: number; - readonly maxPollsSource: "default" | "setting"; + readonly maxPollsSource: "default" | "setting" | "runtime"; } const windowsChangeTimeSettingFailure = ( @@ -176,3 +185,22 @@ export const resolveWindowsChangeTimeSettle = ( maxPollsSource: maxPolls === undefined ? "default" : "setting", }); }; + +/** Apply the accepted operation snapshot. An explicit caller budget is left alone. */ +export const applyWindowsChangeTimeSnapshot = ( + limits: WindowsChangeTimeSettleLimits, +): WindowsChangeTimeSettleLimits => { + const overrides = currentAnalysisOperationSnapshot()?.overrides; + if (overrides === undefined) return limits; + const driftMs = overrides["windows.change_time_drift_ms"]; + const pollMs = overrides["windows.change_time_settle_poll_ms"]; + const maxPolls = overrides["windows.change_time_settle_max_polls"]; + return { + driftMs: driftMs ?? limits.driftMs, + driftSource: driftMs === undefined ? limits.driftSource : "runtime", + pollMs: pollMs ?? limits.pollMs, + pollSource: pollMs === undefined ? limits.pollSource : "runtime", + maxPolls: maxPolls ?? limits.maxPolls, + maxPollsSource: maxPolls === undefined ? limits.maxPollsSource : "runtime", + }; +}; diff --git a/src/domain/analysisOperationSnapshot.ts b/src/domain/analysisOperationSnapshot.ts new file mode 100644 index 0000000000..c49173f79a --- /dev/null +++ b/src/domain/analysisOperationSnapshot.ts @@ -0,0 +1,19 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { AnalysisSettingsSnapshot } from "./analysisSettings.js"; + +const snapshots = new AsyncLocalStorage(); + +/** + * Bind the snapshot accepted for one operation. Providers and filesystem + * helpers read it at the point they enforce a limit. A later update does not + * change work already inside this call. + */ +export const runWithAnalysisOperationSnapshot = ( + snapshot: AnalysisSettingsSnapshot, + operation: () => Value, +): Value => snapshots.run(snapshot, operation); + +/** The snapshot for the operation on this async stack, when one was accepted. */ +export const currentAnalysisOperationSnapshot = (): + | AnalysisSettingsSnapshot + | undefined => snapshots.getStore(); diff --git a/src/domain/analysisResourceLimits.ts b/src/domain/analysisResourceLimits.ts index 54a692b8d2..11d5a7e8a4 100644 --- a/src/domain/analysisResourceLimits.ts +++ b/src/domain/analysisResourceLimits.ts @@ -191,3 +191,28 @@ export const MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES = Math.floor( * a caller cap below this budget must not truncate those required replies. */ export const DEBUGGER_CONTROL_REPLY_BYTES = 16 * 1024 * 1024; + +/** Hopper, Ghidra, and Cutter request payload ceiling. Same bound as the bridge owner. */ +export const DEFAULT_BRIDGE_MAX_REQUEST_BYTES = 16 * 1024 * 1024; +export const MINIMUM_BRIDGE_MAX_REQUEST_BYTES = 7464; +export const MAXIMUM_BRIDGE_MAX_REQUEST_BYTES = 536_870_888 - 1; + +/** MCP client SDK default request timeout. JEB uses it when the setting is unset. */ +export const DEFAULT_JEB_MCP_TIMEOUT_MS = 60_000; + +/** Kuna project export and whole-program scans. */ +export const DEFAULT_KUNA_PROJECT_TIMEOUT_MS = 30 * 60 * 1000; +/** One Kuna function decompile. */ +export const DEFAULT_KUNA_FUNCTION_TIMEOUT_MS = 120_000; + +/** Pwntools artifact admission when producer_limits.input_bytes is omitted. */ +export const DEFAULT_PWNTOOLS_MAX_INPUT_BYTES = 32 * 1024 * 1024; +export const MAXIMUM_PWNTOOLS_MAX_INPUT_BYTES = 1024 * 1024 * 1024; + +/** Windows ChangeTime settle window, poll, and poll count. */ +export const DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS = 2_000; +export const MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS = 60_000; +export const DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = 25; +export const MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS = 1_000; +export const DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS = 40; +export const MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS = 1_000; diff --git a/src/domain/analysisSettingDefinitions.ts b/src/domain/analysisSettingDefinitions.ts index 33f55302a3..9db4b0ecf9 100644 --- a/src/domain/analysisSettingDefinitions.ts +++ b/src/domain/analysisSettingDefinitions.ts @@ -29,6 +29,28 @@ import { DEFAULT_JADX_OPERATION_TIMEOUT_MS, DEFAULT_NATIVE_MATCHING_REPRESENTATION_BYTES, MAXIMUM_NATIVE_MATCHING_REPRESENTATION_BYTES, + DEFAULT_BRIDGE_MAX_REQUEST_BYTES, + MINIMUM_BRIDGE_MAX_REQUEST_BYTES, + MAXIMUM_BRIDGE_MAX_REQUEST_BYTES, + DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS, + MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS, + MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS, + DEFAULT_JEB_MCP_TIMEOUT_MS, + DEFAULT_KUNA_FUNCTION_TIMEOUT_MS, + DEFAULT_KUNA_PROJECT_TIMEOUT_MS, + DEFAULT_PWNTOOLS_MAX_INPUT_BYTES, + MAXIMUM_PWNTOOLS_MAX_INPUT_BYTES, + DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS, + MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS, + DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, + MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, + DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, + MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, } from "./analysisResourceLimits.js"; import { JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS, @@ -313,6 +335,139 @@ export const ANALYSIS_SETTINGS = { provider: "rea-function-comparison", operations: ["match_native_functions"], }, + "bridge.max_request_bytes": { + environment: "REA_BRIDGE_MAX_REQUEST_BYTES", + defaultValue: DEFAULT_BRIDGE_MAX_REQUEST_BYTES, + minimum: MINIMUM_BRIDGE_MAX_REQUEST_BYTES, + maximum: MAXIMUM_BRIDGE_MAX_REQUEST_BYTES, + unit: "bytes", + appliesAt: "next_operation", + provider: "hopper", + relatedProviders: ["ghidra", "cutter"], + operations: [ + "procedure_pseudo_code", + "analyze_function", + "search_strings", + "set_address_name", + ], + }, + "debugger.command_deadline_ms": { + ...timer, + environment: "REA_DEBUGGER_COMMAND_DEADLINE_MS", + defaultValue: DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS, + minimum: MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + maximum: MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + provider: "gdb", + relatedProviders: ["rizin"], + operations: [ + "start_gdb_session", + "gdb_console", + "start_rizin_debug_session", + "rizin_debug_command", + ], + }, + "debugger.max_command_output_bytes": { + environment: "REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES", + defaultValue: DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + minimum: MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + maximum: MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + unit: "bytes", + appliesAt: "next_operation", + provider: "gdb", + relatedProviders: ["rizin"], + operations: ["gdb_console", "rizin_debug_command"], + }, + "hopper.caller_scan_budget_ms": { + ...timer, + environment: "REA_HOPPER_CALLER_SCAN_BUDGET_MS", + defaultValue: DEFAULT_HOPPER_CALLER_SCAN_BUDGET_MS, + minimum: MINIMUM_HOPPER_CALLER_SCAN_BUDGET_MS, + provider: "hopper", + operations: [ + "procedure_callers", + "analyze_function", + "procedure_references", + ], + }, + "jeb.mcp_timeout_ms": { + ...timer, + environment: "REA_JEB_MCP_TIMEOUT_MS", + defaultValue: DEFAULT_JEB_MCP_TIMEOUT_MS, + provider: "jeb", + operations: [ + "inspect_jeb_client", + "open_jeb_project", + "list_jeb_units", + "decompile_jeb_item", + ], + }, + "kuna.project_timeout_ms": { + ...timer, + environment: "REA_KUNA_PROJECT_TIMEOUT_MS", + defaultValue: DEFAULT_KUNA_PROJECT_TIMEOUT_MS, + provider: "kuna", + operations: [ + "list_strings", + "search_strings", + "list_procedures", + "search_procedures", + "xrefs", + "procedure_callers", + "procedure_callees", + ], + }, + "kuna.function_timeout_ms": { + ...timer, + environment: "REA_KUNA_TIMEOUT_MS", + defaultValue: DEFAULT_KUNA_FUNCTION_TIMEOUT_MS, + provider: "kuna", + operations: [ + "procedure_pseudo_code", + "procedure_assembly", + "analyze_function", + ], + }, + "pwntools.max_input_bytes": { + environment: "REA_PWNTOOLS_MAX_INPUT_BYTES", + defaultValue: DEFAULT_PWNTOOLS_MAX_INPUT_BYTES, + minimum: 1, + maximum: MAXIMUM_PWNTOOLS_MAX_INPUT_BYTES, + unit: "bytes", + appliesAt: "next_operation", + provider: "pwntools", + operations: ["inspect_binary_layout", "inspect_recorded_crash"], + parameter: "input_bytes", + }, + "windows.change_time_drift_ms": { + environment: "REA_WINDOWS_CHANGE_TIME_DRIFT_MS", + defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_DRIFT_MS, + minimum: 1, + maximum: MAXIMUM_WINDOWS_CHANGE_TIME_DRIFT_MS, + unit: "ms", + appliesAt: "next_operation", + provider: "rea-artifact-graph", + operations: ["inventory_artifact", "inspect_artifact", "extract_artifact"], + }, + "windows.change_time_settle_poll_ms": { + environment: "REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS", + defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, + minimum: 1, + maximum: MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS, + unit: "ms", + appliesAt: "next_operation", + provider: "rea-artifact-graph", + operations: ["inventory_artifact", "inspect_artifact", "extract_artifact"], + }, + "windows.change_time_settle_max_polls": { + environment: "REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS", + defaultValue: DEFAULT_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, + minimum: 1, + maximum: MAXIMUM_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS, + unit: "count", + appliesAt: "next_operation", + provider: "rea-artifact-graph", + operations: ["inventory_artifact", "inspect_artifact", "extract_artifact"], + }, } as const satisfies Record; export const analysisSettingIds = Object.keys( ANALYSIS_SETTINGS, diff --git a/src/domain/analysisSettings.ts b/src/domain/analysisSettings.ts index 04358a8404..0f3b59ebc9 100644 --- a/src/domain/analysisSettings.ts +++ b/src/domain/analysisSettings.ts @@ -17,6 +17,17 @@ export interface AnalysisSettingValues { readonly "javascript.max_heap_mb": number; readonly "wasm.command_timeout_ms": number; readonly "native.matching_representation_bytes": number; + readonly "bridge.max_request_bytes": number; + readonly "debugger.command_deadline_ms": number; + readonly "debugger.max_command_output_bytes": number; + readonly "hopper.caller_scan_budget_ms": number; + readonly "jeb.mcp_timeout_ms": number; + readonly "kuna.project_timeout_ms": number; + readonly "kuna.function_timeout_ms": number; + readonly "pwntools.max_input_bytes": number; + readonly "windows.change_time_drift_ms": number; + readonly "windows.change_time_settle_poll_ms": number; + readonly "windows.change_time_settle_max_polls": number; } export type AnalysisSettingId = keyof AnalysisSettingValues; export interface AnalysisSettingsSnapshot { diff --git a/src/domain/numericLimitRegistry.test.ts b/src/domain/numericLimitRegistry.test.ts index 26db8f9ae5..70fc8442ed 100644 --- a/src/domain/numericLimitRegistry.test.ts +++ b/src/domain/numericLimitRegistry.test.ts @@ -39,22 +39,14 @@ it("gives every numeric exception a reason and a matching inventory row", () => for (const entry of NUMERIC_LIMIT_REGISTRY) { expect(entry.update_mode).toBe("restart"); expect(entry.reason.trim().length).toBeGreaterThanOrEqual(40); - if (entry.policy === "restart-only") - expect(["startup", "construction"]).toContain(entry.read); - expect(entry.read).not.toBe( - entry.policy === "per-call" ? "startup" : "per-call", - ); + expect(entry.policy).toBe("restart-only"); + expect(["startup", "construction"]).toContain(entry.read); expect(runtimeEnvironments.has(entry.name)).toBe(false); const row = exceptionRow(entry.name); expect(row.length).toBeGreaterThanOrEqual(5); expect(row[1]?.length ?? 0).toBeGreaterThan(0); } - expect( - NUMERIC_LIMIT_REGISTRY.filter((entry) => entry.policy === "restart-only"), - ).toHaveLength(15); - expect( - NUMERIC_LIMIT_REGISTRY.filter((entry) => entry.policy === "per-call"), - ).toHaveLength(11); + expect(NUMERIC_LIMIT_REGISTRY).toHaveLength(15); for (const definition of Object.values(ANALYSIS_SETTINGS)) { if (!("environment" in definition) || definition.environment === undefined) continue; diff --git a/src/domain/numericLimitRegistry.ts b/src/domain/numericLimitRegistry.ts index 46ff63c4d6..7db3abb84c 100644 --- a/src/domain/numericLimitRegistry.ts +++ b/src/domain/numericLimitRegistry.ts @@ -4,9 +4,8 @@ * The static check in `scripts/verify/rea-numeric-limits.mjs` checks recognized * TypeScript reads against this list and `ANALYSIS_SETTINGS`. External readers * and generated controls declare separate behavioral witnesses, which the - * Node-only check cannot verify. `restart-only` is the durable exception. `per-call` - * names a limit still read from the launch environment on each use; the - * runtime migration deletes that entry instead of leaving it here. + * Node-only check cannot verify. Every remaining entry is restart-only; limits + * checked on each call belong in `ANALYSIS_SETTINGS` instead of this list. * * Narrative bounds, owners, and the non-numeric settings live in * `docs/design/rea-limit-inventory.md`. @@ -14,8 +13,8 @@ export interface NumericLimitRegistryEntry { readonly name: string; readonly update_mode: "restart"; - readonly policy: "restart-only" | "per-call"; - readonly read: "startup" | "per-call" | "construction"; + readonly policy: "restart-only"; + readonly read: "startup" | "construction"; readonly reason: string; /** Read outside the TypeScript scan or generated into a bridge; execution needs its behavioral witness. */ readonly provenance?: { @@ -28,30 +27,6 @@ export interface NumericLimitRegistryEntry { } export const NUMERIC_LIMIT_REGISTRY = [ - { - name: "REA_BRIDGE_MAX_REQUEST_BYTES", - update_mode: "restart", - policy: "per-call", - read: "per-call", - reason: - "The Hopper, Ghidra, and Cutter clients measure each request against this ceiling. The value is still taken from the launch environment, so a running process does not see a new value until the runtime migration removes this entry.", - }, - { - name: "REA_DEBUGGER_COMMAND_DEADLINE_MS", - update_mode: "restart", - policy: "per-call", - read: "construction", - reason: - "GDB and Rizin apply this deadline to each debugger command. The session manager currently copies it at construction from the launch environment; the runtime migration reads the accepted operation snapshot instead.", - }, - { - name: "REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES", - update_mode: "restart", - policy: "per-call", - read: "construction", - reason: - "Each debugger command retains output up to this ceiling. The session manager currently copies it at construction from the launch environment; the runtime migration reads the accepted operation snapshot instead.", - }, { name: "REA_DEBUGGER_MAX_PENDING_COMMANDS", update_mode: "restart", @@ -68,14 +43,6 @@ export const NUMERIC_LIMIT_REGISTRY = [ reason: "Caps how many debugger sessions the session manager created at startup will admit. Changing the pool size of a manager that already exists requires a new REA process.", }, - { - name: "REA_HOPPER_CALLER_SCAN_BUDGET_MS", - update_mode: "restart", - policy: "per-call", - read: "per-call", - reason: - "Forward caller reconciliation reads this budget for each scan. It is still resolved from the launch environment; the runtime migration reads the accepted operation snapshot.", - }, { name: "REA_HOPPER_DEMO_DIALOG_TIMEOUT_MS", update_mode: "restart", @@ -105,14 +72,6 @@ export const NUMERIC_LIMIT_REGISTRY = [ reason: "Passed to the JVM as -Xmx when the JADX worker process starts. A running JVM does not adopt a new heap size.", }, - { - name: "REA_JEB_MCP_TIMEOUT_MS", - update_mode: "restart", - policy: "per-call", - read: "construction", - reason: - "Bounds each JEB tools/call and catalog discovery. The connection currently captures the launch value when it is created; the runtime migration reads the accepted snapshot for each request.", - }, { name: "REA_KUNA_CACHE_MAX_BYTES", update_mode: "restart", @@ -129,22 +88,6 @@ export const NUMERIC_LIMIT_REGISTRY = [ reason: "Selects the Kuna worker-pool width when the provider builds its run slots. The pool is not resized for a process that is already running.", }, - { - name: "REA_KUNA_PROJECT_TIMEOUT_MS", - update_mode: "restart", - policy: "per-call", - read: "per-call", - reason: - "Bounds a Kuna project export and the whole-program scans that use that deadline. The configuration object still comes from the launch environment; the runtime migration reads the accepted snapshot at each use.", - }, - { - name: "REA_KUNA_TIMEOUT_MS", - update_mode: "restart", - policy: "per-call", - read: "per-call", - reason: - "Bounds one Kuna function decompile. The configuration object still comes from the launch environment; the runtime migration reads the accepted snapshot at each use.", - }, { name: "REA_MCP_DOCTOR_DEADLINE_MS", update_mode: "restart", @@ -193,14 +136,6 @@ export const NUMERIC_LIMIT_REGISTRY = [ reason: "Bounds the MCP tools/list phase while the server is starting. It shapes server startup, not a later analysis call.", }, - { - name: "REA_PWNTOOLS_MAX_INPUT_BYTES", - update_mode: "restart", - policy: "per-call", - read: "per-call", - reason: - "Default artifact-size admission for a pwntools decode. A per-call producer_limits.input_bytes override already exists; the launch default is still read from the environment and the runtime migration moves that default into the snapshot.", - }, { name: "REA_RECONNECT_TIMEOUT_SECONDS", update_mode: "restart", @@ -224,28 +159,4 @@ export const NUMERIC_LIMIT_REGISTRY = [ reason: "Captured when the Darwin ownership-helper reader is constructed at process start. That helper compile uses the captured deadline, so a later value needs a new REA process.", }, - { - name: "REA_WINDOWS_CHANGE_TIME_DRIFT_MS", - update_mode: "restart", - policy: "per-call", - read: "per-call", - reason: - "Each Windows ChangeTime observation compares the file against this drift window. The observation still parses the launch environment; the runtime migration reads the accepted operation snapshot.", - }, - { - name: "REA_WINDOWS_CHANGE_TIME_SETTLE_MAX_POLLS", - update_mode: "restart", - policy: "per-call", - read: "per-call", - reason: - "Each Windows ChangeTime settle loop stops after this many polls. The observation still parses the launch environment; the runtime migration reads the accepted operation snapshot.", - }, - { - name: "REA_WINDOWS_CHANGE_TIME_SETTLE_POLL_MS", - update_mode: "restart", - policy: "per-call", - read: "per-call", - reason: - "Each Windows ChangeTime settle loop waits this long between reads. The observation still parses the launch environment; the runtime migration reads the accepted operation snapshot.", - }, ] as const satisfies readonly NumericLimitRegistryEntry[]; diff --git a/src/filesystem/ChangeTimeBaseline.ts b/src/filesystem/ChangeTimeBaseline.ts index 6b2ad18218..9e7ccf6b4f 100644 --- a/src/filesystem/ChangeTimeBaseline.ts +++ b/src/filesystem/ChangeTimeBaseline.ts @@ -1,6 +1,7 @@ import type { Stats } from "node:fs"; import { + applyWindowsChangeTimeSnapshot, resolveWindowsChangeTimeSettle, type WindowsChangeTimeSettleLimits, } from "../config/windowsChangeTimeSettle.js"; @@ -49,7 +50,7 @@ const limitsFrom = ( seams.environment ?? process.env, ); if (!resolved.ok) throw resolved.error; - return resolved.value; + return applyWindowsChangeTimeSnapshot(resolved.value); }; /** Stop settling as soon as the owning operation is cancelled. */ diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index bca7d16c45..d63f313e63 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -23,6 +23,7 @@ import type { GdbOutputLimit, } from "../domain/commandObservation.js"; import { DEBUGGER_CONTROL_REPLY_BYTES } from "../domain/analysisResourceLimits.js"; +import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js"; import { err, ok, type Result } from "../domain/result.js"; import { ProviderProcessSupervisor, @@ -596,8 +597,7 @@ export class GdbSessionManager { >(); readonly #command: string; readonly #environment: NodeJS.ProcessEnv; - readonly #commandDeadline: DebuggerAdmissionLimit; - readonly #commandTimeoutMs: number; + readonly #launchCommandDeadline: DebuggerAdmissionLimit; readonly #startupTimeoutMs: number; readonly #platform: NodeJS.Platform; readonly #stopProcess: typeof stopOwnedDebuggerProcess; @@ -610,9 +610,32 @@ export class GdbSessionManager { readonly #readInferiorProcessStartTime: ProcessStartTimeReader | undefined; readonly #maxSessions: DebuggerAdmissionLimit; readonly #maxPendingCommands: DebuggerAdmissionLimit; - readonly #commandOutput: DebuggerAdmissionLimit; - readonly #maxCommandOutputBytes: number; + readonly #launchCommandOutput: DebuggerAdmissionLimit; readonly #controlReplyBytes: number; + get #commandDeadline(): DebuggerAdmissionLimit { + const override = + currentAnalysisOperationSnapshot()?.overrides[ + "debugger.command_deadline_ms" + ]; + return override === undefined + ? this.#launchCommandDeadline + : { ...this.#launchCommandDeadline, value: override, source: "runtime" }; + } + get #commandTimeoutMs(): number { + return this.#commandDeadline.value; + } + get #commandOutput(): DebuggerAdmissionLimit { + const override = + currentAnalysisOperationSnapshot()?.overrides[ + "debugger.max_command_output_bytes" + ]; + return override === undefined + ? this.#launchCommandOutput + : { ...this.#launchCommandOutput, value: override, source: "runtime" }; + } + get #maxCommandOutputBytes(): number { + return this.#commandOutput.value; + } #sessionReservations = 0; #closing = false; #closePromise: Promise | undefined; @@ -642,8 +665,7 @@ export class GdbSessionManager { this.#environment = { ...(options.environment ?? process.env) }; this.#command = this.#environment.REA_GDB_COMMAND ?? "gdb"; const limits = resolveDebuggerSessionLimits(this.#environment); - this.#commandDeadline = limits.commandDeadline; - this.#commandTimeoutMs = this.#commandDeadline.value; + this.#launchCommandDeadline = limits.commandDeadline; this.#startupTimeoutMs = options.startupTimeoutMs ?? STARTUP_TIMEOUT_MS; this.#platform = options.platform ?? process.platform; this.#stopProcess = options.stopProcess ?? stopOwnedDebuggerProcess; @@ -653,8 +675,7 @@ export class GdbSessionManager { this.#readInferiorProcessStartTime = options.readInferiorProcessStartTime; this.#maxSessions = limits.maxSessions; this.#maxPendingCommands = limits.maxPendingCommands; - this.#commandOutput = limits.maxCommandOutputBytes; - this.#maxCommandOutputBytes = this.#commandOutput.value; + this.#launchCommandOutput = limits.maxCommandOutputBytes; this.#controlReplyBytes = options.controlReplyBytes ?? DEBUGGER_CONTROL_REPLY_BYTES; } diff --git a/src/jeb/JebMcpConnection.ts b/src/jeb/JebMcpConnection.ts index 3fad0c20f1..df4a35dd41 100644 --- a/src/jeb/JebMcpConnection.ts +++ b/src/jeb/JebMcpConnection.ts @@ -7,6 +7,7 @@ import { mcpTextBlocks, parseMcpJsonOrText, } from "../providers/McpToolResult.js"; +import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js"; import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js"; import { AnalysisCancelledError, @@ -99,10 +100,14 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( endpoint, timeouts, ) => { - const discoveryTimeoutMs = - timeouts?.discoveryTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MSEC; - const requestTimeoutMs = - timeouts?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MSEC; + const discoveryTimeoutMs = () => + currentAnalysisOperationSnapshot()?.overrides["jeb.mcp_timeout_ms"] ?? + timeouts?.discoveryTimeoutMs ?? + DEFAULT_REQUEST_TIMEOUT_MSEC; + const requestTimeoutMs = () => + currentAnalysisOperationSnapshot()?.overrides["jeb.mcp_timeout_ms"] ?? + timeouts?.requestTimeoutMs ?? + DEFAULT_REQUEST_TIMEOUT_MSEC; let state: JebMcpConnectionOwner | undefined; let sessionGeneration = 0; let closing: Promise | undefined; @@ -211,7 +216,7 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( Result >(async (startupSignal) => { const deadline = new JebMcpDeadline( - discoveryTimeoutMs, + discoveryTimeoutMs(), startupSignal, "discovery", ); @@ -277,7 +282,7 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( ? owner.signal.signal : AbortSignal.any([signal, owner.signal.signal]); const deadline = new JebMcpDeadline( - discoveryTimeoutMs, + discoveryTimeoutMs(), discoverySignal, "discovery", ); @@ -307,7 +312,7 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( signal === undefined ? owner.signal.signal : AbortSignal.any([signal, owner.signal.signal]); - const deadline = new JebMcpDeadline(requestTimeoutMs, requestSignal); + const deadline = new JebMcpDeadline(requestTimeoutMs(), requestSignal); let result: unknown; try { result = await owner.client.callTool( diff --git a/src/jeb/JebMcpDeadline.ts b/src/jeb/JebMcpDeadline.ts index 31a2ece7f7..8b4e0eaf00 100644 --- a/src/jeb/JebMcpDeadline.ts +++ b/src/jeb/JebMcpDeadline.ts @@ -7,20 +7,35 @@ import { AnalysisCancelledError, AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; -import { MAX_NODE_TIMER_DELAY_MS } from "../domain/analysisResourceLimits.js"; +import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; +import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js"; +import { + DEFAULT_JEB_MCP_TIMEOUT_MS, + MAX_NODE_TIMER_DELAY_MS, +} from "../domain/analysisResourceLimits.js"; import { ProviderStartupDeadline } from "../process/ProviderDeadline.js"; /** Which JEB MCP phase consumed `REA_JEB_MCP_TIMEOUT_MS`. */ export type JebMcpDeadlinePhase = "discovery" | "tools/call"; -/** Name the launch-environment deadline that both JEB phases share. */ +/** Name the deadline that both JEB phases share. */ export const jebMcpTimeoutGuidance = ( phase: JebMcpDeadlinePhase, timeoutMs: number, -): { readonly userMessage: string; readonly remediationAction: string } => ({ - userMessage: `This request reached REA's configured JEB ${phase === "discovery" ? "catalog discovery" : phase} deadline of ${String(timeoutMs)} ms (deadlineExpired).`, - remediationAction: `Increase REA_JEB_MCP_TIMEOUT_MS in REA's launch environment to an integer from 1 to ${String(MAX_NODE_TIMER_DELAY_MS)} milliseconds (omitted means ${String(DEFAULT_REQUEST_TIMEOUT_MSEC)} ms) and restart and reconnect REA to apply it. The same setting raises catalog discovery and each tools/call. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, -}); +) => { + const override = + currentAnalysisOperationSnapshot()?.overrides["jeb.mcp_timeout_ms"]; + const limit = analysisSettingLimit( + "jeb.mcp_timeout_ms", + timeoutMs, + override === undefined ? undefined : "runtime", + ); + return { + userMessage: `This request reached REA's configured JEB ${phase === "discovery" ? "catalog discovery" : phase} deadline of ${String(timeoutMs)} ms (deadlineExpired).`, + remediationAction: `jeb.mcp_timeout_ms is ${String(timeoutMs)} ms (source ${limit.source ?? "unknown"}; default ${String(DEFAULT_JEB_MCP_TIMEOUT_MS)} ms, floor ${String(limit.minimum)} ms, maximum ${String(limit.maximum)} ms). It can be changed at runtime with update_analysis_settings. The launch seed is REA_JEB_MCP_TIMEOUT_MS. The same setting raises catalog discovery and each tools/call. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, + limit, + }; +}; /** SDK 2.3.1 preserves an SdkError abort reason by identity. */ export class JebMcpDeadline { diff --git a/src/kuna/KunaCommand.ts b/src/kuna/KunaCommand.ts index 23a49b172b..df2192029f 100644 --- a/src/kuna/KunaCommand.ts +++ b/src/kuna/KunaCommand.ts @@ -8,6 +8,7 @@ import { AnalysisError, type AnalysisCapturedOutput, } from "../domain/analysisErrorBase.js"; +import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { AnalysisCancelledError, @@ -413,10 +414,15 @@ const commandFailure = ( }); else if (cause.reason === "timeout") { const { setting, ms } = request.deadline; + const analysisSetting = + setting === "REA_KUNA_TIMEOUT_MS" + ? "kuna.function_timeout_ms" + : "kuna.project_timeout_ms"; primary = new AnalysisTimeoutError(operation, ms, { ...details, - userMessage: `kuna ${subcommand} did not finish within ${setting} (${String(ms)} ms).`, - remediationAction: `Raise ${setting} in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.`, + limit: analysisSettingLimit(analysisSetting, ms, undefined), + userMessage: `kuna ${subcommand} did not finish within ${analysisSetting} (${String(ms)} ms).`, + remediationAction: `${analysisSetting} is ${String(ms)} ms. It can be changed at runtime with update_analysis_settings. The launch seed is ${setting}.`, }); } // The same input overflows the same fixed buffer; waiting cannot help. diff --git a/src/kuna/KunaConfiguration.ts b/src/kuna/KunaConfiguration.ts index d6b796c58b..b3cb8657c6 100644 --- a/src/kuna/KunaConfiguration.ts +++ b/src/kuna/KunaConfiguration.ts @@ -1,5 +1,6 @@ import { isAbsolute } from "node:path"; +import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js"; import { defaultKunaCacheDirectory } from "../config/kunaCacheDirectory.js"; import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js"; import { providerStartupTimeoutSchema } from "../config/providerStartupTimeout.js"; @@ -59,7 +60,9 @@ export const kunaFunctionDeadline = ( configuration: KunaConfiguration, ): KunaDeadline => ({ setting: "REA_KUNA_TIMEOUT_MS", - ms: configuration.functionTimeoutMs, + ms: + currentAnalysisOperationSnapshot()?.overrides["kuna.function_timeout_ms"] ?? + configuration.functionTimeoutMs, }); /** The project export and whole-program string, graph and xref scans. */ @@ -67,7 +70,9 @@ export const kunaProjectDeadline = ( configuration: KunaConfiguration, ): KunaDeadline => ({ setting: "REA_KUNA_PROJECT_TIMEOUT_MS", - ms: configuration.projectTimeoutMs, + ms: + currentAnalysisOperationSnapshot()?.overrides["kuna.project_timeout_ms"] ?? + configuration.projectTimeoutMs, }); /** Why the configured environment cannot select a Kuna installation. */ diff --git a/src/kuna/KunaProjectExport.ts b/src/kuna/KunaProjectExport.ts index 2f667d9d0f..c4cb079d51 100644 --- a/src/kuna/KunaProjectExport.ts +++ b/src/kuna/KunaProjectExport.ts @@ -215,7 +215,7 @@ export const exportKunaProject = async ( ): Promise => { const { installation, signal } = request; const { configuration } = installation; - const deadline = Date.now() + configuration.projectTimeoutMs; + const deadline = Date.now() + kunaProjectDeadline(configuration).ms; const input = join(request.workspace, "input", basename(request.targetPath)); await mkdir(join(request.workspace, "input"), { mode: 0o700 }); await mkdir(request.output, { recursive: true, mode: 0o700 }); diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index b56d93deda..35b41e3d64 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -39,6 +39,7 @@ import { combineFunctionRelationshipCoverage } from "../domain/functionCallCover import { functionDossierSchema } from "../domain/native/functionDossier.js"; import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { RelationshipCoverage } from "../domain/relationshipCoverage.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -278,8 +279,9 @@ const exportUnavailableMessage = ( /** * How a request that needs the export reports the export's failure. A session * never rebuilds a failed export, so repeating the request cannot succeed; the - * error says what to change before reopening the target. Kuna settings are read - * when REA starts, so changing one also needs a restart. Cancellation and + * error says what to change before reopening the target. A failed export is + * not rebuilt in the same session. The project deadline can change at runtime + * for the next export. Cancellation and * unproven cleanup keep their own errors, which callers must still observe. */ const exportUnavailable = ( @@ -292,9 +294,17 @@ const exportUnavailable = ( const exported = projectAnalysisError(failure); const restart = "REA reads Kuna settings when it starts, so restart REA, then reopen the target."; + if (failure instanceof AnalysisTimeoutError) + failure.bindLimit( + analysisSettingLimit( + "kuna.project_timeout_ms", + failure.timeoutMs, + undefined, + ), + ); const next = failure instanceof AnalysisTimeoutError - ? `To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above ${String(failure.timeoutMs)} ms in the environment that starts REA. ${restart}` + ? `To retry, set kuna.project_timeout_ms above ${String(failure.timeoutMs)} ms with update_analysis_settings. It can be changed at runtime. The launch seed is REA_KUNA_PROJECT_TIMEOUT_MS. Then reopen the target so the export runs again.` : failure instanceof AnalysisResourceConstraintError && failure.remediationAction !== undefined ? // A fixed buffer (`memory`) is no setting, so a restart cannot help. diff --git a/src/native/pwntools/PwntoolsFailures.ts b/src/native/pwntools/PwntoolsFailures.ts index d692a92a69..b3e5a65af7 100644 --- a/src/native/pwntools/PwntoolsFailures.ts +++ b/src/native/pwntools/PwntoolsFailures.ts @@ -1,3 +1,4 @@ +import { analysisSettingLimit } from "../../domain/analysisSettingDefinitions.js"; import { PWNTOOLS_LIMITS, resolvePwntoolsLimits, @@ -296,7 +297,12 @@ const classifyPwntoolsDecoderFailure = ( ...(cause.reason === "limit" ? { userMessage: `${cause.message} Selected producer_limits.input_bytes is ${String(limits.input_bytes)} bytes. ${budgetRecovery}`, - remediationAction: `Increase producer_limits.input_bytes above ${String(limits.input_bytes)} on the next call (CLI --producer-limits JSON). No restart is required. Changing REA_PWNTOOLS_MAX_INPUT_BYTES affects the launch default instead. CPU, memory, output and transport allowances remain independent.`, + remediationAction: `pwntools.max_input_bytes is the launch default of ${String(PWNTOOLS_LIMITS.input_bytes)} bytes and can be changed at runtime with update_analysis_settings. The launch seed is REA_PWNTOOLS_MAX_INPUT_BYTES. producer_limits.input_bytes overrides that default on the next call (CLI --producer-limits JSON). The effective input_bytes for this call is ${String(limits.input_bytes)}. CPU, memory, output and transport allowances remain independent.`, + limit: analysisSettingLimit( + "pwntools.max_input_bytes", + limits.input_bytes, + undefined, + ), } : {}), }, diff --git a/src/native/pwntools/PwntoolsInputLimit.test.ts b/src/native/pwntools/PwntoolsInputLimit.test.ts index b51e3751d8..b176af8c01 100644 --- a/src/native/pwntools/PwntoolsInputLimit.test.ts +++ b/src/native/pwntools/PwntoolsInputLimit.test.ts @@ -14,6 +14,7 @@ describe("pwntools input bound", () => { expect(pwntoolsInputBytes({})).toEqual({ configured: true, bytes: PWNTOOLS_LIMITS.input_bytes, + source: "default", }); expect(PWNTOOLS_LIMITS.input_bytes).toBe(32 * 1024 * 1024); }); @@ -25,7 +26,7 @@ describe("pwntools input bound", () => { ["1073741824", 1024 * 1024 * 1024], ] as const)("accepts %s as %s bytes", (value, expected) => { expect(pwntoolsInputBytes({ REA_PWNTOOLS_MAX_INPUT_BYTES: value })).toEqual( - { configured: true, bytes: expected }, + { configured: true, bytes: expected, source: "setting" }, ); }); diff --git a/src/native/pwntools/PwntoolsInputLimit.ts b/src/native/pwntools/PwntoolsInputLimit.ts index 8a903e2b3c..d4b4525441 100644 --- a/src/native/pwntools/PwntoolsInputLimit.ts +++ b/src/native/pwntools/PwntoolsInputLimit.ts @@ -1,5 +1,6 @@ import { z } from "zod"; +import { currentAnalysisOperationSnapshot } from "../../domain/analysisOperationSnapshot.js"; import { PWNTOOLS_LIMITS } from "../../domain/native/pwntoolsResourceLimits.js"; // The artifact is read into one in-memory buffer and staged again as a private @@ -20,7 +21,11 @@ const CONSTRAINT = /** Resolved input bound, or the reason the caller's override cannot be used. */ export type PwntoolsInputBytes = - | { readonly configured: true; readonly bytes: number } + | { + readonly configured: true; + readonly bytes: number; + readonly source: "default" | "setting" | "runtime"; + } | { readonly configured: false; readonly error: string }; /** @@ -30,11 +35,19 @@ export type PwntoolsInputBytes = export const pwntoolsInputBytes = ( environment: Readonly, ): PwntoolsInputBytes => { + const override = + currentAnalysisOperationSnapshot()?.overrides["pwntools.max_input_bytes"]; + if (override !== undefined) + return { configured: true, bytes: override, source: "runtime" }; const value = environment.REA_PWNTOOLS_MAX_INPUT_BYTES; if (value === undefined) - return { configured: true, bytes: PWNTOOLS_LIMITS.input_bytes }; + return { + configured: true, + bytes: PWNTOOLS_LIMITS.input_bytes, + source: "default", + }; const parsed = pwntoolsInputBytesSchema.safeParse(value); return parsed.success - ? { configured: true, bytes: parsed.data } + ? { configured: true, bytes: parsed.data, source: "setting" } : { configured: false, error: CONSTRAINT }; }; diff --git a/src/process/ownedDebuggerSession.ts b/src/process/ownedDebuggerSession.ts index 8d84a33f71..c87b8def84 100644 --- a/src/process/ownedDebuggerSession.ts +++ b/src/process/ownedDebuggerSession.ts @@ -1,6 +1,12 @@ import { readFile } from "node:fs/promises"; -import type { DebuggerAdmissionLimit } from "../config/debuggerSessionLimits.js"; +import { + DEBUGGER_COMMAND_DEADLINE_SETTING, + DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING, + type DebuggerAdmissionLimit, +} from "../config/debuggerSessionLimits.js"; +import { analysisSettingLimit } from "../domain/analysisSettingDefinitions.js"; +import type { AnalysisSettingId } from "../domain/analysisSettings.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { parseLinuxProcessStat } from "./LinuxProcessStat.js"; @@ -11,36 +17,64 @@ export type OwnedDebuggerStopResult = | { readonly status: "stopped" | "already_exited" } | { readonly status: "incomplete"; readonly reason: string }; +const RUNTIME_DEBUGGER_SETTINGS = { + [DEBUGGER_COMMAND_DEADLINE_SETTING]: "debugger.command_deadline_ms", + [DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING]: + "debugger.max_command_output_bytes", +} as const satisfies Partial>; + +const runtimeDebuggerSetting = ( + admission: DebuggerAdmissionLimit, +): AnalysisSettingId | undefined => + RUNTIME_DEBUGGER_SETTINGS[ + admission.setting as keyof typeof RUNTIME_DEBUGGER_SETTINGS + ]; + +/** Structured limit for a debugger budget that update_analysis_settings can change. */ +export const debuggerAnalysisLimit = (admission: DebuggerAdmissionLimit) => { + const setting = runtimeDebuggerSetting(admission); + return setting === undefined + ? undefined + : analysisSettingLimit(setting, admission.value, admission.source); +}; + /** Structured facts a limit error exposes. The sentence is display text. */ export const debuggerAdmissionDiagnostics = ( admission: DebuggerAdmissionLimit, -): { - readonly setting: string; - readonly effective_value: number; - readonly source: DebuggerAdmissionLimit["source"]; - readonly default_value: number; - readonly minimum: number; - readonly maximum: number; - readonly unit: DebuggerAdmissionLimit["unit"]; - readonly update_mode: "restart"; - readonly pending_restart: false; -} => ({ - setting: admission.setting, - effective_value: admission.value, - source: admission.source, - default_value: admission.defaultValue, - minimum: admission.minimum, - maximum: admission.maximum, - unit: admission.unit, - update_mode: "restart", - pending_restart: false, -}); +) => { + const analysisSetting = runtimeDebuggerSetting(admission); + const update_mode = analysisSetting === undefined ? "restart" : "runtime"; + return { + setting: admission.setting, + effective_value: admission.value, + source: admission.source, + default_value: admission.defaultValue, + minimum: admission.minimum, + maximum: admission.maximum, + unit: admission.unit, + update_mode, + pending_restart: false as const, + ...(analysisSetting === undefined + ? {} + : { analysis_setting: analysisSetting }), + }; +}; -/** Name the setting, the effective value, its source, the range, and a restart. */ +/** Name the setting, the effective value, its source, and the range. */ export const debuggerAdmissionRecovery = ( admission: DebuggerAdmissionLimit, -): string => - `The effective ${admission.setting} value is ${String(admission.value)} ${admission.unit} (source ${admission.source}; default ${String(admission.defaultValue)} ${admission.unit}, floor ${String(admission.minimum)} ${admission.unit}, maximum ${String(admission.maximum)} ${admission.unit}). Set ${admission.setting} in the environment that starts REA to a positive decimal integer from ${String(admission.minimum)} to ${String(admission.maximum)}, then restart REA.`; +): string => { + const facts = debuggerAdmissionDiagnostics(admission); + const identity = + facts.analysis_setting === undefined + ? admission.setting + : `${facts.analysis_setting} (launch seed ${admission.setting})`; + const change = + facts.update_mode === "runtime" + ? `It can be changed at runtime with update_analysis_settings.` + : `Set ${admission.setting} in the environment that starts REA to a positive decimal integer from ${String(admission.minimum)} to ${String(admission.maximum)}, then restart REA.`; + return `The effective ${identity} value is ${String(admission.value)} ${admission.unit} (source ${admission.source}; default ${String(admission.defaultValue)} ${admission.unit}, floor ${String(admission.minimum)} ${admission.unit}, maximum ${String(admission.maximum)} ${admission.unit}). ${change}`; +}; /** Fixed startup deadline. Distinct from the shared command deadline setting. */ export const fixedDeadlineGuidance = ( @@ -61,10 +95,12 @@ export const configurableDeadlineGuidance = ( readonly diagnostics: ReturnType; } => { const recovery = debuggerAdmissionRecovery(admission); + const limit = debuggerAnalysisLimit(admission); return { userMessage: `${what} reached its ${String(admission.value)} ms deadline (deadlineExpired). ${recovery}`, remediationAction: recovery, diagnostics: debuggerAdmissionDiagnostics(admission), + ...(limit === undefined ? {} : { limit }), }; }; @@ -79,6 +115,7 @@ export const debuggerResourceConstraintError = ( ): AnalysisResourceConstraintError => { const facts = debuggerAdmissionDiagnostics(admission); const recovery = debuggerAdmissionRecovery(admission); + const limit = debuggerAnalysisLimit(admission); return new AnalysisResourceConstraintError( operation, resource, @@ -87,6 +124,7 @@ export const debuggerResourceConstraintError = ( { remediationAction: `${remediation} ${recovery}`, diagnostics: facts, + ...(limit === undefined ? {} : { limit }), }, ); }; diff --git a/src/rizin/RizinDebugSessionManager.ts b/src/rizin/RizinDebugSessionManager.ts index 87b2c452d9..3af2f00ecf 100644 --- a/src/rizin/RizinDebugSessionManager.ts +++ b/src/rizin/RizinDebugSessionManager.ts @@ -28,6 +28,7 @@ import type { } from "../domain/analysisErrorBase.js"; import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; +import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSnapshot.js"; import { err, ok, type Result } from "../domain/result.js"; import { ProviderProcessSupervisor, @@ -263,13 +264,11 @@ export class RizinDebugSessionManager { #closingAll = false; readonly #startDrainWaiters: Array<() => void> = []; readonly #command: string; - readonly #commandDeadline: DebuggerAdmissionLimit; - readonly #frameTimeoutMs: number; + readonly #launchCommandDeadline: DebuggerAdmissionLimit; readonly #startupTimeoutMs: number; readonly #maxSessions: DebuggerAdmissionLimit; readonly #maxPendingCommands: DebuggerAdmissionLimit; - readonly #commandOutput: DebuggerAdmissionLimit; - readonly #maxCommandOutputBytes: number; + readonly #launchCommandOutput: DebuggerAdmissionLimit; readonly #platform: NodeJS.Platform; readonly #stopProcess: typeof stopOwnedDebuggerProcess; readonly #spawnProcess: typeof spawnOwnedProviderProcess; @@ -287,18 +286,41 @@ export class RizinDebugSessionManager { this.#environment = { ...environment }; this.#command = environment.REA_RIZIN_COMMAND ?? "rizin"; const limits = resolveDebuggerSessionLimits(this.#environment); - this.#commandDeadline = limits.commandDeadline; - this.#frameTimeoutMs = this.#commandDeadline.value; + this.#launchCommandDeadline = limits.commandDeadline; this.#startupTimeoutMs = options.startupTimeoutMs ?? STARTUP_TIMEOUT_MS; this.#maxSessions = limits.maxSessions; this.#maxPendingCommands = limits.maxPendingCommands; - this.#commandOutput = limits.maxCommandOutputBytes; - this.#maxCommandOutputBytes = this.#commandOutput.value; + this.#launchCommandOutput = limits.maxCommandOutputBytes; this.#platform = options.platform ?? process.platform; this.#stopProcess = options.stopProcess ?? stopOwnedDebuggerProcess; this.#spawnProcess = options.spawnProcess ?? spawnOwnedProviderProcess; } + get #commandDeadline(): DebuggerAdmissionLimit { + const override = + currentAnalysisOperationSnapshot()?.overrides[ + "debugger.command_deadline_ms" + ]; + return override === undefined + ? this.#launchCommandDeadline + : { ...this.#launchCommandDeadline, value: override, source: "runtime" }; + } + get #frameTimeoutMs(): number { + return this.#commandDeadline.value; + } + get #commandOutput(): DebuggerAdmissionLimit { + const override = + currentAnalysisOperationSnapshot()?.overrides[ + "debugger.max_command_output_bytes" + ]; + return override === undefined + ? this.#launchCommandOutput + : { ...this.#launchCommandOutput, value: override, source: "runtime" }; + } + get #maxCommandOutputBytes(): number { + return this.#commandOutput.value; + } + async start( input: { readonly path: string; diff --git a/tests/boundary/mcp/capabilityRemediationMcp.test.ts b/tests/boundary/mcp/capabilityRemediationMcp.test.ts index ef73997b77..eeba046845 100644 --- a/tests/boundary/mcp/capabilityRemediationMcp.test.ts +++ b/tests/boundary/mcp/capabilityRemediationMcp.test.ts @@ -74,7 +74,7 @@ it("names the JEB discovery deadline setting instead of repeating the expired de reason: "provider_unavailable", }); expect(entry?.remediation).toMatch( - /increase REA_JEB_MCP_TIMEOUT_MS.*restart and reconnect REA/iu, + /jeb\.mcp_timeout_ms.*update_analysis_settings.*REA_JEB_MCP_TIMEOUT_MS/iu, ); expect(entry?.remediation).toMatch( /catalog discovery.*tools\/call.*cleanup.*headroom/iu, diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index ce430b2b43..63aa2e67e0 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -1919,10 +1919,11 @@ it("preserves a timeout while startup waits for the automatic-loading response", details: { diagnostics: { setting: DEBUGGER_COMMAND_DEADLINE_SETTING, + analysis_setting: "debugger.command_deadline_ms", effective_value: 25, source: "setting", unit: "ms", - update_mode: "restart", + update_mode: "runtime", pending_restart: false, }, }, @@ -3567,7 +3568,7 @@ process.stdin.on('data', (chunk) => { { kind: "command_bytes", maximum: 16_777_216 }, ]); expect(result.value.evidence.limitations.join(" ")).toContain( - "Set REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES", + "debugger.max_command_output_bytes", ); } expect( @@ -3829,9 +3830,7 @@ it("keeps the session usable after an oversized stream record is dropped", async const limitations = executed.value.evidence.limitations.join(" "); expect(limitations).toContain("MI line limit"); expect(limitations).toContain("does not raise it"); - expect(limitations).not.toContain( - "Set REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES", - ); + expect(limitations).not.toContain("debugger.max_command_output_bytes"); } const second = await manager.execute(started.value.session_id, "after-drop"); expect(second.ok).toBe(true); @@ -3904,9 +3903,7 @@ process.stdin.on('data', (chunk) => { ]); const limitations = result.value.evidence.limitations.join(" "); expect(limitations).toContain("16384 record cap"); - expect(limitations).not.toContain( - "Set REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES", - ); + expect(limitations).not.toContain("debugger.max_command_output_bytes"); } }); diff --git a/tests/boundary/providers/kuna/kunaIndependentDecompileMcp.test.ts b/tests/boundary/providers/kuna/kunaIndependentDecompileMcp.test.ts index 3229809f30..57a60cdf9d 100644 --- a/tests/boundary/providers/kuna/kunaIndependentDecompileMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaIndependentDecompileMcp.test.ts @@ -79,11 +79,17 @@ it("runs a cold independent request without starting a project export", async () }; expect(failure.error).toMatchObject({ code: "provider_timeout", - remediation: { - action: - "Raise REA_KUNA_TIMEOUT_MS in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.", + details: { + timeout_ms: 300, + limit: { + setting: "kuna.function_timeout_ms", + effective_value: 300, + effect: "immediate", + rea_restart_required: false, + minimum: 1, + maximum: 2147483647, + }, }, - details: { timeout_ms: 300 }, }); expect( (await fakeKunaCalls(kuna)).filter( diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 3b97364313..67a178daf6 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -1068,7 +1068,7 @@ it.each([ behavior: { project: "hang" } as const, environment: { REA_KUNA_PROJECT_TIMEOUT_MS: "300" }, cause: "AnalysisTimeoutError", - next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", + next: "To retry, set kuna.project_timeout_ms above 300 ms with update_analysis_settings. It can be changed at runtime. The launch seed is REA_KUNA_PROJECT_TIMEOUT_MS. Then reopen the target so the export runs again.", details: { timeout_ms: 300, captured_output: { truncated: expect.any(Boolean) }, diff --git a/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts b/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts index 9c324f733c..501bf92c7a 100644 --- a/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts +++ b/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts @@ -367,15 +367,15 @@ it("names the setting that owns a single-function deadline", async () => { if (result.ok) throw new Error("the hanging run answered"); expect(projectAnalysisError(result.error)).toMatchObject({ code: "provider_timeout", - message: expect.stringMatching( - /kuna decompile.*REA_KUNA_TIMEOUT_MS.*300 ms/i, - ), - remediation: { - action: expect.stringMatching( - /raise.*REA_KUNA_TIMEOUT_MS.*restart REA/i, - ), + details: { + timeout_ms: 300, + limit: { + setting: "kuna.function_timeout_ms", + effective_value: 300, + effect: "immediate", + rea_restart_required: false, + }, }, - details: { timeout_ms: 300 }, }); } finally { expect(await client.close()).toEqual({ ok: true, value: null }); diff --git a/tests/boundary/providers/kuna/kunaStrings.test.ts b/tests/boundary/providers/kuna/kunaStrings.test.ts index fbd514ebaa..f09a456cd9 100644 --- a/tests/boundary/providers/kuna/kunaStrings.test.ts +++ b/tests/boundary/providers/kuna/kunaStrings.test.ts @@ -432,13 +432,15 @@ it.each([ expect(result.error).toMatchObject({ timeoutMs: 500 }); // Each waiter keeps the producer's setting and recovery. expect(projectAnalysisError(result.error)).toMatchObject({ - message: expect.stringMatching( - /kuna strings.*REA_KUNA_PROJECT_TIMEOUT_MS.*500 ms/i, - ), - remediation: { - action: expect.stringMatching( - /raise.*REA_KUNA_PROJECT_TIMEOUT_MS.*restart REA/i, - ), + code: "provider_timeout", + details: { + timeout_ms: 500, + limit: { + setting: "kuna.project_timeout_ms", + effective_value: 500, + effect: "immediate", + rea_restart_required: false, + }, }, }); }