fix(gdb): wait for the outer console command to finish

After a running result, queue a tokenized read-only MI query to establish that GDB has returned to its command loop. Keep collecting through intermediate inferior stops and retain the query reply as protocol Evidence.

Fixes #6660
This commit is contained in:
morluto committed 2026-10-23 19:18:47 +08:00
1 parent 86a0428d5f
commit 5bb01ca9a3
3 files changed
+173 -47

No files matched your search

+9
View File
@@ -35,6 +35,15 @@ Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_t
Persistent GDB sessions, including the MCP workflow and one-shot `rea debug-with-gdb` CLI, are available only on Linux. REA verifies a live inferior's output descriptors through `/proc` before detaching it; it does not claim equivalent descriptor verification on macOS. Windows is also unsupported because owned Job Object cleanup can terminate debugger inferiors. On supported Linux hosts, `start_gdb_session`, `gdb_console`, `gdb_session_status`, and `close_gdb_session` manage a persistent GDB/MI session in MCP. The one-shot CLI requires a caller-selected durable TTY: `rea debug-with-gdb --inferior-tty /dev/pts/7 'file ./program'`. The TTY must remain available after GDB exits so a detached inferior can continue writing. MCP starts GDB with `--nx --quiet --interpreter=mi3`, retries initialization with MI2 if MI3 initialization returns an error and cleanup succeeds, waits for its prompt, and disables auto-loading. Pass optional `inferior_tty` to select the terminal that future inferior runs use; REA configures it before the session becomes ready and checks the exact value with GDB/MI. `gdb_session_status` reports the selected path, whether REA still considers the configuration verified, and whether invalid UTF-8 has been observed in MI bytes (`mi_invalid_utf8`) or stderr (`stderr_invalid_utf8`). Commands that can change the inferior TTY or redirect a run revoke that verification permanently for the session, so close retains GDB ownership if a live inferior remains. Closing a session with one live inferior and a verified TTY automatically detaches the target, which can resume execution; MCP clients receive destructive-operation annotations for this behavior. The console remains unrestricted and can run shell commands, change target state, access files, and use network in the REA process user's security context. Raw MI records and console/target/log streams are returned with Evidence; command output is retained up to 16 MiB and explicitly marked when truncated. GDB startup waits 10000 ms and each MI command waits 120000 ms. This release has no runtime override for those deadlines, the 16 MiB command output budget, session capacity, or the pending-command queue. `command_timeout_ms` does not change them.
`completion_status` describes completion of the selected console command, not
termination of its inferior. After a `^running` result, REA queues a read-only
`-gdb-show mi-async` query and waits for its tokenized reply before releasing the
command's output collector. The ordered `records` include that query reply;
`mi` retains the console command's result. An inferior `*stopped` event or an
early compatibility prompt does not establish that an outer command file has
finished. See GDB's [MI output syntax](https://sourceware.org/gdb/current/onlinedocs/gdb.html/GDB_002fMI-Output-Syntax.html)
and [command-file semantics](https://sourceware.org/gdb/current/onlinedocs/gdb.html/Command-Files.html).
Before a normal close, REA checks for live GDB thread groups. If one remains and a durable `inferior_tty` is verified, REA asks GDB to detach it and confirms the detach before stopping the debugger. Without that sink, select and apply a disposition to each inferior through `gdb_console` (for example, detach or kill as appropriate), then retry close. REA never infers target ownership from a PID or path. When the MCP server shuts down, REA terminates only its directly owned GDB process after confirming no inferior or a sink-backed detach; otherwise it retains the process and its output streams for retry.
```text
+48 -46
View File
@@ -107,7 +107,7 @@ interface PendingCommand {
outputTruncated: boolean;
outputInvalidUtf8: boolean;
terminalRecord: string | undefined;
awaitingInferiorStop: boolean;
completionToken: number | undefined;
readonly resolve: (result: GdbCommandResult) => void;
readonly reject: (error: Error) => void;
readonly cleanup: () => void;
@@ -1635,45 +1635,44 @@ export class GdbSessionManager {
pending.outputInvalidUtf8 ||= invalidUtf8;
} else pending.outputTruncated = true;
}
if (line.startsWith("*stopped")) {
for (const [token, pending] of session.pending) {
if (!pending.awaitingInferiorStop) continue;
clearTimeout(pending.timer);
session.pending.delete(token);
pending.cleanup();
pending.resolve({
command: "",
mi: pending.terminalRecord ?? "",
...snapshotGdbCommandRecords(pending.lines),
output_truncated: pending.outputTruncated,
...(pending.outputInvalidUtf8
? { output_invalid_utf8: true as const }
: {}),
completion_status: "complete",
});
}
continue;
}
const match = /^(\d+)\^(done|running|connected|error|exit)(?:,|$)/u.exec(
line,
);
if (match === null) continue;
const token = Number(match[1]);
const pending = session.pending.get(token);
const pending =
session.pending.get(token) ??
[...session.pending.values()].find(
(command) => command.completionToken === token,
);
if (pending === undefined) continue;
if (match[2] === "running") {
if (token === pending.completionToken) {
if (match[2] !== "done") {
this.#failProtocol(session, "GDB command completion query failed.");
continue;
}
} else {
pending.terminalRecord = line;
pending.awaitingInferiorStop = true;
continue;
if (match[2] === "running" && pending.completionToken === undefined) {
// ^running and *stopped describe inferior execution, not completion
// of an outer CLI command such as a multi-run command file. GDB
// processes this read-only query after returning to its command loop.
pending.completionToken = session.nextToken++;
this.#writeMiRequest(
session,
pending.completionToken,
"-gdb-show mi-async",
);
}
if (pending.completionToken !== undefined) continue;
}
clearTimeout(pending.timer);
session.pending.delete(token);
session.pending.delete(pending.token);
pending.cleanup();
pending.resolve({
command: "",
mi: line,
mi: pending.terminalRecord ?? line,
...snapshotGdbCommandRecords(pending.lines),
output_truncated: pending.outputTruncated,
...(pending.outputInvalidUtf8
@@ -2085,7 +2084,7 @@ export class GdbSessionManager {
outputTruncated: false,
outputInvalidUtf8: false,
terminalRecord: undefined,
awaitingInferiorStop: false,
completionToken: undefined,
};
session.pending.set(token, pending);
abortListener = (): void => {
@@ -2174,26 +2173,29 @@ export class GdbSessionManager {
abortListener();
return;
}
try {
session.launched.process.stdin?.write(
`${token}${command}\n`,
"utf8",
(cause?: Error | null) => {
if (cause)
this.#failProtocol(
session,
`GDB stdin write failed: ${cause.message}`,
);
},
);
} catch (cause) {
this.#failProtocol(
session,
`GDB stdin write failed: ${cause instanceof Error ? cause.message : "unknown error"}`,
);
}
this.#writeMiRequest(session, token, command);
}).then((result) => ({ ...result, command }));
}
#writeMiRequest(session: GdbSession, token: number, command: string): void {
try {
session.launched.process.stdin?.write(
`${token}${command}\n`,
"utf8",
(cause?: Error | null) => {
if (cause)
this.#failProtocol(
session,
`GDB stdin write failed: ${cause.message}`,
);
},
);
} catch (cause) {
this.#failProtocol(
session,
`GDB stdin write failed: ${cause instanceof Error ? cause.message : "unknown error"}`,
);
}
}
}
const miQuote = (value: string): string =>
@@ -2149,10 +2149,125 @@ it("waits for the prior command and preserves cancellation and queue failures",
).rejects.toBe(queueFailure);
});
it("retains a multi-run console command through its completion query before dispatching a follower", async () => {
let completionToken = "";
let sourceToken = "";
const commands: string[] = [];
const fake = createFakeSpawner((child, token, command) => {
commands.push(command);
if (command.includes("source commands.gdb")) {
sourceToken = token;
child.stdout.write(
`${token}^running\n*running,thread-id="all"\n(gdb) \n`,
);
child.stdout.write('*stopped,reason="exited-normally"\n');
} else if (command === "-gdb-show mi-async") completionToken = token;
else if (command.includes("REA_NEXT_COMMAND"))
child.stdout.write(`~"REA_NEXT_COMMAND\\n"\n${token}^done\n`);
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);
let settled = false;
const source = manager.execute(
started.value.session_id,
"source commands.gdb",
);
void source.then(() => {
settled = true;
});
const follower = manager.execute(
started.value.session_id,
"echo REA_NEXT_COMMAND\\n",
);
await expect.poll(() => completionToken).not.toBe("");
expect(settled).toBe(false);
expect(commands.some((command) => command.includes("REA_NEXT_COMMAND"))).toBe(
false,
);
const child = fake.children[0]!;
child.stdout.write(
'*running,thread-id="all"\n*stopped,reason="exited-normally"\n',
);
child.stdout.write('~"REA_SOURCE_TAIL\\n"\n(gdb) \n');
expect(settled).toBe(false);
child.stdout.write(`${completionToken}^done,value="off"\n(gdb) \n`);
const result = await source;
if (!result.ok) throw result.error;
expect(result.value.result).toMatchObject({
mi: `${sourceToken}^running`,
completion_status: "complete",
output_truncated: false,
console: '~"REA_SOURCE_TAIL\\n"',
});
expect(
result.value.result.records.filter((record) =>
record.startsWith("*stopped"),
),
).toHaveLength(2);
expect(result.value.result.records).toContain(
`${completionToken}^done,value="off"`,
);
expect(result.value.evidence.normalized_result).toMatchObject({
console: result.value.result.console,
});
const next = await follower;
if (!next.ok) throw next.error;
expect(next.value.result.console).toBe('~"REA_NEXT_COMMAND\\n"');
expect(
next.value.result.records.some((record) => record.startsWith("*stopped")),
).toBe(false);
expect(next.value.evidence.normalized_result).toMatchObject({
console: next.value.result.console,
});
});
it("retains unknown completion and partial Evidence when the completion query fails", async () => {
const fake = createFakeSpawner((child, token, command) => {
if (command.includes("source commands.gdb"))
child.stdout.write(
`${token}^running\n*stopped,reason="exited-normally"\n~"REA_SOURCE_TAIL\\n"\n`,
);
else if (command === "-gdb-show mi-async")
child.stdout.write(`${token}^error,msg="completion query failed"\n`);
else child.stdout.write(`${token}^done\n`);
});
const manager = managerWithFakeSpawner(fake.spawnProcess, {
stopProcess: async () => ({ status: "stopped" }),
});
const started = await expectGdbSessionStarted(manager);
const result = await manager.execute(
started.value.session_id,
"source commands.gdb",
);
if (result.ok) throw new Error("Expected unavailable command completion");
expect(result.error).toMatchObject({
_tag: "AnalysisCapabilityUnavailableError",
partialObservation: {
normalized_result: {
completion_status: "unknown",
console: '~"REA_SOURCE_TAIL\\n"',
},
},
});
expect(
await manager.execute(started.value.session_id, "show version"),
).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError" },
});
});
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');
if (match[2].includes('auto-load off') || match[2] === '-gdb-show mi-async') 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');