Skip to content

Commit 6343c7a

Browse files
committed
Add minimal standalone reproduction for issue #262
Single-file reproduction that demonstrates the race condition causing call_tool() to hang. Run with: python reproduce_262.py Output shows: 1. The bug reproduction (send blocks because receiver isn't ready) 2. Fix #1: Using buffer > 0 works 3. Fix #2: Using await tg.start() works No dependencies required beyond anyio. Github-Issue: #262
1 parent 46b7bcb commit 6343c7a

File tree

1 file changed

+179
-0
lines changed

1 file changed

+179
-0
lines changed

reproduce_262.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Minimal reproduction of issue #262: MCP Client Tool Call Hang
4+
5+
This script demonstrates the race condition that causes call_tool() to hang.
6+
Run with: python reproduce_262.py
7+
8+
The bug is caused by:
9+
1. Zero-capacity memory streams (anyio.create_memory_object_stream(0))
10+
2. Tasks started with start_soon() (not awaited)
11+
3. Immediate send after context manager enters
12+
13+
When the receiver task hasn't started yet, send() blocks forever.
14+
15+
See: https://github.com/modelcontextprotocol/python-sdk/issues/262
16+
"""
17+
18+
import anyio
19+
20+
21+
async def demonstrate_bug():
22+
"""Reproduce the exact race condition that causes issue #262."""
23+
24+
print("=" * 60)
25+
print("Issue #262 Reproduction: Zero-buffer + start_soon race condition")
26+
print("=" * 60)
27+
28+
# Create zero-capacity stream - sender blocks until receiver is ready
29+
sender, receiver = anyio.create_memory_object_stream[str](0)
30+
31+
receiver_started = False
32+
33+
async def delayed_receiver():
34+
nonlocal receiver_started
35+
# Simulate the delay that occurs in real code when tasks
36+
# are scheduled with start_soon but haven't started yet
37+
await anyio.sleep(0.05) # 50ms delay
38+
receiver_started = True
39+
async with receiver:
40+
async for item in receiver:
41+
print(f" Received: {item}")
42+
return
43+
44+
print("\n1. Creating zero-capacity stream (like stdio_client lines 117-118)")
45+
print("2. Starting receiver with start_soon (like stdio_client lines 186-187)")
46+
print("3. Immediately trying to send (like session.send_request)")
47+
print()
48+
49+
async with anyio.create_task_group() as tg:
50+
# Start receiver with start_soon - NOT awaited!
51+
# This is exactly what stdio_client does
52+
tg.start_soon(delayed_receiver)
53+
54+
# Try to send immediately - receiver is delayed 50ms
55+
async with sender:
56+
print("Attempting to send...")
57+
print(f" Receiver started yet? {receiver_started}")
58+
59+
try:
60+
# Only wait 20ms - less than the 50ms receiver delay
61+
with anyio.fail_after(0.02):
62+
await sender.send("Hello")
63+
print(" Send completed (receiver was fast)")
64+
except TimeoutError:
65+
print()
66+
print(" *** REPRODUCTION SUCCESSFUL! ***")
67+
print(" Send BLOCKED because receiver wasn't ready!")
68+
print(f" Receiver started: {receiver_started}")
69+
print()
70+
print(" This is EXACTLY what happens in issue #262:")
71+
print(" - call_tool() sends a request")
72+
print(" - The receive loop hasn't started yet")
73+
print(" - Send blocks forever on the zero-capacity stream")
74+
print()
75+
76+
# Cancel to clean up
77+
tg.cancel_scope.cancel()
78+
79+
80+
async def demonstrate_fix_buffer():
81+
"""Show that using buffer > 0 fixes the issue."""
82+
83+
print("\n" + "=" * 60)
84+
print("FIX #1: Use buffer size > 0")
85+
print("=" * 60)
86+
87+
# Buffer size 1 instead of 0
88+
sender, receiver = anyio.create_memory_object_stream[str](1)
89+
90+
async def delayed_receiver():
91+
await anyio.sleep(0.05) # Same 50ms delay
92+
async with receiver:
93+
async for item in receiver:
94+
print(f" Received: {item}")
95+
return
96+
97+
async with anyio.create_task_group() as tg:
98+
tg.start_soon(delayed_receiver)
99+
100+
async with sender:
101+
print("Attempting to send with buffer=1...")
102+
try:
103+
with anyio.fail_after(0.01): # Only 10ms timeout
104+
await sender.send("Hello")
105+
print(" SUCCESS! Send completed immediately")
106+
print(" Buffer allows send without blocking on receiver")
107+
except TimeoutError:
108+
print(" Still blocked (unexpected)")
109+
110+
111+
async def demonstrate_fix_start():
112+
"""Show that using start() instead of start_soon() fixes the issue."""
113+
114+
print("\n" + "=" * 60)
115+
print("FIX #2: Use await tg.start() instead of tg.start_soon()")
116+
print("=" * 60)
117+
118+
sender, receiver = anyio.create_memory_object_stream[str](0)
119+
120+
async def receiver_with_signal(*, task_status=anyio.TASK_STATUS_IGNORED):
121+
# Signal that we're ready BEFORE starting to receive
122+
task_status.started()
123+
async with receiver:
124+
async for item in receiver:
125+
print(f" Received: {item}")
126+
return
127+
128+
async with anyio.create_task_group() as tg:
129+
# Use start() - this WAITS for task_status.started()
130+
await tg.start(receiver_with_signal)
131+
132+
async with sender:
133+
print("Attempting to send after start()...")
134+
try:
135+
with anyio.fail_after(0.01):
136+
await sender.send("Hello")
137+
print(" SUCCESS! Send completed immediately")
138+
print(" start() ensures receiver is ready before continuing")
139+
except TimeoutError:
140+
print(" Still blocked (unexpected)")
141+
142+
143+
async def main():
144+
print("""
145+
╔══════════════════════════════════════════════════════════════╗
146+
║ Issue #262: MCP Client Tool Call Hang - Minimal Reproduction ║
147+
╚══════════════════════════════════════════════════════════════╝
148+
""")
149+
150+
try:
151+
await demonstrate_bug()
152+
except anyio.get_cancelled_exc_class():
153+
pass
154+
155+
await demonstrate_fix_buffer()
156+
await demonstrate_fix_start()
157+
158+
print("\n" + "=" * 60)
159+
print("CONCLUSION")
160+
print("=" * 60)
161+
print("""
162+
The bug in stdio_client (src/mcp/client/stdio/__init__.py):
163+
164+
Lines 117-118:
165+
read_stream_writer, read_stream = anyio.create_memory_object_stream(0) # BUG: 0!
166+
write_stream, write_stream_reader = anyio.create_memory_object_stream(0) # BUG: 0!
167+
168+
Lines 186-187:
169+
tg.start_soon(stdout_reader) # BUG: Not awaited!
170+
tg.start_soon(stdin_writer) # BUG: Not awaited!
171+
172+
FIX OPTIONS:
173+
1. Change buffer from 0 to 1: anyio.create_memory_object_stream(1)
174+
2. Use await tg.start() instead of tg.start_soon()
175+
""")
176+
177+
178+
if __name__ == "__main__":
179+
anyio.run(main)

0 commit comments

Comments
 (0)