2058 lines
72 KiB
TypeScript
2058 lines
72 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { constants as fsConstants } from "node:fs";
|
|
import { isUtf8 } from "node:buffer";
|
|
import { readFile, readlink, realpath, stat } from "node:fs/promises";
|
|
|
|
import {
|
|
AnalysisCapabilityUnavailableError,
|
|
AnalysisCancelledError,
|
|
AnalysisInputError,
|
|
AnalysisResourceConstraintError,
|
|
AnalysisTimeoutError,
|
|
} from "../domain/analysisErrorCore.js";
|
|
import {
|
|
AnalysisError,
|
|
type AnalysisErrorOptions,
|
|
} from "../domain/analysisErrorBase.js";
|
|
import { createEvidence, type Evidence } from "../domain/evidence.js";
|
|
import { err, ok, type Result } from "../domain/result.js";
|
|
import {
|
|
ProviderProcessSupervisor,
|
|
spawnOwnedProviderProcess,
|
|
type OwnedProviderProcessSpawnOptions,
|
|
type SpawnedOwnedProviderProcess,
|
|
} from "../process/ProviderProcess.js";
|
|
import {
|
|
closeOwnedDebuggerSessions,
|
|
stopOwnedDebuggerProcess,
|
|
type OwnedDebuggerDetachResult,
|
|
type OwnedInferiorIdentity,
|
|
type ProcessStartTimeReader,
|
|
verifyOwnedInferiorsEnded,
|
|
} from "../process/ownedDebuggerSession.js";
|
|
import { parseLinuxProcessStartTime } from "../process/ProcessOwnershipObservation.js";
|
|
|
|
const STARTUP_TIMEOUT_MS = 10_000;
|
|
const COMMAND_TIMEOUT_MS = 120_000;
|
|
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]";
|
|
|
|
export const GDB_PROVIDER_IDENTITY = {
|
|
id: "gnu.gdb",
|
|
name: "GNU GDB",
|
|
version: null,
|
|
} as const;
|
|
|
|
interface PendingCommand {
|
|
readonly token: number;
|
|
readonly command: string;
|
|
readonly evidenceCommand: string;
|
|
readonly lines: string[];
|
|
outputBytes: number;
|
|
outputTruncated: boolean;
|
|
outputInvalidUtf8: boolean;
|
|
terminalRecord: string | undefined;
|
|
awaitingInferiorStop: boolean;
|
|
readonly resolve: (result: GdbCommandResult) => void;
|
|
readonly reject: (error: Error) => void;
|
|
readonly cleanup: () => void;
|
|
timer: ReturnType<typeof setTimeout>;
|
|
}
|
|
|
|
export interface GdbCommandResult {
|
|
readonly command: string;
|
|
readonly mi: string;
|
|
readonly records: readonly string[];
|
|
readonly console: string;
|
|
readonly target: string;
|
|
readonly log: string;
|
|
readonly process_exit_observed?: boolean;
|
|
readonly process_exit_code?: number | null;
|
|
readonly process_exit_signal?: NodeJS.Signals | null;
|
|
readonly output_truncated: boolean;
|
|
readonly completion_status: "complete" | "unknown";
|
|
readonly output_invalid_utf8?: true;
|
|
}
|
|
|
|
class GdbCommandTimeoutError extends AnalysisTimeoutError {
|
|
constructor(
|
|
readonly partial: GdbCommandResult,
|
|
readonly evidence: Evidence,
|
|
timeoutMs: number,
|
|
) {
|
|
super("gdb_console", timeoutMs);
|
|
}
|
|
}
|
|
|
|
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}`;
|
|
};
|
|
|
|
/** @internal Pure parser for live PIDs in recursive GDB MI thread groups. */
|
|
export const gdbThreadGroupPids = (
|
|
mi: string,
|
|
): readonly string[] | undefined => {
|
|
const marker = mi.indexOf("groups=[");
|
|
if (marker < 0) return undefined;
|
|
const start = marker + "groups=[".length;
|
|
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 (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) return undefined;
|
|
const body = mi.slice(start, index);
|
|
if (body.includes("groups=[")) 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 statPath = `/proc/${pid}/stat`;
|
|
const before = parseLinuxProcessStartTime(await readFile(statPath, "utf8"));
|
|
if (before === undefined)
|
|
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 = parseLinuxProcessStartTime(await readFile(statPath, "utf8"));
|
|
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);
|
|
}
|
|
}
|
|
|
|
/** @internal Convert an unknown boundary failure without dropping non-Error values. */
|
|
export 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;
|
|
readonly stdoutSettled: Promise<void>;
|
|
readonly settleStdout: () => void;
|
|
inferiorTtyVerified: boolean;
|
|
noInferiorVerified: 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;
|
|
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;
|
|
}
|
|
|
|
/** 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 #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: number;
|
|
readonly #maxCommandOutputBytes: number;
|
|
#sessionReservations = 0;
|
|
#closing = false;
|
|
#closePromise: Promise<void> | undefined;
|
|
|
|
constructor(
|
|
options: {
|
|
readonly environment?: Readonly<NodeJS.ProcessEnv>;
|
|
readonly commandTimeoutMs?: number;
|
|
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;
|
|
readonly maxSessions?: number;
|
|
/** @internal Test seam; production callers should use the 16 MiB default. */
|
|
readonly maxCommandOutputBytes?: number;
|
|
} = {},
|
|
) {
|
|
this.#environment = { ...(options.environment ?? process.env) };
|
|
this.#command = this.#environment.REA_GDB_COMMAND ?? "gdb";
|
|
this.#commandTimeoutMs = options.commandTimeoutMs ?? COMMAND_TIMEOUT_MS;
|
|
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 = options.maxSessions ?? DEFAULT_MAX_SESSIONS;
|
|
this.#maxCommandOutputBytes =
|
|
options.maxCommandOutputBytes ?? MAX_COMMAND_OUTPUT_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(new AnalysisCancelledError("start_gdb_session"));
|
|
if (this.#platform !== "linux")
|
|
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();
|
|
const relayAbort = (): void => 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;
|
|
}
|
|
|
|
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"));
|
|
const occupied = this.#sessions.size + this.#sessionReservations;
|
|
if (occupied >= this.#maxSessions)
|
|
return err(
|
|
new AnalysisResourceConstraintError(
|
|
"start_gdb_session",
|
|
"memory",
|
|
"GDB session capacity reached.",
|
|
{
|
|
max_sessions: this.#maxSessions,
|
|
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
|
|
: 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 instanceof AnalysisCancelledError)
|
|
return err(new AnalysisCancelledError("start_gdb_session", { cause }));
|
|
return err(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"gdb",
|
|
"start_gdb_session",
|
|
cause instanceof Error ? cause.message : "Unable to start GDB",
|
|
),
|
|
);
|
|
}
|
|
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,
|
|
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,
|
|
stopAttempt: undefined,
|
|
pending: new Map(),
|
|
cleanupFailures: new Set(),
|
|
lines: [],
|
|
bufferChunks: [],
|
|
bufferTail: undefined,
|
|
bufferTailBytes: 0,
|
|
bufferBytes: 0,
|
|
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"));
|
|
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",
|
|
),
|
|
},
|
|
),
|
|
),
|
|
this.#startupTimeoutMs,
|
|
);
|
|
startupTimer.unref();
|
|
});
|
|
session.ready = true;
|
|
if (signal?.aborted)
|
|
throw new AnalysisCancelledError("start_gdb_session");
|
|
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");
|
|
// 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");
|
|
if (session.exited || session.protocolError !== undefined)
|
|
throw new Error(
|
|
session.protocolError ?? "GDB exited during startup readiness.",
|
|
);
|
|
// Before this point no target command can run. Once a usable session is
|
|
// published, 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: "",
|
|
records: [...session.lines],
|
|
console: session.lines
|
|
.filter((line) => line.startsWith("~"))
|
|
.join("\n"),
|
|
target: session.lines
|
|
.filter((line) => line.startsWith("@"))
|
|
.join("\n"),
|
|
log: session.lines
|
|
.filter((line) => line.startsWith("&"))
|
|
.join("\n"),
|
|
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: "",
|
|
records: [...session.lines],
|
|
console: session.lines
|
|
.filter((line) => line.startsWith("~"))
|
|
.join("\n"),
|
|
target: session.lines
|
|
.filter((line) => line.startsWith("@"))
|
|
.join("\n"),
|
|
log: session.lines
|
|
.filter((line) => line.startsWith("&"))
|
|
.join("\n"),
|
|
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 cleanup = { reason: stopped.reason, resources: [session.id] };
|
|
const details = {
|
|
cause,
|
|
capturedOutput,
|
|
cleanup,
|
|
...(startupEvidence === undefined
|
|
? {}
|
|
: { partialObservation: startupEvidence }),
|
|
};
|
|
if (cause instanceof AnalysisTimeoutError)
|
|
return err(
|
|
new AnalysisTimeoutError(cause.operation, cause.timeoutMs, details),
|
|
);
|
|
if (cause instanceof AnalysisCancelledError)
|
|
return err(new AnalysisCancelledError(cause.operation, details));
|
|
const startupFailureMessage = gdbErrorMessage(cause);
|
|
return err(
|
|
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 =
|
|
signal?.aborted || 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,
|
|
)
|
|
: 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.exited ||
|
|
session.protocolError !== undefined
|
|
)
|
|
return err(
|
|
new AnalysisInputError("gdb_console", undefined, [
|
|
{
|
|
path: ["session_id"],
|
|
reason: "invalid_value",
|
|
message: "GDB session is unavailable or closed.",
|
|
},
|
|
]),
|
|
);
|
|
if (signal?.aborted) return err(new AnalysisCancelledError("gdb_console"));
|
|
try {
|
|
const completed = await this.#serialize(
|
|
session,
|
|
async () => {
|
|
if (session.exited || session.protocolError !== undefined)
|
|
return err(new AnalysisInputError("gdb_console"));
|
|
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_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_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: [
|
|
"MI records are returned as decoded text; their semantics depend on GDB version and target.",
|
|
...(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.",
|
|
...(result.output_truncated
|
|
? [
|
|
"Retained MI records are a truncated prefix; output completeness is unknown.",
|
|
]
|
|
: []),
|
|
],
|
|
},
|
|
);
|
|
const stopped = await this.#stopDebuggerOnly(session);
|
|
if (stopped.status === "incomplete")
|
|
return err(
|
|
this.#retainCleanupFailure(
|
|
session,
|
|
"gdb_console",
|
|
stopped.reason,
|
|
{ cause, partialObservation: evidence },
|
|
),
|
|
);
|
|
return ok({ result, evidence });
|
|
}
|
|
return err(
|
|
new AnalysisTimeoutError("gdb_console", this.#commandTimeoutMs, {
|
|
cause,
|
|
}),
|
|
);
|
|
}
|
|
},
|
|
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.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(new AnalysisInputError("close_gdb_session"));
|
|
if (session.exited) {
|
|
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: "unknown" });
|
|
}
|
|
try {
|
|
return await this.#serialize(
|
|
session,
|
|
async () => {
|
|
if (session.exited) {
|
|
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: "unknown" });
|
|
}
|
|
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 > 0 && !session.inferiorTtyVerified)
|
|
return err(
|
|
new AnalysisInputError("close_gdb_session", undefined, [
|
|
{
|
|
path: ["session_id"],
|
|
reason: "invalid_value",
|
|
message:
|
|
"Live GDB thread groups remain and no verified durable inferior TTY is configured. Select a safe disposition through the GDB console or restart with inferior_tty before closing.",
|
|
},
|
|
]),
|
|
);
|
|
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 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 >= MAX_PENDING_GDB_COMMANDS)
|
|
throw new AnalysisResourceConstraintError(
|
|
operationName,
|
|
"transport",
|
|
"GDB command queue capacity reached.",
|
|
{
|
|
max_pending_commands: MAX_PENDING_GDB_COMMANDS,
|
|
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) {
|
|
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) {
|
|
this.#failProtocol(
|
|
session,
|
|
"GDB emitted an MI line exceeding the 16 MiB protocol limit.",
|
|
);
|
|
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 <= session.maxCommandOutputBytes &&
|
|
pending.outputBytes + lineBytes <= MAX_COMMAND_OUTPUT_BYTES &&
|
|
pending.lines.length < MAX_COMMAND_OUTPUT_RECORDS
|
|
) {
|
|
pending.lines.push(line);
|
|
pending.outputBytes += lineBytes;
|
|
pending.outputInvalidUtf8 ||= invalidUtf8;
|
|
} else pending.outputTruncated = true;
|
|
}
|
|
if (line.startsWith("*stopped")) {
|
|
for (const [token, pending] of session.pending) {
|
|
if (!pending.awaitingInferiorStop) continue;
|
|
clearTimeout(pending.timer);
|
|
session.pending.delete(token);
|
|
pending.cleanup();
|
|
const records = [...pending.lines];
|
|
pending.resolve({
|
|
command: "",
|
|
mi: pending.terminalRecord ?? "",
|
|
records,
|
|
console: records
|
|
.filter((record) => record.startsWith("~"))
|
|
.join("\n"),
|
|
target: records
|
|
.filter((record) => record.startsWith("@"))
|
|
.join("\n"),
|
|
log: records.filter((record) => record.startsWith("&")).join("\n"),
|
|
output_truncated: pending.outputTruncated,
|
|
...(pending.outputInvalidUtf8
|
|
? { output_invalid_utf8: true as const }
|
|
: {}),
|
|
completion_status: "complete",
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
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);
|
|
if (pending === undefined) continue;
|
|
if (match[2] === "running") {
|
|
pending.terminalRecord = line;
|
|
pending.awaitingInferiorStop = true;
|
|
continue;
|
|
}
|
|
clearTimeout(pending.timer);
|
|
session.pending.delete(token);
|
|
pending.cleanup();
|
|
const records = [...pending.lines];
|
|
pending.resolve({
|
|
command: "",
|
|
mi: line,
|
|
records,
|
|
console: records.filter((record) => record.startsWith("~")).join("\n"),
|
|
target: records.filter((record) => record.startsWith("@")).join("\n"),
|
|
log: records.filter((record) => record.startsWith("&")).join("\n"),
|
|
output_truncated: pending.outputTruncated,
|
|
...(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();
|
|
const records = [...pending.lines];
|
|
pending.reject(
|
|
new GdbProtocolFailureError(message, {
|
|
command: pending.command,
|
|
mi: "",
|
|
records,
|
|
console: records.filter((line) => line.startsWith("~")).join("\n"),
|
|
target: records.filter((line) => line.startsWith("@")).join("\n"),
|
|
log: records.filter((line) => line.startsWith("&")).join("\n"),
|
|
output_truncated: pending.outputTruncated,
|
|
...(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> {
|
|
if (session.stopAttempt !== undefined) return session.stopAttempt;
|
|
const attempt = this.#stopProcess(session, "GDB")
|
|
.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();
|
|
if (
|
|
session.protocolError !== undefined ||
|
|
session.pending.size > 0 ||
|
|
session.pendingCommandCount > 1
|
|
)
|
|
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();
|
|
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.command,
|
|
mi: "",
|
|
records: [...pending.lines],
|
|
console: pending.lines
|
|
.filter((line) => line.startsWith("~"))
|
|
.join("\n"),
|
|
target: pending.lines
|
|
.filter((line) => line.startsWith("@"))
|
|
.join("\n"),
|
|
log: pending.lines.filter((line) => line.startsWith("&")).join("\n"),
|
|
process_exit_observed: true,
|
|
process_exit_code: code,
|
|
process_exit_signal: signalCode,
|
|
output_truncated: pending.outputTruncated,
|
|
...(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: "",
|
|
records: [...pending.lines],
|
|
console: pending.lines
|
|
.filter((line) => line.startsWith("~"))
|
|
.join("\n"),
|
|
target: pending.lines
|
|
.filter((line) => line.startsWith("@"))
|
|
.join("\n"),
|
|
log: pending.lines.filter((line) => line.startsWith("&")).join("\n"),
|
|
output_truncated: pending.outputTruncated,
|
|
...(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}`) },
|
|
);
|
|
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 deadline; 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.",
|
|
],
|
|
});
|
|
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,
|
|
}),
|
|
);
|
|
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.#commandTimeoutMs,
|
|
),
|
|
);
|
|
},
|
|
(cause: unknown) =>
|
|
pending.reject(
|
|
new AnalysisCapabilityUnavailableError(
|
|
"gdb",
|
|
"gdb_console",
|
|
"GDB command output could not be correlated and owned process shutdown failed.",
|
|
{
|
|
cause: new AggregateError(
|
|
[timeout, cause],
|
|
"GDB timeout and cleanup both failed.",
|
|
),
|
|
cleanup: {
|
|
reason: gdbErrorMessage(cause),
|
|
resources: [session.id],
|
|
},
|
|
partialObservation: evidence,
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}, this.#commandTimeoutMs);
|
|
pending = {
|
|
token,
|
|
command,
|
|
evidenceCommand,
|
|
lines: [],
|
|
resolve: (result) => {
|
|
cleanup();
|
|
resolve(result);
|
|
},
|
|
reject: (error) => {
|
|
cleanup();
|
|
reject(error);
|
|
},
|
|
cleanup,
|
|
timer,
|
|
outputBytes: 0,
|
|
outputTruncated: false,
|
|
outputInvalidUtf8: false,
|
|
terminalRecord: undefined,
|
|
awaitingInferiorStop: false,
|
|
};
|
|
session.pending.set(token, pending);
|
|
abortListener = (): void => {
|
|
clearTimeout(pending.timer);
|
|
const partial: GdbCommandResult = {
|
|
command: pending.evidenceCommand,
|
|
mi: "",
|
|
records: [...pending.lines],
|
|
console: pending.lines
|
|
.filter((line) => line.startsWith("~"))
|
|
.join("\n"),
|
|
target: pending.lines
|
|
.filter((line) => line.startsWith("@"))
|
|
.join("\n"),
|
|
log: pending.lines.filter((line) => line.startsWith("&")).join("\n"),
|
|
output_truncated: pending.outputTruncated,
|
|
...(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.",
|
|
"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) => {
|
|
pending.reject(
|
|
stopped.status === "incomplete"
|
|
? new AnalysisCancelledError("gdb_console", {
|
|
cause: new Error(stopped.reason),
|
|
cleanup: {
|
|
reason: stopped.reason,
|
|
resources: [session.id],
|
|
},
|
|
partialObservation: evidence,
|
|
})
|
|
: new AnalysisCancelledError("gdb_console", {
|
|
partialObservation: evidence,
|
|
}),
|
|
);
|
|
},
|
|
(cause: unknown) =>
|
|
pending.reject(
|
|
new AnalysisCancelledError("gdb_console", {
|
|
cause,
|
|
partialObservation: evidence,
|
|
}),
|
|
),
|
|
);
|
|
};
|
|
signal?.addEventListener("abort", abortListener, { once: true });
|
|
if (signal?.aborted) {
|
|
abortListener();
|
|
return;
|
|
}
|
|
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"}`,
|
|
);
|
|
}
|
|
}).then((result) => ({ ...result, command }));
|
|
}
|
|
}
|
|
|
|
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;
|
|
};
|
|
|
|
/** @internal Classify commands that can invalidate the configured output sink. */
|
|
export const gdbOneShotCommandMayInvalidateDetachProof = (
|
|
command: string,
|
|
): boolean => {
|
|
if (commandMayChangeInferiorOutput(command)) return true;
|
|
const firstToken = /^\s*([^\s;]+)/u.exec(command)?.[1]?.toLowerCase();
|
|
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 => {
|
|
const setting = /^\s*(?:set|with)\s+(\S+)/iu.exec(command)?.[1];
|
|
const maySetInferiorTty =
|
|
setting !== undefined && "inferior-tty".startsWith(setting.toLowerCase());
|
|
const firstToken = /^\s*([^\s;]+)/u.exec(command)?.[1]?.toLowerCase();
|
|
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
|
|
);
|
|
};
|
|
|
|
/** Terminate only the directly owned debugger process, never its process group or inferiors. */
|
|
export const waitForGdbCommandTurn = (
|
|
previous: Promise<void>,
|
|
signal?: AbortSignal,
|
|
): Promise<void> => {
|
|
if (signal === undefined) return previous;
|
|
if (signal.aborted)
|
|
return Promise.reject(new AnalysisCancelledError("gdb_console"));
|
|
return new Promise<void>((resolve, reject) => {
|
|
const onAbort = (): void =>
|
|
reject(new AnalysisCancelledError("gdb_console"));
|
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
previous.then(
|
|
() => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
resolve();
|
|
},
|
|
(cause: unknown) => {
|
|
signal.removeEventListener("abort", onAbort);
|
|
reject(cause);
|
|
},
|
|
);
|
|
});
|
|
};
|