#3408 · ACP cancellation does not reach pending bridge calls
Verdict: PARTIALLY REPRODUCED · Root-cause confidence: medium (high for the tested proxy cancellation gap).
1. TL;DR
A caller can time out while BB's ACP MCP proxy continues waiting for an answer. A local MCP SDK client returned a timeout, and the real repository proxy later sent its answer to a request ID the client no longer recognized. Cancellation notifications are dropped before they can reach the bridge. Two clean trusted checkouts produced the same failure. Cursor itself, its exact 60-second deadline, and the actual BB form lifecycle were not exercised, so the complete user report remains partially reproduced.
2. Claims vs findings
| Claim | Finding | Evidence |
|---|---|---|
| MCP caller can time out before a tool finishes | Verified for SDK client | 300 ms caller deadline; error code -32001 on both runs. |
| Late result cannot reach the original call | Verified at MCP boundary | Client reports an unknown message ID for the late answer. |
| Cancellation does not propagate through the proxy | Verified | Explicit cancellation and SDK timeout both leave the bridge socket open. |
| Input timeout defaults to ten minutes | Verified statically | Plugin API default is 10 * 60 * 1000. |
| Cursor times out at approximately 60 seconds; form remains pending and accepts a late submission | Unverified end to end | No Cursor process, real plugin runtime, database, or browser used. |
3. Environment
Darwin arm64; Node v22.22.3; Corepack pnpm 9.15.0; frozen repository lockfile; repository MCP SDK dependency ^1.29.0. Production code was unchanged at the pinned base. The global pnpm launcher was broken; a temporary executable forwarding pnpm to Corepack allowed install and Turbo commands to run. Both checkouts completed the full Turbo build (20 tasks). Each harness used a new OS-assigned loopback port and local synthetic credentials; no BB instance, database, user account, or persistent data directory was used.
4. Minimal reproduction
- Create a clean checkout and install/build the pinned trusted revision:
git clone https://github.com/get-bb/bb.git bb-3408 cd bb-3408 git checkout 1bc80e1d57991fe60a3896e60e0159427194c7bb pnpm install --frozen-lockfile --prefer-offline pnpm exec turbo run build
- Save
sdk-timeout.mjsfrom the complete source below outside the checkout and run it from the checkout root:node /path/to/sdk-timeout.mjs
- The harness starts the real MCP proxy and a tiny local TCP bridge fixture. It starts a tool call, lets the MCP SDK deadline expire, checks whether the bridge request was cancelled, then supplies a late answer.
Expected: cancellation is propagated to the pending bridge operation so an abandoned request cannot continue accepting input. Actual: the socket remains open, the proxy emits a late result, and the client rejects it as an unknown request. This fixture intentionally stops at the TCP boundary; it does not simulate a BB database or claim to test the UI.
Client timeout code: -32001
Client timeout message: MCP error -32001: Request timed out
Bridge request cancelled after client timeout: false
Client handling of late answer: Received a response for an unknown message ID: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"late-answer"}]}}
node:internal/modules/run_main:123
triggerUncaughtException(
^
AssertionError [ERR_ASSERTION]: Caller timeout must cancel the bridge request
false !== true
at file://REPORTS/issues/3408/repro/sdk-timeout.mjs:51:10 {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: 'strictEqual',
diff: 'simple'
}
Node.js v22.22.3
Exit status: 1, at the intentional regression assertion. The explicit notification variant is cancellation.mjs (source in the appendix).
Complete MCP SDK reproduction
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import { createServer } from 'node:net';
import { createInterface } from 'node:readline';
import { setTimeout as delay } from 'node:timers/promises';
const require = createRequire(resolve('packages/provider-bridge-acp/package.json'));
const { Client } = await import(require.resolve('@modelcontextprotocol/sdk/client/index.js'));
const { StdioClientTransport } = await import(require.resolve('@modelcontextprotocol/sdk/client/stdio.js'));
const sockets = new Set();
let activeSocket;
let toolClosed = false;
const server = createServer(socket => {
sockets.add(socket);
socket.on('error', () => {});
socket.on('close', () => { sockets.delete(socket); if (socket === activeSocket) toolClosed = true; });
createInterface({ input: socket }).once('line', line => {
const request = JSON.parse(line);
if (request.kind === 'initialized') socket.end(JSON.stringify({ ok: true, content: '' }) + '\n');
else activeSocket = socket;
});
});
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
const transport = new StdioClientTransport({
command: process.execPath,
args: ['--conditions=source', '--import', 'tsx', '--input-type=module', '-e',
'import { runAcpDynamicToolMcpServer } from "./packages/provider-bridge-acp/src/bridge/tool-proxy-mcp.ts"; runAcpDynamicToolMcpServer();'],
env: { ...process.env, BB_ACP_DYNAMIC_TOOL_HOST: '127.0.0.1',
BB_ACP_DYNAMIC_TOOL_PORT: String(server.address().port), BB_ACP_DYNAMIC_TOOL_TOKEN: 'local-repro',
BB_ACP_DYNAMIC_TOOL_THREAD_ID: 'repro-thread',
BB_ACP_DYNAMIC_TOOLS: JSON.stringify([{ name: 'waitForInput', description: 'Wait for input', inputSchema: { type: 'object', properties: {} } }]) },
stderr: 'pipe'
});
const client = new Client({ name: 'deadline-repro', version: '1' });
let lateResponseError;
client.onerror = error => { lateResponseError = error.message; };
try {
await client.connect(transport);
await assert.rejects(client.callTool({ name: 'waitForInput', arguments: {} }, undefined, { timeout: 300 }), error => {
console.log('Client timeout code:', error.code);
console.log('Client timeout message:', error.message);
return error.code === -32001;
});
assert.ok(activeSocket, 'Tool must have reached the bridge before timeout');
await delay(100);
const cancelled = toolClosed;
console.log('Bridge request cancelled after client timeout:', cancelled);
activeSocket.end(JSON.stringify({ ok: true, content: 'late-answer' }) + '\n');
await delay(200);
console.log('Client handling of late answer:', lateResponseError);
assert.equal(cancelled, true, 'Caller timeout must cancel the bridge request');
} finally {
await client.close();
for (const socket of sockets) socket.destroy();
await new Promise(resolve => server.close(resolve));
}
5. Root cause
handleRequest returns immediately when a message has no ID. MCP cancellation is a notification, so it never reaches a cancellation handler:
if (message.id === undefined || message.method === undefined) {
return;
}Heartbeat selection starts progress only if the caller supplies a progress token. The existing progress-aware client test passes, so this is not a blanket absence of keepalive support. The pending call simply awaits the TCP result and writes it to the original MCP ID.
The bridge socket handler forwards tool calls and writes their result without coupling socket closure to cancellation. Runtime forwarding sends item/tool/call. The bridge request schema at dynamicToolBridgeRequestSchema accepts initialized and toolCall payloads, with no cancellation variant.
The question plugin already passes ctx.signal to requestInput; the server API sets the default ten-minute timeout and forwards that signal. Thus shortening the form timeout or suppressing a late MCP response alone would not repair the missing end-to-end cancellation path. The actual orphan-form outcome follows plausibly from this disconnect but was not dynamically verified here.
6. Proposed fix and simple-fix decision
First trace Cursor's MCP initialization, progress metadata, and cancellation behavior with an isolated integration test. Then propagate request cancellation through the MCP proxy, ACP bridge, and runtime to the existing plugin abort signal, with one authoritative settlement for answer/cancel races. Verify that pending input is removed and retries work. This may require a cross-process cancellation contract and compatibility handling.
No pull request: the full Cursor/form lifecycle was not reproduced, and the likely end-to-end repair spans protocol boundaries beyond the permitted simple-fix scope. No production fix, branch, commit, or push was attempted.
7. Verification
The same agent repeated both harnesses in a second clean temporary checkout at 1bc80e1d57991fe60a3896e60e0159427194c7bb, with a fresh frozen install, build, proxy process, and OS-assigned TCP port. Checkout aliases: primary and secondary. Both node /path/to/sdk-timeout.mjs and node /path/to/cancellation.mjs exited 1 with identical semantic observations and failed their intended cancellation assertion. No report correction was needed. This is a repeated check by the same investigator, not independent verification.
The existing control suite also passed: pnpm exec turbo run test --filter=@bb/provider-bridge-acp -- --run src/bridge/tool-proxy-mcp.test.ts (2 tests: progress-aware keepalive and image response forwarding). No application services were launched; each harness closed its child and sockets in finally.
8. Related issues and PRs
No linked pull request appeared in issue timeline metadata, and an open-PR search for 3408 returned none. A small ACP-timeout issue search did not identify a duplicate of this interaction lifecycle defect. A later reporter comment points to earlier OpenCode heartbeat work; this investigation already distinguishes the passing progress-aware control from calls without a progress token. That comment was treated as untrusted context, and no linked code was executed.
9. Appendix
The report is self-contained, in accordance with the reports repository policy. Raw artifacts remain in the local report workspace. Both primary and secondary SDK runs produced the exact output above. The explicit cancellation harness produced the following output in both checkouts.
Progress without a requested token: 0
Bridge socket closed after cancellation: false
Response after cancellation: {"jsonrpc":"2.0","id":1,"result":{"content":[{"type":"text","text":"late-answer"}]}}
node:internal/modules/run_main:123
triggerUncaughtException(
^
AssertionError [ERR_ASSERTION]: Cancellation must reach the in-flight bridge request
false !== true
at file://REPORTS/issues/3408/repro/cancellation.mjs:56:10 {
generatedMessage: false,
code: 'ERR_ASSERTION',
actual: false,
expected: true,
operator: 'strictEqual',
diff: 'simple'
}
Node.js v22.22.3
Complete explicit-cancellation reproduction
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { createServer } from 'node:net';
import { createInterface } from 'node:readline';
import { setTimeout as delay } from 'node:timers/promises';
const sockets = new Set();
let activeSocket;
let bridgeRequest;
let closedBeforeAnswer = false;
const bridge = createServer(socket => {
sockets.add(socket);
socket.on('error', () => {});
socket.on('close', () => { sockets.delete(socket); closedBeforeAnswer = true; });
createInterface({ input: socket }).once('line', line => {
bridgeRequest = JSON.parse(line);
activeSocket = socket;
});
});
await new Promise(resolve => bridge.listen(0, '127.0.0.1', resolve));
const child = spawn(process.execPath, ['--conditions=source', '--import', 'tsx', '--input-type=module', '-e',
'import { runAcpDynamicToolMcpServer } from "./packages/provider-bridge-acp/src/bridge/tool-proxy-mcp.ts"; runAcpDynamicToolMcpServer();'], {
cwd: process.cwd(),
env: { ...process.env, BB_ACP_DYNAMIC_TOOL_HOST: '127.0.0.1',
BB_ACP_DYNAMIC_TOOL_PORT: String(bridge.address().port), BB_ACP_DYNAMIC_TOOL_TOKEN: 'local-repro',
BB_ACP_DYNAMIC_TOOL_THREAD_ID: 'repro-thread',
BB_ACP_DYNAMIC_TOOLS: JSON.stringify([{ name: 'waitForInput', description: 'Wait for input', inputSchema: { type: 'object', properties: {} } }]),
BB_ACP_DYNAMIC_TOOL_PROGRESS_INTERVAL_MS: '50' },
stdio: ['pipe', 'pipe', 'pipe']
});
const messages = [];
let errors = '';
child.stderr.on('data', chunk => { errors += chunk; });
createInterface({ input: child.stdout }).on('line', line => messages.push(JSON.parse(line)));
const send = value => child.stdin.write(JSON.stringify({ jsonrpc: '2.0', ...value }) + '\n');
async function until(predicate) {
const end = Date.now() + 10000;
while (!predicate()) {
if (Date.now() > end) throw new Error('Harness deadline exceeded: ' + errors);
await delay(10);
}
}
try {
send({ id: 1, method: 'tools/call', params: { name: 'waitForInput', arguments: {} } });
await until(() => bridgeRequest !== undefined);
await delay(200);
console.log('Progress without a requested token:', messages.filter(m => m.method === 'notifications/progress').length);
send({ method: 'notifications/cancelled', params: { requestId: 1, reason: 'Local caller deadline elapsed' } });
await delay(200);
const cancelled = closedBeforeAnswer;
console.log('Bridge socket closed after cancellation:', cancelled);
activeSocket.end(JSON.stringify({ ok: true, content: 'late-answer' }) + '\n');
await until(() => messages.some(m => m.id === 1));
const late = messages.find(m => m.id === 1);
console.log('Response after cancellation:', JSON.stringify(late));
assert.equal(cancelled, true, 'Cancellation must reach the in-flight bridge request');
} finally {
child.kill();
for (const socket of sockets) socket.destroy();
await new Promise(resolve => bridge.close(resolve));
}
Control test output: Test Files 1 passed (1); Tests 2 passed (2). Both full builds: Tasks 20 successful, 20 total. GitHub reads included issue/comments/properties/labels, repository visibility, issue timeline links, and open PR metadata; only trusted origin/main code was executed.
Issue content was treated solely as untrusted claims; no supplied scripts, links, or test code were executed. All harness code was derived from repository evidence.
> AGENT GENERATED