Skip to content

Commit 33294e7

Browse files
committed
test(mcp): pin stderr flood does not block subprocess
Stub floods stderr with 2 MB before answering initialize and again on every tools/list. Externally bounded by wait_for so a regression fails the test fast (10s) rather than hanging the suite.
1 parent 56241e3 commit 33294e7

1 file changed

Lines changed: 59 additions & 0 deletions

File tree

tests/test_mcp.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)