[GH-ISSUE #7429] [Bug] debug-with-rizin returns successful Evidence after failed or interrupted final cleanup #4437

Closed
opened 2026-10-31 00:49:47 +00:00 by bsakaguchi · 0 comments
Owner

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

Before submitting

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

Area

CLI

Observed behavior

The registered handler for debug-with-rizin ./sample.bin dr --inferior-tty /dev/pts/7 --json returns unchanged successful rizin_debug_command Evidence when both cleanup attempts fail, although exit status becomes 1. A SIGINT handled during awaited fallback cleanup also leaves that success unchanged, with exit status 130. Neither return has a typed finalization error or partial-observation envelope. Session identity does survive in parameters.session_id.

Observed through production CLI registration, cancellation/logging wrappers and the real fallback aggregation helper, with mocked manager start/execute/close boundaries and synthetic fallback state. This is not a packaged command execution or real Rizin/Linux/TTY test.

Reproduction

Use Node 24.22.1 at the source revision below. Fresh-checkout setup is npm ci && npm run build:cached; the verification checkout already had matching locked dependencies, so I rebuilt without reinstalling them. Run this probe from the repository root. It ran once with all three characterization cases passing. The temporary filename below is the public-safe spelling of the executed filename.

export PATH="$(mise where node@24.22.1)/bin:$PATH"
WORK=$(mktemp -d)
mkdir -p "$WORK"/{home,config,data,cache,state,tmp}
export HOME="$WORK/home" XDG_CONFIG_HOME="$WORK/config" XDG_DATA_HOME="$WORK/data"
export XDG_CACHE_HOME="$WORK/cache" XDG_STATE_HOME="$WORK/state" TMPDIR="$WORK/tmp"
cat > tests/boundary/cli/rizinFinalizationProbe.test.ts <<'EOF'
import pino from "pino";
import { expect, it, vi } from "vitest";
import { registerReverseEngineeringCommands } from "../../../src/cli/reverseEngineeringCommands.js";
import type { CliInstance } from "../../../src/cli/types.js";
import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js";
import { createEvidence } from "../../../src/domain/evidence.js";
import { err, ok } from "../../../src/domain/result.js";
import { closeOwnedDebuggerSessions } from "../../../src/process/ownedDebuggerSession.js";
import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js";

it.each(["incomplete", "interrupt", "control"] as const)("final cleanup %s", async (mode) => {
  const previous = process.exitCode;
  const evidence = createEvidence(undefined, { id: "rizin.debugger", name: "Rizin debugger", version: null }, {
    predicateType: "rea.debugger.command-observation", operation: "rizin_debug_command",
    parameters: { session_id: "fixture-session", command: "dr", inferior_tty: "/dev/pts/7" },
    result: { output: "registers" }, rawResult: { output: "registers" },
  });
  const sessions = new Map([["fixture-session", { id: "fixture-session" }]]);
  vi.spyOn(RizinDebugSessionManager.prototype, "start").mockResolvedValue(ok({ session_id: "fixture-session", backend: null }));
  vi.spyOn(RizinDebugSessionManager.prototype, "execute").mockResolvedValue(ok({ evidence, result: {
    command: "dr", output: "registers", output_scope: "command_and_interleaved_session_output",
    output_truncated: false, completion_status: "complete", backend: null,
  } }));
  vi.spyOn(RizinDebugSessionManager.prototype, "close").mockImplementation(async () => {
    if (mode === "incomplete") return err(new AnalysisOutputError("close_rizin_debug_session", "stop unconfirmed", {
      cleanup: { reason: "stop unconfirmed", resources: ["fixture-session"] },
    }));
    sessions.clear();
    return ok({ target_state: "unknown" });
  });
  let abortedAtCleanupEnd = false;
  let signal: AbortSignal | undefined;
  const execute = vi.mocked(RizinDebugSessionManager.prototype.execute);
  const closeAll = vi.spyOn(RizinDebugSessionManager.prototype, "closeAll").mockImplementation(async () => {
    signal = execute.mock.calls[0]?.[2];
    await new Promise<void>(resolve => setImmediate(() => {
      if (mode === "interrupt") process.emit("SIGINT");
      resolve();
    }));
    abortedAtCleanupEnd = signal?.aborted === true;
    await closeOwnedDebuggerSessions(sessions, async () => ({ status: "incomplete", reason: "stop unconfirmed" }), "Rizin");
  });
  let handler: any;
  const cli = { command(name: string, definition: any) {
    if (name === "debug-with-rizin") handler = definition.run;
    return cli;
  } } as unknown as CliInstance;
  registerReverseEngineeringCommands(cli, pino({ enabled: false }), {});
  try {
    process.exitCode = 0;
    const result = await handler({ args: { path: "./sample.bin", command: "dr" }, options: { inferiorTty: "/dev/pts/7" } });
    const observed = { mode, fallbackCalls: closeAll.mock.calls.length, exitCode: process.exitCode,
      operation: result.operation, code: result.code ?? null, cleanup: result.cleanup_incomplete ?? null,
      retained: sessions.size, sessionIdentityInEvidence: result.parameters.session_id === "fixture-session", abortedAtCleanupEnd };
    console.log(JSON.stringify(observed));
    expect(result).toBe(evidence);
    expect(observed).toEqual({ mode, fallbackCalls: 1, exitCode: mode === "incomplete" ? 1 : mode === "interrupt" ? 130 : 0,
      operation: "rizin_debug_command", code: null, cleanup: null, retained: mode === "incomplete" ? 1 : 0,
      sessionIdentityInEvidence: true, abortedAtCleanupEnd: mode === "interrupt" });
  } finally {
    process.exitCode = previous;
    vi.restoreAllMocks();
  }
});
EOF
npx vitest run tests/boundary/cli/rizinFinalizationProbe.test.ts
rm tests/boundary/cli/rizinFinalizationProbe.test.ts

Expected behavior

Check cancellation after the last awaited cleanup, preserve completed Evidence as a partial observation under typed cancellation, and return canonical cleanup failure with unresolved resources/recovery details when fallback cannot confirm release. Preserve ordinary success when cleanup succeeds without interruption. This follows docs/tool-design.md:181-190,215-225 at the stated revision.

Impact and scope

P2: structured consumers receive success-shaped Evidence after unsuccessful or interrupted finalization. The observed exit status distinguishes these cases from success, allowing wrappers to reject the misleading result, but does not recover typed finalization details. No target damage, data loss or real process leak was demonstrated. Natural failure frequency and native-provider behavior are unknown. Scope is the one-shot Rizin CLI finalizer.

REA version

Exact node scripts/rea.mjs --version output: 6.8.0. Source main: a116f2a7ae8e7ae18b6d0a24211abdb2b920a7db; rebuilt before verification.

Runtime and platform

Node v24.22.1; macOS 27.0.1; Darwin arm64; locked Vitest 5.0.3. The provider admission boundary was mocked; no supported Linux runtime behavior is claimed from this macOS probe.

Target and artifact context

Synthetic command Evidence, a synthetic session map and controlled cleanup results. The sample path and TTY are inert fixture strings; neither is opened. No debugger, target process, device or external service ran.

Interface

CLI

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Observed records from one focused run, 3 tests passed. Null instrumentation fields mean the product result lacks that field. retained counts the synthetic map passed to the real fallback helper, not live processes or the manager's private state.

{"mode":"incomplete","fallbackCalls":1,"exitCode":1,"operation":"rizin_debug_command","code":null,"cleanup":null,"retained":1,"sessionIdentityInEvidence":true,"abortedAtCleanupEnd":false}
{"mode":"interrupt","fallbackCalls":1,"exitCode":130,"operation":"rizin_debug_command","code":null,"cleanup":null,"retained":0,"sessionIdentityInEvidence":true,"abortedAtCleanupEnd":true}
{"mode":"control","fallbackCalls":1,"exitCode":0,"operation":"rizin_debug_command","code":null,"cleanup":null,"retained":0,"sessionIdentityInEvidence":true,"abortedAtCleanupEnd":false}

Typed outcome or recovery expected

For unresolved cleanup, return cleanup_incomplete with the remaining resource identity and completed Evidence under details.partial_observation. For late interruption after confirmed cleanup, project AnalysisCancelledError, retain the caller's abort reason and completed Evidence, and keep exit status 130. A successful non-aborted control should remain successful.

Analysis or suspected cause

Source-derived: src/cli/reverseEngineeringCommands.ts:409-476 returns Evidence before its manual finally block. It logs initial close failure, swallows fallback rejection after assigning exit status 1, and never checks the command signal after fallback. Rizin returns Evidence directly; it does not use the GDB Evidence-delivery helper. src/rizin/RizinDebugSessionManager.ts:976-1028 retains failed-close sessions and delegates fallback to src/process/ownedDebuggerSession.ts:281-319, which retains unresolved entries and rejects. src/cli/commandCancellation.ts:9-38 assigns signal exit status without replacing the return value.

Additional context

The open GDB sibling #7378 has a separate handler/manager: fixing its return does not repair this Rizin return. Closed #6111 concerns shared-helper execute-rizin-command/objdump cleanup and explicitly leaves debugger-manager paths untested. Closed #5090 concerns an incorrect nonzero status after successful fallback; the successful control here passes. Closed #5963 concerns cancellation during startup/active execution, before this successful-command finalization boundary. Proposed restoration check: rerun the matrix expecting typed cleanup failure/cancellation while retaining the successful control; no repair was tested.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: The registered-handler probe returned unchanged successful Evidence after failed final cleanup and after a handled finalization interrupt, while the successful control exited 0. The discrepancy is bounded; completed Evidence and session identity remain intact.
  • Workaround status: available
  • Workaround basis: Observed exit statuses 1 and 130 distinguish both unsuccessful cases from the successful control, allowing CLI wrappers to reject the misleading success-shaped record; typed recovery metadata remains absent.
Originally created by @coygeek on GitHub (Oct 27, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/7429 ## 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 CLI ## Observed behavior The registered handler for `debug-with-rizin ./sample.bin dr --inferior-tty /dev/pts/7 --json` returns unchanged successful `rizin_debug_command` Evidence when both cleanup attempts fail, although exit status becomes 1. A SIGINT handled during awaited fallback cleanup also leaves that success unchanged, with exit status 130. Neither return has a typed finalization error or partial-observation envelope. Session identity does survive in `parameters.session_id`. Observed through production CLI registration, cancellation/logging wrappers and the real fallback aggregation helper, with mocked manager start/execute/close boundaries and synthetic fallback state. This is not a packaged command execution or real Rizin/Linux/TTY test. ## Reproduction Use Node 24.22.1 at the source revision below. Fresh-checkout setup is `npm ci && npm run build:cached`; the verification checkout already had matching locked dependencies, so I rebuilt without reinstalling them. Run this probe from the repository root. It ran once with all three characterization cases passing. The temporary filename below is the public-safe spelling of the executed filename. ```sh export PATH="$(mise where node@24.22.1)/bin:$PATH" WORK=$(mktemp -d) mkdir -p "$WORK"/{home,config,data,cache,state,tmp} export HOME="$WORK/home" XDG_CONFIG_HOME="$WORK/config" XDG_DATA_HOME="$WORK/data" export XDG_CACHE_HOME="$WORK/cache" XDG_STATE_HOME="$WORK/state" TMPDIR="$WORK/tmp" cat > tests/boundary/cli/rizinFinalizationProbe.test.ts <<'EOF' import pino from "pino"; import { expect, it, vi } from "vitest"; import { registerReverseEngineeringCommands } from "../../../src/cli/reverseEngineeringCommands.js"; import type { CliInstance } from "../../../src/cli/types.js"; import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { err, ok } from "../../../src/domain/result.js"; import { closeOwnedDebuggerSessions } from "../../../src/process/ownedDebuggerSession.js"; import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; it.each(["incomplete", "interrupt", "control"] as const)("final cleanup %s", async (mode) => { const previous = process.exitCode; const evidence = createEvidence(undefined, { id: "rizin.debugger", name: "Rizin debugger", version: null }, { predicateType: "rea.debugger.command-observation", operation: "rizin_debug_command", parameters: { session_id: "fixture-session", command: "dr", inferior_tty: "/dev/pts/7" }, result: { output: "registers" }, rawResult: { output: "registers" }, }); const sessions = new Map([["fixture-session", { id: "fixture-session" }]]); vi.spyOn(RizinDebugSessionManager.prototype, "start").mockResolvedValue(ok({ session_id: "fixture-session", backend: null })); vi.spyOn(RizinDebugSessionManager.prototype, "execute").mockResolvedValue(ok({ evidence, result: { command: "dr", output: "registers", output_scope: "command_and_interleaved_session_output", output_truncated: false, completion_status: "complete", backend: null, } })); vi.spyOn(RizinDebugSessionManager.prototype, "close").mockImplementation(async () => { if (mode === "incomplete") return err(new AnalysisOutputError("close_rizin_debug_session", "stop unconfirmed", { cleanup: { reason: "stop unconfirmed", resources: ["fixture-session"] }, })); sessions.clear(); return ok({ target_state: "unknown" }); }); let abortedAtCleanupEnd = false; let signal: AbortSignal | undefined; const execute = vi.mocked(RizinDebugSessionManager.prototype.execute); const closeAll = vi.spyOn(RizinDebugSessionManager.prototype, "closeAll").mockImplementation(async () => { signal = execute.mock.calls[0]?.[2]; await new Promise<void>(resolve => setImmediate(() => { if (mode === "interrupt") process.emit("SIGINT"); resolve(); })); abortedAtCleanupEnd = signal?.aborted === true; await closeOwnedDebuggerSessions(sessions, async () => ({ status: "incomplete", reason: "stop unconfirmed" }), "Rizin"); }); let handler: any; const cli = { command(name: string, definition: any) { if (name === "debug-with-rizin") handler = definition.run; return cli; } } as unknown as CliInstance; registerReverseEngineeringCommands(cli, pino({ enabled: false }), {}); try { process.exitCode = 0; const result = await handler({ args: { path: "./sample.bin", command: "dr" }, options: { inferiorTty: "/dev/pts/7" } }); const observed = { mode, fallbackCalls: closeAll.mock.calls.length, exitCode: process.exitCode, operation: result.operation, code: result.code ?? null, cleanup: result.cleanup_incomplete ?? null, retained: sessions.size, sessionIdentityInEvidence: result.parameters.session_id === "fixture-session", abortedAtCleanupEnd }; console.log(JSON.stringify(observed)); expect(result).toBe(evidence); expect(observed).toEqual({ mode, fallbackCalls: 1, exitCode: mode === "incomplete" ? 1 : mode === "interrupt" ? 130 : 0, operation: "rizin_debug_command", code: null, cleanup: null, retained: mode === "incomplete" ? 1 : 0, sessionIdentityInEvidence: true, abortedAtCleanupEnd: mode === "interrupt" }); } finally { process.exitCode = previous; vi.restoreAllMocks(); } }); EOF npx vitest run tests/boundary/cli/rizinFinalizationProbe.test.ts rm tests/boundary/cli/rizinFinalizationProbe.test.ts ``` ## Expected behavior Check cancellation after the last awaited cleanup, preserve completed Evidence as a partial observation under typed cancellation, and return canonical cleanup failure with unresolved resources/recovery details when fallback cannot confirm release. Preserve ordinary success when cleanup succeeds without interruption. This follows `docs/tool-design.md:181-190,215-225` at the stated revision. ## Impact and scope P2: structured consumers receive success-shaped Evidence after unsuccessful or interrupted finalization. The observed exit status distinguishes these cases from success, allowing wrappers to reject the misleading result, but does not recover typed finalization details. No target damage, data loss or real process leak was demonstrated. Natural failure frequency and native-provider behavior are unknown. Scope is the one-shot Rizin CLI finalizer. ## REA version Exact `node scripts/rea.mjs --version` output: `6.8.0`. Source main: `a116f2a7ae8e7ae18b6d0a24211abdb2b920a7db`; rebuilt before verification. ## Runtime and platform Node `v24.22.1`; macOS `27.0.1`; `Darwin arm64`; locked Vitest `5.0.3`. The provider admission boundary was mocked; no supported Linux runtime behavior is claimed from this macOS probe. ## Target and artifact context Synthetic command Evidence, a synthetic session map and controlled cleanup results. The sample path and TTY are inert fixture strings; neither is opened. No debugger, target process, device or external service ran. ## Interface CLI ## Earliest failure stage Cleanup, cancellation, or lifecycle ## Relevant output or evidence Observed records from one focused run, 3 tests passed. Null instrumentation fields mean the product result lacks that field. `retained` counts the synthetic map passed to the real fallback helper, not live processes or the manager's private state. ```jsonl {"mode":"incomplete","fallbackCalls":1,"exitCode":1,"operation":"rizin_debug_command","code":null,"cleanup":null,"retained":1,"sessionIdentityInEvidence":true,"abortedAtCleanupEnd":false} {"mode":"interrupt","fallbackCalls":1,"exitCode":130,"operation":"rizin_debug_command","code":null,"cleanup":null,"retained":0,"sessionIdentityInEvidence":true,"abortedAtCleanupEnd":true} {"mode":"control","fallbackCalls":1,"exitCode":0,"operation":"rizin_debug_command","code":null,"cleanup":null,"retained":0,"sessionIdentityInEvidence":true,"abortedAtCleanupEnd":false} ``` ## Typed outcome or recovery expected For unresolved cleanup, return `cleanup_incomplete` with the remaining resource identity and completed Evidence under `details.partial_observation`. For late interruption after confirmed cleanup, project `AnalysisCancelledError`, retain the caller's abort reason and completed Evidence, and keep exit status 130. A successful non-aborted control should remain successful. ## Analysis or suspected cause Source-derived: `src/cli/reverseEngineeringCommands.ts:409-476` returns Evidence before its manual finally block. It logs initial close failure, swallows fallback rejection after assigning exit status 1, and never checks the command signal after fallback. Rizin returns Evidence directly; it does not use the GDB Evidence-delivery helper. `src/rizin/RizinDebugSessionManager.ts:976-1028` retains failed-close sessions and delegates fallback to `src/process/ownedDebuggerSession.ts:281-319`, which retains unresolved entries and rejects. `src/cli/commandCancellation.ts:9-38` assigns signal exit status without replacing the return value. ## Additional context The open GDB sibling [#7378](https://github.com/morluto/rea/issues/7378) has a separate handler/manager: fixing its return does not repair this Rizin return. Closed [#6111](https://github.com/morluto/rea/issues/6111) concerns shared-helper `execute-rizin-command`/objdump cleanup and explicitly leaves debugger-manager paths untested. Closed [#5090](https://github.com/morluto/rea/issues/5090) concerns an incorrect nonzero status after successful fallback; the successful control here passes. Closed [#5963](https://github.com/morluto/rea/issues/5963) concerns cancellation during startup/active execution, before this successful-command finalization boundary. Proposed restoration check: rerun the matrix expecting typed cleanup failure/cancellation while retaining the successful control; no repair was tested. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: The registered-handler probe returned unchanged successful Evidence after failed final cleanup and after a handled finalization interrupt, while the successful control exited 0. The discrepancy is bounded; completed Evidence and session identity remain intact. - Workaround status: available - Workaround basis: Observed exit statuses 1 and 130 distinguish both unsuccessful cases from the successful control, allowing CLI wrappers to reject the misleading success-shaped record; typed recovery metadata remains absent.
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#4437
No description provided.