fix(gdb): report close disposition as a capability precondition

Adapt #4732 using canonical provider identity and explicit producer recovery. Keep the valid session retained for caller-selected disposition and preserve default capability error behavior. Refs #4653.

Co-authored-by: Luigi Pinna <luigipinna3@gmail.com>
This commit is contained in:
morlutoandLuigi Pinna committed 2026-10-18 10:03:39 +08:00
1 parent 270bc38f72
commit 847472acf5
6 files changed
+182 -26

No files matched your search

+3
View File
@@ -124,6 +124,7 @@ export class AnalysisOutputError extends AnalysisError {
export class AnalysisCapabilityUnavailableError extends AnalysisError {
readonly _tag = "AnalysisCapabilityUnavailableError";
override readonly userMessage: string | undefined;
readonly remediationAction: string | undefined;
/** Incomplete cleanup must settle before the operation can be retried. */
readonly retryable: boolean;
@@ -133,6 +134,7 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError {
readonly reason: string,
options?: AnalysisErrorOptions & {
readonly userMessage?: string;
readonly remediationAction?: string;
readonly retryable?: boolean;
},
) {
@@ -141,6 +143,7 @@ export class AnalysisCapabilityUnavailableError extends AnalysisError {
options,
);
this.userMessage = options?.userMessage;
this.remediationAction = options?.remediationAction;
this.retryable = options?.retryable === true && !this.cleanupIncomplete;
}
}
+2 -1
View File
@@ -43,7 +43,8 @@ export const analysisErrorRemediationAction = (
): string => {
error = resolvedAnalysisError(error);
if (
error instanceof ProviderAdapterError &&
(error instanceof ProviderAdapterError ||
error instanceof AnalysisCapabilityUnavailableError) &&
error.remediationAction !== undefined
)
return error.remediationAction;
@@ -866,3 +866,30 @@ it("retains the primary failure and both cleanup reports after a secondary failu
},
});
});
it.each([false, true])(
"preserves producer capability recovery with retryable=%s",
(retryable) => {
const recovery =
"Choose an explicit disposition, then retry the same session.";
const projected = projectAnalysisError(
new AnalysisCapabilityUnavailableError("fixture", "close", "live state", {
retryable,
remediationAction: recovery,
}),
);
expect(projected).toMatchObject({
code: "capability_unavailable",
retryable,
remediation: { action: recovery },
details: {
provider_id: "fixture",
operation: "close",
reason: "live state",
},
});
expect(analysisErrorProjectionSchema.safeParse(projected).success).toBe(
true,
);
},
);
+10 -6
View File
@@ -1316,14 +1316,18 @@ export class GdbSessionManager {
);
if (groupCount > 0 && !session.inferiorTtyVerified)
return err(
new AnalysisInputError("close_gdb_session", undefined, [
new AnalysisCapabilityUnavailableError(
GDB_PROVIDER_IDENTITY.id,
"close_gdb_session",
"Live GDB inferiors require an explicit disposition because no durable inferior TTY is verified.",
{
path: ["session_id"],
reason: "invalid_value",
message:
"Live GDB thread groups remain and no verified durable inferior TTY is configured. Select a safe disposition through the GDB console or restart with inferior_tty before closing.",
retryable: true,
userMessage:
"Close requires an explicit inferior disposition.",
remediationAction:
"Use gdb_console with this session_id to explicitly detach or terminate each remaining inferior, then retry close_gdb_session.",
},
]),
),
);
if (groupCount === 0) {
session.noInferiorVerified = true;
@@ -0,0 +1,140 @@
import { randomUUID } from "node:crypto";
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { McpServer } from "@modelcontextprotocol/server";
import { expect, it } from "vitest";
import { toolContract } from "../../../src/contracts/toolContracts.js";
import { GdbSessionManager } from "../../../src/gdb/GdbSessionManager.js";
import { silentLogger } from "../../../src/logger.js";
import { registerGdbTools } from "../../../src/server/registerGdbTools.js";
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
it.skipIf(process.platform === "win32").each(["kill", "detach"])(
"retains a valid GDB session through disposition refusal and explicit %s recovery",
async (disposition) => {
const directory = await mkdtemp(join(tmpdir(), "rea-gdb-disposition-"));
const provider = join(directory, "fake-gdb.mjs");
await writeFile(
provider,
`#!${process.execPath}
import { createInterface } from 'node:readline';
let live = false;
process.stdout.write('(gdb)\\n');
createInterface({ input: process.stdin }).on('line', line => {
const token = /^(\\d+)/.exec(line)?.[1];
if (!token) return;
const command = line.slice(token.length);
if (command === '-interpreter-exec console "run"') {
live = true;
process.stdout.write(token + '^running\\n*stopped,reason="breakpoint-hit",thread-id="1"\\n(gdb)\\n');
return;
}
const disposition = command === '-interpreter-exec console "kill"' || command === '-interpreter-exec console "detach"';
if (disposition) live = false;
const result = command === '-list-thread-groups --recurse 1'
? (live ? 'done,groups=[{id="i1",type="process",pid="4242"}]' : 'done,groups=[]')
: disposition || command === '-gdb-set auto-load off' || command === '-gdb-exit'
? 'done' : 'error,msg="Unmodeled fixture command"';
process.stdout.write(token + '^' + result + '\\n(gdb)\\n', () => {
if (line.includes('-gdb-exit')) process.exit(0);
});
});
`,
);
await chmod(provider, 0o700);
const manager = new GdbSessionManager({
platform: "linux",
environment: { REA_GDB_COMMAND: provider },
});
const server = new McpServer({
name: "gdb-disposition-test",
version: "1",
});
registerGdbTools(
server,
manager,
silentLogger,
createToolResultDelivery(undefined),
);
const client = new Client({ name: "gdb-disposition-test", version: "1" });
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
let sessionId: string | undefined;
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const started = await client.callTool({
name: "start_gdb_session",
arguments: {},
});
sessionId = toolContract("start_gdb_session").outputSchema.parse(
started.structuredContent,
).session_id;
const running = await client.callTool({
name: "gdb_console",
arguments: { session_id: sessionId, command: "run" },
});
expect(running.isError).not.toBe(true);
for (let attempt = 0; attempt < 2; attempt += 1) {
const refusal = await client.callTool({
name: "close_gdb_session",
arguments: { session_id: sessionId },
});
expect(refusal.isError).toBe(true);
expect(refusal.structuredContent).toMatchObject({
error: {
code: "capability_unavailable",
category: "unavailable",
retryable: true,
remediation: { action: expect.stringContaining("gdb_console") },
details: {
provider_id: "gnu.gdb",
operation: "close_gdb_session",
reason: expect.stringContaining("inferior"),
},
},
});
expect(JSON.stringify(refusal.structuredContent)).not.toContain(
"invalid_value",
);
expect(manager.status(sessionId)?.state).toBe("ready");
}
const disposed = await client.callTool({
name: "gdb_console",
arguments: { session_id: sessionId, command: disposition },
});
expect(disposed.isError).not.toBe(true);
const closed = await client.callTool({
name: "close_gdb_session",
arguments: { session_id: sessionId },
});
expect(closed.isError).not.toBe(true);
expect(closed.structuredContent).toMatchObject({
target_state: "none_observed",
});
expect(manager.status(sessionId)).toBeUndefined();
const unknown = await client.callTool({
name: "close_gdb_session",
arguments: { session_id: randomUUID() },
});
expect(unknown.structuredContent).toMatchObject({
error: { code: "invalid_request", category: "invalid_input" },
});
} finally {
if (sessionId !== undefined && manager.status(sessionId) !== undefined) {
const disposed = await manager.execute(sessionId, "kill");
if (!disposed.ok) throw disposed.error;
const closed = await manager.close(sessionId);
if (!closed.ok) throw closed.error;
}
await client.close();
await server.close();
await manager.closeAll();
await rm(directory, { recursive: true, force: true });
}
},
);
@@ -1752,25 +1752,6 @@ it("correlates command output records and reports invalid or cancelled requests"
expect(manager.status(started.value.session_id)).toBeDefined();
});
it("refuses to close a session while GDB reports a live target", 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] + '^done,groups=[{id="i1",pid="123"}]\\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(JSON.stringify(closed.error)).toContain(
"Live GDB thread groups remain",
);
});
it.each([
["malformed recursive list", "groups=[}]"],
["malformed result suffix", "groups=[],extra=}"],