From 4812843e5b4b6bde49fe29bd4c7cd5ce91e509c2 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:18:18 +0800 Subject: [PATCH] fix(gdb): preserve MI framing and reject lossy result facts (#9982) --- src/gdb/GdbSessionManager.ts | 17 +++- .../GdbSessionManagerCoverage.test.ts | 95 +++++++++++++++++++ 2 files changed, 107 insertions(+), 5 deletions(-) diff --git a/src/gdb/GdbSessionManager.ts b/src/gdb/GdbSessionManager.ts index 5e20b1d339..bca7d16c45 100644 --- a/src/gdb/GdbSessionManager.ts +++ b/src/gdb/GdbSessionManager.ts @@ -1685,6 +1685,7 @@ export class GdbSessionManager { const lines: { readonly value: string; readonly invalidUtf8: boolean }[] = []; let offset = 0; + let framingFailure: string | undefined; while (offset < data.byteLength) { if (session.discardingOversizedStreamRecord) { const newline = data.indexOf(0x0a, offset); @@ -1721,11 +1722,8 @@ export class GdbSessionManager { 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.`, - ); - return; + framingFailure = `GDB emitted an MI line exceeding the ${String(MAX_MI_LINE_BYTES)} byte (16 MiB) protocol limit. This release has no runtime override.`; + break; } this.#appendLineBytes(session, piece); if (newline < 0) break; @@ -1801,6 +1799,13 @@ export class GdbSessionManager { line, ); if (match === null) continue; + if (invalidUtf8) { + this.#failProtocol( + session, + "GDB emitted an MI result record containing invalid UTF-8.", + ); + return; + } const token = Number(match[1]); const pending = session.pending.get(token) ?? @@ -1847,6 +1852,8 @@ export class GdbSessionManager { completion_status: "complete", }); } + if (framingFailure !== undefined) + this.#failProtocol(session, framingFailure); } #appendLineBytes(session: GdbSession, piece: Buffer): void { diff --git a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts index 72a289f624..25d8ffe4d2 100644 --- a/tests/boundary/providers/GdbSessionManagerCoverage.test.ts +++ b/tests/boundary/providers/GdbSessionManagerCoverage.test.ts @@ -3606,6 +3606,101 @@ process.stdin.on('data', (chunk) => { } }); +it.each([false, true])( + "retains completed MI observations before a later framing failure (coalesced=%s)", + async (coalesced) => { + const prefix = '~"completed observation"\n'; + const oversized = "x".repeat(16 * 1024 * 1024 + 1) + "\n"; + const fake = createFakeSpawner((child, token, command) => { + if (command === "-gdb-set auto-load off") { + child.stdout.write(`${token}^done\n`); + return; + } + if (coalesced) child.stdout.write(prefix + oversized); + else { + child.stdout.write(prefix); + child.stdout.write(oversized); + } + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => { + for (const child of fake.children) child.exit(0, null); + return { status: "stopped" }; + }, + }); + const started = await expectGdbSessionStarted(manager); + const failed = await manager.execute( + started.value.session_id, + "fixture-only", + ); + expect(failed).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCapabilityUnavailableError", + partialObservation: { + normalized_result: { + records: ['~"completed observation"'], + console: '~"completed observation"', + completion_status: "unknown", + }, + }, + }, + }); + }, +); + +it.each(["literal", "octal", "invalid-result", "invalid-stream"] as const)( + "requires valid UTF-8 for TTY facts while retaining opaque stream data (%s)", + async (encoding) => { + const tty = "/fixture/terminal-\uFFFD"; + const fake = createFakeSpawner((child, token, command) => { + if (command === "-inferior-tty-show") { + if (encoding === "invalid-stream") + child.stdout.write(Buffer.from([0x7e, 0x22, 0xff, 0x22, 0x0a])); + const suffix = + encoding === "invalid-result" + ? Buffer.from([0xff]) + : encoding === "octal" + ? Buffer.from(String.raw`\357\277\275`) + : Buffer.from("\uFFFD"); + child.stdout.write( + Buffer.concat([ + Buffer.from( + `${token}^done,inferior_tty_terminal="/fixture/terminal-`, + ), + suffix, + Buffer.from('"\n'), + ]), + ); + return; + } + child.stdout.write( + command.includes("list-thread-groups") + ? `${token}^done,groups=[]\n` + : `${token}^done\n`, + ); + }); + const manager = managerWithFakeSpawner(fake.spawnProcess, { + stopProcess: async () => { + for (const child of fake.children) child.exit(0, null); + return { status: "stopped" }; + }, + }); + const started = await manager.start(undefined, { inferiorTty: tty }); + expect(started.ok).toBe(encoding !== "invalid-result"); + if (started.ok) + expect(manager.status(started.value.session_id)).toMatchObject({ + inferior_tty: tty, + inferior_tty_verified: true, + mi_invalid_utf8: encoding === "invalid-stream", + }); + else + expect(started.error.partialObservation).toMatchObject({ + normalized_result: { output_invalid_utf8: true }, + }); + }, +); + it("closes the GDB session when a complete MI record exceeds the protocol limit", async () => { const manager = await managerFor(` process.stdout.write('(gdb)\\n');