http2: Upgrade: h2c on every worker, and docs for the HTTP/2 work - #3711
Merged
Conversation
benoitc
force-pushed
the
docs/http2-changelog
branch
from
August 22, 2026 09:51
55e1c93 to
60530db
Compare
Six merged changes had no entry: h2c on the three workers, the header policy fix, response streaming, the no-body and flow-control fixes. Also states plainly that only prior-knowledge is implemented, since http2_cleartext accepts upgrade and both today while doing nothing with them.
An HTTP/1.1 request asking for h2c gets a 101 and the connection carries on as HTTP/2, with the upgraded request served as stream 1. Bytes the client pipelined behind the request are taken from the parser's unreader and handed to the HTTP/2 connection rather than lost, which is what take_buffered() is for: read() would block on the socket instead. The refusal policy had to be narrowed. A trusted peer that does not send the preface is only a 400 when prior knowledge is the sole mechanism; with upgrade enabled an HTTP/1 request is how an upgrade begins, so refusing it made 'both' reject the very request it exists to accept. The ASGI worker still accepts prior knowledge only.
An Upgrade: h2c request may carry a payload (RFC 7540 3.2). The payload and the frames the client pipelines behind it share the parser's unreader, and the upgrade handler collected the frames first, so the payload was replayed into the HTTP/2 state machine as if it were a connection preface. h2 rejected it and the connection died; a POST upgraded by curl was enough to trigger it. The body is drained before the tail is taken, and passed on to stream 1.
The upgrade path needed the bytes a client pipelines behind the request, which the callback parser could not return. gunicorn_h1c 0.6.8 added remaining(); PythonProtocol gains the same accessor so the parser choice does not decide which protocols are available. The handover runs synchronously in data_received(), which is what keeps the connection preface and the frames behind it away from the HTTP/1 parser. The two parsers disagree about where an upgrade request's payload goes, so it is reassembled before stream 1 is built. Chunked upgrade requests stay on HTTP/1.1: the fast parser hands their payload back still encoded and mixed into the following bytes, leaving no boundary to split on. http2_cleartext now means the same thing on gthread, gevent and asgi.
The ASGI sniffer answered 400 to anything that was not the connection preface, whatever the mode. Under both, the HTTP/1 request carrying the upgrade reaches the sniffer first, so the setting rejected exactly what it exists to accept, and plain HTTP/1 along with it. The gthread and gevent workers route that decision through mismatch_is_error(); ASGI now does the same and falls back to HTTP/1 with the buffered bytes replayed. A client that connects and stays quiet is likewise no longer refused unless prior knowledge stands alone.
benoitc
force-pushed
the
docs/http2-changelog
branch
from
August 22, 2026 14:32
60530db to
6d0679e
Compare
http_parser='fast' raises when the parser is not installed, so the parametrised ASGI upgrade cases failed on FreeBSD CI, which has no wheel for it. The parser is an optional extra; the python cases still run.
0.6.9 stops an Upgrade header from suppressing body parsing, so both callback parsers now deliver an upgrade request's payload the same way. The reconciliation that pulled it back out of remaining() goes away, and chunked upgrade requests are served over HTTP/2 instead of staying on HTTP/1.1. The parser bug was not specific to h2c: on the ASGI worker any request carrying an Upgrade header reached the application with an empty body, whatever the value of that header and with HTTP/2 switched off.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Finishes the HTTP/2 work.
Upgrade: h2cis served on the gthread, gevent and asgi workers: a clientasking to upgrade over HTTP/1.1 gets a 101 and the connection carries on as
HTTP/2, with the upgraded request served as stream 1, behind the same
forwarded_allow_ipsgate as prior knowledge.http2_cleartextnow means thesame thing on the three workers.
The delicate part is the bytes a client sends straight after the upgrade
request. On the sync workers they are already in the parser's unreader. On ASGI
there was no way to get them back until gunicorn_h1c 0.6.8 added
remaining();PythonProtocolgains the same accessor so the parser choice does not decidewhich protocols are available. The ASGI handover runs synchronously in
data_received(), which is what keeps the preface and the frames behind it awayfrom the HTTP/1 parser.
Two bugs came out of testing this against real clients rather than mocks.
A payload on the upgrade request shares that same buffer, and the sync workers
collected the frames before draining it, so the payload was replayed into the
HTTP/2 state machine as a connection preface. A POST upgraded by curl was enough
to kill the connection.
Enabling upgrade also forced narrowing the refusal policy. A trusted peer that
does not send the preface is only refused with 400 when prior knowledge is the
only mechanism. With upgrade enabled an HTTP/1 request is how an upgrade begins,
so
bothwas rejecting the very request it exists to accept, and plain HTTP/1with it.
Chunked upgrade requests stay on HTTP/1.1 on the ASGI worker. The fast parser
hands their payload back still encoded and mixed into the following bytes, so
there is no boundary to split on. The sync workers are unaffected.
Also carries the changelog and guide for everything merged since 26.1.0 that had
no entry: h2c on the three workers, the header policy fix, response streaming,
and the no-body and flow-control fixes.
Breaking changes
None.
http2_cleartextstaysoffby default. The 400 on a non-preface stillapplies to
prior-knowledge, which is the only value that did anything before.