Skip to content

Commit ace9b62

Browse files
mattzcareyjohnnyasantossTomasRup
authored
fix hanging stdio servers (#1200)
Co-authored-by: Johnny Santos <johnnyadsantos@gmail.com> Co-authored-by: TomasRup <tomasr@wix.com>
1 parent 5aaf6ee commit ace9b62

15 files changed

+220
-47
lines changed
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { setInterval } from 'node:timers';
2+
import process from 'node:process';
3+
import { McpServer } from '../server/mcp.js';
4+
import { StdioServerTransport } from '../server/stdio.js';
5+
6+
const transport = new StdioServerTransport();
7+
8+
const server = new McpServer(
9+
{
10+
name: 'server-that-hangs',
11+
title: 'Test Server that hangs',
12+
version: '1.0.0'
13+
},
14+
{
15+
capabilities: {
16+
logging: {}
17+
}
18+
}
19+
);
20+
21+
await server.connect(transport);
22+
23+
// Keep process alive even after stdin closes
24+
const keepAlive = setInterval(() => {}, 60_000);
25+
26+
// Prevent transport close from exiting
27+
transport.onclose = () => {
28+
// Intentionally ignore - we want to test the signal handling
29+
};
30+
31+
const doNotExitImmediately = async (signal: NodeJS.Signals) => {
32+
await server.sendLoggingMessage({
33+
level: 'debug',
34+
data: `received signal ${signal}`
35+
});
36+
// Clear keepalive but delay exit to simulate slow shutdown
37+
clearInterval(keepAlive);
38+
setInterval(() => {}, 30_000);
39+
};
40+
41+
process.on('SIGINT', doNotExitImmediately);
42+
process.on('SIGTERM', doNotExitImmediately);

src/__fixtures__/testServer.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { McpServer } from '../server/mcp.js';
2+
import { StdioServerTransport } from '../server/stdio.js';
3+
4+
const transport = new StdioServerTransport();
5+
6+
const server = new McpServer({
7+
name: 'test-server',
8+
version: '1.0.0'
9+
});
10+
11+
await server.connect(transport);
12+
13+
const exit = async () => {
14+
await server.close();
15+
process.exit(0);
16+
};
17+
18+
process.on('SIGINT', exit);
19+
process.on('SIGTERM', exit);
File renamed without changes.

src/client/stdio.ts

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,6 @@ export function getDefaultEnvironment(): Record<string, string> {
9191
*/
9292
export class StdioClientTransport implements Transport {
9393
private _process?: ChildProcess;
94-
private _abortController: AbortController = new AbortController();
9594
private _readBuffer: ReadBuffer = new ReadBuffer();
9695
private _serverParams: StdioServerParameters;
9796
private _stderrStream: PassThrough | null = null;
@@ -126,18 +125,11 @@ export class StdioClientTransport implements Transport {
126125
},
127126
stdio: ['pipe', 'pipe', this._serverParams.stderr ?? 'inherit'],
128127
shell: false,
129-
signal: this._abortController.signal,
130128
windowsHide: process.platform === 'win32' && isElectron(),
131129
cwd: this._serverParams.cwd
132130
});
133131

134132
this._process.on('error', error => {
135-
if (error.name === 'AbortError') {
136-
// Expected when close() is called.
137-
this.onclose?.();
138-
return;
139-
}
140-
141133
reject(error);
142134
this.onerror?.(error);
143135
});
@@ -210,8 +202,43 @@ export class StdioClientTransport implements Transport {
210202
}
211203

212204
async close(): Promise<void> {
213-
this._abortController.abort();
214-
this._process = undefined;
205+
if (this._process) {
206+
const processToClose = this._process;
207+
this._process = undefined;
208+
209+
const closePromise = new Promise<void>(resolve => {
210+
processToClose.once('close', () => {
211+
resolve();
212+
});
213+
});
214+
215+
try {
216+
processToClose.stdin?.end();
217+
} catch {
218+
// ignore
219+
}
220+
221+
await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2_000).unref())]);
222+
223+
if (processToClose.exitCode === null) {
224+
try {
225+
processToClose.kill('SIGTERM');
226+
} catch {
227+
// ignore
228+
}
229+
230+
await Promise.race([closePromise, new Promise(resolve => setTimeout(resolve, 2_000).unref())]);
231+
}
232+
233+
if (processToClose.exitCode === null) {
234+
try {
235+
processToClose.kill('SIGKILL');
236+
} catch {
237+
// ignore
238+
}
239+
}
240+
}
241+
215242
this._readBuffer.clear();
216243
}
217244

src/integration-tests/process-cleanup.test.ts

Lines changed: 0 additions & 28 deletions
This file was deleted.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import path from 'node:path';
2+
import { Readable, Writable } from 'node:stream';
3+
import { Client } from '../client/index.js';
4+
import { StdioClientTransport } from '../client/stdio.js';
5+
import { Server } from '../server/index.js';
6+
import { StdioServerTransport } from '../server/stdio.js';
7+
import { LoggingMessageNotificationSchema } from '../types.js';
8+
9+
const FIXTURES_DIR = path.resolve(__dirname, '../__fixtures__');
10+
11+
describe('Process cleanup', () => {
12+
vi.setConfig({ testTimeout: 5000 }); // 5 second timeout
13+
14+
it('server should exit cleanly after closing transport', async () => {
15+
const server = new Server(
16+
{
17+
name: 'test-server',
18+
version: '1.0.0'
19+
},
20+
{
21+
capabilities: {}
22+
}
23+
);
24+
25+
const mockReadable = new Readable({
26+
read() {
27+
this.push(null); // signal EOF
28+
}
29+
}),
30+
mockWritable = new Writable({
31+
write(chunk, encoding, callback) {
32+
callback();
33+
}
34+
});
35+
36+
// Attach mock streams to process for the server transport
37+
const transport = new StdioServerTransport(mockReadable, mockWritable);
38+
await server.connect(transport);
39+
40+
// Close the transport
41+
await transport.close();
42+
43+
// ensure a proper disposal mock streams
44+
mockReadable.destroy();
45+
mockWritable.destroy();
46+
47+
// If we reach here without hanging, the test passes
48+
// The test runner will fail if the process hangs
49+
expect(true).toBe(true);
50+
});
51+
52+
it('onclose should be called exactly once', async () => {
53+
const client = new Client({
54+
name: 'test-client',
55+
version: '1.0.0'
56+
});
57+
58+
const transport = new StdioClientTransport({
59+
command: 'node',
60+
args: ['--import', 'tsx', 'testServer.ts'],
61+
cwd: FIXTURES_DIR
62+
});
63+
64+
await client.connect(transport);
65+
66+
let onCloseWasCalled = 0;
67+
client.onclose = () => {
68+
onCloseWasCalled++;
69+
};
70+
71+
await client.close();
72+
73+
// A short delay to allow the close event to propagate
74+
await new Promise(resolve => setTimeout(resolve, 50));
75+
76+
expect(onCloseWasCalled).toBe(1);
77+
});
78+
79+
it('should exit cleanly for a server that hangs', async () => {
80+
const client = new Client({
81+
name: 'test-client',
82+
version: '1.0.0'
83+
});
84+
85+
const transport = new StdioClientTransport({
86+
command: 'node',
87+
args: ['--import', 'tsx', 'serverThatHangs.ts'],
88+
cwd: FIXTURES_DIR
89+
});
90+
91+
await client.connect(transport);
92+
await client.setLoggingLevel('debug');
93+
client.setNotificationHandler(LoggingMessageNotificationSchema, notification => {
94+
console.debug('server log: ' + notification.params.data);
95+
});
96+
const serverPid = transport.pid!;
97+
98+
await client.close();
99+
100+
// A short delay to allow the close event to propagate
101+
await new Promise(resolve => setTimeout(resolve, 50));
102+
103+
try {
104+
process.kill(serverPid, 9);
105+
throw new Error('Expected server to be dead but it is alive');
106+
} catch (err: unknown) {
107+
// 'ESRCH' the process doesn't exist
108+
if (err && typeof err === 'object' && 'code' in err && err.code === 'ESRCH') {
109+
// success
110+
} else throw err;
111+
}
112+
});
113+
});

src/integration-tests/stateManagementStreamableHttp.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
ListPromptsResultSchema,
1313
LATEST_PROTOCOL_VERSION
1414
} from '../types.js';
15-
import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js';
15+
import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js';
1616

1717
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
1818
const { z } = entry;

src/integration-tests/taskResumability.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { McpServer } from '../server/mcp.js';
77
import { StreamableHTTPServerTransport } from '../server/streamableHttp.js';
88
import { CallToolResultSchema, LoggingMessageNotificationSchema } from '../types.js';
99
import { InMemoryEventStore } from '../examples/shared/inMemoryEventStore.js';
10-
import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js';
10+
import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js';
1111

1212
describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => {
1313
const { z } = entry;

src/server/completable.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { completable, getCompleter } from './completable.js';
2-
import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js';
2+
import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js';
33

44
describe.each(zodTestMatrix)('completable with $zodVersionLabel', (entry: ZodMatrixEntry) => {
55
const { z } = entry;

src/server/mcp.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
import { completable } from './completable.js';
2323
import { McpServer, ResourceTemplate } from './mcp.js';
2424
import { InMemoryTaskStore } from '../experimental/tasks/stores/in-memory.js';
25-
import { zodTestMatrix, type ZodMatrixEntry } from '../shared/zodTestMatrix.js';
25+
import { zodTestMatrix, type ZodMatrixEntry } from '../__fixtures__/zodTestMatrix.js';
2626

2727
function createLatch() {
2828
let latch = false;

0 commit comments

Comments
 (0)