From 62d8310eeecd3dec86c5ce63b07c1979b4806ba6 Mon Sep 17 00:00:00 2001 From: "Michiel W. Beijen" Date: Tue, 2 Jun 2026 08:40:16 +0200 Subject: [PATCH] Move HTTP/2 stream events cleanup inside _state_lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_response_closed` deletes `self._events[stream_id]` *outside* the `_state_lock`, but the `not self._events` check that decides whether to transition the connection to IDLE happens *inside* it. With multiplexed HTTP/2 requests this creates a race: 1. Request A is the last active stream; `_response_closed` releases the semaphore, deletes its events entry, then yields before acquiring `_state_lock`. 2. Request B enters `handle_async_request`, acquires `_state_lock`, sets `state=ACTIVE`, releases the lock. 3. Request B acquires the semaphore and stream ID but has not yet added `self._events[stream_id] = []`. 4. Request A resumes, acquires `_state_lock`, sees `not self._events` is True, and marks the connection IDLE with an expiry — while Request B is actively using it. Moving `del self._events[stream_id]` inside the lock makes the events mutation and the IDLE-transition check atomic. Ports encode/httpcore#1062 (bysiber), via codeberg.org/httpxyz/httpcorexyz@e88f30b (second of two fixes bundled in that commit; the first landed as #1012). Co-Authored-By: Kadir Can Ozden <101993364+bysiber@users.noreply.github.com> --- src/httpcore2/httpcore2/_async/http2.py | 2 +- src/httpcore2/httpcore2/_sync/http2.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/httpcore2/httpcore2/_async/http2.py b/src/httpcore2/httpcore2/_async/http2.py index ae3e764c..7cea171f 100644 --- a/src/httpcore2/httpcore2/_async/http2.py +++ b/src/httpcore2/httpcore2/_async/http2.py @@ -377,8 +377,8 @@ async def _receive_remote_settings_change(self, event: h2.events.RemoteSettingsC async def _response_closed(self, stream_id: int) -> None: await self._max_streams_semaphore.release() - del self._events[stream_id] async with self._state_lock: + del self._events[stream_id] if self._connection_terminated and not self._events: await self.aclose() diff --git a/src/httpcore2/httpcore2/_sync/http2.py b/src/httpcore2/httpcore2/_sync/http2.py index 0512c4a2..12d37de4 100644 --- a/src/httpcore2/httpcore2/_sync/http2.py +++ b/src/httpcore2/httpcore2/_sync/http2.py @@ -377,8 +377,8 @@ def _receive_remote_settings_change(self, event: h2.events.RemoteSettingsChanged def _response_closed(self, stream_id: int) -> None: self._max_streams_semaphore.release() - del self._events[stream_id] with self._state_lock: + del self._events[stream_id] if self._connection_terminated and not self._events: self.close()