fix(rizin): retain cancellation and recover cleanup correlation

This commit is contained in:
morluto
2026-10-24 03:08:46 +08:00
parent 3a62b96194
commit 57aa21af26
7 changed files with 394 additions and 160 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ close_gdb_session {"session_id":"…"}
## Rizin
`start_rizin_debug_session` and `rea debug-with-rizin` require an explicit `inferior_tty` on Linux. Rizin launches the target with stdout and stderr routed to that caller-selected TTY and `dbg.exitkills=false`; the target can therefore continue running and writing to the TTY after REA closes the debugger. This is a visible target output effect, and the caller is responsible for selecting a suitable terminal that remains available. Before closing, REA serializes after the active command, parses the structured `olj` descriptor list, detaches only a unique `ptrace://` descriptor, confirms its absence, reads back `dbg.exitkills=false`, and verifies `/proc/<pid>/fd/1` and `/proc/<pid>/fd/2` still identify the selected TTY by device and inode. If a previously identified inferior exits after Rizin confirms detach, REA verifies that the original process has ended instead of requiring output descriptors from an exited process. Unresolved identity or ambiguous readback keeps the Rizin session owned for retry. Persistent Rizin debugger sessions are unavailable outside Linux because this verification depends on `/proc`.
`start_rizin_debug_session` and `rea debug-with-rizin` require an explicit `inferior_tty` on Linux. Rizin launches the target with stdout and stderr routed to that caller-selected TTY and `dbg.exitkills=false`; the target can therefore continue running and writing to the TTY after REA closes the debugger. This is a visible target output effect, and the caller is responsible for selecting a suitable terminal that remains available. Before closing, REA serializes after the active command, parses the structured `olj` descriptor list, detaches only a unique `ptrace://` descriptor, confirms its absence, reads back `dbg.exitkills=false`, and verifies `/proc/<pid>/fd/1` and `/proc/<pid>/fd/2` still identify the selected TTY by device and inode. If a previously identified inferior exits after Rizin confirms detach, REA verifies that the original process has ended instead of requiring output descriptors from an exited process. Unresolved identity or ambiguous readback keeps the Rizin session owned for retry. After output limits or a failed command interval, cleanup first waits up to 1000 ms for a fresh private NUL-delimited marker, discarding earlier frames before obtaining that new inferior readback. Synchronization never reopens user command admission. A missing marker, unavailable transport, or unknown inferior identity retains the cleanup owner. Caller cancellation keeps its exact reason and typed cancellation outcome even when cleanup remains incomplete; a successful retry resolves that retained cleanup state. Persistent Rizin debugger sessions are unavailable outside Linux because this verification depends on `/proc`.
Persistent command Evidence retains the caller-selected and resolved startup paths in `parameters.launch_provenance`. These paths describe launch provenance, not the current target after unrestricted commands. `rizin_debug_session_status.output_invalid_utf8` reports whether lossy frame decoding has been observed during the session. Command Evidence sets `output_invalid_utf8` only when loss was observed in frames collected for that command, including retained partial output; earlier startup or command frames do not mark later valid output as lossy.
+19 -7
View File
@@ -419,20 +419,32 @@ export const registerReverseEngineeringCommands = (
? executed.value.evidence
: projectAnalysisError(executed.error);
} finally {
const closed =
sessionId === undefined
? undefined
: await manager.close(sessionId);
if (closed !== undefined && !closed.ok) {
try {
if (sessionId !== undefined) {
const closed = await manager.close(sessionId);
if (!closed.ok)
logger.error(
{
command: CLI_COMMANDS.debugWithRizin,
cleanup: projectAnalysisError(closed.error),
},
"Rizin cleanup failed after collecting command output",
);
}
} catch (cause) {
logger.error(
{
command: CLI_COMMANDS.debugWithRizin,
cleanup: projectAnalysisError(closed.error),
cleanup:
cause instanceof Error
? cause.message
: "Unknown cleanup failure",
},
"Rizin cleanup failed after collecting command output",
);
process.exitCode = 1;
}
// The retained owner retries all sessions. Only its final outcome
// establishes whether command cleanup remains incomplete.
await manager.closeAll().catch((cause: unknown) => {
process.exitCode = 1;
logger.error(
+144 -37
View File
@@ -218,6 +218,13 @@ interface RizinDebugSession {
exited: boolean;
commandTail: Promise<void>;
protocolError: string | undefined;
cleanupSynchronization:
| {
readonly marker: string;
readonly resolve: () => void;
readonly reject: (cause: Error) => void;
}
| undefined;
commandCaptureActive: boolean;
commandOutputDropped: boolean;
discardingOversizedFrame: boolean;
@@ -351,7 +358,7 @@ export class RizinDebugSessionManager {
>
> {
if (signal?.aborted)
return err(new AnalysisCancelledError("start_rizin_debug_session"));
return err(rizinCancellation("start_rizin_debug_session", signal));
if (this.#platform !== "linux")
return err(
new AnalysisCapabilityUnavailableError(
@@ -401,7 +408,7 @@ export class RizinDebugSessionManager {
// TTY identity validation is asynchronous. Cancellation may arrive while
// realpath/open/stat are pending, and must prevent the debugger spawn.
if (signal?.aborted)
return err(new AnalysisCancelledError("start_rizin_debug_session"));
return err(rizinCancellation("start_rizin_debug_session", signal));
const id = randomUUID();
const resolvedPath = absoluteCallerPath(input.path);
const launchProvenance: RizinLaunchProvenance = {
@@ -434,7 +441,7 @@ export class RizinDebugSessionManager {
} catch (cause) {
if (signal?.aborted)
return err(
new AnalysisCancelledError("start_rizin_debug_session", { cause }),
rizinCancellation("start_rizin_debug_session", signal, cause),
);
return err(
new AnalysisCapabilityUnavailableError(
@@ -493,6 +500,7 @@ export class RizinDebugSessionManager {
exited: false,
commandTail: Promise.resolve(),
protocolError: undefined,
cleanupSynchronization: undefined,
commandCaptureActive: false,
commandOutputDropped: false,
discardingOversizedFrame: false,
@@ -514,6 +522,7 @@ export class RizinDebugSessionManager {
});
launched.process.once("exit", () => {
session.exited = true;
session.cleanupSynchronization?.reject(new RizinProcessExitError());
for (const waiter of session.frameWaiters)
waiter.reject(new RizinProcessExitError());
session.frameWaiters.length = 0;
@@ -524,14 +533,18 @@ export class RizinDebugSessionManager {
// initial frame run before publishing the session as ready.
await new Promise<void>((resolve) => setImmediate(resolve));
if (signal?.aborted)
throw new AnalysisCancelledError("start_rizin_debug_session");
throw rizinCancellation("start_rizin_debug_session", signal);
if (session.exited)
throw new RizinProcessExitError("Rizin exited during startup.");
if (session.protocolError !== undefined)
throw new Error(session.protocolError);
session.ready = true;
return ok({ session_id: id, backend: input.backend ?? null });
} catch (cause) {
} catch (caught) {
const cause =
signal?.aborted || caught instanceof AnalysisCancelledError
? rizinCancellation("start_rizin_debug_session", signal, caught)
: caught;
const stopped = await this.#stopDebuggerOnly(session);
const capturedOutput = this.#startupCapturedOutput(session);
if (stopped.status !== "incomplete") this.#sessions.delete(id);
@@ -546,8 +559,7 @@ export class RizinDebugSessionManager {
);
if (signal?.aborted || cause instanceof AnalysisCancelledError)
return err(
new AnalysisCancelledError("start_rizin_debug_session", {
cause,
rizinCancellation("start_rizin_debug_session", signal, cause, {
capturedOutput,
}),
);
@@ -611,7 +623,7 @@ export class RizinDebugSessionManager {
)
return err(new AnalysisInputError("rizin_debug_command"));
if (signal?.aborted)
return err(new AnalysisCancelledError("rizin_debug_command"));
return err(rizinCancellation("rizin_debug_command", signal));
if (session.pendingCommandCount >= MAX_PENDING_COMMANDS)
return err(
new AnalysisResourceConstraintError(
@@ -655,7 +667,7 @@ export class RizinDebugSessionManager {
await waitForTurn(previous, signal);
acquired = true;
if (signal?.aborted)
return err(new AnalysisCancelledError("rizin_debug_command"));
return err(rizinCancellation("rizin_debug_command", signal));
if (
!session.ready ||
session.exited ||
@@ -724,10 +736,9 @@ export class RizinDebugSessionManager {
}
const captured = received.value;
if (signal?.aborted)
throw new AnalysisCancelledError("rizin_debug_command");
throw rizinCancellation("rizin_debug_command", signal);
let cleanupFailure: string | undefined;
if (captured.outputTruncated) {
// Stryker disable next-line BooleanLiteral -- `waitForCommandFrames` sets `protocolError` before reporting truncation; both detach wrappers therefore fail closed before a queued command can be awaited, so the held-tail flag cannot change this public path.
const stopped = await this.#stopDebuggerOnly(session, true);
if (stopped.status === "incomplete") cleanupFailure = stopped.reason;
}
@@ -798,9 +809,7 @@ export class RizinDebugSessionManager {
});
if (signal?.aborted || cause instanceof AnalysisCancelledError) {
if (!commandSent)
return err(
new AnalysisCancelledError("rizin_debug_command", { cause }),
);
return err(rizinCancellation("rizin_debug_command", signal, cause));
const evidence = partialEvidence([
"The Rizin command was cancelled; collected frame output is partial and command completion is unknown.",
"The owned debugger session was stopped because command output can no longer be correlated safely.",
@@ -816,8 +825,7 @@ export class RizinDebugSessionManager {
"rizin_debug_command",
stopped.reason,
{
cause: new AnalysisCancelledError("rizin_debug_command", {
cause,
cause: rizinCancellation("rizin_debug_command", signal, cause, {
...(capturedOutput === undefined ? {} : { capturedOutput }),
}),
...(evidence === undefined
@@ -827,8 +835,7 @@ export class RizinDebugSessionManager {
),
);
return err(
new AnalysisCancelledError("rizin_debug_command", {
cause,
rizinCancellation("rizin_debug_command", signal, cause, {
...(capturedOutput === undefined ? {} : { capturedOutput }),
...(evidence === undefined ? {} : { partialObservation: evidence }),
}),
@@ -1053,7 +1060,10 @@ export class RizinDebugSessionManager {
}
if (session.bufferedFrameBytes + piece.length > MAX_FRAME_BYTES) {
const message = `Rizin emitted a NUL frame exceeding the ${String(MAX_FRAME_BYTES)} byte (16 MiB) protocol limit. This release has no runtime override.`;
if (!session.commandCaptureActive) {
if (
!session.commandCaptureActive &&
session.cleanupSynchronization === undefined
) {
this.#failProtocol(session, message);
return;
}
@@ -1116,6 +1126,18 @@ export class RizinDebugSessionManager {
frame: RizinFrame,
frameBytes: number,
): void {
const synchronization = session.cleanupSynchronization;
if (synchronization !== undefined) {
// This fresh, private NUL-delimited echo closes the old command interval.
// Earlier frames are discarded, never reused as inferior readback.
if (
!frame.truncated &&
!frame.invalidUtf8 &&
frame.text.trim() === synchronization.marker
)
synchronization.resolve();
return;
}
const historyPrefix = truncateUtf8(frame.text, MAX_RECENT_FRAME_BYTES);
const historyTruncated =
historyPrefix.length < frame.text.length || frame.truncated;
@@ -1155,6 +1177,7 @@ export class RizinDebugSessionManager {
#failProtocol(session: RizinDebugSession, message: string): void {
session.protocolError = message;
session.cleanupSynchronization?.reject(new Error(message));
if (!session.commandCaptureActive) {
session.frameChunks = [];
session.bufferedFrameBytes = 0;
@@ -1191,10 +1214,8 @@ export class RizinDebugSessionManager {
async #detachInferior(
session: RizinDebugSession,
): Promise<OwnedDebuggerDetachResult> {
if (session.exited || session.protocolError !== undefined)
throw new Error(
"Rizin protocol state is ambiguous; inferior detach is unverified.",
);
if (session.exited)
throw new Error("Rizin exited before inferior detach could be verified.");
const previous = session.commandTail;
let release!: () => void;
session.commandTail = new Promise<void>((resolve) => (release = resolve));
@@ -1209,10 +1230,10 @@ export class RizinDebugSessionManager {
async #detachInferiorUnlocked(
session: RizinDebugSession,
): Promise<OwnedDebuggerDetachResult> {
if (session.exited || session.protocolError !== undefined)
throw new Error(
"Rizin protocol state is ambiguous; inferior detach is unverified.",
);
if (session.exited)
throw new Error("Rizin exited before inferior detach could be verified.");
if (session.protocolError !== undefined)
await this.#synchronizeCleanup(session);
session.inferiorInventoryComplete = false;
session.knownInferiorIdentities.clear();
const inferior = parseRizinPtraceRows(
@@ -1306,6 +1327,64 @@ export class RizinDebugSessionManager {
}
}
async #synchronizeCleanup(session: RizinDebugSession): Promise<void> {
const stdin = session.launched.process.stdin;
if (
stdin === null ||
stdin === undefined ||
stdin.destroyed ||
!stdin.writable
)
throw new Error(
"Rizin stdin is unavailable during cleanup synchronization.",
);
// Admission remains closed. Only cleanup can recover correlation, and then
// it still obtains a fresh ptrace inventory and validates identity/output.
session.ready = false;
session.frames = [];
session.queuedFrameBytes = 0;
const marker = `__REA_CLEANUP_SYNC_${randomUUID().replaceAll("-", "")}__`;
await new Promise<void>((resolve, reject) => {
let settled = false;
const finish = (cause?: Error): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
session.cleanupSynchronization = undefined;
if (cause === undefined) {
session.protocolError = undefined;
session.commandOutputDropped = false;
resolve();
} else reject(cause);
};
const timer = setTimeout(
() =>
finish(
new Error("Rizin cleanup synchronization marker was not observed."),
),
CLEANUP_COMMAND_TIMEOUT_MS,
);
session.cleanupSynchronization = {
marker,
resolve: () => finish(),
reject: (cause) => finish(cause),
};
try {
stdin.write(`!echo ${marker}\n`, "utf8", (cause?: Error | null) => {
if (cause) session.cleanupSynchronization?.reject(cause);
});
} catch (cause) {
finish(
cause instanceof Error
? cause
: new Error("Rizin cleanup synchronization write failed.", {
cause,
}),
);
}
});
}
async #runInternalCommand(
session: RizinDebugSession,
command: string,
@@ -1343,12 +1422,24 @@ export class RizinDebugSessionManager {
reason: string,
options: AnalysisErrorOptions = {},
): AnalysisError {
const failure = new AnalysisCapabilityUnavailableError(
"rizin",
operation,
`The owned Rizin process could not be confirmed stopped: ${reason}`,
{ ...options, cleanup: { reason, resources: [session.id] } },
);
const cleanup = { reason, resources: [session.id] };
const primary = options.cause;
const failure =
primary instanceof AnalysisCancelledError
? new AnalysisCancelledError(operation, {
...options,
cause: primary.cause,
capturedOutput: options.capturedOutput ?? primary.capturedOutput,
partialObservation:
options.partialObservation ?? primary.partialObservation,
cleanup,
})
: new AnalysisCapabilityUnavailableError(
"rizin",
operation,
`The owned Rizin process could not be confirmed stopped: ${reason}`,
{ ...options, cleanup },
);
session.cleanupFailures.add(failure);
return failure;
}
@@ -1639,7 +1730,7 @@ const waitForFrame = (
signal?: AbortSignal,
): Promise<RizinFrame> => {
if (signal?.aborted)
return Promise.reject(new AnalysisCancelledError("rizin_debug_command"));
return Promise.reject(rizinCancellation("rizin_debug_command", signal));
const queued = session.frames.shift();
if (queued !== undefined) {
session.queuedFrameBytes -= queued.queueChargeBytes;
@@ -1656,7 +1747,7 @@ const waitForFrame = (
};
const onAbort = (): void => {
cleanup();
reject(new AnalysisCancelledError("rizin_debug_command"));
reject(rizinCancellation("rizin_debug_command", signal));
};
const timer = setTimeout(() => {
cleanup();
@@ -1684,10 +1775,10 @@ const waitForTurn = (
): Promise<void> => {
if (signal === undefined) return previous;
if (signal.aborted)
return Promise.reject(new AnalysisCancelledError("rizin_debug_command"));
return Promise.reject(rizinCancellation("rizin_debug_command", signal));
return new Promise<void>((resolve, reject) => {
const onAbort = (): void =>
reject(new AnalysisCancelledError("rizin_debug_command"));
reject(rizinCancellation("rizin_debug_command", signal));
signal.addEventListener("abort", onAbort, { once: true });
previous.then(() => {
signal.removeEventListener("abort", onAbort);
@@ -1695,3 +1786,19 @@ const waitForTurn = (
});
});
};
/** Translate only this transport's typed cancellation, preserving opaque/null caller reasons. */
const rizinCancellation = (
operation: string,
signal?: AbortSignal,
cause?: unknown,
options: AnalysisErrorOptions = {},
): AnalysisCancelledError =>
new AnalysisCancelledError(operation, {
...options,
cause: signal?.aborted
? signal.reason
: cause instanceof AnalysisCancelledError
? cause.cause
: cause,
});
@@ -383,117 +383,132 @@ it("returns cancellation with the completed observation when the caller interrup
expect(process.exitCode).toBe(130);
});
it("parses the Rizin inferior TTY as an option and passes it to the manager", async () => {
const commands = new Map<
string,
{
run(input: {
args: Record<string, unknown>;
options: Record<string, unknown>;
}): Promise<unknown>;
}
>();
const cli = {
command: (
name: string,
definition: {
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>;
},
) => {
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,
}
>();
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" },
},
);
vi.spyOn(RizinDebugSessionManager.prototype, "close").mockResolvedValue(
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");
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" },
});
await command.run({
args: { path: "./sample.bin", command: "dr" },
options: { inferiorTty: "/dev/pts/7" },
});
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),
);
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",
});
});
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",
});
},
);
@@ -315,11 +315,18 @@ it
abort.signal,
);
await spawned;
abort.abort();
const reason = { stage: "startup cancellation" };
abort.abort(reason);
const result = await starting;
expect(result.ok).toBe(false);
if (!result.ok && stopMode === "is uncertain")
expect(result.error.message).toContain("injected stop uncertainty");
if (!result.ok) {
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(reason);
if (stopMode === "is uncertain")
expect(result.error.cleanup?.reason).toContain(
"injected stop uncertainty",
);
}
await cleanup();
if (ownedLaunch === undefined)
throw new Error("Fixture launch was not observed");
@@ -419,10 +426,13 @@ it.skipIf(process.platform === "win32")(
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,
);
aborted.abort(null);
const result = await manager.execute(sessionId, "dr", aborted.signal);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBeNull();
}
expect(manager.status(sessionId)?.state).toBe("ready");
expect((await manager.close("missing")).ok).toBe(false);
},
@@ -1,5 +1,9 @@
import { afterEach, expect, it, vi } from "vitest";
import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisInputError,
} from "../../../src/domain/analysisErrorCore.js";
import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js";
import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js";
import {
createRizinCoverageHarness,
@@ -290,3 +294,84 @@ it.skipIf(process.platform === "win32")(
expect(writes.some((text) => text.includes("second\n"))).toBe(false);
},
);
it
.skipIf(process.platform === "win32")
.each([null, { selected: "opaque reason" }])(
"preserves exact caller cancellation through incomplete cleanup: %j",
async (reason) => {
const controller = new AbortController();
const injected = createInjectedProcess({
onWrite: (_text, control) => {
control.output("partial\0");
controller.abort(reason);
},
});
let retained = true;
const { manager, started } = await startInjected(injected.spawnProcess, {
stopProcess: async () =>
retained
? { status: "incomplete", reason: "owned stop pending" }
: { status: "stopped" },
});
if (!started.ok) throw started.error;
const result = await manager.execute(
started.value.session_id,
"wait",
controller.signal,
);
expect(result.ok).toBe(false);
if (result.ok) return;
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cause).toBe(reason);
expect(result.error.capturedOutput?.stdout).toContain("partial");
expect(result.error.partialObservation).toBeDefined();
expect(result.error.cleanupIncomplete).toBe(true);
retained = false;
expect((await manager.close(started.value.session_id)).ok).toBe(true);
expect(result.error.cleanupIncomplete).toBe(false);
},
);
it.skipIf(process.platform === "win32").each([false, true])(
"recovers output-limit cleanup only after a fresh marker and inferior readback (retry=%s)",
async (retry) => {
let synchronizationAllowed = !retry;
const writes: string[] = [];
const injected = createInjectedProcess({
onWrite: (text, control) => {
writes.push(text);
const marker = /!echo (.+)\n/u.exec(text)?.[1];
if (marker === undefined) return;
if (text.startsWith("noisy\n"))
control.output(`too much output\0${marker}\0`);
else if (text.startsWith("!echo __REA_CLEANUP_SYNC_")) {
if (synchronizationAllowed)
control.output(`stale frame\0${marker}\0`);
} else if (text.startsWith("olj\n")) control.output(`[]\0${marker}\0`);
else throw new Error(`Unexpected cleanup command: ${text}`);
},
});
const { manager, started } = await startInjected(injected.spawnProcess, {
stopProcess: stopOwnedDebuggerProcess,
maxCommandOutputBytes: 8,
});
if (!started.ok) throw started.error;
const result = await manager.execute(started.value.session_id, "noisy");
expect(result.ok).toBe(!retry);
expect(writes.some((text) => text.startsWith("olj\n"))).toBe(!retry);
if (retry) {
if (result.ok) throw new Error("Expected retained cleanup");
expect(result.error.cleanupIncomplete).toBe(true);
expect(manager.status(started.value.session_id)).toBeDefined();
expect(
(await manager.execute(started.value.session_id, "later")).ok,
).toBe(false);
synchronizationAllowed = true;
expect((await manager.close(started.value.session_id)).ok).toBe(true);
expect(result.error.cleanupIncomplete).toBe(false);
}
expect(writes.some((text) => text.startsWith("olj\n"))).toBe(true);
expect(manager.status(started.value.session_id)).toBeUndefined();
},
);
@@ -322,8 +322,13 @@ it.skipIf(process.platform === "win32")(
setTimeout(() => abort.abort(), 30);
const result = await execution;
expect(result.ok).toBe(false);
if (!result.ok)
expect(result.error.message).toContain("injected stop uncertainty");
if (!result.ok) {
expect(result.error._tag).toBe("AnalysisCancelledError");
expect(result.error.cause).toBe(abort.signal.reason);
expect(result.error.cleanup?.reason).toContain(
"injected stop uncertainty",
);
}
},
);