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.
This commit is contained in:
Cursor Agent authored and morluto committed 2026-11-11 20:16:24 +08:00
1 parent 9924bf3445
commit b0541c80b3
34 files changed
+704 -254

No files matched your search

+84 -2
View File
@@ -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",
});
});
+4 -1
View File
@@ -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<Value>(operation: () => Value): Value {
return this.#accepted.run(this.#current, operation);
return this.#accepted.run(this.#current, () =>
runWithAnalysisOperationSnapshot(this.#current, operation),
);
}
inspect(input: z.infer<typeof inspectAnalysisSettingsInputSchema> = {}) {
return {
+23
View File
@@ -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<Record<string, z.ZodType<number, string>>> =
{
REA_ASAR_MAX_HEADER_BYTES: asarMaxHeaderBytesSchema,
@@ -33,6 +44,18 @@ const environmentSchemas: Readonly<Record<string, z.ZodType<number, string>>> =
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,
+39 -18
View File
@@ -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),
},
);
};
+1 -1
View File
@@ -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;
+12 -18
View File
@@ -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),
+15 -4
View File
@@ -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 : "";
};
+27
View File
@@ -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 = (
+37 -9
View File
@@ -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",
};
};
+19
View File
@@ -0,0 +1,19 @@
import { AsyncLocalStorage } from "node:async_hooks";
import type { AnalysisSettingsSnapshot } from "./analysisSettings.js";
const snapshots = new AsyncLocalStorage<AnalysisSettingsSnapshot>();
/**
* 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 = <Value>(
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();
+25
View File
@@ -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;
+155
View File
@@ -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<AnalysisSettingId, SettingDefinition>;
export const analysisSettingIds = Object.keys(
ANALYSIS_SETTINGS,
+11
View File
@@ -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 {
+3 -11
View File
@@ -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;
+4 -93
View File
@@ -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[];
+2 -1
View File
@@ -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. */
+29 -8
View File
@@ -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<void> | 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;
}
+12 -7
View File
@@ -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<void> | undefined;
@@ -211,7 +216,7 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = (
Result<readonly string[], unknown>
>(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(
+21 -6
View File
@@ -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 {
+8 -2
View File
@@ -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.
+7 -2
View File
@@ -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. */
+1 -1
View File
@@ -215,7 +215,7 @@ export const exportKunaProject = async (
): Promise<KunaExportOutcome> => {
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 });
+13 -3
View File
@@ -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.
+7 -1
View File
@@ -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,
),
}
: {}),
},
@@ -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" },
);
});
+16 -3
View File
@@ -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<NodeJS.ProcessEnv>,
): 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 };
};
+63 -25
View File
@@ -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<Record<string, AnalysisSettingId>>;
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<typeof debuggerAdmissionDiagnostics>;
} => {
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 }),
},
);
};
+30 -8
View File
@@ -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;