mirror of
https://github.com/morluto/rea.git
synced 2026-11-08 07:46:28 +00:00
* test: focus assertions on behavioral evidence Prune redundant and wording-coupled checks while retaining diagnostic, parser, recovery, and cleanup coverage. * refactor: remove test-only wrappers and duplicate checks Exercise production operations through test fixtures, remove unused adapters, and keep managed-member uncertainty guidance under its existing owner. * test: focus process validation on diagnostic facts * refactor: remove test-only runtime helpers Move semantic evidence fixture setup out of runtime and remove unused Windows settle recovery helpers while checking configured defaults independently. * test: focus artifact diagnostics on format facts * test: verify retained cleanup guidance delivery
433 lines
14 KiB
TypeScript
433 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 => {
|
|
const output = JSON.parse(result.stdout) as {
|
|
remediation: { action: string };
|
|
};
|
|
expect(output).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(output.remediation.action, 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.
|
|
const guidance = () =>
|
|
stderr()
|
|
.split("\n")
|
|
.find((line) => line === retainedDebuggerRemediation([pid]));
|
|
await expect
|
|
.poll(guidance, { timeout: EXIT_DEADLINE_MS })
|
|
.toBeDefined();
|
|
expectNamesProcess(guidance()!, 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);
|
|
},
|
|
);
|
|
},
|
|
);
|