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:
Ysopwnable
2026-10-16 21:08:47 +08:00
committed by GitHub
parent fe2a398dd9
commit 1ff705473a
12 changed files with 784 additions and 280 deletions
+1
View File
@@ -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",
+284 -255
View File
@@ -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 => {
+2 -1
View File
@@ -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.",
]);
});
+1
View File
@@ -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",
+1
View File
@@ -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,
+1
View File
@@ -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",