← reports

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

Bug Priority: High Effort: Low providers provider-acp open on GitHub 2026-08-27 · base ad79bbb5ec90

Verdict: REPRODUCED · Root-cause confidence: high

1. TL;DR

An ACP provider process can exit after its bb-bridge helper reports a successful start. The local TCP client then closes a short connection. The server can write after that close and emit ECONNRESET on its accepted socket. The code has no error listener for that socket, so Node throws the error and exits the process. Parallel sessions increase the chance of this race, but one forced reset proves the fault.

2. Claims vs findings

Claim from the issueStatusEvidence
The helper reports initialize, and then the provider process receives an uncaught socket error. Verified The base test printed both initialize lines. It then caught write ECONNRESET as an uncaught exception.
The accepted server socket has no error listener. Verified Base lines 427–478 attach data events and a server error event. They attach no accepted-socket error event.
Version 0.40 added the initialize report, while version 0.39 did not send it. Verified Commit 08d3f730a3 added the report. Tag desktop-v0.40.0 contains it, and tag desktop-v0.39.0 does not.
Several parallel Claude ACP sessions fail on macOS. Unverified The live macOS claim needs the reporter's accounts and host. The forced Linux reset verifies only the accepted-socket error mechanism.
The native Claude Code provider does not use this TCP helper. Verified The faulty code belongs to @bb/provider-bridge-acp. The native provider uses a different provider bridge.

3. Environment

4. Minimal reproduction

  1. Install Node 24.18.0, Corepack, Git, and curl.
  2. Clone bb and check out the report base.
  3. Install the locked dependencies.
  4. Download and apply the saved regression test.
  5. Run only that test.
report_tmp=$(mktemp -d)
git clone https://github.com/get-bb/bb.git "$report_tmp/bb"
cd "$report_tmp/bb"
git checkout ad79bbb5ec909524f8f281e62d860c588a86f332
corepack pnpm install --frozen-lockfile
curl -fsSLo "$report_tmp/bridge-socket-reset.repro.patch" \
  https://get-bb.github.io/reports/issues/2509/repro/bridge-socket-reset.repro.patch
git apply "$report_tmp/bridge-socket-reset.repro.patch"
corepack pnpm exec turbo run test --filter=@bb/provider-bridge-acp -- \
  --run -t "keeps the dynamic-tool TCP server alive"

Expected: The accepted socket has no uncaught error. The test then completes a normal tool call through the same server.

Test Files  1 passed | 16 skipped (17)
Tests       1 passed | 288 skipped (289)

Actual on the base commit:

acp bridge: built "bb-bridge" session MCP config for thread "thread-1" (1 tools)
acp bridge: "bb-bridge" answered initialize for thread "thread-1" (1 tools)

AssertionError: expected [ Error: write ECONNRESET { …(3) } ] to deeply equal []

+ Error {
+   "message": "write ECONNRESET",
+   "errno": -104,
+   "code": "ECONNRESET",
+   "syscall": "write",
+ }

Test Files  1 failed | 16 skipped (17)
Tests       1 failed | 288 skipped (289)

The test intercepts uncaughtException so the test process can show the fault. Without that test listener, Node exits with code 1.

Repro test file

The saved patch contains this complete test.

it("keeps the dynamic-tool TCP server alive after a client reset on initialize", async () => {
    const { bbThreadId, providerThreadId } = await startThread({
      dynamicTools: [
        {
          name: "update_environment_directory",
          description: "Move this thread to another environment directory.",
          inputSchema: {
            type: "object",
            properties: { path: { type: "string" } },
            required: ["path"],
          },
        },
      ],
    });

    const turnId = sendTurnRequest("turn/start", providerThreadId, {
      input: [{ type: "text", text: "echo-mcp-server-config", mentions: [] }],
    });
    await waitForResponse(turnId);
    await waitForTurnCompleted();

    const configPrefix = "mcp-server-config:";
    const configText = agentMessageTexts().find((text) =>
      text.startsWith(configPrefix),
    );
    if (!configText) {
      throw new Error("Fake ACP agent did not report MCP server config");
    }
    const [mcpServerConfig] = JSON.parse(
      configText.slice(configPrefix.length),
    ) as { env: { name: string; value: string }[]; name: string }[];
    if (!mcpServerConfig) {
      throw new Error("Fake ACP agent reported no MCP server config");
    }
    const env = new Map(
      mcpServerConfig.env.map(({ name, value }) => [name, value]),
    );
    const host = env.get("BB_ACP_DYNAMIC_TOOL_HOST");
    const port = Number(env.get("BB_ACP_DYNAMIC_TOOL_PORT"));
    const threadId = env.get("BB_ACP_DYNAMIC_TOOL_THREAD_ID");
    const token = env.get("BB_ACP_DYNAMIC_TOOL_TOKEN");
    if (!host || !Number.isInteger(port) || !threadId || !token) {
      throw new Error("MCP server config is missing dynamic tool bridge env");
    }

    const uncaught: Error[] = [];
    const recordUncaught = (error: Error) => {
      uncaught.push(error);
    };
    process.on("uncaughtException", recordUncaught);
    try {
      await new Promise<void>((resolve) => {
        const socket = createConnection({ host, port });
        socket.on("connect", () => {
          socket.write(
            `${JSON.stringify({
              kind: "initialized",
              threadId,
              token,
              toolCount: 1,
            })}\n`,
          );
          socket.resetAndDestroy();
        });
        socket.on("error", () => {
          resolve();
        });
        socket.on("close", () => {
          resolve();
        });
      });
      await new Promise((resolveTick) => realSetTimeout(resolveTick, 50));
    } finally {
      process.off("uncaughtException", recordUncaught);
    }
    expect(uncaught).toEqual([]);

    const bridgeCall = callDynamicToolBridge({
      callId: "test-dynamic-tool-call-after-reset",
      host,
      port,
      threadId,
      token,
      tool: "update_environment_directory",
      toolArguments: { path: "/tmp/next-worktree" },
    });
    const forwarded = await waitFor(
      () =>
        output.messages.find(
          (message) =>
            message.method === "item/tool/call" &&
            message.id !== undefined &&
            (message.params as { callId?: unknown }).callId ===
              "test-dynamic-tool-call-after-reset",
        ),
      "forwarded dynamic tool call after reset",
    );
    expect(forwarded.params).toMatchObject({
      arguments: { path: "/tmp/next-worktree" },
      callId: "test-dynamic-tool-call-after-reset",
      providerThreadId,
      threadId: bbThreadId,
      tool: "update_environment_directory",
      turnId: null,
    });

    handleLine(
      JSON.stringify({
        jsonrpc: "2.0",
        id: forwarded.id,
        result: {
          success: true,
          contentItems: [
            { type: "inputText", text: "environment directory updated" },
          ],
        },
      }),
    );

    await expect(bridgeCall).resolves.toEqual({
      content: "environment directory updated",
      contentBlocks: [{ type: "text", text: "environment directory updated" }],
      images: [],
      isError: false,
      ok: true,
    });
  });

Artifacts: repro patch, base failure, and PR results.

5. Root cause

The MCP helper answers its initialize request first. It then opens a local TCP connection to report that event.

case "initialize":
  writeResult(message.id, { ... });
  void callBridge(env, {
    kind: "initialized",
    toolCount: env.tools.length,
  }).catch(...);

See tool-proxy-mcp.ts lines 250–270. The client has an error listener at lines 170–206.

The server reads the report and writes its reply with socket.end(). It listens for data, but it does not listen for errors on the accepted socket.

function handleDynamicToolBridgeSocket(bridge, socket): void {
  let buffer = "";
  socket.setEncoding("utf8");
  socket.on("data", (chunk) => {
    ...
    if (request.data.kind === "initialized") {
      process.stderr.write("...answered initialize...");
      socket.end(`${JSON.stringify({ ok: true, content: "" })}\n`);
      return;
    }
  });
}

const server = createServer((socket) => {
  void dynamicToolBridgePromise?.then((bridge) => {
    handleDynamicToolBridgeSocket(bridge, socket);
  });
});

See bridge.ts lines 427–479. An accepted Socket emits an error event when this race occurs. Node throws an error event that has no listener. The ACP bridge process then exits, so every session in that process ends.

Commit 08d3f730a3 added the initialize report for version 0.40. Version 0.39 used the same TCP bridge for tool calls, but it did not send this start report.

No later commit on origin/main changes these files after the report base. Therefore, the bug is not already fixed.

6. Proposed fix (first principles)

Attach one error listener as soon as the TCP server accepts a socket. Treat reset and broken-pipe errors as connection-local failures. Destroy that socket and keep the shared server alive. Report other error codes without an uncaught event. Do not add a second listener in the handler.

This change belongs in @bb/provider-bridge-acp. It does not change the server-to-daemon wire contract. Therefore, it does not require a HOST_DAEMON_PROTOCOL_VERSION change.

7. PR review

PR #2510 · ignore hangups on the bb-bridge TCP socket

The pull request adds an error listener to each accepted socket. It also adds the exact reset test used in this report. The test then proves that a later dynamic tool call still works.

SeverityFinding
Correct The change addresses the root cause at the accepted socket. It does not hide the error at the host daemon.
Low Lines 435 and 478 add two no-op listeners to the same socket. One early listener is sufficient. The no-op callbacks also hide all error codes.
Correct The pull request has no wire change. It correctly omits a daemon protocol version change.
Strong test The test fails before the code change. It passes after the change and verifies a later tool call.

Tests run:

The pull request branches from e4c873521. The report base has five later unrelated commits. GitHub reports the pull request as mergeable. GitHub showed no status checks during this review.

Verdict: MERGE. The duplicate listener is a small cleanup item. It does not block the root-cause fix.

8. Related issues

9. Appendix

Commands

pnpm install --frozen-lockfile --prefer-offline
pnpm exec turbo run build
gh issue view 2509 --comments
gh issue view 2509 --json ...
gh pr view 2510 --json ...
gh pr diff 2510
git fetch origin main
git log ad79bbb5ec90..origin/main -- packages/provider-bridge-acp/src/bridge/...
git log -S'kind: "initialized"' -- packages/provider-bridge-acp/src/bridge
git blame -L 420,485 packages/provider-bridge-acp/src/bridge/bridge.ts
pnpm exec turbo run test --filter=@bb/provider-bridge-acp -- --run -t "keeps the dynamic-tool TCP server alive"
gh pr checkout 2510 --detach
pnpm exec turbo run typecheck --filter=@bb/provider-bridge-acp
pnpm exec turbo run test --filter=@bb/provider-bridge-acp -- --run -t "keeps the dynamic-tool TCP server alive"
pnpm exec turbo run test --filter=@bb/provider-bridge-acp --force
git diff --check ad79bbb5ec90...7697e314c7ac

Limits

The temporary file system had no free inodes during the first dependency install. The review used the matching dependency tree from the initial checkout. The isolated source worktree still ran every test and build command.

This review did not use the reporter's macOS host or Claude accounts. The normal helper initialize path passed on Linux and did not produce a reset. A forced TCP reset reproduced the accepted-socket fault on Linux.

This fault has no visual state. Therefore, this report has no screenshots.

Verification

An independent verifier applied the saved patch at the base commit and ran the three report commands. The base test failed with write ECONNRESET and the stated 1/288 counts. The verifier confirmed that origin/main equals the base commit. The verifier also confirmed that PR #2510 passes typecheck and all 289 tests.

This revision adds a full clone, dependency, and patch download procedure. It now shows the complete test. It marks the live macOS claim as unverified and records the negative normal Linux result.