[BUG] SDK: unguarded transport.write() in handleControlRequest catch block causes unhandledRejection

Open 💬 0 comments Opened Mar 27, 2026 by drhuston1

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

When the Claude Code CLI subprocess exits during an active MCP control flow, handleControlRequest in the SDK's Query class produces an unhandled promise rejection that cannot be caught by SDK consumers.

Root Cause

In sdk.mjs, handleControlRequest has this structure (deminified):

async handleControlRequest(msg) {
  const controller = new AbortController();
  this.cancelControllers.set(msg.request_id, controller);
  try {
    const response = await this.processControlRequest(msg, controller.signal);
    const payload = { type: "control_response", response: { subtype: "success", ... } };
    await Promise.resolve(this.transport.write(JSON.stringify(payload) + "\n"));
  } catch (err) {
    const payload = { type: "control_response", response: { subtype: "error", ... } };
    // BUG: If the transport is dead, this write ALSO throws — escaping the catch block
    await Promise.resolve(this.transport.write(JSON.stringify(payload) + "\n"));
  } finally {
    this.cancelControllers.delete(msg.request_id);
  }
}

When the subprocess exits mid-request:

  1. The try block's transport.write() throws "ProcessTransport is not ready for writing"
  2. Execution enters the catch block
  3. The catch block also calls transport.write() to send an error response
  4. That second write also throws (transport is still dead)
  5. The error escapes the catch block entirely → becomes unhandledRejection

The same issue exists in streamInput, which only catches AbortError and lets the "not ready for writing" Error propagate.

Impact

  • Multi-tenant SDK consumers (our use case: server hosting multiple user sessions via query()) cannot correlate the unhandledRejection to a specific session/query, since it's a global process event
  • The only workaround is process.on('unhandledRejection') with message-sniffing, which is fragile
  • Related symptoms reported in #38156, #27453

Stack Traces (from production, SDK 0.2.85)

Path 1 — handleControlRequest:

[unhandledRejection] Error: ProcessTransport is not ready for writing
    at Y4.write (file:///app/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs:19:5865)
    at W4.handleControlRequest (file:///app/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs:20:161)
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5)

Path 2 — streamInput:

[unhandledRejection] Error: ProcessTransport is not ready for writing
    at Y4.write (file:///app/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs:19:5865)
    at W4.streamInput (file:///app/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs:22:1291)

Suggested Fix

Wrap the transport.write() in the catch block of handleControlRequest with its own try-catch:

catch (err) {
  const payload = { type: "control_response", response: { subtype: "error", ... } };
  try {
    await Promise.resolve(this.transport.write(JSON.stringify(payload) + "\n"));
  } catch {
    // Transport is dead — nothing to do, readMessages/waitForExit will surface the exit error
  }
}

And in streamInput, catch the transport write error alongside AbortError:

catch (err) {
  if (!(err instanceof AbortError) && !err.message?.includes('ProcessTransport is not ready')) {
    throw err;
  }
}

Environment

  • SDK version: 0.2.85 (also present in 0.2.49, verified by diffing minified source)
  • Node.js: 22.x
  • OS: Amazon Linux 2023 (Docker), also reproducible on macOS
  • Usage: Multi-tenant server using query() API with MCP servers and canUseTool

View original on GitHub ↗