Give each HTTP/2 request a stream object that owns its state and cleanup
Part of #849.
Why
Between HEADERS going out and the stream closing, an HTTP/2 request's state is spread across locals in handle_async_request(), self._events[stream_id], HTTP2ConnectionByteStream (the receive half only) and _response_closed(). Nothing represents the stream, and nothing owns closing it. Every stream-level behaviour, reacting to an early response, cancelling on early close, trailers, and eventually handing the stream out as network_stream, has to be threaded through the connection as a special case.
What
AsyncHTTP2Stream holds everything keyed on a stream id: the queued events, sending the request, waiting for its own flow control, iterating the response body, and closing itself. handle_async_request() opens a stream, drives it through the same three traced steps as before, and returns it as the response body. The connection keeps what is shared: the h2 state, the read and write locks, stream admission, event dispatch, and the pool interface. HTTP2ConnectionByteStream goes away.
Trace events, response extensions, locking order and the pool interface are unchanged. The one new piece of shared state is _streams: dict[int, AsyncHTTP2Stream], replacing _events.
The connection and the stream stay in one module on purpose: the stream uses the connection's locks and dispatch, and that is same-module access rather than a cross-module private API.
Behaviour changes
Two, both consequences of the stream owning its cleanup. Reviewed against the tests below.
Early responses. Closes #1294. A server that answers before an upload is complete, with or without RST_STREAM(NO_ERROR), was ignored until the upload finished, which usually meant waiting for a WINDOW_UPDATE that never came. The stream's send path stops when the server has ended the stream, returns the queued response, and closes the stream with RST_STREAM(NO_ERROR) if the server did not. RFC 9113 §8.1.
Responses closed early. Closing a response before reading its body left the stream open in h2 and on the server: the server kept sending into our 16 MB window, the data was dropped unacknowledged, and the stream counted against the concurrent-stream limit (the stall in encode/httpcore#943). The stream now sends RST_STREAM(CANCEL). Once h2 considers the stream closed it acknowledges late data by itself, so no further accounting is needed.
The cancel frame is written when the response is closed deliberately. On failure or cancellation it is queued for the next write, because every cleanup path runs under a cancellation shield and a blocked socket there would turn a cancellation into a hang (test_h2_timeout_during_request guards this). A stream that never reached send_headers() is not reset at all: a refused reset would move h2's connection state to CLOSED.
Tests
Through conn.request() and conn.stream() against scripted frames, each confirming the connection takes a following request:
- early
413+RST_STREAM(NO_ERROR)mid-upload returns413; - early
413without a reset makes the client sendRST_STREAM(NO_ERROR)and noEND_STREAM; RST_STREAM(REFUSED_STREAM)mid-upload raisesRemoteProtocolError;- closing a response early writes
RST_STREAM(CANCEL)and leaves the connection idle; - closing early on a connection whose writes fail does not raise, and the connection is not reused;
- a body iterator closed after the response is a no-op;
- a request failing before its stream is open (a raising
tracehook) releases its permit and leaves the connection usable; test_http2_response_closed_twicecloses through the public API instead of the removed_response_closed().
The early-response tests fail on main. Harness numbers for c16/1m/upload/h2/batch, c16/1m/upload/h2, c64/1k/h2 and c8/5m are unchanged against main.
AI Disclaimer
This PR was developed with the assistance of either Claude or Codex. I've reviewed and verified the changes.