Skip to content

Node: sendAndWait can emit an unhandled rejection and terminate the host process when session.error arrives mid-send #2205

Description

@thejesh23

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:

  1. t=0ms — caller invokes sendAndWait({ prompt: "…" }); listener registered, send() RPC dispatched.
  2. t=5ms — an unrelated session.error arrives.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions