Summary
CopilotSession.sendAndWait can reject a promise that has no handler attached yet, which Node classifies as an unhandled rejection and — under the default --unhandled-rejections=throw — terminates the host application.
A caller cannot defend against it: the rejection is on an internal promise, not on the one sendAndWait returns, so even correct try/.catch handling around the call does not prevent the crash.
Location
nodejs/src/session.ts:723-761:
let resolveIdle: () => void;
let rejectWithError: (error: Error) => void;
const idlePromise = new Promise<void>((resolve, reject) => {
resolveIdle = resolve;
rejectWithError = reject;
});
let lastAssistantMessage: AssistantMessageEvent | undefined;
// Register event handler BEFORE calling send to avoid race condition
// where session.idle fires before we start listening
const unsubscribe = this.on((event) => {
...
} else if (event.type === "session.error") {
const error = new Error(event.data.message);
error.stack = event.data.stack;
rejectWithError(error);
}
});
let timeoutId: ReturnType<typeof setTimeout> | undefined;
try {
await this.send(options);
/* … timeoutPromise built here … */
await Promise.race([idlePromise, timeoutPromise]);
Root cause
idlePromise is created before the listener is registered, but its first consumer is only attached by the Promise.race — which runs after await this.send(options), a full JSON-RPC round trip to the CLI.
The listener is live for that entire window. A session.error arriving in it calls rejectWithError on a promise with zero handlers. Node's rejection tracker runs at the following checkpoint, well before the session.send response lands, and reports it as unhandled.
The existing comment shows the inverse race (subscribing too late) was considered; this is the opposite ordering.
Why the window is reachable in normal use
session.error is not reserved for turn-fatal failures. session.log(message, { level: "error" }) — a public method on this same class, whose own JSDoc example is await session.log("Connection failed", { level: "error" }) — emits a session.error carrying errorType: "notification". The repo's own e2e test asserts exactly that, in nodejs/test/e2e/session.e2e.test.ts:
expect(byMessage("Error message").type).toBe("session.error");
expect(byMessage("Error message").data).toEqual({
errorType: "notification",
message: "Error message",
});
So a joined client or extension writing one error log line while another caller is mid-sendAndWait is enough. MCP servers failing to start, sub-agent errors (ErrorData carries agentId) and multi-client sessions produce session.error at times entirely uncorrelated with the caller's send.
Reproduction
Mirroring the promise wiring from lines 723-761, on Node v26.5.0:
t=0ms — caller invokes sendAndWait({ prompt: "…" }); listener registered, send() RPC dispatched.
t=5ms — an unrelated session.error arrives.
- The
session.send response has not returned yet, so Promise.race has not been reached.
Error: MCP server failed to start
Node.js v26.5.0
$ echo $?
1
The process dies. In the repro the caller does sendAndWait().then(v => …, e => …) — correct handling — and it makes no difference.
Impact
An error-level log line from an unrelated participant in the session can take down the embedding application. Since the SDK is meant to be embedded in host apps, this is a liveness issue rather than just a noisy warning.
The other SDKs are structurally immune, for what it's worth: Go (go/session.go:502-505) uses a buffered errCh; Python (python/copilot/session.py:1729-1731) sets a variable plus idle_event.set().
Suggested fix
Attach a no-op catch when the promise is created. It marks the promise handled without consuming the rejection, so the Promise.race still observes it and sendAndWait rejects with the original error exactly as before:
const idlePromise = new Promise<void>((resolve, reject) => {
resolveIdle = resolve;
rejectWithError = reject;
});
+ void idlePromise.catch(() => {});
Happy to send a PR — I have one ready with a regression test that fails on the current code.
Summary
CopilotSession.sendAndWaitcan reject a promise that has no handler attached yet, which Node classifies as an unhandled rejection and — under the default--unhandled-rejections=throw— terminates the host application.A caller cannot defend against it: the rejection is on an internal promise, not on the one
sendAndWaitreturns, so even correcttry/.catchhandling around the call does not prevent the crash.Location
nodejs/src/session.ts:723-761:Root cause
idlePromiseis created before the listener is registered, but its first consumer is only attached by thePromise.race— which runs afterawait this.send(options), a full JSON-RPC round trip to the CLI.The listener is live for that entire window. A
session.errorarriving in it callsrejectWithErroron a promise with zero handlers. Node's rejection tracker runs at the following checkpoint, well before thesession.sendresponse lands, and reports it as unhandled.The existing comment shows the inverse race (subscribing too late) was considered; this is the opposite ordering.
Why the window is reachable in normal use
session.erroris not reserved for turn-fatal failures.session.log(message, { level: "error" })— a public method on this same class, whose own JSDoc example isawait session.log("Connection failed", { level: "error" })— emits asession.errorcarryingerrorType: "notification". The repo's own e2e test asserts exactly that, innodejs/test/e2e/session.e2e.test.ts:So a joined client or extension writing one error log line while another caller is mid-
sendAndWaitis enough. MCP servers failing to start, sub-agent errors (ErrorDatacarriesagentId) and multi-client sessions producesession.errorat times entirely uncorrelated with the caller's send.Reproduction
Mirroring the promise wiring from lines 723-761, on Node v26.5.0:
t=0ms— caller invokessendAndWait({ prompt: "…" }); listener registered,send()RPC dispatched.t=5ms— an unrelatedsession.errorarrives.session.sendresponse has not returned yet, soPromise.racehas not been reached.The process dies. In the repro the caller does
sendAndWait().then(v => …, e => …)— correct handling — and it makes no difference.Impact
An error-level log line from an unrelated participant in the session can take down the embedding application. Since the SDK is meant to be embedded in host apps, this is a liveness issue rather than just a noisy warning.
The other SDKs are structurally immune, for what it's worth: Go (
go/session.go:502-505) uses a bufferederrCh; Python (python/copilot/session.py:1729-1731) sets a variable plusidle_event.set().Suggested fix
Attach a no-op
catchwhen the promise is created. It marks the promise handled without consuming the rejection, so thePromise.racestill observes it andsendAndWaitrejects with the original error exactly as before:const idlePromise = new Promise<void>((resolve, reject) => { resolveIdle = resolve; rejectWithError = reject; }); + void idlePromise.catch(() => {});Happy to send a PR — I have one ready with a regression test that fails on the current code.