Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions nodejs/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
61 changes: 61 additions & 0 deletions nodejs/test/session-send-and-wait.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});