Skip to content

Commit 064cd99

Browse files
Add regression test and changeset
- Add test verifying stdout listeners are detached on close() so late non-JSON output from the child process no longer fires onerror - Add patch-level changeset
1 parent e3d2f38 commit 064cd99

File tree

2 files changed

+42
-0
lines changed

2 files changed

+42
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
Detach stdio transport stdout/stdin listeners on `close()`. Previously, if the child process wrote to stdout during shutdown (after `close()` was called), the still-attached data listener would attempt to parse it as JSON-RPC and emit a spurious parse error via `onerror`. Fixes #780.

packages/client/test/client/crossSpawn.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { ChildProcess } from 'node:child_process';
2+
import { EventEmitter } from 'node:events';
23

34
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
45
import spawn from 'cross-spawn';
@@ -174,6 +175,42 @@ describe('StdioClientTransport using cross-spawn', () => {
174175
expect(mockProcess.stdin.write).toHaveBeenCalled();
175176
});
176177

178+
// Regression test for #780: listeners must be detached from the child's
179+
// stdio streams on close(), not left attached to a process we no longer track.
180+
test('should detach stdout/stdin listeners on close', async () => {
181+
const stdout = new EventEmitter();
182+
const stdin = Object.assign(new EventEmitter(), {
183+
write: vi.fn().mockReturnValue(true),
184+
end: vi.fn()
185+
});
186+
const proc = Object.assign(new EventEmitter(), {
187+
stdin,
188+
stdout,
189+
stderr: null,
190+
exitCode: null as number | null,
191+
kill: vi.fn()
192+
});
193+
mockSpawn.mockReturnValue(proc as unknown as ChildProcess);
194+
195+
const transport = new StdioClientTransport({ command: 'test-command' });
196+
const started = transport.start();
197+
proc.emit('spawn');
198+
await started;
199+
200+
expect(stdout.listenerCount('data')).toBe(1);
201+
expect(stdout.listenerCount('error')).toBe(1);
202+
expect(stdin.listenerCount('error')).toBe(1);
203+
204+
const closed = transport.close();
205+
proc.exitCode = 0;
206+
proc.emit('close');
207+
await closed;
208+
209+
expect(stdout.listenerCount('data')).toBe(0);
210+
expect(stdout.listenerCount('error')).toBe(0);
211+
expect(stdin.listenerCount('error')).toBe(0);
212+
});
213+
177214
describe('windowsHide', () => {
178215
const originalPlatform = process.platform;
179216

0 commit comments

Comments
 (0)