diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 96ac60842f..751d79b9fc 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -490,6 +490,13 @@ export class CopilotClient { } } + private logDebug(message: string): void { + const level = this.options.logLevel?.toLowerCase(); + if (level === "debug" || level === "all") { + process.stderr.write(`[copilot-sdk] ${message}\n`); + } + } + /** * Creates a new CopilotClient instance. * @@ -2304,10 +2311,19 @@ export class CopilotClient { throw new Error("CLI process not started"); } - // Add error handler to stdin to prevent unhandled rejections during forceStop + // Keep stdin pipe errors inside the normal JSON-RPC teardown path. + // Preserve the failure reason via the gated debug log rather than discarding it. this.cliProcess.stdin?.on("error", (err) => { - if (!this.forceStopping) { - throw err; + if (this.forceStopping) { + return; + } + this.state = "error"; + const reason = err instanceof Error ? (err.stack ?? err.message) : String(err); + this.logDebug(`stdin pipe error: ${reason}`); + try { + this.connection?.dispose(); + } catch { + // The connection may already be closing after the child process exited. } }); diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index ef804095f8..5319e22cd4 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { EventEmitter } from "node:events"; import { describe, expect, it, onTestFinished, vi } from "vitest"; +import { PassThrough } from "stream"; import { approveAll, CopilotClient, @@ -14,6 +15,22 @@ import { defaultJoinSessionPermissionHandler } from "../src/types.js"; // This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead describe("CopilotClient", () => { + it("disposes the stdio connection when child stdin emits an error", async () => { + const client = new CopilotClient(); + onTestFinished(() => client.forceStop()); + + const stdin = new PassThrough(); + const stdout = new PassThrough(); + (client as any).cliProcess = { stdin, stdout }; + await (client as any).connectToChildProcessViaStdio(); + + const dispose = vi.spyOn((client as any).connection, "dispose"); + + const boom = new Error("broken pipe"); + expect(() => stdin.emit("error", boom)).not.toThrow(); + expect(dispose).toHaveBeenCalledOnce(); + }); + it("does not respond to v3 permission requests when handler returns no-result", async () => { const session = new CopilotSession("session-1", {} as any); session.registerPermissionHandler(() => ({ kind: "no-result" }));