From 7560e3387c5b68ab571e7474e7f1e159dad5e128 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Mon, 21 Sep 2026 23:05:35 +0200 Subject: [PATCH 1/5] Run the HTTP/3 tests against a local server 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. --- test/hackney_conn_http3_tests.erl | 148 +++----- test/hackney_h3_integration_tests.erl | 443 +++++++++-------------- test/hackney_h3_low_level_tests.erl | 199 ++++------ test/hackney_h3_redirect_tests.erl | 164 ++++----- test/hackney_h3_test_server.erl | 195 ++++++++++ test/hackney_http3_e2e_SUITE.erl | 73 +++- test/hackney_http3_integration_tests.erl | 266 +++++--------- test/hackney_http3_streaming_tests.erl | 359 +++++++----------- test/hackney_http3_tests.erl | 72 ++-- test/hackney_protocol_tests.erl | 46 +-- 10 files changed, 868 insertions(+), 1097 deletions(-) create mode 100644 test/hackney_h3_test_server.erl diff --git a/test/hackney_conn_http3_tests.erl b/test/hackney_conn_http3_tests.erl index 9f20997c..247bc3e1 100644 --- a/test/hackney_conn_http3_tests.erl +++ b/test/hackney_conn_http3_tests.erl @@ -16,128 +16,64 @@ %%==================================================================== setup() -> - {ok, _} = application:ensure_all_started(hackney), - ok. + hackney_h3_test_server:start(). -cleanup(_) -> +cleanup(Server) -> hackney_conn_sup:stop_all(), - %% Allow time for late UDP packets to be processed - timer:sleep(100), - ok. + hackney_h3_test_server:stop(Server). %%==================================================================== %% HTTP/3 Connection Tests %%==================================================================== -%% Test HTTP/3 connection via hackney_conn +%% HTTP/3 connections via hackney_conn, against a local HTTP/3 server. http3_conn_test_() -> { "HTTP/3 connection tests via hackney_conn", { setup, fun setup/0, fun cleanup/1, - [ - {"HTTP/3 connection and request", fun test_h3_connection_request/0}, - {"HTTP/3 get_protocol returns http3", fun test_h3_get_protocol/0}, - {"HTTP/3 peername/sockname/peercert", fun test_h3_peer_info/0} - ] + fun(Server) -> + [{Title, {timeout, 30, fun() -> Test(Server) end}} || {Title, Test} <- [ + {"HTTP/3 connection and request", fun test_h3_connection_request/1}, + {"HTTP/3 get_protocol returns http3", fun test_h3_get_protocol/1}, + {"HTTP/3 peername/sockname/peercert", fun test_h3_peer_info/1} + ]] + end } }. -test_h3_connection_request() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - %% Start connection with HTTP/3 enabled - {ok, Pid} = hackney_conn:start_link(#{ - host => "cloudflare.com", - port => 443, - transport => hackney_ssl, - connect_options => [{protocols, [http3]}], - connect_timeout => 15000 - }), - ?assert(is_pid(Pid)), - - %% Connect - case hackney_conn:connect(Pid, 15000) of - ok -> - %% Verify protocol is HTTP/3 - ?assertEqual(http3, hackney_conn:get_protocol(Pid)), - - %% Make a request - case hackney_conn:request(Pid, <<"GET">>, <<"/">>, [], <<>>, 15000) of - {ok, Status, Headers, Body} -> - ?assert(is_integer(Status)), - ?assert(Status >= 200 andalso Status < 600), - ?assert(is_list(Headers)), - ?assert(is_binary(Body)); - {error, Reason} -> - %% Request might fail due to network - acceptable - ?debugFmt("HTTP/3 request failed: ~p", [Reason]) - end, - - hackney_conn:stop(Pid); - {error, timeout} -> - %% HTTP/3 connection timeout - might be UDP blocked - hackney_conn:stop(Pid), - ?debugMsg("HTTP/3 connection timed out (UDP may be blocked)"); - {error, Reason} -> - hackney_conn:stop(Pid), - ?debugFmt("HTTP/3 connection failed: ~p", [Reason]) - end - end. - -test_h3_get_protocol() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - %% Start connection configured for HTTP/3 - {ok, Pid} = hackney_conn:start_link(#{ - host => "cloudflare.com", - port => 443, - transport => hackney_ssl, - connect_options => [{protocols, [http3]}], - connect_timeout => 15000 - }), - - %% Before connecting, protocol is http1 (default) - ?assertEqual(http1, hackney_conn:get_protocol(Pid)), - - %% After connecting, should be http3 - case hackney_conn:connect(Pid, 15000) of - ok -> - ?assertEqual(http3, hackney_conn:get_protocol(Pid)); - {error, _} -> - %% Connection failed - that's ok for this test - ok - end, - hackney_conn:stop(Pid) - end. - -test_h3_peer_info() -> - {ok, Pid} = hackney_conn:start_link(#{ - host => "cloudflare.com", - port => 443, - transport => hackney_ssl, - connect_options => [{protocols, [http3]}], - connect_timeout => 15000 - }), - case hackney_conn:connect(Pid, 15000) of - ok -> - ?assertEqual(http3, hackney_conn:get_protocol(Pid)), - ?assertMatch({ok, {_, _}}, hackney_conn:peername(Pid)), - ?assertMatch({ok, {_, _}}, hackney_conn:sockname(Pid)), - case hackney_conn:peercert(Pid) of - {ok, Cert} -> ?assert(is_binary(Cert)); - {error, no_peercert} -> ok - end, - hackney_conn:stop(Pid); - {error, Reason} -> - hackney_conn:stop(Pid), - ?debugFmt("H3 peer info test skipped: ~p", [Reason]) - end. +test_h3_connection_request(Server) -> + {ok, Pid} = hackney_conn:start_link(hackney_h3_test_server:conn_opts(Server)), + ok = hackney_conn:connect(Pid, 5000), + ?assertEqual(http3, hackney_conn:get_protocol(Pid)), + {ok, Status, Headers, Body} = + hackney_conn:request(Pid, <<"GET">>, <<"/">>, [], <<>>, 5000), + ?assertEqual(200, Status), + ?assertEqual(<<"text/html">>, proplists:get_value(<<"content-type">>, Headers)), + ?assertEqual(<<"hackney h3 test server">>, Body), + hackney_conn:stop(Pid). + +test_h3_get_protocol(Server) -> + {ok, Pid} = hackney_conn:start_link(hackney_h3_test_server:conn_opts(Server)), + %% Before connecting, the protocol is the http1 default. + ?assertEqual(http1, hackney_conn:get_protocol(Pid)), + ok = hackney_conn:connect(Pid, 5000), + ?assertEqual(http3, hackney_conn:get_protocol(Pid)), + hackney_conn:stop(Pid). + +test_h3_peer_info(Server) -> + Port = hackney_h3_test_server:port(Server), + {ok, Pid} = hackney_conn:start_link(hackney_h3_test_server:conn_opts(Server)), + ok = hackney_conn:connect(Pid, 5000), + ?assertEqual(http3, hackney_conn:get_protocol(Pid)), + ?assertMatch({ok, {{127, 0, 0, 1}, Port}}, hackney_conn:peername(Pid)), + ?assertMatch({ok, {_, _}}, hackney_conn:sockname(Pid)), + case hackney_conn:peercert(Pid) of + {ok, Cert} -> ?assert(is_binary(Cert)); + {error, no_peercert} -> ok + end, + hackney_conn:stop(Pid). %%==================================================================== %% hackney_h3 Module Tests diff --git a/test/hackney_h3_integration_tests.erl b/test/hackney_h3_integration_tests.erl index eec8f7a8..66e7de78 100644 --- a/test/hackney_h3_integration_tests.erl +++ b/test/hackney_h3_integration_tests.erl @@ -13,7 +13,7 @@ %%% - Various HTTP methods over HTTP/3 %%% - Error handling and edge cases %%% -%%% Tests use cloudflare.com and other public HTTP/3 servers. +%%% Tests run against a local HTTP/3 server (hackney_h3_test_server). -module(hackney_h3_integration_tests). @@ -24,14 +24,21 @@ %%==================================================================== setup() -> - {ok, _} = application:ensure_all_started(hackney), - ok. + hackney_h3_test_server:start(). -cleanup(_) -> +cleanup(Server) -> hackney_conn_sup:stop_all(), - %% Allow time for late UDP packets to be processed - timer:sleep(100), - ok. + hackney_h3_test_server:stop(Server). + +with_server(Tests) -> + {setup, fun setup/0, fun cleanup/1, + fun(Server) -> + [{Title, {timeout, 30, fun() -> Test(Server) end}} || {Title, Test} <- Tests] + end}. + +connect(Server, Opts) -> + hackney_h3:connect(hackney_h3_test_server:host(), + hackney_h3_test_server:port(Server), Opts, self()). %%==================================================================== %% Helper Functions @@ -79,7 +86,7 @@ wait_connected(ConnRef) -> ({closed, Reason}) -> {done, {error, Reason}}; ({transport_error, Code, Msg}) -> {done, {error, {transport_error, Code, Msg}}}; (_) -> continue - end, 15000). + end, 5000). %% Accumulate full response (headers + body) wait_response(ConnRef, StreamId, Timeout) -> @@ -140,88 +147,61 @@ get_location(Headers) -> %%==================================================================== tls_test_() -> - { - "HTTP/3 TLS verification tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"Connect without TLS verification (default)", fun test_connect_no_verify/0}, - {"Connect with TLS verification enabled", fun test_connect_with_verify/0} - ] - } - }. - -test_connect_no_verify() -> - %% Default behavior - no certificate verification - Result = hackney_h3:connect(<<"cloudflare.com">>, 443, #{verify => false}, self()), - ?assertMatch({ok, _}, Result), - {ok, ConnRef} = Result, - case wait_connected(ConnRef) of - {ok, _Info} -> - hackney_h3:close(ConnRef, normal), - ok; - {error, Reason} -> - hackney_h3:close(ConnRef, normal), - %% Connection might fail for network reasons, but should not be TLS error - ?assertNotMatch({tls_error, _}, Reason) - end. - -test_connect_with_verify() -> - %% Explicit TLS verification - Result = hackney_h3:connect(<<"cloudflare.com">>, 443, #{verify => true}, self()), - ?assertMatch({ok, _}, Result), - {ok, ConnRef} = Result, - ConnResult = wait_connected(ConnRef), + {"HTTP/3 TLS verification tests", + with_server([ + {"Connect without TLS verification", fun test_connect_no_verify/1}, + {"Connect with TLS verification against the test CA", fun test_connect_with_verify/1}, + {"Verification fails without the test CA", fun test_connect_verify_untrusted/1} + ])}. + +test_connect_no_verify(Server) -> + {ok, ConnRef} = connect(Server, #{verify => false}), + Result = wait_connected(ConnRef), hackney_h3:close(ConnRef, normal), - %% Should succeed with valid certificate - case ConnResult of - {ok, _} -> ok; - {error, _Reason} -> - %% May fail if CA certs not properly configured, but at least we tried - ok - end. + ?assertMatch({ok, _}, Result). + +test_connect_with_verify(Server) -> + %% The server certificate is issued by the test CA for 127.0.0.1 and + %% localhost, so it verifies once that CA is trusted. + Opts = #{verify => true, cacerts => hackney_h3_test_server:ca_cacerts()}, + {ok, ConnRef} = connect(Server, Opts), + Result = wait_connected(ConnRef), + hackney_h3:close(ConnRef, normal), + ?assertMatch({ok, _}, Result). + +test_connect_verify_untrusted(Server) -> + Result = case connect(Server, #{verify => true}) of + {ok, ConnRef} -> + R = wait_connected(ConnRef), + hackney_h3:close(ConnRef, normal), + R; + {error, _} = Error -> + Error + end, + ?assertMatch({error, _}, Result). %%==================================================================== %% Redirect Tests %%==================================================================== redirect_test_() -> - { - "HTTP/3 redirect handling tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"Detect 301 redirect", fun test_detect_301/0}, - {"Detect 302 redirect", fun test_detect_302/0}, - {"Extract Location header", fun test_location_extraction/0}, - {"Redirect to different path", fun test_redirect_path/0} - ] - } - }. - -test_detect_301() -> - %% Many servers return 301 for http -> https or www redirect - %% Using httpbin.org which supports HTTP/3 and redirects - case make_h3_request(<<"cloudflare.com">>, 443, <<"/cdn-cgi/trace">>) of - {ok, Status, _Headers, _Body} -> - %% Cloudflare trace endpoint returns 200 - ?assert(Status >= 200 andalso Status < 400); - {error, _Reason} -> - %% Network issues are acceptable - ok - end. - -test_detect_302() -> - %% Test with a server that returns 302 - %% Many API endpoints return 302 for temp redirects - case make_h3_request(<<"cloudflare.com">>, 443, <<"/">>) of - {ok, Status, _Headers, _Body} -> - ?assert(Status >= 200 andalso Status < 400); - {error, _Reason} -> - ok - end. + {setup, fun setup/0, fun cleanup/1, + fun(Server) -> + [{"Detect 301 redirect", {timeout, 30, fun() -> test_detect_301(Server) end}}, + {"Detect 302 redirect", {timeout, 30, fun() -> test_detect_302(Server) end}}, + {"Extract Location header", fun test_location_extraction/0}, + {"Redirect to different path", {timeout, 30, fun() -> test_redirect_path(Server) end}}] + end}. + +test_detect_301(Server) -> + {ok, Status, Headers, _Body} = make_h3_request(Server, <<"/status/301">>), + ?assertEqual(301, Status), + ?assertEqual({ok, <<"/">>}, get_location(Headers)). + +test_detect_302(Server) -> + {ok, Status, Headers, _Body} = make_h3_request(Server, <<"/status/302">>), + ?assertEqual(302, Status), + ?assertEqual({ok, <<"/">>}, get_location(Headers)). test_location_extraction() -> %% Test that we can properly extract Location header from redirect response @@ -233,203 +213,129 @@ test_location_extraction() -> FilteredHeaders = filter_pseudo_headers(Headers), ?assertMatch({ok, <<"https://www.example.com/new-path">>}, get_location(FilteredHeaders)). -test_redirect_path() -> - %% Test redirect to different path on same host - case make_h3_request(<<"cloudflare.com">>, 443, <<"/favicon.ico">>) of - {ok, Status, Headers, _Body} -> - %% May return 200 (found) or 3xx (redirect) - ?assert(Status >= 200), - %% If redirect, should have Location header - case Status >= 300 andalso Status < 400 of - true -> ?assertMatch({ok, _}, get_location(Headers)); - false -> ok - end; - {error, _Reason} -> - ok - end. +test_redirect_path(Server) -> + %% Redirect to a different path on the same host. + {ok, Status, Headers, _Body} = make_h3_request(Server, <<"/redirect/1">>), + ?assertEqual(302, Status), + ?assertEqual({ok, <<"/redirect/0">>}, get_location(Headers)). %%==================================================================== %% HTTP Methods Tests %%==================================================================== methods_test_() -> - { - "HTTP/3 method tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"GET request", fun test_get_request/0}, - {"HEAD request", fun test_head_request/0}, - {"POST request", fun test_post_request/0}, - {"Multiple requests on same connection", fun test_multiple_requests/0} - ] - } - }. - -test_get_request() -> - case make_h3_request(<<"cloudflare.com">>, 443, <<"/">>) of - {ok, Status, _Headers, _Body} -> - ?assert(Status >= 200 andalso Status < 400); - {error, _} -> - ok - end. - -test_head_request() -> - %% Use hackney_h3:request for HEAD which properly handles no-body responses - case hackney_h3:request(head, <<"https://cloudflare.com/">>) of - {ok, Status, _RespHeaders, Body} -> - ?assert(Status >= 200 andalso Status < 400), - %% HEAD response should have empty body - ?assertEqual(<<>>, Body); - {error, _} -> - %% Network issues acceptable - ok - end. - -test_post_request() -> - {ok, ConnRef} = hackney_h3:connect(<<"cloudflare.com">>, 443, #{}, self()), - case wait_connected(ConnRef) of - {ok, _} -> - Headers = [ - {<<":method">>, <<"POST">>}, - {<<":scheme">>, <<"https">>}, - {<<":authority">>, <<"cloudflare.com">>}, - {<<":path">>, <<"/cdn-cgi/trace">>}, - {<<"content-type">>, <<"application/json">>}, - {<<"content-length">>, <<"2">>}, - {<<"user-agent">>, <<"hackney-h3-test/1.0">>} - ], - {ok, StreamId} = hackney_h3:send_request(ConnRef, Headers, false), - ok = hackney_h3:send_data(ConnRef, StreamId, <<"{}">>, true), - case wait_response(ConnRef, StreamId, 10000) of - {ok, Status, _RespHeaders, _Body} -> - %% Should get a response (may be 200 or 405 or other) - ?assert(Status >= 200 andalso Status < 600); - {error, _} -> - ok - end, - hackney_h3:close(ConnRef, normal); - {error, _} -> - hackney_h3:close(ConnRef, normal), - ok - end. - -test_multiple_requests() -> - {ok, ConnRef} = hackney_h3:connect(<<"cloudflare.com">>, 443, #{}, self()), - case wait_connected(ConnRef) of - {ok, _} -> - %% First request - Headers1 = build_get_headers(<<"cloudflare.com">>, <<"/">>), - {ok, StreamId1} = hackney_h3:send_request(ConnRef, Headers1, true), - - %% Second request (concurrent) - Headers2 = build_get_headers(<<"cloudflare.com">>, <<"/cdn-cgi/trace">>), - {ok, StreamId2} = hackney_h3:send_request(ConnRef, Headers2, true), - - %% Both stream IDs should be different - ?assertNotEqual(StreamId1, StreamId2), - - %% Wait for both responses (just verify we get them) - _ = wait_response(ConnRef, StreamId1, 10000), - _ = wait_response(ConnRef, StreamId2, 10000), - - hackney_h3:close(ConnRef, normal); - {error, _} -> - hackney_h3:close(ConnRef, normal), - ok - end. + {"HTTP/3 method tests", + with_server([ + {"GET request", fun test_get_request/1}, + {"HEAD request", fun test_head_request/1}, + {"POST request", fun test_post_request/1}, + {"Multiple requests on same connection", fun test_multiple_requests/1} + ])}. + +test_get_request(Server) -> + {ok, Status, Headers, Body} = make_h3_request(Server, <<"/">>), + ?assertEqual(200, Status), + ?assertEqual(<<"text/html">>, proplists:get_value(<<"content-type">>, Headers)), + ?assertEqual(<<"hackney h3 test server">>, Body). + +test_head_request(Server) -> + URL = hackney_h3_test_server:url(Server, <<"/">>), + {ok, Status, _RespHeaders, Body} = + hackney_h3:request(head, URL, [], <<>>, hackney_h3_test_server:h3_opts()), + ?assertEqual(200, Status), + ?assertEqual(<<>>, Body). + +test_post_request(Server) -> + {ok, ConnRef} = connect(Server, hackney_h3_test_server:h3_opts()), + {ok, _} = wait_connected(ConnRef), + Headers = [ + {<<":method">>, <<"POST">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, hackney_h3_test_server:host()}, + {<<":path">>, <<"/echo">>}, + {<<"content-type">>, <<"application/json">>}, + {<<"content-length">>, <<"2">>}, + {<<"user-agent">>, <<"hackney-h3-test/1.0">>} + ], + {ok, StreamId} = hackney_h3:send_request(ConnRef, Headers, false), + ok = hackney_h3:send_data(ConnRef, StreamId, <<"{}">>, true), + {ok, Status, RespHeaders, Body} = wait_response(ConnRef, StreamId, 5000), + hackney_h3:close(ConnRef, normal), + ?assertEqual(200, Status), + ?assertEqual(<<"application/json">>, + proplists:get_value(<<"content-type">>, RespHeaders)), + ?assertEqual(<<"{}">>, Body). + +test_multiple_requests(Server) -> + {ok, ConnRef} = connect(Server, hackney_h3_test_server:h3_opts()), + {ok, _} = wait_connected(ConnRef), + Host = hackney_h3_test_server:host(), + %% Two concurrent requests on the same connection. + {ok, StreamId1} = hackney_h3:send_request(ConnRef, build_get_headers(Host, <<"/">>), true), + {ok, StreamId2} = hackney_h3:send_request(ConnRef, + build_get_headers(Host, <<"/cdn-cgi/trace">>), + true), + ?assertNotEqual(StreamId1, StreamId2), + ?assertMatch({ok, 200, _, _}, wait_response(ConnRef, StreamId1, 5000)), + ?assertMatch({ok, 200, _, <<"h=127.0.0.1\nhttp=http/3\n">>}, + wait_response(ConnRef, StreamId2, 5000)), + hackney_h3:close(ConnRef, normal). %%==================================================================== %% High-level API Tests %%==================================================================== high_level_api_test_() -> - { - "HTTP/3 high-level API tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"hackney_h3:request/2", fun test_h3_simple_request/0}, - {"hackney_h3:request/5 with options", fun test_h3_request_with_options/0}, - {"hackney_h3:connect/3", fun test_h3_connect_api/0} - ] - } - }. - -test_h3_simple_request() -> - case hackney_h3:request(get, <<"https://cloudflare.com/">>) of - {ok, Status, _Headers, _Body} -> - ?assert(Status >= 200 andalso Status < 400); - {error, _Reason} -> - %% Network issues acceptable - ok - end. - -test_h3_request_with_options() -> - Options = #{timeout => 15000, recv_timeout => 10000}, + {"HTTP/3 high-level API tests", + with_server([ + {"hackney_h3:request/5", fun test_h3_simple_request/1}, + {"hackney_h3:request/5 with headers and options", fun test_h3_request_with_options/1}, + {"hackney_h3:connect/3", fun test_h3_connect_api/1} + ])}. + +test_h3_simple_request(Server) -> + URL = hackney_h3_test_server:url(Server, <<"/">>), + ?assertMatch({ok, 200, _, _}, + hackney_h3:request(get, URL, [], <<>>, hackney_h3_test_server:h3_opts())). + +test_h3_request_with_options(Server) -> + URL = hackney_h3_test_server:url(Server, <<"/cdn-cgi/trace">>), + Options = (hackney_h3_test_server:h3_opts())#{timeout => 5000, recv_timeout => 5000}, Headers = [{<<"user-agent">>, <<"hackney-test/1.0">>}], - case hackney_h3:request(get, <<"https://cloudflare.com/">>, Headers, <<>>, Options) of - {ok, Status, _RespHeaders, _Body} -> - ?assert(Status >= 200 andalso Status < 400); - {error, _Reason} -> - ok - end. + ?assertMatch({ok, 200, _, <<"h=127.0.0.1\nhttp=http/3\n">>}, + hackney_h3:request(get, URL, Headers, <<>>, Options)). -test_h3_connect_api() -> - case hackney_h3:connect(<<"cloudflare.com">>, 443, #{}) of - {ok, ConnRef} -> - %% Should be connected - ?assert(is_reference(ConnRef)), - hackney_h3:close(ConnRef); - {error, _Reason} -> - ok - end. +test_h3_connect_api(Server) -> + {ok, ConnRef} = hackney_h3:connect(hackney_h3_test_server:host(), + hackney_h3_test_server:port(Server), + hackney_h3_test_server:h3_opts()), + ?assert(is_reference(ConnRef)), + hackney_h3:close(ConnRef). %%==================================================================== %% Error Handling Tests %%==================================================================== error_handling_test_() -> - { - "HTTP/3 error handling tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"Invalid host", fun test_invalid_host/0}, - {"Connection timeout", fun test_connection_timeout/0}, - {"Invalid port", fun test_invalid_port/0} - ] - } - }. + {"HTTP/3 error handling tests", + [{"Nothing listening", {timeout, 30, fun test_invalid_host/0}}, + {"Connection timeout", {timeout, 30, fun test_connection_timeout/0}}, + {"Invalid port", fun test_invalid_port/0}]}. test_invalid_host() -> - %% Non-existent domain - DNS resolution should fail - case hackney_h3:request(get, <<"https://nonexistent.invalid.domain.test/">>, [], <<>>, #{timeout => 2000}) of - {error, _Reason} -> - %% Expected - DNS or connection should fail - ok; - {ok, _Status, _Headers, _Body} -> - %% Very unlikely but not impossible with DNS wildcards - ok - end. + %% Nothing listens on this port: the request fails instead of hanging. + {ok, _} = application:ensure_all_started(hackney), + URL = iolist_to_binary(["https://127.0.0.1:", + integer_to_list(hackney_h3_test_server:unused_port()), "/"]), + Opts = (hackney_h3_test_server:h3_opts())#{timeout => 1000}, + ?assertMatch({error, _}, hackney_h3:request(get, URL, [], <<>>, Opts)). test_connection_timeout() -> - %% Test with very short timeout - Options = #{timeout => 100}, - case hackney_h3:connect(<<"cloudflare.com">>, 443, Options) of - {ok, ConnRef} -> - %% Fast network, connection succeeded anyway - hackney_h3:close(ConnRef); - {error, timeout} -> - %% Expected - ok; - {error, _Other} -> - %% Some other error - ok - end. + {ok, _} = application:ensure_all_started(hackney), + Opts = (hackney_h3_test_server:h3_opts())#{timeout => 100}, + ?assertMatch({error, _}, + hackney_h3:connect(hackney_h3_test_server:host(), + hackney_h3_test_server:unused_port(), Opts)). test_invalid_port() -> ?assertMatch({error, badarg}, hackney_h3:connect(<<"test">>, 0, #{}, self())), @@ -439,23 +345,14 @@ test_invalid_port() -> %% Internal Functions %%==================================================================== -make_h3_request(Host, Port, Path) -> - case hackney_h3:connect(Host, Port, #{}, self()) of - {ok, ConnRef} -> - case wait_connected(ConnRef) of - {ok, _} -> - Headers = build_get_headers(Host, Path), - {ok, StreamId} = hackney_h3:send_request(ConnRef, Headers, true), - Result = wait_response(ConnRef, StreamId, 10000), - hackney_h3:close(ConnRef, normal), - Result; - {error, _} = Err -> - hackney_h3:close(ConnRef, normal), - Err - end; - {error, _} = Err -> - Err - end. +make_h3_request(Server, Path) -> + {ok, ConnRef} = connect(Server, hackney_h3_test_server:h3_opts()), + {ok, _} = wait_connected(ConnRef), + Headers = build_get_headers(hackney_h3_test_server:host(), Path), + {ok, StreamId} = hackney_h3:send_request(ConnRef, Headers, true), + Result = wait_response(ConnRef, StreamId, 5000), + hackney_h3:close(ConnRef, normal), + Result. build_get_headers(Host, Path) -> [ diff --git a/test/hackney_h3_low_level_tests.erl b/test/hackney_h3_low_level_tests.erl index 29e87a46..96a01581 100644 --- a/test/hackney_h3_low_level_tests.erl +++ b/test/hackney_h3_low_level_tests.erl @@ -17,14 +17,29 @@ %%==================================================================== setup() -> - {ok, _} = application:ensure_all_started(hackney), - ok. + hackney_h3_test_server:start(). -cleanup(_) -> +cleanup(Server) -> hackney_conn_sup:stop_all(), - %% Allow time for late UDP packets to be processed - timer:sleep(100), - ok. + hackney_h3_test_server:stop(Server). + +with_server(Tests) -> + {setup, fun setup/0, fun cleanup/1, + fun(Server) -> + [{Title, {timeout, 30, fun() -> Test(Server) end}} || {Title, Test} <- Tests] + end}. + +connect(Server) -> + hackney_h3:connect(hackney_h3_test_server:host(), + hackney_h3_test_server:port(Server), + hackney_h3_test_server:h3_opts(), self()). + +get_headers(Path) -> + [{<<":method">>, <<"GET">>}, + {<<":scheme">>, <<"https">>}, + {<<":authority">>, hackney_h3_test_server:host()}, + {<<":path">>, Path}, + {<<"user-agent">>, <<"hackney-quic-test/1.0">>}]. %%==================================================================== %% Helper Functions @@ -84,61 +99,33 @@ wait_connected(ConnRef) -> ({connected, Info}) -> {done, {ok, Info}}; ({closed, Reason}) -> {done, {error, Reason}}; (_) -> continue - end, 15000), + end, 5000), Result. %%==================================================================== %% Connection Tests %%==================================================================== -%% Test QUIC connection to a real HTTP/3 server +%% QUIC connection to a local HTTP/3 server. quic_connection_test_() -> - { - "QUIC connection tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"Connect to cloudflare.com", fun test_cloudflare_connect/0}, - {"Test stream opening", fun test_open_stream/0} - ] - } - }. - -test_cloudflare_connect() -> - Result = hackney_h3:connect(<<"cloudflare.com">>, 443, #{}, self()), - ?assertMatch({ok, _}, Result), - {ok, ConnRef} = Result, - + {"QUIC connection tests", + with_server([ + {"Connect to a local server", fun test_local_connect/1}, + {"Test stream opening", fun test_open_stream/1} + ])}. + +test_local_connect(Server) -> + {ok, ConnRef} = connect(Server), ConnResult = wait_connected(ConnRef), hackney_h3:close(ConnRef, normal), + ?assertMatch({ok, #{}}, ConnResult). - case ConnResult of - {ok, Info} -> - ?assert(is_map(Info)); - {error, Reason} -> - ?assertEqual(unexpected_close, Reason) - end. - -test_open_stream() -> - {ok, ConnRef} = hackney_h3:connect(<<"cloudflare.com">>, 443, #{}, self()), - - case wait_connected(ConnRef) of - {ok, _} -> - %% send_request atomically opens a stream and sends HEADERS - Headers = [ - {<<":method">>, <<"GET">>}, - {<<":scheme">>, <<"https">>}, - {<<":authority">>, <<"cloudflare.com">>}, - {<<":path">>, <<"/">>} - ], - Result = hackney_h3:send_request(ConnRef, Headers, true), - ?assertMatch({ok, _}, Result), - hackney_h3:close(ConnRef, normal); - {error, _} -> - hackney_h3:close(ConnRef, normal), - ?assert(false, "Connection closed unexpectedly") - end. +test_open_stream(Server) -> + {ok, ConnRef} = connect(Server), + {ok, _} = wait_connected(ConnRef), + %% send_request atomically opens a stream and sends HEADERS + ?assertMatch({ok, _}, hackney_h3:send_request(ConnRef, get_headers(<<"/">>), true)), + hackney_h3:close(ConnRef, normal). %%==================================================================== %% get_fd Tests @@ -159,87 +146,43 @@ get_fd_test() -> %%==================================================================== %% Test sending HTTP/3 request headers -test_send_request() -> - {ok, ConnRef} = hackney_h3:connect(<<"cloudflare.com">>, 443, #{}, self()), - - case wait_connected(ConnRef) of - {ok, _} -> - Headers = [ - {<<":method">>, <<"GET">>}, - {<<":path">>, <<"/">>}, - {<<":scheme">>, <<"https">>}, - {<<":authority">>, <<"cloudflare.com">>}, - {<<"user-agent">>, <<"hackney-quic-test/1.0">>} - ], - Result = hackney_h3:send_request(ConnRef, Headers, true), - ?assertMatch({ok, _}, Result), - hackney_h3:close(ConnRef, normal); - {error, _} -> - hackney_h3:close(ConnRef, normal), - ?assert(false, "Connection closed unexpectedly") - end. +test_send_request(Server) -> + {ok, ConnRef} = connect(Server), + {ok, _} = wait_connected(ConnRef), + ?assertMatch({ok, _}, hackney_h3:send_request(ConnRef, get_headers(<<"/">>), true)), + hackney_h3:close(ConnRef, normal). http3_request_test_() -> - { - "HTTP/3 request tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"Send HTTP/3 headers", fun test_send_request/0}, - {"Full HTTP/3 request/response", fun test_full_request_response/0} - ] - } - }. + {"HTTP/3 request tests", + with_server([ + {"Send HTTP/3 headers", fun test_send_request/1}, + {"Full HTTP/3 request/response", fun test_full_request_response/1} + ])}. %% Test full HTTP/3 request and response flow -test_full_request_response() -> - {ok, ConnRef} = hackney_h3:connect(<<"cloudflare.com">>, 443, #{}, self()), - - case wait_connected(ConnRef) of - {ok, _} -> - Headers = [ - {<<":method">>, <<"GET">>}, - {<<":scheme">>, <<"https">>}, - {<<":authority">>, <<"cloudflare.com">>}, - {<<":path">>, <<"/">>}, - {<<"user-agent">>, <<"hackney-quic-test/1.0">>} - ], - {ok, _StreamId} = hackney_h3:send_request(ConnRef, Headers, true), - - %% Wait for response headers - HeaderResult = quic_loop(ConnRef, fun - ({stream_headers, _SId, RespHeaders, _Fin}) -> - {done, {ok, RespHeaders}}; - ({closed, Reason}) -> - {done, {error, {closed, Reason}}}; - (_) -> continue - end, 5000), - - case HeaderResult of - {ok, RespHeaders} -> - ?assert(lists:keymember(<<":status">>, 1, RespHeaders)); - {error, timeout} -> - hackney_h3:close(ConnRef, normal), - ?assert(false, "Timeout waiting for response headers"); - {error, Other} -> - hackney_h3:close(ConnRef, normal), - ?assertEqual(ok, Other) - end, - - %% Wait for response body (optional - might be empty for redirects) - _ = quic_loop(ConnRef, fun - ({stream_data, _SId, Body, _BodyFin}) when byte_size(Body) > 0 -> - {done, {ok, Body}}; - ({closed, _}) -> - {done, ok}; - (_) -> continue - end, 5000), - - hackney_h3:close(ConnRef, normal); - {error, _} -> - hackney_h3:close(ConnRef, normal), - ?assert(false, "Connection closed unexpectedly") +test_full_request_response(Server) -> + {ok, ConnRef} = connect(Server), + {ok, _} = wait_connected(ConnRef), + {ok, StreamId} = hackney_h3:send_request(ConnRef, get_headers(<<"/cdn-cgi/trace">>), true), + {ok, RespHeaders} = quic_loop(ConnRef, fun + ({stream_headers, SId, Hdrs, _Fin}) when SId =:= StreamId -> {done, {ok, Hdrs}}; + ({closed, Reason}) -> {done, {error, {closed, Reason}}}; + (_) -> continue + end, 5000), + ?assertEqual({<<":status">>, <<"200">>}, lists:keyfind(<<":status">>, 1, RespHeaders)), + ?assertEqual(<<"h=127.0.0.1\nhttp=http/3\n">>, read_body(ConnRef, StreamId, <<>>)), + hackney_h3:close(ConnRef, normal). + +%% Collect DATA until the frame that carries FIN. +read_body(ConnRef, StreamId, Acc) -> + case quic_loop(ConnRef, fun + ({stream_data, SId, Data, Fin}) when SId =:= StreamId -> {done, {Data, Fin}}; + ({closed, Reason}) -> {done, {error, {closed, Reason}}}; + (_) -> continue + end, 5000) of + {Data, true} -> <>; + {Data, false} -> read_body(ConnRef, StreamId, <>); + Error -> Error end. %%==================================================================== diff --git a/test/hackney_h3_redirect_tests.erl b/test/hackney_h3_redirect_tests.erl index 493a978e..6575af20 100644 --- a/test/hackney_h3_redirect_tests.erl +++ b/test/hackney_h3_redirect_tests.erl @@ -40,6 +40,22 @@ cleanup(_) -> %% Helper Functions %%==================================================================== +%% Groups that make requests run against a local HTTP/3 server. +with_server(Tests) -> + {setup, + fun hackney_h3_test_server:start/0, + fun(Server) -> + hackney_conn_sup:stop_all(), + hackney_h3_test_server:stop(Server) + end, + fun(Server) -> + [{Title, {timeout, 30, fun() -> Test(Server) end}} || {Title, Test} <- Tests] + end}. + +h3_request(Server, Path, Opts) -> + hackney_h3:request(get, hackney_h3_test_server:url(Server, Path), [], <<>>, + maps:merge(hackney_h3_test_server:h3_opts(), Opts)). + %%==================================================================== %% Redirect Detection Tests %%==================================================================== @@ -102,37 +118,18 @@ test_relative_redirect() -> %%==================================================================== follow_redirect_test_() -> - { - "HTTP/3 follow_redirect option tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"Request without follow_redirect returns redirect status", fun test_no_follow_redirect/0}, - {"Max redirect limit is enforced", fun test_max_redirect_limit/0} - ] - } - }. - -test_no_follow_redirect() -> - %% When follow_redirect is false (default), redirect responses are returned as-is - %% This tests the behavior by checking how redirects are handled - - %% Using hackney_h3:request directly to test the low-level behavior - case hackney_h3:is_available() of - true -> - %% Test with a known URL that may redirect - case hackney_h3:request(get, <<"https://cloudflare.com/">>) of - {ok, Status, _Headers, _Body} -> - %% Could be 200 or 3xx depending on server - ?assert(Status >= 200 andalso Status < 400); - {error, _Reason} -> - %% Network issues acceptable in tests - ok - end; - false -> - ok - end. + {"HTTP/3 follow_redirect option tests", + [with_server([ + {"Request without follow_redirect returns redirect status", + fun test_no_follow_redirect/1} + ]), + {"Max redirect limit is enforced", fun test_max_redirect_limit/0}]}. + +test_no_follow_redirect(Server) -> + %% follow_redirect defaults to false: the redirect comes back as is. + {ok, Status, Headers, _Body} = h3_request(Server, <<"/status/302">>, #{}), + ?assertEqual(302, Status), + ?assertEqual(<<"/">>, proplists:get_value(<<"location">>, Headers)). test_max_redirect_limit() -> %% Test that max_redirect option limits redirect following @@ -331,77 +328,44 @@ test_build_opts_sni() -> %%==================================================================== integration_test_() -> - { - "HTTP/3 redirect integration tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"Full H3 request returns valid response", fun test_full_h3_request/0}, - {"H3 request with custom headers", fun test_h3_request_custom_headers/0}, - {"H3 request with follow_redirect=true", fun test_h3_follow_redirect/0}, - {"H3 redirect without following returns 3xx", fun test_h3_no_follow_redirect/0}, - {"H3 max_redirect limit works", fun test_h3_max_redirect/0} - ] - } - }. - -test_h3_follow_redirect() -> - %% Test that follow_redirect option is accepted and doesn't break basic requests - %% cloudflare.com may or may not redirect depending on path - Options = #{follow_redirect => true, timeout => 10000}, - case hackney_h3:request(get, <<"https://cloudflare.com/cdn-cgi/trace">>, [], <<>>, Options) of - {ok, Status, _Headers, _Body} -> - %% Should get 200 response (this path typically doesn't redirect) - ?assert(Status >= 200 andalso Status < 400); - {error, _Reason} -> - %% Network issues acceptable - ok - end. - -test_h3_no_follow_redirect() -> - %% Test that without follow_redirect, requests work normally - Options = #{follow_redirect => false, timeout => 10000}, - case hackney_h3:request(get, <<"https://cloudflare.com/cdn-cgi/trace">>, [], <<>>, Options) of - {ok, Status, _Headers, _Body} -> - ?assert(Status >= 200 andalso Status < 400); - {error, _Reason} -> - ok - end. - -test_h3_max_redirect() -> - %% Test that max_redirect option is parsed correctly - Options = #{follow_redirect => true, max_redirect => 3, timeout => 10000}, - case hackney_h3:request(get, <<"https://cloudflare.com/cdn-cgi/trace">>, [], <<>>, Options) of - {ok, _Status, _Headers, _Body} -> - ok; - {error, {max_redirect, _}} -> - ok; - {error, _Reason} -> - ok - end. - -test_full_h3_request() -> - %% Test a full HTTP/3 request using hackney_h3 - case hackney_h3:request(get, <<"https://cloudflare.com/cdn-cgi/trace">>) of - {ok, Status, Headers, Body} -> - ?assert(Status >= 200 andalso Status < 400), - ?assert(is_list(Headers)), - ?assert(is_binary(Body)); - {error, _Reason} -> - %% Network issues acceptable - ok - end. - -test_h3_request_custom_headers() -> + {"HTTP/3 redirect integration tests", + with_server([ + {"Full H3 request returns valid response", fun test_full_h3_request/1}, + {"H3 request with custom headers", fun test_h3_request_custom_headers/1}, + {"H3 request with follow_redirect=true", fun test_h3_follow_redirect/1}, + {"H3 redirect without following returns 3xx", fun test_h3_no_follow_redirect/1}, + {"H3 max_redirect limit works", fun test_h3_max_redirect/1} + ])}. + +test_h3_follow_redirect(Server) -> + %% Two hops, both followed, end on the 200. + ?assertMatch({ok, 200, _, <<"redirected">>}, + h3_request(Server, <<"/redirect/2">>, #{follow_redirect => true})). + +test_h3_no_follow_redirect(Server) -> + {ok, Status, Headers, _Body} = + h3_request(Server, <<"/redirect/2">>, #{follow_redirect => false}), + ?assertEqual(302, Status), + ?assertEqual(<<"/redirect/1">>, proplists:get_value(<<"location">>, Headers)). + +test_h3_max_redirect(Server) -> + %% Three hops against a limit of one. + ?assertMatch({error, {max_redirect, _}}, + h3_request(Server, <<"/redirect/3">>, + #{follow_redirect => true, max_redirect => 1})). + +test_full_h3_request(Server) -> + {ok, Status, Headers, Body} = h3_request(Server, <<"/cdn-cgi/trace">>, #{}), + ?assertEqual(200, Status), + ?assertEqual(<<"text/plain">>, proplists:get_value(<<"content-type">>, Headers)), + ?assertEqual(<<"h=127.0.0.1\nhttp=http/3\n">>, Body). + +test_h3_request_custom_headers(Server) -> Headers = [{<<"user-agent">>, <<"hackney-test/1.0">>}, {<<"accept">>, <<"*/*">>}], - case hackney_h3:request(get, <<"https://cloudflare.com/">>, Headers) of - {ok, Status, _RespHeaders, _Body} -> - ?assert(Status >= 200 andalso Status < 400); - {error, _Reason} -> - ok - end. + URL = hackney_h3_test_server:url(Server, <<"/">>), + ?assertMatch({ok, 200, _, _}, + hackney_h3:request(get, URL, Headers, <<>>, hackney_h3_test_server:h3_opts())). %%==================================================================== %% Internal Helper Functions diff --git a/test/hackney_h3_test_server.erl b/test/hackney_h3_test_server.erl new file mode 100644 index 00000000..f9d8a5d2 --- /dev/null +++ b/test/hackney_h3_test_server.erl @@ -0,0 +1,195 @@ +%%% -*- erlang -*- +%%% +%%% This file is part of hackney released under the Apache 2 license. +%%% See the NOTICE for more information. +%%% +%%% Copyright (c) 2026 Benoit Chesneau +%%% +%%% @doc Local HTTP/3 server for tests. +%%% +%%% Starts an in-process quic_h3 server on 127.0.0.1 with the test +%%% certificate, so HTTP/3 tests do not depend on public servers. Routes: +%%% +%%% GET|HEAD / 200 text/html +%%% GET /cdn-cgi/trace 200 text/plain, a small key=value body +%%% GET /large 200, a 64 KiB body sent in 16 DATA frames +%%% GET /redirect/N 302 to /redirect/N-1; /redirect/0 is 200 +%%% GET /status/N status N; a 3xx carries location: / +%%% POST any path 200, echoes the request body +%%% anything else 404 +-module(hackney_h3_test_server). + +-export([start/0, stop/1]). +-export([port/1, url/2, host/0]). +-export([hackney_opts/0, h3_opts/0, conn_opts/1]). +-export([cert_file/0, ca_cacerts/0, unused_port/0]). + +-define(LARGE_CHUNK, 4096). +-define(LARGE_CHUNKS, 16). + +%% @doc Start a server on a free UDP port. Returns the context the other +%% functions take. +start() -> + {ok, _} = application:ensure_all_started(hackney), + {ok, CertPem} = file:read_file(cert_file()), + {ok, KeyPem} = file:read_file(filename:join(cert_dir(), "server.key")), + [{'Certificate', CertDer, _} | _] = public_key:pem_decode(CertPem), + Name = list_to_atom("hackney_h3_test_server_" ++ + integer_to_list(erlang:unique_integer([positive]))), + Opts = #{cert => CertDer, key => decode_key(KeyPem), + handler => fun handle/5}, + {ok, _Pid} = quic_h3:start_server(Name, 0, Opts), + {ok, Port} = quic:get_server_port(Name), + #{name => Name, port => Port}. + +stop(#{name := Name}) -> + try quic_h3:stop_server(Name) catch _:_ -> ok end, + ok. + +port(#{port := Port}) -> Port. + +host() -> <<"127.0.0.1">>. + +url(#{port := Port}, Path) -> + iolist_to_binary(["https://127.0.0.1:", integer_to_list(Port), Path]). + +%% @doc hackney:request/connect options for an HTTP/3 request to the server. +hackney_opts() -> + [{protocols, [http3]}, + {connect_timeout, 5000}, + {recv_timeout, 5000}, + {ssl_options, [{insecure, true}]}]. + +%% @doc hackney_h3 options: the certificate is self-signed. +h3_opts() -> + #{insecure_skip_verify => true, timeout => 5000, recv_timeout => 5000}. + +%% @doc hackney_conn:start_link/1 options for an HTTP/3 connection. +conn_opts(Ctx) -> + #{host => "127.0.0.1", + port => port(Ctx), + transport => hackney_ssl, + connect_options => [{protocols, [http3]}], + ssl_options => [{insecure, true}], + connect_timeout => 5000, + recv_timeout => 5000}. + +cert_file() -> + filename:join(cert_dir(), "server.pem"). + +%% @doc The CA that issued the server certificate, as DER trust anchors. +ca_cacerts() -> + {ok, Pem} = file:read_file(filename:join(cert_dir(), "ca.pem")), + [Der || {'Certificate', Der, not_encrypted} <- public_key:pem_decode(Pem)]. + +%% @doc A UDP port with nothing listening on it. +unused_port() -> + {ok, Sock} = gen_udp:open(0, [{ip, {127, 0, 0, 1}}]), + {ok, Port} = inet:port(Sock), + ok = gen_udp:close(Sock), + Port. + +%%==================================================================== +%% Handler +%%==================================================================== + +handle(Conn, StreamId, <<"POST">>, _Path, Headers) -> + Body = read_body(Conn, StreamId), + ContentType = proplists:get_value(<<"content-type">>, Headers, + <<"application/octet-stream">>), + respond(Conn, StreamId, 200, [{<<"content-type">>, ContentType}], Body); +handle(Conn, StreamId, <<"HEAD">>, <<"/">>, _Headers) -> + quic_h3:send_response(Conn, StreamId, 200, + [{<<"content-type">>, <<"text/html">>}, + {<<"content-length">>, integer_to_binary(byte_size(index()))}]), + quic_h3:send_data(Conn, StreamId, <<>>, true); +handle(Conn, StreamId, <<"GET">>, <<"/">>, _Headers) -> + respond(Conn, StreamId, 200, [{<<"content-type">>, <<"text/html">>}], index()); +handle(Conn, StreamId, <<"GET">>, <<"/cdn-cgi/trace">>, _Headers) -> + respond(Conn, StreamId, 200, [{<<"content-type">>, <<"text/plain">>}], + <<"h=127.0.0.1\nhttp=http/3\n">>); +handle(Conn, StreamId, <<"GET">>, <<"/large">>, _Headers) -> + Chunk = binary:copy(<<"x">>, ?LARGE_CHUNK), + quic_h3:send_response(Conn, StreamId, 200, + [{<<"content-type">>, <<"application/octet-stream">>}, + {<<"content-length">>, + integer_to_binary(?LARGE_CHUNK * ?LARGE_CHUNKS)}]), + [quic_h3:send_data(Conn, StreamId, Chunk, false) + || _ <- lists:seq(1, ?LARGE_CHUNKS - 1)], + quic_h3:send_data(Conn, StreamId, Chunk, true); +handle(Conn, StreamId, <<"GET">>, <<"/redirect/", N/binary>>, _Headers) -> + case binary_to_integer(N) of + 0 -> + respond(Conn, StreamId, 200, [{<<"content-type">>, <<"text/plain">>}], + <<"redirected">>); + Count -> + Location = <<"/redirect/", (integer_to_binary(Count - 1))/binary>>, + respond(Conn, StreamId, 302, [{<<"location">>, Location}], <<>>) + end; +handle(Conn, StreamId, <<"GET">>, <<"/status/", N/binary>>, _Headers) -> + Status = binary_to_integer(N), + Headers = case Status >= 300 andalso Status < 400 of + true -> [{<<"location">>, <<"/">>}]; + false -> [] + end, + respond(Conn, StreamId, Status, Headers, <<>>); +handle(Conn, StreamId, _Method, _Path, _Headers) -> + respond(Conn, StreamId, 404, [], <<>>). + +index() -> + <<"hackney h3 test server">>. + +respond(Conn, StreamId, Status, Headers, Body) -> + quic_h3:send_response(Conn, StreamId, Status, + [{<<"content-length">>, integer_to_binary(byte_size(Body))} + | Headers]), + quic_h3:send_data(Conn, StreamId, Body, true). + +%% Body DATA received before the handler registered comes back buffered; +%% the rest arrives as messages until Fin. +read_body(Conn, StreamId) -> + case quic_h3:set_stream_handler(Conn, StreamId, self()) of + ok -> + recv_body(Conn, StreamId, <<>>); + {ok, Buffered} -> + case drain_buffered(Buffered, <<>>) of + {fin, Acc} -> Acc; + {more, Acc} -> recv_body(Conn, StreamId, Acc) + end + end. + +drain_buffered([], Acc) -> + {more, Acc}; +drain_buffered([{Data, true} | _], Acc) -> + {fin, <>}; +drain_buffered([{Data, false} | Rest], Acc) -> + drain_buffered(Rest, <>). + +recv_body(Conn, StreamId, Acc) -> + receive + {quic_h3, Conn, {data, StreamId, Data, true}} -> + <>; + {quic_h3, Conn, {data, StreamId, Data, false}} -> + recv_body(Conn, StreamId, <>) + after 5000 -> + Acc + end. + +%%==================================================================== +%% Certificates +%%==================================================================== + +cert_dir() -> + BeamDir = filename:dirname(code:which(?MODULE)), + Root = filename:join([BeamDir, "..", "..", "..", "..", ".."]), + filename:join([filename:absname(Root), "test", "certs"]). + +decode_key(KeyPem) -> + case public_key:pem_decode(KeyPem) of + [{'RSAPrivateKey', Der, not_encrypted}] -> + public_key:der_decode('RSAPrivateKey', Der); + [{'ECPrivateKey', Der, not_encrypted}] -> + public_key:der_decode('ECPrivateKey', Der); + [{'PrivateKeyInfo', Der, not_encrypted}] -> + public_key:der_decode('PrivateKeyInfo', Der) + end. diff --git a/test/hackney_http3_e2e_SUITE.erl b/test/hackney_http3_e2e_SUITE.erl index 31ff2220..018f10df 100644 --- a/test/hackney_http3_e2e_SUITE.erl +++ b/test/hackney_http3_e2e_SUITE.erl @@ -5,18 +5,15 @@ %%% %%% Copyright (c) 2024-2026 Benoit Chesneau %%% -%%% @doc End-to-end tests for HTTP/3 against real servers. +%%% @doc End-to-end HTTP/3 tests. %%% -%%% These tests validate HTTP/3 compliance against production servers: -%%% - cloudflare-quic.com: Cloudflare's QUIC test server -%%% - quic.tech: HTTP/3 test server -%%% - www.google.com: Google with HTTP/3 support +%%% The `local' group runs against an in-process HTTP/3 server +%%% (hackney_h3_test_server) and always runs. The `interop' group checks +%%% compliance against production servers (cloudflare-quic.com, quic.tech, +%%% www.google.com); it needs the network, so it only runs when +%%% HACKNEY_H3_INTEROP is set: %%% -%%% Tests are skipped if: -%%% - Network is unavailable -%%% - QUIC library is not available -%%% -%%% To run: rebar3 ct --suite=hackney_http3_e2e_SUITE +%%% HACKNEY_H3_INTEROP=1 rebar3 ct --suite=test/hackney_http3_e2e_SUITE -module(hackney_http3_e2e_SUITE). @@ -27,11 +24,15 @@ groups/0, init_per_suite/1, end_per_suite/1, + init_per_group/2, + end_per_group/2, init_per_testcase/2, end_per_testcase/2 ]). -export([ + local_simple_get/1, + local_concurrent_streams/1, cloudflare_simple_get/1, cloudflare_concurrent_streams/1, google_http3/1, @@ -45,10 +46,18 @@ %%==================================================================== all() -> - [{group, e2e_tests}]. + Interop = case os:getenv("HACKNEY_H3_INTEROP") of + false -> []; + _ -> [{group, interop}] + end, + [{group, local} | Interop]. groups() -> - [{e2e_tests, [sequence], [ + [{local, [sequence], [ + local_simple_get, + local_concurrent_streams + ]}, + {interop, [sequence], [ cloudflare_simple_get, cloudflare_concurrent_streams, google_http3, @@ -65,6 +74,16 @@ init_per_suite(Config) -> Config end. +init_per_group(local, Config) -> + [{server, hackney_h3_test_server:start()} | Config]; +init_per_group(_Group, Config) -> + Config. + +end_per_group(local, Config) -> + hackney_h3_test_server:stop(?config(server, Config)); +end_per_group(_Group, _Config) -> + ok. + end_per_suite(_Config) -> ok. @@ -79,7 +98,35 @@ end_per_testcase(_TestCase, _Config) -> ok. %%==================================================================== -%% Test Cases +%% Test Cases: local server +%%==================================================================== + +%% @doc Simple GET request to the local HTTP/3 server. +local_simple_get(Config) -> + URL = hackney_h3_test_server:url(?config(server, Config), <<"/">>), + {ok, 200, Headers, Body} = + hackney:get(URL, [], <<>>, hackney_h3_test_server:hackney_opts()), + <<"text/html">> = proplists:get_value(<<"content-type">>, Headers), + <<"hackney h3 test server">> = Body, + ok. + +%% @doc Five concurrent requests over HTTP/3 to the local server. +local_concurrent_streams(Config) -> + URL = hackney_h3_test_server:url(?config(server, Config), <<"/cdn-cgi/trace">>), + Opts = hackney_h3_test_server:hackney_opts(), + Self = self(), + Pids = [spawn_link(fun() -> Self ! {done, self(), hackney:get(URL, [], <<>>, Opts)} end) + || _ <- lists:seq(1, 5)], + Results = [receive {done, Pid, Result} -> Result after ?TIMEOUT -> {error, timeout} end + || Pid <- Pids], + lists:foreach(fun + ({ok, 200, _Headers, <<"h=127.0.0.1\nhttp=http/3\n">>}) -> ok; + (Other) -> ct:fail({unexpected_result, Other}) + end, Results), + ok. + +%%==================================================================== +%% Test Cases: interop with production servers (HACKNEY_H3_INTEROP) %%==================================================================== %% @doc Simple GET request to Cloudflare's QUIC test server. diff --git a/test/hackney_http3_integration_tests.erl b/test/hackney_http3_integration_tests.erl index 1888a23f..96c9081e 100644 --- a/test/hackney_http3_integration_tests.erl +++ b/test/hackney_http3_integration_tests.erl @@ -16,196 +16,120 @@ %%==================================================================== setup() -> - {ok, _} = application:ensure_all_started(hackney), + Server = hackney_h3_test_server:start(), + {ok, _} = application:ensure_all_started(h2), + Certs = filename:dirname(hackney_h3_test_server:cert_file()), + {ok, H2} = h2:start_server(0, #{ + cert => filename:join(Certs, "server.pem"), + key => filename:join(Certs, "server.key"), + handler => fun(Conn, StreamId, _Method, _Path, _Headers) -> + ok = h2:send_response(Conn, StreamId, 200, + [{<<"content-type">>, <<"text/plain">>}]), + ok = h2:send_data(Conn, StreamId, <<"h2">>, true) + end}), hackney_altsvc:clear_all(), - ok. + Server#{h2 => H2, tcp_port => h2:server_port(H2)}. -cleanup(_) -> +cleanup(#{h2 := H2} = Server) -> hackney_conn_sup:stop_all(), hackney_altsvc:clear_all(), - %% Allow time for late UDP packets to be processed - timer:sleep(100), - ok. + _ = h2:stop_server(H2), + hackney_h3_test_server:stop(Server). + +with_server(Tests) -> + {setup, fun setup/0, fun cleanup/1, + fun(Server) -> + [{Title, {timeout, 30, fun() -> Test(Server) end}} || {Title, Test} <- Tests] + end}. + +connect(Server, Opts) -> + hackney:connect(hackney_ssl, "127.0.0.1", hackney_h3_test_server:port(Server), Opts). + +tcp_opts() -> + [{protocols, [http3, http2, http1]}, + {connect_timeout, 5000}, + {ssl_options, [{insecure, true}]}]. %%==================================================================== %% HTTP/3 Integration Tests %%==================================================================== http3_integration_test_() -> - { - "HTTP/3 hackney API integration tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"connect with explicit http3 protocol", fun test_explicit_h3_connect/0}, - {"connect defaults to http2/http1", fun test_default_protocols/0}, - {"Alt-Svc cached enables H3", fun test_altsvc_enables_h3/0} - ] - } - }. - -test_explicit_h3_connect() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - %% Explicitly request HTTP/3 - Opts = [{protocols, [http3]}, {connect_timeout, 15000}], - case hackney:connect(hackney_ssl, "cloudflare.com", 443, Opts) of - {ok, ConnPid} -> - %% Verify protocol is HTTP/3 - ?assertEqual(http3, hackney_conn:get_protocol(ConnPid)), - hackney:close(ConnPid); - {error, Reason} -> - %% H3 might be blocked, acceptable - ?debugFmt("HTTP/3 connect failed (may be blocked): ~p", [Reason]) - end - end. - -test_default_protocols() -> - %% Default protocols should be http2, http1 (no http3) - Opts = [{connect_timeout, 10000}], - case hackney:connect(hackney_ssl, "httpbin.org", 443, Opts) of - {ok, ConnPid} -> - case is_process_alive(ConnPid) of - true -> - Protocol = hackney_conn:get_protocol(ConnPid), - %% Should be http1 or http2, not http3 - ?assert(Protocol =:= http1 orelse Protocol =:= http2), - hackney:close(ConnPid); - false -> - ?debugFmt("Connection process died unexpectedly", []) - end; - {error, Reason} -> - ?debugFmt("Connect failed: ~p", [Reason]) - end. - -test_altsvc_enables_h3() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - %% Manually cache Alt-Svc for cloudflare - hackney_altsvc:cache(<<"cloudflare.com">>, 443, 443, 3600), - - %% Now connect with h3 in protocols list - Opts = [{protocols, [http3, http2, http1]}, {connect_timeout, 15000}], - case hackney:connect(hackney_ssl, "cloudflare.com", 443, Opts) of - {ok, ConnPid} -> - Protocol = hackney_conn:get_protocol(ConnPid), - ?debugFmt("Protocol with Alt-Svc cached: ~p", [Protocol]), - %% Should try HTTP/3 first due to Alt-Svc - hackney:close(ConnPid); - {error, Reason} -> - ?debugFmt("Connect with Alt-Svc failed: ~p", [Reason]) - end, - - %% Clear cache - hackney_altsvc:clear(<<"cloudflare.com">>, 443) - end. + {"HTTP/3 hackney API integration tests", + with_server([ + {"connect with explicit http3 protocol", fun test_explicit_h3_connect/1}, + {"connect defaults to http2/http1", fun test_default_protocols/1}, + {"Alt-Svc cached enables H3", fun test_altsvc_enables_h3/1} + ])}. + +test_explicit_h3_connect(Server) -> + {ok, ConnPid} = connect(Server, hackney_h3_test_server:hackney_opts()), + ?assertEqual(http3, hackney_conn:get_protocol(ConnPid)), + hackney:close(ConnPid). + +test_default_protocols(#{tcp_port := TcpPort}) -> + %% Without http3 in the protocol list, hackney negotiates over TCP. + Opts = [{connect_timeout, 5000}, {ssl_options, [{insecure, true}]}], + {ok, ConnPid} = hackney:connect(hackney_ssl, "127.0.0.1", TcpPort, Opts), + ?assertEqual(http2, hackney_conn:get_protocol(ConnPid)), + hackney:close(ConnPid). + +test_altsvc_enables_h3(#{tcp_port := TcpPort} = Server) -> + %% The origin is the TCP server; Alt-Svc points at the HTTP/3 port. + H3Port = hackney_h3_test_server:port(Server), + ok = hackney_altsvc:cache(<<"127.0.0.1">>, TcpPort, H3Port, 3600), + {ok, ConnPid} = hackney:connect(hackney_ssl, "127.0.0.1", TcpPort, tcp_opts()), + ?assertEqual(http3, hackney_conn:get_protocol(ConnPid)), + hackney:close(ConnPid), + hackney_altsvc:clear(<<"127.0.0.1">>, TcpPort). %%==================================================================== %% Protocol Selection Tests %%==================================================================== protocol_selection_test_() -> - { - "Protocol selection tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"http3 only forces QUIC", fun test_h3_only/0}, - {"blocked h3 falls back", fun test_blocked_fallback/0} - ] - } - }. - -test_h3_only() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - %% Force HTTP/3 only - Opts = [{protocols, [http3]}, {connect_timeout, 15000}], - case hackney:connect(hackney_ssl, "cloudflare.com", 443, Opts) of - {ok, ConnPid} -> - ?assertEqual(http3, hackney_conn:get_protocol(ConnPid)), - hackney:close(ConnPid); - {error, _} -> - %% H3 blocked is acceptable - ok - end - end. - -test_blocked_fallback() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - %% Mark host as H3 blocked - hackney_altsvc:mark_h3_blocked(<<"blocked-test.example.com">>, 443), - - %% Even with H3 in protocols, should not try H3 - ?assert(hackney_altsvc:is_h3_blocked(<<"blocked-test.example.com">>, 443)), - - %% Clear - hackney_altsvc:clear(<<"blocked-test.example.com">>, 443) - end. + {"Protocol selection tests", + with_server([ + {"http3 only forces QUIC", fun test_h3_only/1}, + {"blocked h3 falls back", fun test_blocked_fallback/1} + ])}. + +test_h3_only(Server) -> + {ok, ConnPid} = connect(Server, hackney_h3_test_server:hackney_opts()), + ?assertEqual(http3, hackney_conn:get_protocol(ConnPid)), + hackney:close(ConnPid). + +test_blocked_fallback(#{tcp_port := TcpPort} = Server) -> + %% With HTTP/3 marked blocked for the origin, hackney does not try QUIC + %% even though Alt-Svc advertises it, and falls back to TCP. + H3Port = hackney_h3_test_server:port(Server), + ok = hackney_altsvc:cache(<<"127.0.0.1">>, TcpPort, H3Port, 3600), + hackney_altsvc:mark_h3_blocked(<<"127.0.0.1">>, TcpPort), + ?assert(hackney_altsvc:is_h3_blocked(<<"127.0.0.1">>, TcpPort)), + {ok, ConnPid} = hackney:connect(hackney_ssl, "127.0.0.1", TcpPort, tcp_opts()), + ?assertEqual(http2, hackney_conn:get_protocol(ConnPid)), + hackney:close(ConnPid), + hackney_altsvc:clear(<<"127.0.0.1">>, TcpPort). %%==================================================================== %% hackney:get with HTTP/3 Tests %%==================================================================== http3_get_test_() -> - { - "HTTP/3 hackney:get tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"hackney:get over HTTP/3", fun test_h3_get/0}, - {"hackney:get verifies h3 protocol", fun test_h3_get_protocol/0} - ] - } - }. - -test_h3_get() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - %% GET request over HTTP/3 - URL = "https://cloudflare.com/cdn-cgi/trace", - Opts = [{protocols, [http3]}, {with_body, true}, {connect_timeout, 15000}], - case hackney:get(URL, [], <<>>, Opts) of - {ok, Status, Headers, Body} -> - ?assert(is_integer(Status)), - ?assert(Status >= 200 andalso Status < 400), - ?assert(is_list(Headers)), - ?assert(is_binary(Body)), - ?assert(byte_size(Body) > 0), - %% cloudflare trace should contain "h3" in body - ?debugFmt("H3 GET body: ~s", [Body]); - {error, Reason} -> - ?debugFmt("H3 GET failed (may be blocked): ~p", [Reason]) - end - end. - -test_h3_get_protocol() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - %% Connect and verify protocol is http3 - Opts = [{protocols, [http3]}, {connect_timeout, 15000}], - case hackney:connect(hackney_ssl, "cloudflare.com", 443, Opts) of - {ok, Conn} -> - Protocol = hackney_conn:get_protocol(Conn), - ?assertEqual(http3, Protocol), - hackney:close(Conn); - {error, Reason} -> - ?debugFmt("H3 connect failed: ~p", [Reason]) - end - end. + {"HTTP/3 hackney:get tests", + with_server([ + {"hackney:get over HTTP/3", fun test_h3_get/1}, + {"hackney:get verifies h3 protocol", fun test_h3_get_protocol/1} + ])}. + +test_h3_get(Server) -> + URL = hackney_h3_test_server:url(Server, <<"/cdn-cgi/trace">>), + Opts = [{with_body, true} | hackney_h3_test_server:hackney_opts()], + {ok, 200, Headers, Body} = hackney:get(URL, [], <<>>, Opts), + ?assertEqual(<<"text/plain">>, proplists:get_value(<<"content-type">>, Headers)), + ?assertEqual(<<"h=127.0.0.1\nhttp=http/3\n">>, Body). + +test_h3_get_protocol(Server) -> + {ok, Conn} = connect(Server, hackney_h3_test_server:hackney_opts()), + ?assertEqual(http3, hackney_conn:get_protocol(Conn)), + hackney:close(Conn). diff --git a/test/hackney_http3_streaming_tests.erl b/test/hackney_http3_streaming_tests.erl index 23667a15..4fe8eb8f 100644 --- a/test/hackney_http3_streaming_tests.erl +++ b/test/hackney_http3_streaming_tests.erl @@ -5,7 +5,8 @@ %%% %%% Copyright (c) 2024-2026 Benoit Chesneau %%% -%%% @doc Tests for HTTP/3 async streaming support. +%%% @doc Tests for HTTP/3 async streaming support, against a local +%%% HTTP/3 server (hackney_h3_test_server). -module(hackney_http3_streaming_tests). @@ -16,272 +17,145 @@ %%==================================================================== setup() -> - {ok, _} = application:ensure_all_started(hackney), + Server = hackney_h3_test_server:start(), hackney_altsvc:clear_all(), - ok. + Server. -cleanup(_) -> +cleanup(Server) -> hackney_conn_sup:stop_all(), hackney_altsvc:clear_all(), - %% Allow time for late UDP packets to be processed - timer:sleep(100), - ok. + hackney_h3_test_server:stop(Server). + +with_server(Tests) -> + {setup, fun setup/0, fun cleanup/1, + fun(Server) -> + [{Title, {timeout, 30, fun() -> Test(Server) end}} || {Title, Test} <- Tests] + end}. %%==================================================================== %% HTTP/3 Async Streaming Tests %%==================================================================== h3_async_streaming_test_() -> - { - "HTTP/3 async streaming tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"async=true streams continuously", fun test_h3_async_true/0}, - {"async=once streams on demand", fun test_h3_async_once/0}, - {"async streaming receives status", fun test_h3_async_status/0}, - {"async streaming receives headers", fun test_h3_async_headers/0} - ] - } - }. + with_server([ + {"async=true streams continuously", fun test_h3_async_true/1}, + {"async=once streams on demand", fun test_h3_async_once/1}, + {"async streaming receives status", fun test_h3_async_status/1}, + {"async streaming receives headers", fun test_h3_async_headers/1} + ]). %%==================================================================== %% HTTP/3 Pull-based Streaming Tests (stream_body) %%==================================================================== h3_stream_body_test_() -> - { - "HTTP/3 stream_body tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"stream_body returns chunks", fun test_h3_stream_body/0}, - {"stream_body returns done", fun test_h3_stream_body_done/0} - ] - } - }. - -test_h3_stream_body() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - %% Connect with HTTP/3 - Opts = [{protocols, [http3]}, {connect_timeout, 15000}], - case hackney:connect(hackney_ssl, "cloudflare.com", 443, Opts) of - {ok, ConnPid} -> - %% Use request_streaming to get headers first - case hackney_conn:request_streaming(ConnPid, <<"GET">>, <<"/cdn-cgi/trace">>, [], <<>>) of - {ok, Status, Headers} -> - ?debugFmt("stream_body: Status=~p, Headers=~p", [Status, Headers]), - ?assert(Status >= 200 andalso Status < 400), - ?assert(is_list(Headers)), - %% Now read body with stream_body - Body = read_all_chunks(ConnPid), - ?debugFmt("stream_body: Body=~s", [Body]), - ?assert(byte_size(Body) > 0), - hackney:close(ConnPid); - {error, Reason} -> - hackney:close(ConnPid), - ?debugFmt("request_streaming failed: ~p", [Reason]) - end; - {error, Reason} -> - ?debugFmt("H3 connect failed: ~p", [Reason]) - end - end. - -test_h3_stream_body_done() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - Opts = [{protocols, [http3]}, {connect_timeout, 15000}], - case hackney:connect(hackney_ssl, "cloudflare.com", 443, Opts) of - {ok, ConnPid} -> - case hackney_conn:request_streaming(ConnPid, <<"GET">>, <<"/cdn-cgi/trace">>, [], <<>>) of - {ok, _Status, _Headers} -> - %% Read all chunks until done - Body = read_all_chunks(ConnPid), - ?assert(byte_size(Body) > 0), - %% Calling stream_body again should return error (no stream) - Result = hackney_conn:stream_body(ConnPid), - ?debugFmt("After done, stream_body returns: ~p", [Result]), - %% After stream is done, stream_body returns {error, no_stream} - ?assertEqual({error, no_stream}, Result), - hackney:close(ConnPid); - {error, Reason} -> - hackney:close(ConnPid), - ?debugFmt("request_streaming failed: ~p", [Reason]) - end; - {error, Reason} -> - ?debugFmt("H3 connect failed: ~p", [Reason]) - end - end. + with_server([ + {"stream_body returns chunks", fun test_h3_stream_body/1}, + {"stream_body returns done", fun test_h3_stream_body_done/1} + ]). + +test_h3_stream_body(Server) -> + {ok, ConnPid} = connect(Server), + {ok, Status, Headers} = + hackney_conn:request_streaming(ConnPid, <<"GET">>, <<"/large">>, [], <<>>), + ?assertEqual(200, Status), + ?assert(is_list(Headers)), + Body = read_all_chunks(ConnPid), + ?assertEqual(binary:copy(<<"x">>, 65536), Body), + hackney:close(ConnPid). + +test_h3_stream_body_done(Server) -> + {ok, ConnPid} = connect(Server), + {ok, 200, _Headers} = + hackney_conn:request_streaming(ConnPid, <<"GET">>, <<"/cdn-cgi/trace">>, [], <<>>), + ?assertEqual(<<"h=127.0.0.1\nhttp=http/3\n">>, read_all_chunks(ConnPid)), + %% Once the stream is done, stream_body has no stream to read. + ?assertEqual({error, no_stream}, hackney_conn:stream_body(ConnPid)), + hackney:close(ConnPid). read_all_chunks(ConnPid) -> read_all_chunks(ConnPid, <<>>). read_all_chunks(ConnPid, Acc) -> case hackney_conn:stream_body(ConnPid) of - {ok, Chunk} -> - read_all_chunks(ConnPid, <>); - done -> - Acc; - {error, _Reason} -> - Acc + {ok, Chunk} -> read_all_chunks(ConnPid, <>); + done -> Acc end. %%==================================================================== -%% HTTP/3 Body Sending Tests (streaming uploads) +%% HTTP/3 send_body Tests %%==================================================================== h3_send_body_test_() -> - { - "HTTP/3 send_body tests", - { - setup, - fun setup/0, fun cleanup/1, - [ - {"send body in chunks", fun test_h3_send_body_chunks/0} - ] - } - }. - -test_h3_send_body_chunks() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - %% Connect with HTTP/3 to cloudflare (supports H3) - Opts = [{protocols, [http3]}, {connect_timeout, 15000}], - case hackney:connect(hackney_ssl, "cloudflare.com", 443, Opts) of - {ok, ConnPid} -> - %% Send headers for POST request (will get redirect, but tests the mechanism) - Headers = [{<<"content-type">>, <<"text/plain">>}], - case hackney_conn:send_request_headers(ConnPid, <<"POST">>, <<"/">>, Headers) of - ok -> - ?debugFmt("send_request_headers ok", []), - %% Send body in chunks - ok = hackney_conn:send_body_chunk(ConnPid, <<"Hello ">>), - ?debugFmt("send_body_chunk 1 ok", []), - ok = hackney_conn:send_body_chunk(ConnPid, <<"World!">>), - ?debugFmt("send_body_chunk 2 ok", []), - ok = hackney_conn:finish_send_body(ConnPid), - ?debugFmt("finish_send_body ok", []), - %% Get response - case hackney_conn:start_response(ConnPid) of - {ok, Status, RespHeaders, _Pid} -> - ?debugFmt("send_body: Status=~p", [Status]), - %% Cloudflare returns redirect (301/302) for POST to / - ?assert(is_integer(Status)), - ?assert(is_list(RespHeaders)), - hackney:close(ConnPid); - {error, Reason} -> - hackney:close(ConnPid), - ?debugFmt("start_response failed: ~p", [Reason]) - end; - {error, Reason} -> - hackney:close(ConnPid), - ?debugFmt("send_request_headers failed: ~p", [Reason]) - end; - {error, Reason} -> - ?debugFmt("H3 connect failed: ~p", [Reason]) - end - end. - -test_h3_async_true() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - %% Async streaming request over HTTP/3 - URL = <<"https://cloudflare.com/cdn-cgi/trace">>, - Opts = [{protocols, [http3]}, {connect_timeout, 15000}, {async, true}], - case hackney:get(URL, [], <<>>, Opts) of - {ok, Ref} when is_pid(Ref) -> - %% Collect all async messages - Messages = collect_async_messages(Ref, 10000), - ?debugFmt("Async messages: ~p", [Messages]), - %% Verify we got status, headers, body chunks, and done - ?assert(has_status_message(Messages)), - ?assert(has_headers_message(Messages)), - ?assert(has_done_message(Messages)); - {error, Reason} -> - ?debugFmt("H3 async request failed (may be blocked): ~p", [Reason]) - end - end. + with_server([ + {"send body in chunks", fun test_h3_send_body_chunks/1} + ]). + +test_h3_send_body_chunks(Server) -> + {ok, ConnPid} = connect(Server), + Headers = [{<<"content-type">>, <<"text/plain">>}], + ok = hackney_conn:send_request_headers(ConnPid, <<"POST">>, <<"/echo">>, Headers), + ok = hackney_conn:send_body_chunk(ConnPid, <<"Hello ">>), + ok = hackney_conn:send_body_chunk(ConnPid, <<"World!">>), + ok = hackney_conn:finish_send_body(ConnPid), + {ok, 200, RespHeaders, _} = hackney_conn:start_response(ConnPid), + ?assertEqual(<<"text/plain">>, + proplists:get_value(<<"content-type">>, RespHeaders)), + ?assertEqual(<<"12">>, proplists:get_value(<<"content-length">>, RespHeaders)), + hackney:close(ConnPid). -test_h3_async_once() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - %% Async once mode - should still work (data is pushed by QUIC) - URL = <<"https://cloudflare.com/cdn-cgi/trace">>, - Opts = [{protocols, [http3]}, {connect_timeout, 15000}, {async, once}], - case hackney:get(URL, [], <<>>, Opts) of - {ok, Ref} when is_pid(Ref) -> - %% Collect messages - Messages = collect_async_messages(Ref, 10000), - ?debugFmt("Async once messages: ~p", [Messages]), - ?assert(has_status_message(Messages)), - ?assert(has_headers_message(Messages)); - {error, Reason} -> - ?debugFmt("H3 async once request failed: ~p", [Reason]) - end - end. - -test_h3_async_status() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - URL = <<"https://cloudflare.com/">>, - Opts = [{protocols, [http3]}, {connect_timeout, 15000}, {async, true}], - case hackney:get(URL, [], <<>>, Opts) of - {ok, Ref} when is_pid(Ref) -> - Messages = collect_async_messages(Ref, 10000), - %% Find status message - StatusMsg = find_message(fun({hackney_response, _, {status, S, _}}) -> is_integer(S); - (_) -> false - end, Messages), - ?assertNotEqual(undefined, StatusMsg), - {hackney_response, _, {status, Status, _}} = StatusMsg, - ?assert(Status >= 200 andalso Status < 400); - {error, Reason} -> - ?debugFmt("H3 async status test failed: ~p", [Reason]) - end - end. +%%==================================================================== +%% Async tests +%%==================================================================== -test_h3_async_headers() -> - case hackney_h3:is_available() of - false -> - {skip, "QUIC NIF not available"}; - true -> - URL = <<"https://cloudflare.com/">>, - Opts = [{protocols, [http3]}, {connect_timeout, 15000}, {async, true}], - case hackney:get(URL, [], <<>>, Opts) of - {ok, Ref} when is_pid(Ref) -> - Messages = collect_async_messages(Ref, 10000), - %% Find headers message - HeadersMsg = find_message(fun({hackney_response, _, {headers, H}}) -> is_list(H); - (_) -> false - end, Messages), - ?assertNotEqual(undefined, HeadersMsg), - {hackney_response, _, {headers, Headers}} = HeadersMsg, - ?assert(length(Headers) > 0), - ?debugFmt("H3 async headers: ~p", [Headers]); - {error, Reason} -> - ?debugFmt("H3 async headers test failed: ~p", [Reason]) - end - end. +test_h3_async_true(Server) -> + URL = hackney_h3_test_server:url(Server, <<"/large">>), + {ok, Ref} = hackney:get(URL, [], <<>>, [{async, true} | opts()]), + Messages = collect_async_messages(Ref, 5000), + ?assert(has_status_message(Messages)), + ?assert(has_headers_message(Messages)), + ?assert(has_done_message(Messages)), + ?assertEqual(binary:copy(<<"x">>, 65536), body_of(Messages)). + +test_h3_async_once(Server) -> + URL = hackney_h3_test_server:url(Server, <<"/cdn-cgi/trace">>), + {ok, Ref} = hackney:get(URL, [], <<>>, [{async, once} | opts()]), + Messages = collect_once_messages(Ref, 5000), + ?assert(has_status_message(Messages)), + ?assert(has_headers_message(Messages)), + ?assert(has_done_message(Messages)), + ?assertEqual(<<"h=127.0.0.1\nhttp=http/3\n">>, body_of(Messages)). + +test_h3_async_status(Server) -> + URL = hackney_h3_test_server:url(Server, <<"/">>), + {ok, Ref} = hackney:get(URL, [], <<>>, [{async, true} | opts()]), + Messages = collect_async_messages(Ref, 5000), + ?assertMatch({hackney_response, _, {status, 200, _}}, + find_message(fun({hackney_response, _, {status, _, _}}) -> true; + (_) -> false + end, Messages)). + +test_h3_async_headers(Server) -> + URL = hackney_h3_test_server:url(Server, <<"/">>), + {ok, Ref} = hackney:get(URL, [], <<>>, [{async, true} | opts()]), + Messages = collect_async_messages(Ref, 5000), + {hackney_response, _, {headers, Headers}} = + find_message(fun({hackney_response, _, {headers, _}}) -> true; + (_) -> false + end, Messages), + ?assertEqual(<<"text/html">>, proplists:get_value(<<"content-type">>, Headers)). %%==================================================================== %% Helper Functions %%==================================================================== +opts() -> + hackney_h3_test_server:hackney_opts(). + +connect(Server) -> + hackney:connect(hackney_ssl, "127.0.0.1", hackney_h3_test_server:port(Server), + opts()). + collect_async_messages(Ref, Timeout) -> collect_async_messages(Ref, Timeout, []). @@ -297,6 +171,23 @@ collect_async_messages(Ref, Timeout, Acc) -> lists:reverse(Acc) end. +%% async once delivers one message per stream_next/1. +collect_once_messages(Ref, Timeout) -> + collect_once_messages(Ref, Timeout, []). + +collect_once_messages(Ref, Timeout, Acc) -> + receive + {hackney_response, Ref, done} -> + lists:reverse([{hackney_response, Ref, done} | Acc]); + {hackney_response, Ref, {error, _Reason} = Error} -> + lists:reverse([{hackney_response, Ref, Error} | Acc]); + {hackney_response, Ref, Msg} -> + ok = hackney:stream_next(Ref), + collect_once_messages(Ref, Timeout, [{hackney_response, Ref, Msg} | Acc]) + after Timeout -> + lists:reverse(Acc) + end. + has_status_message(Messages) -> lists:any(fun({hackney_response, _, {status, _, _}}) -> true; (_) -> false @@ -312,6 +203,10 @@ has_done_message(Messages) -> (_) -> false end, Messages). +body_of(Messages) -> + iolist_to_binary([Data || {hackney_response, _, Data} <- Messages, + is_binary(Data)]). + find_message(Pred, Messages) -> case lists:filter(Pred, Messages) of [First | _] -> First; diff --git a/test/hackney_http3_tests.erl b/test/hackney_http3_tests.erl index 5de3f802..6d6a693d 100644 --- a/test/hackney_http3_tests.erl +++ b/test/hackney_http3_tests.erl @@ -7,7 +7,8 @@ %%% %%% @doc Tests for HTTP/3 integration in hackney. %%% -%%% These tests verify that HTTP/3 works through the standard hackney API. +%%% These tests verify that HTTP/3 works through the standard hackney API, +%%% against a local HTTP/3 server (hackney_h3_test_server). -module(hackney_http3_tests). @@ -18,14 +19,11 @@ %%==================================================================== setup() -> - {ok, _} = application:ensure_all_started(hackney), - ok. + hackney_h3_test_server:start(). -cleanup(_) -> +cleanup(Server) -> hackney_conn_sup:stop_all(), - %% Allow time for late UDP packets to be processed - timer:sleep(100), - ok. + hackney_h3_test_server:stop(Server). %%==================================================================== %% hackney_h3 module tests @@ -38,24 +36,18 @@ http3_request_test_() -> { setup, fun setup/0, fun cleanup/1, - [ - {"Direct HTTP/3 request", fun test_http3_direct_request/0} - ] + fun(Server) -> + [{"Direct HTTP/3 request", + {timeout, 30, fun() -> test_http3_direct_request(Server) end}}] + end } }. -test_http3_direct_request() -> - %% Test HTTP/3 connection establishment - Result = hackney_h3:connect(<<"cloudflare.com">>, 443), - case Result of - {ok, ConnRef} -> - hackney_h3:close(ConnRef), - ok; - {error, Reason} -> - %% Connection issues are acceptable in test environments - ?debugFmt("HTTP/3 connect failed: ~p", [Reason]), - ok - end. +test_http3_direct_request(Server) -> + {ok, ConnRef} = hackney_h3:connect(hackney_h3_test_server:host(), + hackney_h3_test_server:port(Server), + hackney_h3_test_server:h3_opts()), + hackney_h3:close(ConnRef). %% Test hackney_h3:parse_response_headers parse_response_headers_test() -> @@ -86,32 +78,18 @@ hackney_conn_http3_config_test_() -> { setup, fun setup/0, fun cleanup/1, - [ - {"Conn start with http3 protocol option", fun test_conn_http3_option/0} - ] + fun(Server) -> + [{"Conn start with http3 protocol option", + {timeout, 30, fun() -> test_conn_http3_option(Server) end}}] + end } }. -test_conn_http3_option() -> - %% Test that hackney_conn can be started with HTTP/3 configuration - Opts = #{ - host => "cloudflare.com", - port => 443, - transport => hackney_ssl, - connect_options => [{protocols, [http3, http2, http1]}], - connect_timeout => 10000 - }, +test_conn_http3_option(Server) -> + %% With http3 first in the protocol list, hackney_conn tries HTTP/3 first. + Opts0 = hackney_h3_test_server:conn_opts(Server), + Opts = Opts0#{connect_options => [{protocols, [http3, http2, http1]}]}, {ok, Pid} = hackney_conn:start_link(Opts), - ?assert(is_pid(Pid)), - %% Connect - this should try HTTP/3 first - Result = hackney_conn:connect(Pid), - hackney_conn:stop(Pid), - case Result of - ok -> - ok; - {error, Reason} -> - %% HTTP/3 might fail and fall back to HTTP/2 or HTTP/1 - %% That's acceptable behavior - ?debugFmt("Connect result: ~p", [Reason]), - ok - end. + ?assertEqual(ok, hackney_conn:connect(Pid)), + ?assertEqual(http3, hackney_conn:get_protocol(Pid)), + hackney_conn:stop(Pid). diff --git a/test/hackney_protocol_tests.erl b/test/hackney_protocol_tests.erl index bb8d37c1..9d36f0ed 100644 --- a/test/hackney_protocol_tests.erl +++ b/test/hackney_protocol_tests.erl @@ -82,7 +82,7 @@ per_request_protocols_test() -> ). %%==================================================================== -%% HTTP/3 Request Opt-In Test (requires network) +%% HTTP/3 Request Opt-In Test (local HTTP/3 server) %%==================================================================== http3_opt_in_request_test_() -> @@ -90,35 +90,27 @@ http3_opt_in_request_test_() -> "HTTP/3 opt-in request tests", { setup, - fun setup/0, fun cleanup/1, - [ - {"HTTP/3 opt-in to cloudflare", fun test_http3_opt_in/0} - ] + fun() -> setup(), hackney_h3_test_server:start() end, + fun(Server) -> cleanup(Server), hackney_h3_test_server:stop(Server) end, + fun(Server) -> + [{"HTTP/3 opt-in to a local server", + {timeout, 30, fun() -> test_http3_opt_in(Server) end}}] + end } }. -test_http3_opt_in() -> - %% Test that HTTP/3 can be enabled via protocols option - %% Cloudflare supports HTTP/3 - case gen_tcp:connect("cloudflare.com", 443, [], 5000) of - {ok, TestSock} -> - gen_tcp:close(TestSock), - %% This should attempt HTTP/3 first - {ok, ConnRef} = hackney_h3:connect( - <<"cloudflare.com">>, 443, #{}, self() - ), - %% Drive the QUIC event loop until connected or closed - Result = quic_loop(ConnRef, fun - ({connected, _Info}) -> {done, connected}; - ({closed, _Reason}) -> {done, closed}; - (_) -> continue - end, 10000), - hackney_h3:close(ConnRef, normal), - %% Either connected or closed is valid - we're testing opt-in works - ?assert(Result =:= connected orelse Result =:= closed orelse Result =:= {error, timeout}); - {error, _} -> - ?debugMsg("Skipping HTTP/3 opt-in test - network not available") - end. +test_http3_opt_in(Server) -> + %% hackney_h3 connects over QUIC once HTTP/3 is opted in. + {ok, ConnRef} = hackney_h3:connect(hackney_h3_test_server:host(), + hackney_h3_test_server:port(Server), + hackney_h3_test_server:h3_opts(), self()), + Result = quic_loop(ConnRef, fun + ({connected, _Info}) -> {done, connected}; + ({closed, Reason}) -> {done, {closed, Reason}}; + (_) -> continue + end, 5000), + hackney_h3:close(ConnRef, normal), + ?assertEqual(connected, Result). %% Helper to drive the QUIC event loop until a condition is met or timeout quic_loop(ConnRef, Condition, Timeout) -> From 5feb4b588ca7a11e9e1e3fa5af7d9e0f7e9357d5 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Mon, 21 Sep 2026 23:51:07 +0200 Subject: [PATCH 2/5] Fix reading an HTTP/3 upload response and hackney_h3 cacertfile 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. --- src/hackney_conn.erl | 101 +++++++++++++++-------- src/hackney_h3.erl | 14 +++- test/hackney_conn_http3_tests.erl | 8 +- test/hackney_h3_integration_tests.erl | 23 ++++-- test/hackney_h3_low_level_tests.erl | 6 +- test/hackney_h3_test_server.erl | 27 ++++-- test/hackney_http3_integration_tests.erl | 5 +- test/hackney_http3_streaming_tests.erl | 63 ++++++++++++-- test/hackney_protocol_tests.erl | 2 +- 9 files changed, 184 insertions(+), 65 deletions(-) diff --git a/src/hackney_conn.erl b/src/hackney_conn.erl index ab1c1430..f390e467 100644 --- a/src/hackney_conn.erl +++ b/src/hackney_conn.erl @@ -1297,6 +1297,9 @@ connected({call, From}, stream_body, #conn_data{protocol = http2} = Data) -> connected({call, From}, body, #conn_data{protocol = http2} = Data) -> handle_h2_read_body(From, Data); +connected({call, From}, body, #conn_data{protocol = http3, h3_streams = Streams} = Data) -> + handle_h3_read_body(From, Streams, Data); + connected(EventType, Event, Data) -> handle_common(EventType, Event, connected, Data). @@ -1469,7 +1472,7 @@ streaming_body({call, From}, finish_send_body, #conn_data{protocol = http3} = Da #conn_data{h3_conn = ConnRef, h3_stream_id = StreamId, h3_streams = Streams} = Data, case hackney_h3:finish_send_body(ConnRef, StreamId, Streams) of {ok, _} -> - {keep_state, Data#conn_data{request_from = From}, [{reply, From, ok}]}; + {keep_state_and_data, [{reply, From, ok}]}; {error, Reason} -> {next_state, closed, Data, [{reply, From, {error, Reason}}]} end; @@ -1516,11 +1519,21 @@ streaming_body({call, From}, start_response, #conn_data{protocol = http2} = Data {next_state, connected, Data, [CancelIdle, {reply, From, {error, no_stream}}]} end; -streaming_body({call, From}, start_response, #conn_data{protocol = http3} = Data) -> - %% HTTP/3 - wait for response headers via QUIC messages - %% Transition to connected and wait for QUIC stream_headers message - NewData = Data#conn_data{request_from = From}, - {next_state, connected, NewData}; +streaming_body({call, From}, start_response, + #conn_data{protocol = http3, h3_stream_id = StreamId, + h3_streams = Streams} = Data) -> + %% HTTP/3 - the response headers may already be in; otherwise park the + %% caller until handle_h3_headers/5 answers it. + case maps:get(StreamId, Streams, undefined) of + {_, {sending_body, _}} -> + {next_state, connected, Data#conn_data{request_from = From}}; + {_, _Response} -> + #conn_data{status = Status, response_headers = Headers} = Data, + {next_state, connected, Data, + [{reply, From, {ok, Status, Headers, self()}}]}; + undefined -> + {next_state, connected, Data, [{reply, From, {error, no_stream}}]} + end; streaming_body({call, From}, start_response, Data) -> %% Transition to receiving state and get response (HTTP/1.1) @@ -2934,19 +2947,12 @@ h3_ca_opts(SslOpts) -> undefined -> case proplists:get_value(cacertfile, SslOpts) of undefined -> #{}; - File -> #{cacerts => cacertfile_ders(File)} + File -> #{cacerts => hackney_h3:cacertfile_ders(File)} end; CACerts -> #{cacerts => CACerts} end. -%% @private Read a PEM cacertfile into a list of DER certificates. -cacertfile_ders(File) -> - case file:read_file(File) of - {ok, Pem} -> [Der || {'Certificate', Der, _} <- public_key:pem_decode(Pem)]; - {error, _} -> [] - end. - %% @private Drive QUIC event loop until connected wait_h3_connected(ConnRef, Timeout, StartTime) -> Elapsed = erlang:monotonic_time(millisecond) - StartTime, @@ -4038,6 +4044,26 @@ handle_h3_stream_body(From, Streams, Data) -> {keep_state_and_data, [{reply, From, {error, no_stream}}]} end. +%% @private Handle HTTP/3 body call: the rest of the streaming response in +%% one binary, returned at once if it already ended or when its FIN arrives. +handle_h3_read_body(From, Streams, Data) -> + case find_streaming_stream(Streams) of + {ok, StreamId, {streaming_body, Status, Headers, Buffer, undefined}} -> + NewStreamState = {streaming_body_full, Status, Headers, Buffer, From}, + {keep_state, Data#conn_data{h3_streams = maps:put(StreamId, + {undefined, NewStreamState}, Streams)}}; + {ok, StreamId, {streaming_body_final, _Status, _Headers, Buffer}} -> + {keep_state, Data#conn_data{h3_streams = maps:remove(StreamId, Streams)}, + [{reply, From, {ok, Buffer}}]}; + {ok, StreamId, {streaming_body_done, _Status, _Headers}} -> + {keep_state, Data#conn_data{h3_streams = maps:remove(StreamId, Streams)}, + [{reply, From, {ok, <<>>}}]}; + {ok, _StreamId, _OtherState} -> + {keep_state_and_data, [{reply, From, {error, not_streaming}}]}; + none -> + {keep_state_and_data, [{reply, From, {error, no_stream}}]} + end. + %% @private Find a stream in streaming mode find_streaming_stream(Streams) -> Result = maps:fold(fun @@ -4103,29 +4129,24 @@ handle_h3_headers(StreamId, Headers, Fin, Streams, Data) -> status = Status, response_headers = RespHeaders }, [{reply, From, {ok, Status, HeadersList}}]}; - {From, {sending_body, From}} -> - %% Response arrived while sending body - update state - %% If request_from is set (from start_response), reply to it + {_, {sending_body, _}} -> + %% Response to a streamed upload: read it like a + %% request_streaming response, with stream_body/1 or + %% body/1. start_response/1 may already be waiting. + NewStreamState = case Fin of + true -> {streaming_body_done, Status, RespHeaders}; + false -> {streaming_body, Status, RespHeaders, <<>>, undefined} + end, + UpdatedStreams = maps:put(StreamId, {undefined, NewStreamState}, Streams), + NewData = Data#conn_data{h3_streams = UpdatedStreams, + status = Status, + response_headers = RespHeaders}, case Data#conn_data.request_from of undefined -> - %% No one waiting yet - just update state - UpdatedStreams = maps:put(StreamId, - {From, {receiving_body, Status, RespHeaders, <<>>}}, Streams), - {keep_state, Data#conn_data{ - h3_streams = UpdatedStreams, - status = Status, - response_headers = RespHeaders - }}; + {keep_state, NewData}; WaitingFrom -> - %% Someone called start_response - reply with status/headers - UpdatedStreams = maps:put(StreamId, - {WaitingFrom, {receiving_body, Status, RespHeaders, <<>>}}, Streams), - {keep_state, Data#conn_data{ - h3_streams = UpdatedStreams, - status = Status, - response_headers = RespHeaders, - request_from = undefined - }, [{reply, WaitingFrom, {ok, Status, HeadersList, self()}}]} + {keep_state, NewData#conn_data{request_from = undefined}, + [{reply, WaitingFrom, {ok, Status, HeadersList, self()}}]} end; _ -> %% Unknown stream or wrong state @@ -4241,6 +4262,18 @@ handle_h3_data(StreamId, RecvData, Fin, Streams, Data) -> [{reply, PendingFrom, {ok, NewBuffer}}]} end end; + {_, {streaming_body_full, Status, Headers, Acc, From}} -> + %% body/1 is waiting for the whole body + NewAcc = <>, + case Fin of + true -> + {keep_state, Data#conn_data{h3_streams = maps:remove(StreamId, Streams)}, + [{reply, From, {ok, NewAcc}}]}; + false -> + NewStreamState = {streaming_body_full, Status, Headers, NewAcc, From}, + {keep_state, Data#conn_data{h3_streams = maps:put(StreamId, + {undefined, NewStreamState}, Streams)}} + end; {_, {streaming_body_final, Status, Headers, FinalBuffer}} -> %% Already got final data, just buffer more (shouldn't happen) NewBuffer = <>, diff --git a/src/hackney_h3.erl b/src/hackney_h3.erl index 75febc3e..a59d59d1 100644 --- a/src/hackney_h3.erl +++ b/src/hackney_h3.erl @@ -45,6 +45,7 @@ update_stream_state/3, %% Response parsing parse_response_headers/1, + cacertfile_ders/1, %% 0-RTT / session resumption early_data_accepted/1, get_session_ticket/1, @@ -948,11 +949,13 @@ build_h3_opts(Host, Opts) -> disable -> #{}; Sni -> #{server_name_indication => Sni} end, + %% quic takes trust anchors as DER `cacerts' only, so a cacertfile is + %% decoded here; passed through as is, quic ignored it. QuicOpts1 = case maps:get(cacerts, Opts, undefined) of undefined -> case maps:get(cacertfile, Opts, undefined) of undefined -> QuicOpts0; - File -> QuicOpts0#{cacertfile => File} + File -> QuicOpts0#{cacerts => cacertfile_ders(File)} end; CACerts -> QuicOpts0#{cacerts => CACerts} end, @@ -977,6 +980,15 @@ build_h3_opts(Host, Opts) -> Settings -> Base#{settings => Settings} end. +%% @doc Read a PEM CA file into the DER certificates quic takes as `cacerts'. +%% An unreadable file gives no anchors, so verification fails closed. +-spec cacertfile_ders(file:filename_all()) -> [public_key:der_encoded()]. +cacertfile_ders(File) -> + case file:read_file(File) of + {ok, Pem} -> [Der || {'Certificate', Der, _} <- public_key:pem_decode(Pem)]; + {error, _} -> [] + end. + ensure_table() -> case ets:whereis(?CONN_TABLE) of undefined -> diff --git a/test/hackney_conn_http3_tests.erl b/test/hackney_conn_http3_tests.erl index 247bc3e1..931915eb 100644 --- a/test/hackney_conn_http3_tests.erl +++ b/test/hackney_conn_http3_tests.erl @@ -45,10 +45,10 @@ http3_conn_test_() -> test_h3_connection_request(Server) -> {ok, Pid} = hackney_conn:start_link(hackney_h3_test_server:conn_opts(Server)), - ok = hackney_conn:connect(Pid, 5000), + ok = hackney_conn:connect(Pid, 15000), ?assertEqual(http3, hackney_conn:get_protocol(Pid)), {ok, Status, Headers, Body} = - hackney_conn:request(Pid, <<"GET">>, <<"/">>, [], <<>>, 5000), + hackney_conn:request(Pid, <<"GET">>, <<"/">>, [], <<>>, 15000), ?assertEqual(200, Status), ?assertEqual(<<"text/html">>, proplists:get_value(<<"content-type">>, Headers)), ?assertEqual(<<"hackney h3 test server">>, Body), @@ -58,14 +58,14 @@ test_h3_get_protocol(Server) -> {ok, Pid} = hackney_conn:start_link(hackney_h3_test_server:conn_opts(Server)), %% Before connecting, the protocol is the http1 default. ?assertEqual(http1, hackney_conn:get_protocol(Pid)), - ok = hackney_conn:connect(Pid, 5000), + ok = hackney_conn:connect(Pid, 15000), ?assertEqual(http3, hackney_conn:get_protocol(Pid)), hackney_conn:stop(Pid). test_h3_peer_info(Server) -> Port = hackney_h3_test_server:port(Server), {ok, Pid} = hackney_conn:start_link(hackney_h3_test_server:conn_opts(Server)), - ok = hackney_conn:connect(Pid, 5000), + ok = hackney_conn:connect(Pid, 15000), ?assertEqual(http3, hackney_conn:get_protocol(Pid)), ?assertMatch({ok, {{127, 0, 0, 1}, Port}}, hackney_conn:peername(Pid)), ?assertMatch({ok, {_, _}}, hackney_conn:sockname(Pid)), diff --git a/test/hackney_h3_integration_tests.erl b/test/hackney_h3_integration_tests.erl index 66e7de78..4cf92750 100644 --- a/test/hackney_h3_integration_tests.erl +++ b/test/hackney_h3_integration_tests.erl @@ -86,7 +86,7 @@ wait_connected(ConnRef) -> ({closed, Reason}) -> {done, {error, Reason}}; ({transport_error, Code, Msg}) -> {done, {error, {transport_error, Code, Msg}}}; (_) -> continue - end, 5000). + end, 15000). %% Accumulate full response (headers + body) wait_response(ConnRef, StreamId, Timeout) -> @@ -151,7 +151,8 @@ tls_test_() -> with_server([ {"Connect without TLS verification", fun test_connect_no_verify/1}, {"Connect with TLS verification against the test CA", fun test_connect_with_verify/1}, - {"Verification fails without the test CA", fun test_connect_verify_untrusted/1} + {"Verification fails without the test CA", fun test_connect_verify_untrusted/1}, + {"Connect with the test CA as a cacertfile", fun test_connect_verify_cacertfile/1} ])}. test_connect_no_verify(Server) -> @@ -169,6 +170,14 @@ test_connect_with_verify(Server) -> hackney_h3:close(ConnRef, normal), ?assertMatch({ok, _}, Result). +test_connect_verify_cacertfile(Server) -> + %% quic only takes DER cacerts; hackney_h3 decodes the PEM file. + Opts = #{verify => true, cacertfile => hackney_h3_test_server:ca_file()}, + {ok, ConnRef} = connect(Server, Opts), + Result = wait_connected(ConnRef), + hackney_h3:close(ConnRef, normal), + ?assertMatch({ok, _}, Result). + test_connect_verify_untrusted(Server) -> Result = case connect(Server, #{verify => true}) of {ok, ConnRef} -> @@ -259,7 +268,7 @@ test_post_request(Server) -> ], {ok, StreamId} = hackney_h3:send_request(ConnRef, Headers, false), ok = hackney_h3:send_data(ConnRef, StreamId, <<"{}">>, true), - {ok, Status, RespHeaders, Body} = wait_response(ConnRef, StreamId, 5000), + {ok, Status, RespHeaders, Body} = wait_response(ConnRef, StreamId, 15000), hackney_h3:close(ConnRef, normal), ?assertEqual(200, Status), ?assertEqual(<<"application/json">>, @@ -276,9 +285,9 @@ test_multiple_requests(Server) -> build_get_headers(Host, <<"/cdn-cgi/trace">>), true), ?assertNotEqual(StreamId1, StreamId2), - ?assertMatch({ok, 200, _, _}, wait_response(ConnRef, StreamId1, 5000)), + ?assertMatch({ok, 200, _, _}, wait_response(ConnRef, StreamId1, 15000)), ?assertMatch({ok, 200, _, <<"h=127.0.0.1\nhttp=http/3\n">>}, - wait_response(ConnRef, StreamId2, 5000)), + wait_response(ConnRef, StreamId2, 15000)), hackney_h3:close(ConnRef, normal). %%==================================================================== @@ -300,7 +309,7 @@ test_h3_simple_request(Server) -> test_h3_request_with_options(Server) -> URL = hackney_h3_test_server:url(Server, <<"/cdn-cgi/trace">>), - Options = (hackney_h3_test_server:h3_opts())#{timeout => 5000, recv_timeout => 5000}, + Options = (hackney_h3_test_server:h3_opts())#{timeout => 15000, recv_timeout => 15000}, Headers = [{<<"user-agent">>, <<"hackney-test/1.0">>}], ?assertMatch({ok, 200, _, <<"h=127.0.0.1\nhttp=http/3\n">>}, hackney_h3:request(get, URL, Headers, <<>>, Options)). @@ -350,7 +359,7 @@ make_h3_request(Server, Path) -> {ok, _} = wait_connected(ConnRef), Headers = build_get_headers(hackney_h3_test_server:host(), Path), {ok, StreamId} = hackney_h3:send_request(ConnRef, Headers, true), - Result = wait_response(ConnRef, StreamId, 5000), + Result = wait_response(ConnRef, StreamId, 15000), hackney_h3:close(ConnRef, normal), Result. diff --git a/test/hackney_h3_low_level_tests.erl b/test/hackney_h3_low_level_tests.erl index 96a01581..843df750 100644 --- a/test/hackney_h3_low_level_tests.erl +++ b/test/hackney_h3_low_level_tests.erl @@ -99,7 +99,7 @@ wait_connected(ConnRef) -> ({connected, Info}) -> {done, {ok, Info}}; ({closed, Reason}) -> {done, {error, Reason}}; (_) -> continue - end, 5000), + end, 15000), Result. %%==================================================================== @@ -168,7 +168,7 @@ test_full_request_response(Server) -> ({stream_headers, SId, Hdrs, _Fin}) when SId =:= StreamId -> {done, {ok, Hdrs}}; ({closed, Reason}) -> {done, {error, {closed, Reason}}}; (_) -> continue - end, 5000), + end, 15000), ?assertEqual({<<":status">>, <<"200">>}, lists:keyfind(<<":status">>, 1, RespHeaders)), ?assertEqual(<<"h=127.0.0.1\nhttp=http/3\n">>, read_body(ConnRef, StreamId, <<>>)), hackney_h3:close(ConnRef, normal). @@ -179,7 +179,7 @@ read_body(ConnRef, StreamId, Acc) -> ({stream_data, SId, Data, Fin}) when SId =:= StreamId -> {done, {Data, Fin}}; ({closed, Reason}) -> {done, {error, {closed, Reason}}}; (_) -> continue - end, 5000) of + end, 15000) of {Data, true} -> <>; {Data, false} -> read_body(ConnRef, StreamId, <>); Error -> Error diff --git a/test/hackney_h3_test_server.erl b/test/hackney_h3_test_server.erl index f9d8a5d2..148660ca 100644 --- a/test/hackney_h3_test_server.erl +++ b/test/hackney_h3_test_server.erl @@ -22,7 +22,7 @@ -export([start/0, stop/1]). -export([port/1, url/2, host/0]). -export([hackney_opts/0, h3_opts/0, conn_opts/1]). --export([cert_file/0, ca_cacerts/0, unused_port/0]). +-export([cert_file/0, ca_file/0, ca_cacerts/0, unused_port/0]). -define(LARGE_CHUNK, 4096). -define(LARGE_CHUNKS, 16). @@ -53,16 +53,25 @@ host() -> <<"127.0.0.1">>. url(#{port := Port}, Path) -> iolist_to_binary(["https://127.0.0.1:", integer_to_list(Port), Path]). +%% Budget for a QUIC handshake or a response. Generous: the handshake runs +%% in pure Erlang on both ends, and slow CI runners are why these tests +%% stopped using public servers. +-define(TIMEOUT, 15000). + %% @doc hackney:request/connect options for an HTTP/3 request to the server. +%% Session resumption is off: these tests are not about it, and resuming +%% from a cached ticket can stall the handshake (reproduced on one +%% scheduler), which would make unrelated tests flaky. hackney_opts() -> [{protocols, [http3]}, - {connect_timeout, 5000}, - {recv_timeout, 5000}, + {zero_rtt, false}, + {connect_timeout, ?TIMEOUT}, + {recv_timeout, ?TIMEOUT}, {ssl_options, [{insecure, true}]}]. %% @doc hackney_h3 options: the certificate is self-signed. h3_opts() -> - #{insecure_skip_verify => true, timeout => 5000, recv_timeout => 5000}. + #{insecure_skip_verify => true, timeout => ?TIMEOUT, recv_timeout => ?TIMEOUT}. %% @doc hackney_conn:start_link/1 options for an HTTP/3 connection. conn_opts(Ctx) -> @@ -71,15 +80,19 @@ conn_opts(Ctx) -> transport => hackney_ssl, connect_options => [{protocols, [http3]}], ssl_options => [{insecure, true}], - connect_timeout => 5000, - recv_timeout => 5000}. + connect_timeout => ?TIMEOUT, + recv_timeout => ?TIMEOUT}. cert_file() -> filename:join(cert_dir(), "server.pem"). +%% @doc The PEM file of the CA that issued the server certificate. +ca_file() -> + filename:join(cert_dir(), "ca.pem"). + %% @doc The CA that issued the server certificate, as DER trust anchors. ca_cacerts() -> - {ok, Pem} = file:read_file(filename:join(cert_dir(), "ca.pem")), + {ok, Pem} = file:read_file(ca_file()), [Der || {'Certificate', Der, not_encrypted} <- public_key:pem_decode(Pem)]. %% @doc A UDP port with nothing listening on it. diff --git a/test/hackney_http3_integration_tests.erl b/test/hackney_http3_integration_tests.erl index 96c9081e..35c1559f 100644 --- a/test/hackney_http3_integration_tests.erl +++ b/test/hackney_http3_integration_tests.erl @@ -47,7 +47,8 @@ connect(Server, Opts) -> tcp_opts() -> [{protocols, [http3, http2, http1]}, - {connect_timeout, 5000}, + {zero_rtt, false}, + {connect_timeout, 15000}, {ssl_options, [{insecure, true}]}]. %%==================================================================== @@ -69,7 +70,7 @@ test_explicit_h3_connect(Server) -> test_default_protocols(#{tcp_port := TcpPort}) -> %% Without http3 in the protocol list, hackney negotiates over TCP. - Opts = [{connect_timeout, 5000}, {ssl_options, [{insecure, true}]}], + Opts = [{connect_timeout, 15000}, {ssl_options, [{insecure, true}]}], {ok, ConnPid} = hackney:connect(hackney_ssl, "127.0.0.1", TcpPort, Opts), ?assertEqual(http2, hackney_conn:get_protocol(ConnPid)), hackney:close(ConnPid). diff --git a/test/hackney_http3_streaming_tests.erl b/test/hackney_http3_streaming_tests.erl index 4fe8eb8f..90cc997c 100644 --- a/test/hackney_http3_streaming_tests.erl +++ b/test/hackney_http3_streaming_tests.erl @@ -88,7 +88,10 @@ read_all_chunks(ConnPid, Acc) -> h3_send_body_test_() -> with_server([ - {"send body in chunks", fun test_h3_send_body_chunks/1} + {"send body in chunks", fun test_h3_send_body_chunks/1}, + {"stream_body after an upload", fun test_h3_upload_stream_body/1}, + {"upload through hackney:request", fun test_h3_upload_public_api/1}, + {"start_response after the response arrived", fun test_h3_upload_response_first/1} ]). test_h3_send_body_chunks(Server) -> @@ -101,7 +104,35 @@ test_h3_send_body_chunks(Server) -> {ok, 200, RespHeaders, _} = hackney_conn:start_response(ConnPid), ?assertEqual(<<"text/plain">>, proplists:get_value(<<"content-type">>, RespHeaders)), - ?assertEqual(<<"12">>, proplists:get_value(<<"content-length">>, RespHeaders)), + ?assertEqual({ok, <<"Hello World!">>}, hackney_conn:body(ConnPid)), + hackney:close(ConnPid). + +test_h3_upload_stream_body(Server) -> + {ok, ConnPid} = connect(Server), + ok = upload(ConnPid, [<<"chunk 1, ">>, <<"chunk 2">>]), + {ok, 200, _RespHeaders, _} = hackney_conn:start_response(ConnPid), + ?assertEqual(<<"chunk 1, chunk 2">>, read_all_chunks(ConnPid)), + hackney:close(ConnPid). + +test_h3_upload_public_api(Server) -> + URL = hackney_h3_test_server:url(Server, <<"/echo">>), + {ok, Ref} = hackney:request(post, URL, [{<<"content-type">>, <<"text/plain">>}], + stream, [{pool, false} | opts()]), + ok = hackney:send_body(Ref, <<"Hello ">>), + ok = hackney:send_body(Ref, <<"World!">>), + ok = hackney:finish_send_body(Ref), + {ok, 200, _RespHeaders, Ref} = hackney:start_response(Ref), + ?assertEqual({ok, <<"Hello World!">>}, hackney:body(Ref)), + hackney:close(Ref). + +%% The response headers are in before start_response/1 is called: it must +%% answer at once instead of waiting for headers that already came. +test_h3_upload_response_first(Server) -> + {ok, ConnPid} = connect(Server), + ok = upload(ConnPid, [<<"early">>]), + ok = wait_until(fun() -> hackney_conn:response_headers(ConnPid) =/= undefined end), + {ok, 200, _RespHeaders, _} = hackney_conn:start_response(ConnPid), + ?assertEqual({ok, <<"early">>}, hackney_conn:body(ConnPid)), hackney:close(ConnPid). %%==================================================================== @@ -111,7 +142,7 @@ test_h3_send_body_chunks(Server) -> test_h3_async_true(Server) -> URL = hackney_h3_test_server:url(Server, <<"/large">>), {ok, Ref} = hackney:get(URL, [], <<>>, [{async, true} | opts()]), - Messages = collect_async_messages(Ref, 5000), + Messages = collect_async_messages(Ref, 15000), ?assert(has_status_message(Messages)), ?assert(has_headers_message(Messages)), ?assert(has_done_message(Messages)), @@ -120,7 +151,7 @@ test_h3_async_true(Server) -> test_h3_async_once(Server) -> URL = hackney_h3_test_server:url(Server, <<"/cdn-cgi/trace">>), {ok, Ref} = hackney:get(URL, [], <<>>, [{async, once} | opts()]), - Messages = collect_once_messages(Ref, 5000), + Messages = collect_once_messages(Ref, 15000), ?assert(has_status_message(Messages)), ?assert(has_headers_message(Messages)), ?assert(has_done_message(Messages)), @@ -129,7 +160,7 @@ test_h3_async_once(Server) -> test_h3_async_status(Server) -> URL = hackney_h3_test_server:url(Server, <<"/">>), {ok, Ref} = hackney:get(URL, [], <<>>, [{async, true} | opts()]), - Messages = collect_async_messages(Ref, 5000), + Messages = collect_async_messages(Ref, 15000), ?assertMatch({hackney_response, _, {status, 200, _}}, find_message(fun({hackney_response, _, {status, _, _}}) -> true; (_) -> false @@ -138,7 +169,7 @@ test_h3_async_status(Server) -> test_h3_async_headers(Server) -> URL = hackney_h3_test_server:url(Server, <<"/">>), {ok, Ref} = hackney:get(URL, [], <<>>, [{async, true} | opts()]), - Messages = collect_async_messages(Ref, 5000), + Messages = collect_async_messages(Ref, 15000), {hackney_response, _, {headers, Headers}} = find_message(fun({hackney_response, _, {headers, _}}) -> true; (_) -> false @@ -152,6 +183,26 @@ test_h3_async_headers(Server) -> opts() -> hackney_h3_test_server:hackney_opts(). +upload(ConnPid, Chunks) -> + Headers = [{<<"content-type">>, <<"text/plain">>}], + ok = hackney_conn:send_request_headers(ConnPid, <<"POST">>, <<"/echo">>, Headers), + lists:foreach(fun(Chunk) -> ok = hackney_conn:send_body_chunk(ConnPid, Chunk) end, + Chunks), + hackney_conn:finish_send_body(ConnPid). + +wait_until(Fun) -> + wait_until(Fun, erlang:monotonic_time(millisecond) + 15000). + +wait_until(Fun, Deadline) -> + case Fun() of + true -> ok; + false -> + case erlang:monotonic_time(millisecond) > Deadline of + true -> error(wait_until_timeout); + false -> receive after 5 -> ok end, wait_until(Fun, Deadline) + end + end. + connect(Server) -> hackney:connect(hackney_ssl, "127.0.0.1", hackney_h3_test_server:port(Server), opts()). diff --git a/test/hackney_protocol_tests.erl b/test/hackney_protocol_tests.erl index 9d36f0ed..1da02a5d 100644 --- a/test/hackney_protocol_tests.erl +++ b/test/hackney_protocol_tests.erl @@ -108,7 +108,7 @@ test_http3_opt_in(Server) -> ({connected, _Info}) -> {done, connected}; ({closed, Reason}) -> {done, {closed, Reason}}; (_) -> continue - end, 5000), + end, 15000), hackney_h3:close(ConnRef, normal), ?assertEqual(connected, Result). From 21467630140f61e721293bd6a25ea07dd4ac3158 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Mon, 21 Sep 2026 23:51:17 +0200 Subject: [PATCH 3/5] Add NEWS entries for the HTTP/3 upload and cacertfile fixes --- NEWS.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/NEWS.md b/NEWS.md index e62a3c7a..1626bcce 100644 --- a/NEWS.md +++ b/NEWS.md @@ -17,6 +17,15 @@ unreleased - A response that crosses a reset of its stream no longer closes the HTTP/2 connection. `h2` dropped the header block of that response without decoding it, so the next response on the connection failed with COMPRESSION_ERROR. +- The response to an HTTP/3 streaming upload can be read. After + `start_response/1`, `body/1` returned `{error, invalid_state}` and + `stream_body/1` returned `{error, no_stream}`: the body went to the + `start_response/1` caller as a second reply and was lost. When the response + headers arrived before `start_response/1` was called, it could wait forever. +- `hackney_h3:connect/3,4` and `hackney_h3:request/5` honor a `cacertfile` + option. It was handed to quic, which only takes DER `cacerts`, so it was + ignored and verification failed as `{error, timeout}`. `hackney:request/5` + was not affected. - A request that races a peer-initiated close now returns `{error, closed}` instead of `{error, invalid_state}`. A connection that sees the peer close stays alive briefly so late calls get an answer, and during that window every From e7a26898287d074ac88c3b70d88953104a478d68 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 24 Sep 2026 00:43:05 +0200 Subject: [PATCH 4/5] Report why an HTTP/3 connection closed 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. --- NEWS.md | 6 ++++++ src/hackney_h3.erl | 12 ++++++++++++ test/hackney_h3_integration_tests.erl | 12 +++++++++++- 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 1626bcce..325b6a88 100644 --- a/NEWS.md +++ b/NEWS.md @@ -22,6 +22,12 @@ unreleased `stream_body/1` returned `{error, no_stream}`: the body went to the `start_response/1` caller as a second reply and was lost. When the response headers arrived before `start_response/1` was called, it could wait forever. +- An HTTP/3 connection that goes away reports why. `quic_h3` sends the reason + with its close event, and the handler only matched the older shape without + one, so the message was dropped: the caller waited out its own timeout and + the connection process stayed alive. A handshake that fails on a bad + certificate or a TLS alert now comes back as that error instead of + `{error, timeout}`. - `hackney_h3:connect/3,4` and `hackney_h3:request/5` honor a `cacertfile` option. It was handed to quic, which only takes DER `cacerts`, so it was ignored and verification failed as `{error, timeout}`. `hackney:request/5` diff --git a/src/hackney_h3.erl b/src/hackney_h3.erl index a59d59d1..686c2137 100644 --- a/src/hackney_h3.erl +++ b/src/hackney_h3.erl @@ -889,8 +889,20 @@ handle_info({quic_h3, Conn, {early_data_rejected, StreamIds}}, Owner ! {h3, Ref, {early_data_rejected, StreamIds}}, {noreply, State}; +handle_info({quic_h3, Conn, {closed, Reason}}, + #state{h3_conn = Conn, conn_ref = Ref, owner = Owner} = State) -> + %% quic_h3 reports why the connection went away: `normal' for a local + %% close or a drained GOAWAY, `owner_down', an {h3_error, Code, Phrase}, + %% or whatever QUIC reported. A failure before HTTP/3 comes up (a bad + %% certificate, a TLS alert) arrives here too, so pass the reason on + %% rather than letting the caller wait out its timeout. + Owner ! {h3, Ref, {closed, Reason}}, + {stop, normal, State}; + handle_info({quic_h3, Conn, closed}, #state{h3_conn = Conn, conn_ref = Ref, owner = Owner} = State) -> + %% quic =< 1.10.0 closes without a reason on the local-close and + %% QUIC-down paths. Owner ! {h3, Ref, {closed, normal}}, {stop, normal, State}; diff --git a/test/hackney_h3_integration_tests.erl b/test/hackney_h3_integration_tests.erl index 4cf92750..f881d1e9 100644 --- a/test/hackney_h3_integration_tests.erl +++ b/test/hackney_h3_integration_tests.erl @@ -152,6 +152,7 @@ tls_test_() -> {"Connect without TLS verification", fun test_connect_no_verify/1}, {"Connect with TLS verification against the test CA", fun test_connect_with_verify/1}, {"Verification fails without the test CA", fun test_connect_verify_untrusted/1}, + {"A failed handshake reports why", fun test_connect_reports_close_reason/1}, {"Connect with the test CA as a cacertfile", fun test_connect_verify_cacertfile/1} ])}. @@ -187,7 +188,16 @@ test_connect_verify_untrusted(Server) -> {error, _} = Error -> Error end, - ?assertMatch({error, _}, Result). + %% quic_h3 reports why it gave up, rather than leaving the caller to + %% time out. + ?assertMatch({error, {certificate_invalid, _}}, Result). + +%% connect/3 surfaces the close reason of a handshake that failed. +test_connect_reports_close_reason(Server) -> + Result = hackney_h3:connect(hackney_h3_test_server:host(), + hackney_h3_test_server:port(Server), + #{verify => true, timeout => 15000}), + ?assertMatch({error, {connection_closed, {certificate_invalid, _}}}, Result). %%==================================================================== %% Redirect Tests From 307b4c6557554475c791ae80696f90efea9981c6 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 24 Sep 2026 01:30:28 +0200 Subject: [PATCH 5/5] Send one :status in HTTP/3 response headers 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. --- NEWS.md | 6 ++++++ src/hackney_h3.erl | 6 +++++- test/hackney_h3_low_level_tests.erl | 2 ++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/NEWS.md b/NEWS.md index 325b6a88..b9637cb2 100644 --- a/NEWS.md +++ b/NEWS.md @@ -22,6 +22,12 @@ unreleased `stream_body/1` returned `{error, no_stream}`: the body went to the `start_response/1` caller as a second reply and was lost. When the response headers arrived before `start_response/1` was called, it could wait forever. +- HTTP/3 response headers carry one `:status`. quic_h3 passes the status + separately and keeps it in the header list, and hackney prepended its own, + so every response reached the low-level `{h3, _, {stream_headers, ...}}` + consumer with the pseudo-header twice, which RFC 9114 4.3.1 makes a + malformed response. Requests through `hackney:request/5` were not affected: + pseudo-headers are filtered before the caller sees them. - An HTTP/3 connection that goes away reports why. `quic_h3` sends the reason with its close event, and the handler only matched the older shape without one, so the message was dropped: the caller waited out its own timeout and diff --git a/src/hackney_h3.erl b/src/hackney_h3.erl index 686c2137..acb1a9e3 100644 --- a/src/hackney_h3.erl +++ b/src/hackney_h3.erl @@ -850,7 +850,11 @@ handle_info({quic_h3, Conn, connected}, handle_info({quic_h3, Conn, {response, StreamId, Status, Headers}}, #state{h3_conn = Conn, conn_ref = Ref, owner = Owner} = State) -> - Full = [{<<":status">>, integer_to_binary(Status)} | Headers], + %% quic_h3 passes the status separately and keeps it in the header list, + %% so drop it there before prepending the authoritative one: two + %% `:status' make a malformed response (RFC 9114 4.3.1). + Rest = [H || {Name, _} = H <- Headers, Name =/= <<":status">>], + Full = [{<<":status">>, integer_to_binary(Status)} | Rest], Owner ! {h3, Ref, {stream_headers, StreamId, Full, false}}, {noreply, State}; diff --git a/test/hackney_h3_low_level_tests.erl b/test/hackney_h3_low_level_tests.erl index 843df750..876b18e8 100644 --- a/test/hackney_h3_low_level_tests.erl +++ b/test/hackney_h3_low_level_tests.erl @@ -170,6 +170,8 @@ test_full_request_response(Server) -> (_) -> continue end, 15000), ?assertEqual({<<":status">>, <<"200">>}, lists:keyfind(<<":status">>, 1, RespHeaders)), + %% Exactly one :status: two would be a malformed response. + ?assertEqual(1, length([H || {<<":status">>, _} = H <- RespHeaders])), ?assertEqual(<<"h=127.0.0.1\nhttp=http/3\n">>, read_body(ConnRef, StreamId, <<>>)), hackney_h3:close(ConnRef, normal).