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 authored and GitHub committed 2026-10-16 21:08:47 +08:00
1 parent fe2a398dd9
commit 1ff705473a
12 files changed
+784 -280

No files matched your search

+1
View File
@@ -44,6 +44,7 @@ export const SCOPES = {
"tests/boundary/mcp/gdbCancellationMcp.test.ts", "tests/boundary/mcp/gdbCancellationMcp.test.ts",
"tests/boundary/mcp/debuggerCancellationMcp.test.ts", "tests/boundary/mcp/debuggerCancellationMcp.test.ts",
"tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts", "tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts",
"tests/boundary/mcp/rizinPendingOutput.test.ts",
"tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts", "tests/boundary/mcp/reverseEngineeringPartialMcp.test.ts",
"tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts", "tests/boundary/mcp/workflowSnapshotReplayMcp.test.ts",
"tests/boundary/mcp/reverseEngineeringEnvironmentMcp.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.", "The current debugger target identity is not established by the Rizin session identifier.",
limitations: [ limitations: [
...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 ...(result.output_invalid_utf8
? [ ? [
"Collected command frames contained invalid UTF-8 bytes; decoded text may contain replacement characters.", "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 { interface RizinCommandCapture {
constructor( readonly output: string;
readonly partialOutput: string, readonly outputTruncated: boolean;
readonly outputInvalidUtf8 = false, readonly outputInvalidUtf8: boolean;
) { }
super("Timed out waiting for Rizin's command-completion marker");
} interface RizinCommandInterruption {
readonly kind: "cancelled" | "exited" | "timeout" | "failed";
readonly cause: unknown;
readonly capture: RizinCommandCapture;
} }
export const isRizinCommandDeadlineExpired = (remainingMs: number): boolean => export const isRizinCommandDeadlineExpired = (remainingMs: number): boolean =>
remainingMs <= 0; remainingMs <= 0;
export const createRizinCommandTimeoutError = (
partialFrames: readonly string[],
outputInvalidUtf8 = false,
): RizinCommandTimeoutError =>
new RizinCommandTimeoutError(partialFrames.join("\n"), outputInvalidUtf8);
interface RizinCommandTimeoutEvidenceInput { interface RizinCommandTimeoutEvidenceInput {
readonly sessionId: string; readonly sessionId: string;
readonly command: string; readonly command: string;
@@ -197,6 +199,7 @@ interface RizinDebugSession {
commandTail: Promise<void>; commandTail: Promise<void>;
protocolError: string | undefined; protocolError: string | undefined;
commandCaptureActive: boolean; commandCaptureActive: boolean;
commandOutputDropped: boolean;
discardingOversizedFrame: boolean; discardingOversizedFrame: boolean;
} }
@@ -207,28 +210,8 @@ interface RizinFrame {
} }
class RizinProcessExitError extends Error { class RizinProcessExitError extends Error {
constructor( constructor(message = "Rizin exited before completing the active command") {
readonly partialOutput: string, super(message);
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,
},
});
} }
} }
@@ -469,6 +452,7 @@ export class RizinDebugSessionManager {
commandTail: Promise.resolve(), commandTail: Promise.resolve(),
protocolError: undefined, protocolError: undefined,
commandCaptureActive: false, commandCaptureActive: false,
commandOutputDropped: false,
discardingOversizedFrame: false, discardingOversizedFrame: false,
}; };
this.#sessions.set(id, session); this.#sessions.set(id, session);
@@ -489,7 +473,7 @@ export class RizinDebugSessionManager {
launched.process.once("exit", () => { launched.process.once("exit", () => {
session.exited = true; session.exited = true;
for (const waiter of session.frameWaiters) for (const waiter of session.frameWaiters)
waiter.reject(new RizinProcessExitError("")); waiter.reject(new RizinProcessExitError());
session.frameWaiters.length = 0; session.frameWaiters.length = 0;
}); });
try { try {
@@ -595,6 +579,22 @@ export class RizinDebugSessionManager {
let release: (() => void) | undefined; let release: (() => void) | undefined;
let acquired = false; let acquired = false;
let commandSent = 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; const previous = session.commandTail;
session.commandTail = new Promise<void>((resolve) => { session.commandTail = new Promise<void>((resolve) => {
release = resolve; release = resolve;
@@ -612,12 +612,14 @@ export class RizinDebugSessionManager {
session.protocolError !== undefined session.protocolError !== undefined
) )
return err(new AnalysisInputError("rizin_debug_command")); return err(new AnalysisInputError("rizin_debug_command"));
if (session.frames.length > 0) { if (hasUnclaimedRizinFrames(session)) {
const message = const message =
"Unsolicited Rizin frames arrived before a command; command output can no longer be safely correlated."; "Unsolicited Rizin frames arrived before a command; command output can no longer be safely correlated.";
this.#failProtocol(session, message); this.#failProtocol(session, message);
return err( return err(
await this.#stopAfterProtocolFailure(session, undefined, true), await this.#stopAfterProtocolFailure(session, undefined, {
commandTurnHeld: true,
}),
); );
} }
const marker = `__REA_COMMAND_COMPLETE_${randomUUID().replaceAll("-", "")}__`; const marker = `__REA_COMMAND_COMPLETE_${randomUUID().replaceAll("-", "")}__`;
@@ -626,6 +628,8 @@ export class RizinDebugSessionManager {
session.inferiorInventoryComplete = false; session.inferiorInventoryComplete = false;
session.knownInferiorIdentities.clear(); session.knownInferiorIdentities.clear();
commandSent = true; commandSent = true;
session.commandCaptureActive = true;
session.commandOutputDropped = false;
const stdin = session.launched.process.stdin; const stdin = session.launched.process.stdin;
if ( if (
stdin === null || stdin === null ||
@@ -654,16 +658,19 @@ export class RizinDebugSessionManager {
); );
} }
} }
if (session.protocolError !== undefined) const received = await waitForCommandFrames(
throw new Error(session.protocolError);
session.commandCaptureActive = true;
const captured = await waitForCommandFrames(
session, session,
marker, marker,
this.#frameTimeoutMs, this.#frameTimeoutMs,
signal, signal,
this.#maxCommandOutputBytes, 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) if (signal?.aborted)
throw new AnalysisCancelledError("rizin_debug_command"); throw new AnalysisCancelledError("rizin_debug_command");
let cleanupFailure: string | undefined; let cleanupFailure: string | undefined;
@@ -672,19 +679,10 @@ export class RizinDebugSessionManager {
const stopped = await this.#stopDebuggerOnly(session, true); const stopped = await this.#stopDebuggerOnly(session, true);
if (stopped.status === "incomplete") cleanupFailure = stopped.reason; if (stopped.status === "incomplete") cleanupFailure = stopped.reason;
} }
const result = { const result = commandResult(
command, captured,
output: captured.output, captured.outputTruncated ? "unknown" : "complete",
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 evidence = createRizinCommandEvidence({ const evidence = createRizinCommandEvidence({
sessionId, sessionId,
command, command,
@@ -715,43 +713,43 @@ export class RizinDebugSessionManager {
); );
return ok({ result, evidence }); return ok({ result, evidence });
} catch (cause) { } 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 (signal?.aborted || cause instanceof AnalysisCancelledError) {
if (!commandSent) if (!commandSent)
return err( return err(
new AnalysisCancelledError("rizin_debug_command", { cause }), new AnalysisCancelledError("rizin_debug_command", { cause }),
); );
const partialOutput = const evidence = partialEvidence([
cause instanceof RizinCommandCancelledError "The Rizin command was cancelled; collected frame output is partial and command completion is unknown.",
? cause.partialOutput "The owned debugger session was stopped because command output can no longer be correlated safely.",
: undefined; "The command is unrestricted and may access local files, the shell, network, or mutate the target.",
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.",
],
});
session.protocolError = session.protocolError =
"A Rizin command was cancelled; the owned session was stopped because command output can no longer be correlated safely."; "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); const stopped = await this.#stopDebuggerOnly(session, true);
@@ -764,44 +762,42 @@ export class RizinDebugSessionManager {
{ {
cause: new AnalysisCancelledError("rizin_debug_command", { cause: new AnalysisCancelledError("rizin_debug_command", {
cause, cause,
...(cause instanceof RizinCommandCancelledError ...(capturedOutput === undefined ? {} : { capturedOutput }),
? { capturedOutput: cause.capturedOutput }
: {}),
}), }),
...(partialEvidence === undefined ...(evidence === undefined
? {} ? {}
: { partialObservation: partialEvidence }), : { partialObservation: evidence }),
}, },
), ),
); );
return err( return err(
new AnalysisCancelledError("rizin_debug_command", { new AnalysisCancelledError("rizin_debug_command", {
cause, cause,
...(cause instanceof RizinCommandCancelledError ...(capturedOutput === undefined ? {} : { capturedOutput }),
? { capturedOutput: cause.capturedOutput } ...(evidence === undefined ? {} : { partialObservation: evidence }),
: {}),
...(partialEvidence === undefined
? {}
: { partialObservation: partialEvidence }),
}), }),
); );
} }
if (session.protocolError !== undefined) if (session.protocolError !== undefined) {
return err(await this.#stopAfterProtocolFailure(session, cause, true)); const evidence = partialEvidence([
if (cause instanceof RizinCommandTimeoutError) { 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 = session.protocolError =
"Rizin command timed out before its completion marker; output correlation is no longer safe."; "Rizin command timed out before its completion marker; output correlation is no longer safe.";
const stopped = await this.#stopDebuggerOnly(session, true); const stopped = await this.#stopDebuggerOnly(session, true);
const result: RizinDebugCommandResult = { const result = {
command, ...commandResult(interruption.capture, "unknown"),
output: cause.partialOutput,
output_scope: "command_and_interleaved_session_output",
output_truncated: true, output_truncated: true,
completion_status: "unknown",
backend: session.backend,
...(cause.outputInvalidUtf8
? { output_invalid_utf8: true as const }
: {}),
}; };
const evidence = createRizinCommandTimeoutEvidence({ const evidence = createRizinCommandTimeoutEvidence({
sessionId, sessionId,
@@ -826,18 +822,8 @@ export class RizinDebugSessionManager {
); );
return ok({ result, evidence }); return ok({ result, evidence });
} }
if (cause instanceof RizinProcessExitError) { if (interruption?.kind === "exited") {
const result: RizinDebugCommandResult = { const result = commandResult(interruption.capture, "unknown");
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 }
: {}),
};
const evidence = createRizinCommandEvidence({ const evidence = createRizinCommandEvidence({
sessionId, sessionId,
command, command,
@@ -863,6 +849,14 @@ export class RizinDebugSessionManager {
} }
session.protocolError = 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."; "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); const stopped = await this.#stopDebuggerOnly(session, true);
if (stopped.status === "incomplete") if (stopped.status === "incomplete")
return err( return err(
@@ -870,13 +864,15 @@ export class RizinDebugSessionManager {
session, session,
"rizin_debug_command", "rizin_debug_command",
stopped.reason, stopped.reason,
{ cause }, errorOptions,
), ),
); );
return err( return err(
new AnalysisTimeoutError("rizin_debug_command", this.#frameTimeoutMs, { new AnalysisTimeoutError(
cause, "rizin_debug_command",
}), this.#frameTimeoutMs,
errorOptions,
),
); );
} finally { } finally {
if (acquired) { if (acquired) {
@@ -994,7 +990,6 @@ export class RizinDebugSessionManager {
if (session.discardingOversizedFrame) { if (session.discardingOversizedFrame) {
if (boundary < 0) return; if (boundary < 0) return;
session.discardingOversizedFrame = false; session.discardingOversizedFrame = false;
this.#deliverBufferedFrame(session, true);
offset = nextOffset; offset = nextOffset;
continue; continue;
} }
@@ -1016,6 +1011,7 @@ export class RizinDebugSessionManager {
} }
if (boundary < 0) { if (boundary < 0) {
session.discardingOversizedFrame = true; session.discardingOversizedFrame = true;
this.#deliverBufferedFrame(session, true);
return; return;
} }
this.#deliverBufferedFrame(session, true); this.#deliverBufferedFrame(session, true);
@@ -1033,30 +1029,15 @@ export class RizinDebugSessionManager {
} }
#deliverBufferedFrame(session: RizinDebugSession, truncated: boolean): void { #deliverBufferedFrame(session: RizinDebugSession, truncated: boolean): void {
const frameBytes = Buffer.concat( const captured = bufferedRizinFrame(session, truncated);
session.frameChunks,
session.bufferedFrameBytes,
);
session.frameChunks = []; session.frameChunks = [];
session.bufferedFrameBytes = 0; session.bufferedFrameBytes = 0;
const invalidUtf8 = !isUtf8(frameBytes); this.#deliverFrame(session, captured.frame, captured.bytes);
session.invalidUtf8Observed ||= invalidUtf8;
this.#deliverFrame(
session,
{ text: frameBytes.toString("utf8"), truncated, invalidUtf8 },
frameBytes.length,
);
} }
#startupCapturedOutput(session: RizinDebugSession) { #startupCapturedOutput(session: RizinDebugSession) {
const output = session.supervisor.snapshot(); const output = session.supervisor.snapshot();
const partialFrame = const partialFrame = bufferedRizinFrame(session, false).frame.text;
session.bufferedFrameBytes === 0
? ""
: Buffer.concat(
session.frameChunks,
session.bufferedFrameBytes,
).toString("utf8");
const protocolOutput = [...session.history, partialFrame] const protocolOutput = [...session.history, partialFrame]
.filter((frame) => frame.length > 0) .filter((frame) => frame.length > 0)
.join("\n"); .join("\n");
@@ -1109,6 +1090,7 @@ export class RizinDebugSessionManager {
session.frames.length >= MAX_QUEUED_FRAMES || session.frames.length >= MAX_QUEUED_FRAMES ||
session.queuedFrameBytes + queueChargeBytes > MAX_QUEUED_FRAME_BYTES session.queuedFrameBytes + queueChargeBytes > MAX_QUEUED_FRAME_BYTES
) { ) {
session.commandOutputDropped ||= session.commandCaptureActive;
this.#failProtocol( this.#failProtocol(
session, session,
"Rizin produced unsolicited frames exceeding the bounded queue limit.", "Rizin produced unsolicited frames exceeding the bounded queue limit.",
@@ -1121,8 +1103,10 @@ export class RizinDebugSessionManager {
#failProtocol(session: RizinDebugSession, message: string): void { #failProtocol(session: RizinDebugSession, message: string): void {
session.protocolError = message; session.protocolError = message;
session.frameChunks = []; if (!session.commandCaptureActive) {
session.bufferedFrameBytes = 0; session.frameChunks = [];
session.bufferedFrameBytes = 0;
}
for (const waiter of session.frameWaiters) for (const waiter of session.frameWaiters)
waiter.reject(new Error(message)); waiter.reject(new Error(message));
session.frameWaiters.length = 0; session.frameWaiters.length = 0;
@@ -1263,6 +1247,10 @@ export class RizinDebugSessionManager {
session: RizinDebugSession, session: RizinDebugSession,
command: string, command: string,
): Promise<string> { ): Promise<string> {
if (hasUnclaimedRizinFrames(session))
throw new Error(
"Unsolicited Rizin output prevents correlated cleanup readback.",
);
const marker = `__REA_CLEANUP_COMPLETE_${randomUUID().replaceAll("-", "")}__`; const marker = `__REA_CLEANUP_COMPLETE_${randomUUID().replaceAll("-", "")}__`;
const stdin = session.launched.process.stdin; const stdin = session.launched.process.stdin;
if ( if (
@@ -1280,9 +1268,10 @@ export class RizinDebugSessionManager {
undefined, undefined,
this.#maxCommandOutputBytes, this.#maxCommandOutputBytes,
); );
if (captured.outputTruncated) if (!captured.ok) throw captured.error.cause;
if (captured.value.outputTruncated)
throw new Error("Rizin cleanup readback was truncated."); throw new Error("Rizin cleanup readback was truncated.");
return captured.output; return captured.value.output;
} }
#retainCleanupFailure( #retainCleanupFailure(
@@ -1304,21 +1293,33 @@ export class RizinDebugSessionManager {
async #stopAfterProtocolFailure( async #stopAfterProtocolFailure(
session: RizinDebugSession, session: RizinDebugSession,
cause: unknown, cause: unknown,
commandTurnHeld = false, options: {
readonly commandTurnHeld?: boolean;
readonly partialObservation?: Evidence;
} = {},
): Promise<AnalysisError> { ): 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" return stopped.status === "incomplete"
? this.#retainCleanupFailure( ? this.#retainCleanupFailure(
session, session,
"rizin_debug_command", "rizin_debug_command",
stopped.reason, stopped.reason,
{ cause }, errorOptions,
) )
: new AnalysisCapabilityUnavailableError( : new AnalysisCapabilityUnavailableError(
"rizin", "rizin",
"rizin_debug_command", "rizin_debug_command",
session.protocolError!, session.protocolError!,
{ cause }, errorOptions,
); );
} }
} }
@@ -1397,139 +1398,167 @@ export const assertRizinProcessStartTimeStable = (
const readProcStartTime = async (pid: string): Promise<string> => const readProcStartTime = async (pid: string): Promise<string> =>
parseRizinProcStartTime(await readFile(`/proc/${pid}/stat`, "utf8")); 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 ( const waitForCommandFrames = async (
session: RizinDebugSession, session: RizinDebugSession,
marker: string, marker: string,
timeoutMs: number, timeoutMs: number,
signal?: AbortSignal, signal?: AbortSignal,
maxCommandOutputBytes = MAX_COMMAND_OUTPUT_BYTES, maxCommandOutputBytes = MAX_COMMAND_OUTPUT_BYTES,
): Promise<{ ): Promise<Result<RizinCommandCapture, RizinCommandInterruption>> => {
readonly output: string;
readonly outputTruncated: boolean;
readonly outputInvalidUtf8: boolean;
}> => {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
const output: string[] = []; const output: string[] = [];
let outputInvalidUtf8 = false;
const commandTimeoutError = (): RizinCommandTimeoutError =>
createRizinCommandTimeoutError(output, outputInvalidUtf8);
let outputBytes = 0; let outputBytes = 0;
let outputInvalidUtf8 = false;
let outputTruncated = false;
let frameCount = 0; 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 (;;) { 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(); 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; let received: RizinFrame;
try { try {
received = await waitForFrame( received = await waitForFrame(session, remaining, signal);
session,
remaining,
signal,
commandTimeoutError,
);
} catch (cause) { } catch (cause) {
if (cause instanceof AnalysisCancelledError) let drained = true;
throw new RizinCommandCancelledError( if (cause instanceof RizinProcessExitError) {
output.join("\n"), // `exit` observes the leader, not its pipe lifetime. Keep the bounded
outputInvalidUtf8, // receiver active until the supervisor observes output closure, using
cause, // 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); const captured = interrupted();
throw cause; 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; frameCount += 1;
if (frameCount > MAX_COMMAND_FRAMES) { 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."; "Rizin emitted more than 4096 frames for one command; the session was stopped because output correlation is ambiguous.";
session.protocolError = message; outputTruncated = true;
return { return ok(snapshot());
output: output.join("\n"),
outputTruncated: true,
outputInvalidUtf8,
};
} }
if (received.truncated) { const markerSeen = append(received);
const separatorBytes = output.length > 0 ? 1 : 0; if (outputTruncated) {
output.push( session.protocolError ??=
truncateUtf8( "Rizin command output exceeded the 16 MiB command limit.";
received.text, return ok(snapshot());
maxCommandOutputBytes - outputBytes - separatorBytes,
),
);
return {
output: output.join("\n"),
outputTruncated: true,
outputInvalidUtf8,
};
} }
const frame = received.text; if (markerSeen) return ok(snapshot());
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);
} }
}; };
const truncateUtf8 = (value: string, maximumBytes: number): string => { const truncateUtf8 = (value: string, maximumBytes: number): string => {
if (maximumBytes <= 0) return ""; if (maximumBytes <= 0) return "";
const bytes = Buffer.from(value, "utf8").subarray(0, maximumBytes); const bytes = Buffer.from(value, "utf8");
return bytes.toString("utf8").replace(/\uFFFD$/u, ""); 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 = ( const waitForFrame = (
session: RizinDebugSession, session: RizinDebugSession,
timeoutMs: number, timeoutMs: number,
signal?: AbortSignal, signal?: AbortSignal,
timeoutError?: () => Error,
): Promise<RizinFrame> => { ): Promise<RizinFrame> => {
if (signal?.aborted) if (signal?.aborted)
return Promise.reject(new AnalysisCancelledError("rizin_debug_command")); return Promise.reject(new AnalysisCancelledError("rizin_debug_command"));
@@ -1538,7 +1567,7 @@ const waitForFrame = (
session.queuedFrameBytes -= queued.queueChargeBytes; session.queuedFrameBytes -= queued.queueChargeBytes;
return Promise.resolve(queued.frame); 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) => { return new Promise((resolve, reject) => {
// Settlement removes all three trigger paths before resolving or rejecting. // Settlement removes all three trigger paths before resolving or rejecting.
const cleanup = (): void => { const cleanup = (): void => {
@@ -1553,7 +1582,7 @@ const waitForFrame = (
}; };
const timer = setTimeout(() => { const timer = setTimeout(() => {
cleanup(); cleanup();
reject(timeoutError?.() ?? new RizinFrameTimeoutError()); reject(new RizinFrameTimeoutError());
}, timeoutMs); }, timeoutMs);
const waiter = { const waiter = {
resolve: (frame: RizinFrame): void => { resolve: (frame: RizinFrame): void => {
+2 -1
View File
@@ -42,7 +42,8 @@ export default {
}, },
{ {
kind: "declaration", kind: "declaration",
name: "createRizinCommandTimeoutError", name: "snapshot",
withinBlockWithBinding: "output",
}, },
{ {
kind: "declaration", kind: "declaration",
@@ -275,6 +275,7 @@ it.each([
"tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts", "tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts",
"tests/boundary/mcp/gdbCancellationMcp.test.ts", "tests/boundary/mcp/gdbCancellationMcp.test.ts",
"tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts", "tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts",
"tests/boundary/mcp/rizinPendingOutput.test.ts",
"tests/composition/reverseEngineeringAdmission.test.ts", "tests/composition/reverseEngineeringAdmission.test.ts",
"tests/support/reverseEngineeringMcpHarness.ts", "tests/support/reverseEngineeringMcpHarness.ts",
"tests/support/reverseEngineeringCompositionEdgeCases.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 { describe, expect, it } from "vitest";
import { import { isRizinCommandDeadlineExpired } from "../../../src/rizin/RizinDebugSessionManager.js";
createRizinCommandTimeoutError,
isRizinCommandDeadlineExpired,
RizinCommandTimeoutError,
} from "../../../src/rizin/RizinDebugSessionManager.js";
describe("Rizin command timeout semantics", () => { describe("Rizin command timeout semantics", () => {
it("treats zero and negative remaining time as expired", () => { 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(0)).toBe(true);
expect(isRizinCommandDeadlineExpired(-1)).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 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 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.", "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.", "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); expect(result.ok).toBe(true);
if (result.ok) { 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"); 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.", "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.", "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.", "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.", "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 { spawn } from "@lydell/node-pty";
import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js";
import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js"; import { stopOwnedDebuggerProcess } from "../../../src/process/ownedDebuggerSession.js";
let ttyPathPromise: Promise<string> | undefined; let ttyPathPromise: Promise<string> | undefined;
let terminal: ReturnType<typeof spawn> | undefined;
export const rizinTestInferiorTty = (): Promise<string> => { export const rizinTestInferiorTty = (): Promise<string> => {
if (process.platform === "win32") if (process.platform === "win32")
@@ -12,7 +11,7 @@ export const rizinTestInferiorTty = (): Promise<string> => {
new Error("The Rizin test TTY requires a POSIX host."), new Error("The Rizin test TTY requires a POSIX host."),
); );
ttyPathPromise ??= new Promise<string>((resolve, reject) => { 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", name: "xterm",
cols: 80, cols: 80,
rows: 24, rows: 24,
@@ -23,6 +22,21 @@ export const rizinTestInferiorTty = (): Promise<string> => {
() => reject(new Error("test TTY path timeout")), () => reject(new Error("test TTY path timeout")),
2_000, 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) => { terminal.onData((chunk) => {
output += chunk; output += chunk;
const pattern = const pattern =
@@ -39,8 +53,6 @@ export const rizinTestInferiorTty = (): Promise<string> => {
return ttyPathPromise; return ttyPathPromise;
}; };
afterAll(() => terminal?.kill());
/** These fixture processes do not launch or attach an inferior. */ /** These fixture processes do not launch or attach an inferior. */
export const stopFakeNoInferior = async ( export const stopFakeNoInferior = async (
session: Parameters<typeof stopOwnedDebuggerProcess>[0], 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 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 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.", "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.", "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", "src/rizin/RizinDebugSessionManager.ts",
"tests/boundary/providers/GdbSessionManagerCoverage.test.ts", "tests/boundary/providers/GdbSessionManagerCoverage.test.ts",
"tests/boundary/providers/RizinTimeoutEvidence.test.ts", "tests/boundary/providers/RizinTimeoutEvidence.test.ts",
"tests/boundary/mcp/rizinPendingOutput.test.ts",
"tests/boundary/providers/RizinInferiorTty.test.ts", "tests/boundary/providers/RizinInferiorTty.test.ts",
"tests/boundary/providers/RizinTestInferiorTty.ts", "tests/boundary/providers/RizinTestInferiorTty.ts",
"tests/boundary/providers/RizinCommandTimeout.test.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/CutterBridgeClient.test.ts",
"tests/boundary/providers/RizinCommandTimeout.test.ts", "tests/boundary/providers/RizinCommandTimeout.test.ts",
"tests/boundary/providers/RizinTimeoutEvidence.test.ts", "tests/boundary/providers/RizinTimeoutEvidence.test.ts",
"tests/boundary/mcp/rizinPendingOutput.test.ts",
], ],
pool: "forks", pool: "forks",
maxWorkers: 2, maxWorkers: 2,
+1
View File
@@ -43,6 +43,7 @@ export default defineConfig({
"tests/boundary/process/ownedDebuggerSession.test.ts", "tests/boundary/process/ownedDebuggerSession.test.ts",
"tests/boundary/mcp/gdbCancellationMcp.test.ts", "tests/boundary/mcp/gdbCancellationMcp.test.ts",
"tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts", "tests/boundary/mcp/rizinDebuggerCancellationMcp.test.ts",
"tests/boundary/mcp/rizinPendingOutput.test.ts",
"tests/boundary/mcp/cutterMcp.test.ts", "tests/boundary/mcp/cutterMcp.test.ts",
"tests/boundary/mcp/cutterCancellationMcp.test.ts", "tests/boundary/mcp/cutterCancellationMcp.test.ts",
"tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts", "tests/boundary/mcp/reverseEngineeringEnvironmentMcp.test.ts",