From 32c187347527d5d80b887c327cb6089deb967a37 Mon Sep 17 00:00:00 2001 From: Tiago Vilas Boas Date: Fri, 11 Sep 2026 14:46:12 +0000 Subject: [PATCH 1/4] fix(client): drain piped stderr so unread pipe cannot deadlock Put the stderr PassThrough in flowing mode after piping so a chatty child cannot fill the unread pipe and hang the session. Listeners attached before start() still receive chunks. Fixes #2776 Co-authored-by: Tiago Vilas Boas --- packages/client/src/client/stdio.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/client/src/client/stdio.ts b/packages/client/src/client/stdio.ts index a4664e1c93..8efb701ec2 100644 --- a/packages/client/src/client/stdio.ts +++ b/packages/client/src/client/stdio.ts @@ -29,6 +29,12 @@ export type StdioServerParameters = { * How to handle stderr of the child process. This matches the semantics of Node's `child_process.spawn`. * * The default is `"inherit"`, meaning messages to stderr will be printed to the parent process's stderr. + * + * When set to `"pipe"` or `"overlapped"`, stderr is exposed on + * {@linkcode StdioClientTransport.stderr}. The SDK drains that stream so an + * unread pipe cannot fill and deadlock the session. Attach a `data` listener + * (or `.pipe()` it) before {@linkcode StdioClientTransport.start} / `Client.connect` + * to receive every chunk; without a listener the bytes are discarded. */ stderr?: IOType | Stream | number; @@ -173,6 +179,10 @@ export class StdioClientTransport implements Transport { if (this._stderrStream && this._process.stderr) { this._process.stderr.pipe(this._stderrStream); + // Flowing mode discards unread chunks so a chatty child cannot + // fill the PassThrough (16 KiB highWaterMark) and block on + // write(2). Listeners attached before start() still receive data. + this._stderrStream.resume(); } }); } @@ -183,6 +193,13 @@ export class StdioClientTransport implements Transport { * If `stderr` piping was requested, a `PassThrough` stream is returned _immediately_, allowing callers to * attach listeners before the `start` method is invoked. This prevents loss of any early * error output emitted by the child process. + * + * After `start()`, the SDK puts this stream in flowing mode so piping is safe without a + * consumer — unread stderr is drained and cannot deadlock the session. A listener (or + * `.pipe()` destination) attached before `start()` still receives every chunk. A late + * listener sees only data written after it attached; bytes already drained are not + * replayed. The paused-mode API (`read()` in a loop) is not supported once `start()` + * has put the stream in flowing mode — use `data` events or `.pipe()`. */ get stderr(): Stream | null { if (this._stderrStream) { From 4b54afb0f9d45a4fa7a29decc6beddbba747a343 Mon Sep 17 00:00:00 2001 From: Tiago Vilas Boas Date: Fri, 11 Sep 2026 14:46:12 +0000 Subject: [PATCH 2/4] test(client): cover unread and late-attached stderr pipe Assert listTools() completes when stderr is piped with no reader, that a pre-start listener still receives chunks, and that a late listener does not deadlock and sees only post-attach output. Co-authored-by: Tiago Vilas Boas --- packages/client/test/client/stdio.test.ts | 105 ++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/packages/client/test/client/stdio.test.ts b/packages/client/test/client/stdio.test.ts index 315b8a2595..b28a143fa3 100644 --- a/packages/client/test/client/stdio.test.ts +++ b/packages/client/test/client/stdio.test.ts @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'; import type { JSONRPCMessage } from '@modelcontextprotocol/core-internal'; +import { Client } from '../../src/client/client'; import type { StdioServerParameters } from '../../src/client/stdio'; import { StdioClientTransport } from '../../src/client/stdio'; @@ -150,3 +151,107 @@ test('_dispose releases the parent-side pipe handles even when a helper process expect(proc.stdout?.destroyed).toBe(true); expect(proc.stdin?.destroyed).toBe(true); }, 10_000); + +/** Unique markers so late-attach tests can tell drained startup bytes from post-request bytes. */ +const STDERR_STARTUP_MARKER = 'STDERR_STARTUP_MARKER'; +const STDERR_POST_REQUEST_MARKER = 'STDERR_POST_REQUEST_MARKER'; + +/** + * Minimal MCP server that floods stderr on every post-handshake request. + * Enough writes to fill a paused PassThrough (16 KiB) and the OS pipe (~64 KiB) + * so an undrained `stderr: 'pipe'` would block the child on write(2). + */ +function chattyStderrServerScript(): string { + return ` + const { createInterface } = require('readline'); + const send = (o) => process.stdout.write(JSON.stringify(o) + '\\n'); + process.stderr.write(${JSON.stringify(`${STDERR_STARTUP_MARKER}\n`)}); + createInterface({ input: process.stdin }).on('line', (line) => { + const m = JSON.parse(line); + if (m.method === 'initialize') { + return send({ + jsonrpc: '2.0', + id: m.id, + result: { + protocolVersion: '2025-06-18', + capabilities: { tools: {} }, + serverInfo: { name: 'chatty', version: '1.0.0' } + } + }); + } + if (m.method === 'notifications/initialized') return; + for (let i = 0; i < 200; i++) process.stderr.write('log line '.repeat(5000) + '\\n'); + process.stderr.write(${JSON.stringify(`${STDERR_POST_REQUEST_MARKER}\n`)}); + send({ + jsonrpc: '2.0', + id: m.id, + result: { tools: [{ name: 'x', description: 'd', inputSchema: { type: 'object' } }] } + }); + }); + `; +} + +function chattyStderrTransport(): StdioClientTransport { + return new StdioClientTransport({ + command: process.execPath, + args: ['-e', chattyStderrServerScript()], + stderr: 'pipe' + }); +} + +test('piped stderr without a reader does not deadlock listTools', async () => { + const transport = chattyStderrTransport(); + const client = new Client({ name: 'demo', version: '1.0.0' }); + try { + await client.connect(transport); + const result = await client.listTools(undefined, { timeout: 5_000 }); + expect(result.tools).toEqual([{ name: 'x', description: 'd', inputSchema: { type: 'object' } }]); + } finally { + await client.close(); + } +}, 8_000); + +test('piped stderr listener attached before start still receives chunks', async () => { + const transport = chattyStderrTransport(); + const stderr = transport.stderr; + expect(stderr).not.toBeNull(); + let captured = ''; + stderr!.on('data', (chunk: Buffer) => { + captured += chunk.toString(); + }); + + const client = new Client({ name: 'demo', version: '1.0.0' }); + try { + await client.connect(transport); + const result = await client.listTools(undefined, { timeout: 5_000 }); + expect(result.tools).toHaveLength(1); + expect(captured).toContain(STDERR_STARTUP_MARKER); + expect(captured).toContain(STDERR_POST_REQUEST_MARKER); + } finally { + await client.close(); + } +}, 8_000); + +test('late stderr listener does not deadlock and sees only post-attach chunks', async () => { + const transport = chattyStderrTransport(); + const client = new Client({ name: 'demo', version: '1.0.0' }); + try { + await client.connect(transport); + // Flowing-mode drain of startup stderr needs a turn to settle before we attach. + await new Promise(resolve => setImmediate(resolve)); + + const stderr = transport.stderr; + expect(stderr).not.toBeNull(); + let captured = ''; + stderr!.on('data', (chunk: Buffer) => { + captured += chunk.toString(); + }); + + const result = await client.listTools(undefined, { timeout: 5_000 }); + expect(result.tools).toHaveLength(1); + expect(captured).not.toContain(STDERR_STARTUP_MARKER); + expect(captured).toContain(STDERR_POST_REQUEST_MARKER); + } finally { + await client.close(); + } +}, 8_000); From e251e6d01cf1e87c90345ba3af1f70d1d3148100 Mon Sep 17 00:00:00 2001 From: Tiago Vilas Boas Date: Fri, 11 Sep 2026 14:46:12 +0000 Subject: [PATCH 3/4] docs(client): add changeset for stderr pipe drain Co-authored-by: Tiago Vilas Boas --- .changeset/drain-stdio-stderr-pipe.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/drain-stdio-stderr-pipe.md diff --git a/.changeset/drain-stdio-stderr-pipe.md b/.changeset/drain-stdio-stderr-pipe.md new file mode 100644 index 0000000000..43f055d092 --- /dev/null +++ b/.changeset/drain-stdio-stderr-pipe.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Drain piped stdio stderr so an unread `stderr: 'pipe'` stream cannot fill and deadlock the session. Listeners attached before `start()` / `connect()` still receive every chunk. Fixes #2776. From 910cb4541fbf21a0cdc4935299608d3a4fd04bb0 Mon Sep 17 00:00:00 2001 From: Tiago Vilas Boas Date: Fri, 11 Sep 2026 14:48:56 +0000 Subject: [PATCH 4/4] test(client): wait for stderr markers after listTools Avoid racing stdout completion against flowing-mode stderr delivery, and keep flood size large enough to fill a paused pipe without dumping megabytes into assertion output. Co-authored-by: Tiago Vilas Boas --- packages/client/test/client/stdio.test.ts | 46 ++++++++++++++--------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/client/test/client/stdio.test.ts b/packages/client/test/client/stdio.test.ts index b28a143fa3..8481d514ad 100644 --- a/packages/client/test/client/stdio.test.ts +++ b/packages/client/test/client/stdio.test.ts @@ -162,9 +162,9 @@ const STDERR_POST_REQUEST_MARKER = 'STDERR_POST_REQUEST_MARKER'; * so an undrained `stderr: 'pipe'` would block the child on write(2). */ function chattyStderrServerScript(): string { - return ` + return String.raw` const { createInterface } = require('readline'); - const send = (o) => process.stdout.write(JSON.stringify(o) + '\\n'); + const send = (o) => process.stdout.write(JSON.stringify(o) + '\n'); process.stderr.write(${JSON.stringify(`${STDERR_STARTUP_MARKER}\n`)}); createInterface({ input: process.stdin }).on('line', (line) => { const m = JSON.parse(line); @@ -180,7 +180,7 @@ function chattyStderrServerScript(): string { }); } if (m.method === 'notifications/initialized') return; - for (let i = 0; i < 200; i++) process.stderr.write('log line '.repeat(5000) + '\\n'); + for (let i = 0; i < 256; i++) process.stderr.write('x'.repeat(512) + '\n'); process.stderr.write(${JSON.stringify(`${STDERR_POST_REQUEST_MARKER}\n`)}); send({ jsonrpc: '2.0', @@ -199,38 +199,50 @@ function chattyStderrTransport(): StdioClientTransport { }); } +/** stdout can settle before every flowing-mode stderr chunk is delivered. */ +async function waitForStderrMarker(captured: { text: string }, marker: string): Promise { + await vi.waitFor( + () => { + if (!captured.text.includes(marker)) { + throw new Error(`stderr has not yet included ${marker}`); + } + }, + { timeout: 3000, interval: 10 } + ); +} + test('piped stderr without a reader does not deadlock listTools', async () => { const transport = chattyStderrTransport(); const client = new Client({ name: 'demo', version: '1.0.0' }); try { await client.connect(transport); - const result = await client.listTools(undefined, { timeout: 5_000 }); + const result = await client.listTools(undefined, { timeout: 5000 }); expect(result.tools).toEqual([{ name: 'x', description: 'd', inputSchema: { type: 'object' } }]); } finally { await client.close(); } -}, 8_000); +}, 8000); test('piped stderr listener attached before start still receives chunks', async () => { const transport = chattyStderrTransport(); const stderr = transport.stderr; expect(stderr).not.toBeNull(); - let captured = ''; + const captured = { text: '' }; stderr!.on('data', (chunk: Buffer) => { - captured += chunk.toString(); + captured.text += chunk.toString(); }); const client = new Client({ name: 'demo', version: '1.0.0' }); try { await client.connect(transport); - const result = await client.listTools(undefined, { timeout: 5_000 }); + await waitForStderrMarker(captured, STDERR_STARTUP_MARKER); + const result = await client.listTools(undefined, { timeout: 5000 }); expect(result.tools).toHaveLength(1); - expect(captured).toContain(STDERR_STARTUP_MARKER); - expect(captured).toContain(STDERR_POST_REQUEST_MARKER); + await waitForStderrMarker(captured, STDERR_POST_REQUEST_MARKER); } finally { await client.close(); } -}, 8_000); +}, 8000); test('late stderr listener does not deadlock and sees only post-attach chunks', async () => { const transport = chattyStderrTransport(); @@ -242,16 +254,16 @@ test('late stderr listener does not deadlock and sees only post-attach chunks', const stderr = transport.stderr; expect(stderr).not.toBeNull(); - let captured = ''; + const captured = { text: '' }; stderr!.on('data', (chunk: Buffer) => { - captured += chunk.toString(); + captured.text += chunk.toString(); }); - const result = await client.listTools(undefined, { timeout: 5_000 }); + const result = await client.listTools(undefined, { timeout: 5000 }); expect(result.tools).toHaveLength(1); - expect(captured).not.toContain(STDERR_STARTUP_MARKER); - expect(captured).toContain(STDERR_POST_REQUEST_MARKER); + await waitForStderrMarker(captured, STDERR_POST_REQUEST_MARKER); + expect(captured.text.includes(STDERR_STARTUP_MARKER)).toBe(false); } finally { await client.close(); } -}, 8_000); +}, 8000);