|
1 | 1 | import contextlib |
| 2 | +import socket |
2 | 3 | from unittest import mock |
3 | 4 |
|
4 | 5 | import httpx |
@@ -385,3 +386,73 @@ async def test_client_session_group_establish_session_parameterized( |
385 | 386 | # 3. Assert returned values |
386 | 387 | assert returned_server_info is mock_initialize_result.server_info |
387 | 388 | assert returned_session is mock_entered_session |
| 389 | + |
| 390 | + |
| 391 | +def _free_tcp_port() -> int: |
| 392 | + """Return a TCP port number not currently bound on localhost. |
| 393 | +
|
| 394 | + A small race window exists between this returning and the test |
| 395 | + using the port, but it is acceptable here: the test only requires |
| 396 | + that no streamable-http MCP server be listening at connect time. |
| 397 | + """ |
| 398 | + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: |
| 399 | + sock.bind(("127.0.0.1", 0)) |
| 400 | + return sock.getsockname()[1] |
| 401 | + |
| 402 | + |
| 403 | +def _is_cancel_scope_runtime_error(exc: BaseException) -> bool: |
| 404 | + """Walk *exc* and its cause/context/group chain looking for the |
| 405 | + ``Attempted to exit cancel scope in a different task`` RuntimeError |
| 406 | + that previously masked underlying connection errors (issue #915). |
| 407 | + """ |
| 408 | + seen: set[int] = set() |
| 409 | + |
| 410 | + def _walk(e: BaseException | None) -> bool: |
| 411 | + if e is None or id(e) in seen: |
| 412 | + return False |
| 413 | + seen.add(id(e)) |
| 414 | + if isinstance(e, RuntimeError) and "cancel scope" in str(e).lower(): |
| 415 | + return True |
| 416 | + if isinstance(e, BaseExceptionGroup): |
| 417 | + if any(_walk(child) for child in e.exceptions): |
| 418 | + return True |
| 419 | + return _walk(e.__cause__) or _walk(e.__context__) |
| 420 | + |
| 421 | + return _walk(exc) |
| 422 | + |
| 423 | + |
| 424 | +@pytest.mark.anyio |
| 425 | +async def test_unreachable_streamable_http_error_is_catchable() -> None: |
| 426 | + """Regression test for #915. |
| 427 | +
|
| 428 | + Connecting ``ClientSessionGroup`` to an unbound local port must |
| 429 | + raise a *catchable* connection error rather than being shadowed by |
| 430 | + the AnyIO cancel-scope ``RuntimeError`` from concurrent teardown. |
| 431 | + """ |
| 432 | + port = _free_tcp_port() |
| 433 | + server_params = StreamableHttpParameters(url=f"http://127.0.0.1:{port}/mcp/") |
| 434 | + |
| 435 | + caught: BaseException | None = None |
| 436 | + |
| 437 | + try: |
| 438 | + async with ClientSessionGroup() as group: |
| 439 | + try: |
| 440 | + await group.connect_to_server(server_params) |
| 441 | + except BaseException as inner: # noqa: BLE001 |
| 442 | + # Expected post-fix: real ConnectError lands here. |
| 443 | + caught = inner |
| 444 | + except BaseException as outer: # noqa: BLE001 |
| 445 | + # If we land here, the error escaped past the inner handler -- |
| 446 | + # that is the regression case (masking RuntimeError surfacing |
| 447 | + # from __aexit__ instead of the real ConnectError propagating). |
| 448 | + caught = outer |
| 449 | + |
| 450 | + assert caught is not None, ( |
| 451 | + "Expected to catch a connection error against an unreachable " |
| 452 | + "streamable-http server, but no exception was raised." |
| 453 | + ) |
| 454 | + assert not _is_cancel_scope_runtime_error(caught), ( |
| 455 | + "Regression of #915: connection error against an unreachable " |
| 456 | + "streamable-http server was masked by an anyio cancel-scope " |
| 457 | + f"RuntimeError. Got: {type(caught).__name__}: {caught}" |
| 458 | + ) |
0 commit comments