[GH-ISSUE #4015] [Bug] Rizin commands lose pending stdout when the provider exits or times out #1598

Closed
opened 2026-10-16 21:07:43 +00:00 by bsakaguchi · 4 comments
Owner

Originally created by @coygeek on GitHub (Oct 15, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/4015

Before submitting

  • I searched the open and closed issues for a related report.
  • I removed credentials, tokens, private paths, and sensitive target data.

Area

Evidence, artifacts, or reconstruction

Observed behavior

rizin_debug_command returns empty output when an active command receives diagnostic bytes but the provider exits or reaches its frame deadline before a NUL terminator. Completion correctly remains unknown. Adding only the NUL terminator preserves the same diagnostic on exit.

Reproduction

The saved verification ran the following reduced probe through the registered MCP tools and linked in-memory transport. In a disposable built checkout with locked dependencies, save it as tests/boundary/mcp/rizinPendingOutput.test.ts and run npx vitest run tests/boundary/mcp/rizinPendingOutput.test.ts with isolated HOME and TMPDIR directories. The assertions characterize the current failure, not the expected repair.

Executed reduced probe
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { McpServer } from "@modelcontextprotocol/server";
import { expect, it } from "vitest";
import { z } from "zod";
import { createInjectedProcess } from "../providers/RizinDebugSessionCoverage.fixtures.js";
import { createRizinTestManager, rizinTestInferiorTty } from "../providers/RizinTestInferiorTty.js";
import { registerRizinDebugTools } from "../../../src/server/registerRizinDebugTools.js";
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
import { silentLogger } from "../../../src/logger.js";

for (const mode of ["unterminated-exit", "framed-exit", "unterminated-timeout"] as const) {
  it(`observes ${mode} through the registered Rizin MCP tools`, async () => {
    const injected = createInjectedProcess({
      onWrite: (text, control) => {
        const marker = /!echo (.+)\n/u.exec(text)?.[1];
        if (!marker) return;
        setImmediate(() => {
          if (mode === "unterminated-exit" || mode === "framed-exit") {
            control.output(`diagnostic before exit${mode === "framed-exit" ? "\0" : ""}`);
            control.exit();
          } else if (mode === "unterminated-timeout") {
            control.output("diagnostic before timeout");
          }
        });
      },
    });
    const manager = createRizinTestManager({}, {
      spawnProcess: injected.spawnProcess,
      frameTimeoutMs: 30,
    });
    const server = new McpServer({ name: "rizin-observation", version: "1" });
    registerRizinDebugTools(server, manager, silentLogger, createToolResultDelivery(undefined));
    const client = new Client({ name: "rizin-observation", version: "1" });
    const [ct, st] = InMemoryTransport.createLinkedPair();
    try {
      await server.connect(st);
      await client.connect(ct);
      const started = await client.callTool({ name: "start_rizin_debug_session", arguments: {
        path: "fixture.bin", inferior_tty: await rizinTestInferiorTty(),
      }});
      const sessionId = z.object({ session_id: z.string() }).parse(started.structuredContent).session_id;
      const result = await client.callTool({ name: "rizin_debug_command", arguments: {
        session_id: sessionId, command: "dr",
      }});
      expect(result.isError).not.toBe(true);
      const evidence = z.object({ normalized_result: z.object({ output: z.string(), completion_status: z.string(), output_truncated: z.boolean() }) }).parse(result.structuredContent);
      console.log(JSON.stringify({ mode, ...evidence.normalized_result, output: evidence.normalized_result.output.length > 100 ? "<large output>" : evidence.normalized_result.output, output_bytes: Buffer.byteLength(evidence.normalized_result.output) }));
      if (mode === "unterminated-exit") expect(evidence.normalized_result.output).toBe("");
      if (mode === "framed-exit") expect(evidence.normalized_result.output).toBe("diagnostic before exit");
      if (mode === "unterminated-timeout") expect(evidence.normalized_result.output).toBe("");
    } finally {
      injected.control.exit();
      await Promise.allSettled([client.close(), server.close()]);
      await manager.closeAll().catch(() => undefined);
    }
  });
}

Each case ran once in the reduced probe after the same observations in a broader probe. Responses occur after command dispatch, excluding unsolicited startup frames.

Expected behavior

Preserve already received command bytes as partial Evidence, clearly marked incomplete, while keeping completion_status: "unknown". src/contracts/rizinDebugToolContracts.ts:48 promises inline raw command output and retained partial output on bounded incomplete execution. tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts:197 already requires preserving unterminated startup diagnostics.

Impact and scope

P2. Interrupted commands lose diagnostic text already received by the production stdout receiver. The simulated-provider probe establishes adapter behavior at the MCP boundary. Frequency with real Rizin is unknown. No general caller-controlled workaround was verified.

REA version

rea 6.5.0 at source commit 4692bb3330

Runtime and platform

Node 24.18.0; macOS arm64

Target and artifact context

Simulated Linux Rizin process and no-inferior cleanup fixtures; valid host fixture TTY; no target program launched. The test uses a 30 ms frame deadline instead of the production 120,000 ms deadline.

Interface

MCP

Earliest failure stage

Capture or observation

Relevant output or evidence

The reduced probe returned:

{"mode":"unterminated-exit","output":"","completion_status":"unknown","output_truncated":false}
{"mode":"framed-exit","output":"diagnostic before exit","completion_status":"unknown","output_truncated":false}
{"mode":"unterminated-timeout","output":"","completion_status":"unknown","output_truncated":true}

Both normalized and raw command Evidence lost the unterminated diagnostics. The framed control retained the same exit diagnostic.

Typed outcome or recovery expected

Return unterminated diagnostic text as incomplete output on exit and deadline. Preserve unknown completion, output bounds, UTF-8 diagnostics, cleanup uncertainty and exclusion of bytes belonging to startup or earlier unsolicited frames. Retain the framed control text exactly once.

Analysis or suspected cause

Source-derived: src/rizin/RizinDebugSessionManager.ts:980 buffers bytes in frameChunks until NUL. waitForCommandFrames at line 1400 constructs interruption output only from completed frames. Its process-exit branch at line 1436 and timeout error omit pending bytes. src/server/registerRizinDebugTools.ts:66 forwards the validated request to this manager, which publishes the partial result as Evidence.

Additional context

Vitest 5.0.3. Real Rizin and Linux descriptor readback were not exercised. The cited source files are unchanged between the reproduced baseline and origin/main 6fcfc5b5e0. The reproduction was not rerun for this submission.

Originally created by @coygeek on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/4015 ### Before submitting - [x] I searched the open and closed issues for a related report. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Area Evidence, artifacts, or reconstruction ### Observed behavior `rizin_debug_command` returns empty output when an active command receives diagnostic bytes but the provider exits or reaches its frame deadline before a NUL terminator. Completion correctly remains unknown. Adding only the NUL terminator preserves the same diagnostic on exit. ### Reproduction The saved verification ran the following reduced probe through the registered MCP tools and linked in-memory transport. In a disposable built checkout with locked dependencies, save it as `tests/boundary/mcp/rizinPendingOutput.test.ts` and run `npx vitest run tests/boundary/mcp/rizinPendingOutput.test.ts` with isolated `HOME` and `TMPDIR` directories. The assertions characterize the current failure, not the expected repair. <details> <summary>Executed reduced probe</summary> ```ts import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { McpServer } from "@modelcontextprotocol/server"; import { expect, it } from "vitest"; import { z } from "zod"; import { createInjectedProcess } from "../providers/RizinDebugSessionCoverage.fixtures.js"; import { createRizinTestManager, rizinTestInferiorTty } from "../providers/RizinTestInferiorTty.js"; import { registerRizinDebugTools } from "../../../src/server/registerRizinDebugTools.js"; import { createToolResultDelivery } from "../../../src/server/toolResult.js"; import { silentLogger } from "../../../src/logger.js"; for (const mode of ["unterminated-exit", "framed-exit", "unterminated-timeout"] as const) { it(`observes ${mode} through the registered Rizin MCP tools`, async () => { const injected = createInjectedProcess({ onWrite: (text, control) => { const marker = /!echo (.+)\n/u.exec(text)?.[1]; if (!marker) return; setImmediate(() => { if (mode === "unterminated-exit" || mode === "framed-exit") { control.output(`diagnostic before exit${mode === "framed-exit" ? "\0" : ""}`); control.exit(); } else if (mode === "unterminated-timeout") { control.output("diagnostic before timeout"); } }); }, }); const manager = createRizinTestManager({}, { spawnProcess: injected.spawnProcess, frameTimeoutMs: 30, }); const server = new McpServer({ name: "rizin-observation", version: "1" }); registerRizinDebugTools(server, manager, silentLogger, createToolResultDelivery(undefined)); const client = new Client({ name: "rizin-observation", version: "1" }); const [ct, st] = InMemoryTransport.createLinkedPair(); try { await server.connect(st); await client.connect(ct); const started = await client.callTool({ name: "start_rizin_debug_session", arguments: { path: "fixture.bin", inferior_tty: await rizinTestInferiorTty(), }}); const sessionId = z.object({ session_id: z.string() }).parse(started.structuredContent).session_id; const result = await client.callTool({ name: "rizin_debug_command", arguments: { session_id: sessionId, command: "dr", }}); expect(result.isError).not.toBe(true); const evidence = z.object({ normalized_result: z.object({ output: z.string(), completion_status: z.string(), output_truncated: z.boolean() }) }).parse(result.structuredContent); console.log(JSON.stringify({ mode, ...evidence.normalized_result, output: evidence.normalized_result.output.length > 100 ? "<large output>" : evidence.normalized_result.output, output_bytes: Buffer.byteLength(evidence.normalized_result.output) })); if (mode === "unterminated-exit") expect(evidence.normalized_result.output).toBe(""); if (mode === "framed-exit") expect(evidence.normalized_result.output).toBe("diagnostic before exit"); if (mode === "unterminated-timeout") expect(evidence.normalized_result.output).toBe(""); } finally { injected.control.exit(); await Promise.allSettled([client.close(), server.close()]); await manager.closeAll().catch(() => undefined); } }); } ``` </details> Each case ran once in the reduced probe after the same observations in a broader probe. Responses occur after command dispatch, excluding unsolicited startup frames. ### Expected behavior Preserve already received command bytes as partial Evidence, clearly marked incomplete, while keeping `completion_status: "unknown"`. `src/contracts/rizinDebugToolContracts.ts:48` promises inline raw command output and retained partial output on bounded incomplete execution. `tests/boundary/providers/RizinDebugSessionReviewRegressions.test.ts:197` already requires preserving unterminated startup diagnostics. ### Impact and scope P2. Interrupted commands lose diagnostic text already received by the production stdout receiver. The simulated-provider probe establishes adapter behavior at the MCP boundary. Frequency with real Rizin is unknown. No general caller-controlled workaround was verified. ### REA version rea 6.5.0 at source commit 4692bb33300c7cb77b095b6cc0c33da861c1b860 ### Runtime and platform Node 24.18.0; macOS arm64 ### Target and artifact context Simulated Linux Rizin process and no-inferior cleanup fixtures; valid host fixture TTY; no target program launched. The test uses a 30 ms frame deadline instead of the production 120,000 ms deadline. ### Interface MCP ### Earliest failure stage Capture or observation ### Relevant output or evidence The reduced probe returned: ```json {"mode":"unterminated-exit","output":"","completion_status":"unknown","output_truncated":false} {"mode":"framed-exit","output":"diagnostic before exit","completion_status":"unknown","output_truncated":false} {"mode":"unterminated-timeout","output":"","completion_status":"unknown","output_truncated":true} ``` Both normalized and raw command Evidence lost the unterminated diagnostics. The framed control retained the same exit diagnostic. ### Typed outcome or recovery expected Return unterminated diagnostic text as incomplete output on exit and deadline. Preserve unknown completion, output bounds, UTF-8 diagnostics, cleanup uncertainty and exclusion of bytes belonging to startup or earlier unsolicited frames. Retain the framed control text exactly once. ### Analysis or suspected cause Source-derived: `src/rizin/RizinDebugSessionManager.ts:980` buffers bytes in `frameChunks` until NUL. `waitForCommandFrames` at line 1400 constructs interruption output only from completed frames. Its process-exit branch at line 1436 and timeout error omit pending bytes. `src/server/registerRizinDebugTools.ts:66` forwards the validated request to this manager, which publishes the partial result as Evidence. ### Additional context Vitest 5.0.3. Real Rizin and Linux descriptor readback were not exercised. The cited source files are unchanged between the reproduced baseline and origin/main 6fcfc5b5e0c063300cd5278dd8bad5b1ab1374f6. The reproduction was not rerun for this submission.
Author
Owner

@XinwangTao commented on GitHub (Oct 15, 2026):

This matches the contract's intent: pending bytes already received are still evidence, and dropping them on exit/timeout makes partial results indistinguishable from a command that produced nothing. Since waitForCommandFrames already buffers frameChunks until NUL, the exit and timeout branches should flush the unterminated buffer (marked incomplete, completion unknown) rather than constructing output from completed frames only. The review-regressions test that preserves unterminated startup diagnostics is the right analog to extend for this path.

<!-- gh-comment-id:6187001136 --> @XinwangTao commented on GitHub (Oct 15, 2026): This matches the contract's intent: pending bytes already received are still evidence, and dropping them on exit/timeout makes partial results indistinguishable from a command that produced nothing. Since waitForCommandFrames already buffers frameChunks until NUL, the exit and timeout branches should flush the unterminated buffer (marked incomplete, completion unknown) rather than constructing output from completed frames only. The review-regressions test that preserves unterminated startup diagnostics is the right analog to extend for this path.
Author
Owner

@Vedant1521 commented on GitHub (Oct 15, 2026):

Hey @coygeek! I'd love to pick this up if it's still open.

Looking at src/rizin/RizinDebugSessionManager.ts, I can update the process-exit and timeout branches in waitForCommandFrames (around lines 1400–1436) so any buffered, unterminated bytes in frameChunks received after command dispatch are flushed into the partial output alongside completed frames (preserving completion_status: "unknown", UTF-8/output bounds, and avoiding duplicate emission on framed-exit), and add the boundary regression tests covering unterminated-exit, framed-exit, and unterminated-timeout.

Let me know if I can go ahead and open a PR for this!

<!-- gh-comment-id:6188891736 --> @Vedant1521 commented on GitHub (Oct 15, 2026): Hey @coygeek! I'd love to pick this up if it's still open. Looking at `src/rizin/RizinDebugSessionManager.ts`, I can update the process-exit and timeout branches in `waitForCommandFrames` (around lines 1400–1436) so any buffered, unterminated bytes in `frameChunks` received after command dispatch are flushed into the partial output alongside completed frames (preserving `completion_status: "unknown"`, UTF-8/output bounds, and avoiding duplicate emission on `framed-exit`), and add the boundary regression tests covering `unterminated-exit`, `framed-exit`, and `unterminated-timeout`. Let me know if I can go ahead and open a PR for this!
Author
Owner

@yankikucuk commented on GitHub (Oct 15, 2026):

Working on this. Reproduced with the injected provider at the manager boundary: the unterminated exit and deadline cases returned empty output while the framed control kept the diagnostic. The command wait now folds the bytes buffered after the last NUL into the partial output attached to the exit, deadline and cancellation failures, within the same output budget and UTF-8 status as delivered frames; completion stays unknown and the session is still stopped. Real Rizin was not run. Draft PR follows shortly.

<!-- gh-comment-id:6191422108 --> @yankikucuk commented on GitHub (Oct 15, 2026): Working on this. Reproduced with the injected provider at the manager boundary: the unterminated exit and deadline cases returned empty output while the framed control kept the diagnostic. The command wait now folds the bytes buffered after the last NUL into the partial output attached to the exit, deadline and cancellation failures, within the same output budget and UTF-8 status as delivered frames; completion stays unknown and the session is still stopped. Real Rizin was not run. Draft PR follows shortly.
Author
Owner

@N0zoM1z0 commented on GitHub (Oct 16, 2026):

Independently reproduced on Linux / Node24.18.0 at fresh main fe2a398dd9ab290ba6cb3411403d319cec8d07a2. The registered MCP regression selection has 8 failures /1 framed control pass: unterminated exit/deadline output, cancellation with already queued frames, decoded-byte/UTF-8 limits, unfinished pre-command output and an oversized unterminated frame.

Real official Linux Rizin0.9.1 also reproduces the core loss. Native stdout delivers REA_NATIVE_PENDING before NUL; the production manager with a shortened500ms frame deadline, or an injected exit of the exact owned debugger after those real bytes arrive, returns empty partial output on main. The working fix retains the exact diagnostic and completion_status: unknown in both cases. No stdout bytes or native errors were substituted.

The controlled C fixture runs and completes naturally, and native olj inventory is confirmed empty before fault injection. Existing cleanup_incomplete remains explicit; after independently confirming no inferior, the test manually tears down only its exact launched debugger. This is not proof of automatic public recovery in the fault case and changes no cleanup authority.

An actual compiled CLI/stdio MCP normal-task control passed with default deadlines: two explicit-close/reopen cycles plus an active-session client shutdown, preserving the durable caller TTY, naturally completing the detached fixture, retaining unknown debugger subject identity and preserving an unrelated rizin-named control. Final regression/static checks and paired normal-task measurements are running before opening a focused PR. No covering PR was found.

<!-- gh-comment-id:6214911371 --> @N0zoM1z0 commented on GitHub (Oct 16, 2026): Independently reproduced on Linux / Node24.18.0 at fresh main `fe2a398dd9ab290ba6cb3411403d319cec8d07a2`. The registered MCP regression selection has **8 failures /1 framed control pass**: unterminated exit/deadline output, cancellation with already queued frames, decoded-byte/UTF-8 limits, unfinished pre-command output and an oversized unterminated frame. Real official Linux Rizin0.9.1 also reproduces the core loss. Native stdout delivers `REA_NATIVE_PENDING` before NUL; the production manager with a shortened500ms frame deadline, or an injected exit of the exact owned debugger after those real bytes arrive, returns empty partial output on main. The working fix retains the exact diagnostic and `completion_status: unknown` in both cases. No stdout bytes or native errors were substituted. The controlled C fixture runs and completes naturally, and native `olj` inventory is confirmed empty before fault injection. Existing `cleanup_incomplete` remains explicit; after independently confirming no inferior, the test manually tears down only its exact launched debugger. This is not proof of automatic public recovery in the fault case and changes no cleanup authority. An actual compiled CLI/stdio MCP normal-task control passed with default deadlines: two explicit-close/reopen cycles plus an active-session client shutdown, preserving the durable caller TTY, naturally completing the detached fixture, retaining unknown debugger subject identity and preserving an unrelated `rizin`-named control. Final regression/static checks and paired normal-task measurements are running before opening a focused PR. No covering PR was found.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#1598
No description provided.