Skip to content
Merged
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
22 changes: 19 additions & 3 deletions nodejs/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
}
});

Expand Down
17 changes: 17 additions & 0 deletions nodejs/test/client.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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" }));
Expand Down
Loading