Skip to content

Commit be57196

Browse files
committed
fix: modelcontextprotocol#780 onerror and other listener not remove after client close (stdio)
1 parent 06a4fd2 commit be57196

File tree

3 files changed

+57
-36
lines changed

3 files changed

+57
-36
lines changed

package-lock.json

Lines changed: 0 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/client/stdio.ts

Lines changed: 30 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ export class StdioClientTransport implements Transport {
9494
private _readBuffer: ReadBuffer = new ReadBuffer();
9595
private _serverParams: StdioServerParameters;
9696
private _stderrStream: PassThrough | null = null;
97+
private _onServerDataHandler?: (chunk: Buffer) => void;
98+
private _onServerErrorHandler?: (error: Error) => void;
9799

98100
onclose?: () => void;
99101
onerror?: (error: Error) => void;
@@ -129,33 +131,31 @@ export class StdioClientTransport implements Transport {
129131
cwd: this._serverParams.cwd
130132
});
131133

134+
this._onServerDataHandler = (chunk: Buffer) => {
135+
this._readBuffer.append(chunk);
136+
this.processReadBuffer();
137+
};
138+
this._onServerErrorHandler = (error: Error) => {
139+
this.onerror?.(error);
140+
};
141+
142+
this._process.stdout?.on('data', this._onServerDataHandler);
143+
this._process.stdout?.on('error', this._onServerErrorHandler);
144+
this._process.stdin?.on('error', this._onServerErrorHandler);
145+
132146
this._process.on('error', error => {
133147
reject(error);
134148
this.onerror?.(error);
135149
});
136-
137-
this._process.on('spawn', () => {
138-
resolve();
139-
});
140-
141-
this._process.on('close', _code => {
150+
this._process.once('spawn', () => resolve());
151+
this._process.once('close', _code => {
152+
if (this._process) {
153+
this.cleanupListeners(this._process);
154+
}
142155
this._process = undefined;
143156
this.onclose?.();
144157
});
145158

146-
this._process.stdin?.on('error', error => {
147-
this.onerror?.(error);
148-
});
149-
150-
this._process.stdout?.on('data', chunk => {
151-
this._readBuffer.append(chunk);
152-
this.processReadBuffer();
153-
});
154-
155-
this._process.stdout?.on('error', error => {
156-
this.onerror?.(error);
157-
});
158-
159159
if (this._stderrStream && this._process.stderr) {
160160
this._process.stderr.pipe(this._stderrStream);
161161
}
@@ -201,8 +201,19 @@ export class StdioClientTransport implements Transport {
201201
}
202202
}
203203

204+
private cleanupListeners(process: ChildProcess) {
205+
if (this._onServerDataHandler) {
206+
process.stdout?.off('data', this._onServerDataHandler);
207+
}
208+
if (this._onServerErrorHandler) {
209+
process.stdout?.off('error', this._onServerErrorHandler);
210+
process.stdin?.off('error', this._onServerErrorHandler);
211+
}
212+
}
213+
204214
async close(): Promise<void> {
205215
if (this._process) {
216+
this.cleanupListeners(this._process);
206217
const processToClose = this._process;
207218
this._process = undefined;
208219

test/client/cross-spawn.test.ts

Lines changed: 27 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { StdioClientTransport, getDefaultEnvironment } from '../../src/client/st
22
import spawn from 'cross-spawn';
33
import { JSONRPCMessage } from '../../src/types.js';
44
import { ChildProcess } from 'node:child_process';
5-
import { Mock, MockedFunction } from 'vitest';
5+
import { Mock, MockedFunction, vi } from 'vitest';
66

77
// mock cross-spawn
88
vi.mock('cross-spawn');
@@ -14,8 +14,9 @@ describe('StdioClientTransport using cross-spawn', () => {
1414
mockSpawn.mockImplementation(() => {
1515
const mockProcess: {
1616
on: Mock;
17-
stdin?: { on: Mock; write: Mock };
18-
stdout?: { on: Mock };
17+
once: Mock;
18+
stdin?: { on: Mock; write: Mock; off: Mock };
19+
stdout?: { on: Mock; off: Mock };
1920
stderr?: null;
2021
} = {
2122
on: vi.fn((event: string, callback: () => void) => {
@@ -24,12 +25,20 @@ describe('StdioClientTransport using cross-spawn', () => {
2425
}
2526
return mockProcess;
2627
}),
28+
once: vi.fn((event: string, callback: () => void) => {
29+
if (event === 'spawn') {
30+
callback();
31+
}
32+
return mockProcess;
33+
}),
2734
stdin: {
2835
on: vi.fn(),
29-
write: vi.fn().mockReturnValue(true)
36+
write: vi.fn().mockReturnValue(true),
37+
off: vi.fn()
3038
},
3139
stdout: {
32-
on: vi.fn()
40+
on: vi.fn(),
41+
off: vi.fn()
3342
},
3443
stderr: null
3544
};
@@ -107,13 +116,16 @@ describe('StdioClientTransport using cross-spawn', () => {
107116
// get the mock process object
108117
const mockProcess: {
109118
on: Mock;
119+
once: Mock;
110120
stdin: {
111121
on: Mock;
112122
write: Mock;
113123
once: Mock;
124+
off: Mock;
114125
};
115126
stdout: {
116127
on: Mock;
128+
off: Mock;
117129
};
118130
stderr: null;
119131
} = {
@@ -123,13 +135,21 @@ describe('StdioClientTransport using cross-spawn', () => {
123135
}
124136
return mockProcess;
125137
}),
138+
once: vi.fn((event: string, callback: () => void) => {
139+
if (event === 'spawn') {
140+
callback();
141+
}
142+
return mockProcess;
143+
}),
126144
stdin: {
127145
on: vi.fn(),
128146
write: vi.fn().mockReturnValue(true),
129-
once: vi.fn()
147+
once: vi.fn(),
148+
off: vi.fn()
130149
},
131150
stdout: {
132-
on: vi.fn()
151+
on: vi.fn(),
152+
off: vi.fn()
133153
},
134154
stderr: null
135155
};

0 commit comments

Comments
 (0)