mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
* refactor(errors): read cleanup completion from dependencies instead of pushing it to dependents AnalysisError.cleanupIncomplete becomes a getter over the error's own unresolved state and its bound dependencies. The WeakRef dependents graph and resolveCleanup's push propagation are removed, and subclasses mark their initial state through markCleanupIncomplete instead of overriding a public mutable field. * fix(errors): settle bound dependencies before binding more A composite whose dependencies had released but whose status was never read stayed incomplete when a later dependency was bound. * refactor(errors): let errors read a lease group's release instead of resolving them (#10759) AnalysisCleanupLeaseGroup no longer calls resolveCleanup() on its error. The error holds the group as a release claim and reads cleanupReleased when asked, so completion follows the lease owner without a push. * chore(verify): count resolveCleanup and bindCleanupDependencies calls so they only shrink (#10763) * refactor(errors): let errors read a lease group's release instead of resolving them AnalysisCleanupLeaseGroup no longer calls resolveCleanup() on its error. The error holds the group as a release claim and reads cleanupReleased when asked, so completion follows the lease owner without a push. * chore(verify): count resolveCleanup and bindCleanupDependencies calls so they only shrink * refactor(process): let worker cleanup errors read their root epoch's release OwnedWorkerRuntime's root epoch is now an AnalysisCleanupReleaseClaim. Errors bound to it read its release when asked instead of the epoch pushing resolveCleanup() through weak references, and a runtime without a retained root binds an already-released owner. Removes the OwnedWorkerCleanupFailure structural type and four resolveCleanup() pushes; the counted baseline shrinks accordingly. * refactor(pcap): let pcap cleanup errors read command and snapshot release (#10792) Retained pcap commands, retained snapshot failures and snapshot completion descriptors now own their release and errors read it: commands and snapshot failures implement AnalysisCleanupReleaseClaim, and a failed completion-descriptor close is retried through an AnalysisCleanupLease bound to its error. Removes four resolveCleanup() pushes; the counted baseline shrinks accordingly. * refactor(process): let Darwin helper cleanup errors read their owner's release Darwin helper cache and run-token reader root cleanup now record an AnalysisCleanupRelease that every failure reports; a successful retry confirms it. Replaces the first-failure anchor that later failures depended on and the resolveCleanup() push. Removes two resolveCleanup() and two bindCleanupDependencies() calls; both counted baselines shrink. * refactor(ghidra): let Ghidra cleanup errors read the client's and checkpoint's release GhidraClient and GhidraCheckpoint bind each cleanup failure to an AnalysisCleanupRelease and confirm it when a later close or retry releases everything, instead of keeping a set of failures and pushing resolveCleanup() to each. Removes two resolveCleanup() calls; the counted baseline shrinks. * refactor(process): let debugger session cleanup errors read a confirmed stop OwnedDebuggerSessionRegistry binds each retained gdb or rizin cleanup failure to the session's AnalysisCleanupRelease and confirms it when a stop is confirmed, keeping the latest failure for retry reporting, instead of pushing resolveCleanup() to a set of failures. Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(process): drop the debugger registry's unused closeOperation option Returning the latest retained failure removed the last reader of closeOperation; remove the option and the values gdb and rizin supplied. * refactor(server): let environment shutdown errors read a successful close serveEnvironment binds each shutdown failure to an AnalysisCleanupRelease that a later successful close confirms, instead of pushing resolveCleanup() to a set of failures. The state-file retry test now also requires the earlier report to read the release. Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(desktop): release retained screen-judge cleanup through its lease Each retained judge or screenshot cleanup owner is wrapped in an AnalysisCleanupLease bound to its failure; close() releases the lease, which records the release the failure reads, instead of closing the owner and pushing resolveCleanup(). Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(ida): let IDA close failures read the session's later close IdaSessionClient's close failure binds an owner that is both its resource owner and its release claim, read from the client's closed state, instead of keeping weak references and pushing resolveCleanup() after a successful retry. Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(javascript): let source-map codec cleanup errors read a later close (#10814) * refactor(javascript): let source-map codec cleanup errors read a later close OwnedCodec binds each close failure to an AnalysisCleanupRelease that a later successful close confirms, replacing the first-failure anchor that later failures depended on and the resolveCleanup() push. Removes one resolveCleanup() and one bindCleanupDependencies() call; both counted baselines shrink. * refactor(cutter): let pending Cutter command errors read the completion receipt (#10815) * refactor(cutter): let pending Cutter command errors read the completion receipt Each pending Cutter command keeps an AnalysisCleanupRelease bound to its timeout failure; the producer's terminal completion receipt confirms it instead of pushing resolveCleanup(). Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(application): let provider candidate cleanup errors read the candidate's close (#10817) * refactor(application): let provider candidate cleanup errors read the candidate's close AnalysisProviderRegistry keeps one AnalysisCleanupRelease per candidate whose resource close is unconfirmed; failures bind to it and a later confirmed close confirms it, instead of pushing resolveCleanup() to a set of failures. Removes one resolveCleanup() call; the counted baseline shrinks. * fix(errors): bind a cleanup release claim to an error at most once AnalysisError kept release claims in an array, so a provider whose retried close returned the same error bound the same claim again on every retry and each cleanupIncomplete read scanned the duplicates. Keep the claims in a set. * refactor(application): let reverse-tool cleanup errors read a verified stop (#10819) * refactor(application): let reverse-tool cleanup errors read a verified stop Each retained provider process group in ReverseEngineeringService keeps an AnalysisCleanupRelease; its command and retry failures bind to it and a later verified stop confirms it, instead of collecting failures and pushing resolveCleanup(). Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(sqlite): let SQLite worker and root cleanup errors read their release (#10821) Each retained SQLite snapshot cleanup records an AnalysisCleanupRelease for its worker and for its runtime root; the resource failures bind to them and a verified stop or root removal confirms them, instead of pushing resolveCleanup(). Composite failures keep reading those resource failures. Removes two resolveCleanup() calls; the counted baseline shrinks. * refactor(android): let JADX cleanup errors read their retained cleanup's release Each retained JADX cleanup records an AnalysisCleanupRelease for the admission failure's descriptor owners and one for the whole retained cleanup. The admission failure and every error the cleanup reports bind to them, and a confirmed close confirms them, instead of pushing resolveCleanup() through WeakRefs to reported errors. Removes two resolveCleanup() calls; the counted baseline shrinks. * refactor(application): let session close failures read the client's later close A retained session cleanup records an AnalysisCleanupRelease; unowned cleanup failures the client's close reported bind to it and the successful close confirms it, instead of the session collecting them and pushing resolveCleanup(). Failures that already follow their own owner keep reading it. Adds a regression for an unowned close failure. Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(desktop): let build-computer and agentenv cleanup errors read their owner's release Build-computer teardown, retained task readers, remote reader folders and agentenv solve sandboxes each record an AnalysisCleanupRelease that their bound failures read; a verified teardown or stop confirms it instead of pushing resolveCleanup() through WeakRef sets. Long-run job failures read whether every job they name has confirmed its stop, so a failure naming two jobs no longer reads complete when only one stops (regression added). WeakRef sets kept only where late facts (completion receipts, reader views) are still retained as diagnostics. Removes six resolveCleanup() calls; the counted baseline shrinks. * refactor(kuna): let source-map and Kuna cache cleanup errors read their owner's release (#10856) * refactor(browser): let late source-map fetch errors read their response's release A pending source-map fetch and a response cancellation each record an AnalysisCleanupRelease that their cancellation or deadline failure reads; releasing the late response or the reader lock confirms it instead of pushing resolveCleanup(). Removes two resolveCleanup() calls; the counted baseline shrinks. * refactor(kuna): let project-cache cleanup errors read each owner's release Each retained Kuna resource owner (process, staging, hash descriptor, retired file) records a release that the errors reporting it read; an error completes once every owner it reports confirms, and retirement that moves staging to a retained file forwards the staging owner's release to the file's. Replaces the WeakRef report list, its pruning and the resolveCleanup() push. Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(process): forbid resolveCleanup() pushes; native markers read their supervisor's release (#10857) * refactor(providers): let an attributed client failure read its source's completed cleanup Re-attributing a failure whose source cleanup already completed now binds a claim reading that source's release instead of pushing resolveCleanup() onto the new error; a pending source is still bound as a dependency. Resources stay as history, as before. Removes one resolveCleanup() call; the counted baseline shrinks. * refactor(process): let native command markers read their supervisor's release ProviderProcessSupervisor records an AnalysisCleanupRelease that a stop proving the process ended confirms. Native command and LLDB resolver cleanup markers bind to their supervisor's release, so a verified stop by either owner completes them, instead of each owner pushing resolveCleanup() onto the markers it tracks. Removes the last two resolveCleanup() calls outside the error base; the counted baseline is empty. * chore(verify): forbid resolveCleanup() pushes outside the error base Every production owner now confirms a release its errors read, so the counted baseline is empty. The guard becomes an outright ban with no baseline entry. * test(native): complete native command and LLDB markers through a direct supervisor stop * refactor(errors): complete an error's cleanup from its owner's record and every dependency (#10892) * refactor(errors): complete an error's cleanup from its owner's record and every dependency * refactor(errors): complete test errors through an owner release and delete resolveCleanup() (#10898) * refactor(android): compose JADX and Apktool cleanup failures through one shared owner (#10900) * refactor(android): compose JADX and Apktool cleanup failures through one shared owner * refactor(application): compose service cleanup failures through the shared owner (#10902)
732 lines
23 KiB
TypeScript
732 lines
23 KiB
TypeScript
import pino from "pino";
|
|
import { Cli, z } from "incur";
|
|
import { afterEach, expect, it, vi } from "vitest";
|
|
import { rm } from "node:fs/promises";
|
|
|
|
import { registerReverseEngineeringCommands } from "../../../src/cli/reverseEngineeringCommands.js";
|
|
import { CLI_COMMANDS } from "../../../src/cliCommandNames.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
AnalysisTimeoutError,
|
|
AnalysisOutputError,
|
|
} from "../../../src/domain/analysisErrorCore.js";
|
|
import {
|
|
createEvidence,
|
|
evidenceSchema,
|
|
} from "../../../src/domain/evidence.js";
|
|
import { err, ok } from "../../../src/domain/result.js";
|
|
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
|
import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js";
|
|
import { closeOwnedDebuggerSessions } from "../../../src/process/ownedDebuggerSession.js";
|
|
import { ReverseEngineeringService as CoreReverseEngineeringService } from "../../../src/application/reverse/ReverseEngineeringService.js";
|
|
import { createReverseEngineeringCommandPort } from "../../../src/composition/reverseEngineering.js";
|
|
import { OwnedCommandFailure } from "../../../src/process/OwnedCommand.js";
|
|
import type { ProviderProcessSupervisor } from "../../../src/process/ProviderProcess.js";
|
|
import {
|
|
createArtifact,
|
|
providerSnapshot,
|
|
} from "../../support/reverseEngineeringCompositionFixtures.js";
|
|
import type { CliInstance } from "../../../src/cli/types.js";
|
|
import type { ReverseEngineeringService } from "../../../src/application/reverse/ReverseEngineeringService.js";
|
|
import { releaseOwnedCleanup } from "../../support/cleanupRelease.js";
|
|
|
|
const originalExitCode = process.exitCode;
|
|
|
|
afterEach(() => {
|
|
process.exitCode = originalExitCode;
|
|
});
|
|
|
|
const observation = (completionStatus: "complete" | "unknown") =>
|
|
createEvidence(
|
|
undefined,
|
|
{ id: "gnu.objdump", name: "GNU objdump", version: null },
|
|
{
|
|
predicateType: "rea.reverse-engineering.command-observation",
|
|
operation: "inspect_with_objdump",
|
|
parameters: { operation: "file_headers" },
|
|
result: { completion_status: completionStatus },
|
|
rawResult: { completion_status: completionStatus },
|
|
},
|
|
);
|
|
|
|
const runObjdump = async (
|
|
service: Partial<ReverseEngineeringService>,
|
|
path = "/tmp/sample",
|
|
selection: {
|
|
operation?: string;
|
|
symbol?: string;
|
|
commandTimeoutMs?: number;
|
|
maxOutputBytes?: number;
|
|
} = {},
|
|
) => {
|
|
const commands = new Map<
|
|
string,
|
|
{
|
|
run(input: {
|
|
args: { path: string; operation: string };
|
|
options: {
|
|
followDebugLinks: boolean;
|
|
symbol?: string;
|
|
commandTimeoutMs?: number;
|
|
maxOutputBytes?: number;
|
|
};
|
|
}): Promise<unknown>;
|
|
}
|
|
>();
|
|
const cli = {
|
|
command: (
|
|
name: string,
|
|
definition: {
|
|
run(input: {
|
|
args: { path: string; operation: string };
|
|
options: {
|
|
followDebugLinks: boolean;
|
|
symbol?: string;
|
|
commandTimeoutMs?: number;
|
|
maxOutputBytes?: number;
|
|
};
|
|
}): Promise<unknown>;
|
|
},
|
|
) => {
|
|
commands.set(name, definition);
|
|
return cli;
|
|
},
|
|
} as unknown as CliInstance;
|
|
registerReverseEngineeringCommands(
|
|
cli,
|
|
pino({ enabled: false }),
|
|
{},
|
|
undefined,
|
|
() => service as ReverseEngineeringService,
|
|
);
|
|
const command = commands.get(CLI_COMMANDS.inspectWithObjdump);
|
|
if (command === undefined)
|
|
throw new Error("objdump CLI command was not registered");
|
|
return command.run({
|
|
args: { path, operation: selection.operation ?? "file_headers" },
|
|
options: {
|
|
followDebugLinks: false,
|
|
...(selection.symbol === undefined ? {} : { symbol: selection.symbol }),
|
|
...(selection.maxOutputBytes === undefined
|
|
? {}
|
|
: { maxOutputBytes: selection.maxOutputBytes }),
|
|
...(selection.commandTimeoutMs === undefined
|
|
? {}
|
|
: { commandTimeoutMs: selection.commandTimeoutMs }),
|
|
},
|
|
});
|
|
};
|
|
|
|
it("forwards the selected native symbol to the shared workflow", async () => {
|
|
const inspectWithObjdump = vi.fn(async (_input: unknown) =>
|
|
ok(observation("complete")),
|
|
);
|
|
await runObjdump(
|
|
{ inspectWithObjdump, close: async () => undefined },
|
|
"/tmp/sample",
|
|
{ operation: "disassemble", symbol: "_Z3foov@@VERSION_1" },
|
|
);
|
|
expect(inspectWithObjdump.mock.calls[0]?.[0]).toEqual({
|
|
path: "/tmp/sample",
|
|
operation: "disassemble",
|
|
follow_debug_links: false,
|
|
symbol: "_Z3foov@@VERSION_1",
|
|
});
|
|
});
|
|
|
|
it("forwards selected objdump resource budgets and preserves omission", async () => {
|
|
const inspectWithObjdump = vi.fn(async (_input: unknown) =>
|
|
ok(observation("complete")),
|
|
);
|
|
const service = { inspectWithObjdump, close: async () => undefined };
|
|
await runObjdump(service, "/tmp/sample", {
|
|
commandTimeoutMs: 5_000,
|
|
maxOutputBytes: 1234,
|
|
});
|
|
expect(inspectWithObjdump.mock.calls[0]?.[0]).toEqual({
|
|
path: "/tmp/sample",
|
|
operation: "file_headers",
|
|
follow_debug_links: false,
|
|
command_timeout_ms: 5_000,
|
|
max_output_bytes: 1234,
|
|
});
|
|
await runObjdump(service, "/tmp/sample");
|
|
expect(inspectWithObjdump.mock.calls[1]?.[0]).toEqual({
|
|
path: "/tmp/sample",
|
|
operation: "file_headers",
|
|
follow_debug_links: false,
|
|
});
|
|
});
|
|
|
|
it("lists objdump architectures without inventing an artifact path", async () => {
|
|
const evidence = observation("complete");
|
|
const inspectWithObjdump = vi.fn(async (_input: unknown) => ok(evidence));
|
|
const commands = new Map<
|
|
string,
|
|
{
|
|
readonly args?: { safeParse(input: unknown): { success: boolean } };
|
|
run(input: { args?: unknown; options?: unknown }): Promise<unknown>;
|
|
}
|
|
>();
|
|
const cli = {
|
|
command: (_name: string, definition: unknown) => {
|
|
commands.set(
|
|
_name,
|
|
definition as {
|
|
readonly args?: {
|
|
safeParse(input: unknown): { success: boolean };
|
|
};
|
|
run(input: { args?: unknown; options?: unknown }): Promise<unknown>;
|
|
},
|
|
);
|
|
return cli;
|
|
},
|
|
} as unknown as CliInstance;
|
|
registerReverseEngineeringCommands(
|
|
cli,
|
|
pino({ enabled: false }),
|
|
{},
|
|
undefined,
|
|
() =>
|
|
({
|
|
inspectWithObjdump,
|
|
close: vi.fn(async () => undefined),
|
|
}) as unknown as ReverseEngineeringService,
|
|
);
|
|
const command = commands.get(CLI_COMMANDS.listObjdumpArchitectures);
|
|
if (command === undefined)
|
|
throw new Error("architecture command was not registered");
|
|
|
|
const result = await command.run({ options: { detail: "complete" } });
|
|
|
|
expect(result).toEqual(evidence);
|
|
expect(inspectWithObjdump).toHaveBeenCalledWith(
|
|
{ operation: "architectures" },
|
|
expect.objectContaining({ signal: expect.any(AbortSignal) }),
|
|
);
|
|
const artifactCommand = commands.get(CLI_COMMANDS.inspectWithObjdump);
|
|
expect(
|
|
artifactCommand?.args?.safeParse({
|
|
path: "/tmp/sample",
|
|
operation: "dwarf",
|
|
}).success,
|
|
).toBe(true);
|
|
expect(
|
|
artifactCommand?.args?.safeParse({
|
|
path: "/tmp/sample",
|
|
operation: "architectures",
|
|
}).success,
|
|
).toBe(false);
|
|
});
|
|
|
|
it("promotes only complete production cleanup-only Evidence after CLI cleanup retry", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const owner = {
|
|
stop: vi.fn(async () => ({ status: "verified-cleanup" as const })),
|
|
} as unknown as ProviderProcessSupervisor;
|
|
const service = new CoreReverseEngineeringService({
|
|
commands: createReverseEngineeringCommandPort({}),
|
|
runOwned: vi.fn(async () => {
|
|
throw new OwnedCommandFailure(
|
|
"process",
|
|
"Cleanup after process completion was incomplete.",
|
|
providerSnapshot(),
|
|
"provider process still running",
|
|
undefined,
|
|
["owned-run"],
|
|
owner,
|
|
);
|
|
}),
|
|
});
|
|
const inspect = service.inspectWithObjdump.bind(service);
|
|
let producedCleanupFailure: AnalysisOutputError | undefined;
|
|
let cleanupIncompleteBeforeClose: boolean | undefined;
|
|
let completionBeforeClose: unknown;
|
|
vi.spyOn(service, "inspectWithObjdump").mockImplementation(
|
|
async (...args) => {
|
|
const result = await inspect(...args);
|
|
if (!result.ok && result.error instanceof AnalysisOutputError) {
|
|
producedCleanupFailure = result.error;
|
|
cleanupIncompleteBeforeClose = result.error.cleanupIncomplete;
|
|
const partialEvidence = evidenceSchema.safeParse(
|
|
result.error.partialObservation,
|
|
);
|
|
if (partialEvidence.success)
|
|
completionBeforeClose = partialEvidence.data.normalized_result;
|
|
}
|
|
return result;
|
|
},
|
|
);
|
|
|
|
try {
|
|
const result = await runObjdump(service, path);
|
|
|
|
expect(result).toMatchObject({
|
|
provider: { id: "objdump.command" },
|
|
normalized_result: { completion_status: "complete" },
|
|
});
|
|
expect(producedCleanupFailure?.cleanupOnly).toBe(true);
|
|
expect(cleanupIncompleteBeforeClose).toBe(true);
|
|
expect(completionBeforeClose).toMatchObject({
|
|
completion_status: "complete",
|
|
});
|
|
expect(producedCleanupFailure).toMatchObject({
|
|
cleanupIncomplete: false,
|
|
cleanup: { resources: ["owned-run"] },
|
|
partialObservation: {
|
|
normalized_result: { completion_status: "complete" },
|
|
},
|
|
});
|
|
expect(owner.stop).toHaveBeenCalledOnce();
|
|
} finally {
|
|
await service.close().catch(() => undefined);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("preserves an operation timeout and unknown partial Evidence after cleanup retry", async () => {
|
|
const { directory, path } = await createArtifact();
|
|
const owner = {
|
|
stop: vi.fn(async () => ({ status: "verified-cleanup" as const })),
|
|
} as unknown as ProviderProcessSupervisor;
|
|
const service = new CoreReverseEngineeringService({
|
|
commands: createReverseEngineeringCommandPort({}),
|
|
runOwned: vi.fn(async () => {
|
|
throw new OwnedCommandFailure(
|
|
"timeout",
|
|
"Provider command timed out.",
|
|
providerSnapshot({ exitCode: null }),
|
|
"provider process still running",
|
|
undefined,
|
|
["owned-run"],
|
|
owner,
|
|
);
|
|
}),
|
|
});
|
|
let timeout: AnalysisTimeoutError | undefined;
|
|
const inspect = service.inspectWithObjdump.bind(service);
|
|
vi.spyOn(service, "inspectWithObjdump").mockImplementation(
|
|
async (...args) => {
|
|
const result = await inspect(...args);
|
|
if (!result.ok && result.error instanceof AnalysisTimeoutError)
|
|
timeout = result.error;
|
|
return result;
|
|
},
|
|
);
|
|
|
|
try {
|
|
const result = await runObjdump(service, path);
|
|
|
|
expect(result).toMatchObject({
|
|
code: "provider_timeout",
|
|
details: {
|
|
partial_observation: {
|
|
normalized_result: { completion_status: "unknown" },
|
|
},
|
|
},
|
|
});
|
|
expect(timeout).toBeInstanceOf(AnalysisTimeoutError);
|
|
expect(timeout?.cleanupIncomplete).toBe(false);
|
|
expect(owner.stop).toHaveBeenCalledOnce();
|
|
} finally {
|
|
await service.close().catch(() => undefined);
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it("preserves an independent timeout after cleanup succeeds", async () => {
|
|
const evidence = observation("unknown");
|
|
const error = new AnalysisTimeoutError("inspect_with_objdump", 1_000, {
|
|
cleanup: { reason: "first stop incomplete", resources: ["owned-run"] },
|
|
partialObservation: evidence,
|
|
});
|
|
const service = {
|
|
inspectWithObjdump: vi.fn(async () => err(error)),
|
|
close: vi.fn(async () => {
|
|
releaseOwnedCleanup(error);
|
|
}),
|
|
};
|
|
|
|
const result = await runObjdump(service);
|
|
|
|
expect(result).toMatchObject({
|
|
code: "provider_timeout",
|
|
details: {
|
|
partial_observation: {
|
|
normalized_result: { completion_status: "unknown" },
|
|
},
|
|
},
|
|
});
|
|
expect(error.cleanupIncomplete).toBe(false);
|
|
expect((result as { details: unknown }).details).not.toHaveProperty(
|
|
"cleanup",
|
|
);
|
|
});
|
|
|
|
it("does not project cancellation cleanup as incomplete after a successful retry", async () => {
|
|
const error = new AnalysisCancelledError("inspect_with_objdump", {
|
|
cleanup: { reason: "first stop incomplete", resources: ["owned-run"] },
|
|
partialObservation: observation("unknown"),
|
|
});
|
|
const service = {
|
|
inspectWithObjdump: vi.fn(async () => err(error)),
|
|
close: vi.fn(async () => {
|
|
releaseOwnedCleanup(error);
|
|
}),
|
|
};
|
|
|
|
const result = await runObjdump(service);
|
|
|
|
expect(error.cleanupIncomplete).toBe(false);
|
|
expect(result).toMatchObject({
|
|
code: "cancelled",
|
|
details: { cleanup: "complete" },
|
|
});
|
|
});
|
|
|
|
it("returns cancellation with the completed observation when the caller interrupts service close", async () => {
|
|
const evidence = observation("complete");
|
|
const service = {
|
|
inspectWithObjdump: vi.fn(async () => ok(evidence)),
|
|
close: vi.fn(async () => {
|
|
process.emit("SIGINT");
|
|
}),
|
|
};
|
|
|
|
const result = await runObjdump(service);
|
|
|
|
expect(result).toMatchObject({
|
|
code: "cancelled",
|
|
details: { partial_observation: evidence },
|
|
});
|
|
expect(process.exitCode).toBe(130);
|
|
});
|
|
|
|
it("retains completed objdump Evidence when final cleanup fails", async () => {
|
|
const evidence = observation("complete");
|
|
const result = await runObjdump({
|
|
inspectWithObjdump: async () => ok(evidence),
|
|
close: async () => {
|
|
throw new ProviderCleanupError(
|
|
"objdump.command",
|
|
["owned-run"],
|
|
{ reason: "process cleanup remains incomplete" },
|
|
{ operation: "inspect_with_objdump" },
|
|
);
|
|
},
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
resources: ["owned-run"],
|
|
partial_observation: evidence,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("retains an unexpected operation failure and an independent cleanup failure", async () => {
|
|
const result = await runObjdump({
|
|
inspectWithObjdump: async () => {
|
|
throw new AnalysisTimeoutError("inspect_with_objdump", 1_000);
|
|
},
|
|
close: async () => {
|
|
throw new ProviderCleanupError(
|
|
"objdump.command",
|
|
["owned-run"],
|
|
{ reason: "process cleanup remains incomplete" },
|
|
{ operation: "inspect_with_objdump" },
|
|
);
|
|
},
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
category: "timeout",
|
|
details: {
|
|
diagnostics: {
|
|
primary_error: { code: "provider_timeout" },
|
|
cleanup_error: { details: { resources: ["owned-run"] } },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it.each([false, true])(
|
|
"passes the Rizin TTY and uses final cleanup outcome (retry=%s)",
|
|
async (retryClose) => {
|
|
process.exitCode = 0;
|
|
const commands = new Map<
|
|
string,
|
|
{
|
|
run(input: {
|
|
args: Record<string, unknown>;
|
|
options: Record<string, unknown>;
|
|
}): Promise<unknown>;
|
|
}
|
|
>();
|
|
const cli = {
|
|
command: (
|
|
name: string,
|
|
definition: {
|
|
run(input: {
|
|
args: Record<string, unknown>;
|
|
options: Record<string, unknown>;
|
|
}): Promise<unknown>;
|
|
},
|
|
) => {
|
|
commands.set(name, definition);
|
|
return cli;
|
|
},
|
|
} as unknown as CliInstance;
|
|
const evidence = createEvidence(
|
|
undefined,
|
|
{ id: "rizin.debugger", name: "Rizin debugger", version: null },
|
|
{
|
|
predicateType: "rea.debugger.command-observation",
|
|
operation: "rizin_debug_command",
|
|
parameters: {
|
|
session_id: "session-1",
|
|
command: "dr",
|
|
inferior_tty: "/dev/pts/7",
|
|
},
|
|
result: { output: "registers" },
|
|
rawResult: { output: "registers" },
|
|
},
|
|
);
|
|
const start = vi
|
|
.spyOn(RizinDebugSessionManager.prototype, "start")
|
|
.mockResolvedValue(ok({ session_id: "session-1", backend: null }));
|
|
const execute = vi
|
|
.spyOn(RizinDebugSessionManager.prototype, "execute")
|
|
.mockResolvedValue(
|
|
ok({
|
|
evidence,
|
|
result: {
|
|
command: "dr",
|
|
output: "registers",
|
|
output_scope: "command_and_interleaved_session_output",
|
|
output_truncated: false,
|
|
completion_status: "complete",
|
|
backend: null,
|
|
},
|
|
}),
|
|
);
|
|
vi.spyOn(RizinDebugSessionManager.prototype, "close").mockResolvedValue(
|
|
retryClose
|
|
? err(
|
|
new AnalysisOutputError(
|
|
"close_rizin_debug_session",
|
|
"first close incomplete",
|
|
{
|
|
cleanup: { reason: "retry pending", resources: ["session-1"] },
|
|
},
|
|
),
|
|
)
|
|
: ok({ target_state: "unknown" }),
|
|
);
|
|
vi.spyOn(RizinDebugSessionManager.prototype, "closeAll").mockResolvedValue(
|
|
undefined,
|
|
);
|
|
registerReverseEngineeringCommands(cli, pino({ enabled: false }), {});
|
|
const command = commands.get(CLI_COMMANDS.debugWithRizin);
|
|
if (command === undefined)
|
|
throw new Error("Rizin CLI command was not registered");
|
|
|
|
await command.run({
|
|
args: { path: "./sample.bin", command: "dr" },
|
|
options: { inferiorTty: "/dev/pts/7" },
|
|
});
|
|
|
|
expect(process.exitCode).toBe(0);
|
|
expect(start).toHaveBeenCalledWith(
|
|
{ path: "./sample.bin", command: "dr", inferior_tty: "/dev/pts/7" },
|
|
expect.any(AbortSignal),
|
|
);
|
|
expect(execute).toHaveBeenCalledWith(
|
|
"session-1",
|
|
"dr",
|
|
expect.any(AbortSignal),
|
|
);
|
|
|
|
const parserCli = Cli.create("rea-test", { version: "1.0.0", sync: false });
|
|
registerReverseEngineeringCommands(
|
|
parserCli as unknown as CliInstance,
|
|
pino({ enabled: false }),
|
|
{},
|
|
);
|
|
const commandContract = Cli.toCommands
|
|
.get(parserCli)
|
|
?.get(CLI_COMMANDS.debugWithRizin);
|
|
expect(commandContract).toBeDefined();
|
|
const argsSchema =
|
|
commandContract === undefined
|
|
? undefined
|
|
: Reflect.get(commandContract, "args");
|
|
const optionsSchema =
|
|
commandContract === undefined
|
|
? undefined
|
|
: Reflect.get(commandContract, "options");
|
|
if (
|
|
!(argsSchema instanceof z.ZodObject) ||
|
|
!(optionsSchema instanceof z.ZodObject)
|
|
)
|
|
throw new Error("Rizin CLI args/options schemas were not registered");
|
|
expect(argsSchema.parse({ path: "./sample.bin", command: "dr" })).toEqual({
|
|
path: "./sample.bin",
|
|
command: "dr",
|
|
});
|
|
expect(optionsSchema.parse({ inferiorTty: "/dev/pts/7" })).toMatchObject({
|
|
inferiorTty: "/dev/pts/7",
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each(["incomplete", "interrupt", "control"] as const)(
|
|
"reports final Rizin cleanup %s as a typed outcome",
|
|
async (mode) => {
|
|
const commands = new Map<
|
|
string,
|
|
{ run(input: unknown): Promise<unknown> }
|
|
>();
|
|
const cli = {
|
|
command(
|
|
name: string,
|
|
definition: { run(input: unknown): Promise<unknown> },
|
|
) {
|
|
commands.set(name, definition);
|
|
return cli;
|
|
},
|
|
} as unknown as CliInstance;
|
|
const previous = process.exitCode;
|
|
const evidence = createEvidence(
|
|
undefined,
|
|
{ id: "rizin.debugger", name: "Rizin debugger", version: null },
|
|
{
|
|
predicateType: "rea.debugger.command-observation",
|
|
operation: "rizin_debug_command",
|
|
parameters: {
|
|
session_id: "fixture-session",
|
|
command: "dr",
|
|
inferior_tty: "/dev/pts/7",
|
|
},
|
|
result: { output: "registers" },
|
|
rawResult: { output: "registers" },
|
|
},
|
|
);
|
|
const sessions = new Map([
|
|
[
|
|
"fixture-session",
|
|
{
|
|
id: "fixture-session",
|
|
launched: { process: { pid: 4242 } },
|
|
supervisor: { ref: vi.fn(), unref: vi.fn() },
|
|
},
|
|
],
|
|
]);
|
|
vi.spyOn(RizinDebugSessionManager.prototype, "start").mockResolvedValue(
|
|
ok({ session_id: "fixture-session", backend: null }),
|
|
);
|
|
const execute = vi
|
|
.spyOn(RizinDebugSessionManager.prototype, "execute")
|
|
.mockResolvedValue(
|
|
ok({
|
|
evidence,
|
|
result: {
|
|
command: "dr",
|
|
output: "registers",
|
|
output_scope: "command_and_interleaved_session_output",
|
|
output_truncated: false,
|
|
completion_status: "complete",
|
|
backend: null,
|
|
},
|
|
}),
|
|
);
|
|
vi.spyOn(RizinDebugSessionManager.prototype, "close").mockImplementation(
|
|
async () => {
|
|
if (mode === "incomplete")
|
|
return err(
|
|
new AnalysisOutputError(
|
|
"close_rizin_debug_session",
|
|
"stop unconfirmed",
|
|
{
|
|
cleanup: {
|
|
reason: "stop unconfirmed",
|
|
resources: ["fixture-session"],
|
|
},
|
|
},
|
|
),
|
|
);
|
|
sessions.clear();
|
|
return ok({ target_state: "unknown" });
|
|
},
|
|
);
|
|
let abortedAtCleanupEnd = false;
|
|
let signal: AbortSignal | undefined;
|
|
const closeAll = vi
|
|
.spyOn(RizinDebugSessionManager.prototype, "closeAll")
|
|
.mockImplementation(async () => {
|
|
signal = execute.mock.calls[0]?.[2];
|
|
await new Promise<void>((resolve) =>
|
|
setImmediate(() => {
|
|
if (mode === "interrupt") process.emit("SIGINT");
|
|
resolve();
|
|
}),
|
|
);
|
|
abortedAtCleanupEnd = signal?.aborted === true;
|
|
await closeOwnedDebuggerSessions(
|
|
sessions,
|
|
async () => ({ status: "incomplete", reason: "stop unconfirmed" }),
|
|
"Rizin",
|
|
);
|
|
});
|
|
registerReverseEngineeringCommands(cli, pino({ enabled: false }), {});
|
|
const command = commands.get(CLI_COMMANDS.debugWithRizin);
|
|
if (command === undefined)
|
|
throw new Error("Rizin CLI command was not registered");
|
|
try {
|
|
process.exitCode = 0;
|
|
const result = await command.run({
|
|
args: { path: "./sample.bin", command: "dr" },
|
|
options: { inferiorTty: "/dev/pts/7" },
|
|
});
|
|
expect(closeAll).toHaveBeenCalledOnce();
|
|
if (mode === "control") {
|
|
expect(result).toBe(evidence);
|
|
expect(process.exitCode).toBe(0);
|
|
expect(abortedAtCleanupEnd).toBe(false);
|
|
} else if (mode === "interrupt") {
|
|
expect(result).toMatchObject({
|
|
code: "cancelled",
|
|
details: {
|
|
partial_observation: {
|
|
operation: "rizin_debug_command",
|
|
parameters: { session_id: "fixture-session" },
|
|
},
|
|
},
|
|
});
|
|
expect(process.exitCode).toBe(130);
|
|
expect(abortedAtCleanupEnd).toBe(true);
|
|
} else {
|
|
expect(result).toMatchObject({
|
|
code: "cleanup_incomplete",
|
|
details: {
|
|
cleanup: "incomplete",
|
|
resources: ["fixture-session"],
|
|
diagnostics: { retained_debugger_pids: [4242] },
|
|
partial_observation: {
|
|
operation: "rizin_debug_command",
|
|
parameters: { session_id: "fixture-session" },
|
|
},
|
|
},
|
|
});
|
|
expect(sessions.size).toBe(1);
|
|
expect(process.exitCode).toBe(1);
|
|
}
|
|
} finally {
|
|
process.exitCode = previous;
|
|
vi.restoreAllMocks();
|
|
}
|
|
},
|
|
);
|