From f62bb8f1567d5772b4f03987adaaad1365cf7f39 Mon Sep 17 00:00:00 2001 From: thejesh23 Date: Sat, 1 Aug 2026 02:31:27 -0700 Subject: [PATCH] Stop sendAndWait from emitting an unhandled rejection `sendAndWait` creates `idlePromise` and registers the event listener that can reject it, but the first consumer is only attached by the `Promise.race` further down -- after `await this.send(options)`, a full JSON-RPC round trip to the CLI. A `session.error` arriving in that window therefore rejects a promise that has no handler yet. Node's rejection tracker runs at the following checkpoint, well before the `session.send` response lands, and classifies it as unhandled, which terminates the process under the default `--unhandled-rejections=throw`. The window is reachable from ordinary, non-fatal traffic. `session.log` with `{ level: "error" }` emits a `session.error` carrying `errorType: "notification"` (asserted in test/e2e/session.e2e.test.ts), so a joined client or extension writing an error log line while another caller is mid-`sendAndWait` is enough. MCP servers failing to start and sub-agent errors do the same. A caller cannot defend against this: the rejection is on the internal promise, not on the one `sendAndWait` returns, so even correct `.catch`/`try` handling around the call does not prevent the crash. Attaching a no-op `catch` 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. The added test drives a session whose `session.send` RPC is held open, dispatches a `session.error` into the window, and asserts both that no `unhandledRejection` fires and that `sendAndWait` still rejects. It fails on the unfixed code with the error captured by the process-level listener. --- nodejs/src/session.ts | 7 +++ nodejs/test/session-send-and-wait.test.ts | 61 +++++++++++++++++++++++ 2 files changed, 68 insertions(+) create mode 100644 nodejs/test/session-send-and-wait.test.ts diff --git a/nodejs/src/session.ts b/nodejs/src/session.ts index 0d1d90fbbb..79fe52f304 100644 --- a/nodejs/src/session.ts +++ b/nodejs/src/session.ts @@ -726,6 +726,13 @@ export class CopilotSession { resolveIdle = resolve; rejectWithError = reject; }); + // A `session.error` can arrive while `send()`'s RPC is still in flight — + // that is, before the `Promise.race` below attaches the first consumer to + // `idlePromise`. Mark the promise handled now so such a rejection can never + // surface as an unhandled rejection, which terminates the process under + // Node's default `--unhandled-rejections=throw`. This extra `catch` does + // not consume the rejection: the `race` below still sees and rethrows it. + void idlePromise.catch(() => {}); let lastAssistantMessage: AssistantMessageEvent | undefined; diff --git a/nodejs/test/session-send-and-wait.test.ts b/nodejs/test/session-send-and-wait.test.ts new file mode 100644 index 0000000000..9e9b1eba4b --- /dev/null +++ b/nodejs/test/session-send-and-wait.test.ts @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it, onTestFinished } from "vitest"; +import type { MessageConnection } from "vscode-jsonrpc/node.js"; +import { CopilotSession } from "../src/session.js"; +import type { SessionEvent } from "../src/generated/session-events.js"; + +/** Builds a `session.error` event, the shape `session.log(…, { level: "error" })` produces. */ +function errorEvent(message: string): SessionEvent { + return { + type: "session.error", + id: "00000000-0000-4000-8000-000000000001", + parentId: null, + timestamp: new Date().toISOString(), + data: { errorType: "notification", message }, + } as SessionEvent; +} + +describe("sendAndWait", () => { + it("does not emit an unhandled rejection when session.error arrives before the idle race is armed", async () => { + // Hold the `session.send` RPC open so the test can dispatch an event in the + // window between the event listener being registered and Promise.race + // attaching the first consumer to the internal idle promise. + let resolveSend: ((value: unknown) => void) | undefined; + const connection = { + sendRequest: () => + new Promise((resolve) => { + resolveSend = resolve; + }), + } as unknown as MessageConnection; + + const session = new CopilotSession("session-1", connection); + + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + onTestFinished(() => { + process.off("unhandledRejection", onUnhandled); + }); + + const pending = session.sendAndWait({ prompt: "hi" }); + + // A session.error lands while send()'s RPC is still in flight. This is + // ordinary traffic: a joined client calling session.log(…, { level: "error" }) + // or an MCP server failing to start both produce one. + session._dispatchEvent(errorEvent("MCP server failed to start")); + + // Yield past a macrotask boundary so Node has run the checkpoint at which + // it classifies a rejection as unhandled. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(unhandled).toEqual([]); + + resolveSend?.({ messageId: "msg-1" }); + await expect(pending).rejects.toThrow("MCP server failed to start"); + }); +});