2923 lines
98 KiB
TypeScript
2923 lines
98 KiB
TypeScript
import { EventEmitter } from "node:events";
|
||
import { spawn } from "node:child_process";
|
||
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||
import { tmpdir } from "node:os";
|
||
import { join } from "node:path";
|
||
import { PassThrough } from "node:stream";
|
||
|
||
import { afterEach, expect, it } from "vitest";
|
||
|
||
import { AnalysisTimeoutError } from "../../../src/domain/analysisErrorCore.js";
|
||
import {
|
||
commandMayChangeInferiorOutput,
|
||
gdbErrorMessage,
|
||
gdbThreadGroupCount,
|
||
GdbSessionManager,
|
||
miStringField,
|
||
waitForGdbCommandTurn,
|
||
} from "../../../src/gdb/GdbSessionManager.js";
|
||
import type {
|
||
OwnedProviderProcessSpawnOptions,
|
||
ProviderProcessHandle,
|
||
SpawnedOwnedProviderProcess,
|
||
} from "../../../src/process/ProviderProcess.js";
|
||
import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js";
|
||
import { parseLinuxProcessStartTime } from "../../../src/process/ProcessOwnershipObservation.js";
|
||
import { expectGdbSessionStarted } from "./gdbTestHelpers.js";
|
||
|
||
let directory: string | undefined;
|
||
const managers: GdbSessionManager[] = [];
|
||
|
||
it.each([
|
||
["empty list", "1^done,groups=[]", 0],
|
||
["one group", '1^done,groups=[{id="i1",pid="123"}]', 1],
|
||
[
|
||
"multiple groups",
|
||
'1^done,groups=[{id="i1",pid="123"},{id="i2",pid="456"}]',
|
||
2,
|
||
],
|
||
["group without a live pid", '1^done,groups=[{id="i1"}]', 0],
|
||
[
|
||
"nested group-like object",
|
||
'1^done,groups=[{id="i1",pid="123",children=[{id="i2"}]}]',
|
||
1,
|
||
],
|
||
[
|
||
"quoted brackets",
|
||
'1^done,groups=[{id="i1",pid="123",description="[{}]"}]',
|
||
1,
|
||
],
|
||
[
|
||
"escaped quote and backslash",
|
||
'1^done,groups=[{id="i1",pid="123",description="\\\\\\\""}]',
|
||
1,
|
||
],
|
||
[
|
||
"nested groups field",
|
||
'1^done,groups=[{id="i1",extra="groups=["}]',
|
||
undefined,
|
||
],
|
||
["negative object depth", "1^done,groups=[}]", undefined],
|
||
["unclosed list", '1^done,groups=[{id="i1"}', undefined],
|
||
["missing groups field", "1^done", undefined],
|
||
] as const)("parses recursive GDB MI groups: %s", (_label, mi, expected) => {
|
||
expect(gdbThreadGroupCount(mi)).toBe(expected);
|
||
});
|
||
|
||
it.each([
|
||
['1^done,inferior_tty_terminal="/dev/pts/2"', "/dev/pts/2"],
|
||
['1^done,inferior_tty_terminal="line\\nnext"', "line\nnext"],
|
||
['1^done,inferior_tty_terminal="\\x41\\101"', "AA"],
|
||
['1^done,inferior_tty_terminal="\\7"', "\u0007"],
|
||
['1^done,inferior_tty_terminal="emoji 🚀"', "emoji 🚀"],
|
||
['1^done,inferior_tty_terminal="\\xFF"', undefined],
|
||
['1^done,inferior_tty_terminal="\\x"', undefined],
|
||
['1^done,inferior_tty_terminal="\\q"', undefined],
|
||
['1^done,inferior_tty_terminal="/dev/pts/2"junk', undefined],
|
||
['1^done,inferior_tty_terminal="a",inferior_tty_terminal="a"', undefined],
|
||
['1^error,msg="not done"', undefined],
|
||
['1^done,inferior_tty_terminal="\ud800"', undefined],
|
||
] as const)("decodes GDB MI C-string fields: %s", (mi, expected) => {
|
||
expect(miStringField(mi, "inferior_tty_terminal")).toBe(expected);
|
||
});
|
||
|
||
it.each([
|
||
["show version", false],
|
||
["set pagination off", false],
|
||
["set inferior-t /tmp/output", true],
|
||
["inferior-tty /tmp/output", true],
|
||
["tty /dev/pts/2", true],
|
||
["source commands.gdb", true],
|
||
["python print(1)", true],
|
||
["define start\nrun", true],
|
||
["run > /tmp/output", true],
|
||
] as const)("classifies inferior output mutations: %s", (command, expected) => {
|
||
expect(commandMayChangeInferiorOutput(command)).toBe(expected);
|
||
});
|
||
|
||
it.each([
|
||
[new Error("provider failure"), "provider failure"],
|
||
["non-error provider failure", "non-error provider failure"],
|
||
] as const)("formats unknown GDB boundary failures", (cause, message) => {
|
||
expect(gdbErrorMessage(cause)).toBe(message);
|
||
});
|
||
|
||
it("rejects an invalid caller TTY and platforms without safe GDB ownership", async () => {
|
||
const invalid = await new GdbSessionManager().start(undefined, {
|
||
inferiorTty: "bad\0tty",
|
||
});
|
||
expect(invalid).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisInputError" },
|
||
});
|
||
|
||
let macSpawned = false;
|
||
const macos = new GdbSessionManager({
|
||
platform: "darwin",
|
||
spawnProcess: async () => {
|
||
macSpawned = true;
|
||
throw new Error("unsupported hosts must not spawn GDB");
|
||
},
|
||
});
|
||
managers.push(macos);
|
||
const macUnavailable = await macos.start();
|
||
expect(macUnavailable).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||
});
|
||
if (!macUnavailable.ok)
|
||
expect(macUnavailable.error.message).toContain("available only on Linux");
|
||
expect(macSpawned).toBe(false);
|
||
|
||
const windows = new GdbSessionManager({ platform: "win32" });
|
||
managers.push(windows);
|
||
const unavailable = await windows.start();
|
||
expect(unavailable).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||
});
|
||
});
|
||
|
||
class FakeGdbProcess extends EventEmitter implements ProviderProcessHandle {
|
||
readonly pid = process.pid + 1;
|
||
readonly stdout = new PassThrough();
|
||
readonly stderr = new PassThrough();
|
||
readonly stdin: PassThrough | null;
|
||
exitCode: number | null = null;
|
||
signalCode: NodeJS.Signals | null = null;
|
||
readonly killSignals: NodeJS.Signals[] = [];
|
||
|
||
constructor(stdinEnabled = true) {
|
||
super();
|
||
this.stdin = stdinEnabled ? new PassThrough() : null;
|
||
}
|
||
|
||
kill(signal: NodeJS.Signals): boolean {
|
||
this.killSignals.push(signal);
|
||
this.exit(null, signal);
|
||
return true;
|
||
}
|
||
|
||
exit(code: number | null, signal: NodeJS.Signals | null): void {
|
||
if (this.exitCode !== null || this.signalCode !== null) return;
|
||
this.exitCode = code;
|
||
this.signalCode = signal;
|
||
this.stdout?.end();
|
||
this.stderr.end();
|
||
this.stdin?.destroy();
|
||
this.emit("exit", code, signal);
|
||
this.emit("close", code, signal);
|
||
}
|
||
|
||
exitLeader(code: number): void {
|
||
if (this.exitCode !== null || this.signalCode !== null) return;
|
||
this.exitCode = code;
|
||
this.emit("exit", code, null);
|
||
}
|
||
|
||
closeInheritedOutput(): void {
|
||
this.stdout.end();
|
||
this.stderr.end();
|
||
this.stdin?.destroy();
|
||
this.emit("close", this.exitCode, this.signalCode);
|
||
}
|
||
}
|
||
|
||
const createFakeSpawner = (
|
||
respond: (process: FakeGdbProcess, token: string, command: string) => void = (
|
||
child,
|
||
token,
|
||
command,
|
||
) =>
|
||
child.stdout.write(
|
||
command.includes("list-thread-groups")
|
||
? `${token}^done,groups=[]\n`
|
||
: `${token}^done\n`,
|
||
),
|
||
options: {
|
||
readonly stdinEnabled?: boolean;
|
||
readonly exitAfterPrompt?: boolean;
|
||
readonly exitOnFirstStdoutData?: boolean;
|
||
readonly startupExitCode?: number | null;
|
||
readonly startupExitSignal?: NodeJS.Signals | null;
|
||
readonly stdoutNull?: boolean;
|
||
readonly deferPrompt?: boolean;
|
||
readonly afterPrompt?: () => void;
|
||
readonly afterSpawn?: () => void;
|
||
} = {},
|
||
): {
|
||
readonly spawnProcess: (
|
||
spawnOptions: OwnedProviderProcessSpawnOptions,
|
||
) => Promise<SpawnedOwnedProviderProcess>;
|
||
readonly children: FakeGdbProcess[];
|
||
} => {
|
||
const children: FakeGdbProcess[] = [];
|
||
return {
|
||
children,
|
||
spawnProcess: async (spawnOptions) => {
|
||
const child = new FakeGdbProcess(options.stdinEnabled ?? true);
|
||
children.push(child);
|
||
if (options.stdoutNull)
|
||
Object.defineProperty(child, "stdout", { value: null });
|
||
if (options.deferPrompt) {
|
||
setImmediate(() => {
|
||
child.stdout.write("(gdb)\n");
|
||
options.afterPrompt?.();
|
||
});
|
||
} else if (!options.stdoutNull) child.stdout.write("(gdb)\n");
|
||
if (options.exitOnFirstStdoutData)
|
||
child.stdout.once("data", () =>
|
||
queueMicrotask(() =>
|
||
child.exit(
|
||
options.startupExitCode === undefined
|
||
? 0
|
||
: options.startupExitCode,
|
||
options.startupExitSignal === undefined
|
||
? null
|
||
: options.startupExitSignal,
|
||
),
|
||
),
|
||
);
|
||
child.stdin?.setEncoding("utf8");
|
||
child.stdin?.on("data", (chunk: string | Buffer) => {
|
||
for (const line of String(chunk).split("\n")) {
|
||
const match = /^(\d+)(.*)$/u.exec(line);
|
||
if (match !== null) respond(child, match[1] ?? "", match[2] ?? "");
|
||
}
|
||
});
|
||
if (options.exitAfterPrompt) queueMicrotask(() => child.exit(0, null));
|
||
options.afterSpawn?.();
|
||
return {
|
||
process: child,
|
||
ownership: {
|
||
runId: spawnOptions.runId,
|
||
leaderPid: child.pid,
|
||
processGroupId: child.pid,
|
||
expectedParentPid: process.pid,
|
||
},
|
||
};
|
||
},
|
||
};
|
||
};
|
||
|
||
const recordingInferiorListResponder = (groups: string) => {
|
||
const commands: string[] = [];
|
||
return {
|
||
commands,
|
||
respond: (child: FakeGdbProcess, token: string, command: string): void => {
|
||
commands.push(command);
|
||
child.stdout.write(
|
||
command.includes("list-thread-groups")
|
||
? `${token}^done,groups=${groups}\n`
|
||
: `${token}^done\n`,
|
||
);
|
||
},
|
||
};
|
||
};
|
||
|
||
it("returns cancellation when GDB startup is aborted after spawn", async () => {
|
||
const controller = new AbortController();
|
||
const fake = createFakeSpawner(undefined, {
|
||
afterSpawn: () => controller.abort(),
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
|
||
const started = await manager.start(controller.signal);
|
||
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok) expect(started.error._tag).toBe("AnalysisCancelledError");
|
||
expect(fake.children[0]?.signalCode).toBe("SIGTERM");
|
||
});
|
||
|
||
it("cancels immediately after the startup prompt before sending configuration commands", async () => {
|
||
const controller = new AbortController();
|
||
const commands: string[] = [];
|
||
const fake = createFakeSpawner(
|
||
(child, token, command) => {
|
||
commands.push(command);
|
||
child.stdout.write(`${token}^done\n`);
|
||
},
|
||
{
|
||
deferPrompt: true,
|
||
afterPrompt: () => controller.abort(),
|
||
},
|
||
);
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
|
||
const started = await manager.start(controller.signal);
|
||
|
||
expect(started).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCancelledError" },
|
||
});
|
||
expect(commands).toEqual([]);
|
||
expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]);
|
||
});
|
||
|
||
it("rejects a GDB exit queued with the final startup response", async () => {
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
child.stdout.write(`${token}^done\n`);
|
||
if (command.includes("auto-load off"))
|
||
setImmediate(() => child.exit(7, null));
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await manager.start();
|
||
expect(started).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||
});
|
||
expect(fake.children[0]?.exitCode).toBe(7);
|
||
});
|
||
|
||
it("cancels startup when aborted during the final readiness drain", async () => {
|
||
const controller = new AbortController();
|
||
const commands: string[] = [];
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
commands.push(command);
|
||
child.stdout.write(`${token}^done\n`);
|
||
if (command.includes("auto-load off"))
|
||
setImmediate(() => controller.abort());
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
|
||
const started = await manager.start(controller.signal);
|
||
|
||
expect(started).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCancelledError" },
|
||
});
|
||
expect(commands).toEqual(["-gdb-set auto-load off"]);
|
||
expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]);
|
||
});
|
||
|
||
it("preserves cancellation and cleanup details when startup stop is uncertain", async () => {
|
||
const controller = new AbortController();
|
||
let stopCalls = 0;
|
||
const fake = createFakeSpawner(undefined, {
|
||
deferPrompt: true,
|
||
afterPrompt: () => controller.abort(),
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async (session, name) => {
|
||
stopCalls += 1;
|
||
return stopCalls === 1
|
||
? {
|
||
status: "incomplete",
|
||
reason: "simulated startup cancellation cleanup uncertainty",
|
||
}
|
||
: stopOwnedDebuggerProcess(session, name);
|
||
},
|
||
});
|
||
|
||
const started = await manager.start(controller.signal);
|
||
|
||
expect(started).toMatchObject({
|
||
ok: false,
|
||
error: {
|
||
_tag: "AnalysisCancelledError",
|
||
operation: "start_gdb_session",
|
||
cleanup: { reason: "simulated startup cancellation cleanup uncertainty" },
|
||
},
|
||
});
|
||
});
|
||
|
||
it("classifies cancellation while asynchronous GDB ownership preparation is pending", async () => {
|
||
const controller = new AbortController();
|
||
let entered!: () => void;
|
||
const spawnEntered = new Promise<void>((resolve) => {
|
||
entered = resolve;
|
||
});
|
||
const manager = new GdbSessionManager({
|
||
platform: "linux",
|
||
environment: {},
|
||
spawnProcess: async (options) =>
|
||
new Promise<never>((_resolve, reject) => {
|
||
entered();
|
||
options.signal?.addEventListener(
|
||
"abort",
|
||
() => reject(new Error("spawn preparation aborted")),
|
||
{ once: true },
|
||
);
|
||
}),
|
||
});
|
||
const starting = manager.start(controller.signal);
|
||
await spawnEntered;
|
||
controller.abort();
|
||
const started = await starting;
|
||
expect(started).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCancelledError" },
|
||
});
|
||
});
|
||
|
||
it("drains active GDB commands and rejects new commands before closeAll cleanup", async () => {
|
||
let heldToken = "";
|
||
let heldCommand = "";
|
||
const fake = createFakeSpawner((_child, token, command) => {
|
||
if (command.includes("held command")) {
|
||
heldToken = token;
|
||
heldCommand = command;
|
||
} else
|
||
_child.stdout.write(
|
||
command.includes("list-thread-groups")
|
||
? `${token}^done,groups=[]\n`
|
||
: `${token}^done\n`,
|
||
);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const active = manager.execute(started.value.session_id, "held command");
|
||
await expect.poll(() => heldToken).not.toBe("");
|
||
const closing = manager.closeAll();
|
||
const rejected = await manager.execute(
|
||
started.value.session_id,
|
||
"late command",
|
||
);
|
||
expect(rejected.ok).toBe(false);
|
||
const duplicateClose = await manager.close(started.value.session_id);
|
||
expect(duplicateClose).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCancelledError", operation: "close_gdb_session" },
|
||
});
|
||
expect(fake.children[0]?.killSignals).toEqual([]);
|
||
fake.children[0]?.stdout.write(`${heldToken}^done\n`);
|
||
expect((await active).ok).toBe(true);
|
||
await closing;
|
||
expect(heldCommand).toContain("held command");
|
||
expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]);
|
||
});
|
||
|
||
it("enforces the per-session queue capacity while a GDB command is active", async () => {
|
||
let heldToken = "";
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
if (command.includes("held-for-capacity")) heldToken = token;
|
||
else if (command.includes("list-thread-groups"))
|
||
child.stdout.write(`${token}^done,groups=[]\n`);
|
||
else child.stdout.write(`${token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const held = manager.execute(started.value.session_id, "held-for-capacity");
|
||
await expect.poll(() => heldToken).not.toBe("");
|
||
const queued = Array.from({ length: 15 }, (_, index) =>
|
||
manager.execute(started.value.session_id, `queued-${index}`),
|
||
);
|
||
const overCapacity = await manager.execute(
|
||
started.value.session_id,
|
||
"over-capacity",
|
||
);
|
||
expect(overCapacity).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisResourceConstraintError" },
|
||
});
|
||
|
||
fake.children[0]?.stdout.write(`${heldToken}^done\n`);
|
||
expect((await held).ok).toBe(true);
|
||
expect((await Promise.all(queued)).every((result) => result.ok)).toBe(true);
|
||
expect((await manager.close(started.value.session_id)).ok).toBe(true);
|
||
expect(manager.status(started.value.session_id)).toBeUndefined();
|
||
});
|
||
|
||
it("reports invalid UTF-8 in retained GDB stderr status", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
fake.children[0]?.stderr.write(Buffer.from([0xff]));
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
expect(manager.status(started.value.session_id)).toMatchObject({
|
||
stderr: "<22>",
|
||
stderr_invalid_utf8: true,
|
||
recent_mi_records_truncated: false,
|
||
});
|
||
});
|
||
|
||
it("reports invalid UTF-8 in retained GDB MI status", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
fake.children[0]?.stdout.write(Buffer.from([0xff, 0x0a]));
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
|
||
expect(manager.status(started.value.session_id)).toMatchObject({
|
||
recent_mi_records: ["(gdb)", "1^done", "<22>"],
|
||
mi_invalid_utf8: true,
|
||
});
|
||
});
|
||
|
||
it.each([
|
||
["error", "GDB stdin stream failed: simulated stdin failure"],
|
||
["close", "GDB stdin stream closed unexpectedly."],
|
||
] as const)(
|
||
"fails pending GDB commands when stdin emits %s",
|
||
async (event, message) => {
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
if (!command.includes("held-command"))
|
||
child.stdout.write(`${token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const stdin = fake.children[0]?.stdin;
|
||
if (stdin === null || stdin === undefined) return;
|
||
const commandSent = new Promise<void>((resolve) =>
|
||
stdin.once("data", () => resolve()),
|
||
);
|
||
const pending = manager.execute(started.value.session_id, "held-command");
|
||
await commandSent;
|
||
|
||
if (event === "error")
|
||
stdin.emit(event, new Error("simulated stdin failure"));
|
||
else stdin.emit(event);
|
||
|
||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||
const settled = await Promise.race([
|
||
pending.then((result) => ({ result })),
|
||
new Promise<{ readonly timeout: true }>((resolve) => {
|
||
timeout = setTimeout(() => resolve({ timeout: true }), 150);
|
||
}),
|
||
]);
|
||
clearTimeout(timeout);
|
||
if ("timeout" in settled) {
|
||
const child = fake.children[0];
|
||
child?.exitLeader(9);
|
||
child?.closeInheritedOutput();
|
||
await pending;
|
||
throw new Error(`GDB stdin ${event} did not settle the pending command`);
|
||
}
|
||
const failed = settled.result;
|
||
expect(failed.ok).toBe(false);
|
||
if (!failed.ok) expect(failed.error.message).toContain(message);
|
||
expect(manager.status(started.value.session_id)?.state).toBe("closed");
|
||
},
|
||
);
|
||
|
||
it("reports process exit rather than protocol failure when stdin closes afterward", async () => {
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
if (!command.includes("held-command"))
|
||
child.stdout.write(`${token}^done\n`);
|
||
});
|
||
let stopCalls = 0;
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async () => {
|
||
stopCalls += 1;
|
||
return { status: "stopped" };
|
||
},
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const child = fake.children[0];
|
||
const stdin = child?.stdin;
|
||
if (child === undefined || stdin === null || stdin === undefined) return;
|
||
const commandSent = new Promise<void>((resolve) =>
|
||
stdin.once("data", () => resolve()),
|
||
);
|
||
const pending = manager.execute(started.value.session_id, "held-command");
|
||
await commandSent;
|
||
|
||
child.exitLeader(7);
|
||
stdin.emit("close");
|
||
child.stdout.emit("end");
|
||
|
||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||
const settled = await Promise.race([
|
||
pending.then((result) => ({ result })),
|
||
new Promise<{ readonly timeout: true }>((resolve) => {
|
||
timeout = setTimeout(() => resolve({ timeout: true }), 150);
|
||
}),
|
||
]);
|
||
clearTimeout(timeout);
|
||
if ("timeout" in settled) {
|
||
child.closeInheritedOutput();
|
||
await pending;
|
||
throw new Error("GDB process exit did not settle the command promptly");
|
||
}
|
||
const failed = settled.result;
|
||
|
||
expect(failed).toMatchObject({
|
||
ok: true,
|
||
value: {
|
||
result: {
|
||
process_exit_observed: true,
|
||
process_exit_code: 7,
|
||
completion_status: "unknown",
|
||
},
|
||
evidence: {
|
||
limitations: expect.arrayContaining([
|
||
"GDB exited before returning a correlated MI result record; only records retained for the active command are returned raw.",
|
||
]),
|
||
},
|
||
},
|
||
});
|
||
// Process-exit Evidence is returned only after the owned debugger stop is
|
||
// confirmed; stdin closing afterward must not bypass cleanup settlement.
|
||
expect(stopCalls).toBe(1);
|
||
});
|
||
|
||
it("retains partial GDB command Evidence while an inherited writer remains", async () => {
|
||
let writerActive = false;
|
||
const fake = createFakeSpawner((_child, _token, command) => {
|
||
if (command.includes("held-writer-command")) return;
|
||
_child.stdout.write(`${_token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async () => ({
|
||
status: "incomplete",
|
||
reason: writerActive
|
||
? "an inherited output writer remains active"
|
||
: "the owned GDB process was not confirmed stopped",
|
||
}),
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const child = fake.children[0];
|
||
if (child === undefined || child.stdin === null) return;
|
||
const commandSent = new Promise<void>((resolve) =>
|
||
child.stdin!.once("data", () => resolve()),
|
||
);
|
||
const pending = manager.execute(
|
||
started.value.session_id,
|
||
"held-writer-command",
|
||
);
|
||
await commandSent;
|
||
|
||
writerActive = true;
|
||
child.stdout.write("~partial output from owned writer\n");
|
||
child.exitLeader(7);
|
||
const result = await pending;
|
||
|
||
expect(result).toMatchObject({
|
||
ok: false,
|
||
error: {
|
||
_tag: "AnalysisCapabilityUnavailableError",
|
||
cleanupIncomplete: true,
|
||
cleanup: {
|
||
reason: "an inherited output writer remains active",
|
||
resources: [started.value.session_id],
|
||
},
|
||
partialObservation: {
|
||
normalized_result: {
|
||
process_exit_observed: true,
|
||
completion_status: "unknown",
|
||
},
|
||
raw_result: { process_exit_observed: true },
|
||
},
|
||
},
|
||
});
|
||
expect(manager.status(started.value.session_id)?.state).toBe("closed");
|
||
child.stdout.write("~late writer output\n");
|
||
writerActive = false;
|
||
child.closeInheritedOutput();
|
||
});
|
||
|
||
it("converts non-Error queue cancellation failures into a tagged error", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const signal = {
|
||
aborted: false,
|
||
addEventListener: () => {
|
||
throw "opaque listener failure";
|
||
},
|
||
removeEventListener: () => undefined,
|
||
} as unknown as AbortSignal;
|
||
|
||
const result = await manager.execute(
|
||
started.value.session_id,
|
||
"show version",
|
||
signal,
|
||
);
|
||
|
||
expect(result).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCancelledError" },
|
||
});
|
||
expect((await manager.close(started.value.session_id)).ok).toBe(true);
|
||
});
|
||
|
||
it("waits for GDB stdout settlement after a no-inferior exit without re-entering MI", async () => {
|
||
const commands: string[] = [];
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
commands.push(command);
|
||
if (command.includes("list-thread-groups"))
|
||
child.stdout.write(`${token}^done,groups=[]\n`);
|
||
else if (command.includes("gdb-exit")) {
|
||
child.stdout.write(`${token}^exit\n`);
|
||
queueMicrotask(() => child.exit(0, null));
|
||
} else child.stdout.write(`${token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const closed = await manager.close(started.value.session_id);
|
||
expect(closed).toMatchObject({
|
||
ok: true,
|
||
value: { target_state: "none_observed" },
|
||
});
|
||
expect(
|
||
commands.filter((command) => command.includes("list-thread-groups")),
|
||
).toHaveLength(1);
|
||
expect(commands).toContain("-gdb-exit");
|
||
});
|
||
|
||
it("reports incomplete stop if GDB exits while close waits in the serialized queue", async () => {
|
||
let heldToken = "";
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
if (command.includes("held-before-close")) heldToken = token;
|
||
else if (command.includes("list-thread-groups"))
|
||
child.stdout.write(`${token}^done,groups=[]\n`);
|
||
else child.stdout.write(`${token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async () => ({
|
||
status: "incomplete",
|
||
reason: "GDB exited before queued close could verify cleanup",
|
||
}),
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const active = manager.execute(started.value.session_id, "held-before-close");
|
||
await expect.poll(() => heldToken).not.toBe("");
|
||
const closing = manager.close(started.value.session_id);
|
||
fake.children[0]?.exitLeader(9);
|
||
const activeResult = await active;
|
||
expect(activeResult).toMatchObject({
|
||
ok: false,
|
||
error: {
|
||
_tag: "AnalysisCapabilityUnavailableError",
|
||
cleanupIncomplete: true,
|
||
partialObservation: expect.objectContaining({
|
||
normalized_result: expect.objectContaining({
|
||
process_exit_observed: true,
|
||
}),
|
||
}),
|
||
},
|
||
});
|
||
|
||
const closed = await closing;
|
||
|
||
expect(closed).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||
});
|
||
if (!closed.ok)
|
||
expect(closed.error).toMatchObject({
|
||
providerId: "gdb",
|
||
operation: "close_gdb_session",
|
||
});
|
||
});
|
||
|
||
it("configures and verifies the caller-selected inferior TTY before reporting ready", async () => {
|
||
const tty = '/dev/pts/42 "🚀"';
|
||
const miTty = tty.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
|
||
const commands: string[] = [];
|
||
const fake = createFakeSpawner((_child, token, command) => {
|
||
commands.push(command);
|
||
if (command.includes("inferior-tty-show"))
|
||
_child.stdout.write(`${token}^done,inferior_tty_terminal="${miTty}"\n`);
|
||
else if (command.includes("list-thread-groups"))
|
||
_child.stdout.write(`${token}^done,groups=[]\n`);
|
||
else _child.stdout.write(`${token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
|
||
const started = await manager.start(undefined, { inferiorTty: tty });
|
||
|
||
expect(started.ok).toBe(true);
|
||
if (!started.ok) return;
|
||
const status = manager.status(started.value.session_id);
|
||
expect(status).toMatchObject({
|
||
inferior_tty: tty,
|
||
inferior_tty_verified: true,
|
||
});
|
||
expect(
|
||
commands.indexOf(`-inferior-tty-set ${JSON.stringify(tty)}`),
|
||
).toBeGreaterThan(-1);
|
||
expect(commands.indexOf("-inferior-tty-show")).toBeGreaterThan(
|
||
commands.indexOf(`-inferior-tty-set ${JSON.stringify(tty)}`),
|
||
);
|
||
});
|
||
|
||
it("rejects a GDB leader exit without a verified sink and retains inherited output", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const child = fake.children[0];
|
||
if (child === undefined) return;
|
||
child.exitLeader(7);
|
||
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed.ok).toBe(false);
|
||
expect(manager.status(started.value.session_id)).toMatchObject({
|
||
state: "closed",
|
||
inferior_tty: null,
|
||
inferior_tty_verified: false,
|
||
});
|
||
expect(child.killSignals).toEqual([]);
|
||
child.stdout.write("=thread-group-exited,id=descendant-sentinel\n");
|
||
child.closeInheritedOutput();
|
||
expect(manager.status(started.value.session_id)?.recent_mi_records).toContain(
|
||
"=thread-group-exited,id=descendant-sentinel",
|
||
);
|
||
});
|
||
|
||
it("does not infer detached state when the inferior verifier observes an exited GDB", async () => {
|
||
let detachFailure = "";
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async (session) => {
|
||
const debuggerSession = session as typeof session & {
|
||
detachInferior: () => Promise<unknown>;
|
||
};
|
||
try {
|
||
await debuggerSession.detachInferior();
|
||
} catch (cause) {
|
||
detachFailure = cause instanceof Error ? cause.message : String(cause);
|
||
}
|
||
return {
|
||
status: "incomplete",
|
||
reason: "the inferior state remained unknown",
|
||
};
|
||
},
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
expect(
|
||
(await manager.execute(started.value.session_id, "set pagination off")).ok,
|
||
).toBe(true);
|
||
fake.children[0]?.exitLeader(7);
|
||
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed.ok).toBe(false);
|
||
expect(detachFailure).toBe(
|
||
"GDB exited before inferior state could be verified.",
|
||
);
|
||
});
|
||
|
||
it("retains the GDB session while an unknown descendant holds output after leader exit", async () => {
|
||
directory ??= await mkdtemp(join(tmpdir(), "rea-gdb-coverage-"));
|
||
const tty = join(directory, "inferior-output");
|
||
await writeFile(tty, "");
|
||
const fake = createFakeSpawner((_child, token, command) => {
|
||
if (command.includes("inferior-tty-show"))
|
||
fake.children[0]?.stdout.write(
|
||
`${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n`,
|
||
);
|
||
else if (command.includes("list-thread-groups"))
|
||
fake.children[0]?.stdout.write(`${token}^done,groups=[]\n`);
|
||
else fake.children[0]?.stdout.write(`${token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager, undefined, {
|
||
inferiorTty: tty,
|
||
});
|
||
const child = fake.children[0];
|
||
if (child === undefined) return;
|
||
expect(manager.status(started.value.session_id)?.inferior_tty_verified).toBe(
|
||
true,
|
||
);
|
||
child.exitLeader(7);
|
||
|
||
const firstClose = await manager.close(started.value.session_id);
|
||
expect(firstClose.ok).toBe(false);
|
||
if (!firstClose.ok) {
|
||
expect(firstClose.error.cleanupIncomplete).toBe(true);
|
||
expect(firstClose.error.message).toMatch(/inferior disposition/i);
|
||
}
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
await writeFile(tty, "descendant sentinel after GDB leader exit\n");
|
||
child.closeInheritedOutput();
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed).toMatchObject({
|
||
ok: false,
|
||
error: { cleanupIncomplete: true },
|
||
});
|
||
expect(await readFile(tty, "utf8")).toBe(
|
||
"descendant sentinel after GDB leader exit\n",
|
||
);
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
expect(child.killSignals).toEqual([]);
|
||
});
|
||
|
||
it("retries close after an exited GDB's inherited output streams settle", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const child = fake.children[0];
|
||
if (child === undefined) return;
|
||
|
||
child.exitLeader(3);
|
||
const firstClose = await manager.close(started.value.session_id);
|
||
expect(firstClose.ok).toBe(false);
|
||
if (!firstClose.ok)
|
||
expect(firstClose.error.message).toMatch(/inferior disposition/i);
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
|
||
child.closeInheritedOutput();
|
||
await expect(manager.close(started.value.session_id)).resolves.toMatchObject({
|
||
ok: false,
|
||
error: { cleanupIncomplete: true },
|
||
});
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
});
|
||
|
||
it("shares a single stop attempt across concurrent closes after GDB exits", async () => {
|
||
let resolveStop!: (result: { status: "incomplete"; reason: string }) => void;
|
||
let stopCalls = 0;
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async () => {
|
||
stopCalls += 1;
|
||
if (stopCalls > 1)
|
||
return {
|
||
status: "incomplete",
|
||
reason: "shared stop attempt is uncertain",
|
||
};
|
||
return await new Promise((resolve) => {
|
||
resolveStop = resolve;
|
||
});
|
||
},
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
fake.children[0]?.exitLeader(7);
|
||
|
||
const firstClose = manager.close(started.value.session_id);
|
||
await expect.poll(() => stopCalls).toBe(1);
|
||
const secondClose = manager.close(started.value.session_id);
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
expect(stopCalls).toBe(1);
|
||
resolveStop({
|
||
status: "incomplete",
|
||
reason: "shared stop attempt is uncertain",
|
||
});
|
||
|
||
const [first, second] = await Promise.all([firstClose, secondClose]);
|
||
expect(first.ok).toBe(false);
|
||
expect(second.ok).toBe(false);
|
||
expect(stopCalls).toBe(1);
|
||
});
|
||
|
||
it("rejects inferior TTY configuration when GDB reports a different terminal", async () => {
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
child.stdout.write(
|
||
command.includes("inferior-tty-show")
|
||
? `${token}^done,inferior_tty_terminal="/dev/pts/other"\n`
|
||
: command.includes("list-thread-groups")
|
||
? `${token}^done,groups=[]\n`
|
||
: `${token}^done\n`,
|
||
);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
|
||
const started = await manager.start(undefined, {
|
||
inferiorTty: "/dev/pts/requested",
|
||
});
|
||
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok)
|
||
expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
expect(
|
||
fake.children.every((child) => child.killSignals.includes("SIGTERM")),
|
||
).toBe(true);
|
||
});
|
||
|
||
it("rejects an inferior TTY when GDB does not confirm the setting command", async () => {
|
||
const commands: string[] = [];
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
commands.push(command);
|
||
child.stdout.write(
|
||
command.includes("inferior-tty-set")
|
||
? `${token}^error,msg="unsupported"\n`
|
||
: `${token}^done\n`,
|
||
);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
|
||
const started = await manager.start(undefined, {
|
||
inferiorTty: "/dev/pts/requested",
|
||
});
|
||
|
||
expect(started).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||
});
|
||
expect(commands).toContain('-inferior-tty-set "/dev/pts/requested"');
|
||
expect(commands).not.toContain("-inferior-tty-show");
|
||
expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]);
|
||
});
|
||
|
||
it("rejects a truncated inferior TTY readback before publishing the session", async () => {
|
||
const tty = `/dev/pts/${"x".repeat(200)}`;
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
child.stdout.write(
|
||
command.includes("inferior-tty-show")
|
||
? `${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n`
|
||
: `${token}^done\n`,
|
||
);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
maxCommandOutputBytes: 80,
|
||
});
|
||
|
||
const started = await manager.start(undefined, { inferiorTty: tty });
|
||
|
||
expect(started).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||
});
|
||
expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]);
|
||
});
|
||
|
||
it.each([
|
||
["trailing garbage", 'inferior_tty_terminal="/dev/pts/42"junk'],
|
||
[
|
||
"duplicate field",
|
||
'inferior_tty_terminal="/dev/pts/42",inferior_tty_terminal="/dev/pts/42"',
|
||
],
|
||
])("rejects MI inferior TTY readback with %s", async (_label, fields) => {
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
child.stdout.write(
|
||
command.includes("inferior-tty-show")
|
||
? `${token}^done,${fields}\n`
|
||
: `${token}^done\n`,
|
||
);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await manager.start(undefined, {
|
||
inferiorTty: "/dev/pts/42",
|
||
});
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok)
|
||
expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
});
|
||
|
||
const managerWithFakeSpawner = (
|
||
spawnProcess: (
|
||
options: OwnedProviderProcessSpawnOptions,
|
||
) => Promise<SpawnedOwnedProviderProcess>,
|
||
options: ConstructorParameters<typeof GdbSessionManager>[0] = {},
|
||
): GdbSessionManager => {
|
||
const manager = new GdbSessionManager({
|
||
platform: "linux",
|
||
environment: { REA_GDB_COMMAND: "fake-gdb" },
|
||
spawnProcess,
|
||
verifyInferiorOutputSink: async () => undefined,
|
||
...options,
|
||
});
|
||
managers.push(manager);
|
||
return manager;
|
||
};
|
||
|
||
const startWithVerifiedInferiorTty = async (
|
||
tty: string,
|
||
): Promise<{
|
||
readonly manager: GdbSessionManager;
|
||
readonly sessionId: string;
|
||
readonly commands: string[];
|
||
}> => {
|
||
const commands: string[] = [];
|
||
const fake = createFakeSpawner((_child, token, command) => {
|
||
commands.push(command);
|
||
const response = command.includes("inferior-tty-show")
|
||
? `^done,inferior_tty_terminal=${JSON.stringify(tty)}`
|
||
: command.includes("list-thread-groups")
|
||
? '^done,groups=[{id="i1",pid="123"}]'
|
||
: "^done";
|
||
fake.children[0]?.stdout.write(`${token}${response}\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await manager.start(undefined, { inferiorTty: tty });
|
||
if (!started.ok) throw new Error("GDB did not start with its selected TTY");
|
||
return { manager, sessionId: started.value.session_id, commands };
|
||
};
|
||
|
||
const createProvider = async (body: string): Promise<string> => {
|
||
directory ??= await mkdtemp(join(tmpdir(), "rea-gdb-coverage-"));
|
||
const provider = join(directory, `fake-gdb-${managers.length}.mjs`);
|
||
await writeFile(provider, `#!${process.execPath}\n${body}`);
|
||
await chmod(provider, 0o700);
|
||
return provider;
|
||
};
|
||
|
||
const managerFor = async (
|
||
body: string,
|
||
options: ConstructorParameters<typeof GdbSessionManager>[0] = {},
|
||
): Promise<GdbSessionManager> => {
|
||
const manager = new GdbSessionManager({
|
||
platform: "linux",
|
||
environment: { REA_GDB_COMMAND: await createProvider(body) },
|
||
...options,
|
||
});
|
||
managers.push(manager);
|
||
return manager;
|
||
};
|
||
|
||
const responsiveProvider = (
|
||
handler = "if (match[2].includes('list-thread-groups')) process.stdout.write(match[1] + '^done,groups=[]\\n'); else process.stdout.write(match[1] + '^done\\n');",
|
||
): string => `
|
||
process.stdout.write('(gdb)\\n');
|
||
let input = '';
|
||
process.stdin.setEncoding('utf8');
|
||
process.stdin.on('data', (chunk) => {
|
||
input += chunk;
|
||
for (;;) {
|
||
const end = input.indexOf('\\n');
|
||
if (end < 0) break;
|
||
const line = input.slice(0, end); input = input.slice(end + 1);
|
||
const match = /^(\\d+)(.*)/.exec(line);
|
||
if (!match) continue;
|
||
${handler}
|
||
}
|
||
});
|
||
`;
|
||
|
||
afterEach(async () => {
|
||
await Promise.all(
|
||
managers
|
||
.splice(0)
|
||
.map((manager) => manager.closeAll().catch(() => undefined)),
|
||
);
|
||
if (directory !== undefined)
|
||
await rm(directory, { recursive: true, force: true });
|
||
directory = undefined;
|
||
});
|
||
|
||
it("rejects a pre-cancelled GDB start and an unconfigured executable", async () => {
|
||
const cancelled = new AbortController();
|
||
cancelled.abort();
|
||
const startResult = await new GdbSessionManager({ platform: "linux" }).start(
|
||
cancelled.signal,
|
||
);
|
||
expect(startResult.ok).toBe(false);
|
||
if (!startResult.ok)
|
||
expect(startResult.error._tag).toBe("AnalysisCancelledError");
|
||
|
||
const missing = await new GdbSessionManager({
|
||
platform: "linux",
|
||
environment: { REA_GDB_COMMAND: join(tmpdir(), "does-not-exist-gdb") },
|
||
}).start();
|
||
expect(missing.ok).toBe(false);
|
||
if (!missing.ok) {
|
||
expect(missing.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
expect(missing.error.message).toContain("does-not-exist-gdb");
|
||
}
|
||
});
|
||
|
||
it("retries MI2 after an MI3 process exits before its startup prompt", async () => {
|
||
const provider = await createProvider(`
|
||
if (process.argv.includes('--interpreter=mi3')) process.exit(0);
|
||
${responsiveProvider()}
|
||
`);
|
||
const manager = new GdbSessionManager({
|
||
platform: "linux",
|
||
environment: { REA_GDB_COMMAND: provider },
|
||
});
|
||
managers.push(manager);
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started.ok).toBe(true);
|
||
if (started.ok) expect(started.value.mi_version).toBe("mi2");
|
||
});
|
||
|
||
it("retains ownership when GDB startup fails before its prompt and stop is incomplete", async () => {
|
||
let stopCalls = 0;
|
||
let retainedSessionId: string | undefined;
|
||
const provider = await createProvider("process.exit(0);\n");
|
||
const manager = new GdbSessionManager({
|
||
platform: "linux",
|
||
environment: { REA_GDB_COMMAND: provider },
|
||
stopProcess: async (session, name) => {
|
||
stopCalls += 1;
|
||
if (stopCalls === 1) {
|
||
retainedSessionId = (session as typeof session & { id: string }).id;
|
||
return { status: "incomplete", reason: "simulated stop uncertainty" };
|
||
}
|
||
return stopOwnedDebuggerProcess(session, name);
|
||
},
|
||
});
|
||
managers.push(manager);
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok) {
|
||
expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
expect(started.error.message).toContain("simulated stop uncertainty");
|
||
}
|
||
expect(stopCalls).toBe(1);
|
||
expect(manager.status(retainedSessionId ?? "")?.state).toBe("closed");
|
||
});
|
||
|
||
it("uses a fallback message when the process launcher rejects without an Error", async () => {
|
||
let attempts = 0;
|
||
const manager = managerWithFakeSpawner(async () => {
|
||
attempts += 1;
|
||
return Promise.reject("non-error launch failure");
|
||
});
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok) {
|
||
expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
expect(started.error.message).toContain("Unable to start GDB");
|
||
}
|
||
expect(attempts).toBe(2);
|
||
});
|
||
|
||
it("returns startup timeout when the MI prompt never arrives", async () => {
|
||
const provider = await createProvider(
|
||
"process.stdin.resume(); setInterval(() => {}, 1000);\n",
|
||
);
|
||
const manager = new GdbSessionManager({
|
||
platform: "linux",
|
||
environment: { REA_GDB_COMMAND: provider },
|
||
startupTimeoutMs: 20,
|
||
});
|
||
managers.push(manager);
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok) {
|
||
expect(started.error._tag).toBe("AnalysisTimeoutError");
|
||
expect(started.error).toBeInstanceOf(AnalysisTimeoutError);
|
||
if (started.error instanceof AnalysisTimeoutError)
|
||
expect(started.error.operation).toBe("start_gdb_session");
|
||
}
|
||
});
|
||
|
||
it("handles a provider with no MI stdout stream as a startup timeout", async () => {
|
||
const fake = createFakeSpawner(undefined, { stdoutNull: true });
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
startupTimeoutMs: 15,
|
||
});
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisTimeoutError", operation: "start_gdb_session" },
|
||
});
|
||
expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]);
|
||
});
|
||
|
||
it("preserves a timeout while startup waits for the automatic-loading response", async () => {
|
||
const fake = createFakeSpawner((_child, _token, _command) => undefined);
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
commandTimeoutMs: 25,
|
||
});
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok) {
|
||
expect(started.error._tag).toBe("AnalysisTimeoutError");
|
||
expect(started.error).toBeInstanceOf(AnalysisTimeoutError);
|
||
if (started.error instanceof AnalysisTimeoutError)
|
||
expect(started.error.operation).toBe("start_gdb_session");
|
||
}
|
||
expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]);
|
||
});
|
||
|
||
it("maps an acknowledged startup-command timeout to the startup operation", async () => {
|
||
const fake = createFakeSpawner((_child, _token, _command) => undefined);
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
commandTimeoutMs: 20,
|
||
stopProcess: async () => ({ status: "stopped" }),
|
||
});
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisTimeoutError", operation: "start_gdb_session" },
|
||
});
|
||
});
|
||
|
||
it("retains startup timeout without partial evidence when cleanup is uncertain", async () => {
|
||
let stopCalls = 0;
|
||
const provider = await createProvider(
|
||
"process.stdin.resume(); setInterval(() => {}, 1000);\n",
|
||
);
|
||
const manager = new GdbSessionManager({
|
||
platform: "linux",
|
||
environment: { REA_GDB_COMMAND: provider },
|
||
startupTimeoutMs: 20,
|
||
stopProcess: async (session, name) => {
|
||
stopCalls += 1;
|
||
return stopCalls === 1
|
||
? {
|
||
status: "incomplete",
|
||
reason: "simulated startup timeout cleanup uncertainty",
|
||
}
|
||
: stopOwnedDebuggerProcess(session, name);
|
||
},
|
||
});
|
||
managers.push(manager);
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started).toMatchObject({
|
||
ok: false,
|
||
error: {
|
||
_tag: "AnalysisTimeoutError",
|
||
operation: "start_gdb_session",
|
||
cleanup: { reason: "simulated startup timeout cleanup uncertainty" },
|
||
},
|
||
});
|
||
if (!started.ok)
|
||
expect(
|
||
(started.error as AnalysisTimeoutError & { partialObservation?: unknown })
|
||
.partialObservation,
|
||
).toBeUndefined();
|
||
});
|
||
|
||
it("maps startup command timeout and preserves initial no-inferior evidence when cleanup is uncertain", async () => {
|
||
let detachResult: unknown;
|
||
let retainedSessionId = "";
|
||
const fake = createFakeSpawner((_child, _token, _command) => undefined);
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
commandTimeoutMs: 25,
|
||
stopProcess: async (session) => {
|
||
retainedSessionId = (session as typeof session & { id: string }).id;
|
||
const debuggerSession = session as typeof session & {
|
||
detachInferior: () => Promise<unknown>;
|
||
};
|
||
detachResult = await debuggerSession.detachInferior();
|
||
return {
|
||
status: "incomplete",
|
||
reason: "simulated startup cleanup uncertainty",
|
||
};
|
||
},
|
||
});
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok) {
|
||
expect(started.error).toBeInstanceOf(AnalysisTimeoutError);
|
||
if (started.error instanceof AnalysisTimeoutError)
|
||
expect(started.error.operation).toBe("start_gdb_session");
|
||
}
|
||
expect(detachResult).toEqual({ status: "no-inferior" });
|
||
expect(retainedSessionId).not.toBe("");
|
||
expect(manager.status(retainedSessionId)?.state).toBe("closed");
|
||
});
|
||
|
||
it("handles a start signal that becomes aborted during listener registration", async () => {
|
||
const provider = await createProvider(responsiveProvider());
|
||
const controller = new AbortController();
|
||
let registrations = 0;
|
||
const signal = new Proxy(controller.signal, {
|
||
get(target, property) {
|
||
if (property === "addEventListener")
|
||
return (...arguments_: Parameters<AbortSignal["addEventListener"]>) => {
|
||
registrations += 1;
|
||
if (registrations === 1) {
|
||
controller.abort();
|
||
}
|
||
return target.addEventListener(...arguments_);
|
||
};
|
||
const value = Reflect.get(target, property, target) as unknown;
|
||
return typeof value === "function" ? value.bind(target) : value;
|
||
},
|
||
});
|
||
const manager = new GdbSessionManager({
|
||
platform: "linux",
|
||
environment: { REA_GDB_COMMAND: provider },
|
||
});
|
||
managers.push(manager);
|
||
|
||
const started = await manager.start(signal);
|
||
|
||
expect(registrations).toBe(1);
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok) expect(started.error._tag).toBe("AnalysisCancelledError");
|
||
});
|
||
|
||
it("handles cancellation after the automatic-loading response", async () => {
|
||
const controller = new AbortController();
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
child.stdout.write(
|
||
command.includes("list-thread-groups")
|
||
? `${token}^done,groups=[]\n`
|
||
: `${token}^done\n`,
|
||
);
|
||
if (command.includes("auto-load off")) controller.abort();
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await manager.start(controller.signal);
|
||
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok) expect(started.error._tag).toBe("AnalysisCancelledError");
|
||
});
|
||
|
||
it("reports an MI stream failure when the child has no stdin pipe", async () => {
|
||
const fake = createFakeSpawner(undefined, { stdinEnabled: false });
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok) {
|
||
expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
expect(started.error.message).toContain("input stream is closed");
|
||
}
|
||
expect(fake.children[0]?.exitCode).toBeNull();
|
||
expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]);
|
||
expect(fake.children[0]?.signalCode).toBe("SIGTERM");
|
||
});
|
||
|
||
it("preserves the protocol failure when owned-process stop is confirmed", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async () => ({ status: "stopped" }),
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const stdin = fake.children[0]?.stdin;
|
||
expect(stdin).not.toBeNull();
|
||
if (stdin === null || stdin === undefined) return;
|
||
stdin.write = (() => {
|
||
throw new Error("injected MI stdin write failure");
|
||
}) as typeof stdin.write;
|
||
|
||
const result = await manager.execute(started.value.session_id, "fail-write");
|
||
|
||
expect(result).toMatchObject({
|
||
ok: false,
|
||
error: {
|
||
_tag: "AnalysisCapabilityUnavailableError",
|
||
message: expect.stringContaining("injected MI stdin write failure"),
|
||
},
|
||
});
|
||
});
|
||
|
||
it("converts a closed stdin guard failure without partial MI records", async () => {
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
child.stdout.write(`${token}^done\n`);
|
||
if (command.includes("auto-load off"))
|
||
Object.defineProperty(child.stdin, "destroyed", { value: true });
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async () => ({ status: "stopped" }),
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
expect(fake.children[0]?.stdin?.destroyed).toBe(true);
|
||
|
||
const result = await manager.execute(started.value.session_id, "info files");
|
||
|
||
expect(result).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||
});
|
||
});
|
||
|
||
it("fails pending commands when the child closes stdin unexpectedly", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const stdin = fake.children[0]?.stdin;
|
||
expect(stdin).toBeDefined();
|
||
if (stdin === null || stdin === undefined) return;
|
||
const inputClosed = new Promise<void>((resolve) =>
|
||
stdin.once("close", resolve),
|
||
);
|
||
stdin.destroy();
|
||
await inputClosed;
|
||
|
||
expect(manager.status(started.value.session_id)?.state).toBe("closed");
|
||
expect(
|
||
(await manager.execute(started.value.session_id, "after-close")).ok,
|
||
).toBe(false);
|
||
});
|
||
|
||
it.each([
|
||
["throws an Error", "error"],
|
||
["throws a non-Error value", "non-error"],
|
||
["reports a callback write error", "callback"],
|
||
] as const)(
|
||
"handles stdin write failure when the fake writer %s",
|
||
async (_label, failure) => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const stdin = fake.children[0]?.stdin;
|
||
expect(stdin).toBeDefined();
|
||
if (stdin === null || stdin === undefined) return;
|
||
const originalWrite = stdin.write.bind(stdin);
|
||
Object.defineProperty(stdin, "write", {
|
||
value: (...arguments_: unknown[]) => {
|
||
const payload = String(arguments_[0]);
|
||
if (!payload.includes("-interpreter-exec"))
|
||
return Reflect.apply(originalWrite, stdin, arguments_);
|
||
if (failure === "callback") {
|
||
const callback = arguments_[2] as
|
||
| ((error?: Error | null) => void)
|
||
| undefined;
|
||
queueMicrotask(() =>
|
||
callback?.(new Error("simulated callback error")),
|
||
);
|
||
return true;
|
||
}
|
||
if (failure === "error") throw new Error("simulated write error");
|
||
throw "unstructured write failure";
|
||
},
|
||
});
|
||
|
||
const failed = await manager.execute(started.value.session_id, "will-fail");
|
||
|
||
expect(failed.ok).toBe(false);
|
||
if (!failed.ok) {
|
||
expect(failed.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
expect(failed.error.message).toContain(
|
||
failure === "callback"
|
||
? "simulated callback error"
|
||
: failure === "error"
|
||
? "simulated write error"
|
||
: "unknown error",
|
||
);
|
||
}
|
||
},
|
||
);
|
||
|
||
it("does not issue a command after an exit wins the startup prompt race", async () => {
|
||
const fake = createFakeSpawner(undefined, { exitOnFirstStdoutData: true });
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok)
|
||
expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
});
|
||
|
||
it("preserves SIGTERM startup exit status in the capability failure and Evidence", async () => {
|
||
const fake = createFakeSpawner(undefined, {
|
||
exitOnFirstStdoutData: true,
|
||
startupExitCode: null,
|
||
startupExitSignal: "SIGTERM",
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started.ok).toBe(false);
|
||
if (!started.ok) {
|
||
expect(started.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
expect(started.error.message).toContain("exit code null, signal SIGTERM");
|
||
expect(started.error.partialObservation).toMatchObject({
|
||
raw_result: {
|
||
process_exit_code: null,
|
||
process_exit_signal: "SIGTERM",
|
||
process_exit_observed: true,
|
||
},
|
||
});
|
||
}
|
||
});
|
||
|
||
it("rejects the command queued behind a timed-out GDB request", async () => {
|
||
const fake = createFakeSpawner((_child, _token, command) => {
|
||
if (command.includes("auto-load off"))
|
||
fake.children[0]?.stdout.write(`${_token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
commandTimeoutMs: 25,
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const first = manager.execute(started.value.session_id, "stall");
|
||
const queued = manager.execute(started.value.session_id, "after-timeout");
|
||
const firstResult = await first;
|
||
expect(firstResult.ok).toBe(false);
|
||
if (!firstResult.ok)
|
||
expect(firstResult.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
const rejected = await queued;
|
||
|
||
expect(rejected.ok).toBe(false);
|
||
if (!rejected.ok) expect(rejected.error._tag).toBe("AnalysisInputError");
|
||
});
|
||
|
||
it("waits for the prior command and preserves cancellation and queue failures", async () => {
|
||
const resolved = Promise.resolve();
|
||
expect(waitForGdbCommandTurn(resolved)).toBe(resolved);
|
||
|
||
const preAborted = new AbortController();
|
||
preAborted.abort();
|
||
await expect(
|
||
waitForGdbCommandTurn(Promise.resolve(), preAborted.signal),
|
||
).rejects.toMatchObject({ _tag: "AnalysisCancelledError" });
|
||
|
||
let release!: () => void;
|
||
const pending = new Promise<void>((resolve) => {
|
||
release = resolve;
|
||
});
|
||
const controller = new AbortController();
|
||
const cancelledWait = waitForGdbCommandTurn(pending, controller.signal);
|
||
controller.abort();
|
||
await expect(cancelledWait).rejects.toMatchObject({
|
||
_tag: "AnalysisCancelledError",
|
||
});
|
||
release();
|
||
|
||
const queueFailure = new Error("previous command queue failed");
|
||
await expect(
|
||
waitForGdbCommandTurn(
|
||
Promise.reject(queueFailure),
|
||
new AbortController().signal,
|
||
),
|
||
).rejects.toBe(queueFailure);
|
||
});
|
||
|
||
it("correlates command output records and reports invalid or cancelled requests", async () => {
|
||
const manager = await managerFor(
|
||
responsiveProvider(`
|
||
if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n');
|
||
else {
|
||
process.stdout.write('=thread-created,id="1"\\n');
|
||
process.stdout.write('~"console\\n"\\n@"target\\n"\\n&"log\\n"\\n');
|
||
process.stdout.write(match[1] + '^running\\n');
|
||
process.stdout.write('*stopped,reason="end-stepping-range"\\n');
|
||
}
|
||
`),
|
||
);
|
||
expect((await manager.execute("missing", "info registers")).ok).toBe(false);
|
||
expect((await manager.close("missing")).ok).toBe(false);
|
||
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const result = await manager.execute(
|
||
started.value.session_id,
|
||
"info registers",
|
||
);
|
||
expect(result.ok).toBe(true);
|
||
if (result.ok) {
|
||
expect(result.value.result).toMatchObject({
|
||
mi: expect.stringMatching(/\^running/u),
|
||
console: '~"console',
|
||
target: '@"target',
|
||
log: '&"log',
|
||
});
|
||
}
|
||
const signalled = await manager.execute(
|
||
started.value.session_id,
|
||
"info registers",
|
||
new AbortController().signal,
|
||
);
|
||
expect(signalled.ok).toBe(true);
|
||
const cancelled = new AbortController();
|
||
cancelled.abort();
|
||
const rejected = await manager.execute(
|
||
started.value.session_id,
|
||
"info registers",
|
||
cancelled.signal,
|
||
);
|
||
expect(rejected.ok).toBe(false);
|
||
if (!rejected.ok) expect(rejected.error._tag).toBe("AnalysisCancelledError");
|
||
const closed = await manager.close(started.value.session_id);
|
||
expect(closed.ok).toBe(false);
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
});
|
||
|
||
it("refuses to close a session while GDB reports a live target", async () => {
|
||
const manager = await managerFor(
|
||
responsiveProvider(`
|
||
if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n');
|
||
else if (match[2].includes('list-thread-groups')) process.stdout.write(match[1] + '^done,groups=[{id="i1",pid="123"}]\\n');
|
||
else process.stdout.write(match[1] + '^done\\n');
|
||
`),
|
||
);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed.ok).toBe(false);
|
||
if (!closed.ok)
|
||
expect(JSON.stringify(closed.error)).toContain(
|
||
"Live GDB thread groups remain",
|
||
);
|
||
});
|
||
|
||
it("retains ownership when recursive MI returns an unknown inferior list", async () => {
|
||
const commands: string[] = [];
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
commands.push(command);
|
||
child.stdout.write(
|
||
command.includes("list-thread-groups")
|
||
? `${token}^done,groups=[}]\n`
|
||
: `${token}^done\n`,
|
||
);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||
});
|
||
expect(
|
||
commands.filter((command) => command.includes("list-thread-groups")),
|
||
).toHaveLength(2);
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
expect(fake.children[0]?.killSignals).toEqual([]);
|
||
});
|
||
|
||
it("detaches a live inferior before the shared stop helper signals GDB", async () => {
|
||
const tty = "/dev/pts/42";
|
||
const commands: string[] = [];
|
||
let detached = false;
|
||
const fake = createFakeSpawner((_child, token, command) => {
|
||
commands.push(command);
|
||
if (command.includes("inferior-tty-show"))
|
||
fake.children[0]?.stdout.write(
|
||
`${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n`,
|
||
);
|
||
else if (command.includes("list-thread-groups"))
|
||
fake.children[0]?.stdout.write(
|
||
detached
|
||
? `${token}^done,groups=[]\n`
|
||
: `${token}^done,groups=[{id="i1",pid="123"}]\n`,
|
||
);
|
||
else {
|
||
if (command.includes("target-detach")) detached = true;
|
||
fake.children[0]?.stdout.write(`${token}^done\n`);
|
||
}
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager, undefined, {
|
||
inferiorTty: tty,
|
||
});
|
||
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed).toMatchObject({
|
||
ok: true,
|
||
value: { target_state: "unknown" },
|
||
});
|
||
expect(commands).toContain("-list-thread-groups --recurse 1");
|
||
expect(commands).toContain("-target-detach");
|
||
expect(
|
||
commands.filter((command) => command.includes("target-detach")),
|
||
).toHaveLength(1);
|
||
expect(commands).not.toContain("-gdb-exit");
|
||
expect(commands.indexOf("-inferior-tty-show")).toBeLessThan(
|
||
commands.indexOf("-target-detach"),
|
||
);
|
||
expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]);
|
||
});
|
||
|
||
it("reports none observed when the inferior disappears before detach", async () => {
|
||
const tty = "/dev/pts/42";
|
||
const commands: string[] = [];
|
||
let groupQueries = 0;
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
commands.push(command);
|
||
if (command.includes("inferior-tty-show"))
|
||
child.stdout.write(
|
||
`${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n`,
|
||
);
|
||
else if (command.includes("list-thread-groups")) {
|
||
groupQueries += 1;
|
||
child.stdout.write(
|
||
groupQueries === 1
|
||
? `${token}^done,groups=[{id="i1",pid="123"}]\n`
|
||
: `${token}^done,groups=[]\n`,
|
||
);
|
||
} else child.stdout.write(`${token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager, undefined, {
|
||
inferiorTty: tty,
|
||
});
|
||
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed).toMatchObject({
|
||
ok: true,
|
||
value: { target_state: "none_observed" },
|
||
});
|
||
expect(groupQueries).toBe(2);
|
||
expect(commands).not.toContain("-target-detach");
|
||
});
|
||
|
||
it("retains GDB ownership when MI does not confirm target detachment", async () => {
|
||
const tty = "/dev/pts/42";
|
||
const commands: string[] = [];
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
commands.push(command);
|
||
child.stdout.write(
|
||
command.includes("inferior-tty-show")
|
||
? `${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n`
|
||
: command.includes("list-thread-groups")
|
||
? `${token}^done,groups=[{id="i1",pid="123"}]\n`
|
||
: command.includes("target-detach")
|
||
? `${token}^error,msg="detach rejected"\n`
|
||
: `${token}^done\n`,
|
||
);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager, undefined, {
|
||
inferiorTty: tty,
|
||
});
|
||
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||
});
|
||
expect(
|
||
commands.filter((command) => command.includes("target-detach")),
|
||
).toHaveLength(2);
|
||
expect(fake.children[0]?.killSignals).toEqual([]);
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
});
|
||
|
||
it.each([
|
||
["releases cleanup after the identified inferior ends", false],
|
||
["invalidates old identity after a newer ambiguous inventory", true],
|
||
])("%s", async (_title, invalidateAfterFirstInspection) => {
|
||
const inferior = spawn(
|
||
process.execPath,
|
||
["-e", "process.stdout.write('ready\\n'); setInterval(() => {}, 1_000)"],
|
||
{ stdio: ["ignore", "pipe", "ignore"] },
|
||
);
|
||
let inferiorAlive = true;
|
||
let capturedStartTime: string | undefined;
|
||
try {
|
||
if (inferior.pid === undefined) throw new Error("Inferior did not spawn");
|
||
await new Promise<void>((resolve, reject) => {
|
||
inferior.stdout.once("data", () => resolve());
|
||
inferior.once("error", reject);
|
||
});
|
||
const pid = String(inferior.pid);
|
||
const tty = "/dev/pts/42";
|
||
let groupQueries = 0;
|
||
const fake = createFakeSpawner((_child, token, command) => {
|
||
let response: string;
|
||
if (command.includes("inferior-tty-show"))
|
||
response = `^done,inferior_tty_terminal=${JSON.stringify(tty)}`;
|
||
else if (command.includes("list-thread-groups")) {
|
||
groupQueries += 1;
|
||
response =
|
||
invalidateAfterFirstInspection && groupQueries > 1
|
||
? '^done,groups=[{id="i1",pid="123"},{id="i2",pid="456"}]'
|
||
: `^done,groups=[{id="i1",pid="${pid}"}]`;
|
||
} else if (command.includes("target-detach"))
|
||
response = '^error,msg="detach reply lost"';
|
||
else response = "^done";
|
||
fake.children[0]?.stdout.write(`${token}${response}\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
verifyInferiorOutputSink: async (observedPid, _tty, rememberIdentity) => {
|
||
expect(observedPid).toBe(pid);
|
||
if (process.platform === "linux") {
|
||
const stat = await readFile(`/proc/${observedPid}/stat`, "utf8");
|
||
capturedStartTime = parseLinuxProcessStartTime(stat);
|
||
} else capturedStartTime = `test-inferior-${observedPid}`;
|
||
if (capturedStartTime === undefined)
|
||
throw new Error("Controlled inferior identity was unreadable");
|
||
rememberIdentity({ pid: observedPid, startTime: capturedStartTime });
|
||
},
|
||
readInferiorProcessStartTime: async (observedPid) => {
|
||
if (observedPid !== pid || !inferiorAlive) return null;
|
||
if (process.platform === "linux") {
|
||
const stat = await readFile(`/proc/${observedPid}/stat`, "utf8");
|
||
return parseLinuxProcessStartTime(stat) ?? null;
|
||
}
|
||
return capturedStartTime ?? null;
|
||
},
|
||
});
|
||
const started = await expectGdbSessionStarted(manager, undefined, {
|
||
inferiorTty: tty,
|
||
});
|
||
const firstClose = await manager.close(started.value.session_id);
|
||
expect(firstClose.ok).toBe(false);
|
||
if (!firstClose.ok) expect(firstClose.error.cleanupIncomplete).toBe(true);
|
||
|
||
const gdbProcess = fake.children[0];
|
||
if (gdbProcess === undefined) throw new Error("Fake GDB did not spawn");
|
||
expect(groupQueries).toBeGreaterThanOrEqual(2);
|
||
gdbProcess.exitLeader(7);
|
||
gdbProcess.closeInheritedOutput();
|
||
|
||
if (invalidateAfterFirstInspection) {
|
||
const inferiorExit = new Promise<void>((resolve, reject) => {
|
||
inferior.once("exit", () => resolve());
|
||
inferior.once("error", reject);
|
||
});
|
||
inferior.kill("SIGTERM");
|
||
await inferiorExit;
|
||
inferiorAlive = false;
|
||
const invalidated = await manager.close(started.value.session_id);
|
||
expect(invalidated.ok).toBe(false);
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
return;
|
||
}
|
||
|
||
const whileInferiorLives = await manager.close(started.value.session_id);
|
||
expect(whileInferiorLives.ok).toBe(false);
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
|
||
const inferiorExit = new Promise<void>((resolve, reject) => {
|
||
inferior.once("exit", () => resolve());
|
||
inferior.once("error", reject);
|
||
});
|
||
inferior.kill("SIGTERM");
|
||
await inferiorExit;
|
||
inferiorAlive = false;
|
||
await expect(
|
||
manager.close(started.value.session_id),
|
||
).resolves.toMatchObject({
|
||
ok: true,
|
||
value: { target_state: "unknown" },
|
||
});
|
||
expect(manager.status(started.value.session_id)).toBeUndefined();
|
||
} finally {
|
||
if (inferior.exitCode === null && inferior.signalCode === null) {
|
||
const inferiorExit = new Promise<void>((resolve) =>
|
||
inferior.once("exit", () => resolve()),
|
||
);
|
||
inferior.kill("SIGKILL");
|
||
await inferiorExit;
|
||
}
|
||
}
|
||
});
|
||
|
||
it("revokes the verified sink after an abbreviated with inferior-tty run command", async () => {
|
||
const tty = "/dev/pts/42";
|
||
const commands: string[] = [];
|
||
const fake = createFakeSpawner((_child, token, command) => {
|
||
commands.push(command);
|
||
if (command.includes("inferior-tty-show"))
|
||
fake.children[0]?.stdout.write(
|
||
`${token}^done,inferior_tty_terminal=${JSON.stringify(tty)}\n`,
|
||
);
|
||
else if (command.includes("list-thread-groups"))
|
||
fake.children[0]?.stdout.write(
|
||
`${token}^done,groups=[{id="i1",pid="123"}]\n`,
|
||
);
|
||
else fake.children[0]?.stdout.write(`${token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager, undefined, {
|
||
inferiorTty: tty,
|
||
});
|
||
|
||
const overridden = await manager.execute(
|
||
started.value.session_id,
|
||
"with inferior-t /tmp/other-output -- run",
|
||
);
|
||
|
||
expect(overridden.ok).toBe(true);
|
||
expect(manager.status(started.value.session_id)).toMatchObject({
|
||
inferior_tty: tty,
|
||
inferior_tty_verified: false,
|
||
});
|
||
const closed = await manager.close(started.value.session_id);
|
||
expect(closed.ok).toBe(false);
|
||
expect(commands).not.toContain("-target-detach");
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
});
|
||
|
||
const expectDetachProofRevoked = async (
|
||
commandsToRun: readonly string[],
|
||
expectedVerifiedBeforeLastCommand?: boolean,
|
||
): Promise<void> => {
|
||
const { manager, sessionId, commands } =
|
||
await startWithVerifiedInferiorTty("/dev/pts/42");
|
||
for (const [index, command] of commandsToRun.entries()) {
|
||
await manager.execute(sessionId, command);
|
||
if (index === 0 && expectedVerifiedBeforeLastCommand)
|
||
expect(manager.status(sessionId)?.inferior_tty_verified).toBe(true);
|
||
}
|
||
|
||
expect(manager.status(sessionId)?.inferior_tty_verified).toBe(false);
|
||
const closed = await manager.close(sessionId);
|
||
expect(closed.ok).toBe(false);
|
||
expect(commands).not.toContain("-target-detach");
|
||
};
|
||
|
||
it.each([
|
||
{
|
||
title: "an abbreviated TTY change followed by restoring the original TTY",
|
||
commands: [
|
||
"set pagination off",
|
||
"set inferior-t /tmp/other",
|
||
"set inferior-t /dev/pts/42",
|
||
"start",
|
||
],
|
||
expectedVerified: true,
|
||
},
|
||
{
|
||
title: "redirection attached to a run argument",
|
||
commands: ["start arg>/dev/null"],
|
||
expectedVerified: false,
|
||
},
|
||
{
|
||
title: "a Python abbreviation that changes inferior TTY",
|
||
commands: [
|
||
'py gdb.execute("set " + chr(45).join(["inferior", "tty"]) + " /dev/null")',
|
||
],
|
||
expectedVerified: false,
|
||
},
|
||
{
|
||
title: "a GDB alias that can change inferior output",
|
||
commands: ["alias redirect-output = run > /tmp/gdb-redirected-output"],
|
||
expectedVerified: false,
|
||
},
|
||
{
|
||
title: "an abbreviated TTY command",
|
||
commands: ["tt /tmp/gdb-alternate-inferior-output"],
|
||
expectedVerified: false,
|
||
},
|
||
{
|
||
title: "an abbreviated run command",
|
||
commands: ["ru > /tmp/gdb-alternate-inferior-output"],
|
||
expectedVerified: false,
|
||
},
|
||
{
|
||
title: "Python nested in an abbreviated with command",
|
||
commands: [
|
||
'with confirm off -- py gdb.execute("set " + chr(45).join(["inferior", "tty"]) + " /dev/null")',
|
||
],
|
||
expectedVerified: false,
|
||
},
|
||
{
|
||
title: "Python nested in an abbreviated wit command",
|
||
commands: [
|
||
'wit confirm off -- py gdb.execute("set " + chr(45).join(["inferior", "tty"]) + " /dev/null")',
|
||
],
|
||
expectedVerified: false,
|
||
},
|
||
])(
|
||
"revokes detach proof for $title",
|
||
async ({ commands, expectedVerified }) => {
|
||
await expectDetachProofRevoked(commands, expectedVerified);
|
||
},
|
||
);
|
||
|
||
it("does not detach a live inferior when no durable TTY was selected", async () => {
|
||
const responder = recordingInferiorListResponder('[{id="i1",pid="123"}]');
|
||
const fake = createFakeSpawner(responder.respond);
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
await expect(manager.closeAll()).rejects.toMatchObject({
|
||
errors: [
|
||
expect.objectContaining({
|
||
message: expect.stringMatching(/durable output sink/i),
|
||
}),
|
||
],
|
||
});
|
||
|
||
expect(responder.commands).not.toContain("-target-detach");
|
||
expect(responder.commands).not.toContain("-gdb-exit");
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
expect(fake.children[0]?.killSignals).toEqual([]);
|
||
});
|
||
|
||
it("retains ownership without signaling when recursive MI reports multiple inferiors", async () => {
|
||
const responder = recordingInferiorListResponder(
|
||
'[{id="i1",pid="123"},{id="i2",pid="456"}]',
|
||
);
|
||
const fake = createFakeSpawner(responder.respond);
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
await expect(manager.closeAll()).rejects.toMatchObject({
|
||
errors: [
|
||
expect.objectContaining({
|
||
message: expect.stringMatching(/multiple inferiors/iu),
|
||
}),
|
||
],
|
||
});
|
||
|
||
expect(responder.commands).toContain("-list-thread-groups --recurse 1");
|
||
expect(responder.commands).not.toContain("-target-detach");
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
expect(fake.children[0]?.killSignals).toEqual([]);
|
||
});
|
||
|
||
it("confirms no inferior before the shared stop helper signals GDB", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed).toMatchObject({
|
||
ok: true,
|
||
value: { target_state: "none_observed" },
|
||
});
|
||
expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]);
|
||
});
|
||
|
||
it("retains ownership when manual close receives a truncated empty inferior list", async () => {
|
||
const commands: string[] = [];
|
||
const fake = createFakeSpawner((_child, token, command) => {
|
||
commands.push(command);
|
||
if (command.includes("auto-load off")) {
|
||
fake.children[0]?.stdout.write(`${token}^done\n`);
|
||
return;
|
||
}
|
||
if (command.includes("list-thread-groups")) {
|
||
const child = fake.children[0];
|
||
if (child === undefined) return;
|
||
// Keep each MI record below the per-line ceiling while exceeding the
|
||
// per-command byte budget before the apparently empty result arrives.
|
||
for (let index = 0; index < 17; index += 1)
|
||
child.stdout.write(`~"${"x".repeat(1_000_000)}"\n`);
|
||
child.stdout.write(`${token}^done,groups=[]\n`);
|
||
return;
|
||
}
|
||
fake.children[0]?.stdout.write(`${token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed.ok).toBe(false);
|
||
expect(commands).toContain("-list-thread-groups --recurse 1");
|
||
expect(commands).not.toContain("-gdb-exit");
|
||
expect(commands).not.toContain("-target-detach");
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
expect(fake.children[0]?.killSignals).toEqual([]);
|
||
});
|
||
|
||
it("retains ownership and sends no signal when MI state is saturated or unknown", async () => {
|
||
const fake = createFakeSpawner((_child, token, command) => {
|
||
if (command.includes("auto-load off"))
|
||
fake.children[0]?.stdout.write(`${token}^done\n`);
|
||
else if (command.includes("list-thread-groups"))
|
||
fake.children[0]?.stdout.write(`${token}^error,msg="unknown"\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
commandTimeoutMs: 20,
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const timedOut = await manager.execute(started.value.session_id, "saturated");
|
||
expect(timedOut.ok).toBe(false);
|
||
await expect(manager.closeAll()).rejects.toMatchObject({
|
||
errors: [
|
||
expect.objectContaining({
|
||
message: expect.stringMatching(/inferior detach/i),
|
||
}),
|
||
],
|
||
});
|
||
|
||
expect(manager.status(started.value.session_id)?.state).toBe("closed");
|
||
expect(fake.children[0]?.killSignals).toEqual([]);
|
||
});
|
||
|
||
it("routes protocol failure cleanup through detach verification without direct signals", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const child = fake.children[0];
|
||
if (child?.stdin === null || child?.stdin === undefined) return;
|
||
Object.defineProperty(child.stdin, "write", {
|
||
value: (
|
||
_chunk: string,
|
||
_encoding: BufferEncoding,
|
||
callback: (error?: Error | null) => void,
|
||
): boolean => {
|
||
callback(new Error("synthetic protocol failure"));
|
||
return false;
|
||
},
|
||
});
|
||
|
||
const failed = await manager.execute(started.value.session_id, "will-fail");
|
||
expect(failed.ok).toBe(false);
|
||
await new Promise((resolve) => setImmediate(resolve));
|
||
expect(child.killSignals).toEqual([]);
|
||
});
|
||
|
||
it("retains ownership without signaling when detach confirmation fails", async () => {
|
||
const fake = createFakeSpawner((_child, token, command) => {
|
||
fake.children[0]?.stdout.write(
|
||
command.includes("list-thread-groups")
|
||
? `${token}^error,msg="state unavailable"\n`
|
||
: `${token}^done\n`,
|
||
);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
await expect(manager.closeAll()).rejects.toThrow(/did not stop cleanly/i);
|
||
|
||
expect(manager.status(started.value.session_id)?.state).toBe("ready");
|
||
expect(fake.children[0]?.killSignals).toEqual([]);
|
||
});
|
||
|
||
it("closes with unknown target state when MI cannot confirm thread groups", async () => {
|
||
const manager = await managerFor(
|
||
responsiveProvider(`
|
||
if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n');
|
||
else if (match[2].includes('list-thread-groups')) process.stdout.write(match[1] + '^error,msg="not supported"\\n');
|
||
else process.stdout.write(match[1] + '^done\\n');
|
||
`),
|
||
);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const closed = await manager.close(started.value.session_id);
|
||
expect(closed.ok).toBe(false);
|
||
if (!closed.ok) {
|
||
expect(closed.error.message).toContain("inferior state");
|
||
expect(closed.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
}
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
});
|
||
|
||
it("retains unknown inferior ownership after GDB exits during a command", async () => {
|
||
const manager = await managerFor(
|
||
responsiveProvider(`
|
||
if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n');
|
||
else process.stdout.write('~leaving\\n', () => process.exit(2));
|
||
`),
|
||
);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const executing = manager.execute(
|
||
started.value.session_id,
|
||
"exit during command",
|
||
);
|
||
const closing = manager.close(started.value.session_id);
|
||
const executed = await executing;
|
||
expect(executed).toMatchObject({
|
||
ok: false,
|
||
error: {
|
||
cleanupIncomplete: true,
|
||
partialObservation: {
|
||
normalized_result: {
|
||
process_exit_observed: true,
|
||
completion_status: "unknown",
|
||
},
|
||
},
|
||
},
|
||
});
|
||
expect(await closing).toMatchObject({
|
||
ok: false,
|
||
error: { cleanupIncomplete: true },
|
||
});
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
});
|
||
|
||
it("retains an exited GDB session when no inferior identity was captured", async () => {
|
||
const manager = await managerFor(
|
||
responsiveProvider(`
|
||
if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n');
|
||
else process.exit(3);
|
||
`),
|
||
);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const executed = await manager.execute(started.value.session_id, "exit now");
|
||
expect(executed).toMatchObject({
|
||
ok: false,
|
||
error: { cleanupIncomplete: true },
|
||
});
|
||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||
if (manager.status(started.value.session_id)?.state === "closed") break;
|
||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||
}
|
||
expect(manager.status(started.value.session_id)?.state).toBe("closed");
|
||
await expect(manager.close(started.value.session_id)).resolves.toMatchObject({
|
||
ok: false,
|
||
error: { cleanupIncomplete: true },
|
||
});
|
||
expect(manager.status(started.value.session_id)).toBeDefined();
|
||
});
|
||
|
||
it("retains a session when closing cannot confirm the debugger process stopped", async () => {
|
||
let stopCalls = 0;
|
||
const provider = await createProvider(responsiveProvider());
|
||
const manager = new GdbSessionManager({
|
||
platform: "linux",
|
||
environment: { REA_GDB_COMMAND: provider },
|
||
stopProcess: async (session, name) => {
|
||
stopCalls += 1;
|
||
if (stopCalls === 1)
|
||
return { status: "incomplete", reason: "simulated close uncertainty" };
|
||
return stopOwnedDebuggerProcess(session, name);
|
||
},
|
||
});
|
||
managers.push(manager);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed.ok).toBe(false);
|
||
if (!closed.ok) {
|
||
expect(closed.error.message).toContain("simulated close uncertainty");
|
||
expect(closed.error).toMatchObject({
|
||
providerId: "gdb",
|
||
operation: "close_gdb_session",
|
||
});
|
||
}
|
||
expect(manager.status(started.value.session_id)?.state).toBe("ready");
|
||
});
|
||
|
||
it("retains an exited GDB session when provider stop verification is incomplete", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async () => ({
|
||
status: "incomplete",
|
||
reason: "simulated exited debugger cleanup uncertainty",
|
||
}),
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
fake.children[0]?.exitLeader(9);
|
||
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCapabilityUnavailableError" },
|
||
});
|
||
expect(manager.status(started.value.session_id)?.state).toBe("closed");
|
||
});
|
||
|
||
it("preserves a command timeout when stopping the owned process rejects", async () => {
|
||
let stopCalls = 0;
|
||
const provider = await createProvider(
|
||
responsiveProvider(`
|
||
if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n');
|
||
`),
|
||
);
|
||
const manager = new GdbSessionManager({
|
||
platform: "linux",
|
||
environment: { REA_GDB_COMMAND: provider },
|
||
commandTimeoutMs: 25,
|
||
stopProcess: async (session, name) => {
|
||
stopCalls += 1;
|
||
if (stopCalls === 1) throw new Error("simulated stop failure");
|
||
return stopOwnedDebuggerProcess(session, name);
|
||
},
|
||
});
|
||
managers.push(manager);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const timedOut = await manager.execute(started.value.session_id, "stall");
|
||
|
||
expect(timedOut.ok).toBe(false);
|
||
if (!timedOut.ok) {
|
||
expect(timedOut.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
expect(timedOut.error.message).toContain("shutdown failed");
|
||
}
|
||
});
|
||
|
||
it("reports cancellation if owned-process stop rejects during an active command", async () => {
|
||
const controller = new AbortController();
|
||
let stopCalls = 0;
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
if (command.includes("auto-load off"))
|
||
child.stdout.write(`${token}^done\n`);
|
||
else controller.abort();
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async (session, name) => {
|
||
stopCalls += 1;
|
||
if (stopCalls === 1) throw new Error("simulated stop rejection");
|
||
return stopOwnedDebuggerProcess(session, name);
|
||
},
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const cancelled = await manager.execute(
|
||
started.value.session_id,
|
||
"cancel me",
|
||
controller.signal,
|
||
);
|
||
|
||
expect(cancelled.ok).toBe(false);
|
||
if (!cancelled.ok)
|
||
expect(cancelled.error._tag).toBe("AnalysisCancelledError");
|
||
expect(stopCalls).toBe(1);
|
||
});
|
||
|
||
it("returns a tagged cleanup failure thrown while closing", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async () => {
|
||
throw new AnalysisTimeoutError("close_gdb_session", 17);
|
||
},
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const closed = await manager.close(started.value.session_id);
|
||
|
||
expect(closed).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisTimeoutError", operation: "close_gdb_session" },
|
||
});
|
||
});
|
||
|
||
it("rethrows an unexpected non-domain cleanup failure while closing", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async () => {
|
||
throw new Error("unexpected cleanup failure");
|
||
},
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
await expect(manager.close(started.value.session_id)).rejects.toThrow(
|
||
"unexpected cleanup failure",
|
||
);
|
||
});
|
||
|
||
it("returns partial command evidence when the active MI request times out after stop", async () => {
|
||
const fake = createFakeSpawner((_child, token, command) => {
|
||
if (command.includes("hang-for-timeout")) return;
|
||
if (command.includes("list-thread-groups"))
|
||
_child.stdout.write(`${token}^done,groups=[]\n`);
|
||
else _child.stdout.write(`${token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
commandTimeoutMs: 20,
|
||
stopProcess: async () => ({ status: "stopped" }),
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const result = await manager.execute(
|
||
started.value.session_id,
|
||
"hang-for-timeout",
|
||
);
|
||
|
||
expect(result).toMatchObject({
|
||
ok: true,
|
||
value: {
|
||
result: {
|
||
completion_status: "unknown",
|
||
output_truncated: false,
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
it("preserves cancellation without cleanup details when stop is confirmed", async () => {
|
||
const controller = new AbortController();
|
||
let requestToken = "";
|
||
const fake = createFakeSpawner((_child, token, command) => {
|
||
if (command.includes("cancel-confirmed")) requestToken = token;
|
||
else _child.stdout.write(`${token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async () => ({ status: "stopped" }),
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const active = manager.execute(
|
||
started.value.session_id,
|
||
"cancel-confirmed",
|
||
controller.signal,
|
||
);
|
||
await expect.poll(() => requestToken).not.toBe("");
|
||
controller.abort();
|
||
const cancelled = await active;
|
||
|
||
expect(cancelled).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCancelledError", operation: "gdb_console" },
|
||
});
|
||
if (!cancelled.ok) expect(cancelled.error.cleanup).toBeUndefined();
|
||
});
|
||
|
||
it("rechecks cancellation after registering the active MI request listener", async () => {
|
||
const controller = new AbortController();
|
||
let registrations = 0;
|
||
const signal = new Proxy(controller.signal, {
|
||
get(target, property) {
|
||
if (property === "addEventListener")
|
||
return (...arguments_: Parameters<AbortSignal["addEventListener"]>) => {
|
||
registrations += 1;
|
||
const result = target.addEventListener(...arguments_);
|
||
if (registrations === 2) controller.abort();
|
||
return result;
|
||
};
|
||
const value = Reflect.get(target, property, target) as unknown;
|
||
return typeof value === "function" ? value.bind(target) : value;
|
||
},
|
||
});
|
||
const commands: string[] = [];
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
commands.push(command);
|
||
child.stdout.write(`${token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async () => ({ status: "stopped" }),
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const cancelled = await manager.execute(
|
||
started.value.session_id,
|
||
"must-not-be-sent",
|
||
signal,
|
||
);
|
||
|
||
expect(cancelled).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisCancelledError" },
|
||
});
|
||
expect(commands).not.toContain(expect.stringContaining("must-not-be-sent"));
|
||
expect(registrations).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
it("classifies timeout and unexpected failures raised while quoting console commands", async () => {
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const timeoutCommand = Object.assign(new String("timeout"), {
|
||
replaceAll() {
|
||
throw new AnalysisTimeoutError("test_command_quote", 17);
|
||
},
|
||
}) as unknown as string;
|
||
const timeout = await manager.execute(
|
||
started.value.session_id,
|
||
timeoutCommand,
|
||
);
|
||
expect(timeout.ok).toBe(false);
|
||
if (!timeout.ok) expect(timeout.error._tag).toBe("AnalysisTimeoutError");
|
||
|
||
const unexpectedCommand = Object.assign(new String("unexpected"), {
|
||
replaceAll() {
|
||
throw new Error("command quoting failed");
|
||
},
|
||
}) as unknown as string;
|
||
const unexpected = await manager.execute(
|
||
started.value.session_id,
|
||
unexpectedCommand,
|
||
);
|
||
expect(unexpected.ok).toBe(false);
|
||
if (!unexpected.ok)
|
||
expect(unexpected.error._tag).toBe("AnalysisTimeoutError");
|
||
});
|
||
|
||
it("marks retained exit output as truncated when command records exceed the byte cap", async () => {
|
||
const manager = await managerFor(`
|
||
process.stdout.write('(gdb)\\n');
|
||
let input = '';
|
||
process.stdin.setEncoding('utf8');
|
||
process.stdin.on('data', (chunk) => {
|
||
input += chunk;
|
||
for (;;) {
|
||
const end = input.indexOf('\\n');
|
||
if (end < 0) break;
|
||
const line = input.slice(0, end); input = input.slice(end + 1);
|
||
const match = /^(\\d+)(.*)/.exec(line);
|
||
if (!match) continue;
|
||
if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n');
|
||
else {
|
||
const record = 'x'.repeat(6 * 1024 * 1024);
|
||
process.stdout.write('~' + record + '\\n~' + record + '\\n~' + record + '\\n', () => process.exit(7));
|
||
}
|
||
}
|
||
});
|
||
`);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const exited = await manager.execute(started.value.session_id, "large exit");
|
||
|
||
expect(exited.ok).toBe(false);
|
||
if (!exited.ok) {
|
||
expect(exited.error.cleanupIncomplete).toBe(true);
|
||
expect(exited.error.partialObservation).toMatchObject({
|
||
normalized_result: {
|
||
process_exit_observed: true,
|
||
output_truncated: true,
|
||
completion_status: "unknown",
|
||
},
|
||
});
|
||
expect(exited.error.partialObservation).toMatchObject({
|
||
limitations: expect.arrayContaining([
|
||
expect.stringContaining("truncated prefix"),
|
||
]),
|
||
});
|
||
}
|
||
});
|
||
|
||
it("retains split MI records while bounding recent and command output", async () => {
|
||
const manager = await managerFor(`
|
||
process.stdout.write('(gdb)\\n');
|
||
let input = '';
|
||
process.stdin.setEncoding('utf8');
|
||
process.stdin.on('data', (chunk) => {
|
||
input += chunk;
|
||
for (;;) {
|
||
const end = input.indexOf('\\n');
|
||
if (end < 0) break;
|
||
const line = input.slice(0, end); input = input.slice(end + 1);
|
||
const match = /^(\\d+)(.*)/.exec(line);
|
||
if (!match) continue;
|
||
if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n');
|
||
else {
|
||
process.stdout.write('=split-record');
|
||
setTimeout(() => {
|
||
process.stdout.write('\\n999^done\\n=unmatched-token\\n' + 'x'.repeat(70_000) + '\\n');
|
||
const largeRecord = 'a'.repeat(6 * 1024 * 1024);
|
||
process.stdout.write('~' + largeRecord + '\\n~' + largeRecord + '\\n~' + largeRecord + '\\n');
|
||
process.stdout.write(match[1] + '^done\\n');
|
||
}, 5);
|
||
}
|
||
}
|
||
});
|
||
|
||
`);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const result = await manager.execute(
|
||
started.value.session_id,
|
||
"large-output",
|
||
);
|
||
|
||
expect(result.ok).toBe(true);
|
||
if (result.ok) {
|
||
expect(result.value.result.output_truncated).toBe(true);
|
||
expect(result.value.result.records).toHaveLength(7);
|
||
}
|
||
expect(
|
||
manager.status(started.value.session_id)?.recent_mi_records_truncated,
|
||
).toBe(true);
|
||
});
|
||
|
||
it("truncates retained MI records on UTF-8 code point boundaries", async () => {
|
||
const manager = await managerFor(`
|
||
process.stdout.write('(gdb)\\n');
|
||
let input = '';
|
||
process.stdin.setEncoding('utf8');
|
||
process.stdin.on('data', (chunk) => {
|
||
input += chunk;
|
||
for (;;) {
|
||
const end = input.indexOf('\\n');
|
||
if (end < 0) break;
|
||
const line = input.slice(0, end); input = input.slice(end + 1);
|
||
const match = /^(\\d+)(.*)/.exec(line);
|
||
if (!match) continue;
|
||
if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n');
|
||
else process.stdout.write('~' + '💩'.repeat(20_000) + '\\n' + match[1] + '^done\\n');
|
||
}
|
||
});
|
||
`);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
await manager.execute(started.value.session_id, "large unicode output");
|
||
|
||
const retained = manager
|
||
.status(started.value.session_id)
|
||
?.recent_mi_records.find((record) => record.startsWith("~"));
|
||
expect(retained).toBeDefined();
|
||
if (retained !== undefined) {
|
||
expect(Buffer.byteLength(retained, "utf8")).toBeLessThanOrEqual(64 * 1024);
|
||
expect(retained).not.toContain("<22>");
|
||
expect(retained.endsWith("[recent record truncated]")).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("stops the GDB session when a complete MI record exceeds the protocol limit", async () => {
|
||
const manager = await managerFor(`
|
||
process.stdout.write('(gdb)\\n');
|
||
let input = '';
|
||
process.stdin.setEncoding('utf8');
|
||
process.stdin.on('data', (chunk) => {
|
||
input += chunk;
|
||
for (;;) {
|
||
const end = input.indexOf('\\n');
|
||
if (end < 0) break;
|
||
const line = input.slice(0, end); input = input.slice(end + 1);
|
||
const match = /^(\\d+)(.*)/.exec(line);
|
||
if (!match) continue;
|
||
if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n');
|
||
else process.stdout.write('x'.repeat(16 * 1024 * 1024 + 1) + '\\n');
|
||
}
|
||
});
|
||
`);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const failed = await manager.execute(started.value.session_id, "bad-record");
|
||
|
||
expect(failed.ok).toBe(false);
|
||
if (!failed.ok)
|
||
expect(failed.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
});
|
||
|
||
it("rejects a partial MI line that exceeds the protocol limit", async () => {
|
||
const manager = await managerFor(`
|
||
process.stdout.write('(gdb)\\n');
|
||
let input = '';
|
||
process.stdin.setEncoding('utf8');
|
||
process.stdin.on('data', (chunk) => {
|
||
input += chunk;
|
||
for (;;) {
|
||
const end = input.indexOf('\\n');
|
||
if (end < 0) break;
|
||
const line = input.slice(0, end); input = input.slice(end + 1);
|
||
const match = /^(\\d+)(.*)/.exec(line);
|
||
if (!match) continue;
|
||
if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n');
|
||
else process.stdout.write('x'.repeat(16 * 1024 * 1024 + 1));
|
||
}
|
||
});
|
||
`);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const failed = await manager.execute(
|
||
started.value.session_id,
|
||
"partial-line",
|
||
);
|
||
|
||
expect(failed.ok).toBe(false);
|
||
if (!failed.ok)
|
||
expect(failed.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
});
|
||
|
||
it("caps the number of MI records retained for a single command", async () => {
|
||
const recordCount = 16_385;
|
||
const manager = await managerFor(`
|
||
process.stdout.write('(gdb)\\n');
|
||
let input = '';
|
||
process.stdin.setEncoding('utf8');
|
||
process.stdin.on('data', (chunk) => {
|
||
input += chunk;
|
||
for (;;) {
|
||
const end = input.indexOf('\\n');
|
||
if (end < 0) break;
|
||
const line = input.slice(0, end); input = input.slice(end + 1);
|
||
const match = /^(\\d+)(.*)/.exec(line);
|
||
if (!match) continue;
|
||
if (match[2].includes('auto-load off')) process.stdout.write(match[1] + '^done\\n');
|
||
else process.stdout.write(Array.from({ length: ${recordCount} }, () => '~"x"').join('\\n') + '\\n' + match[1] + '^done\\n');
|
||
}
|
||
});
|
||
`);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const result = await manager.execute(
|
||
started.value.session_id,
|
||
"many-records",
|
||
);
|
||
|
||
expect(result.ok).toBe(true);
|
||
if (result.ok) {
|
||
expect(result.value.result.records).toHaveLength(16_384);
|
||
expect(result.value.result.output_truncated).toBe(true);
|
||
}
|
||
});
|
||
|
||
it("completes an MI record split after a chunk without a newline", async () => {
|
||
const fake = createFakeSpawner((child, token, command) => {
|
||
if (command.includes("auto-load off")) {
|
||
child.stdout.write(`${token}^done\n`);
|
||
return;
|
||
}
|
||
child.stdout.write("=thread-created");
|
||
setImmediate(() => child.stdout.write(`,id="1"\n${token}^done\n`));
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess);
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const result = await manager.execute(started.value.session_id, "thread-info");
|
||
|
||
expect(result.ok).toBe(true);
|
||
if (result.ok)
|
||
expect(result.value.result.records).toContain('=thread-created,id="1"');
|
||
});
|
||
|
||
it("preserves the protocol failure when an incomplete stop adds uncertainty", async () => {
|
||
let stopCalls = 0;
|
||
const fake = createFakeSpawner();
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
stopProcess: async (session, name) => {
|
||
stopCalls += 1;
|
||
if (stopCalls === 1) {
|
||
return {
|
||
status: "incomplete",
|
||
reason: "simulated protocol stop uncertainty",
|
||
};
|
||
}
|
||
return stopOwnedDebuggerProcess(session, name);
|
||
},
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
const stdin = fake.children[0]?.stdin;
|
||
if (stdin === null || stdin === undefined) return;
|
||
Object.defineProperty(stdin, "write", {
|
||
value: (
|
||
_chunk: string,
|
||
_encoding: BufferEncoding,
|
||
callback: (error?: Error | null) => void,
|
||
): boolean => {
|
||
fake.children[0]?.stdout.write('~"protocol output before failure"\n');
|
||
callback(new Error("simulated protocol write failure"));
|
||
return false;
|
||
},
|
||
});
|
||
|
||
const failed = await manager.execute(started.value.session_id, "will-fail");
|
||
|
||
expect(failed.ok).toBe(false);
|
||
if (!failed.ok) {
|
||
expect(failed.error.message).toContain(
|
||
"GDB stdin write failed: simulated protocol write failure",
|
||
);
|
||
expect(failed.error.message).toContain(
|
||
"simulated protocol stop uncertainty",
|
||
);
|
||
expect(failed.error.cleanup).toEqual({
|
||
reason: "simulated protocol stop uncertainty",
|
||
resources: [started.value.session_id],
|
||
});
|
||
expect(failed.error.partialObservation).toMatchObject({
|
||
predicate_type: "rea.debugger.console-observation",
|
||
raw_result: {
|
||
records: ['~"protocol output before failure"'],
|
||
completion_status: "unknown",
|
||
},
|
||
});
|
||
}
|
||
expect(manager.status(started.value.session_id)?.state).toBe("closed");
|
||
const subsequent = await manager.execute(
|
||
started.value.session_id,
|
||
"must-not-run",
|
||
);
|
||
expect(subsequent).toMatchObject({
|
||
ok: false,
|
||
error: { _tag: "AnalysisInputError" },
|
||
});
|
||
});
|
||
|
||
it("drains buffered startup stdout before capturing early-exit Evidence", async () => {
|
||
const children: FakeGdbProcess[] = [];
|
||
const manager = new GdbSessionManager({
|
||
platform: "linux",
|
||
environment: {},
|
||
startupTimeoutMs: 500,
|
||
spawnProcess: async (options) => {
|
||
const child = new FakeGdbProcess();
|
||
children.push(child);
|
||
setImmediate(() => {
|
||
child.exitLeader(7);
|
||
setImmediate(() => {
|
||
child.stdout.write('~"late startup record"\n');
|
||
child.closeInheritedOutput();
|
||
});
|
||
});
|
||
return {
|
||
process: child,
|
||
ownership: {
|
||
runId: options.runId,
|
||
leaderPid: child.pid,
|
||
processGroupId: child.pid,
|
||
expectedParentPid: process.pid,
|
||
},
|
||
};
|
||
},
|
||
});
|
||
managers.push(manager);
|
||
|
||
const started = await manager.start();
|
||
|
||
expect(started.ok).toBe(false);
|
||
expect(children).toHaveLength(2);
|
||
if (!started.ok)
|
||
expect(started.error.partialObservation).toMatchObject({
|
||
raw_result: {
|
||
process_exit_code: 7,
|
||
records: ['~"late startup record"'],
|
||
completion_status: "unknown",
|
||
},
|
||
});
|
||
});
|
||
|
||
it("reports command timeout when an incomplete stop cannot confirm process exit", async () => {
|
||
let stopCalls = 0;
|
||
const fake = createFakeSpawner((_child, _token, command) => {
|
||
if (!command.includes("auto-load off")) return;
|
||
fake.children[0]?.stdout.write(`${_token}^done\n`);
|
||
});
|
||
const manager = managerWithFakeSpawner(fake.spawnProcess, {
|
||
commandTimeoutMs: 20,
|
||
stopProcess: async (session, name) => {
|
||
stopCalls += 1;
|
||
if (stopCalls === 1)
|
||
return {
|
||
status: "incomplete",
|
||
reason: "simulated timeout stop uncertainty",
|
||
};
|
||
return stopOwnedDebuggerProcess(session, name);
|
||
},
|
||
});
|
||
const started = await expectGdbSessionStarted(manager);
|
||
|
||
const failed = await manager.execute(started.value.session_id, "stall");
|
||
|
||
expect(failed.ok).toBe(false);
|
||
if (!failed.ok) {
|
||
expect(failed.error._tag).toBe("AnalysisCapabilityUnavailableError");
|
||
expect(failed.error.message).toContain(
|
||
"simulated timeout stop uncertainty",
|
||
);
|
||
}
|
||
});
|