[GH-ISSUE #5960] [Bug] trace_call_path loses the abort reason after the provider checkpoint #3630

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/5960

Before submitting

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

Area

Native analysis or decompilation

Observed behavior

Cancelling trace_call_path after its provider-observation wrapper has checked the request signal, but before the traversal continuation checks it, returns an internal AnalysisCancelledError with cause === undefined. The same server request signal still contains "Requested stop".

I reproduced this through the real MCP SDK client, public server registration and linked in-memory transports, using a simulated analysis provider to synchronize the cancellation. Immediate cancellation during the same relationship response retained the exact reason. Both cases completed an ordinary trace before cancellation, and the server answered a ping afterward. The error was observed immediately before MCP projection; this does not establish a missing wire field.

Reproduction

In a source checkout with its locked dependencies installed, save this focused test as src/application/callPathReason.repro.test.ts and run:

node node_modules/vitest/vitest.mjs run src/application/callPathReason.repro.test.ts --maxWorkers=1

The executed run reported one passing immediate-cancellation case and one failing reason-identity assertion at microtask depth 2. The two queued microtasks synchronize cancellation with the gap after the provider wrapper's own checkpoint. The delivery spy observes the typed result without modifying it. No native engine is required or started.

import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { expect, it, vi } from "vitest";
import { createAnalysisExecution } from "./AnalysisProvider.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { ok } from "../domain/result.js";
import { createServer } from "../server/createServer.js";
import { ToolResultDelivery } from "../server/toolResult.js";

it.each([0, 2])("retains trace cancellation reason at microtask depth %i", async depth => {
  const controller = new AbortController();
  let cancelling = false;
  let serverSignal: AbortSignal | undefined;
  let typed: any;
  let settle!: () => void;
  const settled = new Promise<void>(resolve => { settle = resolve; });
  const delivery = ToolResultDelivery.prototype.toCallToolResult;
  const spy = vi.spyOn(ToolResultDelivery.prototype, "toCallToolResult").mockImplementation(function(result, contract) {
    if (cancelling && contract.name === "trace_call_path") { typed = result; settle(); }
    return delivery.call(this, result, contract);
  });
  const provider = { id: "fixture", name: "Fixture", version: "1" };
  const server = createServer({ kind: "fixed", analysis: { execute: async (operation, parameters, options) => {
    serverSignal = options?.signal;
    if (operation === "procedure_address") return ok(createAnalysisExecution(parameters.procedure, provider));
    if (operation === "procedure_callees") {
      const later = (remaining: number) => remaining === 0 ? controller.abort("Requested stop") : queueMicrotask(() => later(remaining - 1));
      if (cancelling) later(depth);
      return ok(createAnalysisExecution([], provider));
    }
    throw new Error(`Unexpected operation: ${operation}`);
  }}}, { environment: {}, providerEnvironment: {} });
  const client = new Client({ name: "reason-reproduction", version: "1" });
  const [ct, st] = InMemoryTransport.createLinkedPair();
  try {
    await server.connect(st);
    await client.connect(ct);
    const control = await client.callTool({ name: "trace_call_path", arguments: { start: "0x1000" } });
    expect(control.isError).not.toBe(true);
    cancelling = true;
    await client.callTool({ name: "trace_call_path", arguments: { start: "0x1000" } }, { signal: controller.signal }).catch(error => error);
    await settled;
    expect(typed.ok).toBe(false);
    expect(typed.error).toBeInstanceOf(AnalysisCancelledError);
    expect(serverSignal?.aborted).toBe(true);
    expect(serverSignal?.reason).toBeDefined();
    await client.ping();
    console.log(JSON.stringify({ depth, controlSucceeded: true, tag: typed.error._tag, operation: typed.error.operation, serverReason: serverSignal?.reason, causeAbsent: typed.error.cause === undefined, exactReason: typed.error.cause === serverSignal?.reason, pingSucceeded: true }));
    expect(typed.error.cause).toBe(serverSignal?.reason);
  } finally {
    spy.mockRestore();
    await client.close();
    await server.close();
  }
}, 30000);

Expected behavior

The traversal's typed cancellation should retain the exact reason on its request signal. The lifecycle contract explicitly requires preserving the caller's abort reason as the cancellation cause, including after provider completion. The test guidance requires identity assertions at this boundary.

Impact and scope

P2: a timing-dependent path loses a required diagnostic/provenance value before typed-error consumers can inspect it. Cancellation classification remains correct. The signal itself still retains its reason, which is an available workaround for a consumer retaining that signal. No data corruption, incomplete cleanup or server unresponsiveness was observed.

This reproduces with a simulated provider and real in-memory MCP cancellation. Natural frequency with Hopper, Ghidra or IDA, stdio timing, other platforms, and other traversal checkpoints were not measured. The current cancellation projection does not expose the opaque cause; no additional client-visible message is claimed.

REA version

rea-agents 6.7.0; source revision 52415975942a4b9c53a817915b313ccc404f280e.

Runtime and platform

Node.js v26.12.0; macOS 27.0.1 arm64; repository-locked Vitest 5.0.3 and MCP SDK. No external analysis engine.

Target and artifact context

Synthetic selector 0x1000 and an empty successful relationship response from an injected analysis port. No input binary or target process.

Interface

MCP

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

Executed focused test output:

{"depth":0,"controlSucceeded":true,"tag":"AnalysisCancelledError","operation":"procedure_callees","serverReason":"Requested stop","causeAbsent":false,"exactReason":true,"pingSucceeded":true}
{"depth":2,"controlSucceeded":true,"tag":"AnalysisCancelledError","operation":"trace_call_path","serverReason":"Requested stop","causeAbsent":true,"exactReason":false,"pingSucceeded":true}
Tests  1 failed | 1 passed (2)
AssertionError: expected undefined to be 'Requested stop' // Object.is equality

The public handler passes the request signal into the composed workflow, and EnhancedTools dispatches that signal to the traversal. These source references establish the same production path exercised above.

Typed outcome or recovery expected

Keep AnalysisCancelledError and preserve cause === signal.reason when the traversal itself observes cancellation. Retain existing behavior that returns provider cancellation or primary provider failure unchanged. Serializing arbitrary causes in public MCP results is outside this report.

Analysis or suspected cause

The provider-observation wrapper preserves the reason when it observes cancellation before or after a provider call. However, the traversal's post-response checkpoint constructs new AnalysisCancelledError("trace_call_path") without error options. A cancellation processed between those two checks reaches the bare constructor. The same omission appears at the other traversal checkpoints; those additional public timing cases were not exercised by the reduced MCP test.

The error constructor accepts options. The public lifecycle projection retains operation and cleanup details but omits opaque cause, which limits the demonstrated impact to the typed error boundary.

Additional context

Related reports #5935 and #5944 concern different producers: the JavaScript artifact reader and runDerivedOperation. Repairing those helpers does not change CallPathTracing. #3198 concerns provider cancellation converted into successful batch/graph results; cancellation remains an error here. #5931 concerns traversal memory usage. PR #4417 adds multiple goals and leaves these cancellation constructors unchanged.

Fetched upstream main 25e1a24a3ed3671a0148757555f18d16c29d936a and compared the relevant source. The traversal, observation wrapper, public handler and cancellation constructor remain unchanged from the executed baseline. That newer revision was not executed.

Originally created by @coygeek on GitHub (Oct 21, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/5960 ### 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 Native analysis or decompilation ### Observed behavior Cancelling `trace_call_path` after its provider-observation wrapper has checked the request signal, but before the traversal continuation checks it, returns an internal `AnalysisCancelledError` with `cause === undefined`. The same server request signal still contains `"Requested stop"`. I reproduced this through the real MCP SDK client, public server registration and linked in-memory transports, using a simulated analysis provider to synchronize the cancellation. Immediate cancellation during the same relationship response retained the exact reason. Both cases completed an ordinary trace before cancellation, and the server answered a ping afterward. The error was observed immediately before MCP projection; this does not establish a missing wire field. ### Reproduction In a source checkout with its locked dependencies installed, save this focused test as `src/application/callPathReason.repro.test.ts` and run: ```sh node node_modules/vitest/vitest.mjs run src/application/callPathReason.repro.test.ts --maxWorkers=1 ``` The executed run reported one passing immediate-cancellation case and one failing reason-identity assertion at microtask depth 2. The two queued microtasks synchronize cancellation with the gap after the provider wrapper's own checkpoint. The delivery spy observes the typed result without modifying it. No native engine is required or started. ```ts import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { expect, it, vi } from "vitest"; import { createAnalysisExecution } from "./AnalysisProvider.js"; import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { ok } from "../domain/result.js"; import { createServer } from "../server/createServer.js"; import { ToolResultDelivery } from "../server/toolResult.js"; it.each([0, 2])("retains trace cancellation reason at microtask depth %i", async depth => { const controller = new AbortController(); let cancelling = false; let serverSignal: AbortSignal | undefined; let typed: any; let settle!: () => void; const settled = new Promise<void>(resolve => { settle = resolve; }); const delivery = ToolResultDelivery.prototype.toCallToolResult; const spy = vi.spyOn(ToolResultDelivery.prototype, "toCallToolResult").mockImplementation(function(result, contract) { if (cancelling && contract.name === "trace_call_path") { typed = result; settle(); } return delivery.call(this, result, contract); }); const provider = { id: "fixture", name: "Fixture", version: "1" }; const server = createServer({ kind: "fixed", analysis: { execute: async (operation, parameters, options) => { serverSignal = options?.signal; if (operation === "procedure_address") return ok(createAnalysisExecution(parameters.procedure, provider)); if (operation === "procedure_callees") { const later = (remaining: number) => remaining === 0 ? controller.abort("Requested stop") : queueMicrotask(() => later(remaining - 1)); if (cancelling) later(depth); return ok(createAnalysisExecution([], provider)); } throw new Error(`Unexpected operation: ${operation}`); }}}, { environment: {}, providerEnvironment: {} }); const client = new Client({ name: "reason-reproduction", version: "1" }); const [ct, st] = InMemoryTransport.createLinkedPair(); try { await server.connect(st); await client.connect(ct); const control = await client.callTool({ name: "trace_call_path", arguments: { start: "0x1000" } }); expect(control.isError).not.toBe(true); cancelling = true; await client.callTool({ name: "trace_call_path", arguments: { start: "0x1000" } }, { signal: controller.signal }).catch(error => error); await settled; expect(typed.ok).toBe(false); expect(typed.error).toBeInstanceOf(AnalysisCancelledError); expect(serverSignal?.aborted).toBe(true); expect(serverSignal?.reason).toBeDefined(); await client.ping(); console.log(JSON.stringify({ depth, controlSucceeded: true, tag: typed.error._tag, operation: typed.error.operation, serverReason: serverSignal?.reason, causeAbsent: typed.error.cause === undefined, exactReason: typed.error.cause === serverSignal?.reason, pingSucceeded: true })); expect(typed.error.cause).toBe(serverSignal?.reason); } finally { spy.mockRestore(); await client.close(); await server.close(); } }, 30000); ``` ### Expected behavior The traversal's typed cancellation should retain the exact reason on its request signal. The [lifecycle contract](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/tool-design.md#L101-L110) explicitly requires preserving the caller's abort reason as the cancellation cause, including after provider completion. The [test guidance](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/docs/testing.md#L268-L275) requires identity assertions at this boundary. ### Impact and scope P2: a timing-dependent path loses a required diagnostic/provenance value before typed-error consumers can inspect it. Cancellation classification remains correct. The signal itself still retains its reason, which is an available workaround for a consumer retaining that signal. No data corruption, incomplete cleanup or server unresponsiveness was observed. This reproduces with a simulated provider and real in-memory MCP cancellation. Natural frequency with Hopper, Ghidra or IDA, stdio timing, other platforms, and other traversal checkpoints were not measured. The current cancellation projection does not expose the opaque cause; no additional client-visible message is claimed. ### REA version rea-agents 6.7.0; source revision `52415975942a4b9c53a817915b313ccc404f280e`. ### Runtime and platform Node.js v26.12.0; macOS 27.0.1 arm64; repository-locked Vitest 5.0.3 and MCP SDK. No external analysis engine. ### Target and artifact context Synthetic selector `0x1000` and an empty successful relationship response from an injected analysis port. No input binary or target process. ### Interface MCP ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence Executed focused test output: ```json {"depth":0,"controlSucceeded":true,"tag":"AnalysisCancelledError","operation":"procedure_callees","serverReason":"Requested stop","causeAbsent":false,"exactReason":true,"pingSucceeded":true} {"depth":2,"controlSucceeded":true,"tag":"AnalysisCancelledError","operation":"trace_call_path","serverReason":"Requested stop","causeAbsent":true,"exactReason":false,"pingSucceeded":true} ``` ```text Tests 1 failed | 1 passed (2) AssertionError: expected undefined to be 'Requested stop' // Object.is equality ``` The public handler [passes the request signal into the composed workflow](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/server/registerEnhancedTools.ts#L236-L250), and [EnhancedTools dispatches that signal to the traversal](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/EnhancedTools.ts#L230-L241). These source references establish the same production path exercised above. ### Typed outcome or recovery expected Keep `AnalysisCancelledError` and preserve `cause === signal.reason` when the traversal itself observes cancellation. Retain existing behavior that returns provider cancellation or primary provider failure unchanged. Serializing arbitrary causes in public MCP results is outside this report. ### Analysis or suspected cause The [provider-observation wrapper](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/EnhancedTools.ts#L850-L865) preserves the reason when it observes cancellation before or after a provider call. However, the [traversal's post-response checkpoint](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/CallPathTracing.ts#L144-L153) constructs `new AnalysisCancelledError("trace_call_path")` without error options. A cancellation processed between those two checks reaches the bare constructor. The same omission appears at the [other traversal checkpoints](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/application/CallPathTracing.ts#L97-L121); those additional public timing cases were not exercised by the reduced MCP test. The [error constructor](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/analysisErrorCore.ts#L151-L161) accepts options. The [public lifecycle projection](https://github.com/morluto/rea/blob/52415975942a4b9c53a817915b313ccc404f280e/src/domain/analysisErrorProjection.ts#L507-L521) retains operation and cleanup details but omits opaque cause, which limits the demonstrated impact to the typed error boundary. ### Additional context Related reports #5935 and #5944 concern different producers: the JavaScript artifact reader and `runDerivedOperation`. Repairing those helpers does not change `CallPathTracing`. #3198 concerns provider cancellation converted into successful batch/graph results; cancellation remains an error here. #5931 concerns traversal memory usage. PR #4417 adds multiple goals and leaves these cancellation constructors unchanged. Fetched upstream main `25e1a24a3ed3671a0148757555f18d16c29d936a` and compared the relevant source. The traversal, observation wrapper, public handler and cancellation constructor remain unchanged from the executed baseline. That newer revision was not executed.
Author
Owner

@morluto commented on GitHub (Oct 23, 2026):

Resolved on main in 48af28bf7. All continuation checkpoints construct cancellation with signal.reason. Executed selection and relationship checkpoints preserve null/object identity, while a selected provider failure retains precedence. Validation: src/application/CallPathTracing.test.ts — preserves the exact reason at the relationship continuation (object); recorded execution at d397ac742. Five shared tracing controls passed, no real provider or public transport cancellation replay. Owner/test unchanged.

<!-- gh-comment-id:6376132372 --> @morluto commented on GitHub (Oct 23, 2026): Resolved on main in [48af28bf7](https://github.com/morluto/rea/commit/48af28bf71223e3db549a8a8a497d2e2a3d0f5a5). All continuation checkpoints construct cancellation with signal.reason. Executed selection and relationship checkpoints preserve null/object identity, while a selected provider failure retains precedence. Validation: `src/application/CallPathTracing.test.ts` — preserves the exact reason at the relationship continuation (object); recorded execution at `d397ac742`. Five shared tracing controls passed, no real provider or public transport cancellation replay. Owner/test unchanged.
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#3630
No description provided.