From 5bb01ca9a3a15df12b52b871fd28181b10f427ce Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:18:39 +0800 Subject: [PATCH] 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 --- docs/reverse-engineering-tools.md | 9 ++ src/gdb/GdbSessionManager.ts | 94 +++++++------- .../GdbSessionManagerCoverage.test.ts | 117 +++++++++++++++++- 3 files changed, 173 insertions(+), 47 deletions(-) diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index c54b389a4f..32dab608e8 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -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 diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index d33a42f1fc..fab1e7730d 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -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 => diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index 4ae0fe20e6..17c2159137 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -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');