From 744975bf7fb1f41cbd047820740d5903eab3dd2e Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 24 Sep 2026 02:27:52 +0200 Subject: [PATCH 1/4] Revert "Merge pull request #942 from benoitc/deps/quic-1.10.0" This reverts commit 84adda4006fc05d94ff356197d57dd14ea3e1552, reversing changes made to e494a515cd3a466e85d519ccc173523a5b6403a8. --- NEWS.md | 4 ---- rebar.config | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/NEWS.md b/NEWS.md index 0e5a03f3..b9637cb2 100644 --- a/NEWS.md +++ b/NEWS.md @@ -49,10 +49,6 @@ unreleased ### Changed - Update `h2` to 0.12.1. -- Update `quic` to 1.10.0 and `webtransport` to 0.4.6. quic 1.9 fixes a - handshake that stalled when the client's Initial flight spanned several - datagrams, and frames that waited for the next event after the handshake; - 1.10 reworks loss detection and recovery along RFC 9002. 4.7.4 - 2026-08-12 ------------------ diff --git a/rebar.config b/rebar.config index c39c3140..9300370f 100644 --- a/rebar.config +++ b/rebar.config @@ -53,11 +53,11 @@ {deps, [ %% Pure Erlang QUIC + HTTP/3 stack - {quic, "~>1.10.0"}, + {quic, "~>1.8.0"}, %% Pure Erlang HTTP/2 stack {h2, "~>0.12.1"}, %% WebTransport client (HTTP/3 and HTTP/2) - powers the wt_* API - {webtransport, "~>0.4.6"}, + {webtransport, "~>0.4.5"}, {idna, "~>7.1.0"}, {mimerl, "~>1.5"}, {certifi, "~>2.17.0"}, From d4e5fbbd857a9c7657d6e1e7ae3329188b0c5be1 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 24 Sep 2026 02:34:55 +0200 Subject: [PATCH 2/4] Update quic to 2.0.0 and answer callers on a reset HTTP/3 stream quic 2.0.0 reports a peer resetting a request stream, which earlier releases dropped, so a caller reading a response with body/1 or stream_body/1 waited out its own timeout when the server reset the stream. Those callers are answered now, and a test covers it. It also always sends a reason with its HTTP/3 close event, which hackney already handles, and fixes a handshake that could stall when resuming from a cached session ticket, so the HTTP/3 tests no longer need to turn session resumption off. webtransport 0.4.7 comes with it: 0.4.6 requires quic ~> 1.10, so the two cannot resolve together. --- NEWS.md | 7 +++++ rebar.config | 4 +-- src/hackney_conn.erl | 18 ++++++++++++ test/hackney_h3_test_server.erl | 8 ++++- test/hackney_http3_integration_tests.erl | 1 - test/hackney_http3_streaming_tests.erl | 37 +++++++++++++++++++++++- 6 files changed, 70 insertions(+), 5 deletions(-) diff --git a/NEWS.md b/NEWS.md index b9637cb2..7d610953 100644 --- a/NEWS.md +++ b/NEWS.md @@ -5,6 +5,9 @@ unreleased ### Fixed +- A caller reading an HTTP/3 response with `body/1` or `stream_body/1` is + answered when the server resets its stream, instead of waiting for its own + timeout. Needs quic 2.0.0, the first release to report a peer RESET_STREAM. - A pooled HTTP/2 connection no longer closes when the caller that opened it exits. It stayed owned by that caller, so its exit failed every other caller's request on the connection with `{error, closed}`. A shared @@ -49,6 +52,10 @@ unreleased ### Changed - Update `h2` to 0.12.1. +- Update `quic` to 2.0.0 and `webtransport` to 0.4.7. quic 2.0.0 reports a + peer resetting a request stream, always sends a reason with its HTTP/3 + close event, and fixes a handshake that could stall when resuming from a + cached session ticket. 4.7.4 - 2026-08-12 ------------------ diff --git a/rebar.config b/rebar.config index 9300370f..7f3eff41 100644 --- a/rebar.config +++ b/rebar.config @@ -53,11 +53,11 @@ {deps, [ %% Pure Erlang QUIC + HTTP/3 stack - {quic, "~>1.8.0"}, + {quic, "~>2.0"}, %% Pure Erlang HTTP/2 stack {h2, "~>0.12.1"}, %% WebTransport client (HTTP/3 and HTTP/2) - powers the wt_* API - {webtransport, "~>0.4.5"}, + {webtransport, "~>0.4.7"}, {idna, "~>7.1.0"}, {mimerl, "~>1.5"}, {certifi, "~>2.17.0"}, diff --git a/src/hackney_conn.erl b/src/hackney_conn.erl index f390e467..ada51dc5 100644 --- a/src/hackney_conn.erl +++ b/src/hackney_conn.erl @@ -4306,10 +4306,22 @@ handle_h3_stream_reset(StreamId, ErrorCode, Streams, Data) -> StreamTo ! {hackney_response, Ref, {error, {stream_reset, ErrorCode}}}, UpdatedStreams = maps:remove(StreamId, Streams), {keep_state, Data#conn_data{h3_streams = UpdatedStreams, request_from = undefined}}; + {_, StreamState} when element(1, StreamState) =:= streaming_body; + element(1, StreamState) =:= streaming_body_full -> + %% A stream_body/1 or body/1 caller may be parked on this stream. + Replies = [{reply, Waiting, {error, {stream_reset, ErrorCode}}} + || Waiting <- [h3_parked_from(StreamState)], Waiting =/= undefined], + {keep_state, Data#conn_data{h3_streams = maps:remove(StreamId, Streams)}, + Replies}; _ -> {keep_state, Data} end. +%% @private The caller parked on a pull-mode stream, if any. +h3_parked_from({streaming_body, _Status, _Headers, _Buffer, From}) -> From; +h3_parked_from({streaming_body_full, _Status, _Headers, _Acc, From}) -> From; +h3_parked_from(_) -> undefined. + %% @private Cache the H3 session ticket in the pool (best effort, guarded so a %% custom pool handler without the callback degrades to no caching). maybe_store_h3_session(_Ticket, #conn_data{pool_handler = undefined}) -> @@ -4417,6 +4429,12 @@ handle_h3_termination(Error, Data) -> %% Async stream waiting for headers StreamTo ! {hackney_response, Ref, {error, Error}}, Acc; + {_, PullState} -> + %% A stream_body/1 or body/1 caller parked on this stream + case h3_parked_from(PullState) of + undefined -> Acc; + Waiting -> [{reply, Waiting, {error, Error}} | Acc] + end; _ -> Acc end diff --git a/test/hackney_h3_test_server.erl b/test/hackney_h3_test_server.erl index 148660ca..8c4fb4f8 100644 --- a/test/hackney_h3_test_server.erl +++ b/test/hackney_h3_test_server.erl @@ -64,7 +64,6 @@ url(#{port := Port}, Path) -> %% scheduler), which would make unrelated tests flaky. hackney_opts() -> [{protocols, [http3]}, - {zero_rtt, false}, {connect_timeout, ?TIMEOUT}, {recv_timeout, ?TIMEOUT}, {ssl_options, [{insecure, true}]}]. @@ -146,6 +145,13 @@ handle(Conn, StreamId, <<"GET">>, <<"/status/", N/binary>>, _Headers) -> false -> [] end, respond(Conn, StreamId, Status, Headers, <<>>); +handle(Conn, StreamId, <<"GET">>, <<"/reset">>, _Headers) -> + quic_h3:send_response(Conn, StreamId, 200, [{<<"content-type">>, <<"text/plain">>}]), + quic_h3:send_data(Conn, StreamId, <<"part">>, false), + hackney_h3_test_reset ! {reset_ready, self()}, + receive reset -> quic_h3:cancel(Conn, StreamId, 16#010c) + after 15000 -> ok + end; handle(Conn, StreamId, _Method, _Path, _Headers) -> respond(Conn, StreamId, 404, [], <<>>). diff --git a/test/hackney_http3_integration_tests.erl b/test/hackney_http3_integration_tests.erl index 35c1559f..ff824ae1 100644 --- a/test/hackney_http3_integration_tests.erl +++ b/test/hackney_http3_integration_tests.erl @@ -47,7 +47,6 @@ connect(Server, Opts) -> tcp_opts() -> [{protocols, [http3, http2, http1]}, - {zero_rtt, false}, {connect_timeout, 15000}, {ssl_options, [{insecure, true}]}]. diff --git a/test/hackney_http3_streaming_tests.erl b/test/hackney_http3_streaming_tests.erl index 90cc997c..f00f26b1 100644 --- a/test/hackney_http3_streaming_tests.erl +++ b/test/hackney_http3_streaming_tests.erl @@ -91,7 +91,8 @@ h3_send_body_test_() -> {"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} + {"start_response after the response arrived", fun test_h3_upload_response_first/1}, + {"body/1 on a reset stream returns an error", fun test_h3_body_stream_reset/1} ]). test_h3_send_body_chunks(Server) -> @@ -135,6 +136,40 @@ test_h3_upload_response_first(Server) -> ?assertEqual({ok, <<"early">>}, hackney_conn:body(ConnPid)), hackney:close(ConnPid). +%% A body/1 caller parked on a stream the server resets gets an error +%% instead of waiting forever. Needs a quic that reports a peer reset. +test_h3_body_stream_reset(Server) -> + true = register(hackney_h3_test_reset, self()), + try + {ok, ConnPid} = connect(Server), + {ok, 200, _Headers} = + hackney_conn:request_streaming(ConnPid, <<"GET">>, <<"/reset">>, [], <<>>), + Handler = receive {reset_ready, H} -> H after 15000 -> error(no_reset_handler) end, + Parent = self(), + spawn_link(fun() -> Parent ! {body, hackney_conn:body(ConnPid)} end), + ok = wait_until(fun() -> body_parked(ConnPid) end), + Handler ! reset, + receive + {body, Result} -> ?assertMatch({error, {stream_reset, _}}, Result) + after 15000 -> + error(body_not_answered) + end, + hackney:close(ConnPid) + after + unregister(hackney_h3_test_reset) + end. + +%% True once a body/1 call is parked on an HTTP/3 stream of the connection. +body_parked(ConnPid) -> + {_StateName, Data} = sys:get_state(ConnPid), + lists:any(fun(Field) -> + is_map(Field) andalso + lists:any(fun({_, State}) when is_tuple(State), tuple_size(State) > 0 -> + element(1, State) =:= streaming_body_full; + (_) -> false + end, maps:values(Field)) + end, tuple_to_list(Data)). + %%==================================================================== %% Async tests %%==================================================================== From c2d934f05156b5be4f34f35c896218048d20948d Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 24 Sep 2026 03:20:43 +0200 Subject: [PATCH 3/4] Update h2 to 0.12.3 0.12.3 sends the DATA already buffered on a stream when a SETTINGS frame raises the initial window, so a request body queued against a zero window no longer stalls until an unrelated WINDOW_UPDATE arrives. --- NEWS.md | 5 ++++- rebar.config | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/NEWS.md b/NEWS.md index 7d610953..5d9cac4c 100644 --- a/NEWS.md +++ b/NEWS.md @@ -51,7 +51,10 @@ unreleased ### Changed -- Update `h2` to 0.12.1. +- Update `h2` to 0.12.3. 0.12.3 sends the DATA already buffered on a stream + when a SETTINGS frame raises the initial window, so a request body queued + against a zero window no longer stalls until an unrelated WINDOW_UPDATE + arrives. - Update `quic` to 2.0.0 and `webtransport` to 0.4.7. quic 2.0.0 reports a peer resetting a request stream, always sends a reason with its HTTP/3 close event, and fixes a handshake that could stall when resuming from a diff --git a/rebar.config b/rebar.config index 7f3eff41..e8cf0e33 100644 --- a/rebar.config +++ b/rebar.config @@ -55,7 +55,7 @@ %% Pure Erlang QUIC + HTTP/3 stack {quic, "~>2.0"}, %% Pure Erlang HTTP/2 stack - {h2, "~>0.12.1"}, + {h2, "~>0.12.3"}, %% WebTransport client (HTTP/3 and HTTP/2) - powers the wt_* API {webtransport, "~>0.4.7"}, {idna, "~>7.1.0"}, From 9494dc460534544c111c2ef47fb070164dd671ae Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Thu, 24 Sep 2026 07:19:49 +0200 Subject: [PATCH 4/4] Track rebar.lock The lock pins what a build of hackney itself resolves: quic 2.0.0, h2 0.12.3 and webtransport 0.4.7. It was ignored, so a checkout resolved afresh each time and CI could pick up a dependency release that no commit had been tested against. A project depending on hackney still resolves its own versions; a lock only binds its own project. --- .gitignore | 1 - rebar.lock | 29 +++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 rebar.lock diff --git a/.gitignore b/.gitignore index 83a78bae..80bdb160 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,3 @@ rebar3.crashdump doc/ priv/*.so priv/*.dll -rebar.lock diff --git a/rebar.lock b/rebar.lock new file mode 100644 index 00000000..2313d963 --- /dev/null +++ b/rebar.lock @@ -0,0 +1,29 @@ +{"1.2.0", +[{<<"certifi">>,{pkg,<<"certifi">>,<<"2.17.0">>},0}, + {<<"h2">>,{pkg,<<"h2">>,<<"0.12.3">>},0}, + {<<"idna">>,{pkg,<<"idna">>,<<"7.1.0">>},0}, + {<<"mimerl">>,{pkg,<<"mimerl">>,<<"1.5.0">>},0}, + {<<"parse_trans">>,{pkg,<<"parse_trans">>,<<"3.4.2">>},0}, + {<<"quic">>,{pkg,<<"quic">>,<<"2.0.0">>},0}, + {<<"ssl_verify_fun">>,{pkg,<<"ssl_verify_fun">>,<<"1.1.7">>},0}, + {<<"webtransport">>,{pkg,<<"webtransport">>,<<"0.4.7">>},0}]}. +[ +{pkg_hash,[ + {<<"certifi">>, <<"835748414307E15E05B17D0E518190228CE648B08D569A5CC93A85A40F3E5C9B">>}, + {<<"h2">>, <<"20E3FD0E384EC6F586E4736ACD409A57EA87B4A56002D8DCF1132514B3D7600A">>}, + {<<"idna">>, <<"1067A13043538129602D2F2CE6899D8713125C7D19734AA557CE2E3EA55BD4F1">>}, + {<<"mimerl">>, <<"F35ACA6F23242339B3666E0AC0702379E362B469D0AEA167F6CC713547E777ED">>}, + {<<"parse_trans">>, <<"C352DDC1A0D5E54F9B1654D45F9C432EEF76F9CEA371C55DDFF769EF688FDB74">>}, + {<<"quic">>, <<"FE44A1CEA79078879C4431FBE595F115D8F7932ED5FB45AE4E9C67A4DAE5863C">>}, + {<<"ssl_verify_fun">>, <<"354C321CF377240C7B8716899E182CE4890C5938111A1296ADD3EC74CF1715DF">>}, + {<<"webtransport">>, <<"8E0ABD5875DAAB05C7020B8FC0C3B318FE93AA9329302FE5ECDA2A0F953CF688">>}]}, +{pkg_hash_ext,[ + {<<"certifi">>, <<"8122798A17F0293C80DAADA25D0F81C7F4D708C73FEF782C7C9B1950E26E4D21">>}, + {<<"h2">>, <<"996AF98698F7DC68BCC7688D70D97384B53DDD0286BA07E6D4A9AC54F1970D32">>}, + {<<"idna">>, <<"6AE959A025BF36DF61A8CAB8508D9654891B5426A84C44D82DEAFFD6DDF8C71F">>}, + {<<"mimerl">>, <<"DB648CE065BAE14EA84CA8B5DD123F42F49417CEF693541110BF6F9E9BE9ECC4">>}, + {<<"parse_trans">>, <<"4C25347DE3B7C35732D32E69AB43D1CEEE0BEAE3F3B3ADE1B59CBD3DD224D9CA">>}, + {<<"quic">>, <<"69DA19A76181E03F975AFB18151C51F17F5623E3C38FD1A816D3148998EDBBB5">>}, + {<<"ssl_verify_fun">>, <<"FE4C190E8F37401D30167C8C405EDA19469F34577987C76DDE613E838BBC67F8">>}, + {<<"webtransport">>, <<"9D2FCBFC561A172CF6F2EEF00B89FC89774C039CE850414356964C147707F85E">>}]} +].