Skip to content

Process pending HTTP/2 input before reusing connections - #1232

Open
Kludex wants to merge 6 commits into
mainfrom
discard-closed-http2-connections
Open

Kludex wants to merge 6 commits into
mainfrom
discard-closed-http2-connections

Conversation

@Kludex

@Kludex Kludex commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Addresses encode/httpx#2983, tracked in #849.

Process pending input before reusing an idle HTTP/2 connection instead of treating socket readability as proof of disconnection. PING and SETTINGS frames are handled and acknowledged on the existing connection. When the probe finds EOF, GOAWAY, or an unusable connection, the pool replaces it before sending the next request's headers or consuming its body.

The built-in backends expose an optional read_available stream capability. It checks TLS buffers as well as transport input, including TLS-in-TLS proxy connections, and preserves incomplete records and frames. Async probes use the configured read timeout for any native I/O; there are no fixed probe delays, background readers, or blind request retries. TLS control output generated during a probe is retained and flushed in wire order during normal I/O.

Custom backends without the capability keep their existing behavior. Unsupported custom transport shapes decline the probe without breaking ordinary reads or writes. The AnyIO implementation checks its asyncio transport's buffered-input event because raw-socket readiness alone cannot see that data.

Validation

  • Real local TLS tests cover buffered and delayed closure, TCP closure, GOAWAY, malformed control frames, healthy PING/SETTINGS reuse, and partial HTTP/2 and TLS records. Run with sync, asyncio, and Trio clients, directly and through HTTPS proxies, using one-shot request bodies.
  • Backend tests cover post-handshake client authentication through one or two TLS layers, deferred TLS output, reads progressing during blocked writes, custom transports, timeouts, and cancellation.
  • The original Hypercorn idle-timeout reproducer passes with sync, asyncio, and Trio.
  • Full suite: 2189 passed, 1 skipped; 100% coverage. Ruff, mypy, and generation checks pass. Targeted tests also pass with the minimum supported AnyIO 4.10.0.
  • All tests remain in existing files.

AI Disclaimer

This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-24T06:51:26.873972Z 50ed3d9 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@codspeed

codspeed Bot commented Sep 24, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 17 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing discard-closed-http2-connections (1080306) with main (af7a495)

Open in CodSpeed

Footnotes

  1. 7 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 50ed3d9e3a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/httpcore2/httpcore2/_sync/http2.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread src/httpcore2/httpcore2/_sync/http2.py Outdated
Comment thread src/httpcore2/httpcore2/_sync/http2.py Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 3 files

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Re-trigger cubic

Comment thread tests/httpx2/test_http2_keepalive.py Outdated
@Kludex Kludex changed the title Retire readable idle HTTP/2 connections before reuse Process pending HTTP/2 input before reusing connections Sep 24, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High

if new_max_streams and new_max_streams != self._max_streams:

A pending SETTINGS_MAX_CONCURRENT_STREAMS=0 is ignored, so the semaphore retains positive capacity and the next request sends HEADERS even though the peer prohibits opening streams; the server can consequently refuse that request. _receive_remote_settings_change skips the update because if new_max_streams is false for zero. Handle zero as a valid limit by testing whether the value differs from _max_streams.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/httpcore2/httpcore2/_sync/http2.py around line 401:

A pending `SETTINGS_MAX_CONCURRENT_STREAMS=0` is ignored, so the semaphore retains positive capacity and the next request sends `HEADERS` even though the peer prohibits opening streams; the server can consequently refuse that request. `_receive_remote_settings_change` skips the update because `if new_max_streams` is false for zero. Handle zero as a valid limit by testing whether the value differs from `_max_streams`.

Comment on lines +93 to +94
while await self._receive_events(request, read_available=True):
pass

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High _async/http2.py:93

The unbounded probe loop can prevent handle_async_request from ever sending the request when an idle peer continuously sends valid frames such as PING; each read returns True, so the loop holds _state_lock indefinitely despite the request timeout. Limit the probe to a bounded read instead of draining until the socket is empty.

-                    while await self._receive_events(request, read_available=True):
-                        pass
+                    await self._receive_events(request, read_available=True)
Also found in 1 other location(s)

src/httpcore2/httpcore2/_sync/http2.py:93

The unbounded drain loop at while self._receive_events(..., read_available=True) has no elapsed-time or frame limit. An idle HTTP/2 peer that continuously sends valid connection-level frames (for example PINGs) can keep each nonblocking read nonempty indefinitely, so handle_request never reaches the request send and holds _state_lock for the duration. The new request therefore hangs despite its configured timeout.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @src/httpcore2/httpcore2/_async/http2.py around lines 93-94:

The unbounded probe loop can prevent `handle_async_request` from ever sending the request when an idle peer continuously sends valid frames such as `PING`; each read returns `True`, so the loop holds `_state_lock` indefinitely despite the request timeout. Limit the probe to a bounded read instead of draining until the socket is empty.

Also found in 1 other location(s):
- src/httpcore2/httpcore2/_sync/http2.py:93 -- The unbounded drain loop at `while self._receive_events(..., read_available=True)` has no elapsed-time or frame limit. An idle HTTP/2 peer that continuously sends valid connection-level frames (for example PINGs) can keep each nonblocking read nonempty indefinitely, so `handle_request` never reaches the request send and holds `_state_lock` for the duration. The new request therefore hangs despite its configured timeout.

This branch has not been deployed

No deployments
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.

1 participant