mirror of
https://github.com/morluto/rea
synced 2026-10-25 14:01:13 -04:00
fix(rizin): retain interrupted command frames (#4459)
Retain received complete and unfinished Rizin command bytes with bounded UTF-8 capture, truthful completion uncertainty and exact launch provenance. Keep captures independent of cancellation, protocol and cleanup precedence; wait for bounded output settlement on exit and preserve active bytes across stdin failures. Validated353 focused tests (one existing skip), full CI static/docs checks, real official Linux Rizin0.9.1 interruption probes, paired completed public CLI/stdio MCP tasks, and focused mutation checks. Required CI passed; exact-head Codex review reported no findings and Greptile5/5. Fault cleanup uncertainty and partial-marker ambiguity remain explicit. Fixes #4015. Follow-up to #4227.
This commit is contained in:
@@ -44,6 +44,7 @@ export const SCOPES = {
|
||||
"tests/boundary/mcp/gdbCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/debuggerCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/rizinPendingOutput.test.ts",
|
||||
"tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts",
|
||||
"tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts",
|
||||
"tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts",
|
||||
|
||||
@@ -91,6 +91,11 @@ const createRizinCommandEvidence = ({
|
||||
"The current debugger target identity is not established by the Rizin session identifier.",
|
||||
limitations: [
|
||||
...limitations,
|
||||
...(result.completion_status === "unknown"
|
||||
? [
|
||||
"Interrupted output may include an incomplete completion-marker prefix; it is retained because it cannot be distinguished from command diagnostics.",
|
||||
]
|
||||
: []),
|
||||
...(result.output_invalid_utf8
|
||||
? [
|
||||
"Collected command frames contained invalid UTF-8 bytes; decoded text may contain replacement characters.",
|
||||
@@ -100,24 +105,21 @@ const createRizinCommandEvidence = ({
|
||||
],
|
||||
});
|
||||
|
||||
export class RizinCommandTimeoutError extends Error {
|
||||
constructor(
|
||||
readonly partialOutput: string,
|
||||
readonly outputInvalidUtf8 = false,
|
||||
) {
|
||||
super("Timed out waiting for Rizin's command-completion marker");
|
||||
}
|
||||
interface RizinCommandCapture {
|
||||
readonly output: string;
|
||||
readonly outputTruncated: boolean;
|
||||
readonly outputInvalidUtf8: boolean;
|
||||
}
|
||||
|
||||
interface RizinCommandInterruption {
|
||||
readonly kind: "cancelled" | "exited" | "timeout" | "failed";
|
||||
readonly cause: unknown;
|
||||
readonly capture: RizinCommandCapture;
|
||||
}
|
||||
|
||||
export const isRizinCommandDeadlineExpired = (remainingMs: number): boolean =>
|
||||
remainingMs <= 0;
|
||||
|
||||
export const createRizinCommandTimeoutError = (
|
||||
partialFrames: readonly string[],
|
||||
outputInvalidUtf8 = false,
|
||||
): RizinCommandTimeoutError =>
|
||||
new RizinCommandTimeoutError(partialFrames.join("\n"), outputInvalidUtf8);
|
||||
|
||||
interface RizinCommandTimeoutEvidenceInput {
|
||||
readonly sessionId: string;
|
||||
readonly command: string;
|
||||
@@ -197,6 +199,7 @@ interface RizinDebugSession {
|
||||
commandTail: Promise<void>;
|
||||
protocolError: string | undefined;
|
||||
commandCaptureActive: boolean;
|
||||
commandOutputDropped: boolean;
|
||||
discardingOversizedFrame: boolean;
|
||||
}
|
||||
|
||||
@@ -207,28 +210,8 @@ interface RizinFrame {
|
||||
}
|
||||
|
||||
class RizinProcessExitError extends Error {
|
||||
constructor(
|
||||
readonly partialOutput: string,
|
||||
readonly outputInvalidUtf8 = false,
|
||||
) {
|
||||
super("Rizin process exited before completing the active command");
|
||||
}
|
||||
}
|
||||
|
||||
class RizinCommandCancelledError extends AnalysisCancelledError {
|
||||
constructor(
|
||||
readonly partialOutput: string,
|
||||
readonly outputInvalidUtf8: boolean,
|
||||
cause: unknown,
|
||||
) {
|
||||
super("rizin_debug_command", {
|
||||
cause,
|
||||
capturedOutput: {
|
||||
stdout: partialOutput,
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
},
|
||||
});
|
||||
constructor(message = "Rizin exited before completing the active command") {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,6 +452,7 @@ export class RizinDebugSessionManager {
|
||||
commandTail: Promise.resolve(),
|
||||
protocolError: undefined,
|
||||
commandCaptureActive: false,
|
||||
commandOutputDropped: false,
|
||||
discardingOversizedFrame: false,
|
||||
};
|
||||
this.#sessions.set(id, session);
|
||||
@@ -489,7 +473,7 @@ export class RizinDebugSessionManager {
|
||||
launched.process.once("exit", () => {
|
||||
session.exited = true;
|
||||
for (const waiter of session.frameWaiters)
|
||||
waiter.reject(new RizinProcessExitError(""));
|
||||
waiter.reject(new RizinProcessExitError());
|
||||
session.frameWaiters.length = 0;
|
||||
});
|
||||
try {
|
||||
@@ -595,6 +579,22 @@ export class RizinDebugSessionManager {
|
||||
let release: (() => void) | undefined;
|
||||
let acquired = false;
|
||||
let commandSent = false;
|
||||
let commandCapture: RizinCommandCapture | undefined;
|
||||
let interruption: RizinCommandInterruption | undefined;
|
||||
const commandResult = (
|
||||
capture: RizinCommandCapture,
|
||||
completion: "complete" | "unknown",
|
||||
): RizinDebugCommandResult => ({
|
||||
command,
|
||||
output: capture.output,
|
||||
output_scope: "command_and_interleaved_session_output",
|
||||
output_truncated: capture.outputTruncated,
|
||||
completion_status: completion,
|
||||
backend: session.backend,
|
||||
...(capture.outputInvalidUtf8
|
||||
? { output_invalid_utf8: true as const }
|
||||
: {}),
|
||||
});
|
||||
const previous = session.commandTail;
|
||||
session.commandTail = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
@@ -612,12 +612,14 @@ export class RizinDebugSessionManager {
|
||||
session.protocolError !== undefined
|
||||
)
|
||||
return err(new AnalysisInputError("rizin_debug_command"));
|
||||
if (session.frames.length > 0) {
|
||||
if (hasUnclaimedRizinFrames(session)) {
|
||||
const message =
|
||||
"Unsolicited Rizin frames arrived before a command; command output can no longer be safely correlated.";
|
||||
this.#failProtocol(session, message);
|
||||
return err(
|
||||
await this.#stopAfterProtocolFailure(session, undefined, true),
|
||||
await this.#stopAfterProtocolFailure(session, undefined, {
|
||||
commandTurnHeld: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const marker = `__REA_COMMAND_COMPLETE_${randomUUID().replaceAll("-", "")}__`;
|
||||
@@ -626,6 +628,8 @@ export class RizinDebugSessionManager {
|
||||
session.inferiorInventoryComplete = false;
|
||||
session.knownInferiorIdentities.clear();
|
||||
commandSent = true;
|
||||
session.commandCaptureActive = true;
|
||||
session.commandOutputDropped = false;
|
||||
const stdin = session.launched.process.stdin;
|
||||
if (
|
||||
stdin === null ||
|
||||
@@ -654,16 +658,19 @@ export class RizinDebugSessionManager {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (session.protocolError !== undefined)
|
||||
throw new Error(session.protocolError);
|
||||
session.commandCaptureActive = true;
|
||||
const captured = await waitForCommandFrames(
|
||||
const received = await waitForCommandFrames(
|
||||
session,
|
||||
marker,
|
||||
this.#frameTimeoutMs,
|
||||
signal,
|
||||
this.#maxCommandOutputBytes,
|
||||
);
|
||||
commandCapture = received.ok ? received.value : received.error.capture;
|
||||
if (!received.ok) {
|
||||
interruption = received.error;
|
||||
throw interruption.cause;
|
||||
}
|
||||
const captured = received.value;
|
||||
if (signal?.aborted)
|
||||
throw new AnalysisCancelledError("rizin_debug_command");
|
||||
let cleanupFailure: string | undefined;
|
||||
@@ -672,19 +679,10 @@ export class RizinDebugSessionManager {
|
||||
const stopped = await this.#stopDebuggerOnly(session, true);
|
||||
if (stopped.status === "incomplete") cleanupFailure = stopped.reason;
|
||||
}
|
||||
const result = {
|
||||
command,
|
||||
output: captured.output,
|
||||
output_scope: "command_and_interleaved_session_output" as const,
|
||||
output_truncated: captured.outputTruncated,
|
||||
completion_status: captured.outputTruncated
|
||||
? ("unknown" as const)
|
||||
: ("complete" as const),
|
||||
backend: session.backend,
|
||||
...(captured.outputInvalidUtf8
|
||||
? { output_invalid_utf8: true as const }
|
||||
: {}),
|
||||
};
|
||||
const result = commandResult(
|
||||
captured,
|
||||
captured.outputTruncated ? "unknown" : "complete",
|
||||
);
|
||||
const evidence = createRizinCommandEvidence({
|
||||
sessionId,
|
||||
command,
|
||||
@@ -715,43 +713,43 @@ export class RizinDebugSessionManager {
|
||||
);
|
||||
return ok({ result, evidence });
|
||||
} catch (cause) {
|
||||
// Capture is owned by this dispatched command, independently of which
|
||||
// cancellation, protocol or cleanup outcome ultimately takes precedence.
|
||||
const partialResult =
|
||||
commandCapture === undefined
|
||||
? undefined
|
||||
: commandResult(commandCapture, "unknown");
|
||||
const capturedOutput =
|
||||
commandCapture === undefined
|
||||
? undefined
|
||||
: {
|
||||
stdout: commandCapture.output,
|
||||
stderr: "",
|
||||
truncated: commandCapture.outputTruncated,
|
||||
};
|
||||
const partialEvidence = (
|
||||
limitations: readonly string[],
|
||||
): Evidence | undefined =>
|
||||
partialResult === undefined
|
||||
? undefined
|
||||
: createRizinCommandEvidence({
|
||||
sessionId,
|
||||
command,
|
||||
result: partialResult,
|
||||
inferiorTty: session.inferiorTty,
|
||||
launchProvenance: session.launchProvenance,
|
||||
limitations,
|
||||
});
|
||||
if (signal?.aborted || cause instanceof AnalysisCancelledError) {
|
||||
if (!commandSent)
|
||||
return err(
|
||||
new AnalysisCancelledError("rizin_debug_command", { cause }),
|
||||
);
|
||||
const partialOutput =
|
||||
cause instanceof RizinCommandCancelledError
|
||||
? cause.partialOutput
|
||||
: undefined;
|
||||
const commandInvalidUtf8 =
|
||||
cause instanceof RizinCommandCancelledError &&
|
||||
cause.outputInvalidUtf8;
|
||||
const partialEvidence =
|
||||
partialOutput === undefined
|
||||
? undefined
|
||||
: createRizinCommandEvidence({
|
||||
sessionId,
|
||||
command,
|
||||
result: {
|
||||
command,
|
||||
output: partialOutput,
|
||||
output_scope: "command_and_interleaved_session_output",
|
||||
output_truncated: false,
|
||||
completion_status: "unknown",
|
||||
backend: session.backend,
|
||||
...(commandInvalidUtf8
|
||||
? { output_invalid_utf8: true as const }
|
||||
: {}),
|
||||
},
|
||||
inferiorTty: session.inferiorTty,
|
||||
launchProvenance: session.launchProvenance,
|
||||
limitations: [
|
||||
"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.",
|
||||
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
||||
],
|
||||
});
|
||||
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.",
|
||||
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
||||
]);
|
||||
session.protocolError =
|
||||
"A Rizin command was cancelled; the owned session was stopped because command output can no longer be correlated safely.";
|
||||
const stopped = await this.#stopDebuggerOnly(session, true);
|
||||
@@ -764,44 +762,42 @@ export class RizinDebugSessionManager {
|
||||
{
|
||||
cause: new AnalysisCancelledError("rizin_debug_command", {
|
||||
cause,
|
||||
...(cause instanceof RizinCommandCancelledError
|
||||
? { capturedOutput: cause.capturedOutput }
|
||||
: {}),
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
}),
|
||||
...(partialEvidence === undefined
|
||||
...(evidence === undefined
|
||||
? {}
|
||||
: { partialObservation: partialEvidence }),
|
||||
: { partialObservation: evidence }),
|
||||
},
|
||||
),
|
||||
);
|
||||
return err(
|
||||
new AnalysisCancelledError("rizin_debug_command", {
|
||||
cause,
|
||||
...(cause instanceof RizinCommandCancelledError
|
||||
? { capturedOutput: cause.capturedOutput }
|
||||
: {}),
|
||||
...(partialEvidence === undefined
|
||||
? {}
|
||||
: { partialObservation: partialEvidence }),
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
...(evidence === undefined ? {} : { partialObservation: evidence }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (session.protocolError !== undefined)
|
||||
return err(await this.#stopAfterProtocolFailure(session, cause, true));
|
||||
if (cause instanceof RizinCommandTimeoutError) {
|
||||
if (session.protocolError !== undefined) {
|
||||
const evidence = partialEvidence([
|
||||
session.protocolError,
|
||||
"The Rizin protocol failed; retained command output is partial and completion is unknown.",
|
||||
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
||||
]);
|
||||
return err(
|
||||
await this.#stopAfterProtocolFailure(session, cause, {
|
||||
commandTurnHeld: true,
|
||||
...(evidence === undefined ? {} : { partialObservation: evidence }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (interruption?.kind === "timeout") {
|
||||
session.protocolError =
|
||||
"Rizin command timed out before its completion marker; output correlation is no longer safe.";
|
||||
const stopped = await this.#stopDebuggerOnly(session, true);
|
||||
const result: RizinDebugCommandResult = {
|
||||
command,
|
||||
output: cause.partialOutput,
|
||||
output_scope: "command_and_interleaved_session_output",
|
||||
const result = {
|
||||
...commandResult(interruption.capture, "unknown"),
|
||||
output_truncated: true,
|
||||
completion_status: "unknown",
|
||||
backend: session.backend,
|
||||
...(cause.outputInvalidUtf8
|
||||
? { output_invalid_utf8: true as const }
|
||||
: {}),
|
||||
};
|
||||
const evidence = createRizinCommandTimeoutEvidence({
|
||||
sessionId,
|
||||
@@ -826,18 +822,8 @@ export class RizinDebugSessionManager {
|
||||
);
|
||||
return ok({ result, evidence });
|
||||
}
|
||||
if (cause instanceof RizinProcessExitError) {
|
||||
const result: RizinDebugCommandResult = {
|
||||
command,
|
||||
output: cause.partialOutput,
|
||||
output_scope: "command_and_interleaved_session_output",
|
||||
output_truncated: false,
|
||||
completion_status: "unknown",
|
||||
backend: session.backend,
|
||||
...(cause.outputInvalidUtf8
|
||||
? { output_invalid_utf8: true as const }
|
||||
: {}),
|
||||
};
|
||||
if (interruption?.kind === "exited") {
|
||||
const result = commandResult(interruption.capture, "unknown");
|
||||
const evidence = createRizinCommandEvidence({
|
||||
sessionId,
|
||||
command,
|
||||
@@ -863,6 +849,14 @@ export class RizinDebugSessionManager {
|
||||
}
|
||||
session.protocolError =
|
||||
"Rizin did not return the current command-completion marker; the session was stopped to prevent late output from being attributed to a later command.";
|
||||
const evidence = partialEvidence([
|
||||
session.protocolError,
|
||||
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
||||
]);
|
||||
const errorOptions = {
|
||||
cause,
|
||||
...(evidence === undefined ? {} : { partialObservation: evidence }),
|
||||
};
|
||||
const stopped = await this.#stopDebuggerOnly(session, true);
|
||||
if (stopped.status === "incomplete")
|
||||
return err(
|
||||
@@ -870,13 +864,15 @@ export class RizinDebugSessionManager {
|
||||
session,
|
||||
"rizin_debug_command",
|
||||
stopped.reason,
|
||||
{ cause },
|
||||
errorOptions,
|
||||
),
|
||||
);
|
||||
return err(
|
||||
new AnalysisTimeoutError("rizin_debug_command", this.#frameTimeoutMs, {
|
||||
cause,
|
||||
}),
|
||||
new AnalysisTimeoutError(
|
||||
"rizin_debug_command",
|
||||
this.#frameTimeoutMs,
|
||||
errorOptions,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (acquired) {
|
||||
@@ -994,7 +990,6 @@ export class RizinDebugSessionManager {
|
||||
if (session.discardingOversizedFrame) {
|
||||
if (boundary < 0) return;
|
||||
session.discardingOversizedFrame = false;
|
||||
this.#deliverBufferedFrame(session, true);
|
||||
offset = nextOffset;
|
||||
continue;
|
||||
}
|
||||
@@ -1016,6 +1011,7 @@ export class RizinDebugSessionManager {
|
||||
}
|
||||
if (boundary < 0) {
|
||||
session.discardingOversizedFrame = true;
|
||||
this.#deliverBufferedFrame(session, true);
|
||||
return;
|
||||
}
|
||||
this.#deliverBufferedFrame(session, true);
|
||||
@@ -1033,30 +1029,15 @@ export class RizinDebugSessionManager {
|
||||
}
|
||||
|
||||
#deliverBufferedFrame(session: RizinDebugSession, truncated: boolean): void {
|
||||
const frameBytes = Buffer.concat(
|
||||
session.frameChunks,
|
||||
session.bufferedFrameBytes,
|
||||
);
|
||||
const captured = bufferedRizinFrame(session, truncated);
|
||||
session.frameChunks = [];
|
||||
session.bufferedFrameBytes = 0;
|
||||
const invalidUtf8 = !isUtf8(frameBytes);
|
||||
session.invalidUtf8Observed ||= invalidUtf8;
|
||||
this.#deliverFrame(
|
||||
session,
|
||||
{ text: frameBytes.toString("utf8"), truncated, invalidUtf8 },
|
||||
frameBytes.length,
|
||||
);
|
||||
this.#deliverFrame(session, captured.frame, captured.bytes);
|
||||
}
|
||||
|
||||
#startupCapturedOutput(session: RizinDebugSession) {
|
||||
const output = session.supervisor.snapshot();
|
||||
const partialFrame =
|
||||
session.bufferedFrameBytes === 0
|
||||
? ""
|
||||
: Buffer.concat(
|
||||
session.frameChunks,
|
||||
session.bufferedFrameBytes,
|
||||
).toString("utf8");
|
||||
const partialFrame = bufferedRizinFrame(session, false).frame.text;
|
||||
const protocolOutput = [...session.history, partialFrame]
|
||||
.filter((frame) => frame.length > 0)
|
||||
.join("\n");
|
||||
@@ -1109,6 +1090,7 @@ export class RizinDebugSessionManager {
|
||||
session.frames.length >= MAX_QUEUED_FRAMES ||
|
||||
session.queuedFrameBytes + queueChargeBytes > MAX_QUEUED_FRAME_BYTES
|
||||
) {
|
||||
session.commandOutputDropped ||= session.commandCaptureActive;
|
||||
this.#failProtocol(
|
||||
session,
|
||||
"Rizin produced unsolicited frames exceeding the bounded queue limit.",
|
||||
@@ -1121,8 +1103,10 @@ export class RizinDebugSessionManager {
|
||||
|
||||
#failProtocol(session: RizinDebugSession, message: string): void {
|
||||
session.protocolError = message;
|
||||
session.frameChunks = [];
|
||||
session.bufferedFrameBytes = 0;
|
||||
if (!session.commandCaptureActive) {
|
||||
session.frameChunks = [];
|
||||
session.bufferedFrameBytes = 0;
|
||||
}
|
||||
for (const waiter of session.frameWaiters)
|
||||
waiter.reject(new Error(message));
|
||||
session.frameWaiters.length = 0;
|
||||
@@ -1263,6 +1247,10 @@ export class RizinDebugSessionManager {
|
||||
session: RizinDebugSession,
|
||||
command: string,
|
||||
): Promise<string> {
|
||||
if (hasUnclaimedRizinFrames(session))
|
||||
throw new Error(
|
||||
"Unsolicited Rizin output prevents correlated cleanup readback.",
|
||||
);
|
||||
const marker = `__REA_CLEANUP_COMPLETE_${randomUUID().replaceAll("-", "")}__`;
|
||||
const stdin = session.launched.process.stdin;
|
||||
if (
|
||||
@@ -1280,9 +1268,10 @@ export class RizinDebugSessionManager {
|
||||
undefined,
|
||||
this.#maxCommandOutputBytes,
|
||||
);
|
||||
if (captured.outputTruncated)
|
||||
if (!captured.ok) throw captured.error.cause;
|
||||
if (captured.value.outputTruncated)
|
||||
throw new Error("Rizin cleanup readback was truncated.");
|
||||
return captured.output;
|
||||
return captured.value.output;
|
||||
}
|
||||
|
||||
#retainCleanupFailure(
|
||||
@@ -1304,21 +1293,33 @@ export class RizinDebugSessionManager {
|
||||
async #stopAfterProtocolFailure(
|
||||
session: RizinDebugSession,
|
||||
cause: unknown,
|
||||
commandTurnHeld = false,
|
||||
options: {
|
||||
readonly commandTurnHeld?: boolean;
|
||||
readonly partialObservation?: Evidence;
|
||||
} = {},
|
||||
): Promise<AnalysisError> {
|
||||
const stopped = await this.#stopDebuggerOnly(session, commandTurnHeld);
|
||||
const stopped = await this.#stopDebuggerOnly(
|
||||
session,
|
||||
options.commandTurnHeld,
|
||||
);
|
||||
const errorOptions = {
|
||||
cause,
|
||||
...(options.partialObservation === undefined
|
||||
? {}
|
||||
: { partialObservation: options.partialObservation }),
|
||||
};
|
||||
return stopped.status === "incomplete"
|
||||
? this.#retainCleanupFailure(
|
||||
session,
|
||||
"rizin_debug_command",
|
||||
stopped.reason,
|
||||
{ cause },
|
||||
errorOptions,
|
||||
)
|
||||
: new AnalysisCapabilityUnavailableError(
|
||||
"rizin",
|
||||
"rizin_debug_command",
|
||||
session.protocolError!,
|
||||
{ cause },
|
||||
errorOptions,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1397,139 +1398,167 @@ export const assertRizinProcessStartTimeStable = (
|
||||
const readProcStartTime = async (pid: string): Promise<string> =>
|
||||
parseRizinProcStartTime(await readFile(`/proc/${pid}/stat`, "utf8"));
|
||||
|
||||
const hasUnclaimedRizinFrames = (session: RizinDebugSession): boolean =>
|
||||
session.frames.length > 0 ||
|
||||
session.bufferedFrameBytes > 0 ||
|
||||
session.discardingOversizedFrame;
|
||||
|
||||
/** Parse the receiver's bounded bytes once for normal, startup and interrupted reads. */
|
||||
const bufferedRizinFrame = (session: RizinDebugSession, truncated: boolean) => {
|
||||
const bytes = Buffer.concat(session.frameChunks, session.bufferedFrameBytes);
|
||||
const invalidUtf8 = !isUtf8(bytes);
|
||||
session.invalidUtf8Observed ||= invalidUtf8;
|
||||
return {
|
||||
bytes: bytes.length,
|
||||
frame: { text: bytes.toString("utf8"), truncated, invalidUtf8 },
|
||||
};
|
||||
};
|
||||
|
||||
const waitForCommandFrames = async (
|
||||
session: RizinDebugSession,
|
||||
marker: string,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
maxCommandOutputBytes = MAX_COMMAND_OUTPUT_BYTES,
|
||||
): Promise<{
|
||||
readonly output: string;
|
||||
readonly outputTruncated: boolean;
|
||||
readonly outputInvalidUtf8: boolean;
|
||||
}> => {
|
||||
): Promise<Result<RizinCommandCapture, RizinCommandInterruption>> => {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
const output: string[] = [];
|
||||
let outputInvalidUtf8 = false;
|
||||
const commandTimeoutError = (): RizinCommandTimeoutError =>
|
||||
createRizinCommandTimeoutError(output, outputInvalidUtf8);
|
||||
let outputBytes = 0;
|
||||
let outputInvalidUtf8 = false;
|
||||
let outputTruncated = false;
|
||||
let frameCount = 0;
|
||||
const snapshot = () => ({
|
||||
output: output.join("\n"),
|
||||
outputTruncated: outputTruncated || session.commandOutputDropped,
|
||||
outputInvalidUtf8,
|
||||
});
|
||||
const append = (frame: RizinFrame): boolean => {
|
||||
outputInvalidUtf8 ||= frame.invalidUtf8;
|
||||
outputTruncated ||= frame.truncated;
|
||||
const markerIndex = frame.text.indexOf(marker);
|
||||
const text =
|
||||
markerIndex < 0 ? frame.text : frame.text.slice(0, markerIndex).trimEnd();
|
||||
if (markerIndex >= 0 && text.length === 0) return true;
|
||||
const separatorBytes = output.length > 0 ? 1 : 0;
|
||||
const remaining = maxCommandOutputBytes - outputBytes - separatorBytes;
|
||||
const bytes = Buffer.byteLength(text, "utf8");
|
||||
if (bytes > remaining) {
|
||||
outputTruncated = true;
|
||||
const retained = truncateUtf8(text, remaining);
|
||||
if (retained.length > 0) output.push(retained);
|
||||
outputBytes = maxCommandOutputBytes;
|
||||
} else {
|
||||
output.push(text);
|
||||
outputBytes += separatorBytes + bytes;
|
||||
}
|
||||
return markerIndex >= 0;
|
||||
};
|
||||
// After dispatch, queued complete frames and the unfinished frame belong to
|
||||
// this command interval. Never publish the private completion marker or bytes
|
||||
// following it, and apply the same decoded UTF-8 budget as normal frames.
|
||||
const interrupted = () => {
|
||||
let markerSeen = false;
|
||||
for (;;) {
|
||||
const queued = session.frames.shift();
|
||||
if (queued === undefined) break;
|
||||
session.queuedFrameBytes -= queued.queueChargeBytes;
|
||||
frameCount += 1;
|
||||
if (frameCount > MAX_COMMAND_FRAMES) {
|
||||
outputTruncated = true;
|
||||
break;
|
||||
}
|
||||
markerSeen = append(queued.frame);
|
||||
if (markerSeen || outputTruncated) break;
|
||||
}
|
||||
if (!markerSeen && !outputTruncated && session.bufferedFrameBytes > 0)
|
||||
append(
|
||||
bufferedRizinFrame(session, session.discardingOversizedFrame).frame,
|
||||
);
|
||||
else if (session.bufferedFrameBytes > 0 && !markerSeen)
|
||||
outputTruncated = true;
|
||||
session.frameChunks = [];
|
||||
session.bufferedFrameBytes = 0;
|
||||
return snapshot();
|
||||
};
|
||||
for (;;) {
|
||||
if (session.protocolError !== undefined) {
|
||||
const capture = interrupted();
|
||||
// Capacity failures already have a bounded partial-result contract.
|
||||
// Truncation still forces debugger cleanup before that result is returned.
|
||||
if (session.commandOutputDropped) return ok(capture);
|
||||
return err({
|
||||
kind: "failed",
|
||||
cause: new Error(session.protocolError),
|
||||
capture,
|
||||
});
|
||||
}
|
||||
const remaining = deadline - Date.now();
|
||||
if (isRizinCommandDeadlineExpired(remaining)) throw commandTimeoutError();
|
||||
if (isRizinCommandDeadlineExpired(remaining))
|
||||
return err({
|
||||
kind: "timeout",
|
||||
cause: new RizinFrameTimeoutError(),
|
||||
capture: interrupted(),
|
||||
});
|
||||
let received: RizinFrame;
|
||||
try {
|
||||
received = await waitForFrame(
|
||||
session,
|
||||
remaining,
|
||||
signal,
|
||||
commandTimeoutError,
|
||||
);
|
||||
received = await waitForFrame(session, remaining, signal);
|
||||
} catch (cause) {
|
||||
if (cause instanceof AnalysisCancelledError)
|
||||
throw new RizinCommandCancelledError(
|
||||
output.join("\n"),
|
||||
outputInvalidUtf8,
|
||||
cause,
|
||||
let drained = true;
|
||||
if (cause instanceof RizinProcessExitError) {
|
||||
// `exit` observes the leader, not its pipe lifetime. Keep the bounded
|
||||
// receiver active until the supervisor observes output closure, using
|
||||
// the same settlement interval as owned-debugger cleanup.
|
||||
drained = await session.supervisor.waitForOutputClose(
|
||||
CLEANUP_COMMAND_TIMEOUT_MS,
|
||||
);
|
||||
if (cause instanceof RizinProcessExitError)
|
||||
throw new RizinProcessExitError(output.join("\n"), outputInvalidUtf8);
|
||||
throw cause;
|
||||
}
|
||||
const captured = interrupted();
|
||||
return err({
|
||||
kind:
|
||||
cause instanceof AnalysisCancelledError
|
||||
? "cancelled"
|
||||
: cause instanceof RizinProcessExitError
|
||||
? "exited"
|
||||
: cause instanceof RizinFrameTimeoutError
|
||||
? "timeout"
|
||||
: "failed",
|
||||
cause,
|
||||
capture: {
|
||||
...captured,
|
||||
outputTruncated: captured.outputTruncated || !drained,
|
||||
},
|
||||
});
|
||||
}
|
||||
outputInvalidUtf8 ||= received.invalidUtf8;
|
||||
frameCount += 1;
|
||||
if (frameCount > MAX_COMMAND_FRAMES) {
|
||||
const message =
|
||||
session.protocolError =
|
||||
"Rizin emitted more than 4096 frames for one command; the session was stopped because output correlation is ambiguous.";
|
||||
session.protocolError = message;
|
||||
return {
|
||||
output: output.join("\n"),
|
||||
outputTruncated: true,
|
||||
outputInvalidUtf8,
|
||||
};
|
||||
outputTruncated = true;
|
||||
return ok(snapshot());
|
||||
}
|
||||
if (received.truncated) {
|
||||
const separatorBytes = output.length > 0 ? 1 : 0;
|
||||
output.push(
|
||||
truncateUtf8(
|
||||
received.text,
|
||||
maxCommandOutputBytes - outputBytes - separatorBytes,
|
||||
),
|
||||
);
|
||||
return {
|
||||
output: output.join("\n"),
|
||||
outputTruncated: true,
|
||||
outputInvalidUtf8,
|
||||
};
|
||||
const markerSeen = append(received);
|
||||
if (outputTruncated) {
|
||||
session.protocolError ??=
|
||||
"Rizin command output exceeded the 16 MiB command limit.";
|
||||
return ok(snapshot());
|
||||
}
|
||||
const frame = received.text;
|
||||
const markerIndex = frame.indexOf(marker);
|
||||
if (markerIndex >= 0) {
|
||||
const beforeMarker = frame.slice(0, markerIndex).trimEnd();
|
||||
if (beforeMarker.length > 0) {
|
||||
const beforeMarkerBytes = Buffer.byteLength(beforeMarker, "utf8");
|
||||
const separatorBytes = output.length > 0 ? 1 : 0;
|
||||
if (
|
||||
outputBytes + separatorBytes + beforeMarkerBytes >
|
||||
maxCommandOutputBytes
|
||||
) {
|
||||
const message =
|
||||
"Rizin command output exceeded the 16 MiB command limit.";
|
||||
session.protocolError = message;
|
||||
const retained = truncateUtf8(
|
||||
beforeMarker,
|
||||
maxCommandOutputBytes - outputBytes - separatorBytes,
|
||||
);
|
||||
if (retained.length > 0) output.push(retained);
|
||||
return {
|
||||
output: output.join("\n"),
|
||||
outputTruncated: true,
|
||||
outputInvalidUtf8,
|
||||
};
|
||||
}
|
||||
outputBytes += separatorBytes + beforeMarkerBytes;
|
||||
output.push(beforeMarker);
|
||||
}
|
||||
return {
|
||||
output: output.join("\n"),
|
||||
outputTruncated: false,
|
||||
outputInvalidUtf8,
|
||||
};
|
||||
}
|
||||
const frameBytes = Buffer.byteLength(frame, "utf8");
|
||||
const separatorBytes = output.length > 0 ? 1 : 0;
|
||||
if (outputBytes + separatorBytes + frameBytes > maxCommandOutputBytes) {
|
||||
const message = "Rizin command output exceeded the 16 MiB command limit.";
|
||||
session.protocolError = message;
|
||||
output.push(
|
||||
truncateUtf8(
|
||||
frame,
|
||||
maxCommandOutputBytes - outputBytes - separatorBytes,
|
||||
),
|
||||
);
|
||||
return {
|
||||
output: output.join("\n"),
|
||||
outputTruncated: true,
|
||||
outputInvalidUtf8,
|
||||
};
|
||||
}
|
||||
outputBytes += separatorBytes + frameBytes;
|
||||
output.push(frame);
|
||||
if (markerSeen) return ok(snapshot());
|
||||
}
|
||||
};
|
||||
|
||||
const truncateUtf8 = (value: string, maximumBytes: number): string => {
|
||||
if (maximumBytes <= 0) return "";
|
||||
const bytes = Buffer.from(value, "utf8").subarray(0, maximumBytes);
|
||||
return bytes.toString("utf8").replace(/\uFFFD$/u, "");
|
||||
const bytes = Buffer.from(value, "utf8");
|
||||
let end = Math.min(bytes.length, maximumBytes);
|
||||
while (end > 0 && end < bytes.length && (bytes[end]! & 0xc0) === 0x80)
|
||||
end -= 1;
|
||||
return bytes.subarray(0, end).toString("utf8");
|
||||
};
|
||||
|
||||
const waitForFrame = (
|
||||
session: RizinDebugSession,
|
||||
timeoutMs: number,
|
||||
signal?: AbortSignal,
|
||||
timeoutError?: () => Error,
|
||||
): Promise<RizinFrame> => {
|
||||
if (signal?.aborted)
|
||||
return Promise.reject(new AnalysisCancelledError("rizin_debug_command"));
|
||||
@@ -1538,7 +1567,7 @@ const waitForFrame = (
|
||||
session.queuedFrameBytes -= queued.queueChargeBytes;
|
||||
return Promise.resolve(queued.frame);
|
||||
}
|
||||
if (session.exited) return Promise.reject(new RizinProcessExitError(""));
|
||||
if (session.exited) return Promise.reject(new RizinProcessExitError());
|
||||
return new Promise((resolve, reject) => {
|
||||
// Settlement removes all three trigger paths before resolving or rejecting.
|
||||
const cleanup = (): void => {
|
||||
@@ -1553,7 +1582,7 @@ const waitForFrame = (
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(timeoutError?.() ?? new RizinFrameTimeoutError());
|
||||
reject(new RizinFrameTimeoutError());
|
||||
}, timeoutMs);
|
||||
const waiter = {
|
||||
resolve: (frame: RizinFrame): void => {
|
||||
|
||||
@@ -42,7 +42,8 @@ export default {
|
||||
},
|
||||
{
|
||||
kind: "declaration",
|
||||
name: "createRizinCommandTimeoutError",
|
||||
name: "snapshot",
|
||||
withinBlockWithBinding: "output",
|
||||
},
|
||||
{
|
||||
kind: "declaration",
|
||||
|
||||
@@ -275,6 +275,7 @@ it.each([
|
||||
"tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts",
|
||||
"tests/boundary/mcp/gdbCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/rizinPendingOutput.test.ts",
|
||||
"tests/composition/reverseEngineeringAdmission.test.ts",
|
||||
"tests/support/reverseEngineeringMcpHarness.ts",
|
||||
"tests/support/reverseEngineeringCompositionEdgeCases.ts",
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { McpServer } from "@modelcontextprotocol/server";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { registerRizinDebugTools } from "../../../src/server/registerRizinDebugTools.js";
|
||||
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { createInjectedProcess } from "../providers/RizinDebugSessionCoverage.fixtures.js";
|
||||
import {
|
||||
createRizinTestManager,
|
||||
rizinTestInferiorTty,
|
||||
} from "../providers/RizinTestInferiorTty.js";
|
||||
|
||||
const test = it.skipIf(process.platform === "win32");
|
||||
|
||||
test.each([
|
||||
"exit",
|
||||
"deadline",
|
||||
"framed-exit",
|
||||
"drained-exit",
|
||||
"drained-framed-exit",
|
||||
] as const)(
|
||||
"retains received command bytes through MCP after %s",
|
||||
async (mode) => {
|
||||
const injected = createInjectedProcess({
|
||||
onWrite: (text, control) => {
|
||||
if (!text.includes("!echo __REA_COMMAND_COMPLETE_")) return;
|
||||
setImmediate(() => {
|
||||
control.output("complete prefix\0");
|
||||
if (mode.startsWith("drained-")) {
|
||||
control.exitLeader();
|
||||
setImmediate(() => {
|
||||
control.output("unfinished diagnostic Ω");
|
||||
if (mode === "drained-framed-exit") control.output("\0");
|
||||
control.closeOutput();
|
||||
});
|
||||
return;
|
||||
}
|
||||
control.output("unfinished diagnostic Ω");
|
||||
if (mode === "framed-exit") control.output("\0");
|
||||
if (mode !== "deadline") control.exit();
|
||||
});
|
||||
},
|
||||
});
|
||||
const manager = createRizinTestManager(
|
||||
{},
|
||||
{
|
||||
spawnProcess: injected.spawnProcess,
|
||||
frameTimeoutMs: 100,
|
||||
},
|
||||
);
|
||||
const server = new McpServer({ name: "rizin-partial-test", version: "1" });
|
||||
registerRizinDebugTools(
|
||||
server,
|
||||
manager,
|
||||
silentLogger,
|
||||
createToolResultDelivery(undefined),
|
||||
);
|
||||
const client = new Client({ name: "rizin-partial-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const startReply = await client.callTool({
|
||||
name: "start_rizin_debug_session",
|
||||
arguments: {
|
||||
path: "fixture.bin",
|
||||
inferior_tty: await rizinTestInferiorTty(),
|
||||
},
|
||||
});
|
||||
expect(startReply.isError).not.toBe(true);
|
||||
const started = toolContract(
|
||||
"start_rizin_debug_session",
|
||||
).outputSchema.parse(startReply.structuredContent);
|
||||
const reply = await client.callTool({
|
||||
name: "rizin_debug_command",
|
||||
arguments: { session_id: started.session_id, command: "dr" },
|
||||
});
|
||||
expect(reply.isError).not.toBe(true);
|
||||
const expected = {
|
||||
output: "complete prefix\nunfinished diagnostic Ω",
|
||||
completion_status: "unknown",
|
||||
output_truncated: mode === "deadline",
|
||||
};
|
||||
expect(reply.structuredContent).toMatchObject({
|
||||
normalized_result: expected,
|
||||
raw_result: expected,
|
||||
});
|
||||
} finally {
|
||||
injected.control.exit();
|
||||
await client.close();
|
||||
await server.close();
|
||||
await manager.closeAll();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ mode: "exit", maximum: 10, invalid: true, expected: "first\n�A" },
|
||||
{ mode: "cancel", maximum: 9, invalid: true, expected: "first\n�" },
|
||||
{ mode: "exit", maximum: 9, invalid: false, expected: "first\n�" },
|
||||
{ mode: "cancel", maximum: 8, invalid: true, expected: "first" },
|
||||
])(
|
||||
"bounds pending UTF-8 after $mode (budget: $maximum, invalid: $invalid)",
|
||||
async ({ mode, maximum, invalid, expected }) => {
|
||||
const controller = new AbortController();
|
||||
const injected = createInjectedProcess({
|
||||
onWrite: (text, control) => {
|
||||
if (!text.includes("!echo __REA_COMMAND_COMPLETE_")) return;
|
||||
control.output(
|
||||
Buffer.concat([
|
||||
Buffer.from("first\0"),
|
||||
invalid ? Buffer.from([0xff]) : Buffer.from("�"),
|
||||
Buffer.from("ABCDEFGHIJKLM"),
|
||||
]),
|
||||
);
|
||||
if (mode === "exit") control.exit();
|
||||
else controller.abort();
|
||||
},
|
||||
});
|
||||
const manager = createRizinTestManager(
|
||||
{},
|
||||
{
|
||||
spawnProcess: injected.spawnProcess,
|
||||
maxCommandOutputBytes: maximum,
|
||||
},
|
||||
);
|
||||
try {
|
||||
const started = await manager.start({
|
||||
path: "fixture.bin",
|
||||
inferior_tty: await rizinTestInferiorTty(),
|
||||
});
|
||||
if (!started.ok) throw started.error;
|
||||
const result = await manager.execute(
|
||||
started.value.session_id,
|
||||
"dr",
|
||||
controller.signal,
|
||||
);
|
||||
const evidence = result.ok
|
||||
? result.value.evidence
|
||||
: result.error.partialObservation;
|
||||
expect(evidence).toMatchObject({
|
||||
normalized_result: {
|
||||
output: expected,
|
||||
output_truncated: true,
|
||||
completion_status: "unknown",
|
||||
},
|
||||
raw_result: { output: expected, output_truncated: true },
|
||||
});
|
||||
if (invalid)
|
||||
expect(evidence).toMatchObject({
|
||||
normalized_result: { output_invalid_utf8: true },
|
||||
});
|
||||
else
|
||||
expect(evidence).not.toHaveProperty(
|
||||
"normalized_result.output_invalid_utf8",
|
||||
);
|
||||
if (!result.ok) expect(result.error.capturedOutput?.truncated).toBe(true);
|
||||
expect(
|
||||
manager.status(started.value.session_id)?.output_invalid_utf8,
|
||||
).toBe(invalid);
|
||||
} finally {
|
||||
injected.control.exit();
|
||||
await manager.closeAll();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects an unfinished unsolicited frame before command dispatch", async () => {
|
||||
let dispatched = false;
|
||||
const injected = createInjectedProcess({
|
||||
onWrite: (text, control) => {
|
||||
const marker = /!echo (__REA_COMMAND_COMPLETE_\w+)/u.exec(text)?.[1];
|
||||
if (marker === undefined) return;
|
||||
dispatched = true;
|
||||
setImmediate(() => control.output(`new command\0${marker}\n\0`));
|
||||
},
|
||||
});
|
||||
const manager = createRizinTestManager(
|
||||
{},
|
||||
{ spawnProcess: injected.spawnProcess },
|
||||
);
|
||||
try {
|
||||
const started = await manager.start({
|
||||
path: "fixture.bin",
|
||||
inferior_tty: await rizinTestInferiorTty(),
|
||||
});
|
||||
if (!started.ok) throw started.error;
|
||||
injected.control.output("unclaimed old output");
|
||||
const result = await manager.execute(started.value.session_id, "dr");
|
||||
expect(result.ok).toBe(false);
|
||||
expect(dispatched).toBe(false);
|
||||
} finally {
|
||||
injected.control.exit();
|
||||
await manager.closeAll();
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["_", "__REA_", "marker-prefix"])(
|
||||
"preserves ambiguous diagnostic suffix %s on interruption",
|
||||
async (suffix) => {
|
||||
let expected = "";
|
||||
const injected = createInjectedProcess({
|
||||
onWrite: (text, control) => {
|
||||
const marker = /!echo (__REA_COMMAND_COMPLETE_\w+)/u.exec(text)?.[1];
|
||||
if (marker === undefined) return;
|
||||
expected = `literal diagnostic ${suffix === "marker-prefix" ? marker.slice(0, -5) : suffix}`;
|
||||
control.output(expected);
|
||||
control.exit();
|
||||
},
|
||||
});
|
||||
const manager = createRizinTestManager(
|
||||
{},
|
||||
{ spawnProcess: injected.spawnProcess },
|
||||
);
|
||||
try {
|
||||
const started = await manager.start({
|
||||
path: "fixture.bin",
|
||||
inferior_tty: await rizinTestInferiorTty(),
|
||||
});
|
||||
if (!started.ok) throw started.error;
|
||||
const result = await manager.execute(started.value.session_id, "dr");
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.evidence).toMatchObject({
|
||||
normalized_result: { output: expected, completion_status: "unknown" },
|
||||
raw_result: { output: expected },
|
||||
});
|
||||
expect(result.value.evidence.limitations).toContain(
|
||||
"Interrupted output may include an incomplete completion-marker prefix; it is retained because it cannot be distinguished from command diagnostics.",
|
||||
);
|
||||
} finally {
|
||||
injected.control.exit();
|
||||
await manager.closeAll();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("retains partial bytes and uncertainty when exited output never closes", async () => {
|
||||
const injected = createInjectedProcess({
|
||||
onWrite: (text, control) => {
|
||||
if (!text.includes("!echo __REA_COMMAND_COMPLETE_")) return;
|
||||
control.output("retained before exit");
|
||||
control.exitLeader();
|
||||
},
|
||||
});
|
||||
const manager = createRizinTestManager(
|
||||
{},
|
||||
{ spawnProcess: injected.spawnProcess },
|
||||
);
|
||||
try {
|
||||
const started = await manager.start({
|
||||
path: "fixture.bin",
|
||||
inferior_tty: await rizinTestInferiorTty(),
|
||||
});
|
||||
if (!started.ok) throw started.error;
|
||||
const result = await manager.execute(started.value.session_id, "dr");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected retained cleanup uncertainty");
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
normalized_result: {
|
||||
output: "retained before exit",
|
||||
output_truncated: true,
|
||||
completion_status: "unknown",
|
||||
},
|
||||
});
|
||||
expect(manager.status(started.value.session_id)?.state).toBe("closed");
|
||||
} finally {
|
||||
injected.control.closeOutput();
|
||||
await manager.closeAll();
|
||||
}
|
||||
});
|
||||
|
||||
test.each(["cancel", "overflow", "cancel-overflow"] as const)(
|
||||
"preserves exit-drain evidence across %s",
|
||||
async (mode) => {
|
||||
const controller = new AbortController();
|
||||
const injected = createInjectedProcess({
|
||||
onWrite: (text, control) => {
|
||||
if (!text.includes("!echo __REA_COMMAND_COMPLETE_")) return;
|
||||
setImmediate(() => {
|
||||
control.output("before exit\0");
|
||||
control.exitLeader();
|
||||
setImmediate(() => {
|
||||
control.output("after exit\0");
|
||||
if (mode.includes("overflow"))
|
||||
control.output("late\0".repeat(4096));
|
||||
if (mode.includes("cancel")) controller.abort();
|
||||
control.closeOutput();
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const manager = createRizinTestManager(
|
||||
{},
|
||||
{ spawnProcess: injected.spawnProcess },
|
||||
);
|
||||
try {
|
||||
const started = await manager.start({
|
||||
path: "fixture.bin",
|
||||
inferior_tty: await rizinTestInferiorTty(),
|
||||
});
|
||||
if (!started.ok) throw started.error;
|
||||
const result = await manager.execute(
|
||||
started.value.session_id,
|
||||
"dr",
|
||||
controller.signal,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected interrupted command");
|
||||
const expected = mode.includes("overflow")
|
||||
? [
|
||||
"before exit",
|
||||
"after exit",
|
||||
...Array<string>(4094).fill("late"),
|
||||
].join("\n")
|
||||
: "before exit\nafter exit";
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
normalized_result: {
|
||||
output: expected,
|
||||
output_truncated: mode.includes("overflow"),
|
||||
completion_status: "unknown",
|
||||
},
|
||||
raw_result: { output: expected },
|
||||
});
|
||||
expect(manager.status(started.value.session_id)?.state).toBe("closed");
|
||||
} finally {
|
||||
injected.control.exit();
|
||||
await manager.closeAll();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["before-wait", "during-wait"])(
|
||||
"preserves accepted bytes on stdin failure %s",
|
||||
async (mode) => {
|
||||
const injected = createInjectedProcess({
|
||||
onWrite: (text, control) => {
|
||||
if (!text.includes("!echo __REA_COMMAND_COMPLETE_")) return;
|
||||
const fail = () => {
|
||||
control.output("accepted diagnostic Ω");
|
||||
control.stdinError();
|
||||
};
|
||||
if (mode === "before-wait") fail();
|
||||
else setImmediate(fail);
|
||||
},
|
||||
});
|
||||
const manager = createRizinTestManager(
|
||||
{},
|
||||
{ spawnProcess: injected.spawnProcess },
|
||||
);
|
||||
try {
|
||||
const started = await manager.start({
|
||||
path: "fixture.bin",
|
||||
inferior_tty: await rizinTestInferiorTty(),
|
||||
});
|
||||
if (!started.ok) throw started.error;
|
||||
const result = await manager.execute(started.value.session_id, "dr");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected stdin protocol failure");
|
||||
expect(result.error.partialObservation).toMatchObject({
|
||||
normalized_result: {
|
||||
output: "accepted diagnostic Ω",
|
||||
output_truncated: false,
|
||||
completion_status: "unknown",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
injected.control.exit();
|
||||
await manager.closeAll();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("marks output discarded by the byte queue limit during exit drainage", async () => {
|
||||
const size = 1024 * 1024;
|
||||
const injected = createInjectedProcess({
|
||||
onWrite: (text, control) => {
|
||||
if (!text.includes("!echo __REA_COMMAND_COMPLETE_")) return;
|
||||
setImmediate(() => {
|
||||
control.output("before exit\0");
|
||||
control.exitLeader();
|
||||
setImmediate(() => {
|
||||
const frame = Buffer.alloc(size + 1, "x");
|
||||
frame[size] = 0;
|
||||
for (let index = 0; index < 5; index += 1) control.output(frame);
|
||||
control.closeOutput();
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
const manager = createRizinTestManager(
|
||||
{},
|
||||
{ spawnProcess: injected.spawnProcess },
|
||||
);
|
||||
try {
|
||||
const started = await manager.start({
|
||||
path: "fixture.bin",
|
||||
inferior_tty: await rizinTestInferiorTty(),
|
||||
});
|
||||
if (!started.ok) throw started.error;
|
||||
const result = await manager.execute(started.value.session_id, "dr");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected bounded queue failure");
|
||||
const observation = result.error.partialObservation;
|
||||
expect(observation).toMatchObject({
|
||||
normalized_result: {
|
||||
output_truncated: true,
|
||||
completion_status: "unknown",
|
||||
},
|
||||
});
|
||||
const output = toolContract("rizin_debug_command").outputSchema.parse(
|
||||
observation,
|
||||
).normalized_result.output;
|
||||
const frames = output.split("\n");
|
||||
expect(frames.map((frame) => frame.length)).toEqual([
|
||||
"before exit".length,
|
||||
size,
|
||||
size,
|
||||
size,
|
||||
size,
|
||||
]);
|
||||
expect(frames.shift()).toBe("before exit");
|
||||
expect(frames.every((frame) => /^x+$/u.test(frame))).toBe(true);
|
||||
} finally {
|
||||
injected.control.exit();
|
||||
await manager.closeAll();
|
||||
}
|
||||
});
|
||||
|
||||
test("publishes the bounded prefix when an oversized frame has no terminator", async () => {
|
||||
const injected = createInjectedProcess({
|
||||
onWrite: (text, control) => {
|
||||
if (text.includes("!echo __REA_COMMAND_COMPLETE_"))
|
||||
setImmediate(() =>
|
||||
control.output(Buffer.alloc(16 * 1024 * 1024 + 1, "x")),
|
||||
);
|
||||
},
|
||||
});
|
||||
const manager = createRizinTestManager(
|
||||
{},
|
||||
{
|
||||
spawnProcess: injected.spawnProcess,
|
||||
maxCommandOutputBytes: 20,
|
||||
frameTimeoutMs: 100,
|
||||
},
|
||||
);
|
||||
try {
|
||||
const started = await manager.start({
|
||||
path: "fixture.bin",
|
||||
inferior_tty: await rizinTestInferiorTty(),
|
||||
});
|
||||
if (!started.ok) throw started.error;
|
||||
const result = await manager.execute(started.value.session_id, "dr");
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.evidence).toMatchObject({
|
||||
normalized_result: {
|
||||
output: "x".repeat(20),
|
||||
output_truncated: true,
|
||||
completion_status: "unknown",
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
injected.control.exit();
|
||||
await manager.closeAll();
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createRizinCommandTimeoutError,
|
||||
isRizinCommandDeadlineExpired,
|
||||
RizinCommandTimeoutError,
|
||||
} from "../../../src/rizin/RizinDebugSessionManager.js";
|
||||
import { isRizinCommandDeadlineExpired } from "../../../src/rizin/RizinDebugSessionManager.js";
|
||||
|
||||
describe("Rizin command timeout semantics", () => {
|
||||
it("treats zero and negative remaining time as expired", () => {
|
||||
@@ -12,17 +8,4 @@ describe("Rizin command timeout semantics", () => {
|
||||
expect(isRizinCommandDeadlineExpired(0)).toBe(true);
|
||||
expect(isRizinCommandDeadlineExpired(-1)).toBe(true);
|
||||
});
|
||||
|
||||
it("retains partial frames separated by newlines in the timeout error", () => {
|
||||
const error = createRizinCommandTimeoutError([
|
||||
"first frame",
|
||||
"second frame",
|
||||
]);
|
||||
|
||||
expect(error).toBeInstanceOf(RizinCommandTimeoutError);
|
||||
expect(error.partialOutput).toBe("first frame\nsecond frame");
|
||||
expect(error.message).toBe(
|
||||
"Timed out waiting for Rizin's command-completion marker",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ it.skipIf(process.platform === "win32")(
|
||||
"The command timed out before its completion marker; retained output is partial and completion is unknown.",
|
||||
"The owned debugger session was stopped because output correlation could no longer be guaranteed.",
|
||||
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
||||
"Interrupted output may include an incomplete completion-marker prefix; it is retained because it cannot be distinguished from command diagnostics.",
|
||||
"Launch provenance records the original and resolved startup paths; it does not establish the current target after unrestricted debugger commands.",
|
||||
]);
|
||||
}
|
||||
@@ -94,7 +95,10 @@ it.skipIf(process.platform === "win32")(
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.value.result.output).toBe("first frame\nsecond frame");
|
||||
// All three frames arrived before expiry; the third is still queued.
|
||||
expect(result.value.result.output).toBe(
|
||||
"first frame\nsecond frame\nthird frame",
|
||||
);
|
||||
expect(result.value.result.completion_status).toBe("unknown");
|
||||
}
|
||||
},
|
||||
@@ -256,6 +260,7 @@ it.skipIf(process.platform === "win32")(
|
||||
"The current debugger target identity is not established by the Rizin session identifier.",
|
||||
"Rizin exited before the command-completion marker; retained frame output is partial and command completion is unknown.",
|
||||
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
||||
"Interrupted output may include an incomplete completion-marker prefix; it is retained because it cannot be distinguished from command diagnostics.",
|
||||
"Launch provenance records the original and resolved startup paths; it does not establish the current target after unrestricted debugger commands.",
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { afterAll } from "vitest";
|
||||
import { onTestFinished } from "vitest";
|
||||
import { spawn } from "@lydell/node-pty";
|
||||
import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js";
|
||||
import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js";
|
||||
|
||||
let ttyPathPromise: Promise<string> | undefined;
|
||||
let terminal: ReturnType<typeof spawn> | undefined;
|
||||
|
||||
export const rizinTestInferiorTty = (): Promise<string> => {
|
||||
if (process.platform === "win32")
|
||||
@@ -12,7 +11,7 @@ export const rizinTestInferiorTty = (): Promise<string> => {
|
||||
new Error("The Rizin test TTY requires a POSIX host."),
|
||||
);
|
||||
ttyPathPromise ??= new Promise<string>((resolve, reject) => {
|
||||
terminal = spawn("sh", ["-c", "tty; exec sleep 600"], {
|
||||
const terminal = spawn("sh", ["-c", "tty; exec sleep 600"], {
|
||||
name: "xterm",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
@@ -23,6 +22,21 @@ export const rizinTestInferiorTty = (): Promise<string> => {
|
||||
() => reject(new Error("test TTY path timeout")),
|
||||
2_000,
|
||||
);
|
||||
let exited = false;
|
||||
const ended = new Promise<void>((resolveExit) =>
|
||||
terminal.onExit(() => {
|
||||
exited = true;
|
||||
resolveExit();
|
||||
}),
|
||||
);
|
||||
// MCP workers reuse this module across files. A file-scoped afterAll would
|
||||
// leave later files holding a cached path whose native TTY was destroyed.
|
||||
onTestFinished(async () => {
|
||||
clearTimeout(timer);
|
||||
ttyPathPromise = undefined;
|
||||
if (!exited) terminal.kill();
|
||||
await ended;
|
||||
});
|
||||
terminal.onData((chunk) => {
|
||||
output += chunk;
|
||||
const pattern =
|
||||
@@ -39,8 +53,6 @@ export const rizinTestInferiorTty = (): Promise<string> => {
|
||||
return ttyPathPromise;
|
||||
};
|
||||
|
||||
afterAll(() => terminal?.kill());
|
||||
|
||||
/** These fixture processes do not launch or attach an inferior. */
|
||||
export const stopFakeNoInferior = async (
|
||||
session: Parameters<typeof stopOwnedDebuggerProcess>[0],
|
||||
|
||||
@@ -40,6 +40,7 @@ it("preserves every field of partial Rizin timeout Evidence", () => {
|
||||
"The command timed out before its completion marker; retained output is partial and completion is unknown.",
|
||||
"The owned debugger session was stopped because output correlation could no longer be guaranteed.",
|
||||
"The command is unrestricted and may access local files, the shell, network, or mutate the target.",
|
||||
"Interrupted output may include an incomplete completion-marker prefix; it is retained because it cannot be distinguished from command diagnostics.",
|
||||
"Launch provenance records the original and resolved startup paths; it does not establish the current target after unrestricted debugger commands.",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"src/rizin/RizinDebugSessionManager.ts",
|
||||
"tests/boundary/providers/GdbSessionManagerCoverage.test.ts",
|
||||
"tests/boundary/providers/RizinTimeoutEvidence.test.ts",
|
||||
"tests/boundary/mcp/rizinPendingOutput.test.ts",
|
||||
"tests/boundary/providers/RizinInferiorTty.test.ts",
|
||||
"tests/boundary/providers/RizinTestInferiorTty.ts",
|
||||
"tests/boundary/providers/RizinCommandTimeout.test.ts",
|
||||
|
||||
@@ -6,6 +6,7 @@ export default defineConfig({
|
||||
"tests/boundary/providers/CutterBridgeClient.test.ts",
|
||||
"tests/boundary/providers/RizinCommandTimeout.test.ts",
|
||||
"tests/boundary/providers/RizinTimeoutEvidence.test.ts",
|
||||
"tests/boundary/mcp/rizinPendingOutput.test.ts",
|
||||
],
|
||||
pool: "forks",
|
||||
maxWorkers: 2,
|
||||
|
||||
@@ -43,6 +43,7 @@ export default defineConfig({
|
||||
"tests/boundary/process/ownedDebuggerSession.test.ts",
|
||||
"tests/boundary/mcp/gdbCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/rizinPendingOutput.test.ts",
|
||||
"tests/boundary/mcp/cutterMcp.test.ts",
|
||||
"tests/boundary/mcp/cutterCancellationMcp.test.ts",
|
||||
"tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts",
|
||||
|
||||
Reference in New Issue
Block a user