chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
"""Test for issue #1363 - Race condition in StreamableHTTP transport causes ClosedResourceError.
|
||||
|
||||
This test reproduces the race condition described in issue #1363 where MCP servers
|
||||
in HTTP Streamable mode experience ClosedResourceError exceptions when requests
|
||||
fail validation early (e.g., due to incorrect Accept headers).
|
||||
|
||||
The race condition occurs because:
|
||||
1. Transport setup creates a message_router task
|
||||
2. Message router enters async for write_stream_reader loop
|
||||
3. write_stream_reader calls checkpoint() in receive(), yielding control
|
||||
4. Request handling processes HTTP request
|
||||
5. If validation fails early, request returns immediately
|
||||
6. Transport termination closes all streams including write_stream_reader
|
||||
7. Message router may still be in checkpoint() yield and hasn't returned to check stream state
|
||||
8. When message router resumes, it encounters a closed stream, raising ClosedResourceError
|
||||
"""
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import anyio
|
||||
import anyio.to_thread
|
||||
import httpx
|
||||
import pytest
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
|
||||
from mcp.server import Server
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
|
||||
SERVER_NAME = "test_race_condition_server"
|
||||
|
||||
|
||||
class RaceConditionTestServer(Server):
|
||||
def __init__(self):
|
||||
super().__init__(SERVER_NAME)
|
||||
|
||||
|
||||
def create_app(json_response: bool = False) -> Starlette:
|
||||
"""Create a Starlette application for testing."""
|
||||
app = RaceConditionTestServer()
|
||||
|
||||
# Create session manager
|
||||
session_manager = StreamableHTTPSessionManager(
|
||||
app=app,
|
||||
json_response=json_response,
|
||||
stateless=True, # Use stateless mode to trigger the race condition
|
||||
)
|
||||
|
||||
# Create Starlette app with lifespan
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: Starlette) -> AsyncGenerator[None, None]:
|
||||
async with session_manager.run():
|
||||
yield
|
||||
|
||||
routes = [
|
||||
Mount("/", app=session_manager.handle_request),
|
||||
]
|
||||
|
||||
return Starlette(routes=routes, lifespan=lifespan)
|
||||
|
||||
|
||||
class ServerThread(threading.Thread):
|
||||
"""Thread that runs the ASGI application lifespan in a separate event loop."""
|
||||
|
||||
def __init__(self, app: Starlette):
|
||||
super().__init__(daemon=True)
|
||||
self.app = app
|
||||
self._stop_event = threading.Event()
|
||||
self._ready_event = threading.Event()
|
||||
|
||||
def run(self) -> None:
|
||||
"""Run the lifespan in a new event loop."""
|
||||
|
||||
# Create a new event loop for this thread
|
||||
async def run_lifespan():
|
||||
# Use the lifespan context (always present in our tests)
|
||||
lifespan_context = getattr(self.app.router, "lifespan_context", None)
|
||||
assert lifespan_context is not None # Tests always create apps with lifespan
|
||||
async with lifespan_context(self.app):
|
||||
# Only signal readiness once lifespan startup has completed, i.e. the
|
||||
# session manager's task group exists and requests can be handled.
|
||||
self._ready_event.set()
|
||||
# Wait until stop is requested
|
||||
while not self._stop_event.is_set():
|
||||
await anyio.sleep(0.1)
|
||||
|
||||
anyio.run(run_lifespan)
|
||||
|
||||
def wait_ready(self, timeout: float = 5.0) -> None:
|
||||
"""Block until the lifespan has started; call from a worker thread, not the event loop."""
|
||||
assert self._ready_event.wait(timeout), "server thread did not start its lifespan in time"
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Signal the thread to stop."""
|
||||
self._stop_event.set()
|
||||
|
||||
|
||||
def check_logs_for_race_condition_errors(caplog: pytest.LogCaptureFixture, test_name: str) -> None:
|
||||
"""Check logs for ClosedResourceError and other race condition errors.
|
||||
|
||||
Args:
|
||||
caplog: pytest log capture fixture
|
||||
test_name: Name of the test for better error messages
|
||||
"""
|
||||
# Check for specific race condition errors in logs
|
||||
errors_found: list[str] = []
|
||||
|
||||
for record in caplog.records: # pragma: lax no cover
|
||||
message = record.getMessage()
|
||||
if "ClosedResourceError" in message:
|
||||
errors_found.append("ClosedResourceError")
|
||||
if "Error in message router" in message:
|
||||
errors_found.append("Error in message router")
|
||||
if "anyio.ClosedResourceError" in message:
|
||||
errors_found.append("anyio.ClosedResourceError")
|
||||
|
||||
# Assert no race condition errors occurred
|
||||
if errors_found: # pragma: no cover
|
||||
error_msg = f"Test '{test_name}' found race condition errors in logs: {', '.join(set(errors_found))}\n"
|
||||
error_msg += "Log records:\n"
|
||||
for record in caplog.records:
|
||||
if any(err in record.getMessage() for err in ["ClosedResourceError", "Error in message router"]):
|
||||
error_msg += f" {record.levelname}: {record.getMessage()}\n"
|
||||
pytest.fail(error_msg)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_race_condition_invalid_accept_headers(caplog: pytest.LogCaptureFixture):
|
||||
"""Test the race condition with invalid Accept headers.
|
||||
|
||||
This test reproduces the exact scenario described in issue #1363:
|
||||
- Send POST request with incorrect Accept headers (missing either application/json or text/event-stream)
|
||||
- Request fails validation early and returns quickly
|
||||
- This should trigger the race condition where message_router encounters ClosedResourceError
|
||||
"""
|
||||
app = create_app()
|
||||
server_thread = ServerThread(app)
|
||||
server_thread.start()
|
||||
|
||||
try:
|
||||
# Wait for the server thread to enter the lifespan before sending requests
|
||||
await anyio.to_thread.run_sync(server_thread.wait_ready)
|
||||
|
||||
# Suppress WARNING logs (expected validation errors) and capture ERROR logs
|
||||
with caplog.at_level(logging.ERROR):
|
||||
# Test with missing text/event-stream in Accept header
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/",
|
||||
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
|
||||
headers={
|
||||
"Accept": "application/json", # Missing text/event-stream
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
# Should get 406 Not Acceptable due to missing text/event-stream
|
||||
assert response.status_code == 406
|
||||
|
||||
# Test with missing application/json in Accept header
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/",
|
||||
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
|
||||
headers={
|
||||
"Accept": "text/event-stream", # Missing application/json
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
# Should get 406 Not Acceptable due to missing application/json
|
||||
assert response.status_code == 406
|
||||
|
||||
# Test with completely invalid Accept header
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/",
|
||||
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
|
||||
headers={
|
||||
"Accept": "text/plain", # Invalid Accept header
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
# Should get 406 Not Acceptable
|
||||
assert response.status_code == 406
|
||||
|
||||
# Give background tasks time to complete
|
||||
await anyio.sleep(0.2)
|
||||
|
||||
finally:
|
||||
server_thread.stop()
|
||||
server_thread.join(timeout=5.0)
|
||||
# Check logs for race condition errors
|
||||
check_logs_for_race_condition_errors(caplog, "test_race_condition_invalid_accept_headers")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_race_condition_invalid_content_type(caplog: pytest.LogCaptureFixture):
|
||||
"""Test the race condition with invalid Content-Type headers.
|
||||
|
||||
This test reproduces the race condition scenario with Content-Type validation failure.
|
||||
"""
|
||||
app = create_app()
|
||||
server_thread = ServerThread(app)
|
||||
server_thread.start()
|
||||
|
||||
try:
|
||||
# Wait for the server thread to enter the lifespan before sending requests
|
||||
await anyio.to_thread.run_sync(server_thread.wait_ready)
|
||||
|
||||
# Suppress WARNING logs (expected validation errors) and capture ERROR logs
|
||||
with caplog.at_level(logging.ERROR):
|
||||
# Test with invalid Content-Type
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/",
|
||||
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
|
||||
headers={
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"Content-Type": "text/plain", # Invalid Content-Type
|
||||
},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
# Give background tasks time to complete
|
||||
await anyio.sleep(0.2)
|
||||
|
||||
finally:
|
||||
server_thread.stop()
|
||||
server_thread.join(timeout=5.0)
|
||||
# Check logs for race condition errors
|
||||
check_logs_for_race_condition_errors(caplog, "test_race_condition_invalid_content_type")
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_race_condition_message_router_async_for(caplog: pytest.LogCaptureFixture):
|
||||
"""Uses json_response=True to trigger the `if self.is_json_response_enabled` branch,
|
||||
which reproduces the ClosedResourceError when message_router is suspended
|
||||
in async for loop while transport cleanup closes streams concurrently.
|
||||
"""
|
||||
app = create_app(json_response=True)
|
||||
server_thread = ServerThread(app)
|
||||
server_thread.start()
|
||||
|
||||
try:
|
||||
# Wait for the server thread to enter the lifespan before sending requests
|
||||
await anyio.to_thread.run_sync(server_thread.wait_ready)
|
||||
|
||||
# Suppress WARNING logs (expected validation errors) and capture ERROR logs
|
||||
with caplog.at_level(logging.ERROR):
|
||||
# Use httpx.ASGITransport to test the ASGI app directly
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0
|
||||
) as client:
|
||||
# Send a valid initialize request
|
||||
response = await client.post(
|
||||
"/",
|
||||
json={"jsonrpc": "2.0", "method": "initialize", "id": 1, "params": {}},
|
||||
headers={
|
||||
"Accept": "application/json, text/event-stream",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
# Should get a successful response
|
||||
assert response.status_code in (200, 201)
|
||||
|
||||
# Give background tasks time to complete
|
||||
await anyio.sleep(0.2)
|
||||
|
||||
finally:
|
||||
server_thread.stop()
|
||||
server_thread.join(timeout=5.0)
|
||||
# Check logs for race condition errors in message router
|
||||
check_logs_for_race_condition_errors(caplog, "test_race_condition_message_router_async_for")
|
||||
Reference in New Issue
Block a user