Serialize the HTTP/2 send path under a dedicated lock (thread-safety race) - #1118
Dextheking1 wants to merge 12 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.
|
Hi @Dextheking1 / maintainers! A quick note regarding the CI failure in this PR: Also, regarding the concurrency design: I have implemented an alternative approach on my fork that:
Branch reference: https://github.com/Priyankm23/httpcore/tree/fix-http2-header-encoding-race (commit I plan to open this as a PR against |
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.