Serialize the HTTP/2 send path under a dedicated lock (thread-safety race) - #1118
Open
Dextheking1 wants to merge 4 commits into
Open
Dextheking1 wants to merge 4 commits into
Dextheking1 wants to merge 4 commits into
Conversation
httpx.Client(http2=True) multiplexes requests from multiple threads over a
single connection. HTTP2Connection (and its async twin) wrapped one shared
h2.H2Connection state machine, but while the receive path and socket writes
were already serialized, the send path was not. Concurrent threads could
interleave stream-ID allocation (duplicate IDs -> StreamIDTooLowError),
HPACK encoding in send_headers ("deque mutated during iteration"), and
stream-dict iteration ("dictionary changed size during iteration").
Add a _send_lock (threading.Lock / anyio.AsyncLock), held in
handle_request around stream-ID allocation, _events registration,
_send_request_headers and _send_request_body. The lock is acquired
outermost relative to the existing _read_lock/_write_lock/_state_lock,
and after the max-streams semaphore, so no new deadlock is possible.
Adds tests/_sync/test_http2_thread_safety.py: one shared HTTP2Connection
driven from 8 threads over an in-memory fake HTTP/2 server. Fails on the
pre-fix code (327/400 requests errored with LocalProtocolError /
StreamIDTooLowError / KeyError), passes with the fix.
Related to encode/httpx#3566
- tests/_sync/test_http2_thread_safety.py: add type annotations so mypy strict passes (FakeStream now subclasses httpcore.NetworkStream, all methods annotated). - Align the new comments between httpcore/_async/http2.py (unasync source of truth) and httpcore/_sync/http2.py so scripts/unasync.py --check passes; use a magic trailing comma on the split _send_request_headers call so ruff format and unasync agree.
- tests/_sync/test_http2_thread_safety.py: add type annotations so mypy strict passes (FakeStream now subclasses httpcore.NetworkStream, all methods annotated). - Align the new comments between httpcore/_async/http2.py (unasync source of truth) and httpcore/_sync/http2.py so scripts/unasync.py --check passes; use a magic trailing comma on the split _send_request_headers call so ruff format and unasync agree.
- tests/_sync/test_http2_thread_safety.py: add type annotations so mypy strict passes (FakeStream now subclasses httpcore.NetworkStream, all methods annotated). - Align the new comments between httpcore/_async/http2.py (unasync source of truth) and httpcore/_sync/http2.py so scripts/unasync.py --check passes; use a magic trailing comma on the split _send_request_headers call so ruff format and unasync agree.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes encode/httpx#3566
Root cause
httpx.Client(http2=True)shares a single connection across threads (HTTP/2 multiplexing). That connection ishttpcore.HTTP2Connection(plus the async twin), which wraps one sharedh2.H2Connectionstate machine. The receive path (_read_lock) and socket writes (_write_lock) were already serialized, but the send path was not: concurrent threads could interleaveget_next_available_stream_id()→ duplicate stream IDs handed out (StreamIDTooLowError: 2411 is lower than 2413),send_headers()→ HPACK encode iteratingdynamic_entrieswhile another thread mutates it (RuntimeError: deque mutated during iteration— the first traceback in the linked issue),open_outbound_streamsiteratingh2.streamswhile another thread adds/removes streams (dictionary changed size during iteration,KeyError).h2's docs require
get_next_available_stream_id()to be immediately followed by the matchingsend_headers()— impossible under concurrency without a lock. Serializing in httpx would kill multiplexing, so the fix has to live here.The fix
One new lock,
_send_lock(threading.Lock/anyio.AsyncLock), held inhandle_requestaround stream-ID allocation,_events[stream_id]registration,_send_request_headers, and_send_request_body. TheNoAvailableStreamIDError → ConnectionNotAvailablehandling and theexcept BaseExceptioncleanup (_response_closed, semaphore release) keep their exact original semantics — the send block simply moved inside the existing cleanuptry.Lock-order audit:
_send_lockis acquired only inhandle_request, outermost relative to_read_lock/_write_lock/_state_lock; the max-streams semaphore is acquired before it and its holder never waits on a waiter. No new deadlock.Evidence
Fail-before / pass-after with an in-memory fake HTTP/2 transport (real client- and server-side h2 state machines, server-side guarded by its own lock so only client-side httpcore code is under test):
LocalProtocolError(StreamIDTooLowError),KeyErrorNew regression test:
tests/_sync/test_http2_thread_safety.py.