Skip to content

Commit 730c932

Browse files
committed
fix: remove 'forever' cheat from race condition reproduction
The previous implementation allowed MCP_DEBUG_RACE_DELAY_STDIO=forever which would wait indefinitely - this was cheating by introducing a new bug rather than encouraging the existing race condition. Now the delays just use anyio.sleep() which demonstrates the race window exists, but due to cooperative multitasking, won't cause a permanent hang. When send() blocks, the event loop runs other tasks including the delayed one, so eventually everything completes (just slowly). The real issue #262 manifests under specific timing/scheduling conditions (often in WSL) where the event loop behaves differently. The minimal reproduction in reproduce_262.py uses short timeouts to prove the race window exists. Github-Issue: #262
1 parent f6bd35b commit 730c932

File tree

3 files changed

+41
-47
lines changed

3 files changed

+41
-47
lines changed

client_262.py

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,11 @@
1010
Normal run (should work):
1111
python client_262.py
1212
13-
Reproduce the bug with GUARANTEED hang (use 'forever'):
14-
MCP_DEBUG_RACE_DELAY_STDIO=forever python client_262.py
13+
With delay to observe the race window:
14+
MCP_DEBUG_RACE_DELAY_STDIO=2.0 python client_262.py
1515
16-
Or with a timed delay (may or may not hang depending on timing):
17-
MCP_DEBUG_RACE_DELAY_STDIO=0.5 python client_262.py
18-
19-
You can also delay the session receive loop:
20-
MCP_DEBUG_RACE_DELAY_SESSION=forever python client_262.py
21-
22-
Or both for maximum effect:
23-
MCP_DEBUG_RACE_DELAY_STDIO=forever MCP_DEBUG_RACE_DELAY_SESSION=forever python client_262.py
16+
With delay in session receive loop:
17+
MCP_DEBUG_RACE_DELAY_SESSION=2.0 python client_262.py
2418
2519
EXPLANATION:
2620
@@ -29,10 +23,19 @@
2923
1. stdio_client creates zero-capacity memory streams (capacity=0)
3024
2. stdio_client starts stdin_writer task with start_soon() (not awaited)
3125
3. When client calls send_request(), it sends to the write_stream
32-
4. If stdin_writer hasn't reached its receive loop yet, send() blocks forever
26+
4. If stdin_writer hasn't reached its receive loop yet, send() blocks
27+
28+
IMPORTANT: Due to Python's cooperative multitasking, when send() blocks on a
29+
zero-capacity stream, it yields control to the event loop, which then runs
30+
the delayed task. So with simple delays, the client will be SLOW but won't
31+
hang permanently.
3332
34-
The environment variables inject delays at the start of the background tasks,
35-
widening the race window to make the bug reliably reproducible.
33+
The REAL issue #262 manifests under specific timing conditions (often in WSL)
34+
where the event loop scheduling behaves differently. The delays here demonstrate
35+
that the race WINDOW exists, even if cooperative multitasking prevents a
36+
permanent hang in most cases.
37+
38+
For a true reproduction showing the blocking behavior, see: reproduce_262.py
3639
3740
See: https://github.com/modelcontextprotocol/python-sdk/issues/262
3841
"""
@@ -63,13 +66,14 @@ async def main() -> None:
6366
if session_delay:
6467
print(f" MCP_DEBUG_RACE_DELAY_SESSION = {session_delay}s")
6568
print()
66-
print("This should cause a hang/timeout due to the race condition!")
69+
print("Operations will be SLOW due to delays in background tasks.")
70+
print("(Won't hang permanently due to cooperative multitasking)")
6771
print()
6872
else:
6973
print("No debug delays - this should work normally.")
7074
print()
71-
print("To reproduce the bug, run with:")
72-
print(" MCP_DEBUG_RACE_DELAY_STDIO=forever python client_262.py")
75+
print("To observe the race window, run with:")
76+
print(" MCP_DEBUG_RACE_DELAY_STDIO=2.0 python client_262.py")
7377
print()
7478

7579
# Server parameters - run server_262.py
@@ -115,17 +119,18 @@ async def main() -> None:
115119
except TimeoutError:
116120
print()
117121
print("=" * 70)
118-
print("TIMEOUT! The client hung - race condition reproduced!")
122+
print("TIMEOUT! Operations took too long.")
119123
print("=" * 70)
120124
print()
121-
print("This is issue #262: The race condition caused a deadlock.")
122-
print()
123-
print("Root cause:")
125+
print("This demonstrates the race window in issue #262:")
124126
print(" - Zero-capacity streams require sender and receiver to rendezvous")
125127
print(" - Background tasks (stdin_writer) are started with start_soon()")
126-
print(" - If send_request() runs before stdin_writer is ready, it blocks forever")
128+
print(" - Delays in task startup cause send() to block")
129+
print()
130+
print("In the real bug, specific timing/scheduling conditions cause")
131+
print("tasks to never become ready, resulting in a permanent hang.")
127132
print()
128-
print("The injected delays widen this race window to make it reproducible.")
133+
print("See reproduce_262.py for a minimal reproduction with timeouts.")
129134
sys.exit(1)
130135

131136
except Exception as e:

src/mcp/client/stdio/__init__.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -167,21 +167,16 @@ async def stdin_writer():
167167
assert process.stdin, "Opened process is missing stdin"
168168

169169
# DEBUG: Inject delay to reproduce issue #262 race condition
170-
# This prevents stdin_writer from entering its receive loop, simulating
171-
# the scenario where the task isn't ready when send_request() is called.
172-
# Set MCP_DEBUG_RACE_DELAY_STDIO=<seconds> to enable (e.g., "0.1").
173-
# Set MCP_DEBUG_RACE_DELAY_STDIO=forever to wait indefinitely (guaranteed hang).
170+
# This delays stdin_writer from entering its receive loop, widening
171+
# the race window where send_request() might be called before the
172+
# task is ready. Set MCP_DEBUG_RACE_DELAY_STDIO=<seconds> to enable.
173+
#
174+
# NOTE: Due to cooperative multitasking, this delay won't cause a
175+
# permanent hang - when send() blocks, the event loop will eventually
176+
# run this task. But it demonstrates the race window exists.
174177
_race_delay = os.environ.get("MCP_DEBUG_RACE_DELAY_STDIO")
175178
if _race_delay:
176-
if _race_delay.lower() == "forever":
177-
# Wait forever - guarantees the race condition manifests
178-
never_ready = anyio.Event()
179-
await never_ready.wait()
180-
else:
181-
# Wait for specified duration - creates a race window
182-
# During this time, stdin_writer isn't ready to receive,
183-
# so any send() to write_stream will block
184-
await anyio.sleep(float(_race_delay))
179+
await anyio.sleep(float(_race_delay))
185180

186181
try:
187182
async with write_stream_reader:

src/mcp/shared/session.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -350,22 +350,16 @@ async def _send_response(self, request_id: RequestId, response: SendResultT | Er
350350

351351
async def _receive_loop(self) -> None:
352352
# DEBUG: Inject delay to reproduce issue #262 race condition
353-
# This prevents _receive_loop from entering its receive loop, simulating
354-
# the scenario where the task isn't ready when responses arrive.
355-
# Set MCP_DEBUG_RACE_DELAY_SESSION=<seconds> to enable (e.g., "0.1").
356-
# Set MCP_DEBUG_RACE_DELAY_SESSION=forever to wait indefinitely (guaranteed hang).
353+
# This delays _receive_loop from entering its receive loop, widening
354+
# the race window. Set MCP_DEBUG_RACE_DELAY_SESSION=<seconds> to enable.
355+
#
356+
# NOTE: Due to cooperative multitasking, this delay won't cause a
357+
# permanent hang - it just demonstrates the race window exists.
357358
import os
358359

359360
_race_delay = os.environ.get("MCP_DEBUG_RACE_DELAY_SESSION")
360361
if _race_delay:
361-
if _race_delay.lower() == "forever":
362-
# Wait forever - guarantees responses are never processed
363-
never_ready = anyio.Event()
364-
await never_ready.wait()
365-
else:
366-
# Wait for specified duration - creates a window where responses
367-
# might not be processed in time
368-
await anyio.sleep(float(_race_delay))
362+
await anyio.sleep(float(_race_delay))
369363

370364
async with (
371365
self._read_stream,

0 commit comments

Comments
 (0)