Files
reverse-engineering-anything/src/gdb/GdbSessionManager.ts
T
morluto cb4e5cfbff feat(analysis): make resource budgets recoverable across CLI and MCP (#9706)
* refactor(analysis): centralize resource budgets and recovery facts

* feat(analysis): expose ASAR and Ghidra runtime admission controls

* feat(artifacts): expose reader admission and preserve limited recognition

* feat(javascript): expose recoverable semantic precision budgets

* feat(web): add recoverable source-map budgets and compact retained views

* feat(web): make historical capture decoding budgets recoverable

* fix(desktop): enforce selected view policies across capture and export

* feat(runtime): make Frida and LLDB retention budgets selectable

* feat(native): expose diagnostic budgets and preserve exhaustion causes

* feat(managed): expose recoverable snapshot and representation budgets

* feat(metadata): make Go and Rust inspection budgets recoverable

* feat(wasm): expose representation and producer admissions

* feat(evm): expose per-call worker resource budgets

* feat(android): make resource and string-pool omissions recoverable

* feat(producers): expose command and recorded-capture budgets

* docs(analysis): record recovery contracts and integrated validation

* fix(docs): link calibration oracle from published verification pages

* fix(docs): restore desktop task anchors and receipt links

* test(analysis): align fixtures with selectable budget contracts

* fix(analysis): remove source-location projector dependency cycle

* test(analysis): refresh budget recovery and verifier regressions

* test(analysis): preserve format and publication recovery expectations

* test(windows): align conformance with owned runtime admission

* fix(desktop): preserve selected alias kinds in exported state

* test(analysis): align provider fixtures with recoverable budgets

* test(desktop): validate emitted alias metadata structurally

* fix(ghidra): classify bounded Windows endpoint refusals

* test(cli): assert selectable artifact admission recovery

* fix(cli): preserve capture provenance in environment builds
2026-11-10 05:11:20 -05:00

2505 lines
89 KiB
TypeScript

import { GDB_PROVIDER_IDENTITY } from "./GdbProviderIdentity.js";
import { persistentDebuggerHostSupported } from "../process/PersistentDebuggerHost.js";
import { ABORTED, waitForAbortable } from "../process/AbortablePromise.js";
import { snapshotGdbCommandRecords } from "./GdbCommandRecords.js";
import { randomUUID } from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { isUtf8 } from "node:buffer";
import { readlink, realpath, stat } from "node:fs/promises";
import {
AnalysisCapabilityUnavailableError,
AnalysisCancelledError,
AnalysisInputError,
AnalysisTimeoutError,
} from "../domain/analysisErrorCore.js";
import {
AnalysisError,
type AnalysisErrorOptions,
} from "../domain/analysisErrorBase.js";
import { createEvidence, type Evidence } from "../domain/evidence.js";
import type {
GdbCommandResult,
GdbOutputLimit,
} from "../domain/commandObservation.js";
import { DEBUGGER_CONTROL_REPLY_BYTES } from "../domain/analysisResourceLimits.js";
import { err, ok, type Result } from "../domain/result.js";
import {
ProviderProcessSupervisor,
spawnOwnedProviderProcess,
type OwnedProviderProcessSpawnOptions,
type SpawnedOwnedProviderProcess,
} from "../process/ProviderProcess.js";
import {
resolveDebuggerSessionLimits,
type DebuggerAdmissionLimit,
} from "../config/debuggerSessionLimits.js";
import {
closeOwnedDebuggerSessions,
configurableDeadlineGuidance,
debuggerAdmissionDiagnostics,
debuggerAdmissionRecovery,
debuggerResourceConstraintError,
fixedDeadlineGuidance,
readLinuxProcessStartTime,
stopOwnedDebuggerProcess,
type OwnedDebuggerDetachResult,
type OwnedInferiorIdentity,
type ProcessStartTimeReader,
verifyOwnedInferiorsEnded,
} from "../process/ownedDebuggerSession.js";
const STARTUP_TIMEOUT_MS = 10_000;
const copiedTimeoutGuidance = (
cause: AnalysisTimeoutError,
): {
readonly userMessage?: string;
readonly remediationAction?: string;
readonly diagnostics?: AnalysisTimeoutError["diagnostics"];
} => ({
...(cause.userMessage === undefined
? {}
: { userMessage: cause.userMessage }),
...(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 {
constructor(command: string, systemCode: string, cause: Error) {
super("gdb", "start_gdb_session", cause.message, {
cause,
userMessage: `Could not launch the selected GDB executable ${JSON.stringify(command)} (${systemCode}).`,
remediationAction:
"Set REA_GDB_COMMAND to an installed executable GDB command, or make that command available on PATH. Check execute permission on the command and search permission on its parent directories before retrying.",
diagnostics: {
boundary: "provider-launch",
command,
system_code: systemCode,
},
});
}
}
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 RECENT_RECORD_TRUNCATION_MARKER = "[recent record truncated]";
const gdbRetainedOutputLimitations = (
result: GdbCommandResult,
output: DebuggerAdmissionLimit,
): string[] =>
(result.output_limits ?? []).map((limit) => {
switch (limit.kind) {
case "command_bytes":
return `Retained MI records reached the ${String(limit.maximum)} byte command-output cap. ${debuggerAdmissionRecovery(output)} Retained output is partial.`;
case "command_records":
return `Retained MI records reached the ${String(limit.maximum)} record cap. This release has no runtime override for that count; REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES does not raise it. Narrow the command's output on the next call. Retained output is partial.`;
case "mi_line_bytes":
return `GDB emitted a stream record exceeding the ${String(limit.maximum)} byte MI line limit; that record was dropped. This release has no runtime override for that protocol limit; REA_DEBUGGER_MAX_COMMAND_OUTPUT_BYTES does not raise it. Select a command that emits smaller records on the next call. Retained output is partial.`;
}
});
const cancelledWithCallerReason = (
operation: string,
signal: AbortSignal,
previous?: AnalysisCancelledError,
): AnalysisCancelledError => {
const cancellation = new AnalysisCancelledError(operation, {
cause: signal.reason,
cleanupPrimary: previous,
...(previous?.diagnostics === undefined
? {}
: { diagnostics: previous.diagnostics }),
...(previous?.capturedOutput === undefined
? {}
: { capturedOutput: previous.capturedOutput }),
...(previous?.cleanup === undefined ? {} : { cleanup: previous.cleanup }),
...(previous?.partialObservation === undefined
? {}
: { partialObservation: previous.partialObservation }),
});
if (previous?.cleanupIncomplete)
cancellation.bindCleanupDependencies([previous]);
return cancellation;
};
interface PendingCommand {
readonly token: number;
readonly command: string;
readonly evidenceCommand: string;
readonly lines: string[];
outputBytes: number;
/** Caller output uses the setting. Startup and detach replies use the control budget. */
readonly outputBudgetBytes: number;
readonly outputLimits: Set<GdbOutputLimit["kind"]>;
outputInvalidUtf8: boolean;
terminalRecord: string | undefined;
completionToken: number | undefined;
readonly resolve: (result: GdbCommandResult) => void;
readonly reject: (error: Error) => void;
readonly cleanup: () => void;
timer: ReturnType<typeof setTimeout>;
}
/** Preserve the producer's exact exhaustion facts through every completion path. */
const gdbOutputCapture = (
pending: PendingCommand,
): Pick<GdbCommandResult, "output_truncated" | "output_limits"> => ({
output_truncated: pending.outputLimits.size > 0,
...(pending.outputLimits.size === 0
? {}
: {
output_limits: [...pending.outputLimits].map((kind) => ({
kind,
maximum:
kind === "command_bytes"
? pending.outputBudgetBytes
: kind === "command_records"
? MAX_COMMAND_OUTPUT_RECORDS
: MAX_MI_LINE_BYTES,
})),
}),
});
class GdbCommandTimeoutError extends AnalysisTimeoutError {
constructor(
readonly partial: GdbCommandResult,
readonly evidence: Evidence,
deadline: DebuggerAdmissionLimit,
) {
super("gdb_console", deadline.value, {
...configurableDeadlineGuidance("The GDB MI command", deadline),
});
}
}
const truncateUtf8RecordPrefix = (line: string): string => {
const budget =
MAX_RECENT_MI_LINE_BYTES -
Buffer.byteLength(RECENT_RECORD_TRUNCATION_MARKER, "utf8");
let prefix = "";
let retainedBytes = 0;
for (const codePoint of line) {
const codePointBytes = Buffer.byteLength(codePoint, "utf8");
if (retainedBytes + codePointBytes > budget) break;
prefix += codePoint;
retainedBytes += codePointBytes;
}
return `${prefix}${RECENT_RECORD_TRUNCATION_MARKER}`;
};
/** Bound parser memory when a capped MI record contains extreme nesting. */
const MAX_GDB_MI_NESTED_VALUE_FRAMES = 256;
/** Parse a complete MI result record and retain the unique top-level groups list span. */
const gdbMiGroupsListSpan = (
mi: string,
): { readonly start: number; readonly end: number } | undefined => {
const prefix = /^(?:\d+)?\^done/u.exec(mi)?.[0];
if (prefix === undefined) return undefined;
const isNameCharacter = (character: string | undefined): boolean =>
character !== undefined &&
!/[=,{}[\]"\s]/u.test(character) &&
character.charCodeAt(0) >= 0x20;
const readName = (start: number): number => {
if (!isNameCharacter(mi[start])) return start;
let cursor = start;
while (isNameCharacter(mi[cursor])) cursor += 1;
return cursor;
};
const readString = (start: number): number | undefined => {
for (let cursor = start + 1; cursor < mi.length; cursor += 1) {
const character = mi[cursor];
if (character === '"') return cursor + 1;
if (character === "\\") {
cursor += 1;
const escaped = mi[cursor];
if (escaped === undefined) return undefined;
if (escaped === "x") {
const startHex = cursor + 1;
cursor = startHex;
while (/[0-9a-f]/iu.test(mi[cursor] ?? "")) cursor += 1;
if (cursor === startHex) return undefined;
cursor -= 1;
} else if (/[0-7]/u.test(escaped)) {
let digits = 1;
while (digits < 3 && /[0-7]/u.test(mi[cursor + 1] ?? "")) {
cursor += 1;
digits += 1;
}
} else if (!/^[abfnrtv\\'"?]$/u.test(escaped)) return undefined;
} else if (mi.charCodeAt(cursor) < 0x20) return undefined;
}
return undefined;
};
const stack: {
readonly kind: "list" | "tuple";
readonly close: "]" | "}";
readonly groupInventory: boolean;
readonly groupStart: number | undefined;
readonly groupTuple: boolean;
phase: "first" | "afterComma" | "afterItem";
listItems: "result" | "value" | undefined;
groupIdSeen: boolean;
}[] = [];
let groupSpan: { start: number; end: number } | undefined;
let groupSeen = false;
let cursor = prefix.length;
let valueStart = -1;
let pendingGroupList = false;
const completeValue = (): void => {
const parent = stack.at(-1);
if (parent !== undefined) parent.phase = "afterItem";
};
const beginValue = (): boolean => {
const character = mi[cursor];
if (pendingGroupList && character !== "[") return false;
if (character === '"') {
const end = readString(cursor);
if (end === undefined) return false;
cursor = end;
completeValue();
return true;
}
if (character === "[" || character === "{") {
const kind = character === "[" ? "list" : "tuple";
const groupInventory = pendingGroupList;
if (groupInventory && kind !== "list") return false;
if (stack.length >= MAX_GDB_MI_NESTED_VALUE_FRAMES) return false;
const parent = stack.at(-1);
const groupTuple = kind === "tuple" && parent?.groupInventory === true;
pendingGroupList = false;
cursor += 1;
stack.push({
kind,
close: kind === "list" ? "]" : "}",
groupInventory,
groupStart: groupInventory ? valueStart : undefined,
groupTuple,
phase: "first",
listItems: undefined,
groupIdSeen: false,
});
return true;
}
return false;
};
while (true) {
const frame = stack.at(-1);
if (frame === undefined) {
if (cursor === mi.length) break;
if (mi[cursor] !== ",") return undefined;
cursor += 1;
const nameEnd = readName(cursor);
if (nameEnd === cursor || mi[nameEnd] !== "=") return undefined;
const name = mi.slice(cursor, nameEnd);
if (name === "groups") {
if (groupSeen) return undefined;
groupSeen = true;
valueStart = nameEnd + 1;
pendingGroupList = true;
}
cursor = nameEnd + 1;
if (!beginValue()) return undefined;
continue;
}
if (frame.phase === "afterItem") {
if (mi[cursor] === ",") {
cursor += 1;
frame.phase = "afterComma";
continue;
}
if (mi[cursor] !== frame.close) return undefined;
if (frame.groupTuple && !frame.groupIdSeen) return undefined;
cursor += 1;
stack.pop();
if (frame.groupInventory) {
if (frame.groupStart === undefined) return undefined;
groupSpan = { start: frame.groupStart, end: cursor };
}
completeValue();
continue;
}
if (frame.phase === "first" && mi[cursor] === frame.close) {
if (frame.groupTuple && !frame.groupIdSeen) return undefined;
cursor += 1;
stack.pop();
if (frame.groupInventory) {
if (frame.groupStart === undefined) return undefined;
groupSpan = { start: frame.groupStart, end: cursor };
}
completeValue();
continue;
}
if (frame.phase === "first" || frame.phase === "afterComma") {
if (frame.kind === "tuple") {
const nameEnd = readName(cursor);
if (nameEnd === cursor || mi[nameEnd] !== "=") return undefined;
if (frame.groupTuple && mi.slice(cursor, nameEnd) === "id") {
if (frame.groupIdSeen || mi[nameEnd + 1] !== '"') return undefined;
frame.groupIdSeen = true;
}
cursor = nameEnd + 1;
if (!beginValue()) return undefined;
continue;
}
const nameEnd = readName(cursor);
const itemIsResult = nameEnd > cursor && mi[nameEnd] === "=";
const itemKind = itemIsResult ? "result" : "value";
if (frame.listItems !== undefined && frame.listItems !== itemKind)
return undefined;
frame.listItems = itemKind;
if (frame.groupInventory && (itemIsResult || mi[cursor] !== "{"))
return undefined;
if (itemIsResult) cursor = nameEnd + 1;
if (!beginValue()) return undefined;
continue;
}
return undefined;
}
return groupSpan;
};
/** @internal Pure parser for live PIDs in recursive GDB MI thread groups. */
export const gdbThreadGroupPids = (
mi: string,
): readonly string[] | undefined => {
const groupsSpan = gdbMiGroupsListSpan(mi);
if (groupsSpan === undefined) return undefined;
const start = groupsSpan.start + 1;
let squareDepth = 1;
let objectDepth = 0;
let quoted = false;
let escaped = false;
const pids: string[] = [];
let fieldStart = start;
let pidStart: number | undefined;
let hasLivePid = false;
let currentPid: string | undefined;
for (let index = start; index < mi.length; index += 1) {
const character = mi[index];
if (quoted) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') {
quoted = false;
if (pidStart !== undefined) {
const pid = mi.slice(pidStart, index);
if (!/^[1-9]\d*$/u.test(pid)) return undefined;
hasLivePid = true;
currentPid = pid;
pidStart = undefined;
}
}
continue;
}
if (
(mi[index - 1] === "{" || mi[index - 1] === ",") &&
mi.startsWith("groups=", index)
)
return undefined;
if (character === '"') {
if (
squareDepth === 1 &&
objectDepth === 1 &&
mi.slice(fieldStart, index) === "pid="
) {
if (hasLivePid) return undefined;
pidStart = index + 1;
}
quoted = true;
continue;
}
if (character === "[") squareDepth += 1;
else if (character === "]") {
squareDepth -= 1;
if (squareDepth === 0) {
if (objectDepth !== 0 || index + 1 !== groupsSpan.end) return undefined;
return pids;
}
} else if (character === "{") {
if (squareDepth === 1 && objectDepth === 0) {
hasLivePid = false;
currentPid = undefined;
fieldStart = index + 1;
}
objectDepth += 1;
} else if (character === "}") {
if (squareDepth === 1 && objectDepth === 1) {
const field = mi.slice(fieldStart, index);
if (field.startsWith("pid=") && !/^pid="[1-9]\d*"$/u.test(field))
return undefined;
}
objectDepth -= 1;
if (objectDepth < 0) return undefined;
if (squareDepth === 1 && objectDepth === 0 && hasLivePid) {
if (currentPid === undefined) return undefined;
pids.push(currentPid);
}
} else if (character === "," && squareDepth === 1 && objectDepth === 1) {
const field = mi.slice(fieldStart, index);
if (field.startsWith("pid=") && !/^pid="[1-9]\d*"$/u.test(field))
return undefined;
fieldStart = index + 1;
}
}
return undefined;
};
/** @internal Pure count of recursive GDB MI groups with live PIDs. */
export const gdbThreadGroupCount = (mi: string): number | undefined => {
const pids = gdbThreadGroupPids(mi);
return pids?.length;
};
const verifyGdbInferiorOutputSink = async (
pid: string,
selectedTty: string,
rememberIdentity: (identity: OwnedInferiorIdentity) => void,
): Promise<void> => {
if (process.platform !== "linux")
throw new Error(
"GDB cannot confirm live inferior output descriptors outside Linux /proc.",
);
const before = await readLinuxProcessStartTime(pid);
if (before === null)
throw new Error("GDB could not establish the live inferior identity.");
const ttyPath = await realpath(selectedTty);
const tty = await stat(ttyPath);
if ((tty.mode & fsConstants.S_IFMT) !== fsConstants.S_IFCHR)
throw new Error("The selected GDB output sink is not a character device.");
for (const descriptor of ["1", "2"]) {
const descriptorPath = `/proc/${pid}/fd/${descriptor}`;
const destination = await readlink(descriptorPath);
const observed = await stat(descriptorPath);
if (
observed.dev !== tty.dev ||
observed.ino !== tty.ino ||
(observed.mode & fsConstants.S_IFMT) !== fsConstants.S_IFCHR ||
(await realpath(destination)) !== ttyPath
)
throw new Error(
`GDB inferior descriptor ${descriptor} no longer points to the selected TTY.`,
);
}
const after = await readLinuxProcessStartTime(pid);
if (after !== before)
throw new Error(
"The GDB inferior identity changed during descriptor readback.",
);
rememberIdentity({ pid, startTime: before });
};
class GdbCommandProcessExitError extends Error {
constructor(readonly partial: GdbCommandResult) {
super(
`GDB exited before returning the active MI command result (exit code ${String(partial.process_exit_code)}, signal ${String(partial.process_exit_signal)}).`,
);
}
}
class GdbProtocolFailureError extends Error {
constructor(
message: string,
readonly partial: GdbCommandResult,
) {
super(message);
}
}
/** Convert an unknown boundary failure without dropping non-Error values. */
const gdbErrorMessage = (cause: unknown): string =>
cause instanceof Error ? cause.message : String(cause);
interface GdbSession {
readonly id: string;
readonly miVersion: "mi3" | "mi2";
readonly launched: SpawnedOwnedProviderProcess;
readonly supervisor: ProviderProcessSupervisor;
/** Confirms target disposition and any durable sink before process cleanup. */
readonly detachInferior: () => Promise<OwnedDebuggerDetachResult>;
readonly inferiorStateUnknown: () => boolean;
readonly verifyInferiorsEndedAfterExit: () => Promise<boolean>;
readonly knownInferiorIdentities: Map<string, OwnedInferiorIdentity>;
readonly inferiorTty: string | undefined;
readonly maxCommandOutputBytes: number;
/** Startup and detach MI. Separate from the caller command-output cap. */
readonly controlReplyBytes: number;
readonly stdoutSettled: Promise<void>;
readonly settleStdout: () => void;
inferiorTtyVerified: boolean;
noInferiorVerified: boolean;
/** Sticky admission seal once destructive cleanup starts, including failed stops. */
cleanupStarted: boolean;
stopAttempt: ReturnType<typeof stopOwnedDebuggerProcess> | undefined;
readonly pending: Map<number, PendingCommand>;
readonly cleanupFailures: Set<AnalysisError>;
readonly lines: string[];
readonly bufferChunks: Buffer[];
bufferTail: Buffer | undefined;
bufferTailBytes: number;
bufferBytes: number;
discardingOversizedStreamRecord: boolean;
invalidUtf8Observed: boolean;
promptTail: string;
ready: boolean;
exited: boolean;
exitCode: number | null;
exitSignal: NodeJS.Signals | null;
nextToken: number;
startupResolve: (() => void) | undefined;
startupReject: ((error: Error) => void) | undefined;
commandTail: Promise<void>;
pendingCommandCount: number;
protocolError: string | undefined;
recentRecordsTruncated: boolean;
}
const gdbConsoleUnavailable = (): AnalysisInputError =>
new AnalysisInputError("gdb_console", undefined, [
{
path: ["session_id"],
reason: "invalid_value",
message: "GDB session is unavailable or closed.",
},
]);
const gdbCloseUnavailable = (): AnalysisInputError =>
new AnalysisInputError("close_gdb_session");
/** Owns persistent GDB/MI sessions independently from the active binary target. */
export class GdbSessionManager {
readonly #sessions = new Map<string, GdbSession>();
readonly #activeStarts = new Map<
AbortController,
Promise<
Result<
{ readonly session_id: string; readonly mi_version: "mi3" | "mi2" },
AnalysisError
>
>
>();
readonly #command: string;
readonly #environment: NodeJS.ProcessEnv;
readonly #commandDeadline: DebuggerAdmissionLimit;
readonly #commandTimeoutMs: number;
readonly #startupTimeoutMs: number;
readonly #platform: NodeJS.Platform;
readonly #stopProcess: typeof stopOwnedDebuggerProcess;
readonly #spawnProcess: typeof spawnOwnedProviderProcess;
readonly #verifyInferiorOutputSink: (
pid: string,
selectedTty: string,
rememberIdentity: (identity: OwnedInferiorIdentity) => void,
) => Promise<void>;
readonly #readInferiorProcessStartTime: ProcessStartTimeReader | undefined;
readonly #maxSessions: DebuggerAdmissionLimit;
readonly #maxPendingCommands: DebuggerAdmissionLimit;
readonly #commandOutput: DebuggerAdmissionLimit;
readonly #maxCommandOutputBytes: number;
readonly #controlReplyBytes: number;
#sessionReservations = 0;
#closing = false;
#closePromise: Promise<void> | undefined;
constructor(
options: {
readonly environment?: Readonly<NodeJS.ProcessEnv>;
readonly startupTimeoutMs?: number;
readonly platform?: NodeJS.Platform;
readonly stopProcess?: typeof stopOwnedDebuggerProcess;
readonly spawnProcess?: typeof spawnOwnedProviderProcess;
/** @internal Tests may replace only the /proc and TTY readback boundary. */
readonly verifyInferiorOutputSink?: (
pid: string,
selectedTty: string,
rememberIdentity: (identity: OwnedInferiorIdentity) => void,
) => Promise<void>;
/** @internal Read-only process identity observer for lifecycle tests. */
readonly readInferiorProcessStartTime?: ProcessStartTimeReader;
/**
* @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";
const limits = resolveDebuggerSessionLimits(this.#environment);
this.#commandDeadline = limits.commandDeadline;
this.#commandTimeoutMs = this.#commandDeadline.value;
this.#startupTimeoutMs = options.startupTimeoutMs ?? STARTUP_TIMEOUT_MS;
this.#platform = options.platform ?? process.platform;
this.#stopProcess = options.stopProcess ?? stopOwnedDebuggerProcess;
this.#spawnProcess = options.spawnProcess ?? spawnOwnedProviderProcess;
this.#verifyInferiorOutputSink =
options.verifyInferiorOutputSink ?? verifyGdbInferiorOutputSink;
this.#readInferiorProcessStartTime = options.readInferiorProcessStartTime;
this.#maxSessions = limits.maxSessions;
this.#maxPendingCommands = limits.maxPendingCommands;
this.#commandOutput = limits.maxCommandOutputBytes;
this.#maxCommandOutputBytes = this.#commandOutput.value;
this.#controlReplyBytes =
options.controlReplyBytes ?? DEBUGGER_CONTROL_REPLY_BYTES;
}
async start(
signal?: AbortSignal,
options: { readonly inferiorTty?: string } = {},
): Promise<
Result<
{ readonly session_id: string; readonly mi_version: "mi3" | "mi2" },
AnalysisError
>
> {
if (
options.inferiorTty !== undefined &&
(options.inferiorTty.length === 0 || options.inferiorTty.includes("\0"))
)
return err(
new AnalysisInputError("start_gdb_session", undefined, [
{
path: ["inferior_tty"],
reason: "invalid_value",
message:
"The inferior TTY path must be non-empty and must not contain NUL.",
},
]),
);
if (this.#closing || signal?.aborted)
return err(
signal?.aborted
? cancelledWithCallerReason("start_gdb_session", signal)
: new AnalysisCancelledError("start_gdb_session"),
);
if (!persistentDebuggerHostSupported(this.#platform))
return err(
new AnalysisCapabilityUnavailableError(
"gdb",
"start_gdb_session",
this.#platform === "win32"
? "Persistent Windows GDB sessions are unavailable because owned Job Object cleanup can terminate debugger inferiors."
: "Persistent GDB sessions are available only on Linux because REA verifies live inferior output descriptors through /proc.",
),
);
const controller = new AbortController();
let callerAbortWon = false;
const relayAbort = (): void => {
if (controller.signal.aborted) return;
callerAbortWon = true;
controller.abort(signal?.reason);
};
signal?.addEventListener("abort", relayAbort, { once: true });
if (signal?.aborted) relayAbort();
const tracked = Promise.resolve()
.then(() => this.#startWithReservation(controller.signal, options))
.finally(() => {
signal?.removeEventListener("abort", relayAbort);
this.#activeStarts.delete(controller);
});
this.#activeStarts.set(controller, tracked);
return tracked.then((result) =>
!result.ok &&
result.error instanceof AnalysisCancelledError &&
callerAbortWon &&
signal !== undefined
? err(
cancelledWithCallerReason(
"start_gdb_session",
signal,
result.error,
),
)
: result,
);
}
async #startWithReservation(
signal: AbortSignal,
options: { readonly inferiorTty?: string } = {},
): Promise<
Result<
{ readonly session_id: string; readonly mi_version: "mi3" | "mi2" },
AnalysisError
>
> {
if (signal?.aborted)
return err(
new AnalysisCancelledError("start_gdb_session", {
cause: signal.reason,
}),
);
const occupied = this.#sessions.size + this.#sessionReservations;
if (occupied >= this.#maxSessions.value)
return err(
debuggerResourceConstraintError(
"start_gdb_session",
"memory",
`GDB session capacity reached (${String(this.#maxSessions.value)} sessions).`,
"Close a GDB session before starting another.",
this.#maxSessions,
{
max_sessions: this.#maxSessions.value,
active_or_reserved_sessions: occupied,
},
),
);
this.#sessionReservations += 1;
let reservationHeld = true;
const releaseReservation = (): void => {
if (!reservationHeld) return;
reservationHeld = false;
this.#sessionReservations -= 1;
};
try {
return await this.#startReserved(releaseReservation, signal, options);
} finally {
releaseReservation();
}
}
async #startReserved(
releaseReservation: () => void,
signal: AbortSignal,
options: { readonly inferiorTty?: string } = {},
): Promise<
Result<
{ readonly session_id: string; readonly mi_version: "mi3" | "mi2" },
AnalysisError
>
> {
const id = randomUUID();
const mi3 = await this.#startProtocol(
id,
"mi3",
releaseReservation,
signal,
options.inferiorTty,
);
return mi3.ok ||
signal.aborted ||
this.#sessions.has(id) ||
mi3.error instanceof GdbExecutableRefusal
? mi3
: this.#startProtocol(
id,
"mi2",
releaseReservation,
signal,
options.inferiorTty,
);
}
async #startProtocol(
id: string,
miVersion: "mi3" | "mi2",
releaseReservation: () => void,
signal: AbortSignal,
inferiorTty?: string,
): Promise<
Result<
{ readonly session_id: string; readonly mi_version: "mi3" | "mi2" },
AnalysisError
>
> {
let launched: SpawnedOwnedProviderProcess;
try {
const spawnOptions: OwnedProviderProcessSpawnOptions = {
command: this.#command,
arguments: ["--nx", "--quiet", `--interpreter=${miVersion}`],
runId: randomUUID(),
stdin: "pipe",
hostEnvironment: this.#environment,
signal,
};
launched = await this.#spawnProcess(spawnOptions);
} catch (cause) {
if (
(signal?.aborted && cause === signal.reason) ||
cause instanceof AnalysisCancelledError
)
return err(new AnalysisCancelledError("start_gdb_session", { cause }));
const code: unknown =
cause instanceof Error && "code" in cause ? cause.code : undefined;
if (
cause instanceof Error &&
typeof code === "string" &&
["ENOENT", "EACCES", "EPERM", "ENOEXEC", "ENOTDIR", "EISDIR"].includes(
code,
)
)
return err(new GdbExecutableRefusal(this.#command, code, cause));
return err(
new AnalysisCapabilityUnavailableError(
"gdb",
"start_gdb_session",
cause instanceof Error ? cause.message : "Unable to start GDB",
{ cause },
),
);
}
const supervisor = new ProviderProcessSupervisor(
{ process: launched.process, ownsProcessLifetime: false },
{ captureStdout: false, maxDiagnosticBytes: MAX_DIAGNOSTIC_BYTES },
);
let settleStdout!: () => void;
const stdoutSettled = new Promise<void>((resolve) => {
settleStdout = resolve;
});
const session: GdbSession = {
id,
miVersion,
launched,
supervisor,
detachInferior: () => this.#detachInferior(session),
inferiorStateUnknown: () => !session.noInferiorVerified,
verifyInferiorsEndedAfterExit: async () => {
const ended = await verifyOwnedInferiorsEnded(
session.knownInferiorIdentities.values(),
this.#readInferiorProcessStartTime,
);
if (ended) session.knownInferiorIdentities.clear();
return ended;
},
knownInferiorIdentities: new Map(),
inferiorTty,
maxCommandOutputBytes: this.#maxCommandOutputBytes,
controlReplyBytes: this.#controlReplyBytes,
stdoutSettled,
settleStdout,
inferiorTtyVerified: false,
// GDB is launched with --nx and receives no target-execution commands
// during startup, so no inferior can exist until execute() is called.
noInferiorVerified: true,
cleanupStarted: false,
stopAttempt: undefined,
pending: new Map(),
cleanupFailures: new Set(),
lines: [],
bufferChunks: [],
bufferTail: undefined,
bufferTailBytes: 0,
bufferBytes: 0,
discardingOversizedStreamRecord: false,
invalidUtf8Observed: false,
promptTail: "",
ready: false,
exited: false,
exitCode: null,
exitSignal: null,
nextToken: 1,
startupResolve: undefined,
startupReject: undefined,
commandTail: Promise.resolve(),
pendingCommandCount: 0,
protocolError: undefined,
recentRecordsTruncated: false,
};
launched.process.stdout?.on("data", (chunk: string | Buffer) =>
this.#receive(
session,
typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk,
),
);
if (launched.process.stdout === null) settleStdout();
else launched.process.stdout.once("end", settleStdout);
launched.process.stdin?.on("error", (cause: Error) =>
this.#failProtocol(session, `GDB stdin stream failed: ${cause.message}`),
);
launched.process.stdin?.once("close", () =>
this.#failProtocol(session, "GDB stdin stream closed unexpectedly."),
);
launched.process.once("exit", (code, signalCode) => {
session.exited = true;
session.exitCode = code;
session.exitSignal = signalCode;
void this.#waitForStdoutSettlement(session).then(() =>
session.startupReject?.(
new Error(
`GDB exited during startup (exit code ${String(code)}, signal ${String(signalCode)}).`,
),
),
);
void this.#settleExitedCommands(session, code, signalCode);
});
let startupTimer!: ReturnType<typeof setTimeout>;
let startupAbortListener!: () => void;
try {
await new Promise<void>((resolve, reject) => {
session.startupResolve = resolve;
session.startupReject = reject;
startupAbortListener = () =>
reject(
new AnalysisCancelledError("start_gdb_session", {
cause: signal?.reason,
}),
);
signal?.addEventListener("abort", startupAbortListener, {
once: true,
});
if (signal?.aborted) startupAbortListener();
startupTimer = setTimeout(
() =>
reject(
new AnalysisTimeoutError(
"start_gdb_session",
this.#startupTimeoutMs,
{
cause: new Error(
"GDB did not emit the MI prompt before the startup deadline",
),
...fixedDeadlineGuidance(
"GDB startup",
this.#startupTimeoutMs,
),
},
),
),
this.#startupTimeoutMs,
);
startupTimer.unref();
});
if (signal?.aborted)
throw new AnalysisCancelledError("start_gdb_session", {
cause: signal.reason,
});
const autoLoad = await this.#request(
session,
"-gdb-set auto-load off",
signal,
"start_gdb_session",
);
if (!/^\d+\^done(?:,|$)/u.test(autoLoad.mi)) {
throw new AnalysisCapabilityUnavailableError(
"gdb",
"start_gdb_session",
`GDB rejected the required automatic-loading disable command (${autoLoad.mi}); the session was not started.`,
);
}
if (inferiorTty !== undefined) {
const configured = await this.#request(
session,
`-inferior-tty-set ${miQuote(inferiorTty)}`,
signal,
"start_gdb_session",
);
if (
configured.completion_status !== "complete" ||
configured.output_truncated ||
!/^\d+\^done(?:,|$)/u.test(configured.mi)
)
throw new AnalysisCapabilityUnavailableError(
"gdb",
"start_gdb_session",
"GDB did not confirm the inferior TTY configuration.",
);
const observed = await this.#request(
session,
"-inferior-tty-show",
signal,
"start_gdb_session",
);
const reportedTty =
observed.completion_status === "complete" &&
!observed.output_truncated
? miStringField(observed.mi, "inferior_tty_terminal")
: undefined;
if (
!/^\d+\^done(?:,|$)/u.test(observed.mi) ||
reportedTty !== inferiorTty
)
throw new AnalysisCapabilityUnavailableError(
"gdb",
"start_gdb_session",
"GDB did not report the exact caller-selected inferior TTY after configuration.",
);
session.inferiorTtyVerified = true;
}
if (signal?.aborted)
throw new AnalysisCancelledError("start_gdb_session", {
cause: signal.reason,
});
// Give queued exit notifications a turn before publishing startup.
await new Promise<void>((resolve) => setImmediate(resolve));
if (signal?.aborted)
throw new AnalysisCancelledError("start_gdb_session", {
cause: signal.reason,
});
if (session.exited || session.protocolError !== undefined)
throw new Error(
session.protocolError ?? "GDB exited during startup readiness.",
);
// Publish readiness only after every mandatory startup check completes.
// Retained failed-start owners remain addressable for cleanup, not work.
session.ready = true;
// Before publication no target command can run. Once usable, the
// inferior state is no longer inferred from startup phase.
session.noInferiorVerified = false;
this.#sessions.set(id, session);
releaseReservation();
return ok({ session_id: id, mi_version: miVersion });
} catch (cause) {
const startupExited =
cause instanceof GdbCommandProcessExitError || session.exited;
const processExit =
cause instanceof GdbCommandProcessExitError
? cause.partial
: startupExited
? {
command: "GDB startup",
mi: "",
...snapshotGdbCommandRecords(session.lines),
process_exit_observed: true,
process_exit_code: session.exitCode,
process_exit_signal: session.exitSignal,
output_truncated: session.recentRecordsTruncated,
completion_status: "unknown" as const,
}
: undefined;
const startupEvidenceResult =
processExit ??
(session.lines.length > 0
? {
command: "GDB startup",
mi: "",
...snapshotGdbCommandRecords(session.lines),
output_truncated: session.recentRecordsTruncated,
completion_status: "unknown" as const,
}
: undefined);
const startupEvidencePayload =
startupEvidenceResult === undefined
? undefined
: {
...startupEvidenceResult,
...(session.invalidUtf8Observed
? { output_invalid_utf8: true as const }
: {}),
};
const startupEvidence =
startupEvidencePayload === undefined
? undefined
: createEvidence(undefined, GDB_PROVIDER_IDENTITY, {
predicateType: "rea.debugger.startup-observation",
operation: "start_gdb_session",
parameters: {
mi_version: miVersion,
command: startupEvidencePayload.command,
},
result: {
...startupEvidencePayload,
records: [...startupEvidencePayload.records],
},
rawResult: {
...startupEvidencePayload,
records: [...startupEvidencePayload.records],
},
subjectUnavailableReason:
"No usable debugger session became ready during GDB startup.",
limitations: [
"GDB startup failed before a usable session could be established; captured startup records are incomplete.",
...(session.invalidUtf8Observed
? [
"GDB startup output contained invalid UTF-8 bytes; decoded records contain replacement characters.",
]
: []),
],
});
const processOutput = session.supervisor.snapshot();
const capturedOutput = {
stdout: processOutput.stdout.text,
stderr: processOutput.stderr.text,
truncated:
processOutput.diagnosticTruncated === true ||
processOutput.stdout.observedBytes > processOutput.stdout.bytes ||
processOutput.stderr.observedBytes > processOutput.stderr.bytes,
stdout_bytes: processOutput.stdout.observedBytes,
stderr_bytes: processOutput.stderr.observedBytes,
exit_code: processOutput.exitCode ?? null,
signal: processOutput.signal ?? null,
};
const stopped = await this.#stopDebuggerOnly(session);
if (stopped.status === "incomplete") {
this.#sessions.set(id, session);
releaseReservation();
const cleanupFailure = this.#retainCleanupFailure(
session,
"start_gdb_session",
stopped.reason,
{ cause },
);
const retainCleanup = <T extends AnalysisError>(failure: T): T =>
failure.bindCleanupDependencies([cleanupFailure], {
cleanupResources: [session.id],
});
const cleanup = { reason: stopped.reason, resources: [session.id] };
const details = {
cause,
capturedOutput,
cleanup,
...(startupEvidence === undefined
? {}
: { partialObservation: startupEvidence }),
};
if (cause instanceof AnalysisTimeoutError)
return err(
retainCleanup(
new AnalysisTimeoutError(cause.operation, cause.timeoutMs, {
...details,
...copiedTimeoutGuidance(cause),
}),
),
);
if (cause instanceof AnalysisCancelledError)
return err(
retainCleanup(new AnalysisCancelledError(cause.operation, details)),
);
const startupFailureMessage = gdbErrorMessage(cause);
return err(
retainCleanup(
new AnalysisCapabilityUnavailableError(
"gdb",
"start_gdb_session",
`GDB startup failed (${startupFailureMessage}) and its owned process could not be confirmed stopped: ${stopped.reason}`,
details,
),
),
);
}
const errorDetails = {
cause,
capturedOutput,
...(startupEvidence === undefined
? {}
: { partialObservation: startupEvidence }),
};
const error =
cause instanceof AnalysisCancelledError
? new AnalysisCancelledError("start_gdb_session", errorDetails)
: cause instanceof AnalysisTimeoutError
? new AnalysisTimeoutError(
cause instanceof GdbCommandTimeoutError
? "start_gdb_session"
: cause.operation,
cause.timeoutMs,
{
...errorDetails,
...copiedTimeoutGuidance(cause),
},
)
: cause instanceof AnalysisCapabilityUnavailableError
? new AnalysisCapabilityUnavailableError(
"gdb",
"start_gdb_session",
cause.message,
errorDetails,
)
: session.protocolError !== undefined
? new AnalysisCapabilityUnavailableError(
"gdb",
"start_gdb_session",
session.protocolError,
errorDetails,
)
: new AnalysisCapabilityUnavailableError(
"gdb",
"start_gdb_session",
`GDB startup failed (${gdbErrorMessage(cause)}).`,
errorDetails,
);
return err(error);
} finally {
clearTimeout(startupTimer);
signal?.removeEventListener("abort", startupAbortListener);
}
}
async execute(
sessionId: string,
command: string,
signal?: AbortSignal,
): Promise<
Result<
{ readonly result: GdbCommandResult; readonly evidence: Evidence },
AnalysisError
>
> {
const session = this.#sessions.get(sessionId);
if (
this.#closing ||
session === undefined ||
!session.ready ||
session.cleanupStarted ||
session.exited ||
session.protocolError !== undefined
)
return err(gdbConsoleUnavailable());
if (signal?.aborted)
return err(cancelledWithCallerReason("gdb_console", signal));
try {
const completed = await this.#serialize(
session,
async () => {
// Retained cleanup ownership and a delayed exit event do not imply
// command readiness. Recheck admission when this queued turn starts.
if (
this.#closing ||
this.#sessions.get(sessionId) !== session ||
session.cleanupStarted ||
session.exited ||
session.protocolError !== undefined
)
return err(gdbConsoleUnavailable());
session.noInferiorVerified = false;
session.knownInferiorIdentities.clear();
if (commandMayChangeInferiorOutput(command))
session.inferiorTtyVerified = false;
try {
const result = await this.#request(
session,
`-interpreter-exec console ${miQuote(command)}`,
signal,
"gdb_console",
command,
);
const evidence = createEvidence(
undefined,
{ id: "gnu.gdb", name: "GNU GDB", version: null },
{
predicateType: "rea.debugger.console-observation",
operation: "gdb_console",
parameters: { session_id: sessionId, command },
result: {
command,
mi: result.mi,
records: [...result.records],
console: result.console,
target: result.target,
log: result.log,
output_truncated: result.output_truncated,
...(result.output_limits === undefined
? {}
: { output_limits: result.output_limits }),
...(result.output_invalid_utf8
? { output_invalid_utf8: true as const }
: {}),
completion_status: result.completion_status,
},
rawResult: {
command,
mi: result.mi,
records: [...result.records],
console: result.console,
target: result.target,
log: result.log,
output_truncated: result.output_truncated,
...(result.output_limits === undefined
? {}
: { output_limits: result.output_limits }),
...(result.output_invalid_utf8
? { output_invalid_utf8: true as const }
: {}),
completion_status: result.completion_status,
},
subjectUnavailableReason:
"The active GDB inferior identity is not established by the debugger session identifier.",
limitations: [
...(result.mi.length === 0
? [
"A separately tokenized read-only query confirmed return to GDB's command loop. The original result record could not be framed independently from raw stdout; its bytes remain in records and its result class is unknown.",
]
: []),
"MI records are returned as decoded text; their semantics depend on GDB version and target.",
...gdbRetainedOutputLimitations(result, this.#commandOutput),
...(result.output_invalid_utf8
? [
"MI stream bytes were not valid UTF-8; returned text contains replacement characters.",
]
: []),
"The unrestricted GDB console can access local shell, filesystem, network, and target controls.",
],
},
);
return ok({ result, evidence });
} catch (cause) {
if (cause instanceof AnalysisCancelledError) return err(cause);
if (cause instanceof AnalysisCapabilityUnavailableError)
return err(cause as AnalysisError);
if (cause instanceof GdbCommandTimeoutError) {
const result = cause.partial;
return ok({ result, evidence: cause.evidence });
}
if (session.protocolError !== undefined)
return err(await this.#stopAfterProtocolFailure(session, cause));
if (cause instanceof GdbCommandProcessExitError) {
const processExit = cause as GdbCommandProcessExitError;
const result: GdbCommandResult = {
...processExit.partial,
command,
};
const evidence = createEvidence(
undefined,
{ id: "gnu.gdb", name: "GNU GDB", version: null },
{
predicateType: "rea.debugger.console-observation",
operation: "gdb_console",
parameters: { session_id: sessionId, command },
result: { ...result, records: [...result.records] },
rawResult: { ...result, records: [...result.records] },
subjectUnavailableReason:
"The active GDB inferior identity is not established by the debugger session identifier.",
limitations: [
"GDB exited before returning a correlated MI result record; only records retained for the active command are returned raw.",
...gdbRetainedOutputLimitations(
result,
this.#commandOutput,
),
],
},
);
const stopped = await this.#stopDebuggerOnly(session);
if (stopped.status === "incomplete")
return err(
this.#retainCleanupFailure(
session,
"gdb_console",
stopped.reason,
{
cause,
partialObservation: evidence,
...(result.output_limits?.some(
(limit) => limit.kind === "command_bytes",
)
? {
diagnostics: debuggerAdmissionDiagnostics(
this.#commandOutput,
),
}
: {}),
},
),
);
return ok({ result, evidence });
}
return err(
new AnalysisTimeoutError("gdb_console", this.#commandTimeoutMs, {
cause,
...configurableDeadlineGuidance(
"The GDB MI command",
this.#commandDeadline,
),
}),
);
}
},
signal,
"gdb_console",
);
if (!completed.ok && completed.error.cleanupIncomplete)
session.cleanupFailures.add(completed.error);
return completed;
} catch (cause) {
const error =
cause instanceof AnalysisError
? cause
: new AnalysisCancelledError("gdb_console", { cause });
if (error.cleanupIncomplete) session.cleanupFailures.add(error);
return err(error);
}
}
status(sessionId: string):
| {
readonly session_id: string;
readonly mi_version: "mi3" | "mi2";
readonly state: "ready" | "closed";
readonly recent_mi_records: readonly string[];
readonly recent_mi_records_truncated: boolean;
readonly mi_invalid_utf8: boolean;
readonly stderr: string;
readonly stderr_observed_bytes: number;
readonly stderr_truncated: boolean;
readonly stderr_invalid_utf8: boolean;
readonly diagnostics_truncated: boolean;
readonly inferior_tty: string | null;
readonly inferior_tty_verified: boolean;
}
| undefined {
const session = this.#sessions.get(sessionId);
if (session === undefined) return undefined;
const diagnostics = session.supervisor.snapshot();
return {
session_id: session.id,
mi_version: session.miVersion,
state:
session.cleanupStarted ||
session.exited ||
session.protocolError !== undefined ||
!session.ready
? "closed"
: "ready",
recent_mi_records: [...session.lines],
recent_mi_records_truncated: session.recentRecordsTruncated,
mi_invalid_utf8: session.invalidUtf8Observed,
stderr: diagnostics.stderr.text,
stderr_observed_bytes: diagnostics.stderr.observedBytes,
stderr_truncated:
diagnostics.stderr.observedBytes > diagnostics.stderr.bytes,
stderr_invalid_utf8: diagnostics.stderr.invalidUtf8 === true,
diagnostics_truncated: diagnostics.diagnosticTruncated === true,
inferior_tty: session.inferiorTty ?? null,
inferior_tty_verified: session.inferiorTtyVerified,
};
}
async close(
sessionId: string,
): Promise<
Result<
{ readonly target_state: "none_observed" | "unknown" },
AnalysisError
>
> {
if (this.#closing)
return err(new AnalysisCancelledError("close_gdb_session"));
const session = this.#sessions.get(sessionId);
if (session === undefined) return err(gdbCloseUnavailable());
if (session.exited) return this.#finishSessionCleanup(session);
try {
return await this.#serialize(
session,
async () => {
// An earlier close can remove this session while this close waits.
if (this.#sessions.get(sessionId) !== session)
return err(gdbCloseUnavailable());
if (session.exited || session.cleanupStarted)
return this.#finishSessionCleanup(session);
let targetState: "none_observed" | "unknown" = "unknown";
try {
const groups = await this.#request(
session,
"-list-thread-groups --recurse 1",
);
if (
groups.completion_status !== "complete" ||
groups.output_truncated ||
!/^\d+\^done(?:,|$)/u.test(groups.mi)
)
throw new Error(
"GDB could not confirm its complete, untruncated inferior list before shutdown.",
);
const groupCount = gdbThreadGroupCount(groups.mi);
if (groupCount === undefined)
throw new Error(
"GDB returned an unknown inferior list before shutdown.",
);
if (
groupCount > 1 ||
(groupCount > 0 && !session.inferiorTtyVerified)
)
return err(
new AnalysisCapabilityUnavailableError(
GDB_PROVIDER_IDENTITY.id,
"close_gdb_session",
groupCount > 1
? "Multiple live GDB inferiors require an explicit disposition because automatic detach supports only one inferior."
: "Live GDB inferiors require an explicit disposition because no durable inferior TTY is verified.",
{
retryable: true,
userMessage:
"Close requires an explicit inferior disposition.",
remediationAction:
"Use gdb_console with this session_id to explicitly detach or terminate each remaining inferior, then retry close_gdb_session.",
},
),
);
// Refusing inferior disposition above leaves console remediation
// available; from here onward only owned cleanup may be retried.
session.cleanupStarted = true;
if (groupCount === 0) {
session.noInferiorVerified = true;
targetState = "none_observed";
await this.#request(session, "-gdb-exit");
} else {
session.noInferiorVerified = false;
const disposition = await this.#detachInferior(session);
targetState =
disposition.status === "no-inferior"
? "none_observed"
: "unknown";
}
} catch {
// If MI synchronization is lost, stop only the owned debugger process.
}
const stopped = await this.#stopDebuggerOnly(session);
if (stopped.status === "incomplete")
return err(
this.#retainCleanupFailure(
session,
"close_gdb_session",
stopped.reason,
),
);
this.#sessions.delete(sessionId);
return ok({ target_state: targetState });
},
undefined,
"close_gdb_session",
);
} catch (cause) {
if (cause instanceof AnalysisError) return err(cause);
throw cause;
}
}
async #finishSessionCleanup(
session: GdbSession,
): Promise<Result<{ readonly target_state: "unknown" }, AnalysisError>> {
const stopped = await this.#stopDebuggerOnly(session);
if (stopped.status === "incomplete")
return err(
this.#retainCleanupFailure(
session,
"close_gdb_session",
stopped.reason,
),
);
this.#sessions.delete(session.id);
return ok({ target_state: "unknown" });
}
async retryCleanupFailures(): Promise<Result<null, AnalysisError>> {
for (const session of this.#sessions.values()) {
if (session.cleanupFailures.size === 0) continue;
const stopped = await this.#stopDebuggerOnly(session);
if (stopped.status === "incomplete")
return err(
[...session.cleanupFailures].at(-1) ??
this.#retainCleanupFailure(
session,
"close_gdb_session",
stopped.reason,
),
);
this.#sessions.delete(session.id);
}
return ok(null);
}
async closeAll(): Promise<void> {
this.#closing = true;
this.#closePromise ??= this.#drainStartsAndCloseSessions().catch(
(cause: unknown) => {
this.#closePromise = undefined;
throw cause;
},
);
return this.#closePromise;
}
async #drainStartsAndCloseSessions(): Promise<void> {
const active = [...this.#activeStarts.entries()];
for (const [controller] of active) controller.abort();
await Promise.allSettled(active.map(([, start]) => start));
await Promise.all(
[...this.#sessions.values()].map((session) => session.commandTail),
);
await closeOwnedDebuggerSessions(
this.#sessions,
(session) => this.#stopDebuggerOnly(session),
"GDB",
);
}
async #serialize<Value>(
session: GdbSession,
operation: () => Promise<Value>,
signal?: AbortSignal,
operationName: "gdb_console" | "close_gdb_session" = "gdb_console",
): Promise<Value> {
if (session.pendingCommandCount >= this.#maxPendingCommands.value)
throw debuggerResourceConstraintError(
operationName,
"transport",
`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: this.#maxPendingCommands.value,
active_or_queued_commands: session.pendingCommandCount,
},
);
session.pendingCommandCount += 1;
const previous = session.commandTail;
let release: (() => void) | undefined;
let acquired = false;
session.commandTail = new Promise<void>((resolve) => {
release = resolve;
});
try {
await waitForGdbCommandTurn(previous, signal);
acquired = true;
return await operation();
} finally {
if (acquired) {
session.pendingCommandCount -= 1;
release?.();
} else {
// The cancelled placeholder still occupies commandTail until its turn.
// Keep its reservation until then so repeated cancellations cannot
// bypass the per-session queue bound while an earlier command stalls.
void previous.then(() => {
session.pendingCommandCount -= 1;
release?.();
});
}
}
}
#receive(session: GdbSession, data: Buffer): void {
if (session.startupResolve !== undefined) {
const prompt = session.promptTail + data.toString("utf8");
if (prompt.includes("(gdb)")) {
const resolve = session.startupResolve;
session.startupResolve = undefined;
resolve();
}
session.promptTail = prompt.slice(-4);
}
const lines: { readonly value: string; readonly invalidUtf8: boolean }[] =
[];
let offset = 0;
while (offset < data.byteLength) {
if (session.discardingOversizedStreamRecord) {
const newline = data.indexOf(0x0a, offset);
if (newline < 0) break;
session.discardingOversizedStreamRecord = false;
offset = newline + 1;
continue;
}
const newline = data.indexOf(0x0a, offset);
const piece = data.subarray(
offset,
newline < 0 ? data.byteLength : newline,
);
session.bufferBytes += piece.byteLength;
const lineBytes =
session.bufferBytes - (newline >= 0 && piece.at(-1) === 0x0d ? 1 : 0);
if (lineBytes > MAX_MI_LINE_BYTES) {
const firstByte =
session.bufferChunks[0]?.at(0) ??
session.bufferTail?.at(0) ??
piece.at(0);
if (firstByte === 0x7e || firstByte === 0x40 || firstByte === 0x26) {
session.bufferChunks.length = 0;
session.bufferTail = undefined;
session.bufferTailBytes = 0;
session.bufferBytes = 0;
for (const pending of session.pending.values())
pending.outputLimits.add("mi_line_bytes");
session.recentRecordsTruncated = true;
if (newline < 0) {
session.discardingOversizedStreamRecord = true;
break;
}
offset = newline + 1;
continue;
}
this.#failProtocol(
session,
`GDB emitted an MI line exceeding the ${String(MAX_MI_LINE_BYTES)} byte (16 MiB) protocol limit. This release has no runtime override.`,
);
return;
}
this.#appendLineBytes(session, piece);
if (newline < 0) break;
const record = Buffer.concat(
[
...session.bufferChunks,
...(session.bufferTail === undefined
? []
: [session.bufferTail.subarray(0, session.bufferTailBytes)]),
],
session.bufferBytes,
);
const withoutCarriageReturn =
record.at(-1) === 0x0d ? record.subarray(0, -1) : record;
const invalidUtf8 = !isUtf8(withoutCarriageReturn);
const line = withoutCarriageReturn.toString("utf8");
session.bufferChunks.length = 0;
session.bufferTail = undefined;
session.bufferTailBytes = 0;
session.bufferBytes = 0;
if (invalidUtf8) session.invalidUtf8Observed = true;
if (line.length > 0) lines.push({ value: line, invalidUtf8 });
offset = newline + 1;
}
for (const { value: line } of lines) {
if (Buffer.byteLength(line, "utf8") > MAX_RECENT_MI_LINE_BYTES) {
session.lines.push(truncateUtf8RecordPrefix(line));
session.recentRecordsTruncated = true;
} else session.lines.push(line);
}
if (session.lines.length > 256) {
session.lines.splice(0, session.lines.length - 256);
session.recentRecordsTruncated = true;
}
for (const { value: line, invalidUtf8 } of lines) {
for (const pending of session.pending.values()) {
const lineBytes = Buffer.byteLength(line, "utf8") + 1;
if (
pending.outputBytes + lineBytes <= pending.outputBudgetBytes &&
pending.lines.length < MAX_COMMAND_OUTPUT_RECORDS
) {
pending.lines.push(line);
pending.outputBytes += lineBytes;
pending.outputInvalidUtf8 ||= invalidUtf8;
} else {
if (pending.outputBytes + lineBytes > pending.outputBudgetBytes)
pending.outputLimits.add("command_bytes");
if (pending.lines.length >= MAX_COMMAND_OUTPUT_RECORDS)
pending.outputLimits.add("command_records");
}
}
if (line === "(gdb)" || line === "(gdb) ") {
// Shell stdout can precede its result token without an LF. Never scan
// caller output for a token-looking suffix. Once GDB returns its prompt,
// correlate a fresh read-only query before completing that console turn.
for (const pending of session.pending.values()) {
if (
session.ready &&
pending.command.startsWith("-interpreter-exec console ") &&
pending.terminalRecord === undefined &&
pending.completionToken === undefined
) {
pending.completionToken = session.nextToken++;
this.#writeMiRequest(
session,
pending.completionToken,
"-gdb-show mi-async",
);
}
}
}
const match = /^(\d+)\^(done|running|connected|error|exit)(?:,|$)/u.exec(
line,
);
if (match === null) continue;
const token = Number(match[1]);
const pending =
session.pending.get(token) ??
[...session.pending.values()].find(
(command) => command.completionToken === token,
);
if (pending === undefined) continue;
if (token === pending.completionToken) {
if (match[2] !== "done") {
this.#failProtocol(session, "GDB command completion query failed.");
continue;
}
} else {
pending.terminalRecord = line;
if (match[2] === "running" && pending.completionToken === undefined) {
// ^running and *stopped describe inferior execution, not completion
// of an outer CLI command such as a multi-run command file. GDB
// processes this read-only query after returning to its command loop.
pending.completionToken = session.nextToken++;
this.#writeMiRequest(
session,
pending.completionToken,
"-gdb-show mi-async",
);
}
if (pending.completionToken !== undefined) continue;
}
clearTimeout(pending.timer);
session.pending.delete(pending.token);
pending.cleanup();
pending.resolve({
command: "",
// A query proves return to the command loop, not the class of a
// result record that raw stdout prevented us from framing separately.
mi:
pending.terminalRecord ??
(token === pending.completionToken ? "" : line),
...snapshotGdbCommandRecords(pending.lines),
...gdbOutputCapture(pending),
...(pending.outputInvalidUtf8
? { output_invalid_utf8: true as const }
: {}),
completion_status: "complete",
});
}
}
#appendLineBytes(session: GdbSession, piece: Buffer): void {
const slabBytes = 64 * 1024;
let offset = 0;
while (offset < piece.length) {
session.bufferTail ??= Buffer.allocUnsafe(slabBytes);
const remaining = slabBytes - session.bufferTailBytes;
const copied = Math.min(remaining, piece.length - offset);
piece.copy(
session.bufferTail,
session.bufferTailBytes,
offset,
offset + copied,
);
session.bufferTailBytes += copied;
offset += copied;
if (session.bufferTailBytes === slabBytes) {
session.bufferChunks.push(session.bufferTail);
session.bufferTail = undefined;
session.bufferTailBytes = 0;
}
}
}
#failProtocol(session: GdbSession, message: string): void {
if (session.exited || session.protocolError !== undefined) return;
session.protocolError = message;
for (const pending of session.pending.values()) {
clearTimeout(pending.timer);
pending.cleanup();
pending.reject(
new GdbProtocolFailureError(message, {
command: pending.evidenceCommand,
mi: "",
...snapshotGdbCommandRecords(pending.lines),
...gdbOutputCapture(pending),
...(pending.outputInvalidUtf8
? { output_invalid_utf8: true as const }
: {}),
completion_status: "unknown",
}),
);
}
session.pending.clear();
session.startupReject?.(new Error(message));
}
async #stopAfterProtocolFailure(
session: GdbSession,
cause: unknown,
): Promise<AnalysisCapabilityUnavailableError> {
const stopped = await this.#stopDebuggerOnly(session);
const partial =
cause instanceof GdbProtocolFailureError ? cause.partial : undefined;
const partialObservation =
partial === undefined
? undefined
: createEvidence(undefined, GDB_PROVIDER_IDENTITY, {
predicateType: "rea.debugger.console-observation",
operation: "gdb_console",
parameters: {
session_id: session.id,
command: partial.command,
},
result: { ...partial, records: [...partial.records] },
rawResult: { ...partial, records: [...partial.records] },
subjectUnavailableReason:
"The active GDB inferior identity is not established by the debugger session identifier.",
limitations: [
"The GDB MI protocol failed; retained records are partial and command completion is unknown.",
"The unrestricted GDB console can access local shell, filesystem, network, and target controls.",
],
});
return new AnalysisCapabilityUnavailableError(
"gdb",
"gdb_console",
stopped.status === "incomplete"
? `${session.protocolError} The owned process could not be confirmed stopped: ${stopped.reason}`
: session.protocolError!,
{
cause,
...(stopped.status === "incomplete"
? {
cleanup: {
reason: stopped.reason,
resources: [session.id],
},
}
: {}),
...(partialObservation === undefined ? {} : { partialObservation }),
},
);
}
#stopDebuggerOnly(
session: GdbSession,
): ReturnType<typeof stopOwnedDebuggerProcess> {
session.cleanupStarted = true;
if (session.stopAttempt !== undefined) return session.stopAttempt;
const attempt = Promise.resolve()
.then(() => this.#stopProcess(session, "GDB"))
.catch((cause: unknown) => ({
status: "incomplete" as const,
reason: `Owned GDB shutdown failed: ${gdbErrorMessage(cause)}`,
}))
.then((stopped) => {
if (stopped.status !== "incomplete") {
for (const failure of session.cleanupFailures)
failure.resolveCleanup();
session.cleanupFailures.clear();
}
return stopped;
})
.finally(() => {
if (session.stopAttempt === attempt) session.stopAttempt = undefined;
});
session.stopAttempt = attempt;
return attempt;
}
#retainCleanupFailure(
session: GdbSession,
operation: string,
reason: string,
options: AnalysisErrorOptions = {},
): AnalysisError {
const failure = new AnalysisCapabilityUnavailableError(
"gdb",
operation,
`The owned GDB process could not be confirmed stopped: ${reason}`,
{ ...options, cleanup: { reason, resources: [session.id] } },
);
session.cleanupFailures.add(failure);
return failure;
}
async #detachInferior(
session: GdbSession,
): Promise<OwnedDebuggerDetachResult> {
if (session.noInferiorVerified) return { status: "no-inferior" };
if (session.exited)
throw new Error("GDB exited before inferior state could be verified.");
// A new MI inspection replaces the old snapshot. If this read is
// incomplete, malformed, or reports multiple inferiors, no earlier PID
// can stand in for the current unknown inventory.
session.knownInferiorIdentities.clear();
// Queue reservations have not sent MI requests and do not affect synchronization.
if (session.protocolError !== undefined || session.pending.size > 0)
throw new Error(
"GDB MI state is not synchronized; inferior state is unknown.",
);
const groups = await this.#request(
session,
"-list-thread-groups --recurse 1",
);
if (
groups.completion_status !== "complete" ||
groups.output_truncated ||
!/^\d+\^done(?:,|$)/u.test(groups.mi)
)
throw new Error("GDB could not confirm its inferior state.");
const inferiorPids = gdbThreadGroupPids(groups.mi);
if (inferiorPids?.length === 0) {
session.knownInferiorIdentities.clear();
session.noInferiorVerified = true;
return { status: "no-inferior" };
}
session.noInferiorVerified = false;
if (inferiorPids?.length !== 1)
throw new Error(
inferiorPids === undefined
? "GDB returned an unknown recursive inferior list."
: "GDB reports multiple inferiors; automatic detach cannot be confirmed safely.",
);
if (!session.inferiorTtyVerified)
throw new Error(
"GDB cannot detach the live inferior because no durable output sink was verified at session startup.",
);
const observedTty = await this.#request(session, "-inferior-tty-show");
if (
observedTty.completion_status !== "complete" ||
observedTty.output_truncated ||
!/^\d+\^done(?:,|$)/u.test(observedTty.mi) ||
miStringField(observedTty.mi, "inferior_tty_terminal") !==
session.inferiorTty
)
throw new Error(
"GDB no longer reports the caller-selected inferior TTY; detachment cannot be confirmed safely.",
);
await this.#verifyInferiorOutputSink(
inferiorPids[0]!,
session.inferiorTty!,
(identity) =>
session.knownInferiorIdentities.set(
`${identity.pid}:${identity.startTime}`,
identity,
),
);
const detached = await this.#request(session, "-target-detach");
if (
detached.completion_status !== "complete" ||
detached.output_truncated ||
!/^\d+\^done(?:,|$)/u.test(detached.mi)
)
throw new Error("GDB did not confirm inferior detachment.");
session.knownInferiorIdentities.clear();
// The sole recursively inventoried inferior is now detached. Retain this
// fact for owned shutdown even if the MI transport becomes unavailable.
session.noInferiorVerified = true;
return { status: "detached", outputSink: "durable" };
}
async #settleExitedCommands(
session: GdbSession,
code: number | null,
signalCode: NodeJS.Signals | null,
): Promise<void> {
await this.#waitForStdoutSettlement(session);
for (const pending of session.pending.values()) {
clearTimeout(pending.timer);
pending.reject(
new GdbCommandProcessExitError({
command: pending.evidenceCommand,
mi: "",
...snapshotGdbCommandRecords(pending.lines),
process_exit_observed: true,
process_exit_code: code,
process_exit_signal: signalCode,
...gdbOutputCapture(pending),
...(pending.outputInvalidUtf8
? { output_invalid_utf8: true as const }
: {}),
completion_status: "unknown",
}),
);
}
session.pending.clear();
}
async #waitForStdoutSettlement(session: GdbSession): Promise<void> {
let timer!: ReturnType<typeof setTimeout>;
try {
await Promise.race([
session.stdoutSettled,
new Promise<void>((resolve) => {
timer = setTimeout(resolve, 1_000);
timer.unref?.();
}),
]);
} finally {
clearTimeout(timer);
}
}
#request(
session: GdbSession,
command: string,
signal?: AbortSignal,
operation: "gdb_console" | "start_gdb_session" = "gdb_console",
evidenceCommand: string = command,
): Promise<GdbCommandResult> {
const stdin = session.launched.process.stdin;
if (session.exited)
return Promise.reject(
new Error(
`GDB MI process has exited (exit code ${String(session.exitCode)}, signal ${String(session.exitSignal)}).`,
),
);
if (
stdin === null ||
stdin === undefined ||
stdin.destroyed ||
!stdin.writable
) {
const message = "GDB MI input stream is closed";
this.#failProtocol(session, message);
return Promise.reject(new Error(message));
}
const token = session.nextToken++;
return new Promise<GdbCommandResult>((resolve, reject) => {
let abortListener!: () => void;
const cleanup = (): void => {
signal?.removeEventListener("abort", abortListener);
};
let pending!: PendingCommand;
const timer = setTimeout(() => {
session.pending.delete(token);
session.protocolError =
"A GDB MI command timed out; untagged stream output can no longer be safely correlated with later commands.";
pending.cleanup();
const partial: GdbCommandResult = {
command: pending.evidenceCommand,
mi: "",
...snapshotGdbCommandRecords(pending.lines),
...gdbOutputCapture(pending),
...(pending.outputInvalidUtf8
? { output_invalid_utf8: true as const }
: {}),
completion_status: "unknown",
};
const timeout = new AnalysisTimeoutError(
operation,
this.#commandTimeoutMs,
{
cause: new Error(`GDB MI command timed out: ${command}`),
...configurableDeadlineGuidance(
"The GDB MI command",
this.#commandDeadline,
),
},
);
const evidence = createEvidence(undefined, GDB_PROVIDER_IDENTITY, {
predicateType:
operation === "start_gdb_session"
? "rea.debugger.startup-observation"
: "rea.debugger.console-observation",
operation,
parameters: {
session_id: session.id,
command: pending.evidenceCommand,
},
result: { ...partial, records: [...partial.records] },
rawResult: { ...partial, records: [...partial.records] },
subjectUnavailableReason:
"The active GDB inferior identity is not established by the debugger session identifier.",
limitations: [
`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.`,
...gdbRetainedOutputLimitations(partial, this.#commandOutput),
"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.",
],
});
void this.#stopDebuggerOnly(session).then((stopped) => {
if (
stopped.status === "incomplete" &&
operation === "start_gdb_session"
) {
pending.reject(
new AnalysisTimeoutError(operation, this.#commandTimeoutMs, {
cause: timeout,
cleanup: { reason: stopped.reason, resources: [session.id] },
partialObservation: evidence,
...copiedTimeoutGuidance(timeout),
}),
);
return;
}
pending.reject(
stopped.status === "incomplete"
? new AnalysisCapabilityUnavailableError(
"gdb",
"gdb_console",
`GDB command output could not be correlated and its owned process could not be confirmed stopped: ${stopped.reason}`,
{
cause: timeout,
cleanup: {
reason: stopped.reason,
resources: [session.id],
},
partialObservation: evidence,
},
)
: new GdbCommandTimeoutError(
partial,
evidence,
this.#commandDeadline,
),
);
});
}, this.#commandTimeoutMs);
// Cleanup bounds its own wait, so a late reply must not hold the host open.
if (session.cleanupStarted) timer.unref();
pending = {
token,
command,
evidenceCommand,
lines: [],
resolve: (result) => {
cleanup();
resolve(result);
},
reject: (error) => {
cleanup();
reject(error);
},
cleanup,
timer,
outputBytes: 0,
outputBudgetBytes: command.startsWith("-interpreter-exec console ")
? session.maxCommandOutputBytes
: session.controlReplyBytes,
outputLimits: new Set(),
outputInvalidUtf8: false,
terminalRecord: undefined,
completionToken: undefined,
};
session.pending.set(token, pending);
abortListener = (): void => {
clearTimeout(pending.timer);
const partial: GdbCommandResult = {
command: pending.evidenceCommand,
mi: "",
...snapshotGdbCommandRecords(pending.lines),
...gdbOutputCapture(pending),
...(pending.outputInvalidUtf8
? { output_invalid_utf8: true as const }
: {}),
completion_status: "unknown",
};
const evidence = createEvidence(undefined, GDB_PROVIDER_IDENTITY, {
predicateType: "rea.debugger.console-observation",
operation: "gdb_console",
parameters: {
session_id: session.id,
command: pending.evidenceCommand,
},
result: { ...partial, records: [...partial.records] },
rawResult: { ...partial, records: [...partial.records] },
subjectUnavailableReason:
"The active GDB inferior identity is not established by the debugger session identifier.",
limitations: [
"The GDB command was cancelled; retained MI records are partial and command completion is unknown.",
...gdbRetainedOutputLimitations(partial, this.#commandOutput),
"Shutdown 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.",
],
});
session.pending.delete(token);
session.protocolError =
"A GDB MI command was cancelled; owned-session shutdown was requested because output correlation can no longer be guaranteed.";
void this.#stopDebuggerOnly(session).then((stopped) => {
if (stopped.status !== "incomplete") {
pending.reject(
new AnalysisCancelledError("gdb_console", {
cause: signal?.reason,
partialObservation: evidence,
}),
);
return;
}
const cleanupFailure = this.#retainCleanupFailure(
session,
"gdb_console",
stopped.reason,
);
pending.reject(
new AnalysisCancelledError("gdb_console", {
cause: signal?.reason,
cleanup: {
reason: stopped.reason,
resources: [session.id],
},
partialObservation: evidence,
}).bindCleanupDependencies([cleanupFailure], {
cleanupResources: [session.id],
}),
);
});
};
signal?.addEventListener("abort", abortListener, { once: true });
if (signal?.aborted) {
abortListener();
return;
}
this.#writeMiRequest(session, token, command);
}).then((result) => ({ ...result, command }));
}
#writeMiRequest(session: GdbSession, token: number, command: string): void {
try {
session.launched.process.stdin?.write(
`${token}${command}\n`,
"utf8",
(cause?: Error | null) => {
if (cause)
this.#failProtocol(
session,
`GDB stdin write failed: ${cause.message}`,
);
},
);
} catch (cause) {
this.#failProtocol(
session,
`GDB stdin write failed: ${cause instanceof Error ? cause.message : "unknown error"}`,
);
}
}
}
const miQuote = (value: string): string =>
`"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"').replaceAll("\r", "\\r").replaceAll("\n", "\\n").replaceAll("\t", "\\t")}"`;
/** @internal Decode one unambiguous GDB MI C-string field. */
export const miStringField = (
record: string,
field: string,
): string | undefined => {
const escapedField = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
const valuePattern = `((?:\\\\.|[^"\\\\])*)`;
const fieldPattern = new RegExp(
`(?:^|,)${escapedField}="${valuePattern}"`,
"gu",
);
const matches = [...record.matchAll(fieldPattern)];
if (matches.length !== 1) return undefined;
const match = new RegExp(
`^\\d+\\^done,${escapedField}="${valuePattern}"$`,
"u",
).exec(record);
const raw = match?.[1];
if (raw === undefined) return undefined;
const bytes: number[] = [];
for (let index = 0; index < raw.length;) {
const character = raw[index]!;
if (character !== "\\") {
const codePoint = raw.codePointAt(index);
if (
codePoint === undefined ||
(codePoint >= 0xd800 && codePoint <= 0xdfff)
)
return undefined;
const value = String.fromCodePoint(codePoint);
bytes.push(...Buffer.from(value, "utf8"));
index += value.length;
continue;
}
const escaped = raw[++index]!;
const simple: Readonly<Record<string, number>> = {
a: 7,
b: 8,
f: 12,
n: 10,
r: 13,
t: 9,
v: 11,
"\\": 92,
'"': 34,
"'": 39,
"?": 63,
};
const simpleByte = simple[escaped];
if (simpleByte !== undefined) {
bytes.push(simpleByte);
index += 1;
continue;
}
if (escaped === "x") {
const hex = /^[0-9a-f]+/iu.exec(raw.slice(index + 1))?.[0];
if (hex === undefined) return undefined;
bytes.push(Number.parseInt(hex, 16) & 0xff);
index += hex.length;
index += 1;
continue;
}
if (/[0-7]/u.test(escaped)) {
const octal = `${escaped}${/^[0-7]{0,2}/u.exec(raw.slice(index + 1))![0]}`;
bytes.push(Number.parseInt(octal, 8) & 0xff);
index += octal.length;
continue;
}
return undefined;
}
const decoded = Buffer.from(bytes).toString("utf8");
return Buffer.from(decoded, "utf8").equals(Buffer.from(bytes))
? decoded
: undefined;
};
// GDB resolves its built-in aliases before abbreviations, so `p` is print, never
// python. Of the prefixes of the commands classified below, only these four
// name another command (checked against GDB 18.1).
const GDB_BUILTIN_ALIASES: ReadonlyMap<string, string> = new Map([
["d", "delete"],
["p", "print"],
["s", "step"],
["t", "thread"],
]);
/** The first console word, lowercased, with GDB's built-in aliases resolved. */
const gdbCommandWord = (command: string): string | undefined => {
const word = /^\s*([^\s;]+)/u.exec(command)?.[1]?.toLowerCase();
return word === undefined
? undefined
: (GDB_BUILTIN_ALIASES.get(word) ?? word);
};
/** @internal Classify commands that can invalidate the configured output sink. */
export const gdbOneShotCommandMayInvalidateDetachProof = (
command: string,
): boolean => {
if (commandMayChangeInferiorOutput(command)) return true;
const firstToken = gdbCommandWord(command);
if (
firstToken !== undefined &&
(["source", "define", "alias"].some((name) =>
name.startsWith(firstToken),
) ||
firstToken === "py" ||
"python".startsWith(firstToken))
)
return true;
// One-shot execution cannot establish which shell/GDB context interprets
// redirections, nor safely track multiline command definitions.
return /[<>]{1,2}|&>|[\r\n;]/u.test(command);
};
/** @internal Classify commands that can invalidate the configured output sink. */
export const commandMayChangeInferiorOutput = (command: string): boolean => {
// These exact read-only TTY inspections ("show inferior-tty", "help
// inferior-tty", MI "-inferior-tty-show") cannot reconfigure the output
// sink, so they must not revoke the startup verification. Keep compound
// commands and abbreviations under the conservative rules below
// (fail-closed).
if (
/^[ \t]*(?:show|help)[ \t]+inferior-tty[ \t]*$/iu.test(command) ||
/^[ \t]*-inferior-tty-show[ \t]*$/iu.test(command)
)
return false;
const setting = /^\s*(?:set|with)\s+(\S+)/iu.exec(command)?.[1];
const maySetInferiorTty =
setting !== undefined && "inferior-tty".startsWith(setting.toLowerCase());
const firstToken = gdbCommandWord(command);
const mayBeTtyCommand =
firstToken !== undefined && "tty".startsWith(firstToken);
const mayDefineAlias =
firstToken !== undefined && "alias".startsWith(firstToken);
const mayRunWithOutputRedirection =
firstToken !== undefined &&
["run", "start", "starti"].some((name) => name.startsWith(firstToken)) &&
/[<>]{1,2}|&>/u.test(command.slice(firstToken.length));
return (
maySetInferiorTty ||
mayBeTtyCommand ||
mayDefineAlias ||
/\binferior-tty\b/iu.test(command) ||
/^\s*(?:wit(?:h)?(?:\s|$)|tty(?:\s|$)|source\b|py\w*|define\b)/iu.test(
command,
) ||
mayRunWithOutputRedirection
);
};
/** Await a serialized command turn, preserving the caller's cancellation reason. */
const waitForGdbCommandTurn = (
previous: Promise<void>,
signal?: AbortSignal,
): Promise<void> => {
if (signal === undefined) return previous;
return waitForAbortable(previous, signal).then((outcome) => {
if (outcome === ABORTED)
throw new AnalysisCancelledError("gdb_console", { cause: signal.reason });
});
};