Skip to content

Missing client stream reset in h2 connection #943

Closed

Description

@MarkLux

Discussed in #941

Originally posted by MarkLux August 9, 2024
Background

  1. Using Http2Connection with a high concurrency stream num (e.g over 400 stream_ids reusing single connection)
  2. Each stream is waiting for SSE events (assume that there are many server events to receive for each stream)
  3. Some of the streams cancelled by client (Not ALL)

Phenomenon

  1. The Cancelled streams will be closed
  2. But, the Server is still sending SSE events for that stream (because server doesn't receive any signal to indicate the stream is closed, and the connection is still alive because other streams are using)
  3. The rest streams on this connection, will be blocked in this loop:
# http2.py, class AsyncHTTP2Connection
# ...
async def _receive_stream_event(
        self, request: Request, stream_id: int
    ) -> typing.Union[
        h2.events.ResponseReceived, h2.events.DataReceived, h2.events.StreamEnded
    ]:
        while not self._events.get(stream_id): 
            # REST STREAM ARE BLOCKED HERE
            # for each loop, the _receive_events give an events that belongs to other stream (those we closed, but server didn't know, keep sending)
            await self._receive_events(request, stream_id)
        event = self._events[stream_id].pop(0)
        if isinstance(event, h2.events.StreamReset):
            raise RemoteProtocolError(event)
        return event

we made some experiments and the following logs were observed

img_v3_02dj_93f91287-7a04-4c02-8089-1bb14c957c5g

Solution

reset the stream when client cancelled the stream, here is a demo (have made experiments to prove it works)

# http2.py, class AsyncHTTP2Connection

async def _response_closed(self, stream_id: int) -> None:
        await self._max_streams_semaphore.release()
        del self._events[stream_id]
        # ADD
        stream = self._h2_state._get_stream_by_id(stream_id=stream_id)
        if stream and not stream.closed:
            # send a RstStream with Cancel Code 
            self._h2_state.reset_stream(stream_id=stream_id, error_code=8)
        # ADD END
        async with self._state_lock:
            if self._connection_terminated and not self._events:
                await self.aclose()

We referred some h2 libraries of other language, and found that the 'client stream reset when cancelled' behavior were both implemented.

e.g.1: go net http2 library
e.g.2: rust h2 library

Activity

  1. stale commented on Apr 26, 2025

    @stale

    This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

  2. Teuniomani commented on Aug 17, 2026

    @Teuniomani

    This is still present on master, and I have a self-contained reproduction plus a
    control. Posting it here rather than opening a duplicate — the issue looks to have
    been closed by the stale bot rather than fixed.

    Second observable symptom of the same cause

    Besides the blocked-streams case described above, the missing reset also causes
    LocalProtocolError: Max outbound streams is 100, 100 open on connections whose
    actual concurrency never approaches 100.

    _response_closed() releases _max_streams_semaphore and deletes the events entry,
    but never tells the h2 state machine the stream is finished. When a response is closed
    before END_STREAM — a cancelled task, a timeout, an exception mid-body — h2 keeps the
    stream in open_outbound_streams while httpcore hands the slot to the next caller.
    The two counters then drift apart by exactly one per early close.

    Reproduction (no network, no TLS — httpcore's connection is driven over an
    in-memory stream backed by a real h2 server, so the accounting measured is h2's own):

    import asyncio
    import h2.config, h2.connection, h2.events
    import httpcore
    from httpcore import AsyncHTTP2Connection, Origin, Request
    
    class MemoryStream(httpcore.AsyncNetworkStream):
        def __init__(self, end_stream: bool):
            cfg = h2.config.H2Configuration(client_side=False, header_encoding="utf-8")
            self.server = h2.connection.H2Connection(config=cfg)
            self.server.initiate_connection()
            self._out = bytearray(self.server.data_to_send())
            self._end_stream = end_stream
    
        async def read(self, max_bytes, timeout=None):
            for _ in range(2000):
                if self._out:
                    chunk = bytes(self._out[:max_bytes]); del self._out[:len(chunk)]
                    return chunk
                await asyncio.sleep(0)
            return b""
    
        async def write(self, buffer, timeout=None):
            for ev in self.server.receive_data(buffer):
                if isinstance(ev, h2.events.RequestReceived):
                    self.server.send_headers(ev.stream_id, [(":status", "200")])
                    if self._end_stream:
                        self.server.send_data(ev.stream_id, b"{}", end_stream=True)
            self._out.extend(self.server.data_to_send())
    
        async def aclose(self): pass
        def get_extra_info(self, info): return None
    
    async def run(*, early_close: bool):
        stream = MemoryStream(end_stream=not early_close)
        conn = AsyncHTTP2Connection(origin=Origin(b"https", b"example.com", 443), stream=stream)
        for _ in range(12):
            req = Request("GET", "https://example.com/x", headers=[(b"Host", b"example.com")])
            resp = await conn.handle_async_request(req)
            if not early_close:
                await resp.aread()
            await resp.aclose()
        return conn._h2_state.open_outbound_streams
    
    async def main():
        print("responses read to completion :", await run(early_close=False))
        print("responses closed early       :", await run(early_close=True))
    
    asyncio.run(main())

    Output (httpx 0.28.1 / httpcore 1.0.9 / h2 4.3.0, and unchanged against master):

    responses read to completion : 0
    responses closed early       : 12
    

    Same code, same server, one difference. httpcore reports all 100 permits free in both
    runs; h2 disagrees in the second. Extrapolated, the 101st request on such a connection
    raises Max outbound streams is 100, 100 open while httpcore still believes the
    connection is idle — which also means an application-level concurrency limit cannot
    prevent it, since it bounds the counter that is already correct.

    The leak is proportional to the number of early closes, not to load: 100 cancellations
    spread over a day have the same effect as 100 in a second. It clears only when the
    connection is replaced.

    Would a maintainer be willing to reopen? Happy to send a PR that resets the stream in
    _response_closed() when it has not already been closed, if that is the direction you'd
    want.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    http/2wontfixThis will not be worked on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions