From 4a8d8f72b354dc96a8d1bce06da4bf6c7eda33ec Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 30 Oct 2026 05:41:54 +0800 Subject: [PATCH] feat(debugger): share GDB and Rizin session caps as REA_ settings (#8056) * feat(debugger): share GDB and Rizin session caps as REA_ settings Move the duplicated deadline guidance into the owned debugger helper and replace the shared session, queue, output, and 120s command caps with launch settings that name their value, source, and range. * test(gdb): assert the command-output setting on truncated exit evidence The retained-output limitation now names REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES instead of the old fixed-cap sentence. * test(gdb): assert command-output limit fields on truncated exit A truncated GDB exit now carries the command-output setting, value, and source on the cleanup error. The coverage test reads those fields. * fix(debugger): keep control replies and evidence hashes inside their budgets Point the session-cap mutation target at DEFAULT_DEBUGGER_MAX_SESSIONS. Give startup and cleanup replies a fixed 16 MiB budget so a small caller output cap cannot truncate them. Lower the command-output maximum so escaped Evidence JSON stays under Node's string ceiling, and keep GDB startup timeout diagnostics when the error is rebuilt. * test(debugger): throw typed Result errors before success checks Success cases no longer assert ok and return. They throw the typed error first, then check session, deadline, and close fields. --------- Co-authored-by: Cursor Agent --- src/config/debuggerSessionLimits.test.ts | 255 ++++++++++++++++++ src/config/debuggerSessionLimits.ts | 251 +++++++++++++++++ src/config/environment.ts | 2 + src/domain/analysisResourceLimits.ts | 72 +++++ src/gdb/GdbSessionManager.ts | 149 ++++++---- src/process/ownedDebuggerSession.ts | 81 ++++++ src/rizin/RizinDebugSessionManager.ts | 126 +++++---- stryker.config.mjs | 10 +- .../GdbSessionManagerCoverage.test.ts | 65 ++++- .../RizinDebugSessionCoverage.part3.test.ts | 88 ++++-- .../providers/RizinTimeoutEvidence.test.ts | 19 +- 11 files changed, 983 insertions(+), 135 deletions(-) create mode 100644 src/config/debuggerSessionLimits.test.ts create mode 100644 src/config/debuggerSessionLimits.ts diff --git a/src/config/debuggerSessionLimits.test.ts b/src/config/debuggerSessionLimits.test.ts new file mode 100644 index 0000000000..6dad453fd6 --- /dev/null +++ b/src/config/debuggerSessionLimits.test.ts @@ -0,0 +1,255 @@ +import { expect, it } from "vitest"; + +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import { + DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + MAXIMUM_DEBUGGER_MAX_PENDING_COMMANDS, + MAXIMUM_DEBUGGER_MAX_SESSIONS, + MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + MINIMUM_DEBUGGER_MAX_PENDING_COMMANDS, + MINIMUM_DEBUGGER_MAX_SESSIONS, +} from "../domain/analysisResourceLimits.js"; +import { GdbSessionManager } from "../gdb/GdbSessionManager.js"; +import { RizinDebugSessionManager } from "../rizin/RizinDebugSessionManager.js"; +import { parseEnvironment } from "./environment.js"; +import { + DEBUGGER_COMMAND_DEADLINE_SETTING, + DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING, + DEBUGGER_MAX_PENDING_COMMANDS_SETTING, + DEBUGGER_MAX_SESSIONS_SETTING, + debuggerCommandDeadlineConstraint, + debuggerMaxCommandOutputBytesConstraint, + debuggerMaxPendingCommandsConstraint, + debuggerMaxSessionsConstraint, + parseDebuggerSessionLimits, + resolveDebuggerSessionLimits, +} from "./debuggerSessionLimits.js"; + +it("uses the shared defaults when the settings are unset", () => { + const parsed = parseDebuggerSessionLimits({}); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.commandDeadline).toMatchObject({ + setting: DEBUGGER_COMMAND_DEADLINE_SETTING, + value: 120_000, + source: "default", + minimum: MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + maximum: MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + unit: "ms", + }); + expect(parsed.value.maxSessions).toMatchObject({ + setting: DEBUGGER_MAX_SESSIONS_SETTING, + value: 8, + source: "default", + minimum: MINIMUM_DEBUGGER_MAX_SESSIONS, + maximum: MAXIMUM_DEBUGGER_MAX_SESSIONS, + unit: "sessions", + }); + expect(parsed.value.maxPendingCommands).toMatchObject({ + setting: DEBUGGER_MAX_PENDING_COMMANDS_SETTING, + value: 16, + source: "default", + minimum: MINIMUM_DEBUGGER_MAX_PENDING_COMMANDS, + maximum: MAXIMUM_DEBUGGER_MAX_PENDING_COMMANDS, + unit: "commands", + }); + expect(parsed.value.maxCommandOutputBytes).toMatchObject({ + setting: DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING, + value: 16 * 1024 * 1024, + source: "default", + minimum: MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + maximum: MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + unit: "bytes", + }); +}); + +it("accepts one in-range override for each shared debugger cap", () => { + const parsed = parseDebuggerSessionLimits({ + [DEBUGGER_COMMAND_DEADLINE_SETTING]: "1000", + [DEBUGGER_MAX_SESSIONS_SETTING]: "2", + [DEBUGGER_MAX_PENDING_COMMANDS_SETTING]: "3", + [DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING]: "4096", + }); + if (!parsed.ok) throw parsed.error; + expect(parsed.value.commandDeadline).toMatchObject({ + value: 1000, + source: "setting", + }); + expect(parsed.value.maxSessions).toMatchObject({ + value: 2, + source: "setting", + }); + expect(parsed.value.maxPendingCommands).toMatchObject({ + value: 3, + source: "setting", + }); + expect(parsed.value.maxCommandOutputBytes).toMatchObject({ + value: 4096, + source: "setting", + }); +}); + +it.each([ + [DEBUGGER_COMMAND_DEADLINE_SETTING, "0", debuggerCommandDeadlineConstraint], + [DEBUGGER_MAX_SESSIONS_SETTING, "65", debuggerMaxSessionsConstraint], + [ + DEBUGGER_MAX_PENDING_COMMANDS_SETTING, + "00", + debuggerMaxPendingCommandsConstraint, + ], + [ + DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING, + String(MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES + 1), + debuggerMaxCommandOutputBytesConstraint, + ], +])( + "rejects invalid %s at the setting boundary", + (setting, value, constraint) => { + const parsed = parseDebuggerSessionLimits({ [setting]: value }); + if (parsed.ok) throw new Error(`${setting} was accepted`); + expect(parsed.error).toBeInstanceOf(ConfigurationError); + expect(parsed.error.settings).toEqual([{ setting, constraint }]); + const startup = parseEnvironment({ [setting]: value }); + if (startup.ok) throw new Error(`${setting} was accepted at startup`); + expect(projectAnalysisError(startup.error)).toMatchObject({ + code: "configuration_invalid", + details: { settings: [{ setting, constraint }] }, + }); + }, +); + +it("names the session setting on a Rizin capacity refusal", async () => { + const manager = new RizinDebugSessionManager( + { [DEBUGGER_MAX_SESSIONS_SETTING]: "1" }, + { platform: "linux" }, + ); + const first = manager.start({ + path: "/tmp/rea-debugger-limit", + inferior_tty: "/no/such/rea-debugger-tty", + }); + const blocked = await manager.start({ + path: "/tmp/rea-debugger-limit-2", + inferior_tty: "/no/such/rea-debugger-tty", + }); + if (blocked.ok) throw new Error("expected the session cap to refuse"); + expect(projectAnalysisError(blocked.error)).toMatchObject({ + code: "resource_constraint", + details: { + resource: "memory", + reported_limits: { + setting: DEBUGGER_MAX_SESSIONS_SETTING, + effective_value: 1, + source: "setting", + minimum: MINIMUM_DEBUGGER_MAX_SESSIONS, + maximum: MAXIMUM_DEBUGGER_MAX_SESSIONS, + unit: "sessions", + restart_required: true, + max_sessions: 1, + active_or_reserved_sessions: 1, + }, + }, + }); + await first; +}); + +it("keeps the command-output maximum inside one Evidence hash string", () => { + const escaped = JSON.stringify("\u0001"); + const expansion = escaped.length - 2; + const nodeMaxStringLength = 536_870_888; + const envelope = 1024 * 1024; + expect(expansion).toBe(6); + expect(MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES).toBe( + Math.floor((nodeMaxStringLength - envelope) / expansion), + ); + expect(MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES).toBeGreaterThanOrEqual( + DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + ); + expect( + MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES * expansion + envelope, + ).toBeLessThanOrEqual(nodeMaxStringLength); +}); + +it("does not let one Rizin manager consume another manager's session cap", async () => { + const first = new RizinDebugSessionManager( + { [DEBUGGER_MAX_SESSIONS_SETTING]: "1" }, + { platform: "linux" }, + ); + const second = new RizinDebugSessionManager( + { [DEBUGGER_MAX_SESSIONS_SETTING]: "1" }, + { platform: "linux" }, + ); + const input = { + path: "/tmp/rea-debugger-limit", + inferior_tty: "/no/such/rea-debugger-tty", + }; + const held = first.start(input); + const independent = second.start(input); + const blocked = first.start({ + ...input, + path: "/tmp/rea-debugger-limit-2", + }); + try { + const independentResult = await independent; + if (independentResult.ok) + throw new Error("expected the missing terminal to fail"); + expect(projectAnalysisError(independentResult.error)).toMatchObject({ + code: "invalid_request", + details: { + issues: [{ path: ["inferior_tty"], reason: "invalid_value" }], + }, + }); + const blockedResult = await blocked; + if (blockedResult.ok) + throw new Error("expected the first manager to stay capped"); + expect(projectAnalysisError(blockedResult.error)).toMatchObject({ + code: "resource_constraint", + details: { + reported_limits: { + setting: DEBUGGER_MAX_SESSIONS_SETTING, + effective_value: 1, + source: "setting", + active_or_reserved_sessions: 1, + }, + }, + }); + } finally { + await held; + await independent; + await blocked; + } +}); + +it("refuses an invalid GDB deadline before a session starts", () => { + expect( + () => + new GdbSessionManager({ + environment: { [DEBUGGER_COMMAND_DEADLINE_SETTING]: "0" }, + }), + ).toThrow(ConfigurationError); + try { + new GdbSessionManager({ + environment: { [DEBUGGER_COMMAND_DEADLINE_SETTING]: "0" }, + }); + } catch (error) { + if (!(error instanceof ConfigurationError)) throw error; + expect(projectAnalysisError(error)).toMatchObject({ + code: "configuration_invalid", + details: { + settings: [ + { + setting: DEBUGGER_COMMAND_DEADLINE_SETTING, + constraint: debuggerCommandDeadlineConstraint, + }, + ], + }, + }); + } + expect( + resolveDebuggerSessionLimits({ + [DEBUGGER_COMMAND_DEADLINE_SETTING]: "1500", + }).commandDeadline, + ).toMatchObject({ value: 1500, source: "setting" }); +}); diff --git a/src/config/debuggerSessionLimits.ts b/src/config/debuggerSessionLimits.ts new file mode 100644 index 0000000000..84748434fa --- /dev/null +++ b/src/config/debuggerSessionLimits.ts @@ -0,0 +1,251 @@ +import { + DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS, + DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + DEFAULT_DEBUGGER_MAX_PENDING_COMMANDS, + DEFAULT_DEBUGGER_MAX_SESSIONS, + MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + MAXIMUM_DEBUGGER_MAX_PENDING_COMMANDS, + MAXIMUM_DEBUGGER_MAX_SESSIONS, + MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + MINIMUM_DEBUGGER_MAX_PENDING_COMMANDS, + MINIMUM_DEBUGGER_MAX_SESSIONS, +} from "../domain/analysisResourceLimits.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { positiveDecimalIntegerSchema } from "./positiveDecimalInteger.js"; + +/** + * Launch caps shared by GDB and Rizin. Each setting is declared here once. + * Startup validation spreads {@link debuggerSessionLimitEnvironmentShape}; + * it does not restate the name, bounds, or schema. + */ +export const DEBUGGER_COMMAND_DEADLINE_SETTING = + "REA_DEBUGGER_COMMAND_DEADLINE_MS"; +export const DEBUGGER_MAX_SESSIONS_SETTING = "REA_DEBUGGER_MAX_SESSIONS"; +export const DEBUGGER_MAX_PENDING_COMMANDS_SETTING = + "REA_DEBUGGER_MAX_PENDING_COMMANDS"; +export const DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING = + "REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES"; + +/** Effective debugger admission and where its value came from. */ +export interface DebuggerAdmissionLimit { + readonly setting: string; + readonly value: number; + readonly source: "default" | "setting" | "runtime"; + readonly defaultValue: number; + readonly minimum: number; + readonly maximum: number; + readonly unit: "ms" | "bytes" | "sessions" | "commands"; +} + +const limit = ( + setting: string, + value: number, + minimum: number, + maximum: number, + unit: DebuggerAdmissionLimit["unit"], +): DebuggerAdmissionLimit => ({ + setting, + value, + source: "default", + defaultValue: value, + minimum, + maximum, + unit, +}); + +export const DEFAULT_DEBUGGER_COMMAND_DEADLINE: DebuggerAdmissionLimit = limit( + DEBUGGER_COMMAND_DEADLINE_SETTING, + DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS, + MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + "ms", +); +export const DEFAULT_DEBUGGER_MAX_SESSIONS_LIMIT: DebuggerAdmissionLimit = + limit( + DEBUGGER_MAX_SESSIONS_SETTING, + DEFAULT_DEBUGGER_MAX_SESSIONS, + MINIMUM_DEBUGGER_MAX_SESSIONS, + MAXIMUM_DEBUGGER_MAX_SESSIONS, + "sessions", + ); +export const DEFAULT_DEBUGGER_MAX_PENDING_COMMANDS_LIMIT: DebuggerAdmissionLimit = + limit( + DEBUGGER_MAX_PENDING_COMMANDS_SETTING, + DEFAULT_DEBUGGER_MAX_PENDING_COMMANDS, + MINIMUM_DEBUGGER_MAX_PENDING_COMMANDS, + MAXIMUM_DEBUGGER_MAX_PENDING_COMMANDS, + "commands", + ); +export const DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_LIMIT: DebuggerAdmissionLimit = + limit( + DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING, + DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + "bytes", + ); + +const constraintFor = (admission: DebuggerAdmissionLimit): string => + `${admission.setting} must be a positive decimal integer from ${String(admission.minimum)} to ${String(admission.maximum)} ${admission.unit}; the default is ${String(admission.defaultValue)} ${admission.unit}.${ + admission.unit === "ms" + ? " Node schedules a delay above that maximum as 1 ms, so a larger value is refused." + : "" + }`; + +export const debuggerCommandDeadlineConstraint = constraintFor( + DEFAULT_DEBUGGER_COMMAND_DEADLINE, +); +export const debuggerMaxSessionsConstraint = constraintFor( + DEFAULT_DEBUGGER_MAX_SESSIONS_LIMIT, +); +export const debuggerMaxPendingCommandsConstraint = constraintFor( + DEFAULT_DEBUGGER_MAX_PENDING_COMMANDS_LIMIT, +); +export const debuggerMaxCommandOutputBytesConstraint = constraintFor( + DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_LIMIT, +); + +export const debuggerCommandDeadlineMsSchema = positiveDecimalIntegerSchema( + MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS, + debuggerCommandDeadlineConstraint, +); +export const debuggerMaxSessionsSchema = positiveDecimalIntegerSchema( + MINIMUM_DEBUGGER_MAX_SESSIONS, + MAXIMUM_DEBUGGER_MAX_SESSIONS, + debuggerMaxSessionsConstraint, +); +export const debuggerMaxPendingCommandsSchema = positiveDecimalIntegerSchema( + MINIMUM_DEBUGGER_MAX_PENDING_COMMANDS, + MAXIMUM_DEBUGGER_MAX_PENDING_COMMANDS, + debuggerMaxPendingCommandsConstraint, +); +export const debuggerMaxCommandOutputBytesSchema = positiveDecimalIntegerSchema( + MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + debuggerMaxCommandOutputBytesConstraint, +); + +/** Schemas startup validation spreads. The setting names are not restated. */ +export const debuggerSessionLimitEnvironmentShape = { + [DEBUGGER_COMMAND_DEADLINE_SETTING]: + debuggerCommandDeadlineMsSchema.optional(), + [DEBUGGER_MAX_SESSIONS_SETTING]: debuggerMaxSessionsSchema.optional(), + [DEBUGGER_MAX_PENDING_COMMANDS_SETTING]: + debuggerMaxPendingCommandsSchema.optional(), + [DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING]: + debuggerMaxCommandOutputBytesSchema.optional(), +}; + +export interface DebuggerSessionLimits { + readonly commandDeadline: DebuggerAdmissionLimit; + readonly maxSessions: DebuggerAdmissionLimit; + readonly maxPendingCommands: DebuggerAdmissionLimit; + readonly maxCommandOutputBytes: DebuggerAdmissionLimit; +} + +const parseOne = ( + admission: DebuggerAdmissionLimit, + schema: ReturnType, + constraint: string, + value: string | undefined, +): Result => { + if (value === undefined) return ok(admission); + const parsed = schema.safeParse(value); + return parsed.success + ? ok({ ...admission, value: parsed.data, source: "setting" }) + : err( + new ConfigurationError(constraint, { + settings: [{ setting: admission.setting, constraint }], + }), + ); +}; + +/** Resolve every shared debugger cap from one environment. Invalid values are refused. */ +export const parseDebuggerSessionLimits = ( + environment: Readonly>, +): Result => { + const commandDeadline = parseOne( + DEFAULT_DEBUGGER_COMMAND_DEADLINE, + debuggerCommandDeadlineMsSchema, + debuggerCommandDeadlineConstraint, + environment[DEBUGGER_COMMAND_DEADLINE_SETTING], + ); + const maxSessions = parseOne( + DEFAULT_DEBUGGER_MAX_SESSIONS_LIMIT, + debuggerMaxSessionsSchema, + debuggerMaxSessionsConstraint, + environment[DEBUGGER_MAX_SESSIONS_SETTING], + ); + const maxPendingCommands = parseOne( + DEFAULT_DEBUGGER_MAX_PENDING_COMMANDS_LIMIT, + debuggerMaxPendingCommandsSchema, + debuggerMaxPendingCommandsConstraint, + environment[DEBUGGER_MAX_PENDING_COMMANDS_SETTING], + ); + const maxCommandOutputBytes = parseOne( + DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_LIMIT, + debuggerMaxCommandOutputBytesSchema, + debuggerMaxCommandOutputBytesConstraint, + environment[DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING], + ); + const settings = [ + commandDeadline, + maxSessions, + maxPendingCommands, + maxCommandOutputBytes, + ].flatMap((result) => (result.ok ? [] : result.error.settings)); + if ( + settings.length > 0 || + !commandDeadline.ok || + !maxSessions.ok || + !maxPendingCommands.ok || + !maxCommandOutputBytes.ok + ) + return err( + new ConfigurationError("Invalid REA environment configuration", { + settings, + }), + ); + return ok({ + commandDeadline: commandDeadline.value, + maxSessions: maxSessions.value, + maxPendingCommands: maxPendingCommands.value, + maxCommandOutputBytes: maxCommandOutputBytes.value, + }); +}; + +/** Refuse an invalid launch cap instead of falling back to the default. */ +export const resolveDebuggerSessionLimits = ( + environment: Readonly>, +): DebuggerSessionLimits => { + const parsed = parseDebuggerSessionLimits(environment); + if (!parsed.ok) throw parsed.error; + return parsed.value; +}; + +/** + * Fixed budget for Rizin cleanup readback and GDB startup or detach MI. + * This is not `REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES`. A caller cap below + * this budget must not truncate those required replies, and recovery for + * this budget is not an environment setting. + */ +export const debuggerControlReplyOutput: DebuggerAdmissionLimit = { + setting: "debugger control-reply budget", + value: DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + source: "default", + defaultValue: DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + minimum: DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + maximum: DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + unit: "bytes", +}; + +/** Test and caller seams replace the launch value and record that source. */ +export const applyDebuggerAdmissionOverride = ( + admission: DebuggerAdmissionLimit, + value: number | undefined, +): DebuggerAdmissionLimit => + value === undefined ? admission : { ...admission, value, source: "runtime" }; diff --git a/src/config/environment.ts b/src/config/environment.ts index 054563ad9e..ef9b25739c 100644 --- a/src/config/environment.ts +++ b/src/config/environment.ts @@ -30,6 +30,7 @@ import { mcpResponseBudgetSchema } from "./mcpResponseBudget.js"; import { asarMaxHeaderBytesSchema } from "./asarHeaderAdmission.js"; import { bridgeMaxRequestBytesSchema } from "./bridgeMaxRequestBytes.js"; import { captureDecoderOldGenerationMiBSchema } from "./captureDecoderHeap.js"; +import { debuggerSessionLimitEnvironmentShape } from "./debuggerSessionLimits.js"; import { regexMatchingDeadlineMsSchema } from "./regexMatchingDeadline.js"; import { regexWorkerHeapRequestMiBSchema } from "./regexWorkerHeapRequest.js"; import { @@ -44,6 +45,7 @@ import { processStartConfigurationSchema } from "./processStart.js"; import { parseJebMcpEndpoint } from "./jebMcpEndpoint.js"; const environmentSchema = z.object({ + ...debuggerSessionLimitEnvironmentShape, REA_ANALYSIS_PROVIDER: analysisProviderSelectorSchema.default("auto"), REA_BRIDGE_MAX_REQUEST_BYTES: bridgeMaxRequestBytesSchema.optional(), REA_MCP_MAX_RESPONSE_BYTES: mcpResponseBudgetSchema.optional(), diff --git a/src/domain/analysisResourceLimits.ts b/src/domain/analysisResourceLimits.ts index 0c781c85af..0fe28647cb 100644 --- a/src/domain/analysisResourceLimits.ts +++ b/src/domain/analysisResourceLimits.ts @@ -114,3 +114,75 @@ export const CAPTURE_DECODER_SEMI_SPACE_MIB = 8; */ export const MAXIMUM_CAPTURE_DECODER_OLD_GENERATION_MIB = 16_384; export const DEFAULT_JADX_OPERATION_TIMEOUT_MS = 120_000; + +/** + * Shared GDB and Rizin command deadline used when the setting is unset. + * Today's fixed 120 s constant. + */ +export const DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS = 120_000; + +/** + * Smallest command deadline. Zero is not a deadline. The maximum is the + * largest delay Node schedules exactly; a `setTimeout` delay above that + * signed 32-bit bound becomes 1 ms. + */ +export const MINIMUM_DEBUGGER_COMMAND_DEADLINE_MS = 1; +export const MAXIMUM_DEBUGGER_COMMAND_DEADLINE_MS = 2_147_483_647; + +/** Debugger sessions admitted when the setting is unset. Today's constant. */ +export const DEFAULT_DEBUGGER_MAX_SESSIONS = 8; + +/** + * Smallest useful session cap. Zero admits no session. 64 is REA's refusal + * so one process cannot spawn an unbounded set of debugger processes; it is + * not a measurement of host process capacity. + */ +export const MINIMUM_DEBUGGER_MAX_SESSIONS = 1; +export const MAXIMUM_DEBUGGER_MAX_SESSIONS = 64; + +/** Queued commands admitted on one session when the setting is unset. */ +export const DEFAULT_DEBUGGER_MAX_PENDING_COMMANDS = 16; + +/** + * Smallest useful queue. Zero admits no command. 256 is REA's refusal so + * one session cannot retain an unbounded command queue. Each queued command + * can also retain output up to the command-output cap. + */ +export const MINIMUM_DEBUGGER_MAX_PENDING_COMMANDS = 1; +export const MAXIMUM_DEBUGGER_MAX_PENDING_COMMANDS = 256; + +/** Command output retained when the setting is unset. Today's 16 MiB cap. */ +export const DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024; + +/** + * Node's single-string ceiling, in UTF-16 code units. This is the same V8 + * bound `MAXIMUM_BRIDGE_MAX_REQUEST_BYTES` leaves room under. + */ +const NODE_MAX_STRING_LENGTH = 536_870_888; + +/** + * `JSON.stringify` encodes U+0000–U+001F as `\uXXXX` (6 code units). Evidence + * hashing stringifies the retained command output as one string. One mebibyte + * is reserved for the surrounding evidence document. + */ +const DEBUGGER_OUTPUT_JSON_ESCAPE_CODE_UNITS = 6; +const DEBUGGER_OUTPUT_EVIDENCE_ENVELOPE_CODE_UNITS = 1024 * 1024; + +/** + * Largest configurable command-output retention. A lower value only retains + * less caller command output, so the floor is one byte. The maximum keeps + * the worst-case JSON expansion of that text under Node's string ceiling. + */ +export const MINIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES = 1; +export const MAXIMUM_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES = Math.floor( + (NODE_MAX_STRING_LENGTH - DEBUGGER_OUTPUT_EVIDENCE_ENVELOPE_CODE_UNITS) / + DEBUGGER_OUTPUT_JSON_ESCAPE_CODE_UNITS, +); + +/** + * Bytes reserved for debugger control replies (Rizin cleanup readback and + * GDB startup or detach MI). This is not `REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES`: + * a caller cap below this budget must not truncate those required replies. + */ +export const DEBUGGER_CONTROL_REPLY_BYTES = + DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES; diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index 02be67de68..350b866741 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -10,7 +10,6 @@ import { AnalysisCapabilityUnavailableError, AnalysisCancelledError, AnalysisInputError, - AnalysisResourceConstraintError, AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; import { @@ -19,6 +18,7 @@ import { } from "../domain/analysisErrorBase.js"; import { createEvidence, type Evidence } from "../domain/evidence.js"; import type { GdbCommandResult } from "../domain/commandObservation.js"; +import { DEBUGGER_CONTROL_REPLY_BYTES } from "../domain/analysisResourceLimits.js"; import { err, ok, type Result } from "../domain/result.js"; import { ProviderProcessSupervisor, @@ -26,8 +26,18 @@ import { type OwnedProviderProcessSpawnOptions, type SpawnedOwnedProviderProcess, } from "../process/ProviderProcess.js"; +import { + applyDebuggerAdmissionOverride, + resolveDebuggerSessionLimits, + type DebuggerAdmissionLimit, +} from "../config/debuggerSessionLimits.js"; import { closeOwnedDebuggerSessions, + configurableDeadlineGuidance, + debuggerAdmissionDiagnostics, + debuggerAdmissionRecovery, + debuggerResourceConstraintError, + fixedDeadlineGuidance, readLinuxProcessStartTime, stopOwnedDebuggerProcess, type OwnedDebuggerDetachResult, @@ -37,22 +47,13 @@ import { } from "../process/ownedDebuggerSession.js"; const STARTUP_TIMEOUT_MS = 10_000; -const COMMAND_TIMEOUT_MS = 120_000; - -/** Fixed debugger deadline. Callers cannot raise it in this release. */ -const fixedDeadlineGuidance = ( - what: string, - timeoutMs: number, -): { readonly userMessage: string; readonly remediationAction: string } => ({ - userMessage: `${what} reached its ${String(timeoutMs)} ms deadline (deadlineExpired). This release has no runtime override.`, - remediationAction: `This ${String(timeoutMs)} ms deadline has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, -}); const copiedTimeoutGuidance = ( cause: AnalysisTimeoutError, ): { readonly userMessage?: string; readonly remediationAction?: string; + readonly diagnostics?: AnalysisTimeoutError["diagnostics"]; } => ({ ...(cause.userMessage === undefined ? {} @@ -60,6 +61,9 @@ const copiedTimeoutGuidance = ( ...(cause.remediationAction === undefined ? {} : { remediationAction: cause.remediationAction }), + ...(cause.diagnostics === undefined + ? {} + : { diagnostics: cause.diagnostics }), }); /** A host executable refusal cannot be repaired by changing MI dialect. */ class GdbExecutableRefusal extends AnalysisCapabilityUnavailableError { @@ -78,15 +82,15 @@ class GdbExecutableRefusal extends AnalysisCapabilityUnavailableError { } } -const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024; const MAX_COMMAND_OUTPUT_RECORDS = 16_384; const MAX_MI_LINE_BYTES = 16 * 1024 * 1024; const MAX_RECENT_MI_LINE_BYTES = 64 * 1024; const MAX_DIAGNOSTIC_BYTES = 64 * 1024; -const DEFAULT_MAX_SESSIONS = 8; -const MAX_PENDING_GDB_COMMANDS = 16; const RECENT_RECORD_TRUNCATION_MARKER = "[recent record truncated]"; +const gdbRetainedOutputLimitation = (output: DebuggerAdmissionLimit): string => + `Retained MI records reached the ${String(output.value)} byte command-output cap or the ${String(MAX_COMMAND_OUTPUT_RECORDS)} record cap. ${debuggerAdmissionRecovery(output)} The record cap has no separate setting. Output completeness is unknown.`; + const cancelledWithCallerReason = ( operation: string, signal: AbortSignal, @@ -123,6 +127,8 @@ interface PendingCommand { readonly evidenceCommand: string; readonly lines: string[]; outputBytes: number; + /** Caller output uses the setting. Startup and detach replies use the control budget. */ + readonly outputBudgetBytes: number; outputTruncated: boolean; outputInvalidUtf8: boolean; terminalRecord: string | undefined; @@ -137,10 +143,10 @@ class GdbCommandTimeoutError extends AnalysisTimeoutError { constructor( readonly partial: GdbCommandResult, readonly evidence: Evidence, - timeoutMs: number, + deadline: DebuggerAdmissionLimit, ) { - super("gdb_console", timeoutMs, { - ...fixedDeadlineGuidance("The GDB MI command", timeoutMs), + super("gdb_console", deadline.value, { + ...configurableDeadlineGuidance("The GDB MI command", deadline), }); } } @@ -503,6 +509,8 @@ interface GdbSession { readonly knownInferiorIdentities: Map; readonly inferiorTty: string | undefined; readonly maxCommandOutputBytes: number; + /** Startup and detach MI. Separate from the caller command-output cap. */ + readonly controlReplyBytes: number; readonly stdoutSettled: Promise; readonly settleStdout: () => void; inferiorTtyVerified: boolean; @@ -558,6 +566,7 @@ export class GdbSessionManager { >(); readonly #command: string; readonly #environment: NodeJS.ProcessEnv; + readonly #commandDeadline: DebuggerAdmissionLimit; readonly #commandTimeoutMs: number; readonly #startupTimeoutMs: number; readonly #platform: NodeJS.Platform; @@ -569,8 +578,11 @@ export class GdbSessionManager { rememberIdentity: (identity: OwnedInferiorIdentity) => void, ) => Promise; readonly #readInferiorProcessStartTime: ProcessStartTimeReader | undefined; - readonly #maxSessions: number; + readonly #maxSessions: DebuggerAdmissionLimit; + readonly #maxPendingCommands: DebuggerAdmissionLimit; + readonly #commandOutput: DebuggerAdmissionLimit; readonly #maxCommandOutputBytes: number; + readonly #controlReplyBytes: number; #sessionReservations = 0; #closing = false; #closePromise: Promise | undefined; @@ -578,6 +590,7 @@ export class GdbSessionManager { constructor( options: { readonly environment?: Readonly; + /** @internal Test seam. Production uses REA_DEBUGGER_COMMAND_DEADLINE_MS. */ readonly commandTimeoutMs?: number; readonly startupTimeoutMs?: number; readonly platform?: NodeJS.Platform; @@ -591,14 +604,27 @@ export class GdbSessionManager { ) => Promise; /** @internal Read-only process identity observer for lifecycle tests. */ readonly readInferiorProcessStartTime?: ProcessStartTimeReader; + /** @internal Test seam. Production uses REA_DEBUGGER_MAX_SESSIONS. */ readonly maxSessions?: number; - /** @internal Test seam; production callers should use the 16 MiB default. */ + /** @internal Test seam. Production uses REA_DEBUGGER_MAX_PENDING_COMMANDS. */ + readonly maxPendingCommands?: number; + /** @internal Test seam. Production uses REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES. */ readonly maxCommandOutputBytes?: number; + /** + * @internal Test seam for startup and detach MI truncation. + * Production uses the fixed control-reply budget. + */ + readonly controlReplyBytes?: number; } = {}, ) { this.#environment = { ...(options.environment ?? process.env) }; this.#command = this.#environment.REA_GDB_COMMAND ?? "gdb"; - this.#commandTimeoutMs = options.commandTimeoutMs ?? COMMAND_TIMEOUT_MS; + const limits = resolveDebuggerSessionLimits(this.#environment); + this.#commandDeadline = applyDebuggerAdmissionOverride( + limits.commandDeadline, + options.commandTimeoutMs, + ); + this.#commandTimeoutMs = this.#commandDeadline.value; this.#startupTimeoutMs = options.startupTimeoutMs ?? STARTUP_TIMEOUT_MS; this.#platform = options.platform ?? process.platform; this.#stopProcess = options.stopProcess ?? stopOwnedDebuggerProcess; @@ -606,9 +632,21 @@ export class GdbSessionManager { this.#verifyInferiorOutputSink = options.verifyInferiorOutputSink ?? verifyGdbInferiorOutputSink; this.#readInferiorProcessStartTime = options.readInferiorProcessStartTime; - this.#maxSessions = options.maxSessions ?? DEFAULT_MAX_SESSIONS; - this.#maxCommandOutputBytes = - options.maxCommandOutputBytes ?? MAX_COMMAND_OUTPUT_BYTES; + this.#maxSessions = applyDebuggerAdmissionOverride( + limits.maxSessions, + options.maxSessions, + ); + this.#maxPendingCommands = applyDebuggerAdmissionOverride( + limits.maxPendingCommands, + options.maxPendingCommands, + ); + this.#commandOutput = applyDebuggerAdmissionOverride( + limits.maxCommandOutputBytes, + options.maxCommandOutputBytes, + ); + this.#maxCommandOutputBytes = this.#commandOutput.value; + this.#controlReplyBytes = + options.controlReplyBytes ?? DEBUGGER_CONTROL_REPLY_BYTES; } async start( @@ -698,19 +736,18 @@ export class GdbSessionManager { }), ); const occupied = this.#sessions.size + this.#sessionReservations; - if (occupied >= this.#maxSessions) + if (occupied >= this.#maxSessions.value) return err( - new AnalysisResourceConstraintError( + debuggerResourceConstraintError( "start_gdb_session", "memory", - `GDB session capacity reached (${String(this.#maxSessions)} sessions). This release has no runtime override.`, + `GDB session capacity reached (${String(this.#maxSessions.value)} sessions).`, + "Close a GDB session before starting another.", + this.#maxSessions, { - max_sessions: this.#maxSessions, + max_sessions: this.#maxSessions.value, active_or_reserved_sessions: occupied, }, - { - remediationAction: `Close a GDB session before starting another. This capacity of ${String(this.#maxSessions)} sessions has no runtime override.`, - }, ), ); this.#sessionReservations += 1; @@ -833,6 +870,7 @@ export class GdbSessionManager { knownInferiorIdentities: new Map(), inferiorTty, maxCommandOutputBytes: this.#maxCommandOutputBytes, + controlReplyBytes: this.#controlReplyBytes, stdoutSettled, settleStdout, inferiorTtyVerified: false, @@ -1272,9 +1310,7 @@ export class GdbSessionManager { : []), "MI records are returned as decoded text; their semantics depend on GDB version and target.", ...(result.output_truncated - ? [ - `Retained MI records reached the ${String(this.#maxCommandOutputBytes)} byte or ${String(MAX_COMMAND_OUTPUT_RECORDS)} record cap. This release has no runtime override. Output completeness is unknown.`, - ] + ? [gdbRetainedOutputLimitation(this.#commandOutput)] : []), ...(result.output_invalid_utf8 ? [ @@ -1316,9 +1352,7 @@ export class GdbSessionManager { limitations: [ "GDB exited before returning a correlated MI result record; only records retained for the active command are returned raw.", ...(result.output_truncated - ? [ - `Retained MI records reached the ${String(this.#maxCommandOutputBytes)} byte or ${String(MAX_COMMAND_OUTPUT_RECORDS)} record cap. This release has no runtime override. Output completeness is unknown.`, - ] + ? [gdbRetainedOutputLimitation(this.#commandOutput)] : []), ], }, @@ -1330,7 +1364,17 @@ export class GdbSessionManager { session, "gdb_console", stopped.reason, - { cause, partialObservation: evidence }, + { + cause, + partialObservation: evidence, + ...(result.output_truncated + ? { + diagnostics: debuggerAdmissionDiagnostics( + this.#commandOutput, + ), + } + : {}), + }, ), ); return ok({ result, evidence }); @@ -1338,9 +1382,9 @@ export class GdbSessionManager { return err( new AnalysisTimeoutError("gdb_console", this.#commandTimeoutMs, { cause, - ...fixedDeadlineGuidance( + ...configurableDeadlineGuidance( "The GDB MI command", - this.#commandTimeoutMs, + this.#commandDeadline, ), }), ); @@ -1571,18 +1615,17 @@ export class GdbSessionManager { signal?: AbortSignal, operationName: "gdb_console" | "close_gdb_session" = "gdb_console", ): Promise { - if (session.pendingCommandCount >= MAX_PENDING_GDB_COMMANDS) - throw new AnalysisResourceConstraintError( + if (session.pendingCommandCount >= this.#maxPendingCommands.value) + throw debuggerResourceConstraintError( operationName, "transport", - `GDB command queue capacity reached (${String(MAX_PENDING_GDB_COMMANDS)} pending commands). This release has no runtime override.`, + `GDB command queue capacity reached (${String(this.#maxPendingCommands.value)} pending commands).`, + "Wait for a GDB command on this session to finish, then retry.", + this.#maxPendingCommands, { - max_pending_commands: MAX_PENDING_GDB_COMMANDS, + max_pending_commands: this.#maxPendingCommands.value, active_or_queued_commands: session.pendingCommandCount, }, - { - remediationAction: `Wait for a GDB command on this session to finish, then retry. This queue of ${String(MAX_PENDING_GDB_COMMANDS)} commands has no runtime override.`, - }, ); session.pendingCommandCount += 1; const previous = session.commandTail; @@ -1677,8 +1720,7 @@ export class GdbSessionManager { for (const pending of session.pending.values()) { const lineBytes = Buffer.byteLength(line, "utf8") + 1; if ( - pending.outputBytes + lineBytes <= session.maxCommandOutputBytes && - pending.outputBytes + lineBytes <= MAX_COMMAND_OUTPUT_BYTES && + pending.outputBytes + lineBytes <= pending.outputBudgetBytes && pending.lines.length < MAX_COMMAND_OUTPUT_RECORDS ) { pending.lines.push(line); @@ -2063,9 +2105,9 @@ export class GdbSessionManager { this.#commandTimeoutMs, { cause: new Error(`GDB MI command timed out: ${command}`), - ...fixedDeadlineGuidance( + ...configurableDeadlineGuidance( "The GDB MI command", - this.#commandTimeoutMs, + this.#commandDeadline, ), }, ); @@ -2084,7 +2126,7 @@ export class GdbSessionManager { subjectUnavailableReason: "The active GDB inferior identity is not established by the debugger session identifier.", limitations: [ - `The GDB command exceeded its ${String(this.#commandTimeoutMs)} ms deadline (deadlineExpired). This release has no runtime override. Retained MI and stream records are partial and command completion is unknown.`, + `The GDB command exceeded its ${String(this.#commandDeadline.value)} ms deadline (deadlineExpired). ${debuggerAdmissionRecovery(this.#commandDeadline)} Retained MI and stream records are partial and command completion is unknown.`, "Cleanup of the owned GDB process was requested because output correlation could no longer be guaranteed.", "The unrestricted GDB console can access local shell, filesystem, network, and target controls.", ], @@ -2122,7 +2164,7 @@ export class GdbSessionManager { : new GdbCommandTimeoutError( partial, evidence, - this.#commandTimeoutMs, + this.#commandDeadline, ), ); }); @@ -2143,6 +2185,9 @@ export class GdbSessionManager { cleanup, timer, outputBytes: 0, + outputBudgetBytes: command.startsWith("-interpreter-exec console ") + ? session.maxCommandOutputBytes + : session.controlReplyBytes, outputTruncated: false, outputInvalidUtf8: false, terminalRecord: undefined, diff --git a/src/process/ownedDebuggerSession.ts b/src/process/ownedDebuggerSession.ts index a5e587c8a1..009bb42e82 100644 --- a/src/process/ownedDebuggerSession.ts +++ b/src/process/ownedDebuggerSession.ts @@ -1,5 +1,8 @@ import { readFile } from "node:fs/promises"; +import type { DebuggerAdmissionLimit } from "../config/debuggerSessionLimits.js"; +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; +import type { JsonValue } from "../domain/jsonValue.js"; import { parseLinuxProcessStat } from "./LinuxProcessStat.js"; /** Result of terminating one debugger process owned by this application. */ @@ -7,6 +10,84 @@ export type OwnedDebuggerStopResult = | { readonly status: "stopped" | "already_exited" } | { readonly status: "incomplete"; readonly reason: string }; +/** 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 restart_required: true; +} => ({ + setting: admission.setting, + effective_value: admission.value, + source: admission.source, + default_value: admission.defaultValue, + minimum: admission.minimum, + maximum: admission.maximum, + unit: admission.unit, + restart_required: true, +}); + +/** Name the setting, the effective value, its source, the range, and a restart. */ +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.`; + +/** Fixed startup deadline. Distinct from the shared command deadline setting. */ +export const fixedDeadlineGuidance = ( + what: string, + timeoutMs: number, +): { readonly userMessage: string; readonly remediationAction: string } => ({ + userMessage: `${what} reached its ${String(timeoutMs)} ms deadline (deadlineExpired). This release has no runtime override.`, + remediationAction: `This ${String(timeoutMs)} ms deadline has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, +}); + +/** Shared command deadline. The setting, not a per-provider constant, owns the cap. */ +export const configurableDeadlineGuidance = ( + what: string, + admission: DebuggerAdmissionLimit, +): { + readonly userMessage: string; + readonly remediationAction: string; + readonly diagnostics: ReturnType; +} => { + const recovery = debuggerAdmissionRecovery(admission); + return { + userMessage: `${what} reached its ${String(admission.value)} ms deadline (deadlineExpired). ${recovery}`, + remediationAction: recovery, + diagnostics: debuggerAdmissionDiagnostics(admission), + }; +}; + +/** Capacity refusal whose projection carries the setting, value, source, and range. */ +export const debuggerResourceConstraintError = ( + operation: string, + resource: "memory" | "transport", + summary: string, + remediation: string, + admission: DebuggerAdmissionLimit, + reported: Readonly>, +): AnalysisResourceConstraintError => { + const facts = debuggerAdmissionDiagnostics(admission); + const recovery = debuggerAdmissionRecovery(admission); + return new AnalysisResourceConstraintError( + operation, + resource, + `${summary} ${recovery}`, + { ...facts, ...reported }, + { + remediationAction: `${remediation} ${recovery}`, + diagnostics: facts, + }, + ); +}; + const DETACH_TIMEOUT_MS = 1_000; const MAX_LATE_DEBUGGER_OUTPUT_BYTES = 64 * 1024; export type OwnedDebuggerDetachResult = diff --git a/src/rizin/RizinDebugSessionManager.ts b/src/rizin/RizinDebugSessionManager.ts index cb3fc70941..050d9eb349 100644 --- a/src/rizin/RizinDebugSessionManager.ts +++ b/src/rizin/RizinDebugSessionManager.ts @@ -9,7 +9,6 @@ import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisInputError, - AnalysisResourceConstraintError, AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; import type { @@ -24,8 +23,18 @@ import { spawnOwnedProviderProcess, type SpawnedOwnedProviderProcess, } from "../process/ProviderProcess.js"; +import { + applyDebuggerAdmissionOverride, + debuggerControlReplyOutput, + resolveDebuggerSessionLimits, + type DebuggerAdmissionLimit, +} from "../config/debuggerSessionLimits.js"; import { closeOwnedDebuggerSessions, + configurableDeadlineGuidance, + debuggerAdmissionRecovery, + debuggerResourceConstraintError, + fixedDeadlineGuidance, stopOwnedDebuggerProcess, readLinuxProcessStartTime, verifyOwnedInferiorsEnded, @@ -35,22 +44,9 @@ import { import { absoluteCallerPath } from "../filesystem/callerPath.js"; import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js"; -const MAX_SESSIONS = 8; -const MAX_PENDING_COMMANDS = 16; -const FRAME_TIMEOUT_MS = 120_000; const CLEANUP_COMMAND_TIMEOUT_MS = 1_000; const STARTUP_TIMEOUT_MS = 30_000; - -/** Fixed debugger deadline. Callers cannot raise it in this release. */ -const fixedDeadlineGuidance = ( - what: string, - timeoutMs: number, -): { readonly userMessage: string; readonly remediationAction: string } => ({ - userMessage: `${what} reached its ${String(timeoutMs)} ms deadline (deadlineExpired). This release has no runtime override.`, - remediationAction: `This ${String(timeoutMs)} ms deadline has no runtime override. Allow execution, cleanup and delivery headroom in the outer MCP client deadline. Confirm outstanding work and cleanup before retrying.`, -}); const MAX_FRAME_BYTES = 16 * 1024 * 1024; -const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024; const MAX_QUEUED_FRAME_BYTES = 4 * 1024 * 1024; const MAX_QUEUED_FRAMES = 4096; const MIN_QUEUED_FRAME_CHARGE_BYTES = 64; @@ -148,9 +144,14 @@ interface RizinCommandTimeoutEvidenceInput { readonly inferiorTty: string; readonly cleanupFailure: string | undefined; readonly launchProvenance: RizinLaunchProvenance; - readonly timeoutMs?: number; + readonly deadline?: DebuggerAdmissionLimit; } +export const rizinCommandTimeoutLimitation = ( + deadline: DebuggerAdmissionLimit, +): string => + `The command timed out before its completion marker at ${String(deadline.value)} ms (deadlineExpired). ${debuggerAdmissionRecovery(deadline)} Retained output is partial and completion is unknown.`; + export const createRizinCommandTimeoutEvidence = ({ sessionId, command, @@ -158,7 +159,7 @@ export const createRizinCommandTimeoutEvidence = ({ inferiorTty, cleanupFailure, launchProvenance, - timeoutMs = FRAME_TIMEOUT_MS, + deadline = resolveDebuggerSessionLimits({}).commandDeadline, }: RizinCommandTimeoutEvidenceInput): Evidence => createRizinCommandEvidence({ sessionId, @@ -167,7 +168,7 @@ export const createRizinCommandTimeoutEvidence = ({ inferiorTty, launchProvenance, limitations: [ - `The command timed out before its completion marker at ${String(timeoutMs)} ms (deadlineExpired). This release has no runtime override. Retained output is partial and completion is unknown.`, + rizinCommandTimeoutLimitation(deadline), ...(result.output_invalid_utf8 ? [ "Collected command frame bytes did not form valid UTF-8; displayed output contains replacement characters.", @@ -260,8 +261,12 @@ export class RizinDebugSessionManager { #closingAll = false; readonly #startDrainWaiters: Array<() => void> = []; readonly #command: string; + readonly #commandDeadline: DebuggerAdmissionLimit; readonly #frameTimeoutMs: number; readonly #startupTimeoutMs: number; + readonly #maxSessions: DebuggerAdmissionLimit; + readonly #maxPendingCommands: DebuggerAdmissionLimit; + readonly #commandOutput: DebuggerAdmissionLimit; readonly #maxCommandOutputBytes: number; readonly #platform: NodeJS.Platform; readonly #stopProcess: typeof stopOwnedDebuggerProcess; @@ -270,10 +275,15 @@ export class RizinDebugSessionManager { constructor( environment: Readonly = process.env, options: { + /** @internal Test seam. Production uses REA_DEBUGGER_COMMAND_DEADLINE_MS. */ readonly frameTimeoutMs?: number; /** @internal Test seam; production callers should use the 30 s default. */ readonly startupTimeoutMs?: number; - /** @internal Test seam; production callers should use the 16 MiB default. */ + /** @internal Test seam. Production uses REA_DEBUGGER_MAX_SESSIONS. */ + readonly maxSessions?: number; + /** @internal Test seam. Production uses REA_DEBUGGER_MAX_PENDING_COMMANDS. */ + readonly maxPendingCommands?: number; + /** @internal Test seam. Production uses REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES. */ readonly maxCommandOutputBytes?: number; readonly platform?: NodeJS.Platform; readonly stopProcess?: typeof stopOwnedDebuggerProcess; @@ -282,10 +292,26 @@ export class RizinDebugSessionManager { ) { this.#environment = { ...environment }; this.#command = environment.REA_RIZIN_COMMAND ?? "rizin"; - this.#frameTimeoutMs = options.frameTimeoutMs ?? FRAME_TIMEOUT_MS; + const limits = resolveDebuggerSessionLimits(this.#environment); + this.#commandDeadline = applyDebuggerAdmissionOverride( + limits.commandDeadline, + options.frameTimeoutMs, + ); + this.#frameTimeoutMs = this.#commandDeadline.value; this.#startupTimeoutMs = options.startupTimeoutMs ?? STARTUP_TIMEOUT_MS; - this.#maxCommandOutputBytes = - options.maxCommandOutputBytes ?? MAX_COMMAND_OUTPUT_BYTES; + this.#maxSessions = applyDebuggerAdmissionOverride( + limits.maxSessions, + options.maxSessions, + ); + this.#maxPendingCommands = applyDebuggerAdmissionOverride( + limits.maxPendingCommands, + options.maxPendingCommands, + ); + this.#commandOutput = applyDebuggerAdmissionOverride( + limits.maxCommandOutputBytes, + options.maxCommandOutputBytes, + ); + this.#maxCommandOutputBytes = this.#commandOutput.value; this.#platform = options.platform ?? process.platform; this.#stopProcess = options.stopProcess ?? stopOwnedDebuggerProcess; this.#spawnProcess = options.spawnProcess ?? spawnOwnedProviderProcess; @@ -316,15 +342,17 @@ export class RizinDebugSessionManager { ]), ); const occupied = this.#sessions.size + this.#sessionReservations; - if (occupied >= MAX_SESSIONS) + if (occupied >= this.#maxSessions.value) return err( - new AnalysisResourceConstraintError( + debuggerResourceConstraintError( "start_rizin_debug_session", "memory", - `Rizin session capacity reached (${String(MAX_SESSIONS)} sessions). This release has no runtime override.`, - { max_sessions: MAX_SESSIONS, active_or_reserved_sessions: occupied }, + `Rizin session capacity reached (${String(this.#maxSessions.value)} sessions).`, + "Close a Rizin debug session before starting another.", + this.#maxSessions, { - remediationAction: `Close a Rizin debug session before starting another. This capacity of ${String(MAX_SESSIONS)} sessions has no runtime override.`, + max_sessions: this.#maxSessions.value, + active_or_reserved_sessions: occupied, }, ), ); @@ -621,19 +649,18 @@ export class RizinDebugSessionManager { return err(new AnalysisInputError("rizin_debug_command")); if (signal?.aborted) return err(rizinCancellation("rizin_debug_command", signal)); - if (session.pendingCommandCount >= MAX_PENDING_COMMANDS) + if (session.pendingCommandCount >= this.#maxPendingCommands.value) return err( - new AnalysisResourceConstraintError( + debuggerResourceConstraintError( "rizin_debug_command", "transport", - `Rizin command queue capacity reached (${String(MAX_PENDING_COMMANDS)} pending commands). This release has no runtime override.`, + `Rizin command queue capacity reached (${String(this.#maxPendingCommands.value)} pending commands).`, + "Wait for a Rizin debug command on this session to finish, then retry.", + this.#maxPendingCommands, { - max_pending_commands: MAX_PENDING_COMMANDS, + max_pending_commands: this.#maxPendingCommands.value, active_or_queued_commands: session.pendingCommandCount, }, - { - remediationAction: `Wait for a Rizin debug command on this session to finish, then retry. This queue of ${String(MAX_PENDING_COMMANDS)} commands has no runtime override.`, - }, ), ); session.pendingCommandCount += 1; @@ -724,8 +751,8 @@ export class RizinDebugSessionManager { session, marker, this.#frameTimeoutMs, + this.#commandOutput, signal, - this.#maxCommandOutputBytes, ); commandCapture = received.ok ? received.value : received.error.capture; if (!received.ok) { @@ -760,7 +787,7 @@ export class RizinDebugSessionManager { ? [ rizinCaptureStoppedSentence( captured.outputLimit, - this.#maxCommandOutputBytes, + this.#commandOutput, cleanupFailure, ), ] @@ -854,7 +881,7 @@ export class RizinDebugSessionManager { ); } if (interruption?.kind === "timeout") { - session.protocolError = `Rizin command timed out before its completion marker at ${String(this.#frameTimeoutMs)} ms (deadlineExpired). This release has no runtime override. Output correlation is no longer safe.`; + session.protocolError = `Rizin command timed out before its completion marker at ${String(this.#commandDeadline.value)} ms (deadlineExpired). ${debuggerAdmissionRecovery(this.#commandDeadline)} Output correlation is no longer safe.`; const stopped = await this.#stopDebuggerOnly(session, true); const result = { ...commandResult(interruption.capture, "unknown"), @@ -865,10 +892,10 @@ export class RizinDebugSessionManager { command, result, inferiorTty: session.inferiorTty, + deadline: this.#commandDeadline, cleanupFailure: stopped.status === "incomplete" ? stopped.reason : undefined, launchProvenance: session.launchProvenance, - timeoutMs: this.#frameTimeoutMs, }); return this.#finalizeCommandObservation( session, @@ -914,9 +941,9 @@ export class RizinDebugSessionManager { this.#frameTimeoutMs, { ...errorOptions, - ...fixedDeadlineGuidance( + ...configurableDeadlineGuidance( "The Rizin debug command", - this.#frameTimeoutMs, + this.#commandDeadline, ), }, ); @@ -1401,8 +1428,7 @@ export class RizinDebugSessionManager { session, marker, CLEANUP_COMMAND_TIMEOUT_MS, - undefined, - this.#maxCommandOutputBytes, + debuggerControlReplyOutput, ); if (!captured.ok) throw captured.error.cause; if (captured.value.outputTruncated) @@ -1623,7 +1649,7 @@ const bufferedRizinFrame = (session: RizinDebugSession, truncated: boolean) => { const rizinCaptureStoppedSentence = ( limit: RizinOutputLimit | undefined, - maxCommandOutputBytes: number, + output: DebuggerAdmissionLimit, cleanupFailure: string | undefined, ): string => { const cap = @@ -1634,22 +1660,27 @@ const rizinCaptureStoppedSentence = ( : limit === "queued_frames" ? `the queued frame limit of ${String(MAX_QUEUED_FRAMES)} frames or ${String(MAX_QUEUED_FRAME_BYTES)} bytes` : limit === "command_output_bytes" - ? `the ${String(maxCommandOutputBytes)} byte output limit` + ? `the ${String(output.value)} byte output limit` : "a capture limit"; const cleanup = cleanupFailure === undefined ? " The owned debugger session was stopped." : ` The owned debugger session remains retained because cleanup could not be verified: ${cleanupFailure}`; - return `The Rizin command exceeded ${cap}. This release has no runtime override. Retained output is partial and command completion is unknown.${cleanup}`; + const recovery = + limit === "command_output_bytes" + ? ` ${debuggerAdmissionRecovery(output)}` + : " This release has no runtime override."; + return `The Rizin command exceeded ${cap}.${recovery} Retained output is partial and command completion is unknown.${cleanup}`; }; const waitForCommandFrames = async ( session: RizinDebugSession, marker: string, timeoutMs: number, + commandOutput: DebuggerAdmissionLimit, signal?: AbortSignal, - maxCommandOutputBytes = MAX_COMMAND_OUTPUT_BYTES, ): Promise> => { + const maxCommandOutputBytes = commandOutput.value; const deadline = Date.now() + timeoutMs; const output: string[] = []; let outputBytes = 0; @@ -1777,7 +1808,10 @@ const waitForCommandFrames = async ( const markerSeen = append(received); if (outputTruncated) { if (outputLimit === "command_output_bytes") - session.protocolError ??= `Rizin command output exceeded the ${String(maxCommandOutputBytes)} byte (16 MiB) command limit. This release has no runtime override.`; + session.protocolError ??= + commandOutput === debuggerControlReplyOutput + ? `Rizin control readback exceeded the ${String(maxCommandOutputBytes)} byte control-reply budget. That budget is separate from REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES and has no runtime override.` + : `Rizin command output exceeded the ${String(maxCommandOutputBytes)} byte command limit. ${debuggerAdmissionRecovery(commandOutput)}`; else if (outputLimit === "frame_bytes") session.protocolError ??= `Rizin emitted a NUL frame exceeding the ${String(MAX_FRAME_BYTES)} byte (16 MiB) protocol limit. This release has no runtime override.`; return ok(snapshot()); diff --git a/stryker.config.mjs b/stryker.config.mjs index 0fe7f37455..38087bcb05 100644 --- a/stryker.config.mjs +++ b/stryker.config.mjs @@ -123,10 +123,6 @@ export default { kind: "constructor", owner: "GdbSessionManager", }, - { - kind: "declaration", - name: "DEFAULT_MAX_SESSIONS", - }, { kind: "declaration", owner: "GdbSessionManager", @@ -213,6 +209,12 @@ export default { name: "nextOffset", }, ]), + ...mutationRanges("src/domain/analysisResourceLimits.ts", [ + { + kind: "declaration", + name: "DEFAULT_DEBUGGER_MAX_SESSIONS", + }, + ]), ...mutationRanges("src/cli/reverseEngineeringCommands.ts", [ { kind: "command-handler", diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index bcd11c4754..1e48af6df0 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -25,6 +25,11 @@ import type { ProviderProcessHandle, SpawnedOwnedProviderProcess, } from "../../../src/process/ProviderProcess.js"; +import { + DEBUGGER_COMMAND_DEADLINE_SETTING, + DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING, +} from "../../../src/config/debuggerSessionLimits.js"; +import { DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES } from "../../../src/domain/analysisResourceLimits.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; import { parseLinuxProcessStat } from "../../../src/process/LinuxProcessStat.js"; @@ -1509,7 +1514,7 @@ it("rejects a truncated inferior TTY readback before publishing the session", as ); }); const manager = managerWithFakeSpawner(fake.spawnProcess, { - maxCommandOutputBytes: 80, + controlReplyBytes: 80, }); const started = await manager.start(undefined, { inferiorTty: tty }); @@ -1521,6 +1526,28 @@ it("rejects a truncated inferior TTY readback before publishing the session", as expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); }); +it("keeps inferior TTY readback when the caller output cap is smaller than that reply", async () => { + const tty = `/dev/pts/${"x".repeat(200)}`; + const fake = createFakeSpawner((child, token, command) => { + child.stdout.write( + command.includes("inferior-tty-show") + ? `${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n` + : `${token}^done\n`, + ); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + maxCommandOutputBytes: 80, + }); + + const started = await manager.start(undefined, { inferiorTty: tty }); + + if (!started.ok) throw started.error; + expect(started.value.mi_version).toMatch(/^mi[23]$/u); + expect(started.value.session_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u, + ); +}); + it.each([ ["trailing garbage", 'inferior_tty_terminal="/dev/pts/42"junk'], [ @@ -1871,13 +1898,22 @@ it("preserves a timeout while startup waits for the automatic-loading response", const started = await manager.start(); - expect(started.ok).toBe(false); - if (!started.ok) { - expect(started.error._tag).toBe("AnalysisTimeoutError"); - expect(started.error).toBeInstanceOf(AnalysisTimeoutError); - if (started.error instanceof AnalysisTimeoutError) - expect(started.error.operation).toBe("start_gdb_session"); - } + if (started.ok) throw new Error("expected startup to time out"); + expect(started.error._tag).toBe("AnalysisTimeoutError"); + if (!(started.error instanceof AnalysisTimeoutError)) throw started.error; + expect(started.error.operation).toBe("start_gdb_session"); + expect(projectAnalysisError(started.error)).toMatchObject({ + code: "provider_timeout", + details: { + diagnostics: { + setting: DEBUGGER_COMMAND_DEADLINE_SETTING, + effective_value: 25, + source: "runtime", + unit: "ms", + restart_required: true, + }, + }, + }); expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); }); @@ -3424,10 +3460,15 @@ process.stdin.on('data', (chunk) => { completion_status: "unknown", }, }); - expect(exited.error.partialObservation).toMatchObject({ - limitations: expect.arrayContaining([ - expect.stringContaining("16777216 byte or 16384 record cap"), - ]), + expect(projectAnalysisError(exited.error)).toMatchObject({ + code: "cleanup_incomplete", + details: { + diagnostics: { + setting: DEBUGGER_MAX_COMMAND_OUTPUT_BYTES_SETTING, + effective_value: DEFAULT_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES, + source: "default", + }, + }, }); } }); diff --git a/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts b/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts index 312237c2c3..c65ffbfdf2 100644 --- a/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts +++ b/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts @@ -4,6 +4,12 @@ import { AnalysisInputError, } from "../../../src/domain/analysisErrorCore.js"; import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; +import { + applyDebuggerAdmissionOverride, + DEBUGGER_COMMAND_DEADLINE_SETTING, + resolveDebuggerSessionLimits, +} from "../../../src/config/debuggerSessionLimits.js"; +import { rizinCommandTimeoutLimitation } from "../../../src/rizin/RizinDebugSessionManager.js"; import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js"; import { createRizinCoverageHarness, @@ -22,26 +28,33 @@ it.skipIf(process.platform === "win32")( frameTimeoutMs: 10, stopProcess: async () => ({ status: "stopped" }), }); - expect(started.ok).toBe(true); - if (!started.ok) return; + if (!started.ok) throw started.error; + const deadline = applyDebuggerAdmissionOverride( + resolveDebuggerSessionLimits({}).commandDeadline, + 10, + ); + expect(deadline).toMatchObject({ + setting: DEBUGGER_COMMAND_DEADLINE_SETTING, + value: 10, + source: "runtime", + unit: "ms", + }); const result = await manager.execute(started.value.session_id, "wait"); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.value.result.completion_status).toBe("unknown"); - expect(result.value.evidence.raw_result).toEqual(result.value.result); - expect(result.value.evidence.parameters.launch_provenance).toEqual({ - caller_selected_path: "/tmp/injected", - resolved_path: "/tmp/injected", - }); - expect(result.value.evidence.limitations).toEqual([ - "The current debugger target identity is not established by the Rizin session identifier.", - "The command timed out before its completion marker at 10 ms (deadlineExpired). This release has no runtime override. Retained output is partial and completion is unknown.", - "The owned debugger session was stopped because output correlation could no longer be guaranteed.", - "The command is unrestricted and may access local files, the shell, network, or mutate the target.", - "Interrupted output may include an incomplete completion-marker prefix; it is retained because it cannot be distinguished from command diagnostics.", - "Launch provenance records the original and resolved startup paths; it does not establish the current target after unrestricted debugger commands.", - ]); - } + if (!result.ok) throw result.error; + expect(result.value.result.completion_status).toBe("unknown"); + expect(result.value.evidence.raw_result).toEqual(result.value.result); + expect(result.value.evidence.parameters.launch_provenance).toEqual({ + caller_selected_path: "/tmp/injected", + resolved_path: "/tmp/injected", + }); + expect(result.value.evidence.limitations).toEqual([ + "The current debugger target identity is not established by the Rizin session identifier.", + rizinCommandTimeoutLimitation(deadline), + "The owned debugger session was stopped because output correlation could no longer be guaranteed.", + "The command is unrestricted and may access local files, the shell, network, or mutate the target.", + "Interrupted output may include an incomplete completion-marker prefix; it is retained because it cannot be distinguished from command diagnostics.", + "Launch provenance records the original and resolved startup paths; it does not establish the current target after unrestricted debugger commands.", + ]); }, ); @@ -499,6 +512,43 @@ it.skipIf(process.platform === "win32").each([ }, ); +it.skipIf(process.platform === "win32")( + "accepts cleanup readback larger than the caller command-output cap", + async () => { + const cleanupReadback = `${" ".repeat(32)}[]`; + const injected = createInjectedProcess({ + onWrite: (text, control) => { + const marker = /!echo (.+)\n/u.exec(text)?.[1]; + if (marker === undefined) return; + if (text.startsWith("ok\n")) control.output(`ok\0${marker}\0`); + else if (text.startsWith("olj\n")) + control.output(`${cleanupReadback}\0${marker}\0`); + else control.output(`${marker}\0`); + }, + }); + const { manager, started } = await startInjected(injected.spawnProcess, { + maxCommandOutputBytes: 8, + stopProcess: async (session) => { + try { + await session.detachInferior?.(); + injected.control.exit(); + return { status: "stopped" }; + } catch (cause) { + return { status: "incomplete", reason: String(cause) }; + } + }, + }); + if (!started.ok) throw started.error; + const executed = await manager.execute(started.value.session_id, "ok"); + if (!executed.ok) throw executed.error; + expect(executed.value.result.completion_status).toBe("complete"); + expect(Buffer.byteLength(cleanupReadback, "utf8")).toBeGreaterThan(8); + const closed = await manager.close(started.value.session_id); + if (!closed.ok) throw closed.error; + expect(closed.value).toEqual({ target_state: "unknown" }); + }, +); + it.skipIf(process.platform === "win32")( "retains a startup protocol diagnosis across successful cleanup synchronization", async () => { diff --git a/tests/boundary/providers/RizinTimeoutEvidence.test.ts b/tests/boundary/providers/RizinTimeoutEvidence.test.ts index 2f383b8b16..8c9d4484b6 100644 --- a/tests/boundary/providers/RizinTimeoutEvidence.test.ts +++ b/tests/boundary/providers/RizinTimeoutEvidence.test.ts @@ -1,6 +1,14 @@ import { expect, it } from "vitest"; -import { createRizinCommandTimeoutEvidence } from "../../../src/rizin/RizinDebugSessionManager.js"; +import { + DEBUGGER_COMMAND_DEADLINE_SETTING, + resolveDebuggerSessionLimits, +} from "../../../src/config/debuggerSessionLimits.js"; +import { DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS } from "../../../src/domain/analysisResourceLimits.js"; +import { + createRizinCommandTimeoutEvidence, + rizinCommandTimeoutLimitation, +} from "../../../src/rizin/RizinDebugSessionManager.js"; it("preserves every field of partial Rizin timeout Evidence", () => { const result = { @@ -35,9 +43,16 @@ it("preserves every field of partial Rizin timeout Evidence", () => { }); expect(evidence.raw_result).toEqual(result); expect(evidence.normalized_result).toEqual(result); + const deadline = resolveDebuggerSessionLimits({}).commandDeadline; + expect(deadline).toMatchObject({ + setting: DEBUGGER_COMMAND_DEADLINE_SETTING, + value: DEFAULT_DEBUGGER_COMMAND_DEADLINE_MS, + source: "default", + unit: "ms", + }); expect(evidence.limitations).toEqual([ "The current debugger target identity is not established by the Rizin session identifier.", - "The command timed out before its completion marker at 120000 ms (deadlineExpired). This release has no runtime override. Retained output is partial and completion is unknown.", + rizinCommandTimeoutLimitation(deadline), "The owned debugger session was stopped because output correlation could no longer be guaranteed.", "The command is unrestricted and may access local files, the shell, network, or mutate the target.", "Interrupted output may include an incomplete completion-marker prefix; it is retained because it cannot be distinguished from command diagnostics.",