Skip to content

Commit 5485e0b

Browse files
author
kai-agent-free
committed
fix: handle EPIPE and ERR_STREAM_DESTROYED in StdioServerTransport
When a client disconnects abruptly, stdout.write() can emit an EPIPE or ERR_STREAM_DESTROYED error that crashes the server process. This adds: - An error listener on stdout that catches EPIPE/ERR_STREAM_DESTROYED and triggers graceful close() instead of crashing - try/catch in send() for synchronous write errors - A writable check before writing - Cleanup of the stdout error listener in close() Fixes #1564
1 parent 8cdb4bb commit 5485e0b

2 files changed

Lines changed: 113 additions & 5 deletions

File tree

packages/server/src/server/stdio.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,14 @@ export class StdioServerTransport implements Transport {
3737
_onerror = (error: Error) => {
3838
this.onerror?.(error);
3939
};
40+
_onstdouterror = (error: NodeJS.ErrnoException) => {
41+
if (error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED') {
42+
// Client disconnected — close gracefully instead of crashing.
43+
void this.close();
44+
} else {
45+
this.onerror?.(error);
46+
}
47+
};
4048

4149
/**
4250
* Starts listening for messages on `stdin`.
@@ -51,6 +59,7 @@ export class StdioServerTransport implements Transport {
5159
this._started = true;
5260
this._stdin.on('data', this._ondata);
5361
this._stdin.on('error', this._onerror);
62+
this._stdout.on('error', this._onstdouterror);
5463
}
5564

5665
private processReadBuffer() {
@@ -72,6 +81,7 @@ export class StdioServerTransport implements Transport {
7281
// Remove our event listeners first
7382
this._stdin.off('data', this._ondata);
7483
this._stdin.off('error', this._onerror);
84+
this._stdout.off('error', this._onstdouterror);
7585

7686
// Check if we were the only data listener
7787
const remainingDataListeners = this._stdin.listenerCount('data');
@@ -87,12 +97,26 @@ export class StdioServerTransport implements Transport {
8797
}
8898

8999
send(message: JSONRPCMessage): Promise<void> {
90-
return new Promise(resolve => {
100+
return new Promise((resolve, reject) => {
91101
const json = serializeMessage(message);
92-
if (this._stdout.write(json)) {
93-
resolve();
94-
} else {
95-
this._stdout.once('drain', resolve);
102+
if (!this._stdout.writable) {
103+
reject(new Error('stdout is not writable'));
104+
return;
105+
}
106+
try {
107+
if (this._stdout.write(json)) {
108+
resolve();
109+
} else {
110+
this._stdout.once('drain', resolve);
111+
}
112+
} catch (error: unknown) {
113+
const errno = error as NodeJS.ErrnoException;
114+
if (errno.code === 'EPIPE' || errno.code === 'ERR_STREAM_DESTROYED') {
115+
void this.close();
116+
reject(error);
117+
} else {
118+
reject(error);
119+
}
96120
}
97121
});
98122
}

packages/server/test/server/stdio.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,87 @@ test('should read multiple messages', async () => {
102102
await finished;
103103
expect(readMessages).toEqual(messages);
104104
});
105+
106+
test('should handle EPIPE error on stdout gracefully', async () => {
107+
const server = new StdioServerTransport(input, output);
108+
109+
let didClose = false;
110+
server.onclose = () => {
111+
didClose = true;
112+
};
113+
114+
await server.start();
115+
116+
// Simulate EPIPE error on stdout
117+
const epipeError = new Error('write EPIPE') as NodeJS.ErrnoException;
118+
epipeError.code = 'EPIPE';
119+
output.emit('error', epipeError);
120+
121+
// Should trigger graceful close, not crash
122+
expect(didClose).toBeTruthy();
123+
});
124+
125+
test('should handle ERR_STREAM_DESTROYED error on stdout gracefully', async () => {
126+
const server = new StdioServerTransport(input, output);
127+
128+
let didClose = false;
129+
server.onclose = () => {
130+
didClose = true;
131+
};
132+
133+
await server.start();
134+
135+
const destroyedError = new Error('stream destroyed') as NodeJS.ErrnoException;
136+
destroyedError.code = 'ERR_STREAM_DESTROYED';
137+
output.emit('error', destroyedError);
138+
139+
expect(didClose).toBeTruthy();
140+
});
141+
142+
test('should forward non-EPIPE stdout errors to onerror', async () => {
143+
const server = new StdioServerTransport(input, output);
144+
145+
let reportedError: Error | undefined;
146+
server.onerror = (error) => {
147+
reportedError = error;
148+
};
149+
150+
await server.start();
151+
152+
const otherError = new Error('some other error') as NodeJS.ErrnoException;
153+
otherError.code = 'ENOSPC';
154+
output.emit('error', otherError);
155+
156+
expect(reportedError).toBe(otherError);
157+
});
158+
159+
test('should reject send when stdout is not writable', async () => {
160+
const closedOutput = new Writable({
161+
write(_chunk, _encoding, callback) {
162+
callback();
163+
}
164+
});
165+
closedOutput.destroy();
166+
167+
const server = new StdioServerTransport(input, closedOutput);
168+
await server.start();
169+
170+
const message: JSONRPCMessage = {
171+
jsonrpc: '2.0',
172+
id: 1,
173+
method: 'ping'
174+
};
175+
176+
await expect(server.send(message)).rejects.toThrow('stdout is not writable');
177+
});
178+
179+
test('should remove stdout error listener on close', async () => {
180+
const server = new StdioServerTransport(input, output);
181+
await server.start();
182+
183+
const listenersBefore = output.listenerCount('error');
184+
await server.close();
185+
const listenersAfter = output.listenerCount('error');
186+
187+
expect(listenersAfter).toBeLessThan(listenersBefore);
188+
});

0 commit comments

Comments
 (0)