[GH-ISSUE #3198] [Bug] Batch decompilation and call graphs report provider cancellation as success #1137

Closed
opened 2026-10-15 18:31:24 +00:00 by bsakaguchi · 0 comments
Owner

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

Before submitting

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

Area

MCP or agent integration

Observed behavior

batch_decompile and get_call_graph return successful MCP replies when a provider returns AnalysisCancelledError without aborting the request signal. Independent verification reproduced this through actual MCP tools/call requests, the production server, linked in-memory SDK transports, and a recording provider.

Batch resolution cancellation returns isError: false and structuredContent, embeds cancelled in procedure.error, dispatches pseudocode afterward, and counts the item as successful. Label cancellation similarly embeds cancelled in procedure.name_error and continues to pseudocode. Pseudocode cancellation returns a successful tool reply with an item-level cancellation error.

Graph relationship cancellation returns a successful structured graph with a node-level cancellation error. When the root discovers two children, cancellation while expanding 0x2 does not stop the queued expansion of 0x3. All recorded provider request signals were present and un-aborted.

The graph root-resolution, trace_call_path, and direct provider-tool cancellation controls return isError: true, canonical code cancelled, and no structured data. The ordinary graph output-error control returns a successful partial graph with node-level unreadable_output.

Reproduction

  1. Start from a clean checkout at the verified revision and prepare the locked dependencies and runtime.
git clone https://github.com/morluto/rea.git
cd rea
git checkout 58300885207133422af235e0f2b10658cf958b03
npm ci
npm run build:cached
  1. From the checkout root, run the inline fixture below. It creates every input and the recording provider inline, leaves the signal un-aborted, and sends valid MCP calls through the production server. It requires no native engine or input binary.
node --input-type=module <<'EOF'
import { Client, InMemoryTransport } from '@modelcontextprotocol/client';
import { createServer } from './dist/server/createServer.js';
import { createAnalysisExecution } from './dist/application/AnalysisProvider.js';
import { AnalysisCancelledError, AnalysisOutputError } from './dist/domain/analysisErrorCore.js';
import { err, ok } from './dist/domain/result.js';

async function run(label, name, args, failOperation, failureKind = 'cancelled', queued = false) {
  const calls = [];
  const signals = [];
  const analysis = { execute: async (operation, parameters, options) => {
    const selector = parameters.procedure ?? parameters.address;
    calls.push(`${operation}:${selector}`);
    signals.push(options?.signal?.aborted ?? false);
    if (operation === failOperation && (!queued || selector === '0x2'))
      return err(failureKind === 'cancelled' ? new AnalysisCancelledError(operation) : new AnalysisOutputError(operation, 'fixture error'));
    const value = operation === 'procedure_address' ? String(selector)
      : operation === 'address_name' ? 'fixture_name'
      : operation === 'procedure_callees' ? (queued && selector === '0x1' ? ['0x2', '0x3'] : [])
      : operation === 'procedure_pseudo_code' ? 'return 0;'
      : null;
    return ok(createAnalysisExecution(value, { id: 'fixture', name: 'Fixture provider', version: '1' }));
  }};
  const server = createServer({ kind: 'fixed', analysis }, { environment: {}, providerEnvironment: {} });
  const client = new Client({ name: 'cancellation-repro', version: '1' });
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
  try {
    await server.connect(serverTransport);
    await client.connect(clientTransport);
    const response = await client.callTool({ name, arguments: args });
    console.log(JSON.stringify({label, isError: response.isError ?? false, structuredContent: response.structuredContent !== undefined, result: response.structuredContent?.normalized_result ?? response.content, calls, signals}));
  } finally { await client.close(); await server.close(); }
}
await run('batch-resolution-cancellation', 'batch_decompile', { addresses: ['0x1'] }, 'procedure_address');
await run('batch-label-cancellation', 'batch_decompile', { addresses: ['0x1'] }, 'address_name');
await run('batch-pseudocode-cancellation', 'batch_decompile', { addresses: ['0x1'] }, 'procedure_pseudo_code');
await run('graph-expansion-cancellation', 'get_call_graph', { address: '0x1', direction: 'forward' }, 'procedure_callees');
await run('graph-queued-cancellation', 'get_call_graph', { address: '0x1', direction: 'forward' }, 'procedure_callees', 'cancelled', true);
await run('graph-resolution-control', 'get_call_graph', { address: '0x1', direction: 'forward' }, 'procedure_address');
await run('trace-cancellation-control', 'trace_call_path', { start: '0x1', goal: '0x2' }, 'procedure_callees');
await run('graph-ordinary-error-control', 'get_call_graph', { address: '0x1', direction: 'forward' }, 'procedure_callees', 'output');
await run('direct-pseudocode-control', 'procedure_pseudo_code', { procedure: '0x1' }, 'procedure_pseudo_code');
await run('direct-relationship-control', 'procedure_callees', { procedure: '0x1' }, 'procedure_callees');
EOF
  1. Compare each cancelled composed request with the resolution, tracing, direct-tool, and ordinary-error controls. Each output line records the error flag, presence of structured data, normalized result, provider-call order, and signal state at dispatch. The process exits 0 because it records replies rather than asserting restoration.

The independent verifier executed this exact inline fixture against a prepared built runtime and reproduced all ten replies. Dependency installation and the build commands were not rerun during that verification. The existing selector and tracing test file passed all nine tests.

Expected behavior

docs/mcp-contracts.md:75-94 requires tool execution failures to return isError: true, a canonical JSON error in text content, and no structuredContent. Its composed graph/tracing discussion states that cancellation remains a tool error. docs/tool-design.md:68 requires reporting cancellation separately from partial failure. src/domain/analysisErrorCore.ts:136-146 defines AnalysisCancelledError as cancellation before completion.

A composed workflow that receives typed provider cancellation must return the canonical cancelled tool error and stop dispatching later provider work. Independent concurrent batch calls already dispatched may remain in flight. Ordinary per-item and per-node failures must retain the partial-result behavior documented in src/contracts/enhancedToolContracts.ts:44-51.

Impact and scope

P2. MCP consumers that rely on isError receive successful Evidence for interrupted batch or graph composition, and later read-only provider work can continue. The reproduced failure is bounded to these cancelled composed requests. No data loss, security boundary crossing, or live-service disruption was demonstrated.

A verified workaround uses the supported direct procedure_pseudo_code and procedure_callees tools and stops caller-managed composition on their tool errors. This requires callers to orchestrate batches or traversal themselves. Inspecting embedded cancellation detects incorrect success but cannot prevent work the composed tool has already dispatched.

Verification used recording providers. No live Ghidra, Hopper, or IDA engine ran, and live-engine frequency was not measured. Source inspection establishes that the production Ghidra adapter can return typed cancellation without caller abort, but does not establish its frequency.

REA version

6.4.0 (main at 5830088520)

Runtime and platform

Node.js v26.10.0; Darwin arm64; the checkout's locked MCP SDK dependencies.

Target and artifact context

Synthetic selectors 0x1, 0x2, and 0x3, inline pseudocode return 0;, a recording analysis provider, and linked in-memory MCP transports. No input binary, native target, or external API is required.

Interface

MCP

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

These three complete output lines from independent verification show batch continuation, queued graph continuation, and the direct-tool cancellation control.

{"label":"batch-resolution-cancellation","isError":false,"structuredContent":true,"result":{"items":[{"address":"0x1","procedure":{"status":"unknown","error":{"code":"cancelled","category":"cancelled","message":"Analysis was cancelled. Start it again when ready.","retryable":true,"remediation":{"action":"Analysis was cancelled. Start it again when ready."},"details":{"operation":"procedure_address","cleanup":"complete"}}},"status":"ok","pseudocode":"return 0;"}],"total":1,"succeeded":1,"failed":0},"calls":["procedure_address:0x1","procedure_pseudo_code:0x1"],"signals":[false,false]}
{"label":"graph-queued-cancellation","isError":false,"structuredContent":true,"result":{"graph":{"0":[{"address":"0x1","status":"ok","calls":["0x2","0x3"],"relationship_coverage":{"status":"unknown","reason":"Provider did not report relationship coverage."},"limitations":["Provider did not report relationship coverage."]}],"1":[{"address":"0x2","status":"error","error":{"code":"cancelled","category":"cancelled","message":"Analysis was cancelled. Start it again when ready.","retryable":true,"remediation":{"action":"Analysis was cancelled. Start it again when ready."},"details":{"operation":"procedure_callees","cleanup":"complete"}}},{"address":"0x3","status":"ok","calls":[],"relationship_coverage":{"status":"unknown","reason":"Provider did not report relationship coverage."},"limitations":["Provider did not report relationship coverage."]}]},"limitations":[]},"calls":["procedure_address:0x1","procedure_callees:0x1","procedure_callees:0x2","procedure_callees:0x3"],"signals":[false,false,false,false]}
{"label":"direct-relationship-control","isError":true,"structuredContent":false,"result":[{"type":"text","text":"{\"error\":{\"code\":\"cancelled\",\"category\":\"cancelled\",\"message\":\"Analysis was cancelled. Start it again when ready.\",\"retryable\":true,\"remediation\":{\"action\":\"Analysis was cancelled. Start it again when ready.\"},\"details\":{\"operation\":\"procedure_callees\",\"cleanup\":\"complete\"}}}"}],"calls":["procedure_callees:0x1"],"signals":[false]}

Typed outcome or recovery expected

Every composed cancellation case should return isError: true, canonical code cancelled, and no successful structured result. Resolution or label cancellation should prevent subsequent pseudocode dispatch for that item. Graph cancellation at 0x2 should prevent dispatch of the queued 0x3 expansion. Retain the passing resolution, tracing, and direct-tool controls.

Ordinary graph failures should remain successful partial graphs with typed node errors. Ordinary identity or label failures should still allow usable batch pseudocode. Distinguish new work dispatched after cancellation from independent concurrent work already dispatched beforehand.

Analysis or suspected cause

Source-derived reasoning at the verified baseline:

  • src/application/EnhancedTools.ts:262-313 projects batch resolution or pseudocode cancellation into procedure/item errors, then returns success. src/application/EnhancedTools.ts:317-337 similarly projects entry-label cancellation instead of propagating it.
  • src/application/EnhancedTools.ts:381-394 projects graph relationship cancellation into a node and continues traversal.
  • src/server/registerEnhancedTools.ts:243-294 consumes the successful workflow result as Evidence. src/application/WorkflowEvidence.ts:29-55 does not reinterpret embedded errors.
  • src/application/CallPathTracing.ts:144-159 already distinguishes typed cancellation from ordinary expansion failure even without an aborted signal, consistent with the passing tracing control.
  • src/ghidra/GhidraProviderClient.ts:1096-1099 maps remote decompile_cancelled to AnalysisCancelledError. src/ghidra/GhidraProvider.behavior.test.ts:911-941 covers that mapping for pseudocode without caller cancellation. The verifier inspected this existing test and mapping; it did not execute that test or a live engine.

Additional context

Related reports #2544 and #2535 concern missing caller-signal handling during in-flight or queued Frida operations; this report concerns an already typed provider cancellation swallowed by batch and graph analysis with an un-aborted signal, so their fixes have different closure signals.

Related: #3197 reports a separate failure in the same area; the two have independent fixes.

Originally created by @coygeek on GitHub (Oct 14, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3198 ### 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 MCP or agent integration ### Observed behavior `batch_decompile` and `get_call_graph` return successful MCP replies when a provider returns `AnalysisCancelledError` without aborting the request signal. Independent verification reproduced this through actual MCP `tools/call` requests, the production server, linked in-memory SDK transports, and a recording provider. Batch resolution cancellation returns `isError: false` and `structuredContent`, embeds `cancelled` in `procedure.error`, dispatches pseudocode afterward, and counts the item as successful. Label cancellation similarly embeds `cancelled` in `procedure.name_error` and continues to pseudocode. Pseudocode cancellation returns a successful tool reply with an item-level cancellation error. Graph relationship cancellation returns a successful structured graph with a node-level cancellation error. When the root discovers two children, cancellation while expanding `0x2` does not stop the queued expansion of `0x3`. All recorded provider request signals were present and un-aborted. The graph root-resolution, `trace_call_path`, and direct provider-tool cancellation controls return `isError: true`, canonical code `cancelled`, and no structured data. The ordinary graph output-error control returns a successful partial graph with node-level `unreadable_output`. ### Reproduction 1. Start from a clean checkout at the verified revision and prepare the locked dependencies and runtime. ```sh git clone https://github.com/morluto/rea.git cd rea git checkout 58300885207133422af235e0f2b10658cf958b03 npm ci npm run build:cached ``` 2. From the checkout root, run the inline fixture below. It creates every input and the recording provider inline, leaves the signal un-aborted, and sends valid MCP calls through the production server. It requires no native engine or input binary. ```sh node --input-type=module <<'EOF' import { Client, InMemoryTransport } from '@modelcontextprotocol/client'; import { createServer } from './dist/server/createServer.js'; import { createAnalysisExecution } from './dist/application/AnalysisProvider.js'; import { AnalysisCancelledError, AnalysisOutputError } from './dist/domain/analysisErrorCore.js'; import { err, ok } from './dist/domain/result.js'; async function run(label, name, args, failOperation, failureKind = 'cancelled', queued = false) { const calls = []; const signals = []; const analysis = { execute: async (operation, parameters, options) => { const selector = parameters.procedure ?? parameters.address; calls.push(`${operation}:${selector}`); signals.push(options?.signal?.aborted ?? false); if (operation === failOperation && (!queued || selector === '0x2')) return err(failureKind === 'cancelled' ? new AnalysisCancelledError(operation) : new AnalysisOutputError(operation, 'fixture error')); const value = operation === 'procedure_address' ? String(selector) : operation === 'address_name' ? 'fixture_name' : operation === 'procedure_callees' ? (queued && selector === '0x1' ? ['0x2', '0x3'] : []) : operation === 'procedure_pseudo_code' ? 'return 0;' : null; return ok(createAnalysisExecution(value, { id: 'fixture', name: 'Fixture provider', version: '1' })); }}; const server = createServer({ kind: 'fixed', analysis }, { environment: {}, providerEnvironment: {} }); const client = new Client({ name: 'cancellation-repro', version: '1' }); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); try { await server.connect(serverTransport); await client.connect(clientTransport); const response = await client.callTool({ name, arguments: args }); console.log(JSON.stringify({label, isError: response.isError ?? false, structuredContent: response.structuredContent !== undefined, result: response.structuredContent?.normalized_result ?? response.content, calls, signals})); } finally { await client.close(); await server.close(); } } await run('batch-resolution-cancellation', 'batch_decompile', { addresses: ['0x1'] }, 'procedure_address'); await run('batch-label-cancellation', 'batch_decompile', { addresses: ['0x1'] }, 'address_name'); await run('batch-pseudocode-cancellation', 'batch_decompile', { addresses: ['0x1'] }, 'procedure_pseudo_code'); await run('graph-expansion-cancellation', 'get_call_graph', { address: '0x1', direction: 'forward' }, 'procedure_callees'); await run('graph-queued-cancellation', 'get_call_graph', { address: '0x1', direction: 'forward' }, 'procedure_callees', 'cancelled', true); await run('graph-resolution-control', 'get_call_graph', { address: '0x1', direction: 'forward' }, 'procedure_address'); await run('trace-cancellation-control', 'trace_call_path', { start: '0x1', goal: '0x2' }, 'procedure_callees'); await run('graph-ordinary-error-control', 'get_call_graph', { address: '0x1', direction: 'forward' }, 'procedure_callees', 'output'); await run('direct-pseudocode-control', 'procedure_pseudo_code', { procedure: '0x1' }, 'procedure_pseudo_code'); await run('direct-relationship-control', 'procedure_callees', { procedure: '0x1' }, 'procedure_callees'); EOF ``` 3. Compare each cancelled composed request with the resolution, tracing, direct-tool, and ordinary-error controls. Each output line records the error flag, presence of structured data, normalized result, provider-call order, and signal state at dispatch. The process exits 0 because it records replies rather than asserting restoration. The independent verifier executed this exact inline fixture against a prepared built runtime and reproduced all ten replies. Dependency installation and the build commands were not rerun during that verification. The existing selector and tracing test file passed all nine tests. ### Expected behavior `docs/mcp-contracts.md:75-94` requires tool execution failures to return `isError: true`, a canonical JSON error in text content, and no `structuredContent`. Its composed graph/tracing discussion states that cancellation remains a tool error. `docs/tool-design.md:68` requires reporting cancellation separately from partial failure. `src/domain/analysisErrorCore.ts:136-146` defines `AnalysisCancelledError` as cancellation before completion. A composed workflow that receives typed provider cancellation must return the canonical `cancelled` tool error and stop dispatching later provider work. Independent concurrent batch calls already dispatched may remain in flight. Ordinary per-item and per-node failures must retain the partial-result behavior documented in `src/contracts/enhancedToolContracts.ts:44-51`. ### Impact and scope P2. MCP consumers that rely on `isError` receive successful Evidence for interrupted batch or graph composition, and later read-only provider work can continue. The reproduced failure is bounded to these cancelled composed requests. No data loss, security boundary crossing, or live-service disruption was demonstrated. A verified workaround uses the supported direct `procedure_pseudo_code` and `procedure_callees` tools and stops caller-managed composition on their tool errors. This requires callers to orchestrate batches or traversal themselves. Inspecting embedded cancellation detects incorrect success but cannot prevent work the composed tool has already dispatched. Verification used recording providers. No live Ghidra, Hopper, or IDA engine ran, and live-engine frequency was not measured. Source inspection establishes that the production Ghidra adapter can return typed cancellation without caller abort, but does not establish its frequency. ### REA version 6.4.0 (main at 58300885207133422af235e0f2b10658cf958b03) ### Runtime and platform Node.js v26.10.0; Darwin arm64; the checkout's locked MCP SDK dependencies. ### Target and artifact context Synthetic selectors `0x1`, `0x2`, and `0x3`, inline pseudocode `return 0;`, a recording analysis provider, and linked in-memory MCP transports. No input binary, native target, or external API is required. ### Interface MCP ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence These three complete output lines from independent verification show batch continuation, queued graph continuation, and the direct-tool cancellation control. ```text {"label":"batch-resolution-cancellation","isError":false,"structuredContent":true,"result":{"items":[{"address":"0x1","procedure":{"status":"unknown","error":{"code":"cancelled","category":"cancelled","message":"Analysis was cancelled. Start it again when ready.","retryable":true,"remediation":{"action":"Analysis was cancelled. Start it again when ready."},"details":{"operation":"procedure_address","cleanup":"complete"}}},"status":"ok","pseudocode":"return 0;"}],"total":1,"succeeded":1,"failed":0},"calls":["procedure_address:0x1","procedure_pseudo_code:0x1"],"signals":[false,false]} {"label":"graph-queued-cancellation","isError":false,"structuredContent":true,"result":{"graph":{"0":[{"address":"0x1","status":"ok","calls":["0x2","0x3"],"relationship_coverage":{"status":"unknown","reason":"Provider did not report relationship coverage."},"limitations":["Provider did not report relationship coverage."]}],"1":[{"address":"0x2","status":"error","error":{"code":"cancelled","category":"cancelled","message":"Analysis was cancelled. Start it again when ready.","retryable":true,"remediation":{"action":"Analysis was cancelled. Start it again when ready."},"details":{"operation":"procedure_callees","cleanup":"complete"}}},{"address":"0x3","status":"ok","calls":[],"relationship_coverage":{"status":"unknown","reason":"Provider did not report relationship coverage."},"limitations":["Provider did not report relationship coverage."]}]},"limitations":[]},"calls":["procedure_address:0x1","procedure_callees:0x1","procedure_callees:0x2","procedure_callees:0x3"],"signals":[false,false,false,false]} {"label":"direct-relationship-control","isError":true,"structuredContent":false,"result":[{"type":"text","text":"{\"error\":{\"code\":\"cancelled\",\"category\":\"cancelled\",\"message\":\"Analysis was cancelled. Start it again when ready.\",\"retryable\":true,\"remediation\":{\"action\":\"Analysis was cancelled. Start it again when ready.\"},\"details\":{\"operation\":\"procedure_callees\",\"cleanup\":\"complete\"}}}"}],"calls":["procedure_callees:0x1"],"signals":[false]} ``` ### Typed outcome or recovery expected Every composed cancellation case should return `isError: true`, canonical code `cancelled`, and no successful structured result. Resolution or label cancellation should prevent subsequent pseudocode dispatch for that item. Graph cancellation at `0x2` should prevent dispatch of the queued `0x3` expansion. Retain the passing resolution, tracing, and direct-tool controls. Ordinary graph failures should remain successful partial graphs with typed node errors. Ordinary identity or label failures should still allow usable batch pseudocode. Distinguish new work dispatched after cancellation from independent concurrent work already dispatched beforehand. ### Analysis or suspected cause Source-derived reasoning at the verified baseline: - `src/application/EnhancedTools.ts:262-313` projects batch resolution or pseudocode cancellation into procedure/item errors, then returns success. `src/application/EnhancedTools.ts:317-337` similarly projects entry-label cancellation instead of propagating it. - `src/application/EnhancedTools.ts:381-394` projects graph relationship cancellation into a node and continues traversal. - `src/server/registerEnhancedTools.ts:243-294` consumes the successful workflow result as Evidence. `src/application/WorkflowEvidence.ts:29-55` does not reinterpret embedded errors. - `src/application/CallPathTracing.ts:144-159` already distinguishes typed cancellation from ordinary expansion failure even without an aborted signal, consistent with the passing tracing control. - `src/ghidra/GhidraProviderClient.ts:1096-1099` maps remote `decompile_cancelled` to `AnalysisCancelledError`. `src/ghidra/GhidraProvider.behavior.test.ts:911-941` covers that mapping for pseudocode without caller cancellation. The verifier inspected this existing test and mapping; it did not execute that test or a live engine. ### Additional context Related reports #2544 and #2535 concern missing caller-signal handling during in-flight or queued Frida operations; this report concerns an already typed provider cancellation swallowed by batch and graph analysis with an un-aborted signal, so their fixes have different closure signals. Related: #3197 reports a separate failure in the same area; the two have independent fixes.
bsakaguchi 2026-10-15 18:31:24 +00:00
  • closed this issue
  • added the
    bug
    label
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#1137
No description provided.