mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 14:56:32 +00:00
* feat(process): add owned debugger shutdown lifecycle * feat(reverse): add local inspection commands * feat(gdb): add persistent debugger integration * feat(cutter): add Cutter bridge integration * test(reverse): enforce complete integration coverage * fix(process): preserve debugger shutdown ownership * fix(reverse): retain evidence across interrupted commands * fix(gdb): honor captured provider environment * test(process): cover debugger shutdown retries * fix(reverse): retain evidence for interrupted providers * test(process): cover debugger shutdown retries * fix(reverse): retain evidence for interrupted providers * fix(server): snapshot provider environment at creation * fix(reverse): close review gaps with complete coverage * fix(reverse): address inspection review findings * fix(gdb): bound sessions and retain cleanup ownership * fix(ci): require reverse-tools quality lane * fix(process): unref inherited debugger output pipes * fix(reverse): address remaining PR 1960 findings * fix(gdb): address remaining PR 1961 findings * fix(reverse): account for unrestricted provider execution * fix(process): retain debugger ownership until safe shutdown * fix(process): preserve replacement debugger sessions * fix(reverse): preserve admission and output provenance * fix(process): retain exited debugger until output settles * fix(mcp): bound provider admission and debugger cleanup * fix(reverse): preserve failure provenance and classify E2BIG * fix(gdb): preserve startup cleanup and detach safety * fix(process): retain debugger ownership after detach failure * fix(reverse): preserve mutation and failure provenance * fix(gdb): address debugger review findings * fix(reverse): rebind sessions after external mutation * fix(process): cap debugger output after exit * fix(gdb): protect one-shot detach verification * feat(debugger): add reverse tool coverage gate * test(debugger): cover integrated failure paths * fix(gdb): bound and preserve session observations * fix(gdb): address review regressions * style: format merged integration files * fix(reverse): preserve session and output evidence * refactor(gdb): share repeated test and partial result setup * fix(gdb): invalidate TTY proof for Python aliases * fix(reverse): retain failure output byte counts * test(process): synchronize late debugger output * fix(gdb): invalidate TTY proof for wrapped commands * fix(gdb): recognize wit command abbreviation * test(reverse): wait for cancelled descendant readiness * test(reverse): bound descendant cancellation cleanup * style(reverse): format integration tests and adapters * style: format reverse engineering integration files * test(reverse): remove duplicate environment harness * test(gdb): share session startup assertions * fix(gdb): invalidate output proof for command abbreviations * test(reverse): share composition edge case scenarios * fix(ci): recognize verification and mutation tool entries * test(rizin): cover successful fake inferior detach * test(cli): align full output failure envelope * test(cli): isolate reverse command lifecycle service * test(process): share debugger output fixture setup * fix(binary): preserve high-bit Windows signature bytes * style(binary): format Windows signature parser * test(mcp): preserve call coverage in unchanged comparison * fix(ci): include reverse tools Vitest configs in lint cache * fix(mcp): preserve target content on delivery errors * ci: align duplication ceiling with expanded suite * test(reverse): allow signal admission mutants * fix(reverse): bound retained cleanup failures to process owners * fix(test): replace drifting mutation ranges with owned selectors * fix(reverse): admit persistent commands and retain cleanup ownership * fix(cutter): recover timed-out commands from exact receipts * fix(cutter): reject unsupported bridge request kinds * fix(ci): repair client format and shared verification fixtures * fix(native): preserve debugger cleanup ownership and bridge authentication * test(native): retain diagnostics without perfect coverage requirements * chore(licenses): refresh locked dependency inventory and notices * fix(test): exclude transient command locks from mutation sandboxes * fix(gdb): admit only platforms with safe detach verification * fix(cutter): retain bounded completion receipts until acknowledgement * fix(cutter): acknowledge commands rejected before UI dispatch * test(reverse): fail cancellation checks before fixture teardown * test(gdb): clean up the retained broken-stdin fixture owner * docs(test): clarify focused provider verification scope * fix(rizin): retain close cleanup failures * fix(gdb): wait for inferior stop and bound MI buffering * fix(cutter): retain failed receipt acknowledgements * fix(rizin): return retained session identity on rebind failure * fix(gdb): preserve startup cancellation and lossy status * fix(rizin): retain launch provenance and assemble frames once * fix(cutter): recover receipts across callers and bound discovery * docs: describe debugger evidence and bridge recovery * fix(process): keep cleanup incomplete while writers remain Stopping a debugger no longer reports success when inherited output streams stay open, so BinarySession cannot reopen a binary a child is still changing. Concurrent Cutter cleanup retries share one receipt acknowledgement instead of treating a consumed receipt as failure. * fix(debuggers): recover cleanup with captured process identities * fix(analysis): retire sessions after unrestricted commands * fix(cli): preserve completed evidence after cleanup recovery * fix(cutter): bound frame collection and coalesce cleanup * fix(windows): resolve selected provider paths absolutely * fix(gdb): report decoding loss in failed startup evidence * fix(rizin): scope decoding loss to collected command frames * test(mcp): cover snapshot retirement in reverse tools CI --------- Co-authored-by: Daniel Gonçalves Araujo <47118053+dandgabr@users.noreply.github.com> Co-authored-by: REA Maintainers <noreply@github.com> Co-authored-by: OpenAI Codex <codex@users.noreply.github.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
423 lines
13 KiB
TypeScript
423 lines
13 KiB
TypeScript
import { afterEach, expect, it, vi } from "vitest";
|
|
import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js";
|
|
import {
|
|
createRizinTestManager,
|
|
stopFakeNoInferior,
|
|
rizinTestInferiorTty,
|
|
} from "./RizinTestInferiorTty.js";
|
|
import { rizinCommand } from "../../../src/rizin/RizinCommand.js";
|
|
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
|
import {
|
|
createRizinCoverageHarness,
|
|
createInjectedProcess,
|
|
abortOnCheck,
|
|
incompleteStop,
|
|
} from "./RizinDebugSessionCoverage.fixtures.js";
|
|
|
|
const {
|
|
cleanup,
|
|
makeProvider,
|
|
missingProviderPath,
|
|
start,
|
|
startInjected,
|
|
track,
|
|
} = createRizinCoverageHarness();
|
|
afterEach(cleanup);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"builds the one-shot command with the caller's path and command",
|
|
() => {
|
|
expect(rizinCommand({ path: "./sample file", command: "izz" })).toEqual([
|
|
"-N",
|
|
"-q",
|
|
"-c",
|
|
"izz",
|
|
expect.stringMatching(/sample file/u),
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"rejects unsupported, pre-cancelled, and failed startup requests",
|
|
async () => {
|
|
const windows = createRizinTestManager({}, { platform: "win32" });
|
|
expect(
|
|
(
|
|
await windows.start({
|
|
path: "/tmp/a",
|
|
inferior_tty: "unused-before-platform-admission",
|
|
})
|
|
).ok,
|
|
).toBe(false);
|
|
const aborted = new AbortController();
|
|
aborted.abort();
|
|
expect(
|
|
(
|
|
await windows.start(
|
|
{ path: "/tmp/a", inferior_tty: await rizinTestInferiorTty() },
|
|
aborted.signal,
|
|
)
|
|
).ok,
|
|
).toBe(false);
|
|
|
|
const failed = await makeProvider("process.exit(9);");
|
|
const manager = createRizinTestManager({
|
|
REA_RIZIN_COMMAND: failed,
|
|
});
|
|
track(manager);
|
|
const result = await manager.start({
|
|
path: "/tmp/a",
|
|
inferior_tty: await rizinTestInferiorTty(),
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
|
|
const missing = createRizinTestManager({
|
|
REA_RIZIN_COMMAND: missingProviderPath(),
|
|
});
|
|
track(missing);
|
|
const spawnFailure = await missing.start({
|
|
path: "/tmp/a",
|
|
inferior_tty: await rizinTestInferiorTty(),
|
|
});
|
|
expect(spawnFailure.ok).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"returns a startup timeout when Rizin exits before its initial frame",
|
|
async () => {
|
|
const provider = await makeProvider("process.exit(0);");
|
|
const manager = createRizinTestManager({
|
|
REA_RIZIN_COMMAND: provider,
|
|
});
|
|
track(manager);
|
|
const result = await manager.start({
|
|
path: "/tmp/early-exit",
|
|
inferior_tty: await rizinTestInferiorTty(),
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"times out startup through the frame wait when Rizin never emits a frame",
|
|
async () => {
|
|
let resolveSpawned!: () => void;
|
|
const spawned = new Promise<void>((resolve) => {
|
|
resolveSpawned = resolve;
|
|
});
|
|
const injected = createInjectedProcess({
|
|
startup: () => undefined,
|
|
beforeSpawnReturns: resolveSpawned,
|
|
});
|
|
const manager = createRizinTestManager(
|
|
{ REA_RIZIN_COMMAND: "injected-rizin" },
|
|
{
|
|
spawnProcess: injected.spawnProcess,
|
|
stopProcess: async () => ({ status: "stopped" }),
|
|
},
|
|
);
|
|
track(manager);
|
|
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
|
|
try {
|
|
const starting = manager.start({
|
|
path: "/tmp/no-startup-frame",
|
|
inferior_tty: await rizinTestInferiorTty(),
|
|
});
|
|
await spawned;
|
|
await vi.advanceTimersByTimeAsync(30_001);
|
|
const result = await starting;
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) expect(result.error.message).toContain("timed out");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"does not spawn Rizin when cancellation arrives during TTY validation",
|
|
async () => {
|
|
let spawned = false;
|
|
const manager = createRizinTestManager(
|
|
{ REA_RIZIN_COMMAND: "injected-rizin" },
|
|
{
|
|
spawnProcess: async () => {
|
|
spawned = true;
|
|
throw new Error("must not spawn after cancellation");
|
|
},
|
|
},
|
|
);
|
|
const result = await manager.start(
|
|
{
|
|
path: "/tmp/cancel-before-spawn",
|
|
inferior_tty: await rizinTestInferiorTty(),
|
|
},
|
|
abortOnCheck(2),
|
|
);
|
|
expect(result.ok).toBe(false);
|
|
expect(spawned).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"rejects a non-TTY path before spawning Rizin",
|
|
async () => {
|
|
let spawned = false;
|
|
const manager = createRizinTestManager(
|
|
{ REA_RIZIN_COMMAND: "injected-rizin" },
|
|
{
|
|
spawnProcess: async () => {
|
|
spawned = true;
|
|
throw new Error("must not spawn with an invalid TTY");
|
|
},
|
|
},
|
|
);
|
|
const result = await manager.start({
|
|
path: "/tmp/not-a-tty-target",
|
|
inferior_tty: "/dev/null",
|
|
});
|
|
expect(result.ok).toBe(false);
|
|
expect(spawned).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"closeAll drains concurrent startups and blocks later admission",
|
|
async () => {
|
|
let spawnCount = 0;
|
|
let resolveSpawnCalls!: () => void;
|
|
const bothSpawnsCalled = new Promise<void>((resolve) => {
|
|
resolveSpawnCalls = resolve;
|
|
});
|
|
let releaseSpawns!: () => void;
|
|
const spawnGate = new Promise<void>((resolve) => {
|
|
releaseSpawns = resolve;
|
|
});
|
|
const injected = createInjectedProcess();
|
|
const manager = createRizinTestManager(
|
|
{ REA_RIZIN_COMMAND: "injected-rizin" },
|
|
{
|
|
spawnProcess: async (options) => {
|
|
spawnCount += 1;
|
|
if (spawnCount === 2) resolveSpawnCalls();
|
|
await spawnGate;
|
|
return injected.spawnProcess(options);
|
|
},
|
|
stopProcess: async () => ({ status: "stopped" }),
|
|
},
|
|
);
|
|
const tty = await rizinTestInferiorTty();
|
|
const starts = [
|
|
manager.start({ path: "/tmp/start-one", inferior_tty: tty }),
|
|
manager.start({ path: "/tmp/start-two", inferior_tty: tty }),
|
|
];
|
|
await bothSpawnsCalled;
|
|
let closeAllResolved = false;
|
|
const closing = manager.closeAll().then(() => {
|
|
closeAllResolved = true;
|
|
});
|
|
await Promise.resolve();
|
|
expect(closeAllResolved).toBe(false);
|
|
releaseSpawns();
|
|
const started = await Promise.all(starts);
|
|
expect(started.every((result) => result.ok)).toBe(true);
|
|
await closing;
|
|
expect(
|
|
manager.status(started[0]!.ok ? started[0]!.value.session_id : "missing"),
|
|
).toBeUndefined();
|
|
expect(
|
|
(await manager.start({ path: "/tmp/after-close-all", inferior_tty: tty }))
|
|
.ok,
|
|
).toBe(false);
|
|
expect((await manager.close("missing")).ok).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"rejects an oversized initial frame instead of marking the session ready",
|
|
async () => {
|
|
const provider = await makeProvider(
|
|
'process.stdout.write("x".repeat(16 * 1024 * 1024 + 1) + "\\0"); process.stdin.resume();',
|
|
);
|
|
const manager = createRizinTestManager({
|
|
REA_RIZIN_COMMAND: provider,
|
|
});
|
|
track(manager);
|
|
|
|
const result = await manager.start({
|
|
path: "/tmp/oversized-startup",
|
|
inferior_tty: await rizinTestInferiorTty(),
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"cancels startup while waiting for the initial NUL frame",
|
|
async () => {
|
|
let resolveSpawned!: () => void;
|
|
const spawned = new Promise<void>((resolve) => {
|
|
resolveSpawned = resolve;
|
|
});
|
|
const provider = await makeProvider(
|
|
'setTimeout(() => process.stdout.write("\\0"), 500); process.stdin.resume();',
|
|
);
|
|
const manager = createRizinTestManager(
|
|
{
|
|
REA_RIZIN_COMMAND: provider,
|
|
},
|
|
{
|
|
spawnProcess: async (options) => {
|
|
const launched = await spawnOwnedProviderProcess(options);
|
|
resolveSpawned();
|
|
return launched;
|
|
},
|
|
},
|
|
);
|
|
track(manager);
|
|
const abort = new AbortController();
|
|
const starting = manager.start(
|
|
{ path: "/tmp/cancel-start", inferior_tty: await rizinTestInferiorTty() },
|
|
abort.signal,
|
|
);
|
|
await spawned;
|
|
abort.abort();
|
|
expect((await starting).ok).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"preserves startup cleanup uncertainty after cancellation",
|
|
async () => {
|
|
let resolveSpawned!: () => void;
|
|
const spawned = new Promise<void>((resolve) => {
|
|
resolveSpawned = resolve;
|
|
});
|
|
const provider = await makeProvider(
|
|
"process.stdin.resume(); setInterval(() => {}, 1000);",
|
|
);
|
|
const manager = createRizinTestManager(
|
|
{ REA_RIZIN_COMMAND: provider },
|
|
{
|
|
stopProcess: incompleteStop,
|
|
spawnProcess: async (options) => {
|
|
const launched = await spawnOwnedProviderProcess(options);
|
|
resolveSpawned();
|
|
return launched;
|
|
},
|
|
},
|
|
);
|
|
track(manager);
|
|
const abort = new AbortController();
|
|
const starting = manager.start(
|
|
{ path: "/tmp/cancel-start", inferior_tty: await rizinTestInferiorTty() },
|
|
abort.signal,
|
|
);
|
|
await spawned;
|
|
abort.abort();
|
|
const result = await starting;
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok)
|
|
expect(result.error.message).toContain("injected stop uncertainty");
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"rejects commands for missing and pre-cancelled sessions",
|
|
async () => {
|
|
const provider = await makeProvider(
|
|
'process.stdout.write("\\0"); process.stdin.resume();',
|
|
);
|
|
const { manager, sessionId } = await start(provider);
|
|
expect((await manager.execute("missing", "dr")).ok).toBe(false);
|
|
const aborted = new AbortController();
|
|
aborted.abort();
|
|
expect((await manager.execute(sessionId, "dr", aborted.signal)).ok).toBe(
|
|
false,
|
|
);
|
|
expect(manager.status(sessionId)?.state).toBe("ready");
|
|
expect((await manager.close("missing")).ok).toBe(false);
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"retains close failures and resolves their owner after cleanup retry",
|
|
async () => {
|
|
const provider = await makeProvider(
|
|
'process.stdout.write("\\0"); process.stdin.resume();',
|
|
);
|
|
let stopCalls = 0;
|
|
const { manager, sessionId } = await start(provider, {
|
|
stopProcess: async (session, name, options) => {
|
|
stopCalls += 1;
|
|
if (stopCalls === 1) {
|
|
session.launched.process.kill("SIGTERM");
|
|
return {
|
|
status: "incomplete",
|
|
reason: "first stop result is uncertain",
|
|
};
|
|
}
|
|
await session.supervisor.waitForExit(1000);
|
|
return stopFakeNoInferior(session, name, options);
|
|
},
|
|
});
|
|
const closed = await manager.close(sessionId);
|
|
expect(closed.ok).toBe(false);
|
|
if (!closed.ok) {
|
|
expect(closed.error.cleanupIncomplete).toBe(true);
|
|
expect(closed.error.cleanup?.resources).toEqual([sessionId]);
|
|
const retainedFailure = closed.error;
|
|
expect(await manager.retryCleanupFailures()).toEqual({
|
|
ok: true,
|
|
value: null,
|
|
});
|
|
expect(retainedFailure.cleanupIncomplete).toBe(false);
|
|
}
|
|
expect(stopCalls).toBe(2);
|
|
expect(manager.status(sessionId)).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"retains a Rizin close owner until inherited output writers close",
|
|
async () => {
|
|
const injected = createInjectedProcess();
|
|
const { manager, started } = await startInjected(injected.spawnProcess);
|
|
expect(started.ok).toBe(true);
|
|
if (!started.ok) return;
|
|
const sessionId = started.value.session_id;
|
|
injected.control.exitLeader();
|
|
|
|
const firstClose = await manager.close(sessionId);
|
|
expect(firstClose.ok).toBe(false);
|
|
if (!firstClose.ok) {
|
|
expect(firstClose.error.cleanupIncomplete).toBe(true);
|
|
expect(firstClose.error.message).toMatch(/inherited output streams/i);
|
|
}
|
|
expect(manager.status(sessionId)).toBeDefined();
|
|
|
|
injected.control.closeOutput();
|
|
const retried = await manager.retryCleanupFailures();
|
|
|
|
expect(retried).toEqual({ ok: true, value: null });
|
|
expect(manager.status(sessionId)).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === "win32")(
|
|
"closes a session after unsolicited frames make command correlation unsafe",
|
|
async () => {
|
|
const provider = await makeProvider(
|
|
'process.stdout.write("\\0"); setTimeout(() => process.stdout.write("unexpected\\0"), 20); process.stdin.resume();',
|
|
);
|
|
const { manager, sessionId } = await start(provider);
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
const result = await manager.execute(sessionId, "dr");
|
|
expect(result.ok).toBe(false);
|
|
expect(manager.status(sessionId)?.state).toBe("closed");
|
|
expect((await manager.close(sessionId)).ok).toBe(true);
|
|
},
|
|
);
|