fix(rizin): preserve output whitespace before completion markers

Adapt #4044 at the shared normal/interrupted frame owner. Preserve the decoded prefix before byte-budget accounting and cover MCP delivery across framing and UTF-8 boundaries. Refs #4016.

Co-authored-by: Xander-AJ <133286098+Xander-AJ@users.noreply.github.com>
This commit is contained in:
morluto
2026-10-18 09:53:59 +08:00
parent 279c590ef6
commit d5fc7e6ebf
2 changed files with 120 additions and 1 deletions
+1 -1
View File
@@ -1437,7 +1437,7 @@ const waitForCommandFrames = async (
outputTruncated ||= frame.truncated;
const markerIndex = frame.text.indexOf(marker);
const text =
markerIndex < 0 ? frame.text : frame.text.slice(0, markerIndex).trimEnd();
markerIndex < 0 ? frame.text : frame.text.slice(0, markerIndex);
if (markerIndex >= 0 && text.length === 0) return true;
const separatorBytes = output.length > 0 ? 1 : 0;
const remaining = maxCommandOutputBytes - outputBytes - separatorBytes;
@@ -0,0 +1,119 @@
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");
const observe = async (
prefix: string,
framing: "coalesced" | "separate" | "split-transport",
maximum?: number,
) => {
const injected = createInjectedProcess({
onWrite: (text, control) => {
const marker = /!echo (.+)\n/u.exec(text)?.[1];
if (marker === undefined) return;
setImmediate(() => {
if (framing === "separate") control.output(`${prefix}\0${marker}\n\0`);
else if (framing === "split-transport") {
const bytes = Buffer.from(`${prefix}${marker}\n\0`);
const split = Math.max(1, Buffer.byteLength(prefix) - 1);
control.output(bytes.subarray(0, split));
control.output(bytes.subarray(split));
} else control.output(`${prefix}${marker}\n\0`);
});
},
});
const manager = createRizinTestManager(
{},
{
spawnProcess: injected.spawnProcess,
frameTimeoutMs: 100,
...(maximum === undefined ? {} : { maxCommandOutputBytes: maximum }),
},
);
const server = new McpServer({ name: "rizin-output-test", version: "1" });
registerRizinDebugTools(
server,
manager,
silentLogger,
createToolResultDelivery(undefined),
);
const client = new Client({ name: "rizin-output-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);
return toolContract("rizin_debug_command").outputSchema.parse(
reply.structuredContent,
);
} finally {
injected.control.exit();
await client.close();
await server.close();
await manager.closeAll();
}
};
test.each(
["value \n", " \t\r\n", "Ω雪\t\r\n", "", "😀"].flatMap((prefix) =>
(["coalesced", "separate", "split-transport"] as const).map((framing) => ({
prefix,
framing,
})),
),
)(
"preserves output %j across $framing framing",
async ({ prefix, framing }) => {
const evidence = await observe(prefix, framing);
const expected = {
output: prefix,
completion_status: "complete",
output_truncated: false,
};
expect(evidence.normalized_result).toMatchObject(expected);
expect(evidence.raw_result).toMatchObject(expected);
},
);
test.each([7, 8])(
"counts trailing whitespace in the exact %s-byte budget",
async (maximum) => {
const evidence = await observe("value \n", "coalesced", maximum);
const truncated = maximum < 8;
const expected = {
output: truncated ? "value " : "value \n",
output_truncated: truncated,
completion_status: truncated ? "unknown" : "complete",
};
expect(evidence.normalized_result).toMatchObject(expected);
expect(evidence.raw_result).toMatchObject(expected);
},
);