Skip to content

ACP provider process dies after bb-bridge initialize hangup #2509

Description

@Danielalnajjar

Summary

On 0.40, when I start several ACP sessions at once (a workflow with parallel Claude workers), some of the provider processes just die. The workers never get to talk.

What I expected: the session starts, the helper says it is up, and the worker keeps running. What happens: the helper says it is up, then Node treats the hangup as a crash and the whole provider process exits.

Versions and environment

  • bb 0.40.0 (desktop app), macOS 26.6.2
  • ACP providers acp-sub-claude-icloud and acp-sub-claude-woktowalk (Claude Code over ACP, not the native claude-code provider)
  • Claude Code CLI 2.1.246
  • Local machine, no Connect, default data dir
  • Reproduced on main at e4c873521abbcab1ea3e1aa127b73bb28e7905bb

Steps to reproduce

Live path (needs an ACP Claude provider and a workflow that starts several child sessions at once):

  1. Use bb 0.40.0 desktop with an ACP Claude provider (not native claude-code).
  2. Run a workflow that starts several ACP child threads in parallel. Each session gets bb's bb-bridge helper.
  3. Watch ~/.bb/logs/host-daemon*.log.

Did not reproduce with: the native claude-code provider, which does not use this TCP helper.

Closest faithful repro on main (no live ACP account needed): the new test keeps the dynamic-tool TCP server alive after a client reset on initialize in packages/provider-bridge-acp/src/bridge/bridge.test.ts. On current main it fails with an uncaught socket error (write EPIPE in the test; live logs are read ECONNRESET). Same hole: the TCP server has no error listener, so Node kills the process.

pnpm exec turbo run test --filter=@bb/provider-bridge-acp -- --run -t "keeps the dynamic-tool TCP server alive"

Expected vs actual

Expected: after bb-bridge answers initialize, the provider process stays up and later tool calls still work.

Actual, from ~/.bb/logs/host-daemon.14.log (provider acp-sub-claude-icloud, thread thr_xvvsythpr4):

acp bridge: built "bb-bridge" session MCP config for thread "thr_xvvsythpr4" (3 tools)
acp bridge: "bb-bridge" answered initialize for thread "thr_xvvsythpr4" (3 tools)
node:events:487
      throw er; // Unhandled 'error' event
      ^

Error: read ECONNRESET
    at TCP.onStreamRead (node:internal/stream_base_commons:216:20)
Emitted 'error' event on Socket instance at:
    at emitErrorNT (node:internal/streams/destroy:170:8)
    at emitErrorCloseNT (node:internal/streams/destroy:129:3)
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
  errno: -54,
  code: 'ECONNRESET',
  syscall: 'read'
}

Node.js v24.15.0

Host daemon message: Unexpected provider process exited with stderr (exit code 1). Same pattern on acp-sub-claude-woktowalk for threads including thr_drf49rrpsm and thr_pumvkn2wbi.

Evidence

Observed: the helper answers initialize, then the process dies on an unhandled socket error. Thought cause, labeled as such: 0.40 made bb-bridge open a short local TCP connection at initialize to tell the host it started. Both sides hang up right after that. The client already listens for errors. The server does not, so Node kills the process.

  • Server handler, no error listener:
    function handleDynamicToolBridgeSocket(
    bridge: AcpDynamicToolBridge,
    socket: Socket,
    ): void {
    let buffer = "";
    socket.setEncoding("utf8");
    socket.on("data", (chunk) => {
    buffer += chunk;
    const newlineIndex = buffer.indexOf("\n");
    if (newlineIndex === -1) {
    return;
    }
    const line = buffer.slice(0, newlineIndex);
    let parsed: unknown;
    try {
    parsed = JSON.parse(line);
    } catch {
    socket.end(`${JSON.stringify({ ok: false, error: "Invalid JSON" })}\n`);
    return;
    }
    const request = dynamicToolBridgeRequestSchema.safeParse(parsed);
    if (!request.success || request.data.token !== bridge.token) {
    socket.end(
    `${JSON.stringify({ ok: false, error: "Invalid dynamic tool request" })}\n`,
    );
    return;
    }
    if (request.data.kind === "initialized") {
    process.stderr.write(
    `acp bridge: "${ACP_BRIDGE_MCP_SERVER_NAME}" answered initialize for thread "${request.data.threadId}" (${request.data.toolCount} tools)\n`,
    );
    socket.end(`${JSON.stringify({ ok: true, content: "" })}\n`);
    return;
    }
    void forwardDynamicToolCall(request.data).then((response) => {
    socket.end(`${JSON.stringify(response)}\n`);
    });
    });
    }
  • Accept path, same hole:
    const server = createServer((socket) => {
    void dynamicToolBridgePromise?.then((bridge) => {
    handleDynamicToolBridgeSocket(bridge, socket);
    });
    });
  • 0.40 initialize ping, then client socket.end():
    switch (message.method) {
    case "initialize":
    writeResult(message.id, {
    protocolVersion:
    typeof objectParams(message.params).protocolVersion === "string"
    ? objectParams(message.params).protocolVersion
    : "2024-11-05",
    capabilities: { tools: {} },
    serverInfo: { name: ACP_BRIDGE_MCP_SERVER_NAME, version: "1.0.0" },
    });
    void callBridge(env, {
    kind: "initialized",
    toolCount: env.tools.length,
    }).catch((error) => {
    process.stderr.write(
    `bb-bridge MCP: failed to report initialize: ${
    error instanceof Error ? error.message : String(error)
    }\n`,
    );
    });
  • Client already has socket.on("error", reject):
    socket.on("data", (chunk) => {
    buffer += chunk;
    const newlineIndex = buffer.indexOf("\n");
    if (newlineIndex === -1) {
    return;
    }
    const line = buffer.slice(0, newlineIndex);
    socket.end();
    try {
    resolve(bridgeToolCallResponseSchema.parse(JSON.parse(line)));
    } catch (error) {
    reject(error);
    }
    });
    socket.on("error", reject);

0.39 already had bb-bridge for tool calls. It did not send this initialize ping, so session start did not hit the hangup. That matches "giant ACP fleets worked on 0.39."

Suggested fix: listen for error on the accepted socket and ignore it. These connections are one-shot. A hangup after the reply is normal. Do not bump HOST_DAEMON_PROTOCOL_VERSION; this is inside the ACP provider process.

What you ruled out

Suggested priority and effort

High for anyone running several ACP sessions at once (workflows). Workaround is native claude-code, which does not keep per-lane named Max accounts. In-flight workers die; no board/data loss. Effort is low: one error listener plus a regression test.

AGENT GENERATED: by Grok 4.6

Metadata

Metadata

Assignees

No one assigned

    Labels

    confirmed-reproBug reproduced independently; see linked reportprovider-acpBuilt-in plugin: provider-acpprovidersCross-provider bridges, models, login

    Type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions