In nodejs/src/client.ts, connectToChildProcessViaStdio() attaches an 'error' listener to the spawned CLI's stdin that re-throws the error whenever the client is not in the middle of forceStop():
|
// Add error handler to stdin to prevent unhandled rejections during forceStop |
|
this.cliProcess.stdin?.on("error", (err) => { |
|
if (!this.forceStopping) { |
|
throw err; |
|
} |
|
}); |
Throwing synchronously inside a Node.js EventEmitter callback does not reject any caller's promise and does not route through any existing error handling — it surfaces as an uncaughtException on the Node process, which by default terminates the host application. This defeats the listener's stated purpose ("prevent unhandled rejections during forceStop"): without the listener, an emitted 'error' with no handler crashes the process; with this listener, an emitted 'error' outside forceStop also crashes the process, just one tick later.
This affects any SDK consumer using RuntimeConnection.forStdio(...) whenever the CLI child process dies for any reason other than a user-initiated forceStop() — e.g. the CLI crashing, being SIGKILL'd by the OS, or closing its stdin — because the next write attempt by StreamMessageWriter will trigger EPIPE on the pipe and emit 'error'.
Reproduction
- Create a
CopilotClient configured with RuntimeConnection.forStdio(...).
- After
connect() succeeds, send SIGKILL to the CLI child process (or otherwise close its stdin) without calling forceStop() on the client.
- Trigger any RPC that causes a write to the now-broken stdin pipe.
Expected: the SDK surfaces the failure through its normal error path — pending RPCs reject, state transitions to "disconnected", and the host application stays alive so the consumer can decide what to do.
Actual: Node emits 'error' on stdin, the listener re-throws, and the host application terminates with an uncaughtException.
Why the current implementation doesn't achieve its goal
The comment says the listener exists "to prevent unhandled rejections during forceStop." Two issues:
- It re-introduces the very crash it's trying to suppress. Node's default for an
'error' event with no listener is to throw uncaughtException. Re-throwing for the non-forceStop branch produces the same outcome with extra steps.
- The throw doesn't reach any caller. It happens synchronously inside an
EventEmitter callback, so it doesn't reject any in-flight sendRequest promise, doesn't reach attachConnectionHandlers's connection.onError, and doesn't transition state to "error".
The information is also redundant: a broken stdin pipe almost always implies the child process exited, and the existing cliProcess.on("exit", ...) handler in startCLIServer already populates processExitPromise, which verifyProtocolVersion (and any other call site using raceAgainstExit) uses to fail in-flight requests with a meaningful message including the CLI's stderr.
Suggested fixes
Two viable options, in increasing order of behavior change:
Option 1 — Swallow the error (minimal change)
A stdin 'error' is virtually always a side effect of the CLI process dying, and that path is already covered: cliProcess.on("exit", ...) populates processExitPromise (which rejects in-flight RPCs that use raceAgainstExit), and MessageConnection's own writer-error detection fires onError / onClose (which sets state = "disconnected").
The only reason an 'error' listener needs to be present at all is so Node doesn't promote the event to uncaughtException. So the smallest correct change is to keep the listener but make it a no-op:
this.cliProcess.stdin?.on("error", () => {
// EPIPE on stdin means the CLI exited; the exit handler populates
// processExitPromise and the MessageConnection's own writer-error
// detection sets state = "disconnected".
});
Option 2 — Route through the existing connection-teardown path
If we want the stdin error to actively drive teardown (so that RPCs not using raceAgainstExit also fail fast instead of hanging on a dead pipe), dispose the connection. This mirrors how a TCP socket disconnect is already handled in connectViaTcp() and is consistent with the try { dispose() } catch {} pattern already used in forceStop():
this.cliProcess.stdin?.on("error", () => {
if (this.forceStopping) {
return; // forceStop is already tearing everything down
}
this.state = "error";
try {
this.connection?.dispose();
} catch {
// dispose is idempotent and may race with forceStop
}
});
This:
- Keeps the
'error' listener registered so Node never promotes the event to uncaughtException.
- Disposes the
MessageConnection, which fires the existing onClose handler (sets state = "disconnected") and rejects pending requests through vscode-jsonrpc's normal channels.
- Does not duplicate
processExitPromise's job — that promise continues to independently reject in-flight RPCs racing against it with the captured stderr.
Either option fixes the crash; Option 2 additionally makes failure propagation more deterministic.
Environment
- Package:
@github/copilot-sdk (Node.js SDK)
- File:
nodejs/src/client.ts, connectToChildProcessViaStdio()
- Affects:
RuntimeConnection.forStdio(...) consumers when the CLI child dies outside of forceStop().
In
nodejs/src/client.ts,connectToChildProcessViaStdio()attaches an'error'listener to the spawned CLI'sstdinthat re-throws the error whenever the client is not in the middle offorceStop():copilot-sdk/nodejs/src/client.ts
Lines 1801 to 1806 in c69ea43
Throwing synchronously inside a Node.js
EventEmittercallback does not reject any caller's promise and does not route through any existing error handling — it surfaces as anuncaughtExceptionon the Node process, which by default terminates the host application. This defeats the listener's stated purpose ("prevent unhandled rejections during forceStop"): without the listener, an emitted'error'with no handler crashes the process; with this listener, an emitted'error'outsideforceStopalso crashes the process, just one tick later.This affects any SDK consumer using
RuntimeConnection.forStdio(...)whenever the CLI child process dies for any reason other than a user-initiatedforceStop()— e.g. the CLI crashing, being SIGKILL'd by the OS, or closing its stdin — because the next write attempt byStreamMessageWriterwill triggerEPIPEon the pipe and emit'error'.Reproduction
CopilotClientconfigured withRuntimeConnection.forStdio(...).connect()succeeds, sendSIGKILLto the CLI child process (or otherwise close its stdin) without callingforceStop()on the client.Expected: the SDK surfaces the failure through its normal error path — pending RPCs reject,
statetransitions to"disconnected", and the host application stays alive so the consumer can decide what to do.Actual: Node emits
'error'onstdin, the listener re-throws, and the host application terminates with anuncaughtException.Why the current implementation doesn't achieve its goal
The comment says the listener exists "to prevent unhandled rejections during forceStop." Two issues:
'error'event with no listener is to throwuncaughtException. Re-throwing for the non-forceStopbranch produces the same outcome with extra steps.EventEmittercallback, so it doesn't reject any in-flightsendRequestpromise, doesn't reachattachConnectionHandlers'sconnection.onError, and doesn't transitionstateto"error".The information is also redundant: a broken stdin pipe almost always implies the child process exited, and the existing
cliProcess.on("exit", ...)handler instartCLIServeralready populatesprocessExitPromise, whichverifyProtocolVersion(and any other call site usingraceAgainstExit) uses to fail in-flight requests with a meaningful message including the CLI's stderr.Suggested fixes
Two viable options, in increasing order of behavior change:
Option 1 — Swallow the error (minimal change)
A stdin
'error'is virtually always a side effect of the CLI process dying, and that path is already covered:cliProcess.on("exit", ...)populatesprocessExitPromise(which rejects in-flight RPCs that useraceAgainstExit), andMessageConnection's own writer-error detection firesonError/onClose(which setsstate = "disconnected").The only reason an
'error'listener needs to be present at all is so Node doesn't promote the event touncaughtException. So the smallest correct change is to keep the listener but make it a no-op:Option 2 — Route through the existing connection-teardown path
If we want the stdin error to actively drive teardown (so that RPCs not using
raceAgainstExitalso fail fast instead of hanging on a dead pipe), dispose the connection. This mirrors how a TCP socket disconnect is already handled inconnectViaTcp()and is consistent with thetry { dispose() } catch {}pattern already used inforceStop():This:
'error'listener registered so Node never promotes the event touncaughtException.MessageConnection, which fires the existingonClosehandler (setsstate = "disconnected") and rejects pending requests throughvscode-jsonrpc's normal channels.processExitPromise's job — that promise continues to independently reject in-flight RPCs racing against it with the captured stderr.Either option fixes the crash; Option 2 additionally makes failure propagation more deterministic.
Environment
@github/copilot-sdk(Node.js SDK)nodejs/src/client.ts,connectToChildProcessViaStdio()RuntimeConnection.forStdio(...)consumers when the CLI child dies outside offorceStop().