mirror of
https://github.com/morluto/rea.git
synced 2026-11-05 06:15:59 +00:00
* fix(debugger): let REA exit when a debugger could not be stopped `rea debug-with-gdb` and `rea debug-with-rizin` printed cleanup_incomplete and set exit status 1, but the retained child and its pipes kept the event loop alive, so the process never exited. A stdio MCP server whose client disconnected hung the same way. closeOwnedDebuggerSessions is the owner both managers finish through. It now unreferences retained sessions, as supervised providers already do after incomplete cleanup, restores the references on retry, and names the retained process IDs. The one-shot commands report those IDs, since no later command can retry. Debugger ownership, signals and detach behavior are unchanged. GDB cleanup requests also no longer hold the host until their 120 s command deadline, because cleanup already bounds its own wait. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(debugger): name retained debuggers when an MCP server shuts down An MCP server that exits with a retained debugger logged only "REA provider cleanup failed" and "could not close cleanly", so nothing named the process left for the caller to check. The PIDs were on OwnedDebuggerCleanupError, but each shutdown reporter read only the outer aggregate's message, and the debug failure_cause description dropped the field too. The failure type now lives in its own dependency-free module with one reader that walks composed failures. The shutdown report, the server's onclose log, the debug description and the one-shot CLI all use it, and the shutdown report writes the same remediation the CLI returns. The fake debuggers now model each request exactly, with real reply shapes, and a request they do not model fails the test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(debugger): compare retained-debugger guidance with its formatter The new checks froze the wording "Check process ... and its target". docs/testing.md asks guidance checks to compare with the owning formatter, alongside independent checks of the facts. They now do, and each still proves the process ID is named, because a formatter comparison alone would pass if the formatter dropped it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
424 lines
14 KiB
TypeScript
424 lines
14 KiB
TypeScript
import { chmod, readFile, writeFile } from "node:fs/promises";
|
|
import { join, resolve } from "node:path";
|
|
|
|
import { describe, expect, onTestFinished } from "vitest";
|
|
|
|
import { MCP_SHUTDOWN_FAILED } from "../../../src/main/messages.js";
|
|
import { retainedDebuggerRemediation } from "../../../src/process/ownedDebuggerCleanupError.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
import {
|
|
cliProcessEnvironment,
|
|
cliTest,
|
|
} from "../../support/cli/cliFixture.js";
|
|
import type {
|
|
ProcessResult,
|
|
TestProcesses,
|
|
} from "../../support/process/processFixture.js";
|
|
import { rizinTestInferiorTty } from "../providers/RizinTestInferiorTty.js";
|
|
|
|
/**
|
|
* Fake debuggers behind the compiled public entrypoint. Real Rizin and GDB are
|
|
* not exercised: what they do once REA's end of their pipes closes is unverified.
|
|
* Every retained mode stays alive after stdin ends, like a debugger that ignores
|
|
* EOF, so a passing test also shows that REA did not stop what it reports retained.
|
|
* A request neither fake models is noted, and that note fails the test.
|
|
*/
|
|
const fakeDebuggerPrelude = `#!${process.execPath}
|
|
const { appendFileSync } = require("node:fs");
|
|
const note = (line) => appendFileSync(process.env.FAKE_DEBUGGER_LOG, line + "\\n");
|
|
const mode = process.env.FAKE_DEBUGGER_MODE;
|
|
note("pid " + process.pid);
|
|
process.on("SIGTERM", () => { note("sigterm"); process.exit(0); });
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.on("end", () => note("stdin-end"));
|
|
setInterval(() => {}, 1000);
|
|
const onLines = (receive) => {
|
|
let input = "";
|
|
process.stdin.on("data", (chunk) => {
|
|
input += chunk;
|
|
for (let end = input.indexOf("\\n"); end >= 0; end = input.indexOf("\\n")) {
|
|
receive(input.slice(0, end));
|
|
input = input.slice(end + 1);
|
|
}
|
|
});
|
|
};
|
|
`;
|
|
|
|
const fakeRizin = `${fakeDebuggerPrelude}
|
|
process.stdout.write("\\0");
|
|
let silent = false;
|
|
onLines((line) => {
|
|
if (line === "dr") process.stdout.write("rax=0x1234\\0");
|
|
else if (line === "olj") {
|
|
if (silent) return;
|
|
note("inventory-request");
|
|
if (mode === "unanswered") silent = true;
|
|
else process.stdout.write(mode === "clean" ? "[]\\0" : "not-json\\0");
|
|
} else if (/^!echo __REA_(COMMAND|CLEANUP)_COMPLETE_[0-9a-f]+__$/u.test(line)) {
|
|
if (!silent) process.stdout.write(line.slice(6) + "\\n\\0");
|
|
} else {
|
|
note("unmodeled " + line);
|
|
process.stdout.write("unmodeled\\0");
|
|
}
|
|
});
|
|
`;
|
|
|
|
const fakeGdb = `${fakeDebuggerPrelude}
|
|
process.stdout.write("(gdb)\\n");
|
|
let tty = "";
|
|
let inventoryRequests = 0;
|
|
let ttyReads = 0;
|
|
const liveGroup = '^done,groups=[{id="i1",type="process",pid="4194305",threads=[{id="1",state="stopped"}]}]';
|
|
onLines((line) => {
|
|
const [, token, request] = /^(\\d+)(.*)$/u.exec(line) ?? [];
|
|
const reply = (tail) => process.stdout.write(token + tail + "\\n(gdb)\\n");
|
|
if (request === "-gdb-set auto-load off") reply("^done");
|
|
else if (request?.startsWith("-inferior-tty-set ")) {
|
|
tty = JSON.parse(request.slice("-inferior-tty-set ".length));
|
|
reply("^done");
|
|
} else if (request === "-inferior-tty-show") {
|
|
ttyReads += 1;
|
|
reply('^done,inferior_tty_terminal="' + (ttyReads > 1 ? "/dev/null" : tty) + '"');
|
|
} else if (request === '-interpreter-exec console "show version"') {
|
|
process.stdout.write('~"GNU gdb (fake)\\\\n"\\n');
|
|
reply("^done");
|
|
} else if (request === "-list-thread-groups --recurse 1") {
|
|
inventoryRequests += 1;
|
|
if (mode === "clean") reply("^done,groups=[]");
|
|
else if (mode === "malformed") reply('^error,msg="unavailable"');
|
|
else if (inventoryRequests <= 2) reply(liveGroup);
|
|
else note("inventory-request");
|
|
} else if (request === "-gdb-exit") {
|
|
process.stdout.write(token + "^exit\\n");
|
|
process.exit(0);
|
|
} else {
|
|
note("unmodeled " + line);
|
|
if (token !== undefined) reply('^error,msg="unmodeled request"');
|
|
}
|
|
});
|
|
`;
|
|
|
|
const debuggers = {
|
|
rizin: {
|
|
environmentKey: "REA_RIZIN_COMMAND",
|
|
source: fakeRizin,
|
|
arguments: (tty: string) => [
|
|
"debug-with-rizin",
|
|
process.execPath,
|
|
"dr",
|
|
"--inferior-tty",
|
|
tty,
|
|
"--format",
|
|
"json",
|
|
],
|
|
operation: "rizin_debug_command",
|
|
startTool: {
|
|
name: "start_rizin_debug_session",
|
|
arguments: (tty: string) => ({
|
|
path: process.execPath,
|
|
inferior_tty: tty,
|
|
}),
|
|
},
|
|
},
|
|
gdb: {
|
|
environmentKey: "REA_GDB_COMMAND",
|
|
source: fakeGdb,
|
|
arguments: (tty: string) => [
|
|
"debug-with-gdb",
|
|
"show version",
|
|
"--inferior-tty",
|
|
tty,
|
|
"--format",
|
|
"json",
|
|
],
|
|
operation: "gdb_console",
|
|
startTool: {
|
|
name: "start_gdb_session",
|
|
arguments: (tty: string) => ({ inferior_tty: tty }),
|
|
},
|
|
},
|
|
} as const;
|
|
|
|
type DebuggerName = keyof typeof debuggers;
|
|
type FakeMode = "clean" | "malformed" | "unanswered";
|
|
|
|
const EXIT_DEADLINE_MS = 20_000;
|
|
const TEST_DEADLINE_MS = 2 * EXIT_DEADLINE_MS;
|
|
|
|
const isAlive = (pid: number): boolean => {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
/** Launch the compiled CLI against one fake debugger, on any POSIX host. */
|
|
const prepareLaunch = async (
|
|
name: DebuggerName,
|
|
mode: FakeMode,
|
|
compileCache: string,
|
|
) => {
|
|
const root = await createTestTempDirectory("rea-debugger-one-shot-exit-");
|
|
const log = join(root, "debugger.log");
|
|
await writeFile(log, "");
|
|
const provider = join(root, name);
|
|
await writeFile(provider, debuggers[name].source);
|
|
await chmod(provider, 0o755);
|
|
// Persistent debugger sessions are Linux-only behind this gate. These modes
|
|
// stop before any /proc read, so the same workflow also runs on macOS.
|
|
const linuxGate = join(root, "linux-gate.mjs");
|
|
await writeFile(
|
|
linuxGate,
|
|
'Object.defineProperty(process, "platform", { value: "linux" });\n',
|
|
);
|
|
const notes = async (): Promise<readonly string[]> =>
|
|
(await readFile(log, "utf8")).split("\n").filter(Boolean);
|
|
const debuggerPid = async (): Promise<number> => {
|
|
const line = (await notes()).find((note) => note.startsWith("pid "));
|
|
if (line === undefined) throw new Error("The fake debugger did not start");
|
|
return Number(line.slice("pid ".length));
|
|
};
|
|
onTestFinished(async () => {
|
|
const pid = await debuggerPid().catch(() => undefined);
|
|
if (pid !== undefined && isAlive(pid)) process.kill(pid, "SIGKILL");
|
|
expect(
|
|
(await notes()).filter((note) => note.startsWith("unmodeled ")),
|
|
).toEqual([]);
|
|
});
|
|
return {
|
|
notes,
|
|
debuggerPid,
|
|
command: [
|
|
"--import",
|
|
linuxGate,
|
|
resolve("scripts/rea.mjs"),
|
|
] as readonly string[],
|
|
env: cliProcessEnvironment(
|
|
{
|
|
[debuggers[name].environmentKey]: provider,
|
|
FAKE_DEBUGGER_LOG: log,
|
|
FAKE_DEBUGGER_MODE: mode,
|
|
},
|
|
compileCache,
|
|
),
|
|
};
|
|
};
|
|
|
|
/** Comparing with the formatter alone would still pass if it dropped the process. */
|
|
const expectNamesProcess = (guidance: string, pid: number): void => {
|
|
expect(guidance).toMatch(new RegExp(`\\b${String(pid)}\\b`, "u"));
|
|
};
|
|
|
|
const expectRetainedOutcome = (
|
|
name: DebuggerName,
|
|
result: ProcessResult,
|
|
pid: number,
|
|
): void => {
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
retryable: false,
|
|
remediation: { action: retainedDebuggerRemediation([pid]) },
|
|
details: {
|
|
operation: debuggers[name].operation,
|
|
cleanup: "incomplete",
|
|
partial_observation: {
|
|
operation: debuggers[name].operation,
|
|
normalized_result: { completion_status: "complete" },
|
|
},
|
|
},
|
|
});
|
|
expectNamesProcess(retainedDebuggerRemediation([pid]), pid);
|
|
expect(result.stdout).toContain(`"retained_debugger_pids":[${String(pid)}]`);
|
|
};
|
|
|
|
/** Serve one MCP stdio client that has started a debugger session. */
|
|
const startMcpSession = async (
|
|
processes: TestProcesses,
|
|
name: DebuggerName,
|
|
launch: Awaited<ReturnType<typeof prepareLaunch>>,
|
|
tty: string,
|
|
) => {
|
|
const child = processes.spawn(process.execPath, [...launch.command, "mcp"], {
|
|
env: launch.env,
|
|
});
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (chunk: Buffer) => (stdout += String(chunk)));
|
|
child.stderr.on("data", (chunk: Buffer) => (stderr += String(chunk)));
|
|
const request = async (
|
|
id: number,
|
|
method: string,
|
|
params: object,
|
|
): Promise<void> => {
|
|
child.stdin.write(
|
|
`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`,
|
|
);
|
|
await expect
|
|
.poll(() => stdout, { timeout: EXIT_DEADLINE_MS })
|
|
.toContain(`"id":${String(id)}`);
|
|
};
|
|
await request(1, "initialize", {
|
|
protocolVersion: "2025-06-18",
|
|
capabilities: {},
|
|
clientInfo: { name: "debugger-one-shot-exit", version: "1" },
|
|
});
|
|
child.stdin.write(
|
|
`${JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" })}\n`,
|
|
);
|
|
const { startTool } = debuggers[name];
|
|
await request(2, "tools/call", {
|
|
name: startTool.name,
|
|
arguments: startTool.arguments(tty),
|
|
});
|
|
expect(stdout).toContain("session_id");
|
|
return { child, stderr: () => stderr };
|
|
};
|
|
|
|
describe.skipIf(process.platform === "win32")(
|
|
"debugger processes that outlive their cleanup",
|
|
() => {
|
|
cliTest.for([
|
|
["rizin", "malformed"],
|
|
["rizin", "unanswered"],
|
|
["gdb", "malformed"],
|
|
["gdb", "unanswered"],
|
|
] as const)(
|
|
"ends one-shot %s with a retained debugger whose cleanup is %s",
|
|
{ timeout: TEST_DEADLINE_MS },
|
|
async ([name, mode], { processes, cliCompileCache }) => {
|
|
const tty = await rizinTestInferiorTty();
|
|
const launch = await prepareLaunch(name, mode, cliCompileCache);
|
|
|
|
const result = await processes.run(
|
|
process.execPath,
|
|
[...launch.command, ...debuggers[name].arguments(tty)],
|
|
{ env: launch.env, timeoutMs: EXIT_DEADLINE_MS },
|
|
);
|
|
|
|
expect(result).toMatchObject({ exitCode: 1, signal: null });
|
|
const pid = await launch.debuggerPid();
|
|
expectRetainedOutcome(name, result, pid);
|
|
// REA reports the debugger as retained and did not stop it. Exiting
|
|
// closes its command stream; that is the only thing it sees from REA.
|
|
await expect
|
|
.poll(launch.notes, { timeout: EXIT_DEADLINE_MS })
|
|
.toContain("stdin-end");
|
|
expect(await launch.notes()).not.toContain("sigterm");
|
|
expect(isAlive(pid)).toBe(true);
|
|
},
|
|
);
|
|
|
|
cliTest.for(["rizin", "gdb"] as const)(
|
|
"keeps a one-shot %s success and its exit status when cleanup is confirmed",
|
|
{ timeout: TEST_DEADLINE_MS },
|
|
async (name, { processes, cliCompileCache }) => {
|
|
const tty = await rizinTestInferiorTty();
|
|
const launch = await prepareLaunch(name, "clean", cliCompileCache);
|
|
|
|
const result = await processes.run(
|
|
process.execPath,
|
|
[...launch.command, ...debuggers[name].arguments(tty)],
|
|
{ env: launch.env, timeoutMs: EXIT_DEADLINE_MS },
|
|
);
|
|
|
|
expect(result).toMatchObject({ exitCode: 0, signal: null });
|
|
expect(JSON.parse(result.stdout)).toMatchObject({
|
|
operation: debuggers[name].operation,
|
|
normalized_result: { completion_status: "complete" },
|
|
});
|
|
expect(result.stdout).not.toContain("retained_debugger_pids");
|
|
const pid = await launch.debuggerPid();
|
|
await expect
|
|
.poll(() => isAlive(pid), { timeout: EXIT_DEADLINE_MS })
|
|
.toBe(false);
|
|
},
|
|
);
|
|
|
|
cliTest.for(["rizin", "gdb"] as const)(
|
|
"ends one-shot %s with its signal status when interrupted during final cleanup",
|
|
{ timeout: TEST_DEADLINE_MS },
|
|
async (name, { processes, cliCompileCache }) => {
|
|
const tty = await rizinTestInferiorTty();
|
|
const launch = await prepareLaunch(name, "unanswered", cliCompileCache);
|
|
|
|
const child = processes.spawn(
|
|
process.execPath,
|
|
[...launch.command, ...debuggers[name].arguments(tty)],
|
|
{ env: launch.env },
|
|
);
|
|
let stdout = "";
|
|
child.stdout.on("data", (chunk: Buffer) => (stdout += String(chunk)));
|
|
child.stdin.end();
|
|
// The unanswered request is issued by final cleanup, after the command.
|
|
await expect
|
|
.poll(launch.notes, { timeout: EXIT_DEADLINE_MS })
|
|
.toContain("inventory-request");
|
|
child.kill("SIGINT");
|
|
await expect
|
|
.poll(() => child.exitCode, { timeout: EXIT_DEADLINE_MS })
|
|
.toBe(130);
|
|
await expect
|
|
.poll(() => stdout, { timeout: EXIT_DEADLINE_MS })
|
|
.toContain("retained_debugger_pids");
|
|
|
|
const pid = await launch.debuggerPid();
|
|
expectRetainedOutcome(
|
|
name,
|
|
{ exitCode: 130, signal: null, stdout, stderr: "" },
|
|
pid,
|
|
);
|
|
expect(await launch.notes()).not.toContain("sigterm");
|
|
expect(isAlive(pid)).toBe(true);
|
|
},
|
|
);
|
|
|
|
cliTest.for([
|
|
["rizin", "client disconnect"],
|
|
["gdb", "client disconnect"],
|
|
["rizin", "SIGTERM"],
|
|
] as const)(
|
|
"ends the stdio MCP server holding a retained %s session on %s and names it",
|
|
{ timeout: TEST_DEADLINE_MS },
|
|
async ([name, trigger], { processes, cliCompileCache }) => {
|
|
const tty = await rizinTestInferiorTty();
|
|
const launch = await prepareLaunch(name, "malformed", cliCompileCache);
|
|
|
|
const { child, stderr } = await startMcpSession(
|
|
processes,
|
|
name,
|
|
launch,
|
|
tty,
|
|
);
|
|
|
|
if (trigger === "SIGTERM") child.kill("SIGTERM");
|
|
else child.stdin.end();
|
|
|
|
await expect
|
|
.poll(() => child.exitCode, { timeout: EXIT_DEADLINE_MS })
|
|
.toBe(1);
|
|
const pid = await launch.debuggerPid();
|
|
// Nothing else reports these once the server has exited.
|
|
await expect
|
|
.poll(stderr, { timeout: EXIT_DEADLINE_MS })
|
|
.toContain(`${retainedDebuggerRemediation([pid])}\n`);
|
|
expectNamesProcess(retainedDebuggerRemediation([pid]), pid);
|
|
const logs = stderr()
|
|
.split("\n")
|
|
.filter((line) => line.startsWith("{"))
|
|
.map((line): unknown => JSON.parse(line));
|
|
for (const msg of [
|
|
"Provider cleanup failed during server shutdown",
|
|
MCP_SHUTDOWN_FAILED,
|
|
])
|
|
expect(logs).toContainEqual(
|
|
expect.objectContaining({ msg, retained_debugger_pids: [pid] }),
|
|
);
|
|
expect(await launch.notes()).not.toContain("sigterm");
|
|
expect(isAlive(pid)).toBe(true);
|
|
},
|
|
);
|
|
},
|
|
);
|