Current section

Files

Jump to
hackney src hackney_h2_stream.erl
Raw

src/hackney_h2_stream.erl

%%% -*- erlang -*-
%%%
%%% This file is part of hackney released under the Apache 2 license.
%%% See the NOTICE for more information.
%%%
%%% Copyright (c) 2026 Benoît Chesneau <benoitc@pm.me>
%%%
%%% @doc gen_statem process for a single full-duplex HTTP/2 stream
%%% (gRPC-style bidirectional streaming).
%%%
%%% This module mirrors {@link hackney_wt} / {@link hackney_ws}: an
%%% application gets a pid and uses `send'/`recv' (passive) or active-mode
%%% messages. It owns one dedicated HTTP/2 connection (negotiated via ALPN)
%%% and opens one stream on it whose events are routed here directly by the
%%% `h2' library's per-stream handler mechanism, so the request body and the
%%% response can be sent and read interleaved on the same stream.
%%%
%%% == Send / receive ==
%%%
%%% `send/2,3' writes a DATA frame (optionally with END_STREAM via `fin').
%%% `send_trailers/2' closes the send side with trailing HEADERS (gRPC
%%% carries grpc-status there). `recv/1,2' returns the next inbound message:
%%% `{response, Status, Headers}', `{data, Data}', `{trailers, Trailers}' or
%%% `done' (the peer ended the stream). After `done', recv returns
%%% `{error, closed}'.
%%%
%%% == Delivery modes ==
%%%
%%% Passive (default): messages are buffered and read with `recv'. Active
%%% (`{active, true|once}'): every message is forwarded to the owner as
%%% `{hackney_h2, Pid, Msg}', and errors as `{hackney_h2_error, Pid, Reason}'.
%%%
%%% == Flow control ==
%%%
%%% With `{flow_control, manual}' the receive window is only replenished by
%%% `consume/2', so a slow reader applies backpressure to the peer. The
%%% default `auto' replenishes automatically.
%%%
%%% States: idle (not yet connected), connected (ready), closed (stream/conn
%%% ended, buffered data still drainable).
-module(hackney_h2_stream).
-behaviour(gen_statem).
%% API
-export([
start_link/1,
connect/1, connect/2,
send/2, send/3,
send_trailers/2,
recv/1, recv/2,
consume/2,
setopts/2,
controlling_process/2,
close/1
]).
%% gen_statem callbacks
-export([init/1, callback_mode/0, terminate/3, code_change/4]).
%% State functions
-export([idle/3, connected/3, closed/3]).
-define(CONNECT_TIMEOUT, 8000).
-define(RECV_TIMEOUT, infinity).
%% Bound the decoded bytes buffered in the passive recv queue so a hostile
%% peer cannot drive the client to OOM; complements HTTP/2 flow control.
-define(DEFAULT_MAX_RECV_BUFFER, 16#4000000). %% 64 MiB
%% A normalized inbound message handed to recv/2 or, in active mode, the owner.
-type h2_msg() :: {response, non_neg_integer(), list()}
| {data, binary()}
| {trailers, list()}
| done.
-export_type([h2_msg/0]).
-record(h2s_data, {
%% Stream owner (linked via start_link, trap_exit handles death)
owner :: pid(),
%% Request identity used to open the stream
method :: binary(),
host :: string() | binary(),
port :: inet:port_number(),
transport :: module(),
path :: binary(),
headers = [] :: list(),
%% Connection setup
connect_options = [] :: list(),
ssl_options = [] :: list(),
connect_timeout = ?CONNECT_TIMEOUT :: timeout(),
recv_timeout = ?RECV_TIMEOUT :: timeout(),
flow_control = auto :: auto | manual,
active = false :: false | true | once,
%% Underlying hackney_conn (dedicated, owned here) and the h2 stream
conn_pid :: pid() | undefined,
conn_mon :: reference() | undefined,
h2_conn :: pid() | undefined,
stream_id :: pos_integer() | undefined,
%% Passive recv buffering
recv_q = queue:new() :: queue:queue(),
recv_from :: gen_statem:from() | undefined,
recv_bytes = 0 :: non_neg_integer(),
max_recv_buffer = ?DEFAULT_MAX_RECV_BUFFER :: non_neg_integer() | infinity,
%% Peer END_STREAM seen (no more inbound after the queue drains)
ended = false :: boolean(),
closed_reason :: term()
}).
%%====================================================================
%% API
%%====================================================================
%% @doc Start a stream process. See hackney:h2_open/3,4 for option docs.
-spec start_link(map()) -> {ok, pid()} | {error, term()}.
start_link(Opts) when is_map(Opts) ->
gen_statem:start_link(?MODULE, [self(), Opts], []).
%% @doc Establish the connection and open the stream. Blocks until ready.
-spec connect(pid()) -> ok | {error, term()}.
connect(Pid) ->
connect(Pid, ?CONNECT_TIMEOUT).
-spec connect(pid(), timeout()) -> ok | {error, term()}.
connect(Pid, Timeout) ->
gen_statem:call(Pid, {connect, Timeout}, infinity).
%% @doc Send a DATA frame on the stream (no END_STREAM).
-spec send(pid(), iodata()) -> ok | {error, term()}.
send(Pid, Data) ->
send(Pid, Data, nofin).
%% @doc Send a DATA frame, optionally half-closing the send side (`fin').
-spec send(pid(), iodata(), fin | nofin) -> ok | {error, term()}.
send(Pid, Data, Fin) when Fin =:= fin; Fin =:= nofin ->
gen_statem:call(Pid, {send, Data, Fin}).
%% @doc Send trailing HEADERS, half-closing the send side (gRPC trailers).
-spec send_trailers(pid(), list()) -> ok | {error, term()}.
send_trailers(Pid, Trailers) ->
gen_statem:call(Pid, {send_trailers, Trailers}).
%% @doc Receive the next inbound message (passive mode only).
-spec recv(pid()) -> {ok, h2_msg()} | {error, term()}.
recv(Pid) ->
gen_statem:call(Pid, {recv, default}, infinity).
-spec recv(pid(), timeout()) -> {ok, h2_msg()} | {error, term()}.
recv(Pid, Timeout) ->
gen_statem:call(Pid, {recv, Timeout}, infinity).
%% @doc Acknowledge N consumed bytes (manual flow control only).
-spec consume(pid(), non_neg_integer()) -> ok | {error, term()}.
consume(Pid, NBytes) when is_integer(NBytes), NBytes >= 0 ->
gen_statem:call(Pid, {consume, NBytes}).
%% @doc Set options. Supported: [{active, true|false|once}].
-spec setopts(pid(), list()) -> ok | {error, term()}.
setopts(Pid, Opts) ->
gen_statem:call(Pid, {setopts, Opts}).
%% @doc Assign a new controlling process.
-spec controlling_process(pid(), pid()) -> ok | {error, term()}.
controlling_process(Pid, NewOwner) ->
gen_statem:call(Pid, {controlling_process, NewOwner}).
%% @doc Cancel the stream and tear down the connection.
-spec close(pid()) -> ok.
close(Pid) ->
gen_statem:cast(Pid, close).
%%====================================================================
%% gen_statem callbacks
%%====================================================================
%% @private
callback_mode() ->
[state_functions, state_enter].
%% @private
init([Owner, Opts]) ->
process_flag(trap_exit, true),
Data = #h2s_data{
owner = Owner,
method = maps:get(method, Opts, <<"POST">>),
host = maps:get(host, Opts),
port = maps:get(port, Opts),
transport = maps:get(transport, Opts),
path = maps:get(path, Opts, <<"/">>),
headers = maps:get(headers, Opts, []),
connect_options = maps:get(connect_options, Opts, []),
ssl_options = maps:get(ssl_options, Opts, []),
connect_timeout = maps:get(connect_timeout, Opts, ?CONNECT_TIMEOUT),
recv_timeout = maps:get(recv_timeout, Opts, ?RECV_TIMEOUT),
flow_control = maps:get(flow_control, Opts, auto),
active = maps:get(active, Opts, false),
max_recv_buffer = maps:get(max_recv_buffer, Opts, ?DEFAULT_MAX_RECV_BUFFER)
},
{ok, idle, Data}.
%% @private
terminate(_Reason, _State, #h2s_data{conn_pid = undefined}) ->
ok;
terminate(_Reason, _State, #h2s_data{conn_pid = ConnPid}) ->
stop_conn(ConnPid),
ok.
%% @private
code_change(_OldVsn, State, Data, _Extra) ->
{ok, State, Data}.
%%====================================================================
%% State: idle
%%====================================================================
idle(enter, _OldState, _Data) ->
keep_state_and_data;
idle({call, From}, {connect, Timeout}, Data) ->
case establish(Timeout, Data) of
{ok, Data1} ->
{next_state, connected, Data1, [{reply, From, ok}]};
{error, Reason} ->
{stop_and_reply, normal, [{reply, From, {error, Reason}}]}
end;
idle({call, From}, _Request, _Data) ->
{keep_state_and_data, [{reply, From, {error, not_connected}}]};
idle(info, {'EXIT', Owner, _Reason}, #h2s_data{owner = Owner}) ->
{stop, normal};
idle(_, _, _) ->
keep_state_and_data.
%%====================================================================
%% State: connected
%%====================================================================
connected(enter, _OldState, _Data) ->
keep_state_and_data;
%% --- send ------------------------------------------------------------
connected({call, From}, {send, SData, Fin},
#h2s_data{h2_conn = H2Conn, stream_id = Sid}) ->
EndStream = (Fin =:= fin),
Reply = h2_call(fun() ->
h2_connection:send_data(H2Conn, Sid, iolist_to_binary(SData), EndStream)
end),
{keep_state_and_data, [{reply, From, Reply}]};
connected({call, From}, {send_trailers, Trailers},
#h2s_data{h2_conn = H2Conn, stream_id = Sid}) ->
Reply = h2_call(fun() -> h2_connection:send_trailers(H2Conn, Sid, Trailers) end),
{keep_state_and_data, [{reply, From, Reply}]};
connected({call, From}, {consume, NBytes},
#h2s_data{h2_conn = H2Conn, stream_id = Sid}) ->
Reply = h2_call(fun() -> h2_connection:consume(H2Conn, Sid, NBytes) end),
{keep_state_and_data, [{reply, From, Reply}]};
%% --- recv ------------------------------------------------------------
connected({call, From}, {recv, _Timeout}, #h2s_data{active = Active})
when Active =/= false ->
{keep_state_and_data, [{reply, From, {error, {active_mode, Active}}}]};
connected({call, From}, {recv, _Timeout}, #h2s_data{recv_from = RF})
when RF =/= undefined ->
{keep_state_and_data, [{reply, From, {error, recv_busy}}]};
connected({call, From}, {recv, Timeout0}, Data) ->
Timeout = recv_timeout(Timeout0, Data),
case dequeue(Data) of
{value, Msg, Data1} ->
{keep_state, Data1, [{reply, From, {ok, Msg}}]};
empty ->
case Data#h2s_data.ended of
true ->
{keep_state_and_data, [{reply, From, {error, closed}}]};
false ->
{keep_state, Data#h2s_data{recv_from = From}, recv_timer(Timeout)}
end
end;
%% --- options ---------------------------------------------------------
connected({call, From}, {setopts, Opts}, Data) ->
case proplists:get_value(active, Opts) of
undefined ->
{keep_state_and_data, [{reply, From, ok}]};
NewActive when NewActive =:= true; NewActive =:= false; NewActive =:= once ->
{keep_state, apply_active(NewActive, Data), [{reply, From, ok}]};
_ ->
{keep_state_and_data, [{reply, From, {error, badarg}}]}
end;
connected({call, From}, {controlling_process, NewOwner}, #h2s_data{owner = OldOwner} = Data) ->
unlink(OldOwner),
link(NewOwner),
{keep_state, Data#h2s_data{owner = NewOwner}, [{reply, From, ok}]};
connected({call, From}, _Request, _Data) ->
{keep_state_and_data, [{reply, From, {error, badrequest}}]};
%% --- close -----------------------------------------------------------
connected(cast, close, Data) ->
cancel_stream_safe(Data),
stop_conn(Data#h2s_data.conn_pid),
{next_state, closed, Data#h2s_data{closed_reason = closed}};
%% --- recv timeout ----------------------------------------------------
connected({timeout, recv}, recv, #h2s_data{recv_from = From} = Data)
when From =/= undefined ->
{keep_state, Data#h2s_data{recv_from = undefined}, [{reply, From, {error, timeout}}]};
connected({timeout, recv}, recv, _Data) ->
keep_state_and_data;
%% --- stream events from the h2 connection ----------------------------
connected(info, {h2, H2Conn, Event}, #h2s_data{h2_conn = H2Conn} = Data) ->
handle_h2_event(Event, Data);
%% The hackney_conn owning the h2 connection died.
connected(info, {'DOWN', Mon, process, _Pid, Reason}, #h2s_data{conn_mon = Mon} = Data) ->
stream_ended(down_reason(Reason), Data#h2s_data{conn_pid = undefined, conn_mon = undefined});
connected(info, {'EXIT', Owner, _Reason}, #h2s_data{owner = Owner} = Data) ->
cancel_stream_safe(Data),
stop_conn(Data#h2s_data.conn_pid),
{stop, normal};
connected(_, _, _) ->
keep_state_and_data.
%%====================================================================
%% State: closed
%%====================================================================
closed(enter, _OldState, _Data) ->
keep_state_and_data;
%% Drain whatever was buffered before the stream/connection ended.
closed({call, From}, {recv, _Timeout}, Data) ->
case dequeue(Data) of
{value, Msg, Data1} ->
{keep_state, Data1, [{reply, From, {ok, Msg}}]};
empty ->
{keep_state_and_data, [{reply, From, {error, closed_reason(Data)}}]}
end;
%% Once the stream has ended there is no receive window left to manage, so
%% consume is a harmless no-op (a slow reader may still be draining + acking
%% buffered chunks after the peer's END_STREAM arrived).
closed({call, From}, {consume, _NBytes}, _Data) ->
{keep_state_and_data, [{reply, From, ok}]};
closed({call, From}, {controlling_process, NewOwner}, #h2s_data{owner = OldOwner} = Data) ->
unlink(OldOwner),
link(NewOwner),
{keep_state, Data#h2s_data{owner = NewOwner}, [{reply, From, ok}]};
closed({call, From}, _Request, _Data) ->
{keep_state_and_data, [{reply, From, {error, closed}}]};
closed(cast, close, _Data) ->
keep_state_and_data;
closed(info, {'EXIT', Owner, _Reason}, #h2s_data{owner = Owner}) ->
{stop, normal};
closed(_, _, _) ->
keep_state_and_data.
%%====================================================================
%% Internal: connection + stream setup
%%====================================================================
%% @private Open a dedicated HTTP/2 connection and one handler-routed stream.
establish(Timeout, Data) ->
#h2s_data{host = Host, port = Port, transport = Transport,
connect_options = COpts, ssl_options = SslOpts,
recv_timeout = RT, method = Method, path = Path,
headers = Headers, flow_control = FC} = Data,
ConnOpts = #{
host => Host,
port => Port,
transport => Transport,
connect_timeout => Timeout,
recv_timeout => RT,
connect_options => [{protocols, [http2]} | COpts],
ssl_options => SslOpts
},
case hackney_conn_sup:start_conn(ConnOpts) of
{ok, ConnPid} ->
case hackney_conn:connect(ConnPid, Timeout) of
ok ->
case hackney_conn:get_protocol(ConnPid) of
http2 ->
open_stream(ConnPid, Method, Path, Headers, FC, Data);
Other ->
stop_conn(ConnPid),
{error, {protocol_not_negotiated, Other}}
end;
{error, Reason} ->
stop_conn(ConnPid),
{error, Reason}
end;
{error, Reason} ->
{error, Reason}
end.
open_stream(ConnPid, Method, Path, Headers, FC, Data) ->
case hackney_conn:open_h2_stream(ConnPid, Method, Path, Headers, self(),
#{flow_control => FC}) of
{ok, H2Conn, StreamId} ->
Mon = erlang:monitor(process, ConnPid),
{ok, Data#h2s_data{conn_pid = ConnPid, conn_mon = Mon,
h2_conn = H2Conn, stream_id = StreamId}};
{error, Reason} ->
stop_conn(ConnPid),
{error, Reason}
end.
%%====================================================================
%% Internal: inbound stream events
%%====================================================================
%% @private Map an {h2, Conn, Event} stream event onto recv messages.
handle_h2_event({response, _Sid, Status, Headers}, Data) ->
deliver({response, Status, Headers}, Data);
handle_h2_event({informational, _Sid, _Status, _Headers}, Data) ->
{keep_state, Data};
handle_h2_event({data, _Sid, Body, false}, Data) ->
case Body of
<<>> -> {keep_state, Data};
_ -> deliver({data, Body}, Data)
end;
handle_h2_event({data, _Sid, Body, true}, Data) ->
Data1 = case Body of
<<>> -> Data;
_ -> deliver_acc({data, Body}, Data)
end,
finish(Data1);
handle_h2_event({trailers, _Sid, Trailers}, Data) ->
Data1 = deliver_acc({trailers, Trailers}, Data),
finish(Data1);
handle_h2_event({stream_reset, _Sid, ErrorCode}, Data) ->
stream_ended({stream_error, ErrorCode}, Data);
handle_h2_event({goaway, _LastSid, _ErrorCode}, #h2s_data{ended = true}) ->
keep_state_and_data;
handle_h2_event({goaway, _LastSid, ErrorCode}, Data) ->
stream_ended({goaway, ErrorCode}, Data);
handle_h2_event({closed, Reason}, Data) ->
stream_ended(down_reason(Reason), Data);
handle_h2_event(_Other, Data) ->
{keep_state, Data}.
%% @private Deliver a message, returning the gen_statem transition.
deliver(Msg, Data) ->
{keep_state, deliver_acc(Msg, Data)}.
%% @private Deliver/buffer a message and return the updated #h2s_data only,
%% so callers can chain (e.g. data then done).
deliver_acc(Msg, #h2s_data{active = Active} = Data) when Active =/= false ->
_ = (Data#h2s_data.owner) ! {hackney_h2, self(), Msg},
case Active of
once -> Data#h2s_data{active = false};
true -> Data
end;
deliver_acc(Msg, #h2s_data{recv_from = From} = Data) when From =/= undefined ->
gen_statem:reply(From, {ok, Msg}),
%% A pending {timeout, recv} may still fire; it is a no-op once recv_from
%% is cleared (see the connected/closed {timeout, recv} clauses).
Data#h2s_data{recv_from = undefined};
deliver_acc(Msg, Data) ->
enqueue(Msg, Data).
%% @private Mark the stream ended and surface a terminal `done', then move to
%% closed (buffered data stays drainable).
finish(Data0) ->
Data1 = deliver_acc(done, Data0#h2s_data{ended = true}),
{next_state, closed, Data1#h2s_data{closed_reason = closed}}.
%% @private The stream/connection ended abnormally: fail a waiting reader,
%% notify the owner in active mode, move to closed.
stream_ended(Reason, #h2s_data{recv_from = From} = Data) when From =/= undefined ->
_ = maybe_notify_error(Reason, Data),
{next_state, closed,
Data#h2s_data{recv_from = undefined, ended = true, closed_reason = Reason},
[{reply, From, {error, Reason}}]};
stream_ended(Reason, Data) ->
_ = maybe_notify_error(Reason, Data),
{next_state, closed, Data#h2s_data{ended = true, closed_reason = Reason}}.
maybe_notify_error(_Reason, #h2s_data{active = false}) ->
ok;
maybe_notify_error(closed, #h2s_data{owner = Owner}) ->
Owner ! {hackney_h2, self(), {closed, closed}};
maybe_notify_error(Reason, #h2s_data{owner = Owner}) ->
Owner ! {hackney_h2_error, self(), Reason}.
%%====================================================================
%% Internal: passive buffering
%%====================================================================
enqueue(Msg, #h2s_data{recv_q = Q} = Data) ->
case add_bytes(msg_size(Msg), Data) of
{ok, Data1} ->
Data1#h2s_data{recv_q = queue:in(Msg, Q)};
overflow ->
%% Tear the connection down; recv surfaces the overflow reason.
cancel_stream_safe(Data),
stop_conn(Data#h2s_data.conn_pid),
Data#h2s_data{ended = true, closed_reason = recv_buffer_overflow}
end.
dequeue(#h2s_data{recv_q = Q} = Data) ->
case queue:out(Q) of
{{value, Msg}, Q1} ->
{value, Msg, sub_bytes(msg_size(Msg), Data#h2s_data{recv_q = Q1})};
{empty, _} ->
empty
end.
add_bytes(_Size, #h2s_data{max_recv_buffer = infinity} = Data) ->
{ok, Data};
add_bytes(Size, #h2s_data{recv_bytes = B, max_recv_buffer = Max} = Data) ->
case B + Size > Max of
true -> overflow;
false -> {ok, Data#h2s_data{recv_bytes = B + Size}}
end.
sub_bytes(Size, #h2s_data{recv_bytes = B} = Data) ->
Data#h2s_data{recv_bytes = max(0, B - Size)}.
msg_size({data, B}) -> byte_size(B);
msg_size(_) -> 0.
%% @private Apply a new active mode, flushing buffered messages on the way.
apply_active(false, Data) ->
Data#h2s_data{active = false};
apply_active(true, #h2s_data{owner = Owner, recv_q = Q} = Data) ->
lists:foreach(fun(Msg) -> Owner ! {hackney_h2, self(), Msg} end, queue:to_list(Q)),
Data#h2s_data{active = true, recv_q = queue:new(), recv_bytes = 0};
apply_active(once, #h2s_data{owner = Owner} = Data) ->
case dequeue(Data) of
{value, Msg, Data1} ->
Owner ! {hackney_h2, self(), Msg},
Data1#h2s_data{active = false};
empty ->
Data#h2s_data{active = once}
end.
%%====================================================================
%% Internal: helpers
%%====================================================================
recv_timer(infinity) -> [];
recv_timer(Timeout) -> [{{timeout, recv}, Timeout, recv}].
recv_timeout(default, #h2s_data{recv_timeout = T}) -> T;
recv_timeout(T, _Data) -> T.
closed_reason(#h2s_data{closed_reason = undefined}) -> closed;
closed_reason(#h2s_data{closed_reason = R}) -> R.
down_reason(normal) -> closed;
down_reason(shutdown) -> closed;
down_reason({shutdown, _}) -> closed;
down_reason(Reason) -> {closed, Reason}.
%% @private Run an h2_connection call, normalising a dead-connection exit.
h2_call(Fun) ->
try Fun()
catch
exit:{ExitReason, _} -> {error, {closed, ExitReason}};
exit:ExitReason -> {error, {closed, ExitReason}}
end.
cancel_stream_safe(#h2s_data{h2_conn = undefined}) ->
ok;
cancel_stream_safe(#h2s_data{h2_conn = H2Conn, stream_id = Sid}) ->
_ = try h2_connection:cancel_stream(H2Conn, Sid) catch _:_ -> ok end,
ok.
stop_conn(undefined) ->
ok;
stop_conn(ConnPid) ->
try hackney_conn:stop(ConnPid) catch _:_ -> ok end,
ok.