[GH-ISSUE #5959] [Bug] Rizin cancellation errors lose the exact caller abort reason #3628

Closed
opened 2026-10-31 00:42:31 +00:00 by bsakaguchi · 1 comment
Owner

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

Before submitting

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

Area

Process capture or runtime observation

Observed behavior

Rizin debugger startup and command cancellation return AnalysisCancelledError without the caller's exact AbortSignal.reason. A focused production-manager run lost an object reason before startup, before command dispatch, while queued, and during an active command even when cleanup succeeded. Wrapped active/queued errors contained another cancellation error as their cause; the original reason was absent from the entire cause chain.

Reproduction

From the pinned source checkout with locked dependencies, save this focused test as tests/boundary/providers/rizinCancellation.repro.test.ts and run:

node node_modules/vitest/vitest.mjs run --project boundary \
  tests/boundary/providers/rizinCancellation.repro.test.ts \
  --maxWorkers=1 --no-file-parallelism

The test below was executed as part of one seven-case file. That complete file passed all seven characterization assertions on Node 24.18.0 and again on Node 26.12.0. Assertions describe current faulty behavior; they do not assert a repair. Rizin is not installed on PATH. The run uses REA's injected process fixture, platform: "linux", a real host PTY for admission, and controlled stop results. No debugger or target program runs. Remove the temporary test afterwards.

import { expect, it } from "vitest";
import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js";
import { AnalysisCancelledError, AnalysisInputError } from "../../../src/domain/analysisErrorCore.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { createInjectedProcess } from "./RizinDebugSessionCoverage.fixtures.js";
import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js";

const reasonChainContains = (value: unknown, target: unknown): boolean => {
  const seen = new Set<unknown>();
  while (value instanceof Error && !seen.has(value)) {
    if (value === target) return true;
    seen.add(value); value = value.cause;
  }
  return value === target;
};
const input = async () => ({ path: "fixture.bin", inferior_tty: await rizinTestInferiorTty() });

it("pre-aborted startup drops the reason before spawn", async () => {
  let spawned = false;
  const injected = createInjectedProcess({ beforeSpawnReturns: () => { spawned = true; } });
  const manager = new RizinDebugSessionManager({}, { platform: "linux", spawnProcess: injected.spawnProcess });
  const controller = new AbortController(); const reason = { requested: "stop-start" }; controller.abort(reason);
  const result = await manager.start({ path: "fixture.bin", inferior_tty: "/dev/null" }, controller.signal);
  expect(result.ok).toBe(false); if (result.ok) throw new Error("expected cancellation");
  expect(result.error).toBeInstanceOf(AnalysisCancelledError); expect(result.error.cause).toBeUndefined(); expect(spawned).toBe(false);
  console.log(JSON.stringify({ phase: "pre-start", tag: result.error._tag, exactReason: result.error.cause === reason, reasonInChain: reasonChainContains(result.error, reason), spawned }));
  await manager.closeAll();
});

it("pre-aborted command drops the reason without dispatch", async () => {
  let writes = 0;
  const injected = createInjectedProcess({ onWrite: () => { writes++; } });
  const manager = new RizinDebugSessionManager({}, { platform: "linux", spawnProcess: injected.spawnProcess, stopProcess: async () => { injected.control.exit(); return { status: "stopped" }; } });
  try {
    const started = await manager.start(await input()); if (!started.ok) throw started.error;
    const controller = new AbortController(); const reason = { requested: "stop-command" }; controller.abort(reason);
    const result = await manager.execute(started.value.session_id, "dr", controller.signal);
    expect(result.ok).toBe(false); if (result.ok) throw new Error("expected cancellation");
    expect(result.error).toBeInstanceOf(AnalysisCancelledError); expect(result.error.cause).toBeUndefined(); expect(writes).toBe(0);
    console.log(JSON.stringify({ phase: "pre-command", tag: result.error._tag, exactReason: result.error.cause === reason, reasonInChain: reasonChainContains(result.error, reason), writes }));
  } finally { await manager.closeAll(); injected.control.exit(); }
});

it("queued cancellation wraps a reasonless cancellation and keeps the first command usable", async () => {
  let dispatched!: () => void; const sent = new Promise<void>(resolve => { dispatched = resolve; });
  let marker = ""; let writes = 0;
  const injected = createInjectedProcess({ onWrite: (text) => { writes++; marker = /!echo (\S+)/u.exec(text)?.[1] ?? ""; dispatched(); } });
  const manager = new RizinDebugSessionManager({}, { platform: "linux", spawnProcess: injected.spawnProcess, stopProcess: async () => { injected.control.exit(); return { status: "stopped" }; } });
  try {
    const started = await manager.start(await input()); if (!started.ok) throw started.error;
    const first = manager.execute(started.value.session_id, "first"); await sent;
    const controller = new AbortController(); const reason = { requested: "stop-queued" };
    const second = manager.execute(started.value.session_id, "second", controller.signal); controller.abort(reason);
    const result = await second; if (result.ok) throw new Error("expected cancellation");
    expect(result.error).toBeInstanceOf(AnalysisCancelledError); expect(reasonChainContains(result.error, reason)).toBe(false); expect(writes).toBe(1);
    injected.control.output(`first-output\0${marker}\0`); expect((await first).ok).toBe(true);
    console.log(JSON.stringify({ phase: "queued", tag: result.error._tag, exactReason: result.error.cause === reason, reasonInChain: reasonChainContains(result.error, reason), writes, firstCompleted: true }));
  } finally { await manager.closeAll(); injected.control.exit(); }
});

it("active cancellation with successful cleanup still drops the caller reason", async () => {
  const controller = new AbortController(); const reason = { requested: "stop-active" };
  const injected = createInjectedProcess({ onWrite: (_text, control) => { control.output("partial-output\0"); setImmediate(() => controller.abort(reason)); } });
  const manager = new RizinDebugSessionManager({}, { platform: "linux", spawnProcess: injected.spawnProcess, stopProcess: async () => { injected.control.exit(); return { status: "stopped" }; } });
  try {
    const started = await manager.start(await input()); if (!started.ok) throw started.error;
    const result = await manager.execute(started.value.session_id, "slow", controller.signal); if (result.ok) throw new Error("expected cancellation");
    expect(result.error).toBeInstanceOf(AnalysisCancelledError); expect(reasonChainContains(result.error, reason)).toBe(false); expect(result.error.cleanupIncomplete).toBe(false);
    expect(projectAnalysisError(result.error).code).toBe("cancelled");
    console.log(JSON.stringify({ phase: "active-cleanup-success", tag: result.error._tag, exactReason: result.error.cause === reason, reasonInChain: reasonChainContains(result.error, reason), cleanupIncomplete: result.error.cleanupIncomplete }));
  } finally { await manager.closeAll(); injected.control.exit(); }
});

Expected behavior

The lifecycle contract requires preserving the caller's abort reason as the typed cancellation error's cause. The testing guidance explicitly requires reason identity assertions. For each observed cancellation, error.cause should be the exact value on the signal seen by the adapter.

Impact and scope

P2: required cancellation provenance is lost at the shared Rizin producer. Cancellation classification remained correct in these four cases, the queued cancellation did not dispatch its command, and the prior command completed normally. Keeping the original signal lets its owner read the reason separately, but the returned error no longer carries it. This does not demonstrate a lost public wire field: opaque causes are not currently serialized in cancellation responses. Natural occurrence frequency and real-provider behavior were not measured.

REA version

rea-agents 6.7.0; source revision 5241597594. Fetched origin/main 25e1a24a3e: relevant Rizin manager, lifecycle contract and registration lines are unchanged. An unrelated output-projection helper was added; it does not alter these classifications. The newer revision was source-compared, not executed.

Runtime and platform

macOS 27.0.1 arm64; Node.js v24.18.0 (repository pin) and v26.12.0; locked Vitest 5.0.3. Linux protocol behavior was simulated on this host. Real Linux /proc/inferior verification was not executed.

Target and artifact context

Injected process streams, object abort-reason sentinels, a real host PTY and the nominal fixture.bin launch input. The fixture path was not opened or executed. No external engine or inferior.

Interface

MCP

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

{"phase":"pre-start","tag":"AnalysisCancelledError","exactReason":false,"reasonInChain":false,"spawned":false}
{"phase":"pre-command","tag":"AnalysisCancelledError","exactReason":false,"reasonInChain":false,"writes":0}
{"phase":"queued","tag":"AnalysisCancelledError","exactReason":false,"reasonInChain":false,"writes":1,"firstCompleted":true}
{"phase":"active-cleanup-success","tag":"AnalysisCancelledError","exactReason":false,"reasonInChain":false,"cleanupIncomplete":false}

Observed at the production manager and error-projection boundary. Public reachability is source-derived: registered Rizin handlers pass the server request signal into these same start/execute methods and deliver failed typed results. The one-shot debug-with-rizin CLI uses the same manager. No CLI subprocess or MCP cancellation transport was executed; a cancelled MCP request may have no terminal wire reply.

Typed outcome or recovery expected

Preserve AnalysisCancelledError, partial observations and true cleanup ownership. Its direct cause should equal the exact observed caller reason, including object identity. Do not interpret an opaque caller reason as cleanup authority or require arbitrary causes to be serialized publicly.

Analysis or suspected cause

Source-derived explanation: pre-aborted startup and pre-aborted execution omit cause. The frame and turn waits also create cancellations without cause. The execution catch preserves those reasonless wrappers, which cannot recover the original signal value. These are separate constructors from the fixed shared owned-command and derived-operation boundaries.

Additional context

Related #5944 concerns runDerivedOperation; #4638 concerns OwnedCommand; #5059 concerns retainSecondaryFailure; #5935 concerns JavaScript artifact reads. They require different producer fixes. #5896 concerns inability to complete cleanup after output/deadline limits. The exact abort-reason loss also occurs before any process spawn and with successful cleanup. PR #5516 keeps these reasonless constructors. Late cancellation after confirmed provider completion is source-derived and was not separately executed. NOT_ENOUGH_INFO: introducing commit and last known good release.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5959 ### 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 Process capture or runtime observation ### Observed behavior Rizin debugger startup and command cancellation return AnalysisCancelledError without the caller's exact AbortSignal.reason. A focused production-manager run lost an object reason before startup, before command dispatch, while queued, and during an active command even when cleanup succeeded. Wrapped active/queued errors contained another cancellation error as their cause; the original reason was absent from the entire cause chain. ### Reproduction From the pinned source checkout with locked dependencies, save this focused test as tests/boundary/providers/rizinCancellation.repro.test.ts and run: ```sh node node_modules/vitest/vitest.mjs run --project boundary \ tests/boundary/providers/rizinCancellation.repro.test.ts \ --maxWorkers=1 --no-file-parallelism ``` The test below was executed as part of one seven-case file. That complete file passed all seven characterization assertions on Node 24.18.0 and again on Node 26.12.0. Assertions describe current faulty behavior; they do not assert a repair. Rizin is not installed on PATH. The run uses REA's injected process fixture, platform: "linux", a real host PTY for admission, and controlled stop results. No debugger or target program runs. Remove the temporary test afterwards. ```ts import { expect, it } from "vitest"; import { RizinDebugSessionManager } from "../../../src/rizin/RizinDebugSessionManager.js"; import { AnalysisCancelledError, AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { createInjectedProcess } from "./RizinDebugSessionCoverage.fixtures.js"; import { rizinTestInferiorTty } from "./RizinTestInferiorTty.js"; const reasonChainContains = (value: unknown, target: unknown): boolean => { const seen = new Set<unknown>(); while (value instanceof Error && !seen.has(value)) { if (value === target) return true; seen.add(value); value = value.cause; } return value === target; }; const input = async () => ({ path: "fixture.bin", inferior_tty: await rizinTestInferiorTty() }); it("pre-aborted startup drops the reason before spawn", async () => { let spawned = false; const injected = createInjectedProcess({ beforeSpawnReturns: () => { spawned = true; } }); const manager = new RizinDebugSessionManager({}, { platform: "linux", spawnProcess: injected.spawnProcess }); const controller = new AbortController(); const reason = { requested: "stop-start" }; controller.abort(reason); const result = await manager.start({ path: "fixture.bin", inferior_tty: "/dev/null" }, controller.signal); expect(result.ok).toBe(false); if (result.ok) throw new Error("expected cancellation"); expect(result.error).toBeInstanceOf(AnalysisCancelledError); expect(result.error.cause).toBeUndefined(); expect(spawned).toBe(false); console.log(JSON.stringify({ phase: "pre-start", tag: result.error._tag, exactReason: result.error.cause === reason, reasonInChain: reasonChainContains(result.error, reason), spawned })); await manager.closeAll(); }); it("pre-aborted command drops the reason without dispatch", async () => { let writes = 0; const injected = createInjectedProcess({ onWrite: () => { writes++; } }); const manager = new RizinDebugSessionManager({}, { platform: "linux", spawnProcess: injected.spawnProcess, stopProcess: async () => { injected.control.exit(); return { status: "stopped" }; } }); try { const started = await manager.start(await input()); if (!started.ok) throw started.error; const controller = new AbortController(); const reason = { requested: "stop-command" }; controller.abort(reason); const result = await manager.execute(started.value.session_id, "dr", controller.signal); expect(result.ok).toBe(false); if (result.ok) throw new Error("expected cancellation"); expect(result.error).toBeInstanceOf(AnalysisCancelledError); expect(result.error.cause).toBeUndefined(); expect(writes).toBe(0); console.log(JSON.stringify({ phase: "pre-command", tag: result.error._tag, exactReason: result.error.cause === reason, reasonInChain: reasonChainContains(result.error, reason), writes })); } finally { await manager.closeAll(); injected.control.exit(); } }); it("queued cancellation wraps a reasonless cancellation and keeps the first command usable", async () => { let dispatched!: () => void; const sent = new Promise<void>(resolve => { dispatched = resolve; }); let marker = ""; let writes = 0; const injected = createInjectedProcess({ onWrite: (text) => { writes++; marker = /!echo (\S+)/u.exec(text)?.[1] ?? ""; dispatched(); } }); const manager = new RizinDebugSessionManager({}, { platform: "linux", spawnProcess: injected.spawnProcess, stopProcess: async () => { injected.control.exit(); return { status: "stopped" }; } }); try { const started = await manager.start(await input()); if (!started.ok) throw started.error; const first = manager.execute(started.value.session_id, "first"); await sent; const controller = new AbortController(); const reason = { requested: "stop-queued" }; const second = manager.execute(started.value.session_id, "second", controller.signal); controller.abort(reason); const result = await second; if (result.ok) throw new Error("expected cancellation"); expect(result.error).toBeInstanceOf(AnalysisCancelledError); expect(reasonChainContains(result.error, reason)).toBe(false); expect(writes).toBe(1); injected.control.output(`first-output\0${marker}\0`); expect((await first).ok).toBe(true); console.log(JSON.stringify({ phase: "queued", tag: result.error._tag, exactReason: result.error.cause === reason, reasonInChain: reasonChainContains(result.error, reason), writes, firstCompleted: true })); } finally { await manager.closeAll(); injected.control.exit(); } }); it("active cancellation with successful cleanup still drops the caller reason", async () => { const controller = new AbortController(); const reason = { requested: "stop-active" }; const injected = createInjectedProcess({ onWrite: (_text, control) => { control.output("partial-output\0"); setImmediate(() => controller.abort(reason)); } }); const manager = new RizinDebugSessionManager({}, { platform: "linux", spawnProcess: injected.spawnProcess, stopProcess: async () => { injected.control.exit(); return { status: "stopped" }; } }); try { const started = await manager.start(await input()); if (!started.ok) throw started.error; const result = await manager.execute(started.value.session_id, "slow", controller.signal); if (result.ok) throw new Error("expected cancellation"); expect(result.error).toBeInstanceOf(AnalysisCancelledError); expect(reasonChainContains(result.error, reason)).toBe(false); expect(result.error.cleanupIncomplete).toBe(false); expect(projectAnalysisError(result.error).code).toBe("cancelled"); console.log(JSON.stringify({ phase: "active-cleanup-success", tag: result.error._tag, exactReason: result.error.cause === reason, reasonInChain: reasonChainContains(result.error, reason), cleanupIncomplete: result.error.cleanupIncomplete })); } finally { await manager.closeAll(); injected.control.exit(); } }); ``` ### Expected behavior The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L110) requires preserving the caller's abort reason as the typed cancellation error's cause. The [testing guidance](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/testing.md#L268-L275) explicitly requires reason identity assertions. For each observed cancellation, error.cause should be the exact value on the signal seen by the adapter. ### Impact and scope P2: required cancellation provenance is lost at the shared Rizin producer. Cancellation classification remained correct in these four cases, the queued cancellation did not dispatch its command, and the prior command completed normally. Keeping the original signal lets its owner read the reason separately, but the returned error no longer carries it. This does not demonstrate a lost public wire field: opaque causes are not currently serialized in cancellation responses. Natural occurrence frequency and real-provider behavior were not measured. ### REA version rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e. Fetched origin/main 25e1a24a3ed3671a0148757555f18d16c29d936a: relevant Rizin manager, lifecycle contract and registration lines are unchanged. An unrelated output-projection helper was added; it does not alter these classifications. The newer revision was source-compared, not executed. ### Runtime and platform macOS 27.0.1 arm64; Node.js v24.18.0 (repository pin) and v26.12.0; locked Vitest 5.0.3. Linux protocol behavior was simulated on this host. Real Linux /proc/inferior verification was not executed. ### Target and artifact context Injected process streams, object abort-reason sentinels, a real host PTY and the nominal fixture.bin launch input. The fixture path was not opened or executed. No external engine or inferior. ### Interface MCP ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence ```json {"phase":"pre-start","tag":"AnalysisCancelledError","exactReason":false,"reasonInChain":false,"spawned":false} {"phase":"pre-command","tag":"AnalysisCancelledError","exactReason":false,"reasonInChain":false,"writes":0} {"phase":"queued","tag":"AnalysisCancelledError","exactReason":false,"reasonInChain":false,"writes":1,"firstCompleted":true} {"phase":"active-cleanup-success","tag":"AnalysisCancelledError","exactReason":false,"reasonInChain":false,"cleanupIncomplete":false} ``` Observed at the production manager and error-projection boundary. Public reachability is source-derived: [registered Rizin handlers](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/registerRizinDebugTools.ts#L32-L109) pass the server request signal into these same start/execute methods and deliver failed typed results. The one-shot debug-with-rizin CLI uses the same manager. No CLI subprocess or MCP cancellation transport was executed; a cancelled MCP request may have no terminal wire reply. ### Typed outcome or recovery expected Preserve AnalysisCancelledError, partial observations and true cleanup ownership. Its direct cause should equal the exact observed caller reason, including object identity. Do not interpret an opaque caller reason as cleanup authority or require arbitrary causes to be serialized publicly. ### Analysis or suspected cause Source-derived explanation: [pre-aborted startup](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/rizin/RizinDebugSessionManager.ts#L322-L323) and [pre-aborted execution](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/rizin/RizinDebugSessionManager.ts#L565-L566) omit cause. The [frame and turn waits](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/rizin/RizinDebugSessionManager.ts#L1547-L1601) also create cancellations without cause. The [execution catch](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/rizin/RizinDebugSessionManager.ts#L744-L779) preserves those reasonless wrappers, which cannot recover the original signal value. These are separate constructors from the fixed shared owned-command and derived-operation boundaries. ### Additional context Related #5944 concerns runDerivedOperation; #4638 concerns OwnedCommand; #5059 concerns retainSecondaryFailure; #5935 concerns JavaScript artifact reads. They require different producer fixes. #5896 concerns inability to complete cleanup after output/deadline limits. The exact abort-reason loss also occurs before any process spawn and with successful cleanup. PR #5516 keeps these reasonless constructors. Late cancellation after confirmed provider completion is source-derived and was not separately executed. NOT_ENOUGH_INFO: introducing commit and last known good release.
Author
Owner

@coygeek commented on GitHub (Oct 24, 2026):

At d87e73f2db50be72d33a4b3c7199af35518b441c, Rizin cancellation paths use a shared constructor that preserves exact signal.reason, including frame/turn waits; cleanup wrapping preserves the direct cause. Active cancellation tests assert object/null identity and partial observations.

I ran the cited regression files on this revision; all passed as part of the focused 28-file, 603-test run. Provider-facing checks here use controlled fixtures; this does not claim a run with the real analysis engine. Closing as completed on main.

<!-- gh-comment-id:6380646615 --> @coygeek commented on GitHub (Oct 24, 2026): At `d87e73f2db50be72d33a4b3c7199af35518b441c`, Rizin cancellation paths use a [shared constructor that preserves exact `signal.reason`](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/rizin/RizinDebugSessionManager.ts#L1855-L1869), including [frame/turn waits](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/rizin/RizinDebugSessionManager.ts#L1792-L1852); [cleanup wrapping preserves the direct cause](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/src/rizin/RizinDebugSessionManager.ts#L1482-L1499). [Active cancellation tests](https://github.com/morluto/rea/blob/d87e73f2db50be72d33a4b3c7199af35518b441c/tests/boundary/providers/RizinDebugSessionCoverage.part3.test.ts#L300-L336) assert object/null identity and partial observations. I ran the cited regression files on this revision; all passed as part of the focused 28-file, 603-test run. Provider-facing checks here use controlled fixtures; this does not claim a run with the real analysis engine. Closing as completed on main.
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#3628
No description provided.