Skip to content

http2: Upgrade: h2c on every worker, and docs for the HTTP/2 work - #3711

Merged
benoitc merged 7 commits into
masterfrom
docs/http2-changelog
Aug 24, 2026
Merged

http2: Upgrade: h2c on every worker, and docs for the HTTP/2 work#3711
benoitc merged 7 commits into
masterfrom
docs/http2-changelog

Conversation

@benoitc

@benoitc benoitc commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Finishes the HTTP/2 work.

Upgrade: h2c is served on the gthread, gevent and asgi workers: a client
asking 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_ips gate as prior knowledge. http2_cleartext now means the
same 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();
PythonProtocol gains the same accessor so the parser choice does not decide
which protocols are available. The ASGI handover runs synchronously in
data_received(), which is what keeps the preface and the frames behind it away
from 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 both was rejecting the very request it exists to accept, and plain HTTP/1
with 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_cleartext stays off by default. The 400 on a non-preface still
applies to prior-knowledge, which is the only value that did anything before.

@benoitc benoitc changed the title docs: changelog and guide for the HTTP/2 work http2: Upgrade: h2c, and docs for the HTTP/2 work Aug 22, 2026
@benoitc
benoitc force-pushed the docs/http2-changelog branch from 55e1c93 to 60530db Compare August 22, 2026 09:51
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
benoitc force-pushed the docs/http2-changelog branch from 60530db to 6d0679e Compare August 22, 2026 14:32
@benoitc benoitc changed the title http2: Upgrade: h2c, and docs for the HTTP/2 work http2: Upgrade: h2c on every worker, and docs for the HTTP/2 work Aug 22, 2026
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.
@benoitc
benoitc merged commit 7b3f16b into master Aug 24, 2026
26 checks passed
@benoitc
benoitc deleted the docs/http2-changelog branch August 24, 2026 08:52
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.

1 participant