mirror of
https://github.com/morluto/rea.git
synced 2026-10-18 20:10:13 -05:00
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:
1 parent
270bc38f72
commit
847472acf5
6 files changed
+182
-26
No files matched your search
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -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=}"],
|
||||
|
||||
Reference in new issue
Block a user