Skip to content

Serialize h2 state machine access for thread-safe HTTP/2 connections - #1153

Open
Kludex wants to merge 6 commits into
mainfrom
http2-thread-safety
Open

Serialize h2 state machine access for thread-safe HTTP/2 connections#1153
Kludex wants to merge 6 commits into
mainfrom
http2-thread-safety

Conversation

@Kludex

@Kludex Kludex commented Aug 21, 2026

Copy link
Copy Markdown
Member

Closes #908, addresses discussion #1152.

A sync Client(http2=True) shared across threads corrupts the connection's h2 state machine: stream ID allocation, HPACK encoding, and frame writes were not atomic with respect to each other, producing StreamIDTooLowError, LocalProtocolError, KeyError, and interleaved HEADERS frames on the wire.

Changes

  • Stream ID allocation, _events registration, and sending the HEADERS frame now happen as a single atomic section under the write lock. Two streams can no longer send HEADERS out of stream ID order or interleave HPACK encoder state.
  • Every h2 state mutation (send_data, end_stream, acknowledge_received_data, receive_data) is now paired with its data_to_send() flush under the write lock, so frames from concurrent streams cannot interleave mid-sequence.
  • _wait_for_outgoing_flow is folded into _send_stream_data: the flow control window is checked and consumed under the same lock as the send_data that spends it, closing the window where two streams could both observe the same credit.
  • The h2.ProtocolError -> RemoteProtocolError/LocalProtocolError mapping is extracted into _map_exception since the request flow now has two exception paths.

The async connection gets the same structure via unasync. Async locks are no-op-cheap under a single event loop, so behaviour there is unchanged.

Testing

New test_http2_connection_concurrent_requests runs 10 concurrent requests over one connection - threads in the sync case, trio tasks in the async case. With a reduced sys.setswitchinterval the sync test fails deterministically on main (StreamIDTooLowError / LocalProtocolError) and passes with this change.

AI Disclaimer

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

Review in cubic

@Kludex
Kludex deployed to cloudflare August 21, 2026 18:08 — with GitHub Actions Active
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

@codspeed-hq

codspeed-hq Bot commented Aug 21, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 17 untouched benchmarks
⏩ 7 skipped benchmarks1


Comparing http2-thread-safety (61cbed3) with main (5465b4e)

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.

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

@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: 1f4bda20e8

ℹ️ 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/_async/http2.py Outdated
self._request_count -= 1
await self._max_streams_semaphore.release()
raise ConnectionNotAvailable()
async with self._write_lock:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Clean up cancellation while awaiting the write lock

When remote settings permit multiple streams and one task holds _write_lock during a slow network write, another task can acquire _max_streams_semaphore and then be cancelled while awaiting this new async with. Because the cleanup try begins only after lock acquisition, cancellation escapes without releasing the semaphore permit; repeated cancellations can permanently exhaust the connection's stream capacity and make later requests hang. Move lock acquisition inside the cleanup scope or release the permit if acquisition is interrupted.

Useful? React with 👍 / 👎.


### Fixed

* Serialize access to the `h2` state machine so that a single HTTP/2 connection can be safely shared across threads. ([#1153](https://github.com/pydantic/httpx2/pull/1153))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Restore engineer-only commit authorship

The reviewed commit records Codex <codex@openai.com> as both its Author and Committer, whereas repository policy requires authorship to belong only to the engineer. Recreate the commit with the engineer's author identity before merging so the permanent history does not attribute the coding agent.

AGENTS.md reference: AGENTS.md:L3-L3

Useful? React with 👍 / 👎.

@Kludex
Kludex deployed to cloudflare August 21, 2026 18:13 — with GitHub Actions Active

@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 tests/httpcore2/_async/test_http2.py
Comment thread tests/httpcore2/_sync/test_http2.py Outdated
@Kludex
Kludex deployed to cloudflare August 21, 2026 18:22 — with GitHub Actions Active

@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.

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/httpcore2/httpcore2/_sync/http2.py">

<violation number="1" location="src/httpcore2/httpcore2/_sync/http2.py:122">
P3: The `http2.send_request_headers.started` trace no longer includes `stream_id`. `kwargs` is initialized as `{"request": request}` and the Trace captures it before `__enter__` fires; `kwargs["stream_id"] = stream_id` is only assigned later, inside the write lock, so the started event's info dict is emitted without the stream ID. Before this delta, `kwargs` was built as `{"request", "stream_id"}` before entering the Trace, so the started event carried the stream ID. Trace-extension hooks that key `http2.send_request_headers.started` off the stream ID (and which still receive it for `send_request_body`/`receive_response_headers`) now lose it. If the stream ID is needed in the started event, allocate it before tracing is not possible without entering the lock, so either accept the loss or document it; the mismatch with the other h2 traces is what makes this worth confirming.</violation>
</file>

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

Re-trigger cubic

Comment thread src/httpcore2/httpcore2/_async/http2.py
Comment thread src/httpcore2/httpcore2/_sync/http2.py
stream_id = self._h2_state.get_next_available_stream_id()
self._events[stream_id] = []
kwargs: dict[str, typing.Any] = {"request": request}
with Trace("send_request_headers", logger, request, kwargs):

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.

P3: The http2.send_request_headers.started trace no longer includes stream_id. kwargs is initialized as {"request": request} and the Trace captures it before __enter__ fires; kwargs["stream_id"] = stream_id is only assigned later, inside the write lock, so the started event's info dict is emitted without the stream ID. Before this delta, kwargs was built as {"request", "stream_id"} before entering the Trace, so the started event carried the stream ID. Trace-extension hooks that key http2.send_request_headers.started off the stream ID (and which still receive it for send_request_body/receive_response_headers) now lose it. If the stream ID is needed in the started event, allocate it before tracing is not possible without entering the lock, so either accept the loss or document it; the mismatch with the other h2 traces is what makes this worth confirming.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/httpcore2/httpcore2/_sync/http2.py, line 122:

<comment>The `http2.send_request_headers.started` trace no longer includes `stream_id`. `kwargs` is initialized as `{"request": request}` and the Trace captures it before `__enter__` fires; `kwargs["stream_id"] = stream_id` is only assigned later, inside the write lock, so the started event's info dict is emitted without the stream ID. Before this delta, `kwargs` was built as `{"request", "stream_id"}` before entering the Trace, so the started event carried the stream ID. Trace-extension hooks that key `http2.send_request_headers.started` off the stream ID (and which still receive it for `send_request_body`/`receive_response_headers`) now lose it. If the stream ID is needed in the started event, allocate it before tracing is not possible without entering the lock, so either accept the loss or document it; the mismatch with the other h2 traces is what makes this worth confirming.</comment>

<file context>
@@ -116,30 +116,34 @@ def handle_request(self, request: Request) -> Response:
+        stream_id: int | None = None
+        try:
+            kwargs: dict[str, typing.Any] = {"request": request}
+            with Trace("send_request_headers", logger, request, kwargs):
+                with self._write_lock:
+                    # Stream ID allocation, HPACK encoding of the outgoing headers, and
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Accepting the loss, intentionally. The stream ID cannot exist before entering the lock, and allocating it earlier reintroduces the ordering race this PR fixes. The .complete event and the send_request_body/receive_response_headers traces still carry stream_id; only send_request_headers.started loses it.

@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.

3 issues found and verified against the latest diff

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="tests/httpcore2/_async/test_http2.py">

<violation number="1" location="tests/httpcore2/_async/test_http2.py:113">
P2: The process-global switch interval is lowered to 1e-6 but restored only on the happy path at the end of the test. If any assert or request raises (exactly the race failure this test targets), the restore line is skipped and the low interval leaks into every subsequent test in the session, slowing them down. Wrap the body in try/finally so `sys.setswitchinterval(switch_interval)` always runs.</violation>

<violation number="2" location="tests/httpcore2/_async/test_http2.py:113">
P3: This trio test sets `sys.setswitchinterval(1e-6)`, but trio (and the async path here) schedules cooperative tasks on a single thread and does not use GIL-based thread switching, so this call changes nothing for the async concurrency under test. It only adds a process-global side effect that, if the test body raises before the trailing restore, leaks into the rest of the test session. Drop the switch-interval manipulation here (or place it in try/finally).</violation>
</file>

<file name="tests/httpcore2/_sync/test_http2.py">

<violation number="1" location="tests/httpcore2/_sync/test_http2.py:113">
P2: The process-wide thread-switch interval is lowered to 1e-6 and only restored at the final line of the test, not in a finally. Because this test intentionally fails on main/regressions, a failure leaves the interpreter switching threads at 1e-6 for the rest of the session, which can make unrelated threaded tests flaky and obscure the real failure. Wrap the test body so the restore runs in a finally (or use a fixture).</violation>
</file>

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

Re-trigger cubic

sharing one connection. See https://github.com/pydantic/httpx2/discussions/1152
"""
switch_interval = sys.getswitchinterval()
sys.setswitchinterval(1e-6)

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.

P2: The process-global switch interval is lowered to 1e-6 but restored only on the happy path at the end of the test. If any assert or request raises (exactly the race failure this test targets), the restore line is skipped and the low interval leaks into every subsequent test in the session, slowing them down. Wrap the body in try/finally so sys.setswitchinterval(switch_interval) always runs.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/httpcore2/_async/test_http2.py, line 113:

<comment>The process-global switch interval is lowered to 1e-6 but restored only on the happy path at the end of the test. If any assert or request raises (exactly the race failure this test targets), the restore line is skipped and the low interval leaks into every subsequent test in the session, slowing them down. Wrap the body in try/finally so `sys.setswitchinterval(switch_interval)` always runs.</comment>

<file context>
@@ -99,6 +102,54 @@ async def test_http2_response_closed_twice() -> None:
+    sharing one connection. See https://github.com/pydantic/httpx2/discussions/1152
+    """
+    switch_interval = sys.getswitchinterval()
+    sys.setswitchinterval(1e-6)
+    requests_count = 10
+    origin = httpcore2.Origin(b"https", b"example.com", 443)
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Stale - this was addressed in 0d3a916; the test body is already wrapped in try/finally.

sharing one connection. See https://github.com/pydantic/httpx2/discussions/1152
"""
switch_interval = sys.getswitchinterval()
sys.setswitchinterval(1e-6)

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.

P2: The process-wide thread-switch interval is lowered to 1e-6 and only restored at the final line of the test, not in a finally. Because this test intentionally fails on main/regressions, a failure leaves the interpreter switching threads at 1e-6 for the rest of the session, which can make unrelated threaded tests flaky and obscure the real failure. Wrap the test body so the restore runs in a finally (or use a fixture).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/httpcore2/_sync/test_http2.py, line 113:

<comment>The process-wide thread-switch interval is lowered to 1e-6 and only restored at the final line of the test, not in a finally. Because this test intentionally fails on main/regressions, a failure leaves the interpreter switching threads at 1e-6 for the rest of the session, which can make unrelated threaded tests flaky and obscure the real failure. Wrap the test body so the restore runs in a finally (or use a fixture).</comment>

<file context>
@@ -100,6 +103,54 @@ def test_http2_response_closed_twice() -> None:
+    sharing one connection. See https://github.com/pydantic/httpx2/discussions/1152
+    """
+    switch_interval = sys.getswitchinterval()
+    sys.setswitchinterval(1e-6)
+    requests_count = 10
+    origin = httpcore2.Origin(b"https", b"example.com", 443)
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Stale - already wrapped in try/finally since 0d3a916.

Comment thread src/httpcore2/httpcore2/_async/http2.py
sharing one connection. See https://github.com/pydantic/httpx2/discussions/1152
"""
switch_interval = sys.getswitchinterval()
sys.setswitchinterval(1e-6)

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.

P3: This trio test sets sys.setswitchinterval(1e-6), but trio (and the async path here) schedules cooperative tasks on a single thread and does not use GIL-based thread switching, so this call changes nothing for the async concurrency under test. It only adds a process-global side effect that, if the test body raises before the trailing restore, leaks into the rest of the test session. Drop the switch-interval manipulation here (or place it in try/finally).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At tests/httpcore2/_async/test_http2.py, line 113:

<comment>This trio test sets `sys.setswitchinterval(1e-6)`, but trio (and the async path here) schedules cooperative tasks on a single thread and does not use GIL-based thread switching, so this call changes nothing for the async concurrency under test. It only adds a process-global side effect that, if the test body raises before the trailing restore, leaks into the rest of the test session. Drop the switch-interval manipulation here (or place it in try/finally).</comment>

<file context>
@@ -99,6 +102,54 @@ async def test_http2_response_closed_twice() -> None:
+    sharing one connection. See https://github.com/pydantic/httpx2/discussions/1152
+    """
+    switch_interval = sys.getswitchinterval()
+    sys.setswitchinterval(1e-6)
+    requests_count = 10
+    origin = httpcore2.Origin(b"https", b"example.com", 443)
</file context>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The switch interval tweak is only meaningful for the sync test, but both are generated from one async source via unasync, so it stays in the async version to keep them in lockstep. It is harmless under trio and already guarded by try/finally.

@Kludex
Kludex deployed to cloudflare August 21, 2026 18:34 — with GitHub Actions Active
Comment thread src/httpcore2/httpcore2/_sync/http2.py Outdated
Comment on lines +122 to +130
with Trace("send_request_headers", logger, request, kwargs):
with self._write_lock:
# Stream ID allocation, HPACK encoding of the outgoing headers, and
# writing the HEADERS frame to the network must be a single atomic
# section with respect to other streams on this connection.
stream_id = self._h2_state.get_next_available_stream_id()
self._events[stream_id] = []
kwargs["stream_id"] = stream_id
self._send_request_headers(request=request, stream_id=stream_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Medium _sync/http2.py:122

http2.send_request_headers.started callbacks now receive only request, so trace consumers cannot correlate the event with its HTTP/2 stream. kwargs["stream_id"] is assigned after entering Trace; allocate and assign the stream ID before emitting the started event.

-            with Trace("send_request_headers", logger, request, kwargs):
-                with self._write_lock:
-                    # Stream ID allocation, HPACK encoding of the outgoing headers, and
-                    # writing the HEADERS frame to the network must be a single atomic
-                    # section with respect to other streams on this connection.
-                    stream_id = self._h2_state.get_next_available_stream_id()
-                    self._events[stream_id] = []
-                    kwargs["stream_id"] = stream_id
-                    self._send_request_headers(request=request, stream_id=stream_id)
+            with self._write_lock:
+                # Stream ID allocation, HPACK encoding of the outgoing headers, and
+                # writing the HEADERS frame to the network must be a single atomic
+                # section with respect to other streams on this connection.
+                stream_id = self._h2_state.get_next_available_stream_id()
+                self._events[stream_id] = []
+                kwargs["stream_id"] = stream_id
+                with Trace("send_request_headers", logger, request, kwargs):
+                    self._send_request_headers(request=request, stream_id=stream_id)
Also found in 1 other location(s)

src/httpcore2/httpcore2/_async/http2.py:122

kwargs[&#34;stream_id&#34;] is populated only after entering Trace, so the http2.send_request_headers.started trace callback now receives only request, whereas it previously received both request and stream_id. This silently breaks trace consumers that correlate the header-send start event by HTTP/2 stream ID. Allocate the ID under the lock before emitting the started event, or otherwise preserve the callback payload.

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

`http2.send_request_headers.started` callbacks now receive only `request`, so trace consumers cannot correlate the event with its HTTP/2 stream. `kwargs["stream_id"]` is assigned after entering `Trace`; allocate and assign the stream ID before emitting the started event.

Also found in 1 other location(s):
- src/httpcore2/httpcore2/_async/http2.py:122 -- `kwargs["stream_id"]` is populated only after entering `Trace`, so the `http2.send_request_headers.started` trace callback now receives only `request`, whereas it previously received both `request` and `stream_id`. This silently breaks trace consumers that correlate the header-send start event by HTTP/2 stream ID. Allocate the ID under the lock before emitting the started event, or otherwise preserve the callback payload.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Declining this one: moving the Trace inside _write_lock runs user trace callbacks while holding the lock, which reintroduces the deadlock flagged earlier (a callback awaiting another request on this connection would block forever). The stream ID genuinely does not exist before the atomic section starts. The .complete event and the send_request_body/receive_response_headers traces still carry stream_id, so correlation remains possible - only the started payload loses it.

@Kludex
Kludex deployed to cloudflare August 21, 2026 18:37 — with GitHub Actions Active

@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 2 files (changes from recent commits).

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/httpcore2/httpcore2/_async/http2.py Outdated
Comment thread src/httpcore2/httpcore2/_sync/http2.py Outdated
@Kludex
Kludex deployed to cloudflare August 21, 2026 18:49 — with GitHub Actions Active

@johnflavin johnflavin 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.

I had been looking at this with Claude before I saw you had already fixed it. But my Claude made a slightly different choice about the lock than you did here—reusing _write_lock vs making a dedicated AsyncThreadLock()—which it seems to think could cause some bugs.

Why the lock choice matters

There are two different kinds of critical section in this file, and they have opposite requirements:

  • Protecting the h2 state machine. Pure CPU work, microseconds long, and it must be indivisible — the invariant is "bytes that left the socket have been handed to h2", "the stream ID we were given is the one we send headers on".
  • Serializing the socket. Held across await network_stream.write(...). Long, blocking, and — this is the part that bites — suspendable.

_write_lock is the second kind. Reusing it as the h2 mutex makes every h2 critical section inherit the properties of an I/O lock: in async, acquiring a contended lock is an await, and an await is a cancellation point. So each place the PR wraps h2 state in _write_lock becomes a place a task can be suspended and torn out mid-invariant. I think that's also visible in the commit history here — several of the follow-up commits (restoring idle state, cleaning up pre-allocation state, narrowing the NoAvailableStreamIDError handling) are repairs for states that only became reachable once handle_request grew a suspension point between acquiring the semaphore and allocating the stream.

The second half of the argument: the async path never needed this lock. The bug is a thread-safety bug, not a task-safety bug. Within one event loop these regions contain no await, so tasks cannot interleave inside them — the PR description says as much ("async locks remain no-op under single event loop"), but _write_lock is a real lock in async, so async pays the cost and takes on the new failure mode without getting anything back.

httpcore already has a primitive for precisely this case, and connection_pool.py already uses it:

class AsyncThreadLock:
    """
    This is a threading-only lock for no-I/O contexts.
 
    In the sync case `ThreadLock` provides thread locking.
    In the async case `AsyncThreadLock` is a no-op.
    """

Choosing it isn't a micro-optimization — it's a statement that the section contains no I/O, and it makes the cancellation hazard structurally impossible rather than something reviewers have to keep re-checking.

The concrete failure

_receive_events now reads from the network and then awaits the lock before handing the bytes to h2:

data = await self._read_incoming_data(request)   # bytes are off the socket
async with self._write_lock:                     # <-- await == cancellation point
    events: list[h2.events.Event] = self._h2_state.receive_data(data)

If the task is cancelled while waiting for that lock, the bytes it already consumed are gone. h2 never sees them, so framing is corrupted for every stream on that connection, not just the cancelled one. On main there is no await between the read and receive_data, so the pair is atomic with respect to cancellation. This is reachable from ordinary code — a per-request timeout, or a task group cancelling siblings, on an async client multiplexing over one connection, i.e. exactly when the write lock is contended.

Test below (drop it in tests/httpcore2/); it opens a stream, lets another task hold the write lock, and cancels a _receive_events call while it waits:

asyncio trio
main frame recorded — PASS PASS
this PR frame read and discarded — FAIL FAIL

The sync path is unaffected, since threading.Lock.acquire() isn't a cancellation point. That asymmetry is itself the tell: the regression exists only in the half of the codebase that didn't have the bug.

Options

  1. Use AsyncThreadLock/ThreadLock for the h2-state sections and leave _write_lock owning the socket. Async goes back to a no-op, sync gets the serialization it needs, and read/receive_data stays atomic. Ordering is still safe: data_to_send() drains FIFO under the write lock, so wire order still matches encode order.
  2. Keep _write_lock, but re-join the read and receive_data — either both under the lock, or by stashing unconsumed bytes on the connection so a later call feeds them to h2 instead of dropping them.
tests/httpcore2/test_cancel_loss.py
"""
Does a cancellation between "read bytes off the socket" and "feed them to h2"
lose those bytes?

On main (and with the ThreadLock approach) `_read_incoming_data` reads and then
calls `h2_state.receive_data(...)` with no await in between, so the pair is
atomic with respect to cancellation. PR #1153 moves `receive_data` behind
`async with self._write_lock`, which is an await point.
"""
import anyio
import hpack
import hyperframe.frame
import pytest

import httpcore2


class ReplayStream(httpcore2.AsyncNetworkStream):
    def __init__(self, buffer):
        self._buffer = list(buffer)
        self.reads = 0

    async def read(self, max_bytes: int, timeout: float | None = None) -> bytes:
        if not self._buffer:
            await anyio.sleep(999)
        self.reads += 1
        return self._buffer.pop(0)

    async def write(self, buffer: bytes, timeout: float | None = None) -> None:
        pass

    async def aclose(self) -> None:
        pass


@pytest.mark.anyio
async def test_cancellation_between_read_and_receive_data() -> None:
    encoder = hpack.Encoder()
    buffer = [
        hyperframe.frame.SettingsFrame().serialize(),
        hyperframe.frame.HeadersFrame(
            stream_id=1,
            data=encoder.encode([(b":status", b"200"), (b"content-type", b"plain/text")]),
            flags=["END_HEADERS"],
        ).serialize(),
        hyperframe.frame.DataFrame(stream_id=1, data=b"Hello, world!", flags=["END_STREAM"]).serialize(),
    ]
    origin = httpcore2.Origin(b"https", b"example.com", 443)
    stream = ReplayStream(buffer)
    conn = httpcore2.AsyncHTTP2Connection(origin=origin, stream=stream)
    request = httpcore2.Request("GET", "https://example.com/", headers={"Host": "example.com"})
    await conn._send_connection_init(request)

    # Open stream 1 for real, so that the HEADERS frame the server sends back
    # for it is legitimate. (Done via h2 directly so that the test runs
    # unchanged against every variant of `_send_request_headers`.)
    async with conn._write_lock:
        stream_id = conn._h2_state.get_next_available_stream_id()
        assert stream_id == 1
        conn._events[stream_id] = []
        conn._h2_state.send_headers(
            stream_id,
            [(b":method", b"GET"), (b":authority", b"example.com"), (b":scheme", b"https"), (b":path", b"/")],
            end_stream=True,
        )
        conn._h2_state.data_to_send()

    # Consume the server's SETTINGS frame first, so that the read we're
    # interested in is the one that returns the response HEADERS frame.
    await conn._receive_events(request, stream_id=1)
    assert conn._events[1] == []

    holding = anyio.Event()
    release = anyio.Event()

    async def hold_write_lock() -> None:
        # Stand in for another task that is stuck writing to the network.
        async with conn._write_lock:
            holding.set()
            await release.wait()

    async with anyio.create_task_group() as tg:
        tg.start_soon(hold_write_lock)
        await holding.wait()
        # Our task reads a frame off the network, then gets cancelled while
        # waiting for that same lock.
        with anyio.move_on_after(0.05):
            await conn._receive_events(request, stream_id=1)
        release.set()

    frames_read = stream.reads - 1  # not counting the SETTINGS frame
    events_recorded = len(conn._events[1])
    print(f"\nframes read after cancellation point: {frames_read}, events recorded: {events_recorded}")
    assert frames_read == 1, "the test did not get as far as reading the HEADERS frame"
    # If the frame that was read never reached h2, it is gone for good: the
    # response it carried can never be delivered.
    assert events_recorded == 1, "the HEADERS frame read from the network was discarded by the cancellation"

Generated by Claude Code

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.

BUG: RuntimeError: deque mutated during iteration

2 participants