Skip to content

Commit 157a3e8

Browse files
fix(stdio): resolve BrokenResourceError race condition on quick exit
ROOT CAUSE: The stdio_client async context manager had a race condition when exiting quickly (before subprocess finished outputting data). The cleanup code in the finally block closed memory streams while background tasks (stdout_reader and stdin_writer) were still using them, resulting in BrokenResourceError. Timeline of the bug: 1. User code exits the async with stdio_client(...) context 2. The finally block executes 3. Streams are closed immediately 4. Background tasks are still running and trying to send/receive data 5. Tasks encounter closed streams → BrokenResourceError CHANGES: 1. Added BrokenResourceError to exception handlers in both client and server - src/mcp/client/stdio.py: Lines 161, 177 (stdout_reader, stdin_writer) - src/mcp/server/stdio.py: Lines 67, 77 (stdin_reader, stdout_writer) - This allows tasks to exit gracefully if streams close during operation 2. Added task cancellation before stream closure in client transport - src/mcp/client/stdio.py: Line 210 (after process cleanup) - tg.cancel_scope.cancel() sends cancellation signal to background tasks - Tasks receive signal and finish their current operation - Then streams are closed (tasks aren't using them anymore) 3. Added regression test - tests/client/test_stdio.py: test_stdio_client_quick_exit_race_condition - Verifies that quick context exits don't cause ExceptionGroup crashes IMPACT: - No more ExceptionGroup crashes when exiting quickly - Graceful task shutdown with proper cancellation - Backward compatible - all existing tests pass - Better resource cleanup - tasks finish before streams close TECHNICAL NOTES: - Server transport only needed exception handler changes (not task cancellation) because it doesn't manage subprocess lifecycle - The fix uses defense-in-depth: both proper coordination AND graceful handling - anyio.BrokenResourceError is raised when operations are attempted on closed resources, distinct from ClosedResourceError (resource already closed) FILES MODIFIED: - src/mcp/client/stdio.py - src/mcp/server/stdio.py - tests/client/test_stdio.py
1 parent 2fe56e5 commit 157a3e8

File tree

3 files changed

+56
-4
lines changed

3 files changed

+56
-4
lines changed

src/mcp/client/stdio.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ async def stdout_reader():
158158

159159
session_message = SessionMessage(message)
160160
await read_stream_writer.send(session_message)
161-
except anyio.ClosedResourceError: # pragma: lax no cover
161+
except (anyio.ClosedResourceError, anyio.BrokenResourceError): # pragma: lax no cover
162162
await anyio.lowlevel.checkpoint()
163163

164164
async def stdin_writer():
@@ -174,7 +174,7 @@ async def stdin_writer():
174174
errors=server.encoding_error_handler,
175175
)
176176
)
177-
except anyio.ClosedResourceError: # pragma: no cover
177+
except (anyio.ClosedResourceError, anyio.BrokenResourceError): # pragma: no cover
178178
await anyio.lowlevel.checkpoint()
179179

180180
async with anyio.create_task_group() as tg, process:
@@ -205,6 +205,11 @@ async def stdin_writer():
205205
except ProcessLookupError: # pragma: no cover
206206
# Process already exited, which is fine
207207
pass
208+
209+
# Cancel background tasks before closing streams to prevent race condition
210+
# where tasks try to use closed streams (BrokenResourceError)
211+
tg.cancel_scope.cancel()
212+
208213
await read_stream.aclose()
209214
await write_stream.aclose()
210215
await read_stream_writer.aclose()

src/mcp/server/stdio.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ async def stdin_reader():
6464

6565
session_message = SessionMessage(message)
6666
await read_stream_writer.send(session_message)
67-
except anyio.ClosedResourceError: # pragma: no cover
67+
except (anyio.ClosedResourceError, anyio.BrokenResourceError): # pragma: no cover
6868
await anyio.lowlevel.checkpoint()
6969

7070
async def stdout_writer():
@@ -74,7 +74,7 @@ async def stdout_writer():
7474
json = session_message.message.model_dump_json(by_alias=True, exclude_none=True)
7575
await stdout.write(json + "\n")
7676
await stdout.flush()
77-
except anyio.ClosedResourceError: # pragma: no cover
77+
except (anyio.ClosedResourceError, anyio.BrokenResourceError): # pragma: no cover
7878
await anyio.lowlevel.checkpoint()
7979

8080
async with anyio.create_task_group() as tg:

tests/client/test_stdio.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,3 +620,50 @@ def sigterm_handler(signum, frame):
620620
f"stdio_client cleanup took {elapsed:.1f} seconds for stdin-ignoring process. "
621621
f"Expected between 2-4 seconds (2s stdin timeout + termination time)."
622622
)
623+
624+
625+
@pytest.mark.anyio
626+
async def test_stdio_client_quick_exit_race_condition():
627+
"""Test that stdio_client handles quick context exits without crashing.
628+
629+
This reproduces the race condition where:
630+
1. Subprocess is spawned and starts outputting data
631+
2. User code exits the context quickly (e.g., timeout, error, disconnect)
632+
3. Cleanup code closes streams while background tasks are still using them
633+
4. Background tasks should handle closed streams gracefully (no BrokenResourceError)
634+
635+
The fix ensures:
636+
- Tasks are cancelled before streams are closed
637+
- Tasks handle BrokenResourceError gracefully as a fallback
638+
"""
639+
640+
# Create a Python script that continuously outputs data
641+
# This simulates a subprocess that's slow to shut down
642+
continuous_output_script = textwrap.dedent(
643+
"""
644+
import sys
645+
import time
646+
647+
# Continuously output to keep stdout_reader busy
648+
for i in range(100):
649+
print(f'{{"jsonrpc":"2.0","id":{i},"result":{{}}}}')
650+
sys.stdout.flush()
651+
time.sleep(0.01)
652+
"""
653+
)
654+
655+
server_params = StdioServerParameters(
656+
command=sys.executable,
657+
args=["-c", continuous_output_script],
658+
)
659+
660+
# This should not raise an ExceptionGroup or BrokenResourceError
661+
# The background tasks should handle stream closure gracefully
662+
async with stdio_client(server_params) as (read_stream, write_stream):
663+
# Immediately exit - triggers cleanup while subprocess is still outputting
664+
pass
665+
666+
# If we get here without exception, the race condition is handled correctly
667+
# The tasks either:
668+
# 1. Were cancelled before stream closure (proper fix)
669+
# 2. Handled BrokenResourceError gracefully (defense in depth)

0 commit comments

Comments
 (0)