[GH-ISSUE #1233] fix(process): MCP cancellation waits through post-exit settlement #432

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

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

Summary

Cancelling capture_process_scenario after the root has exited does not interrupt its post-exit settlement loop. A finite owned descendant remained alive after the server received a real MCP cancellation notification; the descendant was still alive at the post-cancellation observations and disappeared by the observation after its natural-exit deadline. The ordinary active-target cancellation path stopped its target promptly in the control.

Steps to reproduce

In a fresh disposable directory, run npm install --no-audit --no-fund @modelcontextprotocol/client@2.3.1 for the reproduction driver. Create child.mjs and root.mjs with the following contents.

// child.mjs
import { writeFileSync } from 'node:fs';
process.on('SIGHUP', () => {});
writeFileSync(process.argv[2], String(process.pid));
setTimeout(() => process.exit(0), 5000);
// root.mjs
import { spawn } from 'node:child_process';
import { existsSync, writeFileSync } from 'node:fs';
const child = spawn(process.execPath, [process.argv[2], process.argv[3]], { stdio: 'ignore' });
child.unref();
while (!existsSync(process.argv[3])) await new Promise(resolve => setTimeout(resolve, 5));
writeFileSync(process.argv[4], 'root exited');
process.exit(0);

Save the following driver as repro.mjs beside those two fixture scripts and run node repro.mjs. Use a fresh disposable directory so the readiness markers do not exist before the run. It derives absolute paths, sends and records a real MCP cancellation notification, and probes only its own finite child's PID. The request promise rejecting immediately is not used as proof that the server stopped its work.

import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
import { existsSync, readFileSync } from 'node:fs';
import { resolve, join } from 'node:path';
const directory = resolve('.');
const childPidPath = join(directory, 'child.pid');
const rootDonePath = join(directory, 'root.done');
const transport = new StdioClientTransport({ command: 'npx', args: ['-y', 'rea-agents@6.1.0', 'mcp'], stderr: 'pipe' });
const send = transport.send.bind(transport);
transport.send = async (message, options) => {
  if (message.method === 'notifications/cancelled') console.log('cancellation', message);
  return send(message, options);
};
const client = new Client({ name: 'capture-cancellation-repro', version: '1.0.0' });
await client.connect(transport);
await client.listTools();
const controller = new AbortController();
const call = client.callTool({
  name: 'capture_process_scenario',
  arguments: { executable: process.execPath, arguments: [join(directory, 'root.mjs'), join(directory, 'child.mjs'), childPidPath, rootDonePath], working_directory: directory, timeout_ms: 15000, settle_ms: 10000 }
}, { signal: controller.signal, timeout: 20000 }).catch(error => ({ error: String(error) }));
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const deadline = Date.now() + 15000;
while (!existsSync(rootDonePath)) { if (Date.now() > deadline) throw new Error('root did not finish'); await delay(10); }
await delay(500);
const childPid = Number(readFileSync(childPidPath, 'utf8'));
const aborted = Date.now();
controller.abort();
console.log('client outcome', await call);
for (const interval of [500, 1000, 4500]) {
  await delay(interval);
  let alive = true;
  try { process.kill(childPid, 0); } catch { alive = false; }
  console.log({ msAfterAbort: Date.now() - aborted, childAlive: alive });
}
console.log('ledger', await client.callTool({ name: 'get_evidence_bundle', arguments: {} }));
await client.close();
await transport.close();

Expected behavior

Cancellation should interrupt the ongoing process-capture settlement phase and enter owned cleanup instead of waiting for the descendant's normal exit or the full caller-selected settlement deadline. docs/mcp-contracts.md states that process capture checks the request cancellation signal and distinguishes cancellation from timeout; docs/process-capture.md states that cancellation uses the owned-process cleanup path.

Actual behavior

On the release build, the SDK sent notifications/cancelled for the capture request. The owned descendant remained alive at 503 ms and 1504 ms after that notification, then was absent at 6007 ms. On the main build it remained alive at 503 ms and 1505 ms and was absent at 6008 ms. No successful capture Evidence was added to the session ledger. A direct invocation of the same production harness with the same scenario returned its cancellation result 5912 ms after abort; its retained partial observation reported root exit code 0 and settlement quiesced with elapsed_ms: 5265. These direct observations establish which phase consumed the wait, separately from the real MCP request-path observations.

The active-target MCP cancellation control used the SIGHUP-handling finite fixture from the interrupted-CLI case and the same large settlement setting. It was absent at the first 503 ms post-abort observation, and its delayed write did not occur.

Evidence

  • Expected source: docs/mcp-contracts.md, Progress and cancellation, process-capture signal and cleanup guarantees; docs/process-capture.md, Capture a command, cancellation/timeout cleanup paragraph.
  • Failure source: Executed SDK stdio cancellation trials on both source builds and the production harness's retained partial observation, with the root already exited and an owned child alive during settlement.
  • Evidence provenance: observed
  • Local verification: reproduced
  • Reproduction completeness: complete

Runtime or environment

REA release source revision ae9aaee16b9c738d761d3938a3b34fdf37e581b5 reporting package 6.1.0; main source revision f0da05f67fff1411cf2f7a48a752ee7b348c71ff; Node.js v26.10.0; macOS 27.0.1 arm64; MCP client SDK 2.3.1. Both workloads were finite owned Node fixtures.

Restoration check

Send the same real MCP cancellation notification after the root's recorded exit while its owned descendant is alive. Verify the descendant is stopped through owned cleanup without waiting for its 5-second self-termination or the settlement deadline, and verify no successful capture Evidence enters the ledger. Retain the active-target cancellation control and ordinary successful settlement observations.

Triage assessment

  • Impact level: P2
  • Assessment status: supported
  • Impact basis: A cancellation request during a supported lifecycle phase leaves its owned descendant executing until natural exit or settlement timeout; the observed request-path failure lasted beyond 1.5 seconds and the direct harness completed almost 6 seconds after abort. Reach is limited to the post-exit settlement phase.
  • Workaround status: unverified
  • Workaround basis: Reducing settle_ms could reduce the wait, but that exact scenario-level workaround was not executed. The verified active-target cancellation control requires cancelling before the failing phase and does not restore cancellation once settlement has begun.
Originally created by @coygeek on GitHub (Oct 9, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/1233 ## Summary Cancelling `capture_process_scenario` after the root has exited does not interrupt its post-exit settlement loop. A finite owned descendant remained alive after the server received a real MCP cancellation notification; the descendant was still alive at the post-cancellation observations and disappeared by the observation after its natural-exit deadline. The ordinary active-target cancellation path stopped its target promptly in the control. ## Steps to reproduce In a fresh disposable directory, run `npm install --no-audit --no-fund @modelcontextprotocol/client@2.3.1` for the reproduction driver. Create `child.mjs` and `root.mjs` with the following contents. ```js // child.mjs import { writeFileSync } from 'node:fs'; process.on('SIGHUP', () => {}); writeFileSync(process.argv[2], String(process.pid)); setTimeout(() => process.exit(0), 5000); ``` ```js // root.mjs import { spawn } from 'node:child_process'; import { existsSync, writeFileSync } from 'node:fs'; const child = spawn(process.execPath, [process.argv[2], process.argv[3]], { stdio: 'ignore' }); child.unref(); while (!existsSync(process.argv[3])) await new Promise(resolve => setTimeout(resolve, 5)); writeFileSync(process.argv[4], 'root exited'); process.exit(0); ``` Save the following driver as `repro.mjs` beside those two fixture scripts and run `node repro.mjs`. Use a fresh disposable directory so the readiness markers do not exist before the run. It derives absolute paths, sends and records a real MCP cancellation notification, and probes only its own finite child's PID. The request promise rejecting immediately is not used as proof that the server stopped its work. ```js import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; import { existsSync, readFileSync } from 'node:fs'; import { resolve, join } from 'node:path'; const directory = resolve('.'); const childPidPath = join(directory, 'child.pid'); const rootDonePath = join(directory, 'root.done'); const transport = new StdioClientTransport({ command: 'npx', args: ['-y', 'rea-agents@6.1.0', 'mcp'], stderr: 'pipe' }); const send = transport.send.bind(transport); transport.send = async (message, options) => { if (message.method === 'notifications/cancelled') console.log('cancellation', message); return send(message, options); }; const client = new Client({ name: 'capture-cancellation-repro', version: '1.0.0' }); await client.connect(transport); await client.listTools(); const controller = new AbortController(); const call = client.callTool({ name: 'capture_process_scenario', arguments: { executable: process.execPath, arguments: [join(directory, 'root.mjs'), join(directory, 'child.mjs'), childPidPath, rootDonePath], working_directory: directory, timeout_ms: 15000, settle_ms: 10000 } }, { signal: controller.signal, timeout: 20000 }).catch(error => ({ error: String(error) })); const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); const deadline = Date.now() + 15000; while (!existsSync(rootDonePath)) { if (Date.now() > deadline) throw new Error('root did not finish'); await delay(10); } await delay(500); const childPid = Number(readFileSync(childPidPath, 'utf8')); const aborted = Date.now(); controller.abort(); console.log('client outcome', await call); for (const interval of [500, 1000, 4500]) { await delay(interval); let alive = true; try { process.kill(childPid, 0); } catch { alive = false; } console.log({ msAfterAbort: Date.now() - aborted, childAlive: alive }); } console.log('ledger', await client.callTool({ name: 'get_evidence_bundle', arguments: {} })); await client.close(); await transport.close(); ``` ## Expected behavior Cancellation should interrupt the ongoing process-capture settlement phase and enter owned cleanup instead of waiting for the descendant's normal exit or the full caller-selected settlement deadline. `docs/mcp-contracts.md` states that process capture checks the request cancellation signal and distinguishes cancellation from timeout; `docs/process-capture.md` states that cancellation uses the owned-process cleanup path. ## Actual behavior On the release build, the SDK sent `notifications/cancelled` for the capture request. The owned descendant remained alive at 503 ms and 1504 ms after that notification, then was absent at 6007 ms. On the main build it remained alive at 503 ms and 1505 ms and was absent at 6008 ms. No successful capture Evidence was added to the session ledger. A direct invocation of the same production harness with the same scenario returned its cancellation result 5912 ms after abort; its retained partial observation reported root exit code 0 and settlement `quiesced` with `elapsed_ms: 5265`. These direct observations establish which phase consumed the wait, separately from the real MCP request-path observations. The active-target MCP cancellation control used the SIGHUP-handling finite fixture from the interrupted-CLI case and the same large settlement setting. It was absent at the first 503 ms post-abort observation, and its delayed write did not occur. ## Evidence - Expected source: `docs/mcp-contracts.md`, Progress and cancellation, process-capture signal and cleanup guarantees; `docs/process-capture.md`, Capture a command, cancellation/timeout cleanup paragraph. - Failure source: Executed SDK stdio cancellation trials on both source builds and the production harness's retained partial observation, with the root already exited and an owned child alive during settlement. - Evidence provenance: observed - Local verification: reproduced - Reproduction completeness: complete ## Runtime or environment REA release source revision `ae9aaee16b9c738d761d3938a3b34fdf37e581b5` reporting package 6.1.0; main source revision `f0da05f67fff1411cf2f7a48a752ee7b348c71ff`; Node.js v26.10.0; macOS 27.0.1 arm64; MCP client SDK 2.3.1. Both workloads were finite owned Node fixtures. ## Restoration check Send the same real MCP cancellation notification after the root's recorded exit while its owned descendant is alive. Verify the descendant is stopped through owned cleanup without waiting for its 5-second self-termination or the settlement deadline, and verify no successful capture Evidence enters the ledger. Retain the active-target cancellation control and ordinary successful settlement observations. ## Triage assessment - Impact level: P2 - Assessment status: supported - Impact basis: A cancellation request during a supported lifecycle phase leaves its owned descendant executing until natural exit or settlement timeout; the observed request-path failure lasted beyond 1.5 seconds and the direct harness completed almost 6 seconds after abort. Reach is limited to the post-exit settlement phase. - Workaround status: unverified - Workaround basis: Reducing `settle_ms` could reduce the wait, but that exact scenario-level workaround was not executed. The verified active-target cancellation control requires cancelling before the failing phase and does not restore cancellation once settlement has begun.
bsakaguchi 2026-10-15 18:25:00 +00:00
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#432
No description provided.