Skip to content

Fix: AsyncTunnelHTTPConnection leaks connection on handshake failure#1056

Open
drshvik wants to merge 4 commits into
encode:masterfrom
drshvik:fix/proxy-tunnel-leak
Open

Fix: AsyncTunnelHTTPConnection leaks connection on handshake failure#1056
drshvik wants to merge 4 commits into
encode:masterfrom
drshvik:fix/proxy-tunnel-leak

Conversation

@drshvik

@drshvik drshvik commented Jan 31, 2026

Copy link
Copy Markdown

Summary

Fixes #1023.

When using an HTTP proxy with tunneling (HTTPS requests), if the CONNECT handshake fails (e.g. RemoteProtocolError, timeouts, or TLS errors during the tunnel setup), the underlying network stream was not explicitly closed.

This caused the connection to remain in the pool as "active" but dead. If max_connections is set, this eventually leads to pool starvation where no new requests can be made.

The Fix

  • Wrapped the connection setup logic in AsyncTunnelHTTPConnection.handle_async_request with a try...except block.
  • Explicitly calls await self._connection.aclose() if any exception occurs during the handshake or TLS upgrade steps.

Verification

I verified this with a local reproduction script that simulates a proxy server accepting a connection and then immediately closing it (crashing the handshake).

Before the fix:

  • The pool count remained at 1 (stuck connection).
  • Subsequent requests hung indefinitely waiting for a pool slot (if max_connections=1).

After the fix:

  • The pool count correctly returns to 0.
  • Subsequent requests fail immediately (correct behavior) instead of hanging.

@drshvik

drshvik commented Jan 31, 2026

Copy link
Copy Markdown
Author

This relates to #1049 but provides a broader fix by wrapping the entire handshake process, ensuring cleanup even if the failure occurs before the TLS upgrade step.

@drshvik

drshvik commented Feb 2, 2026

Copy link
Copy Markdown
Author

@simonw Please review this PR and let me know your opinion.

@baizhufbb

Copy link
Copy Markdown

Thanks for looking into this. I believe the broader fix overlaps with existing exception handling:
Current state

  • AsyncHTTP11Connection.handle_async_request has try...except BaseException (http11.py#L132-136), calling _response_closed() on any exception
  • _response_closed() calls aclose() when request is incomplete (L250), transitioning state to CLOSED
  • Non-2xx CONNECT responses already call aclose() explicitly (http_proxy.py#L296)

The only gap is TLS handshake After CONNECT 200 returns, start_tls() (http_proxy.py#L317) sits outside the exception handler above. When TLS fails, the underlying socket is closed but _state remains ACTIVE → zombie connection.

My PR #1049 adds minimal try-except for this specific gap. Wrapping the entire handshake duplicates HTTP/1.1 layer's existing cleanup logic.

Detailed analysis: https://www.reddit.com/r/Python/comments/1qs7crk/bug_fix_connection_pool_exhaustion_in_httpcore/
,or https://juejin.cn/post/7601020578491744265 (for Chinese readers)

Is there a specific edge case I'm missing that isn't covered by the existing handle_async_request try-except?

@dawei41468

Copy link
Copy Markdown

Downstream field evidence from a long-lived Telegram gateway behind an explicit HTTP CONNECT proxy (Tinyproxy) on a restricted network.

Environment:

We had a repeatable production symptom where proxy-facing CLOSE_WAIT descriptors accumulated quickly. Application-level mitigations did not stop it: bounded pools, zero keepalive, and explicitly reading/closing HTTP responses still reached 16 proxy-facing CLOSE_WAIT sockets in a five-minute same-PID sample (38 -> 54 total FDs), followed by 18 shortly afterward. The retained objects were in the AnyIO/httpcore proxy response-stream path.

I then added a focused downstream regression at the AsyncTunnelHTTPConnection seam. The fake underlying proxy connection raises while handling CONNECT; the assertion requires that its aclose() has run before the exception propagates. Against released 1.0.9 it failed:

assert tunnel._connection.closed is True
E assert False is True

After applying only this PR's async http_proxy.py hunk (not the sync or SOCKS changes), the same test passed.

I restarted the real gateway so it imported the patched module and sampled the same PID every 15 seconds for nine minutes (37 samples), spanning several polling/heartbeat cycles:

FDs:                     38 -> 38
FD min/max:              38 / 38
CLOSE_WAIT:               0 -> 0
max CLOSE_WAIT:               0
max proxy CLOSE_WAIT:         0

No Telegram network/pool/protocol error appeared during that interval. A later check on the same PID also still had zero CLOSE_WAIT.

Caveat: the live run also followed a downstream Hermes update, so it is not a perfectly controlled same-version field A/B. The regression itself is a direct RED -> GREEN check of this PR's cleanup behavior; the live result is supporting evidence, not proof that this PR is preferable to the narrower TLS-only approach discussed in #1049. It does, however, match the downstream failure class and is the first mitigation we tested that passed the previously fast live reproduction window.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Exceptions not handled properly in the proxy's AsyncTunnelHTTPConnection leading to the leaks and pool starvation

3 participants