From d5fc7e6ebf9af050081fc4c6e18b9079cfaa5cdc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 18 Oct 2026 09:53:59 +0800 Subject: [PATCH] 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> --- src/rizin/RizinDebugSessionManager.ts | 2 +- .../mcp/rizinOutputPreservation.test.ts | 119 ++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) create mode 100644 tests/boundary/mcp/rizinOutputPreservation.test.ts diff --git a/src/rizin/RizinDebugSessionManager.ts b/src/rizin/RizinDebugSessionManager.ts index 47c120b607..a8bc548946 100644 --- a/src/rizin/RizinDebugSessionManager.ts +++ b/src/rizin/RizinDebugSessionManager.ts @@ -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; diff --git a/tests/boundary/mcp/rizinOutputPreservation.test.ts b/tests/boundary/mcp/rizinOutputPreservation.test.ts new file mode 100644 index 0000000000..2c09f3e9ef --- /dev/null +++ b/tests/boundary/mcp/rizinOutputPreservation.test.ts @@ -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); + }, +);