Files
reverse-engineering-anything/tests/boundary/providers/RizinDebugSessionCoverage.part1.test.ts
T
4aa51bfe19 fix(reverse): reconcile cleanup ownership and mutation diagnostics (#2799)
* 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>
2026-10-14 04:47:34 +08:00

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);
},
);