diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index 10e7ab43cc..58fb66f0e7 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -46,7 +46,7 @@ Captured stdout and stderr are retained up to a 16 MiB combined limit; `output_t ## GDB -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. The complete built-in inspections `show inferior-tty` and `help inferior-tty` preserve verification; compound commands still use the conservative mutation checks. 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. +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. The complete built-in inspections `show inferior-tty` and `help inferior-tty` preserve verification; compound commands still use the conservative mutation checks. 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. A single console, target or log record over the 16 MiB MI line limit is dropped and the command is marked truncated, while an oversized result or async record still stops the session. 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. With multiple live inferiors, or a live inferior without a verified TTY, manual close requests an explicit disposition and keeps the session ready. Use diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index 8eb8c3d7fb..23b2372e75 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -520,6 +520,7 @@ interface GdbSession { bufferTail: Buffer | undefined; bufferTailBytes: number; bufferBytes: number; + discardingOversizedStreamRecord: boolean; invalidUtf8Observed: boolean; promptTail: string; ready: boolean; @@ -881,6 +882,7 @@ export class GdbSessionManager { bufferTail: undefined, bufferTailBytes: 0, bufferBytes: 0, + discardingOversizedStreamRecord: false, invalidUtf8Observed: false, promptTail: "", ready: false, @@ -1663,6 +1665,13 @@ export class GdbSessionManager { []; let offset = 0; while (offset < data.byteLength) { + if (session.discardingOversizedStreamRecord) { + const newline = data.indexOf(0x0a, offset); + if (newline < 0) break; + session.discardingOversizedStreamRecord = false; + offset = newline + 1; + continue; + } const newline = data.indexOf(0x0a, offset); const piece = data.subarray( offset, @@ -1672,6 +1681,25 @@ export class GdbSessionManager { const lineBytes = session.bufferBytes - (newline >= 0 && piece.at(-1) === 0x0d ? 1 : 0); if (lineBytes > MAX_MI_LINE_BYTES) { + const firstByte = + session.bufferChunks[0]?.at(0) ?? + session.bufferTail?.at(0) ?? + piece.at(0); + if (firstByte === 0x7e || firstByte === 0x40 || firstByte === 0x26) { + session.bufferChunks.length = 0; + session.bufferTail = undefined; + session.bufferTailBytes = 0; + session.bufferBytes = 0; + for (const pending of session.pending.values()) + pending.outputTruncated = true; + session.recentRecordsTruncated = true; + if (newline < 0) { + session.discardingOversizedStreamRecord = true; + break; + } + offset = newline + 1; + continue; + } this.#failProtocol( session, `GDB emitted an MI line exceeding the ${String(MAX_MI_LINE_BYTES)} byte (16 MiB) protocol limit. This release has no runtime override.`, diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index 5ddd2026ed..5d0adde808 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -3633,6 +3633,85 @@ process.stdin.on('data', (chunk) => { expect(manager.status(started.value.session_id)?.state).toBe("closed"); }); +it("keeps the session usable after an oversized stream record is dropped", async () => { + const fake = createFakeSpawner((child, token, command) => { + if (command === "-gdb-set auto-load off") { + child.stdout.write(`${token}^done\n`); + return; + } + if (command === '-interpreter-exec console "big-stream"') { + child.stdout.write('~"before"\n~"' + "x".repeat(16 * 1024 * 1024 + 1)); + child.stdout.write('"\n' + token + "^done\n(gdb)\n"); + return; + } + if (command === '-interpreter-exec console "after-drop"') { + child.stdout.write(token + "^done\n(gdb)\n"); + return; + } + if (command === "-list-thread-groups --recurse 1") { + child.stdout.write(`${token}^done,groups=[]\n`); + return; + } + if (command === "-gdb-exit") { + child.stdout.write(`${token}^done\n`); + return; + } + child.stdout.write(token + '^error,msg="unmodeled request"\n'); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess); + const started = await expectGdbSessionStarted(manager); + + const executed = await manager.execute( + started.value.session_id, + "big-stream", + ); + + expect(executed.ok).toBe(true); + if (executed.ok) { + expect(executed.value.result.mi).toMatch(/^\d+\^done$/); + expect(executed.value.result.console).toContain("before"); + expect(executed.value.result.completion_status).toBe("complete"); + expect(executed.value.result.output_truncated).toBe(true); + expect(executed.value.evidence.limitations.join(" ")).toContain( + "Retained MI records reached the 16777216 byte or 16384 record cap. This release has no runtime override. Output completeness is unknown.", + ); + } + const second = await manager.execute(started.value.session_id, "after-drop"); + expect(second.ok).toBe(true); + if (second.ok) expect(second.value.result.mi).toMatch(/^\d+\^done$/); + await expect(manager.close(started.value.session_id)).resolves.toMatchObject({ + ok: true, + }); +}); + +it("still stops the session for an oversized result record", 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] === '-gdb-set auto-load off') process.stdout.write(match[1] + '^done\\n'); + else if (match[2] === '-interpreter-exec console "bad-value"') process.stdout.write(match[1] + '^done,value="' + 'x'.repeat(16 * 1024 * 1024 + 1) + '"\\n'); + else process.stdout.write(match[1] + '^error,msg="unmodeled request"\\n'); + } +}); +`); + const started = await expectGdbSessionStarted(manager); + + const failed = await manager.execute(started.value.session_id, "bad-value"); + + 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(`