Skip to content

Run the HTTP/3 tests against a local server, fix two HTTP/3 bugs - #943

Merged
benoitc merged 5 commits into
masterfrom
test/h3-local-servers
Sep 24, 2026
Merged

benoitc merged 5 commits into
masterfrom
test/h3-local-servers

Conversation

@benoitc

@benoitc benoitc commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Nine test modules sent HTTP/3 requests to public servers (mostly cloudflare.com) under eunit's default 5 s limit, and accepted a failed connection as a pass. A slow UDP path from a CI runner made them time out: that is the macOS flake on #939 and #941.

hackney_h3_test_server starts an in-process quic_h3 server on 127.0.0.1 with the test certificates. The tests now assert real results instead of tolerating network errors: protocol, status, headers and bodies, redirect following and the max_redirect limit, Alt-Svc upgrading a TCP origin to HTTP/3 and the blocked-host fallback, and certificate verification against the test CA. The public-server checks in hackney_http3_e2e_SUITE move to an interop group that runs with HACKNEY_H3_INTEROP=1.

Three HTTP/3 bugs the stricter tests turned up, fixed here:

  • The response to an HTTP/3 streaming upload could not be read: body/1 returned {error, invalid_state} and stream_body/1 returned {error, no_stream}, because the body went to the start_response/1 caller as a second reply and the stream was dropped. The stream now enters the same pull states as request_streaming, body/1 gains an HTTP/3 clause, and start_response/1 answers at once when the headers came first. finish_send_body/1 no longer parks its already answered caller, which made start_response/1 hang when the headers arrived early.
  • hackney_h3 passed cacertfile to quic, which only takes DER cacerts, so it was ignored and verification failed as {error, timeout}. It is now decoded.
  • A closing HTTP/3 connection reported no reason. quic_h3 sends one with its close event and hackney only matched the older shape without it, so the message was dropped, the h3 process stayed alive and the caller waited out its timeout. A handshake that fails on a bad certificate now comes back as {error, {connection_closed, {certificate_invalid, _}}} instead of {error, timeout}. Both shapes are handled, since quic =< 1.10.0 still closes without a reason on two of its paths.

Found along the way, left for separate changes:

  • Resuming an HTTP/3 connection from a cached session ticket can stall the handshake: 6 of 10 runs on one scheduler, none with zero_rtt off. The local tests run with {zero_rtt, false}.
  • When the HTTP/3 connect times out, hackney:connect lets the gen_statem:call timeout exit escape instead of returning {error, _}.
  • Once a quic release carries Finish the HTTP/3 owner contract and accept a CA file erlang_quic#355, hackney should answer callers waiting in stream_body/1 or body/1 when their stream is reset. Until then quic drops a peer RESET_STREAM on request streams, so that path cannot be tested.

Nine test modules sent HTTP/3 requests to public servers, mostly
cloudflare.com, under eunit's default 5 s limit, and accepted a failed
connection as a pass. A slow UDP path from a CI runner made them time
out, which is the macOS flake seen on #939 and #941.

hackney_h3_test_server starts an in-process quic_h3 server on 127.0.0.1
with the test certificate and a few routes (/, /cdn-cgi/trace, /large,
/redirect/N, /status/N, POST echo). The tests now assert real results:
protocol, status, headers and bodies, redirects followed and the
max_redirect limit, Alt-Svc upgrading a TCP origin to HTTP/3 and the
blocked-host fallback, and certificate verification against the test
CA. The public-server checks in hackney_http3_e2e_SUITE move to an
interop group that runs when HACKNEY_H3_INTEROP is set.
After an HTTP/3 streaming upload the response body could not be read:
body/1 returned {error, invalid_state} and stream_body/1 {error,
no_stream}. The stream moved to a receiving state keyed to the
start_response/1 caller, whose call was already answered, and the body
went to it as a second reply before the stream was dropped. The stream
now enters the same pull states as request_streaming, body/1 gains an
HTTP/3 clause that returns the rest of the body at FIN, and
start_response/1 answers at once when the headers came first.
finish_send_body/1 no longer parks its already answered caller, which
made start_response/1 wait forever when the headers arrived early.

hackney_h3 passed cacertfile to quic, which only takes DER cacerts, so
the option was ignored and verification failed as {error, timeout}. It
is now decoded, with the helper hackney_conn already used.

The local HTTP/3 tests get 15 s for a handshake or a response, and run
without session resumption: resuming from a cached ticket can stall the
handshake, reproduced on one scheduler, which is its own issue.
@benoitc benoitc changed the title Run the HTTP/3 tests against a local server Run the HTTP/3 tests against a local server, fix two HTTP/3 bugs Sep 21, 2026
quic_h3 sends the reason with its close event, and hackney only matched
the older shape without one. The message fell into the catch-all and was
dropped: the h3 process stayed alive, the owner heard nothing, and the
caller waited out its timeout. The failure path before HTTP/3 comes up
(bad certificate, TLS alert) already carries a reason in quic 1.10.0, so
a certificate failure surfaced as {error, timeout}; it now surfaces as
{error, {connection_closed, {certificate_invalid, _}}}.

Both shapes are handled: quic =< 1.10.0 still closes without a reason on
its local-close and QUIC-down paths.
quic_h3 reports a response as {response, StreamId, Status, Headers} and
keeps :status in Headers, so prepending the status again handed every
low-level consumer two of them, which RFC 9114 4.3.1 makes a malformed
response. Drop any :status from the list and keep the one quic parsed.

hackney:request/5 was unaffected: parse_response_headers/1 takes the
first :status and filters the pseudo-headers out.
@benoitc
benoitc merged commit cee0032 into master Sep 24, 2026
6 checks passed
@benoitc
benoitc deleted the test/h3-local-servers branch September 24, 2026 00:08
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