[GH-ISSUE #3946] [Bug] Cutter session discovery keeps probing after MCP cancellation #1552

Closed
opened 2026-10-16 21:07:04 +00:00 by bsakaguchi · 1 comment
Owner

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

Before submitting

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

Area

Provider or capability discovery

Observed behavior

Observed in one stdio MCP run: the authenticated control returned sessions_found. After cancellation of the silent-peer request, the client promise rejected immediately with AbortError: This operation was aborted, but all eight sockets remained active 502 ms later. The first batch closed 24,997 to 24,998 ms after abort, and the ninth connection opened 25,003 ms after abort. The script deliberately destroyed that last socket during cleanup; its natural timeout and the final discovery result were not observed.

control: sessions_found with authenticated loopback fixture
{"client":{"outcome":"rejected","message":"AbortError: This operation was aborted"},"elapsedAfterAbortMs":0}
{"afterAbortMs":502,"accepted":8,"activeSockets":8}
{"queuedProbeAfterAbortMs":25003,"accepted":9,"activeSockets":1,"closedAfterAbort":[24997,24997,24998,24998,24998,24998,24998,24998]}
verified: cancellation leaves active probes and admits a queued probe after timeout

Source-derived diagnosis: discovery discards the MCP request context, and neither the shared service nor its client receives an AbortSignal. The socket request implementation supports cancellation, but discovery does not supply it. Its worker queue therefore continues after the SDK cancels the caller's wait. The 25-second timer bounds each silent-peer probe; responsive or disconnected peers can finish earlier. No CLI signal-handling failure or real Cutter plugin behavior was tested.

Reproduction

  1. From the baseline repository root, use the pinned Node.js runtime with the locked dependencies installed and runtime artifacts already built.
  2. Run the following script. It launches the public stdio MCP entry point with an isolated home and XDG directories. It first checks an authenticated discovery response, then admits nine private descriptors whose peer accepts connections without responding. The script cancels the MCP request after eight connections open and observes both active connections and subsequent queued work. It cleans up its own sockets, child MCP process and temporary directory.
(
case_root=$(mktemp -d)
mkdir -p "$case_root/home"
export HOME="$case_root/home"
export CASE_SCRATCH="$case_root"
export XDG_CONFIG_HOME="$HOME/config"
export XDG_CACHE_HOME="$HOME/cache"
export XDG_DATA_HOME="$HOME/data"
node --input-type=module <<'NODE'
import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import { mkdir, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { join, resolve } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

const root = process.env.CASE_SCRATCH;
assert(root);
const directory = join(root, 'bridge');
await mkdir(directory, { mode: 0o700, recursive: true });
const token = 'synthetic-cutter-cancellation-fixture';
const sessionId = (n) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
let respond = true;
let accepted = 0;
let abortTime;
const closedAfterAbort = [];
const sockets = new Set();
const bridge = createServer((socket) => {
  accepted++;
  sockets.add(socket);
  socket.on('close', () => {
    sockets.delete(socket);
    if (abortTime !== undefined) closedAfterAbort.push(Math.round(performance.now() - abortTime));
  });
  socket.on('error', () => {});
  let buffer = '';
  let clientNonce;
  const serverNonce = 'e'.repeat(64);
  socket.setEncoding('utf8');
  socket.on('data', (chunk) => {
    if (!respond) return;
    buffer += chunk;
    for (;;) {
      const end = buffer.indexOf('\n');
      if (end < 0) return;
      const frame = JSON.parse(buffer.slice(0, end));
      buffer = buffer.slice(end + 1);
      if (frame.kind === 'rea-auth-v2') {
        clientNonce = frame.nonce;
        const proof = createHmac('sha256', token)
          .update(`rea-cutter-server-v2:${sessionId(1)}:${clientNonce}:${serverNonce}`).digest('hex');
        socket.write(JSON.stringify({ session_id: sessionId(1), nonce: serverNonce, proof }) + '\n');
      } else {
        const expected = createHmac('sha256', token)
          .update(`rea-cutter-client-v2:${sessionId(1)}:${clientNonce}:${serverNonce}:${frame.request}`).digest('hex');
        assert.equal(frame.proof, expected);
        const response = JSON.stringify({ ok: true, session_id: sessionId(1), document_generation: 1,
          current_file: null, cutter_version: 'fixture', identity_status: 'partial' });
        const proof = createHmac('sha256', token)
          .update(`rea-cutter-response-v2:${sessionId(1)}:${clientNonce}:${serverNonce}:${response}`).digest('hex');
        socket.end(JSON.stringify({ response, proof }) + '\n');
      }
    }
  });
});
await new Promise((done) => bridge.listen(0, '127.0.0.1', done));
const descriptor = async (n) => writeFile(join(directory, `cutter-${process.pid}-${sessionId(n)}.json`),
  JSON.stringify({ session_id: sessionId(n), pid: process.pid, host: '127.0.0.1',
    port: bridge.address().port, token, document_generation: 1, current_file: null,
    cutter_version: 'fixture', identity_status: 'partial' }), { mode: 0o600 });
await descriptor(1);
const client = new Client({ name: 'cancellation-probe', version: '1' });
const transport = new StdioClientTransport({
  command: process.execPath, args: ['scripts/rea.mjs', 'mcp'], cwd: resolve('.'),
  env: { ...process.env, REA_CUTTER_BRIDGE_DIR: directory }, stderr: 'pipe',
});
let stderr = '';
transport.stderr?.on('data', (chunk) => { stderr += chunk; });
const until = async (predicate, budget) => {
  const end = performance.now() + budget;
  while (!predicate()) {
    if (performance.now() > end) throw new Error('Fixture observation deadline exceeded');
    await delay(10);
  }
};
try {
  await client.connect(transport);
  const control = await client.callTool({ name: 'list_cutter_sessions', arguments: {} });
  assert.equal(control.isError, undefined);
  assert.equal(control.structuredContent.discovery_status, 'sessions_found');
  assert.equal(control.structuredContent.sessions[0].session_id, sessionId(1));
  console.log('control: sessions_found with authenticated loopback fixture');
  await until(() => sockets.size === 0, 1000);
  respond = false;
  accepted = 0;
  for (let n = 2; n <= 9; n++) await descriptor(n);
  const controller = new AbortController();
  const request = client.callTool({ name: 'list_cutter_sessions', arguments: {} }, { signal: controller.signal })
    .then(() => ({ outcome: 'resolved' }), (error) => ({ outcome: 'rejected', message: error.message }));
  await until(() => accepted === 8, 5000);
  abortTime = performance.now();
  controller.abort();
  const result = await request;
  console.log(JSON.stringify({ client: result, elapsedAfterAbortMs: Math.round(performance.now() - abortTime) }));
  assert.equal(result.outcome, 'rejected');
  await delay(500);
  console.log(JSON.stringify({ afterAbortMs: Math.round(performance.now() - abortTime), accepted, activeSockets: sockets.size }));
  assert.equal(sockets.size, 8);
  await until(() => accepted === 9, 28000);
  console.log(JSON.stringify({ queuedProbeAfterAbortMs: Math.round(performance.now() - abortTime), accepted, activeSockets: sockets.size, closedAfterAbort }));
  assert(performance.now() - abortTime > 24000);
  console.log('verified: cancellation leaves active probes and admits a queued probe after timeout');
} catch (error) {
  console.error('MCP stderr:', stderr);
  throw error;
} finally {
  for (const socket of sockets) socket.destroy();
  await client.close();
  await transport.close();
  await new Promise((done) => bridge.close(done));
}
NODE
probe_status=$?
rm -rf "$case_root"
exit "$probe_status"
)

Expected behavior

The documented MCP cancellation signal should reach provider discovery, stop its active socket probes, and prevent further queued probes from starting for the cancelled request. A normal uncancelled request must continue to discover an authenticated bridge session.

Impact and scope

A documented provider cancellation contract fails for silent or slow bridge peers. The observed cancelled request retained eight sockets for roughly 25 seconds and began additional queued work afterward. The timeout and candidate limits bound each discovery operation; no data loss or security-boundary crossing was demonstrated.

Workaround: Allowing admitted probes to finish through their existing response, disconnect or timeout paths preserves discovery behavior without changing code or deleting state. Normal timeout closure of the initial eight silent probes was observed. Cancellation does not accelerate that cleanup, and the remaining queued probe's full timeout was not observed.

REA version

rea 6.5.0 at source commit 4692bb3330; current-main source checked at 22ec8bdb2f

Runtime and platform

Node 24.18.0; macOS arm64

Target and artifact context

Authenticated synthetic loopback Cutter bridge control, followed by nine admitted silent-peer descriptors; no actual Cutter installation.

Interface

MCP

Earliest failure stage

Cleanup, cancellation, or lifecycle

Relevant output or evidence

docs/mcp-contracts.md:418-420,449-452 requires cancellation to reach provider work and distinguishes stopped server work from a rejected client wait. src/contracts/cutterToolContracts.ts:66-74 documents eight concurrent discovery probes. src/server/registerCutterTools.ts:28-40, src/cutter/CutterBridgeService.ts:77-79, and src/cutter/CutterBridgeClient.ts:289-344 omit the request signal; src/cutter/CutterBridgeClient.ts:167-184,803-812 retains queued work and a separate 25-second timeout. The production stdio MCP run observed both retained sockets and a new queued connection after cancellation.

Typed outcome or recovery expected

Close active probes promptly on cancellation and prevent queued probes from starting for that request. Preserve authenticated discovery for an uncancelled request.

Analysis or suspected cause

Source-derived: discovery discards request context before calling the service/client. The socket request method supports an optional AbortSignal, but discovery does not pass it. CLI signal handling and real Cutter plugin behavior were not tested.

Additional context

The ninth connection was destroyed during fixture cleanup. Its natural timeout and the final discovery result were not observed.

Freshness check: the failing source is unchanged on current upstream main at 22ec8bdb2f. The only change among the cited files is unrelated CLI output-flag normalization. Runtime observations above are from the stated baseline; current-main persistence is source-derived.

Originally created by @coygeek on GitHub (Oct 15, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/3946 ### 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 Provider or capability discovery ### Observed behavior Observed in one stdio MCP run: the authenticated control returned `sessions_found`. After cancellation of the silent-peer request, the client promise rejected immediately with `AbortError: This operation was aborted`, but all eight sockets remained active 502 ms later. The first batch closed 24,997 to 24,998 ms after abort, and the ninth connection opened 25,003 ms after abort. The script deliberately destroyed that last socket during cleanup; its natural timeout and the final discovery result were not observed. ```text control: sessions_found with authenticated loopback fixture {"client":{"outcome":"rejected","message":"AbortError: This operation was aborted"},"elapsedAfterAbortMs":0} {"afterAbortMs":502,"accepted":8,"activeSockets":8} {"queuedProbeAfterAbortMs":25003,"accepted":9,"activeSockets":1,"closedAfterAbort":[24997,24997,24998,24998,24998,24998,24998,24998]} verified: cancellation leaves active probes and admits a queued probe after timeout ``` Source-derived diagnosis: discovery discards the MCP request context, and neither the shared service nor its client receives an AbortSignal. The socket request implementation supports cancellation, but discovery does not supply it. Its worker queue therefore continues after the SDK cancels the caller's wait. The 25-second timer bounds each silent-peer probe; responsive or disconnected peers can finish earlier. No CLI signal-handling failure or real Cutter plugin behavior was tested. ### Reproduction 1. From the baseline repository root, use the pinned Node.js runtime with the locked dependencies installed and runtime artifacts already built. 2. Run the following script. It launches the public stdio MCP entry point with an isolated home and XDG directories. It first checks an authenticated discovery response, then admits nine private descriptors whose peer accepts connections without responding. The script cancels the MCP request after eight connections open and observes both active connections and subsequent queued work. It cleans up its own sockets, child MCP process and temporary directory. ```sh ( case_root=$(mktemp -d) mkdir -p "$case_root/home" export HOME="$case_root/home" export CASE_SCRATCH="$case_root" export XDG_CONFIG_HOME="$HOME/config" export XDG_CACHE_HOME="$HOME/cache" export XDG_DATA_HOME="$HOME/data" node --input-type=module <<'NODE' import assert from 'node:assert/strict'; import { createHmac } from 'node:crypto'; import { mkdir, writeFile } from 'node:fs/promises'; import { createServer } from 'node:net'; import { join, resolve } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; import { Client } from '@modelcontextprotocol/client'; import { StdioClientTransport } from '@modelcontextprotocol/client/stdio'; const root = process.env.CASE_SCRATCH; assert(root); const directory = join(root, 'bridge'); await mkdir(directory, { mode: 0o700, recursive: true }); const token = 'synthetic-cutter-cancellation-fixture'; const sessionId = (n) => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`; let respond = true; let accepted = 0; let abortTime; const closedAfterAbort = []; const sockets = new Set(); const bridge = createServer((socket) => { accepted++; sockets.add(socket); socket.on('close', () => { sockets.delete(socket); if (abortTime !== undefined) closedAfterAbort.push(Math.round(performance.now() - abortTime)); }); socket.on('error', () => {}); let buffer = ''; let clientNonce; const serverNonce = 'e'.repeat(64); socket.setEncoding('utf8'); socket.on('data', (chunk) => { if (!respond) return; buffer += chunk; for (;;) { const end = buffer.indexOf('\n'); if (end < 0) return; const frame = JSON.parse(buffer.slice(0, end)); buffer = buffer.slice(end + 1); if (frame.kind === 'rea-auth-v2') { clientNonce = frame.nonce; const proof = createHmac('sha256', token) .update(`rea-cutter-server-v2:${sessionId(1)}:${clientNonce}:${serverNonce}`).digest('hex'); socket.write(JSON.stringify({ session_id: sessionId(1), nonce: serverNonce, proof }) + '\n'); } else { const expected = createHmac('sha256', token) .update(`rea-cutter-client-v2:${sessionId(1)}:${clientNonce}:${serverNonce}:${frame.request}`).digest('hex'); assert.equal(frame.proof, expected); const response = JSON.stringify({ ok: true, session_id: sessionId(1), document_generation: 1, current_file: null, cutter_version: 'fixture', identity_status: 'partial' }); const proof = createHmac('sha256', token) .update(`rea-cutter-response-v2:${sessionId(1)}:${clientNonce}:${serverNonce}:${response}`).digest('hex'); socket.end(JSON.stringify({ response, proof }) + '\n'); } } }); }); await new Promise((done) => bridge.listen(0, '127.0.0.1', done)); const descriptor = async (n) => writeFile(join(directory, `cutter-${process.pid}-${sessionId(n)}.json`), JSON.stringify({ session_id: sessionId(n), pid: process.pid, host: '127.0.0.1', port: bridge.address().port, token, document_generation: 1, current_file: null, cutter_version: 'fixture', identity_status: 'partial' }), { mode: 0o600 }); await descriptor(1); const client = new Client({ name: 'cancellation-probe', version: '1' }); const transport = new StdioClientTransport({ command: process.execPath, args: ['scripts/rea.mjs', 'mcp'], cwd: resolve('.'), env: { ...process.env, REA_CUTTER_BRIDGE_DIR: directory }, stderr: 'pipe', }); let stderr = ''; transport.stderr?.on('data', (chunk) => { stderr += chunk; }); const until = async (predicate, budget) => { const end = performance.now() + budget; while (!predicate()) { if (performance.now() > end) throw new Error('Fixture observation deadline exceeded'); await delay(10); } }; try { await client.connect(transport); const control = await client.callTool({ name: 'list_cutter_sessions', arguments: {} }); assert.equal(control.isError, undefined); assert.equal(control.structuredContent.discovery_status, 'sessions_found'); assert.equal(control.structuredContent.sessions[0].session_id, sessionId(1)); console.log('control: sessions_found with authenticated loopback fixture'); await until(() => sockets.size === 0, 1000); respond = false; accepted = 0; for (let n = 2; n <= 9; n++) await descriptor(n); const controller = new AbortController(); const request = client.callTool({ name: 'list_cutter_sessions', arguments: {} }, { signal: controller.signal }) .then(() => ({ outcome: 'resolved' }), (error) => ({ outcome: 'rejected', message: error.message })); await until(() => accepted === 8, 5000); abortTime = performance.now(); controller.abort(); const result = await request; console.log(JSON.stringify({ client: result, elapsedAfterAbortMs: Math.round(performance.now() - abortTime) })); assert.equal(result.outcome, 'rejected'); await delay(500); console.log(JSON.stringify({ afterAbortMs: Math.round(performance.now() - abortTime), accepted, activeSockets: sockets.size })); assert.equal(sockets.size, 8); await until(() => accepted === 9, 28000); console.log(JSON.stringify({ queuedProbeAfterAbortMs: Math.round(performance.now() - abortTime), accepted, activeSockets: sockets.size, closedAfterAbort })); assert(performance.now() - abortTime > 24000); console.log('verified: cancellation leaves active probes and admits a queued probe after timeout'); } catch (error) { console.error('MCP stderr:', stderr); throw error; } finally { for (const socket of sockets) socket.destroy(); await client.close(); await transport.close(); await new Promise((done) => bridge.close(done)); } NODE probe_status=$? rm -rf "$case_root" exit "$probe_status" ) ``` ### Expected behavior The documented MCP cancellation signal should reach provider discovery, stop its active socket probes, and prevent further queued probes from starting for the cancelled request. A normal uncancelled request must continue to discover an authenticated bridge session. ### Impact and scope A documented provider cancellation contract fails for silent or slow bridge peers. The observed cancelled request retained eight sockets for roughly 25 seconds and began additional queued work afterward. The timeout and candidate limits bound each discovery operation; no data loss or security-boundary crossing was demonstrated. Workaround: Allowing admitted probes to finish through their existing response, disconnect or timeout paths preserves discovery behavior without changing code or deleting state. Normal timeout closure of the initial eight silent probes was observed. Cancellation does not accelerate that cleanup, and the remaining queued probe's full timeout was not observed. ### REA version rea 6.5.0 at source commit 4692bb33300c7cb77b095b6cc0c33da861c1b860; current-main source checked at 22ec8bdb2f7d4626587f23b1eed4a207df600386 ### Runtime and platform Node 24.18.0; macOS arm64 ### Target and artifact context Authenticated synthetic loopback Cutter bridge control, followed by nine admitted silent-peer descriptors; no actual Cutter installation. ### Interface MCP ### Earliest failure stage Cleanup, cancellation, or lifecycle ### Relevant output or evidence `docs/mcp-contracts.md:418-420,449-452` requires cancellation to reach provider work and distinguishes stopped server work from a rejected client wait. `src/contracts/cutterToolContracts.ts:66-74` documents eight concurrent discovery probes. `src/server/registerCutterTools.ts:28-40`, `src/cutter/CutterBridgeService.ts:77-79`, and `src/cutter/CutterBridgeClient.ts:289-344` omit the request signal; `src/cutter/CutterBridgeClient.ts:167-184,803-812` retains queued work and a separate 25-second timeout. The production stdio MCP run observed both retained sockets and a new queued connection after cancellation. ### Typed outcome or recovery expected Close active probes promptly on cancellation and prevent queued probes from starting for that request. Preserve authenticated discovery for an uncancelled request. ### Analysis or suspected cause Source-derived: discovery discards request context before calling the service/client. The socket request method supports an optional AbortSignal, but discovery does not pass it. CLI signal handling and real Cutter plugin behavior were not tested. ### Additional context The ninth connection was destroyed during fixture cleanup. Its natural timeout and the final discovery result were not observed. Freshness check: the failing source is unchanged on current upstream main at 22ec8bdb2f7d4626587f23b1eed4a207df600386. The only change among the cited files is unrelated CLI output-flag normalization. Runtime observations above are from the stated baseline; current-main persistence is source-derived.
Author
Owner

@yankikucuk commented on GitHub (Oct 15, 2026):

Working on this. Reproduced with your stdio MCP probe on a clean build: eight silent probes stayed connected after abort and a ninth started at ~25 s. The MCP handler and the CLI now pass their signal to discovery; the client hands it to every status probe and stops admitting queued ones, and a cancelled run is reported as cancelled rather than as an absence. The probe now shows 0 active sockets 502 ms after abort, all eight closed within 2 ms and no queued probe after 3 s; the authenticated control is unchanged. Draft PR follows shortly.

<!-- gh-comment-id:6191710713 --> @yankikucuk commented on GitHub (Oct 15, 2026): Working on this. Reproduced with your stdio MCP probe on a clean build: eight silent probes stayed connected after abort and a ninth started at ~25 s. The MCP handler and the CLI now pass their signal to discovery; the client hands it to every status probe and stops admitting queued ones, and a cancelled run is reported as cancelled rather than as an absence. The probe now shows 0 active sockets 502 ms after abort, all eight closed within 2 ms and no queued probe after 3 s; the authenticated control is unchanged. Draft PR follows shortly.
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#1552
No description provided.