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 { AnalysisCancelledError, 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 { parseLinuxProcessStat } from "../../../src/process/LinuxProcessStat.js"; import { expectGdbSessionStarted } from "./gdbTestHelpers.js"; let directory: string | undefined; const managers: GdbSessionManager[] = []; it.each([ ["empty list", "1^done,groups=[]", 0], [ "valid trailing extension fields", '1^done,groups=[],extension="retained",details=[{state="known"}]', 0, ], [ "valid leading and trailing extension fields", '1^done,before={state="retained"},groups=[],after=["known"]', 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, ], [ "unquoted nested groups field", '1^done,groups=[{id="i1",children=[{id="i2",groups=[{id="i3"}]}]}]', undefined, ], [ "unknown extension field ending in groups", '1^done,groups=[{id="i1",other_groups=[{id="i2"}]}]', 0, ], [ "quoted groups marker before the top-level inventory", '1^done,msg="fixture/groups=[",groups=[]', 0, ], [ "duplicate top-level groups fields", '1^done,groups=[],groups=[{id="i1",pid="123"}]', undefined, ], [ "duplicate groups field with a non-list value", '1^done,groups=[],groups="unavailable"', undefined, ], ["trailing bytes after inventory", "1^done,groups=[]garbage", undefined], ["malformed trailing result value", "1^done,groups=[],extra=}", undefined], ["malformed prefix result", "1^done,extra=},groups=[]", undefined], ["groups list with a non-tuple value", '1^done,groups=["i1"]', undefined], ["group tuple without its required id", "1^done,groups=[{}]", undefined], ["negative object depth", "1^done,groups=[}]", undefined], ["unclosed list", '1^done,groups=[{id="i1"}', undefined], ["missing groups field", "1^done", undefined], [ "unclosed trailing quoted field", '1^done,groups=[],extra="unterminated', undefined, ], ] as const)("parses recursive GDB MI groups: %s", (_label, mi, expected) => { expect(gdbThreadGroupCount(mi)).toBe(expected); }); it("keeps GDB inventory unknown when MI nesting exceeds the parser frame budget", () => { const nestedValue = "[".repeat(257) + "]".repeat(257); expect( gdbThreadGroupCount("1^done,groups=[],extension=" + nestedValue), ).toBeUndefined(); }); 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; 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 reason = { source: "caller startup cancellation" }; const fake = createFakeSpawner(undefined, { afterSpawn: () => controller.abort(reason), }); 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(started.error.cause).toBe(reason); } expect(fake.children[0]?.signalCode).toBe("SIGTERM"); }); it("cancels immediately after the startup prompt before sending configuration commands", async () => { const controller = new AbortController(); const reason = new Error("caller cancelled after prompt"); const commands: string[] = []; const fake = createFakeSpawner( (child, token, command) => { commands.push(command); child.stdout.write(`${token}^done\n`); }, { deferPrompt: true, afterPrompt: () => controller.abort(reason), }, ); const manager = managerWithFakeSpawner(fake.spawnProcess); const started = await manager.start(controller.signal); expect(started).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" }, }); expect(commands).toEqual([]); if (!started.ok) expect(started.error.cause).toBe(reason); 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("keeps a consumed startup failure primary when caller abort arrives during cleanup", async () => { const controller = new AbortController(); const reason = { source: "late caller cancellation" }; const fake = createFakeSpawner((_child, token, command) => { if (command.includes("auto-load off")) _child.stdout.write(`${token}^error,msg="configuration rejected"\n`); }); const manager = managerWithFakeSpawner(fake.spawnProcess, { stopProcess: async () => { controller.abort(reason); return { status: "stopped" }; }, }); const started = await manager.start(controller.signal); expect(started).toMatchObject({ ok: false, error: { _tag: "AnalysisCapabilityUnavailableError" }, }); if (!started.ok) expect(started.error.cause).toMatchObject({ _tag: "AnalysisCapabilityUnavailableError", }); }); it("cancels startup when aborted during the final readiness drain", async () => { const controller = new AbortController(); const reason = "caller cancelled during readiness drain"; 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(reason)); }); 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"]); if (!started.ok) expect(started.error.cause).toBe(reason); expect(fake.children[0]?.killSignals).toEqual(["SIGTERM"]); }); it("preserves cancellation and cleanup details when startup stop is uncertain", async () => { const controller = new AbortController(); const reason = { source: "startup caller", token: 7 }; let stopCalls = 0; const fake = createFakeSpawner(undefined, { deferPrompt: true, afterPrompt: () => controller.abort(reason), }); 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" }, }, }); if (!started.ok) expect(started.error.cause).toBe(reason); }); it("keeps manager-close cancellation primary when caller abort arrives during startup cleanup", async () => { const caller = new AbortController(); const callerReason = { source: "late caller abort", token: Symbol("opaque") }; let commandEntered!: () => void; const commandStarted = new Promise((resolve) => { commandEntered = resolve; }); let cleanupEntered!: () => void; const cleanupStarted = new Promise((resolve) => { cleanupEntered = resolve; }); let releaseCleanup!: () => void; const cleanupGate = new Promise((resolve) => { releaseCleanup = resolve; }); let stopCalls = 0; const fake = createFakeSpawner((_child, _token, command) => { if (command.includes("auto-load off")) commandEntered(); }); const manager = managerWithFakeSpawner(fake.spawnProcess, { stopProcess: async () => { stopCalls += 1; if (stopCalls === 1) { cleanupEntered(); await cleanupGate; } return { status: "incomplete", reason: "held fake cleanup remains uncertain", }; }, }); const starting = manager.start(caller.signal); await commandStarted; const closing = manager.closeAll().catch(() => undefined); await cleanupStarted; caller.abort(callerReason); releaseCleanup(); const started = await starting; expect(started.ok).toBe(false); if (!started.ok) { expect(started.error).toBeInstanceOf(AnalysisCancelledError); expect(started.error.cause).toBeInstanceOf(AnalysisCancelledError); expect(started.error.cause).not.toBe(callerReason); expect(started.error.cleanup).toEqual({ reason: "held fake cleanup remains uncertain", resources: started.error.cleanupResources, }); expect(started.error.cleanupIncomplete).toBe(true); } expect(stopCalls).toBeGreaterThanOrEqual(1); expect(fake.children[0]?.exitCode).toBeNull(); await closing; expect(stopCalls).toBeGreaterThanOrEqual(2); expect(fake.children[0]?.exitCode).toBeNull(); }); it("classifies cancellation while asynchronous GDB ownership preparation is pending", async () => { const controller = new AbortController(); let entered!: () => void; const spawnEntered = new Promise((resolve) => { entered = resolve; }); const manager = new GdbSessionManager({ platform: "linux", environment: {}, spawnProcess: async (options) => new Promise((_resolve, reject) => { entered(); options.signal?.addEventListener( "abort", () => reject(options.signal?.reason), { 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: "�", 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", "�"], 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((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 | 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((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 | 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((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("closes an idle session when its quoted executable contains groups=[", 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=[{id="i1",executable="fixture/groups=[.out"}]\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"); }, 10_000); 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; }; 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, options: ConstructorParameters[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 => { 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[0] = {}, ): Promise => { 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(); const reason = new Error("caller cancelled before start"); cancelled.abort(reason); const startResult = await new GdbSessionManager({ platform: "linux" }).start( cancelled.signal, ); expect(startResult.ok).toBe(false); if (!startResult.ok) { expect(startResult.error._tag).toBe("AnalysisCancelledError"); expect(startResult.error.cause).toBe(reason); } const nullReasonController = new AbortController(); nullReasonController.abort(null); const nullReasonResult = await new GdbSessionManager({ platform: "linux", }).start(nullReasonController.signal); expect(nullReasonResult.ok).toBe(false); if (!nullReasonResult.ok) expect(nullReasonResult.error.cause).toBeNull(); 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.each([ ["required auto-load configuration", "auto-load"], ["caller-selected inferior TTY", "tty"], ["late startup cancellation", "cancel"], ] as const)( "keeps an incompletely stopped failed startup unavailable and retriable after %s", async (_label, failure) => { const controller = new AbortController(); const commands: string[] = []; let stopCalls = 0; const fake = createFakeSpawner((child, token, command) => { commands.push(command); if (failure === "auto-load" && command.includes("auto-load off")) { child.stdout.write(`${token}^error,msg="setting rejected"\n`); } else if (failure === "tty" && command.includes("inferior-tty-show")) { child.stdout.write( `${token}^done,inferior_tty_terminal="/dev/pts/8"\n`, ); } else { child.stdout.write(`${token}^done\n`); } // The MI response is complete, but the startup continuation has not yet // observed it. This exercises cancellation between, not during, requests. if (failure === "cancel" && command.includes("auto-load off")) controller.abort(); }); const manager = managerWithFakeSpawner(fake.spawnProcess, { stopProcess: async (session, name) => { stopCalls += 1; return stopCalls === 1 ? { status: "incomplete", reason: "simulated startup stop uncertainty", } : stopOwnedDebuggerProcess(session, name); }, }); const started = await manager.start( failure === "cancel" ? controller.signal : undefined, failure === "tty" ? { inferiorTty: "/dev/pts/7" } : {}, ); expect(started.ok).toBe(false); if (started.ok) return; expect(started.error._tag).toBe( failure === "cancel" ? "AnalysisCancelledError" : "AnalysisCapabilityUnavailableError", ); expect(started.error.cause).toBeDefined(); expect(started.error.partialObservation).toMatchObject({ predicate_type: "rea.debugger.startup-observation", }); expect(started.error.cleanup).toMatchObject({ reason: "simulated startup stop uncertainty", }); expect(started.error.cleanupIncomplete).toBe(true); const [sessionId] = started.error.cleanupResources; if (sessionId === undefined) throw new Error("Failed startup did not retain its GDB session ID."); expect(sessionId).toMatch(/^[0-9a-f-]{36}$/u); expect(started.error.cleanup?.resources).toEqual([sessionId]); expect(stopCalls).toBe(1); expect(fake.children[0]?.exitCode).toBeNull(); expect(manager.status(sessionId)?.state).toBe("closed"); const command = await manager.execute(sessionId, "show version"); expect(command).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" }, }); expect(commands).not.toContain('-interpreter-exec console "show version"'); expect(fake.children[0]?.exitCode).toBeNull(); expect((await manager.retryCleanupFailures()).ok).toBe(true); expect(stopCalls).toBe(2); expect(started.error.cleanupIncomplete).toBe(false); expect(manager.status(sessionId)).toBeUndefined(); expect(fake.children[0]?.exitCode).toBeNull(); expect(fake.children[0]?.signalCode).toBe("SIGTERM"); }, ); 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; }; 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) => { 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((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(); const queueReason = "queue cancelled before admission"; preAborted.abort(queueReason); await expect( waitForGdbCommandTurn(Promise.resolve(), preAborted.signal), ).rejects.toMatchObject({ _tag: "AnalysisCancelledError", cause: queueReason, }); let release!: () => void; const pending = new Promise((resolve) => { release = resolve; }); const controller = new AbortController(); const activeQueueReason = { source: "active queue cancellation" }; const cancelledWait = waitForGdbCommandTurn(pending, controller.signal); controller.abort(activeQueueReason); await expect(cancelledWait).rejects.toMatchObject({ _tag: "AnalysisCancelledError", cause: activeQueueReason, }); 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.each([ ["malformed recursive list", "groups=[}]"], ["malformed result suffix", "groups=[],extra=}"], ["malformed leading result", "extra=},groups=[]"], [ "duplicate inventory with a live PID after an empty list", 'groups=[],groups=[{id="i1",pid="123"}]', ], ] as const)( "retains ownership when recursive MI cannot establish one safe inventory: %s", async (_label, groups) => { 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(commands).not.toContain("-gdb-exit"); 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((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"); const parsed = parseLinuxProcessStat(stat); capturedStartTime = parsed?.pid === Number(observedPid) ? parsed.startTime : undefined; } 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"); const parsed = parseLinuxProcessStat(stat); return parsed?.pid === Number(observedPid) ? parsed.startTime : 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((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((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((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 => { 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("retains caller cancellation and cleanup authority after active-command stop rejects", async () => { const controller = new AbortController(); const reason = { source: "console caller cancellation" }; const cleanupFailure = new AnalysisTimeoutError("foreign_cleanup", 17); let stopCalls = 0; const fake = createFakeSpawner((child, token, command) => { if (command.includes("auto-load off")) child.stdout.write(`${token}^done\n`); else controller.abort(reason); }); const manager = managerWithFakeSpawner(fake.spawnProcess, { stopProcess: async (session, name) => { stopCalls += 1; if (stopCalls === 1) throw cleanupFailure; 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(cancelled.error.cause).toBe(reason); expect(cancelled.error.cleanup).toEqual({ reason: expect.stringContaining("foreign_cleanup"), resources: [started.value.session_id], }); expect(cancelled.error.cleanupIncomplete).toBe(true); } expect(stopCalls).toBe(1); expect((await manager.retryCleanupFailures()).ok).toBe(false); if (!cancelled.ok) expect(cancelled.error.cleanupIncomplete).toBe(true); expect(stopCalls).toBe(2); }); 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(); const reason = { source: "confirmed active command cancellation" }; 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(reason); const cancelled = await active; expect(cancelled).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError", operation: "gdb_console" }, }); if (!cancelled.ok) { expect(cancelled.error.cleanup).toBeUndefined(); expect(cancelled.error.cause).toBe(reason); } }); 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) => { 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("16777216 byte or 16384 record cap"), ]), }); } }); 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("�"); 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", ); } });