Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,3 @@ rebar3.crashdump
doc/
priv/*.so
priv/*.dll
rebar.lock
16 changes: 11 additions & 5 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,11 +51,14 @@ 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.
- 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
cached session ticket.

4.7.4 - 2026-08-12
------------------
Expand Down
6 changes: 3 additions & 3 deletions rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,11 @@

{deps, [
%% Pure Erlang QUIC + HTTP/3 stack
{quic, "~>1.10.0"},
{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.6"},
{webtransport, "~>0.4.7"},
{idna, "~>7.1.0"},
{mimerl, "~>1.5"},
{certifi, "~>2.17.0"},
Expand Down
29 changes: 29 additions & 0 deletions rebar.lock
Original file line number Diff line number Diff line change
@@ -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">>}]}
].
18 changes: 18 additions & 0 deletions src/hackney_conn.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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}) ->
Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion test/hackney_h3_test_server.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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}]}].
Expand Down Expand Up @@ -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, [], <<>>).

Expand Down
1 change: 0 additions & 1 deletion test/hackney_http3_integration_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@ connect(Server, Opts) ->

tcp_opts() ->
[{protocols, [http3, http2, http1]},
{zero_rtt, false},
{connect_timeout, 15000},
{ssl_options, [{insecure, true}]}].

Expand Down
37 changes: 36 additions & 1 deletion test/hackney_http3_streaming_tests.erl
Original file line number Diff line number Diff line change
Expand Up @@ -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) ->
Expand Down Expand Up @@ -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
%%====================================================================
Expand Down
Loading