fix: clean up garbage connections on cancellation in AsyncConnectionPool

Merged
MB
3mo ago
+110-0

Summary

  • Fixes the AsyncConnectionPool poisoning bug reported in #982: an asyncio.CancelledError (or any cancel scope timeout) during connection establishment leaves a NEW-state connection in self._connections that is neither closed nor connected, eventually exhausting max_connections and surfacing as PoolTimeout on every subsequent request.
  • Adds is_connected() to (Async)ConnectionInterface, overridden on the concrete connection classes and proxy wrappers so a connection in the pre-TCP NEW state reports False.
  • Teaches _assign_requests_to_connections() to recognise such garbage connections (not connected AND no live pool request references them) and silently drop them from the pool.

Ported from internal commit https://codeberg.org/httpxyz/httpcorexyz/commit/a7500e4 on the httpcorexyz fork

Reproduction (from #982)

import asyncio
import httpcore2 as httpcore

async def main():
    async with httpcore.AsyncConnectionPool(max_connections=1) as pool:
        tasks = [asyncio.create_task(pool.request("GET", f"http://localhost:8888/{i}")) for i in range(2)]
        await asyncio.sleep(0)
        for task in tasks:
            task.cancel()
        await asyncio.gather(*tasks, return_exceptions=True)

        print(pool)
        # Before: <AsyncConnectionPool [Requests: 0 active, 0 queued | Connections: 1 active, 0 idle]>
        # After:  <AsyncConnectionPool [Requests: 0 active, 0 queued | Connections: 0 active, 0 idle]>
        await pool.request("GET", "http://localhost:8888/", extensions={"timeout": {"pool": 1}})
        # Before: httpcore2.PoolTimeout
        # After:  proceeds normally (or surfaces a genuine ConnectError/ConnectTimeout)

Test plan

  • scripts/check โ€” ruff format/check + mypy clean
  • scripts/test โ€” full suite passes (1651 passed, 1 skipped) on both asyncio and trio backends
  • scripts/coverage โ€” 100% coverage maintained
  • New test_connection_pool_cancellation_during_waiting_for_connection verifies the pool is empty after cancellation in the connection-wait phase, on both asyncio and trio
  • Manually re-ran the reproduction from #982 against the patched pool โ€” pool stays empty and the follow-up request no longer hangs on a PoolTimeout

Closes #982.

๐Ÿค– Generated with Claude Code