Skip to content

Commit 460b48a

Browse files
sjh9714SteveSandersonMSCopilot
authored
fix(nodejs): handle stdio stdin errors (#1584)
* fix(nodejs): handle stdio stdin errors * fix(nodejs): preserve stdin error reason during stdio teardown Keep the stdin "error" handler's no-throw teardown, but stash the failure reason on a private field and surface it via the existing logLevel-gated debug logger instead of discarding it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(nodejs): drop unused lastError field; keep gated debug log Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 59168f0 commit 460b48a

2 files changed

Lines changed: 36 additions & 3 deletions

File tree

nodejs/src/client.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -490,6 +490,13 @@ export class CopilotClient {
490490
}
491491
}
492492

493+
private logDebug(message: string): void {
494+
const level = this.options.logLevel?.toLowerCase();
495+
if (level === "debug" || level === "all") {
496+
process.stderr.write(`[copilot-sdk] ${message}\n`);
497+
}
498+
}
499+
493500
/**
494501
* Creates a new CopilotClient instance.
495502
*
@@ -2304,10 +2311,19 @@ export class CopilotClient {
23042311
throw new Error("CLI process not started");
23052312
}
23062313

2307-
// Add error handler to stdin to prevent unhandled rejections during forceStop
2314+
// Keep stdin pipe errors inside the normal JSON-RPC teardown path.
2315+
// Preserve the failure reason via the gated debug log rather than discarding it.
23082316
this.cliProcess.stdin?.on("error", (err) => {
2309-
if (!this.forceStopping) {
2310-
throw err;
2317+
if (this.forceStopping) {
2318+
return;
2319+
}
2320+
this.state = "error";
2321+
const reason = err instanceof Error ? (err.stack ?? err.message) : String(err);
2322+
this.logDebug(`stdin pipe error: ${reason}`);
2323+
try {
2324+
this.connection?.dispose();
2325+
} catch {
2326+
// The connection may already be closing after the child process exited.
23112327
}
23122328
});
23132329

nodejs/test/client.test.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
/* eslint-disable @typescript-eslint/no-explicit-any */
22
import { EventEmitter } from "node:events";
33
import { describe, expect, it, onTestFinished, vi } from "vitest";
4+
import { PassThrough } from "stream";
45
import {
56
approveAll,
67
CopilotClient,
@@ -14,6 +15,22 @@ import { defaultJoinSessionPermissionHandler } from "../src/types.js";
1415
// This file is for unit tests. Where relevant, prefer to add e2e tests in e2e/*.test.ts instead
1516

1617
describe("CopilotClient", () => {
18+
it("disposes the stdio connection when child stdin emits an error", async () => {
19+
const client = new CopilotClient();
20+
onTestFinished(() => client.forceStop());
21+
22+
const stdin = new PassThrough();
23+
const stdout = new PassThrough();
24+
(client as any).cliProcess = { stdin, stdout };
25+
await (client as any).connectToChildProcessViaStdio();
26+
27+
const dispose = vi.spyOn((client as any).connection, "dispose");
28+
29+
const boom = new Error("broken pipe");
30+
expect(() => stdin.emit("error", boom)).not.toThrow();
31+
expect(dispose).toHaveBeenCalledOnce();
32+
});
33+
1734
it("does not respond to v3 permission requests when handler returns no-result", async () => {
1835
const session = new CopilotSession("session-1", {} as any);
1936
session.registerPermissionHandler(() => ({ kind: "no-result" }));

0 commit comments

Comments
 (0)