mirror of
https://github.com/morluto/rea.git
synced 2026-10-28 10:25:42 +00:00
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>
This commit is contained in:
168 files changed
+32703
-238
No files matched your search
@@ -1,13 +1,49 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { access, readFile, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
import { execFileBytes, execFileOutput } from "./ExecFileOutput.js";
|
||||
import {
|
||||
execFileBytes,
|
||||
execFileOutput,
|
||||
execFileOutputFailure,
|
||||
} from "./ExecFileOutput.js";
|
||||
|
||||
const waitForFile = async (path: string): Promise<boolean> => {
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
if (
|
||||
await access(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
)
|
||||
return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
return access(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
};
|
||||
|
||||
describe("execFileOutput", () => {
|
||||
it("ignores non-Error and incomplete failure metadata", () => {
|
||||
expect(execFileOutputFailure("failure")).toBeUndefined();
|
||||
expect(execFileOutputFailure(new Error("failure"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recognizes the execFile output limit code as truncated output", () => {
|
||||
const failure = Object.assign(new Error("output limit"), {
|
||||
stdout: "partial stdout",
|
||||
stderr: "partial stderr",
|
||||
code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER",
|
||||
});
|
||||
|
||||
expect(execFileOutputFailure(failure)?.outputTruncated).toBe(true);
|
||||
});
|
||||
|
||||
it("preserves captured bytes for callers that own the output format", async () => {
|
||||
const result = await execFileBytes(process.execPath, [
|
||||
"-e",
|
||||
@@ -18,6 +54,20 @@ describe("execFileOutput", () => {
|
||||
expect(result.stderr).toEqual(Buffer.from([0xfe]));
|
||||
});
|
||||
|
||||
it("reports raw byte counts when UTF-8 decoding changes the captured text", async () => {
|
||||
const result = await execFileOutput(process.execPath, [
|
||||
"-e",
|
||||
"process.stdout.write(Buffer.from([0x61, 0xff])); process.stderr.write(Buffer.from([0xe2, 0x82, 0xac]));",
|
||||
]);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stdout: "a�",
|
||||
stderr: "€",
|
||||
stdoutBytes: 2,
|
||||
stderrBytes: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("captures output larger than Node's default execFile limit", async () => {
|
||||
const marker = "tail-marker";
|
||||
const result = await execFileOutput(process.execPath, [
|
||||
@@ -48,6 +98,14 @@ describe("execFileOutput", () => {
|
||||
).rejects.toMatchObject({ code: 7, stdout: "out", stderr: "err" });
|
||||
});
|
||||
|
||||
it("uses the built-in execFile timeout when no stop signal is requested", async () => {
|
||||
await expect(
|
||||
execFileOutput(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
|
||||
timeout: 100,
|
||||
}),
|
||||
).rejects.toMatchObject({ killed: true });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"stops an aborted process with the caller's signal and settles after it exits",
|
||||
async () => {
|
||||
@@ -75,25 +133,48 @@ describe("execFileOutput", () => {
|
||||
),
|
||||
}),
|
||||
);
|
||||
for (let attempt = 0; attempt < 500; attempt += 1) {
|
||||
if (
|
||||
await access(ready).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
)
|
||||
break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
const readyObserved = await waitForFile(ready);
|
||||
controller.abort();
|
||||
const outcome = await execution;
|
||||
|
||||
expect(readyObserved).toBe(true);
|
||||
expect(outcome?.cause).toMatchObject({ name: "AbortError" });
|
||||
expect(outcome?.stoppedBeforeSettling).toBe("SIGINT");
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"keeps the first stop reason when timeout arrives during abort cleanup",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-exec-double-stop-");
|
||||
const ready = join(directory, "ready");
|
||||
const script = [
|
||||
'const fs = require("node:fs");',
|
||||
'for (const name of ["SIGINT", "SIGTERM"]) process.on(name, () => {});',
|
||||
`fs.writeFileSync(${JSON.stringify(ready)}, "");`,
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n");
|
||||
const controller = new AbortController();
|
||||
const execution = execFileOutput(process.execPath, ["-e", script], {
|
||||
signal: controller.signal,
|
||||
timeout: 10_000,
|
||||
stopSignal: "SIGINT",
|
||||
});
|
||||
const startedAt = Date.now();
|
||||
expect(await waitForFile(ready)).toBe(true);
|
||||
const remainingBeforeDeadline = Math.max(
|
||||
0,
|
||||
9_000 - (Date.now() - startedAt),
|
||||
);
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, remainingBeforeDeadline),
|
||||
);
|
||||
controller.abort();
|
||||
|
||||
await expect(execution).rejects.toMatchObject({ name: "AbortError" });
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an already aborted stoppable command without starting it", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
@@ -149,6 +230,33 @@ describe.skipIf(process.platform === "win32")(
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a child failure's missing signal when reporting timeout", async () => {
|
||||
const directory = await createTestTempDirectory(
|
||||
"rea-exec-missing-signal-",
|
||||
);
|
||||
const ready = join(directory, "ready");
|
||||
const script = [
|
||||
'const fs = require("node:fs");',
|
||||
'for (const name of ["SIGINT", "SIGTERM"])',
|
||||
" process.on(name, () => process.exit(1));",
|
||||
`fs.writeFileSync(${JSON.stringify(ready)}, "");`,
|
||||
"setInterval(() => {}, 1000);",
|
||||
].join("\n");
|
||||
try {
|
||||
const execution = execFileOutput(process.execPath, ["-e", script], {
|
||||
timeout: 10_000,
|
||||
stopSignal: "SIGINT",
|
||||
});
|
||||
expect(await waitForFile(ready)).toBe(true);
|
||||
await expect(execution).rejects.toMatchObject({
|
||||
code: "ETIMEDOUT",
|
||||
signal: null,
|
||||
});
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("kills a timed-out process that ignores the stop signal", async () => {
|
||||
const script = [
|
||||
'for (const name of ["SIGINT", "SIGTERM"]) process.on(name, () => {});',
|
||||
@@ -168,3 +276,40 @@ describe.skipIf(process.platform === "win32")(
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("execFileOutput failure metadata", () => {
|
||||
it("preserves raw byte counts on a failed command with invalid UTF-8", async () => {
|
||||
const failure: unknown = await execFileOutput(process.execPath, [
|
||||
"-e",
|
||||
"process.stdout.write(Buffer.from([0x61, 0xff])); process.stderr.write(Buffer.from([0xe2, 0x82, 0xac])); process.exitCode = 7;",
|
||||
]).then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
);
|
||||
|
||||
expect(execFileOutputFailure(failure)).toMatchObject({
|
||||
stdout: "a�",
|
||||
stderr: "€",
|
||||
stdoutBytes: 2,
|
||||
stderrBytes: 3,
|
||||
code: 7,
|
||||
});
|
||||
});
|
||||
it("preserves explicit truncation metadata from an owned process failure", () => {
|
||||
const failure = Object.assign(new Error("owned output was truncated"), {
|
||||
stdout: "partial stdout",
|
||||
stderr: "partial stderr",
|
||||
code: 7,
|
||||
outputTruncated: true,
|
||||
});
|
||||
|
||||
expect(execFileOutputFailure(failure)).toEqual({
|
||||
stdout: "partial stdout",
|
||||
stderr: "partial stderr",
|
||||
code: 7,
|
||||
signal: null,
|
||||
killed: false,
|
||||
outputTruncated: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user