Improve HTTP/2 and HTTP/3 multiplexing, add stable benchmark suite, and carry forward upstream-aligned fixes#350
Improve HTTP/2 and HTTP/3 multiplexing, add stable benchmark suite, and carry forward upstream-aligned fixes#350menudoproblema wants to merge 43 commits into
Conversation
The tests use unittest.mock from the standard library, so the dependency on the mock PyPI backport package is unneeded.
…nused mock dev dependency Original PR: pgjones#343
…rror when waiting for the writer to close Original PR: pgjones#342
…lready-closed on DataReceived Original PR: pgjones#334
…ions Original PR: pgjones#332
|
Thank you for this! |
rm-you
left a comment
There was a problem hiding this comment.
Thanks for the thorough work here — this LOOKS very good, though I still am in the process of testing it. I had run into problems with multiplexing during some OpenStack work that this should resolve, so wanted to post just to express my interest. Really only had the one nit otherwise!
| self.priority.unblock(stream_id) | ||
| await self.has_data.set() | ||
| await self.stream_buffers[stream_id].drain() | ||
| self.connection.send_headers(stream_id, headers, end_stream=True) |
There was a problem hiding this comment.
Design nit: calling connection.send_headers() directly from the ASGI task bypasses the scheduler. All other h2.Connection mutations for sending (send_data, end_stream) are serialized through the single scheduler task in run() — this is the exception.
Suggested fix (~10 lines): store trailer headers on the StreamBuffer alongside the completion flag, then emit send_headers(..., end_stream=True) from _send_data when the buffer is complete and has trailers — same path that currently handles bare end_stream.
There was a problem hiding this comment.
Good catch. The trailers path should be serialized through the scheduler. drain() only indicates that the payload buffer has become empty; particularly with Trio, the waiting ASGI task may resume before the scheduler has completed the final send_data() mutation. Calling send_headers(..., end_stream=True) directly could therefore overtake the final DATA frame.
I’ll store the trailing headers on StreamBuffer, mark the buffer complete, and let _send_data() emit either trailing HEADERS or a bare end_stream after the payload has been drained. I’ll also add a regression test asserting DATA-before-trailers ordering.
| @property | ||
| def idle(self) -> bool: | ||
| return len(self.streams) == 0 or all(stream.idle for stream in self.streams.values()) | ||
| return len(self.streams) == 0 |
There was a problem hiding this comment.
The all(stream.idle ...) check was removed — guessing because with QueuedStream + the send scheduler, stream-level idle doesn't capture whether the scheduler still has buffered frames. Is that actually the reasoning behind this change or is it something the AI agent I'm co-reviewing with made up? 😅
There was a problem hiding this comment.
Yes, broadly, although buffered scheduler frames are only part of the reasoning. After introducing QueuedStream and the separate send scheduler, stream.idle only describes application-delivery state; it no longer represents the complete HTTP/2 stream lifecycle. In particular, a WebSocket stream may report itself idle while its HTTP/2 stream is still registered or half-closed. _close_stream() is the point where both the registered stream and its scheduler state are removed, so the connection is now considered idle only when no registered streams remain. This avoids emitting Updated(idle=True) and starting the connection idle timeout prematurely.
This behavior is covered by test_protocol_idle_requires_no_registered_streams, although the rationale should be made clearer in the code.
This PR bundles a runtime update together with a maintainable benchmark suite that can live in
main.At a high level, it does three things:
QueuedStream,asyncio.wait_for()wrapping when no read timeout is configured,benchmarks/into a reusable suite instead of a loose collection of scripts:Important note about previously incorporated PRs
This branch already carries a small set of upstream-aligned PRs that were incorporated earlier into the fork and are not being presented here as the net-new value of this PR:
DataReceivedTimeoutErrorwhile waiting for the writer to closemockdev dependencyThe performance numbers below therefore compare the current
HEADagainstupstream/main, but they should be read as the result of the current fork state as a whole, not as a claim that every line of improvement is newly introduced by this PR alone.What changed
Runtime
asyncio.wait_for()overhead when no read timeout is configured,Correctness fixes
HTTPStreamstate defensively soBody,EndBody, andStreamClosedcannot hit missing attributes beforeRequest;WSStreamhandshake/connection state defensively for out-of-order event safety;EventWrapper.clear()so existing waiters are not stranded on a replaced event object;QueuedStreamadmission so a first oversized event does not deadlock againstmax_queue_bytes.Benchmarks
main;Benchmark methodology
All public numbers below were produced against
upstream/mainusing the benchmark suite in this branch with the following rules:currentandbaselineruns;This is important because earlier sequential runs were noisy enough to produce misleading conclusions in HTTP/1.1. The current methodology is intended to minimize order effects and machine drift.
Improvement percentages below are relative to
upstream/main:Commands used:
Results vs
upstream/mainGeneral HTTP
Raw medians-of-runs:
314.48 req/s,124.57 ms mean,340.46 ms p95317.50 req/s,123.31 ms mean,331.23 ms p95282.96 req/s,138.79 ms mean,378.16 ms p95281.81 req/s,139.30 ms mean,373.88 ms p952816.19 req/s,14.02 ms mean,16.71 ms p952569.14 req/s,15.34 ms mean,16.84 ms p95Targeted protocol scenarios
These scenarios are the most important for the structural runtime changes in this PR.
Raw medians-of-runs:
1.13 ms mean,1.25 ms p9527.18 ms mean,27.79 ms p953.45 ms mean,3.55 ms p95303.18 ms mean,311.52 ms p951998.00 req/s,18.91 ms mean,21.25 ms p952613.21 req/s,14.59 ms mean,31.53 ms p95Interpretation
The main outcome is not a uniform micro-optimization across every path. The strongest result in this branch is still the same: Hypercorn becomes much better at the HTTP/2 cases where multiplexing actually matters:
HTTP/1.1 remains close to upstream:
That tradeoff is acceptable for this branch because the dominant gains are large, targeted and repeatable in HTTP/2, while the general HTTP/1.1 results remain close to upstream rather than materially diverging.
Why include the benchmark suite in the same PR
This branch has been developed under a strict “measure first” rule. Keeping the benchmark suite alongside the runtime changes makes future work easier to validate:
upstream/mainor another local repo directly;Suggested reviewer focus
main;