mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(rizin): retain interrupted command frames (#4459)
Retain received complete and unfinished Rizin command bytes with bounded UTF-8 capture, truthful completion uncertainty and exact launch provenance. Keep captures independent of cancellation, protocol and cleanup precedence; wait for bounded output settlement on exit and preserve active bytes across stdin failures. Validated353 focused tests (one existing skip), full CI static/docs checks, real official Linux Rizin0.9.1 interruption probes, paired completed public CLI/stdio MCP tasks, and focused mutation checks. Required CI passed; exact-head Codex review reported no findings and Greptile5/5. Fault cleanup uncertainty and partial-marker ambiguity remain explicit. Fixes #4015. Follow-up to #4227.
This commit is contained in:
1 parent
fe2a398dd9
commit
1ff705473a
12 files changed
+784
-280
No files matched your search
@@ -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",
|
||||||
|
|||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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.",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in new issue
Block a user