diff --git a/erlang.mk b/erlang.mk index fbde250..1078fc5 100644 --- a/erlang.mk +++ b/erlang.mk @@ -17,7 +17,7 @@ ERLANG_MK_FILENAME := $(realpath $(lastword $(MAKEFILE_LIST))) export ERLANG_MK_FILENAME -ERLANG_MK_VERSION = 2022.05.31-142-gba4dcff-dirty +ERLANG_MK_VERSION = 4dad822 ERLANG_MK_WITHOUT = # Make 3.81 and 3.82 are deprecated. @@ -441,7 +441,7 @@ export REBAR_DEPS_DIR # When testing Erlang.mk and updating these, make sure # to delete test/test_rebar_git before running tests again. REBAR3_GIT ?= https://github.com/erlang/rebar3 -REBAR3_COMMIT ?= bde4b54248d16280b2c70a244aca3bb7566e2033 # 3.23.0 +REBAR3_COMMIT ?= 619df55d9dab109cbd1b5d9ed1d45c93f5450e24 # 3.27.0 CACHE_DEPS ?= 0 diff --git a/src/cow_http3.erl b/src/cow_http3.erl index d950500..54a6f90 100644 --- a/src/cow_http3.erl +++ b/src/cow_http3.erl @@ -92,7 +92,7 @@ -spec parse(binary()) -> {ok, frame(), binary()} - | {webtransport_stream_header, stream_id(), binary()} + | {wt_stream_header, stream_id(), binary()} | {more, {data, binary()} | ignore, non_neg_integer()} | {ignore, binary()} | {connection_error, h3_frame_error | h3_frame_unexpected | h3_settings_error, atom()} @@ -215,13 +215,13 @@ parse(<<13, _/bits>>) -> %% WebTransport stream header. %% parse(<<1:2, 16#41:14, 0:2, SessionID:6, Rest/bits>>) -> - {webtransport_stream_header, SessionID, Rest}; + {wt_stream_header, SessionID, Rest}; parse(<<1:2, 16#41:14, 1:2, SessionID:14, Rest/bits>>) -> - {webtransport_stream_header, SessionID, Rest}; + {wt_stream_header, SessionID, Rest}; parse(<<1:2, 16#41:14, 2:2, SessionID:30, Rest/bits>>) -> - {webtransport_stream_header, SessionID, Rest}; + {wt_stream_header, SessionID, Rest}; parse(<<1:2, 16#41:14, 3:2, SessionID:62, Rest/bits>>) -> - {webtransport_stream_header, SessionID, Rest}; + {wt_stream_header, SessionID, Rest}; parse(<<16#41, _/bits>>) -> more; %% diff --git a/src/cow_http3_machine.erl b/src/cow_http3_machine.erl index 515f87a..a4b2972 100644 --- a/src/cow_http3_machine.erl +++ b/src/cow_http3_machine.erl @@ -14,23 +14,40 @@ -module(cow_http3_machine). +%% Init. -export([init/2]). -export([init_unidi_local_streams/4]). --export([init_unidi_stream/3]). + +%% New streams. +-export([new_unidi_stream/3]). -export([set_unidi_remote_stream_type/3]). --export([init_bidi_stream/2]). --export([init_bidi_stream/3]). +-export([new_bidi_stream/2]). +-export([new_bidi_stream/3]). + +%% WebTransport streams. -export([become_webtransport_session/2]). -export([become_webtransport_stream/3]). +-export([new_wt_local_stream/4]). -export([close_webtransport_session/2]). --export([close_bidi_stream_for_sending/2]). --export([close_stream/2]). + +%% Stream shutdown. +-export([discard_stream/2]). +-export([fin_local/2]). +%% @todo -export([reset_stream_local/2]). +-export([fin_remote/2]). +-export([reset_stream_remote/2]). +-export([stop_sending_remote/2]). + +%% Incoming frames and data. -export([unidi_data/4]). -export([frame/4]). --export([ignored_frame/2]). +-export([ignored_frame/3]). + +%% Outgoing headers/trailers. -export([prepare_headers/5]). -export([prepare_trailers/3]). --export([reset_stream/2]). + +%% Getters. -export([get_bidi_stream_local_state/2]). -export([get_bidi_stream_remote_state/2]). @@ -91,7 +108,13 @@ session_id :: cow_http3:stream_id(), %% Unidi stream direction (local = we initiated) or bidi. - dir :: unidi_stream_dir() | bidi + dir :: unidi_stream_dir() | bidi, + + %% Whether we finished sending data. + local :: cow_http:fin(), + + %% Whether we finished receiving data. + remote :: cow_http:fin() }). -type stream() :: #unidi_stream{} | #bidi_stream{} | #wt_session{} | #wt_stream{}. @@ -135,6 +158,8 @@ -type instructions() :: undefined | {decoder_instructions | encoder_instructions, iodata()}. +%% Init. + -spec init(client | server, opts()) -> {ok, iolist(), http3_machine()}. @@ -195,36 +220,39 @@ init_unidi_local_streams(ControlID, EncoderID, DecoderID, DecoderID => #unidi_stream{id=DecoderID, dir=unidi_local, type=decoder} }}. --spec init_unidi_stream(cow_http3:stream_id(), unidi_stream_dir(), State) +%% New streams. + +-spec new_unidi_stream(cow_http3:stream_id(), unidi_stream_dir(), State) -> State when State::http3_machine(). -init_unidi_stream(StreamID, StreamDir, State=#http3_machine{streams=Streams}) -> +new_unidi_stream(StreamID, StreamDir, State=#http3_machine{streams=Streams}) -> State#http3_machine{streams=Streams#{StreamID => #unidi_stream{ id=StreamID, dir=StreamDir, type=undefined}}}. -spec set_unidi_remote_stream_type(cow_http3:stream_id(), unidi_stream_type(), State) -> {ok, State} + %% @todo Most {error, {connection_error... are unnecessary. | {error, {connection_error, h3_stream_creation_error, atom()}, State} when State::http3_machine(). -set_unidi_remote_stream_type(StreamID, Type=control, +set_unidi_remote_stream_type(StreamID, StreamType=control, State=#http3_machine{peer_control_state=no_stream}) -> Stream = stream_get(StreamID, State), - {ok, stream_store(Stream#unidi_stream{type=Type}, + {ok, stream_store(Stream#unidi_stream{type=StreamType}, State#http3_machine{peer_control_state=no_settings})}; set_unidi_remote_stream_type(_, control, State) -> {error, {connection_error, h3_stream_creation_error, 'A peer cannot open two control streams. (RFC9114 6.2.1)'}, State}; -set_unidi_remote_stream_type(StreamID, Type=decoder, +set_unidi_remote_stream_type(StreamID, StreamType=decoder, State=#http3_machine{peer_decode_state=no_stream}) -> Stream = stream_get(StreamID, State), - {ok, stream_store(Stream#unidi_stream{type=Type}, + {ok, stream_store(Stream#unidi_stream{type=StreamType}, State#http3_machine{peer_decode_state=ready})}; -set_unidi_remote_stream_type(StreamID, Type=encoder, +set_unidi_remote_stream_type(StreamID, StreamType=encoder, State=#http3_machine{peer_encode_state=no_stream}) -> Stream = stream_get(StreamID, State), - {ok, stream_store(Stream#unidi_stream{type=Type}, + {ok, stream_store(Stream#unidi_stream{type=StreamType}, State#http3_machine{peer_encode_state=ready})}; set_unidi_remote_stream_type(_, decoder, State) -> {error, {connection_error, h3_stream_creation_error, @@ -238,21 +266,19 @@ set_unidi_remote_stream_type(_, encoder, State) -> %% All bidi streams are request/response. %% We only need to know the method when in client mode. --spec init_bidi_stream(cow_http3:stream_id(), State) +-spec new_bidi_stream(cow_http3:stream_id(), State) -> State when State::http3_machine(). -init_bidi_stream(StreamID, State=#http3_machine{streams=Streams}) -> - State#http3_machine{streams=Streams#{ - StreamID => #bidi_stream{id=StreamID} - }}. +new_bidi_stream(StreamID, State) -> + stream_store(#bidi_stream{id=StreamID}, State). --spec init_bidi_stream(cow_http3:stream_id(), binary(), State) +-spec new_bidi_stream(cow_http3:stream_id(), binary(), State) -> State when State::http3_machine(). -init_bidi_stream(StreamID, Method, State=#http3_machine{streams=Streams}) -> - State#http3_machine{streams=Streams#{ - StreamID => #bidi_stream{id=StreamID, method=Method} - }}. +new_bidi_stream(StreamID, Method, State) -> + stream_store(#bidi_stream{id=StreamID, method=Method}, State). + +%% WebTransport streams. -spec become_webtransport_session(cow_http3:stream_id(), State) -> State when State::http3_machine(). @@ -269,18 +295,31 @@ become_webtransport_stream(StreamID, SessionID, State0) -> case stream_get(SessionID, State0) of #wt_session{} -> %% The stream becomes a WT stream tied to SessionID. - Dir = case stream_get(StreamID, State0) of - #unidi_stream{dir=Dir0} -> Dir0; - %% @todo The bidi stream must be in idle state. - #bidi_stream{} -> bidi + {Dir, LocalIsFin} = case stream_get(StreamID, State0) of + #unidi_stream{dir=unidi_remote} -> {unidi_remote, fin}; + #bidi_stream{local=idle, remote=idle} -> {bidi, nofin} end, State = stream_store(#wt_stream{ - id=StreamID, session_id=SessionID, dir=Dir}, + id=StreamID, session_id=SessionID, dir=Dir, + local=LocalIsFin, remote=nofin}, State0), {ok, State} %% @todo Error conditions. end. +-spec new_wt_local_stream(cow_http3:stream_id(), bidi | unidi, cow_http3:stream_id(), State) + -> State when State::http3_machine(). + +new_wt_local_stream(StreamID, StreamType, SessionID, State) -> + {Dir, RemoteIsFin} = case StreamType of + bidi -> {bidi, nofin}; + unidi -> {unidi_local, fin} + end, + stream_store(#wt_stream{ + id=StreamID, session_id=SessionID, dir=Dir, + local=nofin, remote=RemoteIsFin + }, State). + -spec close_webtransport_session(cow_http3:stream_id(), State) -> State when State::http3_machine(). @@ -297,36 +336,152 @@ close_webtransport_session(SessionID, State=#http3_machine{streams=Streams0}) -> end, Streams0), State#http3_machine{streams=Streams}. --spec close_bidi_stream_for_sending(cow_http3:stream_id(), State) - -> State when State::http3_machine(). +%% Stream shutdown. +%% +%% Streams may be completely discarded, send/receive a FIN flag +%% or send/receive a RESET_STREAM or STOP_SENDING frame. +%% +%% When receiving a STOP_SENDING frame we have to send back +%% a RESET_STREAM frame so we treat STOP_SENDING as if the +%% direction was already closed. It is up to the user of this +%% module to send a RESET_STREAM frame back. +%% +%% When the user of this module sends a STOP_SENDING frame we +%% will want to drop any incoming data from the stream. But +%% that is best done directly by the user of this module so +%% there is no corresponding function. As far as this module +%% is concerned the remote direction will get closed on FIN +%% or RESET_STREAM reception. -close_bidi_stream_for_sending(StreamID, State=#http3_machine{streams=Streams}) -> - #{StreamID := Stream} = Streams, - stream_store(Stream#bidi_stream{local=fin}, State). +-spec discard_stream(cow_http3:stream_id(), State) + -> {closed, State} when State::http3_machine(). --spec close_stream(cow_http3:stream_id(), State) - -> {ok, State} - | {error, {connection_error, h3_closed_critical_stream, atom()}, State} +%% This function MUST NOT be used when the stream is: +%% +%% * #unidi_stream{} control/encoder/decoder (they must not be closed) +%% * #wt_session{} (use the dedicated function) + +discard_stream(StreamID, State=#http3_machine{streams=Streams0}) -> + %% Error out if the stream does not exist. + {_, Streams} = maps:take(StreamID, Streams0), + {closed, State#http3_machine{streams=Streams}}. + +-spec fin_local(cow_http3:stream_id(), State) + -> {ok | closed, State} when State::http3_machine(). + +%% This function MUST NOT be used when the stream is: +%% +%% * #unidi_stream{} control/encoder/decoder (they must not be closed) +%% * #wt_session{} (use the dedicated function) + +fin_local(StreamID, State=#http3_machine{streams=Streams0}) -> + {Stream, OtherStreams} = maps:take(StreamID, Streams0), + case Stream of + #bidi_stream{remote=nofin} -> + {ok, stream_store(Stream#bidi_stream{local=fin}, State)}; + #bidi_stream{remote=fin} -> + {closed, State#http3_machine{streams=OtherStreams}}; + #wt_stream{remote=nofin} -> + {ok, stream_store(Stream#wt_stream{local=fin}, State)}; + #wt_stream{remote=fin} -> + {closed, State#http3_machine{streams=OtherStreams}} + end. + +-spec fin_remote(cow_http3:stream_id(), State) + -> {ok | closed | wt_session_closed, State} + when State::http3_machine(). + +%% This function is to be used for streams that do not end up calling +%% the functions unidi_data/4, frame/4 or ignored_frame/2. These can +%% only be #wt_session{} or #wt_stream{} at this time. + +fin_remote(StreamID, State=#http3_machine{streams=Streams0}) -> + {Stream, OtherStreams} = maps:take(StreamID, Streams0), + case Stream of + #wt_session{} -> + %% We expect the user to call close_webtransport_session/2 separately. + {wt_session_closed, State#http3_machine{streams=OtherStreams}}; + #wt_stream{remote=nofin} -> + {ok, stream_store(Stream#wt_stream{local=fin}, State)}; + #wt_stream{remote=fin} -> + {closed, State#http3_machine{streams=OtherStreams}} + end. + +-spec reset_stream_remote(cow_http3:stream_id(), State) + -> {ok | closed | wt_session_closed, State} + | {{connection_error, h3_closed_critical_stream, atom()}, State} when State::http3_machine(). -close_stream(StreamID, State=#http3_machine{streams=Streams0}) -> - case maps:take(StreamID, Streams0) of - {#unidi_stream{type=control}, Streams} -> - {error, {connection_error, h3_closed_critical_stream, +%% The RESET_STREAM frame can be received by any stream. + +reset_stream_remote(StreamID, State=#http3_machine{streams=Streams0}) -> + {Stream, OtherStreams} = maps:take(StreamID, Streams0), + case Stream of + #unidi_stream{type=control} -> + {{connection_error, h3_closed_critical_stream, 'A control stream was closed. (RFC9114 6.2.1)'}, - State#http3_machine{streams=Streams}}; - {#unidi_stream{type=decoder}, Streams} -> - {error, {connection_error, h3_closed_critical_stream, + State#http3_machine{streams=OtherStreams}}; + #unidi_stream{type=decoder} -> + {{connection_error, h3_closed_critical_stream, 'A decoder stream was closed. (RFC9204 4.2)'}, - State#http3_machine{streams=Streams}}; - {#unidi_stream{type=encoder}, Streams} -> - {error, {connection_error, h3_closed_critical_stream, + State#http3_machine{streams=OtherStreams}}; + #unidi_stream{type=encoder} -> + {{connection_error, h3_closed_critical_stream, 'An encoder stream was closed. (RFC9204 4.2)'}, - State#http3_machine{streams=Streams}}; - {_, Streams} -> - {ok, State#http3_machine{streams=Streams}} + State#http3_machine{streams=OtherStreams}}; + #bidi_stream{local=nofin} -> + {ok, stream_store(Stream#bidi_stream{remote=fin}, State)}; + #bidi_stream{local=fin} -> + {closed, State#http3_machine{streams=OtherStreams}}; + #wt_session{} -> + %% We expect the user to call close_webtransport_session/2 separately. + {wt_session_closed, State#http3_machine{streams=OtherStreams}}; + #wt_stream{local=nofin} -> + {ok, stream_store(Stream#wt_stream{remote=fin}, State)}; + #wt_stream{local=fin} -> + {closed, State#http3_machine{streams=OtherStreams}} end. +-spec stop_sending_remote(cow_http3:stream_id(), State) + -> {ok | closed | wt_session_closed, State} + | {{connection_error, h3_closed_critical_stream, atom()}, State} + when State::http3_machine(). + +%% The STOP_SENDING frame can be received by any stream. +%% The user is expected to send a RESET_STREAM frame back +%% immediately so this function closes the relevant stream +%% direction. + +stop_sending_remote(StreamID, State=#http3_machine{streams=Streams0}) -> + {Stream, OtherStreams} = maps:take(StreamID, Streams0), + case Stream of + #unidi_stream{type=control} -> + {{connection_error, h3_closed_critical_stream, + 'A control stream was closed. (RFC9114 6.2.1)'}, + State#http3_machine{streams=OtherStreams}}; + #unidi_stream{type=decoder} -> + {{connection_error, h3_closed_critical_stream, + 'A decoder stream was closed. (RFC9204 4.2)'}, + State#http3_machine{streams=OtherStreams}}; + #unidi_stream{type=encoder} -> + {{connection_error, h3_closed_critical_stream, + 'An encoder stream was closed. (RFC9204 4.2)'}, + State#http3_machine{streams=OtherStreams}}; + #bidi_stream{remote=nofin} -> + {ok, stream_store(Stream#bidi_stream{local=fin}, State)}; + #bidi_stream{remote=fin} -> + {closed, State#http3_machine{streams=OtherStreams}}; + #wt_session{} -> + %% We expect the user to call close_webtransport_session/2 separately. + {wt_session_closed, State#http3_machine{streams=OtherStreams}}; + #wt_stream{remote=nofin} -> + {ok, stream_store(Stream#wt_stream{local=fin}, State)}; + #wt_stream{remote=fin} -> + {closed, State#http3_machine{streams=OtherStreams}} + end. + +%% Incoming frames and data. + -spec unidi_data(binary(), cow_http:fin(), cow_http3:stream_id(), State) -> {ok, instructions(), State} | {error, {connection_error, cow_qpack:error(), atom()}, State} @@ -397,7 +552,7 @@ data_frame(Frame={data, Data}, IsFin, StreamID, State) -> 'DATA frame received after trailer HEADERS frame. (RFC9114 4.1)'}, State}; #unidi_stream{type=control} -> - control_frame(Frame, State) + control_frame(Frame, IsFin, State) end. data_frame(Frame, IsFin, Stream0=#bidi_stream{remote_read_size=StreamRead}, State0, DataLen) -> @@ -448,7 +603,7 @@ headers_frame(Frame, IsFin, StreamID, State=#http3_machine{mode=Mode}) -> 'HEADERS frame received after trailer HEADERS frame. (RFC9114 4.1)'}, State}; #unidi_stream{type=control} -> - control_frame(Frame, State) + control_frame(Frame, IsFin, State) end. headers_decode({headers, EncodedFieldSection}, IsFin, Stream=#bidi_stream{id=StreamID}, @@ -583,49 +738,53 @@ format_error(uppercase_header_name) -> format_error(Reason) -> cow_http:format_semantic_error(Reason). -cancel_push_frame(Frame, _IsFin, StreamID, State) -> +cancel_push_frame(Frame, IsFin, StreamID, State) -> case stream_get(StreamID, State) of #unidi_stream{type=control} -> - control_frame(Frame, State) + control_frame(Frame, IsFin, State) end. -settings_frame(Frame, _IsFin, StreamID, State) -> +settings_frame(Frame, IsFin, StreamID, State) -> case stream_get(StreamID, State) of #unidi_stream{type=control} -> - control_frame(Frame, State); + control_frame(Frame, IsFin, State); #bidi_stream{} -> {error, {connection_error, h3_frame_unexpected, 'The SETTINGS frame is not allowed on a bidi stream. (RFC9114 7.2.4)'}, State} end. -push_promise_frame(Frame, _IsFin, StreamID, State) -> +push_promise_frame(Frame, IsFin, StreamID, State) -> case stream_get(StreamID, State) of #unidi_stream{type=control} -> - control_frame(Frame, State) + control_frame(Frame, IsFin, State) end. -goaway_frame(Frame, _IsFin, StreamID, State) -> +goaway_frame(Frame, IsFin, StreamID, State) -> case stream_get(StreamID, State) of #unidi_stream{type=control} -> - control_frame(Frame, State); + control_frame(Frame, IsFin, State); #bidi_stream{} -> {error, {connection_error, h3_frame_unexpected, 'The GOAWAY frame is not allowed on a bidi stream. (RFC9114 7.2.6)'}, State} end. -max_push_id_frame(Frame, _IsFin, StreamID, State) -> +max_push_id_frame(Frame, IsFin, StreamID, State) -> case stream_get(StreamID, State) of #unidi_stream{type=control} -> - control_frame(Frame, State); + control_frame(Frame, IsFin, State); #bidi_stream{} -> {error, {connection_error, h3_frame_unexpected, 'The MAX_PUSH_ID frame is not allowed on a bidi stream. (RFC9114 7.2.7)'}, State} end. -control_frame({settings, Settings}, State=#http3_machine{ +control_frame(_, fin, State) -> + {error, {connection_error, h3_closed_critical_stream, + 'A control stream was closed. (RFC9114 6.2.1)'}, + State}; +control_frame({settings, Settings}, nofin, State=#http3_machine{ peer_control_state=no_settings, encode_state=EncState0}) -> %% @todo max_field_section_size %% Send the QPACK values to the encoder. @@ -633,18 +792,18 @@ control_frame({settings, Settings}, State=#http3_machine{ MaxBlockedStreams = maps:get(qpack_blocked_streams, Settings, 0), EncState = cow_qpack:encoder_set_settings(MaxTableCapacity, MaxBlockedStreams, EncState0), {ok, State#http3_machine{peer_control_state=ready, encode_state=EncState}}; -control_frame({settings, _}, State) -> +control_frame({settings, _}, nofin, State) -> {error, {connection_error, h3_frame_unexpected, 'The SETTINGS frame cannot be sent more than once. (RFC9114 7.2.4)'}, State}; -control_frame(_Frame, State=#http3_machine{peer_control_state=no_settings}) -> +control_frame(_Frame, nofin, State=#http3_machine{peer_control_state=no_settings}) -> {error, {connection_error, h3_missing_settings, 'The first frame on the control stream must be a SETTINGS frame. (RFC9114 6.2.1)'}, State}; -control_frame(Frame = {goaway, _}, State) -> +control_frame(Frame = {goaway, _}, nofin, State) -> {ok, Frame, State}; %% @todo Implement server push. -control_frame({max_push_id, PushID}, State=#http3_machine{max_push_id=MaxPushID}) -> +control_frame({max_push_id, PushID}, nofin, State=#http3_machine{max_push_id=MaxPushID}) -> if PushID >= MaxPushID -> {ok, State#http3_machine{max_push_id=PushID}}; @@ -653,28 +812,30 @@ control_frame({max_push_id, PushID}, State=#http3_machine{max_push_id=MaxPushID} 'MAX_PUSH_ID must not be lower than previously received. (RFC9114 7.2.7)'}, State} end; -control_frame(ignored_frame, State) -> +control_frame(ignored_frame, nofin, State) -> {ok, State}; -control_frame(_Frame, State) -> +control_frame(_Frame, nofin, State) -> {error, {connection_error, h3_frame_unexpected, 'DATA and HEADERS frames are not allowed on the control stream. (RFC9114 7.2.1, RFC9114 7.2.2)'}, State}. %% Ignored frames. --spec ignored_frame(cow_http3:stream_id(), State) +-spec ignored_frame(cow_http3:stream_id(), cow_http:fin(), State) -> {ok, State} | {error, {connection_error, cow_http3:error(), atom()}, State} when State::http3_machine(). -ignored_frame(StreamID, State) -> +ignored_frame(StreamID, IsFin, State) -> case stream_get(StreamID, State) of #unidi_stream{type=control} -> - control_frame(ignored_frame, State); + control_frame(ignored_frame, IsFin, State); _ -> {ok, State} end. +%% Outgoing headers/trailers. +%% %% Functions for sending a message header or body. Note that %% this module does not send data directly, instead it returns %% a value that can then be used to send the frames. @@ -738,20 +899,7 @@ prepare_trailers(StreamID, State=#http3_machine{encode_state=EncodeState0}, Trai {no_trailers, stream_store(Stream#bidi_stream{local=fin}, State)} end. -%% Public interface to reset streams. - --spec reset_stream(cow_http3:stream_id(), State) - -> {ok, State} | {error, not_found} when State::http3_machine(). - -reset_stream(StreamID, State=#http3_machine{streams=Streams0}) -> - case maps:take(StreamID, Streams0) of - {_, Streams} -> - {ok, State#http3_machine{streams=Streams}}; - error -> - {error, not_found} - end. - -%% Retrieve the local state for a bidi stream. +%% Getters. -spec get_bidi_stream_local_state(cow_http3:stream_id(), http3_machine()) -> {ok, idle | cow_http:fin()} | {error, not_found}. @@ -766,8 +914,6 @@ get_bidi_stream_local_state(StreamID, State) -> {error, not_found} end. -%% Retrieve the remote state for a bidi stream. - -spec get_bidi_stream_remote_state(cow_http3:stream_id(), http3_machine()) -> {ok, idle | cow_http:fin()} | {error, not_found}.