[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:
- The
tryblock'stransport.write()throws "ProcessTransport is not ready for writing" - Execution enters the
catchblock - The
catchblock also callstransport.write()to send an error response - That second write also throws (transport is still dead)
- 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 theunhandledRejectionto 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 andcanUseTool