@@ -193,3 +193,62 @@ async def test_request_times_out_when_server_silent(silent_stub_path):
193193 await client .call_tool ("echo" , {"text" : "x" })
194194 finally :
195195 await client .close ()
196+
197+
198+ # A stub that floods stderr with 2 MB before answering initialize, then again
199+ # on every tools/list. Without a drain, the ~64KB pipe buffer + StreamReader
200+ # limit fills, the subprocess blocks on stderr.flush() and never gets to read
201+ # stdin — initialize never completes.
202+ STDERR_FLOOD_STUB_SCRIPT = '''
203+ import json, sys
204+ for _ in range(32):
205+ sys.stderr.write("x" * (64 * 1024))
206+ sys.stderr.flush()
207+ sys.stderr.write("\\ n")
208+ sys.stderr.flush()
209+ for line in sys.stdin:
210+ line = line.strip()
211+ if not line:
212+ continue
213+ req = json.loads(line)
214+ rid = req.get("id")
215+ method = req.get("method")
216+ if method == "initialize":
217+ sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": {}}) + "\\ n")
218+ sys.stdout.flush()
219+ elif method == "tools/list":
220+ for _ in range(32):
221+ sys.stderr.write("y" * (64 * 1024))
222+ sys.stderr.flush()
223+ sys.stderr.write("\\ n"); sys.stderr.flush()
224+ sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": {"tools": []}}) + "\\ n")
225+ sys.stdout.flush()
226+ '''
227+
228+
229+ @pytest .fixture
230+ def stderr_flood_stub_path ():
231+ fd , path = tempfile .mkstemp (suffix = "_stderr_flood_stub.py" )
232+ with os .fdopen (fd , "w" ) as f :
233+ f .write (STDERR_FLOOD_STUB_SCRIPT )
234+ yield path
235+ os .unlink (path )
236+
237+
238+ @pytest .mark .asyncio
239+ async def test_stderr_flood_does_not_block_subprocess (stderr_flood_stub_path ):
240+ # Regression: stderr=PIPE without a consumer used to fill the pipe buffer
241+ # (+ asyncio StreamReader limit), pause the transport, and force the child
242+ # to block on stderr.flush() — no more stdin reads, no more stdout writes,
243+ # every request timed out. The drain task must consume stderr as it comes
244+ # in so the subprocess stays responsive.
245+ client = MCPClient ([sys .executable , stderr_flood_stub_path ])
246+ # Bounded externally: without the drain this would hang past init_timeout,
247+ # so an outer wait_for guarantees the test fails fast rather than
248+ # slowing the suite.
249+ await asyncio .wait_for (client .start (init_timeout = 8.0 ), timeout = 10.0 )
250+ try :
251+ tools = await asyncio .wait_for (client .list_tools (), timeout = 8.0 )
252+ assert tools == []
253+ finally :
254+ await client .close ()
0 commit comments