Skip to content

Validate and track HTTP/2 server push streams - #519

Merged
ericmj merged 7 commits into
mainfrom
http2-server-push
Sep 27, 2026
Merged

ericmj merged 7 commits into
mainfrom
http2-server-push

Conversation

@ericmj

@ericmj ericmj commented Sep 27, 2026

Copy link
Copy Markdown
Member

Builds on #518, whose two commits are included here until it merges. The five server push commits are meant to be rebase-merged rather than squashed.

  • PUSH_PROMISE on a stream the client already closed or cancelled no longer fails with {:stream_not_found, id}. The header block is decoded and the promised stream is reset with CANCEL.
  • Promised stream IDs are checked per RFC 9113 5.1.1 and 8.4: no stream 0, no ID lower than an earlier promise, no PUSH_PROMISE on a server-initiated stream. The last promised ID is reported in the GOAWAY Mint sends.
  • Promised requests are validated (RFC 9113 8.3.1, 8.4 and 8.4.1): the required pseudo-headers, including a non-empty :authority, and a :path starting with "/". The method must be safe and cacheable, there must be no content, and connection-specific fields are rejected apart from te: trailers. Invalid promises are reset with PROTOCOL_ERROR.
  • Promised streams open on interim responses too. A pushed response refused because of the client's :max_concurrent_streams returns {:error, promised_ref, :too_many_concurrent_requests} instead of nothing.
  • The promised ref works with cancel_request/2, get_window_size/2 and set_window_size/3. stream_request_body/3 with trailers checks the stream state like DATA does, so it returns :request_is_not_streaming for promised or finished requests instead of sending HEADERS. Trailers also no longer increment open_request_count/1.
  • Frames other than PRIORITY on an even stream ID that was never promised are a connection error.

Streams are removed as soon as the server ends them with END_STREAM, so
a RST_STREAM frame on a stream still being tracked means the response
is incomplete. With the NO_ERROR code it was reported as {:done, ref},
both before any headers and in the middle of a body with a declared
content-length. It now produces {:server_closed_request, :no_error}.
…ed status codes

RFC 9113 8.2.2 makes a message containing connection, keep-alive,
proxy-connection, transfer-encoding or upgrade malformed, which is a
stream error. The te header is only allowed in requests, so it's
rejected in responses and trailers as well.

A :status of 101 is not supported in HTTP/2 (RFC 9113 8.6) and used to
be delivered as an interim response, and :status values below 100 were
accepted although HTTP/1 rejects them; both are now stream errors.
A PUSH_PROMISE on a stream that is no longer in the stream map, for
example because the client cancelled the request before the server
processed the RST_STREAM, raised {:stream_not_found, id} out of
stream/2. The connection stayed open but the socket was never re-armed
with active: :once, frames after the PUSH_PROMISE in the same message
were dropped, and the header block was not decoded, so the next
header block from the server failed with a compression error.

RFC 9113 6.6 requires handling PUSH_PROMISE frames created before the
RST_STREAM was processed. The header block is now decoded to keep the
HPACK table in sync, the promised stream is reset with CANCEL and the
frame is otherwise ignored.
Promised stream identifiers were only checked for being even and
unused, so a server could promise stream 0, promise identifiers lower
than an earlier promise or reuse the identifier of a reset stream, and
it could send PUSH_PROMISE on a stream it initiated itself. RFC 9113
5.1.1 and 8.4 make all of these connection errors. The highest promised
identifier is now tracked and also reported as the last stream
identifier in the GOAWAY frame sent on a connection error, which was
hard-coded to 2.

The promised request headers were delivered without validation. They
now need non-empty :method, :scheme, :authority and :path pseudo-headers
before any regular field, a :path starting with "/", a safe and
cacheable method, no content, no connection-specific fields other than
"te: trailers", and field names and values following the same rules as
response headers (RFC 9113 8.2.2, 8.3.1, 8.4 and 8.4.1). A promise that
fails is reset with PROTOCOL_ERROR.

GOAWAY marked pushed streams above the last stream identifier as
unprocessed and dropped their responses, but that identifier only
covers client-initiated streams (RFC 9113 6.8).
An interim (1xx) response on a stream reserved by a PUSH_PROMISE closed
the connection with a protocol error because the stream was still in the
reserved state. RFC 9113 5.1 moves a reserved (remote) stream to
half-closed (local) on any HEADERS frame, so the stream is now opened,
and counted against the concurrency limit, before the interim response
is delivered.

Opening the stream first also means a pushed response that ends with its
HEADERS frame no longer makes the client send a RST_STREAM on the closed
stream (RFC 9113 5.1).

A pushed response refused at that point because it would exceed the
client's max_concurrent_streams setting was reset without any response
for the promised request ref. It now returns a
:too_many_concurrent_requests error for that ref.
Streams reserved by a PUSH_PROMISE were added to the stream map but not
to the ref-to-stream index, so functions taking the promised request
ref treated it as unknown. cancel_request/2 returned {:ok, conn} without
sending RST_STREAM, so there was no way to refuse a pushed response,
get_window_size/2 raised ArgumentError, and set_window_size/3 returned
:unknown_request_to_stream. The docs describe the promised ref as a
request ref like any other.

stream_request_body/3 with trailers now checks the stream state the way
DATA does, so trailers for a promised request or for a request whose
body has ended return :request_is_not_streaming instead of sending a
HEADERS frame. Trailers also no longer count the request as open a
second time in open_request_count/1.
Frames on an even stream ID that was never promised were ignored. A
server only opens streams through PUSH_PROMISE (RFC 9113 8.4 and 5.1.1),
so any frame other than PRIORITY on such an idle stream is now a
connection error, the same treatment client streams above the next
stream ID already got.
@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 0

Coverage increased (+0.6%) to 90.159%

Details

  • Coverage increased (+0.6%) from the base build.
  • Patch coverage: 2 uncovered changes across 1 file (112 of 114 lines covered, 98.25%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
lib/mint/http2.ex 114 112 98.25%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1951
Covered Lines: 1759
Line Coverage: 90.16%
Coverage Strength: 665.62 hits per line

💛 - Coveralls

@ericmj
ericmj marked this pull request as ready for review September 27, 2026 18:50
@ericmj
ericmj merged commit 6c531fe into main Sep 27, 2026
3 checks passed
@ericmj
ericmj deleted the http2-server-push branch September 27, 2026 19:56
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.

2 participants