Summary
When a caller wires ClientSideConnection up to a child process's stdio (via Readable.toWeb / Writable.toWeb) and the spawn fails with ENOENT, Node fires unhandledRejection for the pending-response promise before the caller's await attaches a handler. initialize() does reject correctly, so the caller's control flow is fine — but the cosmetic unhandled rejection still crashes strict test runners (vitest, Node --unhandled-rejections=strict) and pollutes logs.
Telltale warning:
(node:NNN) PromiseRejectionHandledWarning: Promise rejection was handled asynchronously (rejection id: N)
Related but distinct from #102 (stream read failures during #receive). The control scenarios below show #102's fix holds — the remaining leak is in sendRequest's listener-attachment window, not in #receive.
Environment
@agentclientprotocol/sdk 0.19.0
- Node.js v22.16.0
- Windows 11 (also reproduces on Linux per downstream reports)
Minimal repro
// repro.mjs — node repro.mjs
import { spawn } from 'node:child_process';
import { Readable, Writable } from 'node:stream';
import { ClientSideConnection, ndJsonStream } from '@agentclientprotocol/sdk';
process.on('unhandledRejection', (err) => {
console.log('UNHANDLED:', err?.message ?? String(err));
process.exitCode = 1;
});
const child = spawn('definitely-not-a-real-binary-xyz', [], {
stdio: ['pipe', 'pipe', 'pipe'],
});
child.on('error', () => {}); // absorb spawn error
const stream = ndJsonStream(
Writable.toWeb(child.stdin),
Readable.toWeb(child.stdout),
);
const connection = new ClientSideConnection(
() => ({
async writeTextFile() { return {}; },
async readTextFile() { return { content: '' }; },
async requestPermission() {
return { outcome: { outcome: 'selected', optionId: 'allow' } };
},
async sessionUpdate() {},
}),
stream,
);
try {
await connection.initialize({
protocolVersion: 1,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
});
} catch (err) {
console.log('initialize rejected (expected):', err.message);
}
Expected: clean exit, single "initialize rejected" line.
Actual: UNHANDLED: ACP connection closed fires first, then the caught rejection, then PromiseRejectionHandledWarning.
Control scenarios (both clean, both exercise #102's fix path)
- A — synthetic
ReadableStream that calls controller.close() immediately: clean.
- B — real child process that exits 0 before sending any bytes: clean.
- C — spawn
ENOENT (above): LEAKS.
So the trigger is specifically "streams wired up but never opened," not "streams that close cleanly after opening."
Diagnosis
In Connection.sendRequest (dist/acp.js ~line 1127):
async sendRequest(method, params) {
this.#throwIfClosed();
const id = this.#nextRequestId++;
const responsePromise = new Promise((resolve, reject) => {
this.#pendingResponses.set(id, { resolve, reject });
});
await this.#sendMessage({ jsonrpc: "2.0", id, method, params });
return responsePromise;
}
Race:
responsePromise is created; {resolve, reject} stored in #pendingResponses.
await this.#sendMessage(...) yields.
#receive() observes the broken transport → calls #close(err) → iterates #pendingResponses and rejects responsePromise.
#sendMessage swallows its own write error via .catch(err => this.#close(err)), so the outer await resolves normally.
return responsePromise hands back an already-rejected promise. By the time the caller's await attaches a handler, Node has already flagged unhandledRejection. The later handler attach produces PromiseRejectionHandledWarning.
Suggested fix
Attach a no-op handler inside sendRequest so the rejection is marked "observed" during the #sendMessage await window. Semantics unchanged — the caller's await still sees the rejection:
async sendRequest(method, params) {
this.#throwIfClosed();
const id = this.#nextRequestId++;
const responsePromise = new Promise((resolve, reject) => {
this.#pendingResponses.set(id, { resolve, reject });
});
responsePromise.catch(() => {}); // suppress unhandledRejection during send-await window
await this.#sendMessage({ jsonrpc: "2.0", id, method, params });
return responsePromise;
}
Happy to open a PR with this fix and a regression test (spawn ENOENT → initialize() should reject with no unhandledRejection).
Impact on downstream
- Vitest flags the rejection and fails tests that exercise the missing-binary path.
- Downstream workaround in agent-loom PR #62: removed the spawn-based missing-binary test case. Will re-enable when this lands.
Summary
When a caller wires
ClientSideConnectionup to a child process's stdio (viaReadable.toWeb/Writable.toWeb) and the spawn fails withENOENT, Node firesunhandledRejectionfor the pending-response promise before the caller'sawaitattaches a handler.initialize()does reject correctly, so the caller's control flow is fine — but the cosmetic unhandled rejection still crashes strict test runners (vitest, Node--unhandled-rejections=strict) and pollutes logs.Telltale warning:
Related but distinct from #102 (stream read failures during
#receive). The control scenarios below show #102's fix holds — the remaining leak is insendRequest's listener-attachment window, not in#receive.Environment
@agentclientprotocol/sdk0.19.0Minimal repro
Expected: clean exit, single "initialize rejected" line.
Actual:
UNHANDLED: ACP connection closedfires first, then the caught rejection, thenPromiseRejectionHandledWarning.Control scenarios (both clean, both exercise #102's fix path)
ReadableStreamthat callscontroller.close()immediately: clean.ENOENT(above): LEAKS.So the trigger is specifically "streams wired up but never opened," not "streams that close cleanly after opening."
Diagnosis
In
Connection.sendRequest(dist/acp.js ~line 1127):Race:
responsePromiseis created;{resolve, reject}stored in#pendingResponses.await this.#sendMessage(...)yields.#receive()observes the broken transport → calls#close(err)→ iterates#pendingResponsesand rejectsresponsePromise.#sendMessageswallows its own write error via.catch(err => this.#close(err)), so the outerawaitresolves normally.return responsePromisehands back an already-rejected promise. By the time the caller'sawaitattaches a handler, Node has already flaggedunhandledRejection. The later handler attach producesPromiseRejectionHandledWarning.Suggested fix
Attach a no-op handler inside
sendRequestso the rejection is marked "observed" during the#sendMessageawait window. Semantics unchanged — the caller'sawaitstill sees the rejection:Happy to open a PR with this fix and a regression test (spawn ENOENT →
initialize()should reject with nounhandledRejection).Impact on downstream