diff --git a/CHANGELOG.md b/CHANGELOG.md index 3db7d0e..758c9db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 now removes the keys that bind to the evicted sequence. A key that a later insertion of the same header or the same name rebound to a newer sequence survives the eviction of the older entry. +- `nhttp_h2:send_data/4` held back an empty payload with `fin` at a send + window of zero. The stream never closed, and the caller waited for + credit that no frame consumes. The empty END_STREAM frame is now sent at + any window value (RFC 9113 Section 6.9.1) +- A SETTINGS_INITIAL_WINDOW_SIZE change that took a stream send window + past 2^31-1 was applied without a bound test. The change is now a + connection error of type FLOW_CONTROL_ERROR (RFC 9113 Section 6.9.2) +- A SETTINGS frame with a positive SETTINGS_INITIAL_WINDOW_SIZE delta + reported one synthetic `window_update` event per stream. The codec + adjusts every stream window itself and reports `{settings, _}` only. A + `window_update` event now mirrors one WINDOW_UPDATE frame from the peer +- `nhttp_h2:send_window_update/3` tested the increment against 2^31-1 and + not the sum. An increment that takes the local receive window past the + bound is now refused as `{recv_window_overflow, Target}` and nothing is + sent (RFC 9113 Section 6.9.1) +- `nhttp_h2:send_window_update/3` answered a stream error for a stream + that the codec no longer tracks. That stream closed, so the call now + answers `{ok, Conn, []}` and sends nothing +- A client ignored a WINDOW_UPDATE frame on an idle stream. It is now a + connection error of type PROTOCOL_ERROR, as it already was for a server + (RFC 9113 Section 5.1) +- `nhttp_h2:open_stream/1` advanced `next_stream_id` past 2^31-1, and the + frame encoder then truncated the identifier to 31 bits. The call now + answers `{error, stream_ids_exhausted}` and the caller must open a new + connection (RFC 9113 Section 5.1.1) + +### Added + +- `nhttp_h2:connection_send_window/1` reads the connection send window +- `nhttp_h2:stream_send_window/2` reads the send window of one stream +- `nhttp_h2:peer_settings/1` reads the settings the peer sent, merged over + the defaults + +### Changed + +- `t:nhttp_h2:send_result/0` no longer carries the `{ok, conn()}` arm, + which no function returned. `nhttp_h2:send_headers/4` carries its own + spec of `{ok, conn(), iodata()} | {error, send_error()}` - Support partial UTF for WS compliance ### Performance diff --git a/src/nhttp_h2.erl b/src/nhttp_h2.erl index d528660..6b29ce7 100644 --- a/src/nhttp_h2.erl +++ b/src/nhttp_h2.erl @@ -95,6 +95,15 @@ Flow-control and END_STREAM ride on the DATA frame. stream_stats/1 ]). +%%%----------------------------------------------------------------------------- +%% STATE ACCESSORS +%%%----------------------------------------------------------------------------- +-export([ + connection_send_window/1, + peer_settings/1, + stream_send_window/2 +]). + %%%----------------------------------------------------------------------------- %% TYPE EXPORTS %%%----------------------------------------------------------------------------- @@ -165,6 +174,15 @@ CONTINUATION frames that carry the larger field section. -type role() :: nhttp_lib:role(). +-doc """ +Events that `recv/2` reports. + +A `window_update` event mirrors one WINDOW_UPDATE frame from the peer, with +stream id 0 for the connection window. A SETTINGS frame that changes +`initial_window_size` adjusts every stream send window inside the codec and +reports `{settings, _}` only. `connection_send_window/1` and +`stream_send_window/2` read the adjusted windows. +""". -type event() :: nhttp_lib:event_common() | {stream_closed, nhttp_lib:stream_id(), error_code()} @@ -185,15 +203,23 @@ CONTINUATION frames that carry the larger field section. | {stream_error, conn(), nhttp_lib:stream_id(), error_code(), binary()} | {error, nhttp_h2_frame:decode_error()}. +-doc """ +Reasons a send function refuses a call. + +`{recv_window_overflow, Target}` reports that the increment given to +`send_window_update/3` takes the local receive window of `Target` past +2^31-1 (RFC 9113 Section 6.9.1). The codec sent nothing, the peer saw +nothing, and the connection stays open. +""". -type send_error() :: connection_closing | {unknown_stream, nhttp_lib:stream_id()} | {stream_closed, nhttp_lib:stream_id()} - | {stream_error, nhttp_lib:stream_id(), error_code(), binary()}. + | {stream_error, nhttp_lib:stream_id(), error_code(), binary()} + | {recv_window_overflow, nhttp_lib:stream_id() | connection}. -type send_result() :: - {ok, conn()} - | {ok, conn(), iodata()} + {ok, conn(), iodata()} | {partial, conn(), iodata(), binary(), fin(), Window :: integer()} | {error, send_error()}. @@ -202,6 +228,7 @@ CONTINUATION frames that carry the larger field section. %%%----------------------------------------------------------------------------- -define(H2_DEFAULT_INITIAL_WINDOW_SIZE, 65535). -define(H2_MAX_WINDOW_SIZE, 16#7fffffff). +-define(H2_MAX_STREAM_ID, 16#7fffffff). -define(H2_PREFACE, <<"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n">>). -define(H2_PREFACE_LEN, 24). @@ -329,7 +356,22 @@ recv(#h2_conn{buffer = Buffer} = Conn, Data) -> %%%----------------------------------------------------------------------------- %% SENDING %%%----------------------------------------------------------------------------- --doc "Send DATA frame(s).". +-doc """ +Send a DATA frame. + +The payload is bounded by the connection send window, the stream send +window and the peer `max_frame_size`. A payload that fits comes back as +`{ok, Conn, Frame}`. A payload that does not fit comes back as +`{partial, Conn, Frame, Rest, EndStream, Window}`, where `Frame` carries the +prefix that fit and `Rest` is the remainder the caller must offer again +after WINDOW_UPDATE. + +An empty payload with `fin` is sent at any window value, because a frame +without payload consumes no flow-control credit (RFC 9113 Section 6.9.1). +An empty payload with `nofin` at a window of zero or below is held back as +`{partial, ...}`. The frame is legal, but it carries nothing, so the codec +declines to spend a frame on it. +""". -spec send_data(conn(), nhttp_lib:stream_id(), iodata(), fin()) -> send_result(). send_data(#h2_conn{} = Conn, StreamId, Data, EndStream) -> case validate_send_data(Conn, StreamId) of @@ -351,7 +393,8 @@ send_goaway(#h2_conn{last_peer_stream_id = LastStreamId} = Conn, ErrorCode, Debu {ok, NewConn, Frame}. -doc "Send HEADERS frame for a new request/response or trailers.". --spec send_headers(conn(), nhttp_lib:stream_id(), nhttp_lib:headers(), fin()) -> send_result(). +-spec send_headers(conn(), nhttp_lib:stream_id(), nhttp_lib:headers(), fin()) -> + {ok, conn(), iodata()} | {error, send_error()}. send_headers(#h2_conn{} = Conn, StreamId, Headers, EndStream) -> case validate_send_headers(Conn, StreamId) of ok -> @@ -377,26 +420,45 @@ send_rst_stream(#h2_conn{} = Conn, StreamId, ErrorCode) -> NewConn = close_stream(Conn, StreamId), {ok, NewConn, Frame}. --doc "Send WINDOW_UPDATE for connection or stream.". +-doc """ +Send WINDOW_UPDATE for the connection or for a stream. + +The increment is added to the local receive window. An increment that takes +that window past 2^31-1 is refused as `{recv_window_overflow, Target}` and +nothing is sent (RFC 9113 Section 6.9.1). A stream that the codec no longer +tracks answers `{ok, Conn, []}`: the stream closed, no credit is owed, and +the peer ignores a frame on a closed stream (RFC 9113 Section 5.1). +""". -spec send_window_update(conn(), nhttp_lib:stream_id() | connection, pos_integer()) -> send_result(). -send_window_update(#h2_conn{} = Conn, connection, Increment) when +send_window_update(#h2_conn{recv_window = RecvWindow} = Conn, connection, Increment) when Increment > 0, Increment =< ?H2_MAX_WINDOW_SIZE -> - {ok, Frame} = nhttp_h2_frame:window_update(Increment), - NewConn = Conn#h2_conn{recv_window = Conn#h2_conn.recv_window + Increment}, - {ok, NewConn, Frame}; + NewWindow = RecvWindow + Increment, + case NewWindow > ?H2_MAX_WINDOW_SIZE of + true -> + {error, {recv_window_overflow, connection}}; + false -> + {ok, Frame} = nhttp_h2_frame:window_update(Increment), + {ok, Conn#h2_conn{recv_window = NewWindow}, Frame} + end; send_window_update(#h2_conn{streams = Streams} = Conn, StreamId, Increment) when Increment > 0, Increment =< ?H2_MAX_WINDOW_SIZE -> case maps:get(StreamId, Streams, undefined) of undefined -> - {error, {stream_error, StreamId, protocol_error, <<"Unknown stream">>}}; + {ok, Conn, []}; #h2_stream{recv_window = RecvWindow} = Stream -> - {ok, Frame} = nhttp_h2_frame:window_update(StreamId, Increment), - NewStream = Stream#h2_stream{recv_window = RecvWindow + Increment}, - NewConn = Conn#h2_conn{streams = Streams#{StreamId => NewStream}}, - {ok, NewConn, Frame} + NewWindow = RecvWindow + Increment, + case NewWindow > ?H2_MAX_WINDOW_SIZE of + true -> + {error, {recv_window_overflow, StreamId}}; + false -> + {ok, Frame} = nhttp_h2_frame:window_update(StreamId, Increment), + NewStream = Stream#h2_stream{recv_window = NewWindow}, + NewConn = Conn#h2_conn{streams = Streams#{StreamId => NewStream}}, + {ok, NewConn, Frame} + end end. %%%----------------------------------------------------------------------------- @@ -411,13 +473,23 @@ check_peer_max_streams(ActiveCount, PeerSettings) -> _ -> ok end. --doc "Open a new stream and return its ID.". +-doc """ +Open a new stream and return its ID. + +Stream identifiers grow by two per stream and end at 2^31-1. When the next +identifier is past that bound, the call answers `{error, stream_ids_exhausted}` +and leaves the connection unchanged. The caller must open a new connection +for further requests (RFC 9113 Section 5.1.1). +""". -spec open_stream(conn()) -> - {ok, nhttp_lib:stream_id(), conn()} | {error, connection_closing | max_streams_reached}. + {ok, nhttp_lib:stream_id(), conn()} + | {error, connection_closing | max_streams_reached | stream_ids_exhausted}. open_stream(#h2_conn{goaway_sent = true}) -> {error, connection_closing}; open_stream(#h2_conn{goaway_received = true}) -> {error, connection_closing}; +open_stream(#h2_conn{next_stream_id = StreamId}) when StreamId > ?H2_MAX_STREAM_ID -> + {error, stream_ids_exhausted}; open_stream( #h2_conn{ next_stream_id = StreamId, @@ -478,6 +550,51 @@ stream_stats(#h2_conn{ }) -> #{active => Active, peer_opened => Opened, peer_reset => Reset}. +%%%----------------------------------------------------------------------------- +%% STATE ACCESSORS +%%%----------------------------------------------------------------------------- +-doc """ +Return the connection send window in octets. + +This is the credit the peer granted for DATA payloads on every stream of the +connection, less what `send_data/4` spent (RFC 9113 Section 6.9.1). The +value is negative after a SETTINGS_INITIAL_WINDOW_SIZE shrink that overtakes +the credit already spent, and `send_data/4` sends nothing until WINDOW_UPDATE +takes it positive again. +""". +-spec connection_send_window(conn()) -> integer(). +connection_send_window(#h2_conn{send_window = Window}) -> + Window. + +-doc """ +Return the settings the peer sent, merged over the defaults. + +The map carries the last value of every SETTINGS parameter the peer sent, +and the default of RFC 9113 Section 6.5.2 for the rest. `initial_window_size` +is the send window that a new stream starts with, and `max_frame_size` bounds +the payload of one DATA frame. +""". +-spec peer_settings(conn()) -> settings(). +peer_settings(#h2_conn{peer_settings = Settings}) -> + Settings. + +-doc """ +Return the send window of one stream in octets. + +The value is the credit the peer granted for DATA payloads on that stream, +less what `send_data/4` spent (RFC 9113 Section 6.9.1). A stream that +`open_stream/1` created and `send_headers/4` has not yet moved out of `idle` +carries a window too, and a SETTINGS_INITIAL_WINDOW_SIZE change reaches it. +A stream the codec does not track answers `{error, {unknown_stream, Id}}`. +""". +-spec stream_send_window(conn(), nhttp_lib:stream_id()) -> + {ok, integer()} | {error, {unknown_stream, nhttp_lib:stream_id()}}. +stream_send_window(#h2_conn{streams = Streams}, StreamId) -> + case Streams of + #{StreamId := #h2_stream{send_window = Window}} -> {ok, Window}; + #{} -> {error, {unknown_stream, StreamId}} + end. + %%%----------------------------------------------------------------------------- %% INTERNAL FUNCTIONS %%%----------------------------------------------------------------------------- @@ -704,34 +821,26 @@ derived_continuation_bound(Settings) -> end. -spec do_send_data(conn(), nhttp_lib:stream_id(), iodata(), fin()) -> send_result(). +do_send_data(#h2_conn{streams = Streams} = Conn, StreamId, Data, EndStream) -> + Stream = maps:get(StreamId, Streams), + do_send_data(Conn, Stream, Data, iolist_size(Data), EndStream). + +-spec do_send_data(conn(), #h2_stream{}, iodata(), non_neg_integer(), fin()) -> send_result(). +do_send_data(Conn, Stream, Data, 0, fin) -> + emit_data(Conn, Stream, Data, 0, fin); do_send_data( #h2_conn{streams = Streams, send_window = ConnWindow, peer_settings = PeerSettings} = Conn, - StreamId, + #h2_stream{id = StreamId, send_window = StreamWindow} = Stream, Data, + DataSize, EndStream ) -> - #h2_stream{send_window = StreamWindow} = Stream = maps:get(StreamId, Streams), - DataSize = iolist_size(Data), EffectiveWindow = min(ConnWindow, StreamWindow), MaxFrameSize = maps:get(max_frame_size, PeerSettings, ?H2_DEFAULT_MAX_FRAME_SIZE), MaxSend = min(EffectiveWindow, MaxFrameSize), case DataSize =< MaxSend of true when EffectiveWindow > 0 -> - {ok, Frame} = nhttp_h2_frame:data(StreamId, EndStream, Data), - OldState = Stream#h2_stream.state, - NewState = transition_on_send_end_stream(OldState, EndStream), - NewStream = Stream#h2_stream{ - state = NewState, - send_window = StreamWindow - DataSize - }, - ActiveCount = Conn#h2_conn.active_stream_count, - NewActiveCount = update_active_count_on_transition(OldState, NewState, ActiveCount), - NewConn = Conn#h2_conn{ - streams = store_or_remove_stream(Streams, StreamId, NewStream), - send_window = ConnWindow - DataSize, - active_stream_count = NewActiveCount - }, - {ok, NewConn, Frame}; + emit_data(Conn, Stream, Data, DataSize, EndStream); true when EffectiveWindow =< 0 -> {partial, Conn, [], <<>>, EndStream, EffectiveWindow}; false when MaxSend > 0 -> @@ -750,7 +859,32 @@ do_send_data( {partial, Conn, [], iolist_to_binary(Data), EndStream, EffectiveWindow} end. --spec do_send_headers(conn(), nhttp_lib:stream_id(), nhttp_lib:headers(), fin()) -> send_result(). +-spec emit_data(conn(), #h2_stream{}, iodata(), non_neg_integer(), fin()) -> + {ok, conn(), iodata()}. +emit_data( + #h2_conn{streams = Streams, send_window = ConnWindow, active_stream_count = ActiveCount} = + Conn, + #h2_stream{id = StreamId, state = OldState, send_window = StreamWindow} = Stream, + Data, + DataSize, + EndStream +) -> + {ok, Frame} = nhttp_h2_frame:data(StreamId, EndStream, Data), + NewState = transition_on_send_end_stream(OldState, EndStream), + NewStream = Stream#h2_stream{ + state = NewState, + send_window = StreamWindow - DataSize + }, + NewActiveCount = update_active_count_on_transition(OldState, NewState, ActiveCount), + NewConn = Conn#h2_conn{ + streams = store_or_remove_stream(Streams, StreamId, NewStream), + send_window = ConnWindow - DataSize, + active_stream_count = NewActiveCount + }, + {ok, NewConn, Frame}. + +-spec do_send_headers(conn(), nhttp_lib:stream_id(), nhttp_lib:headers(), fin()) -> + {ok, conn(), iodata()}. do_send_headers( #h2_conn{hpack_enc = HpackEnc, streams = Streams, peer_settings = PeerSettings} = Conn, StreamId, @@ -976,6 +1110,13 @@ process_frame( _ -> {ok, Conn, [], []} end; +process_frame( + #h2_conn{role = client, next_stream_id = NextId}, + {window_update, StreamId, _Increment} +) when StreamId band 1 =:= 0; StreamId >= NextId -> + {error, + {connection_error, protocol_error, + <<"WINDOW_UPDATE on idle stream (RFC 9113 Section 5.1)">>}}; process_frame(#h2_conn{} = Conn, {settings, Settings}) -> process_settings(Conn, Settings); process_frame(#h2_conn{settings_acked = false} = Conn, settings_ack) -> @@ -1120,40 +1261,52 @@ process_settings(#h2_conn{peer_settings = OldSettings, streams = Streams} = Conn ), UpdatedEnc end, - {NewStreams, WindowEvents} = + Delta = case maps:get(initial_window_size, NewSettings, undefined) of undefined -> - {Streams, []}; + 0; NewInitialWindow -> - OldInitialWindow = maps:get( - initial_window_size, OldSettings, ?H2_DEFAULT_INITIAL_WINDOW_SIZE - ), - Delta = NewInitialWindow - OldInitialWindow, - UpdatedStreams = maps:map( - fun(_Id, Stream) -> - Stream#h2_stream{ - send_window = Stream#h2_stream.send_window + Delta - } - end, - Streams - ), - Events = - case Delta > 0 of - true -> - [{window_update, Id, Delta} || Id <- maps:keys(Streams)]; - false -> - [] - end, - {UpdatedStreams, Events} + NewInitialWindow - + maps:get(initial_window_size, OldSettings, ?H2_DEFAULT_INITIAL_WINDOW_SIZE) end, - NewConn = Conn#h2_conn{ - peer_settings = MergedSettings, - streams = NewStreams, - hpack_enc = NewHpackEnc, - state = open - }, - {ok, AckFrame} = nhttp_h2_frame:settings_ack(), - {ok, NewConn, [{settings, NewSettings}] ++ WindowEvents, AckFrame}. + maybe + {ok, NewStreams} ?= adjust_stream_windows(Streams, Delta), + NewConn = Conn#h2_conn{ + peer_settings = MergedSettings, + streams = NewStreams, + hpack_enc = NewHpackEnc, + state = open + }, + {ok, AckFrame} = nhttp_h2_frame:settings_ack(), + {ok, NewConn, [{settings, NewSettings}], AckFrame} + end. + +-spec adjust_stream_windows(#{nhttp_lib:stream_id() => #h2_stream{}}, integer()) -> + {ok, #{nhttp_lib:stream_id() => #h2_stream{}}} | {error, nhttp_h2_frame:decode_error()}. +adjust_stream_windows(Streams, 0) -> + {ok, Streams}; +adjust_stream_windows(Streams, Delta) -> + adjust_stream_windows(maps:next(maps:iterator(Streams)), Delta, Streams). + +-spec adjust_stream_windows( + {nhttp_lib:stream_id(), #h2_stream{}, maps:iterator(nhttp_lib:stream_id(), #h2_stream{})} + | none, + integer(), + #{nhttp_lib:stream_id() => #h2_stream{}} +) -> {ok, #{nhttp_lib:stream_id() => #h2_stream{}}} | {error, nhttp_h2_frame:decode_error()}. +adjust_stream_windows(none, _Delta, Streams) -> + {ok, Streams}; +adjust_stream_windows({StreamId, #h2_stream{send_window = Window} = Stream, Iter}, Delta, Streams) -> + NewWindow = Window + Delta, + case NewWindow > ?H2_MAX_WINDOW_SIZE of + true -> + {error, + {connection_error, flow_control_error, + <<"Stream window overflow on SETTINGS_INITIAL_WINDOW_SIZE (RFC 9113 Section 6.9.2)">>}}; + false -> + NewStream = Stream#h2_stream{send_window = NewWindow}, + adjust_stream_windows(maps:next(Iter), Delta, Streams#{StreamId := NewStream}) + end. -spec rapid_reset_error() -> {error, nhttp_h2_frame:decode_error()}. rapid_reset_error() -> diff --git a/test/compliance/nhttp_h2_rfc9113_SUITE.erl b/test/compliance/nhttp_h2_rfc9113_SUITE.erl index 1277a0d..2dfa69b 100644 --- a/test/compliance/nhttp_h2_rfc9113_SUITE.erl +++ b/test/compliance/nhttp_h2_rfc9113_SUITE.erl @@ -55,11 +55,18 @@ groups() -> server_rejects_even_client_stream_id, server_rejects_non_increasing_stream_id, client_rejects_odd_server_stream_id, - max_concurrent_streams_triggers_refused_stream + max_concurrent_streams_triggers_refused_stream, + client_rejects_window_update_on_idle_stream, + client_refuses_stream_id_above_maximum ]}, {section_6_settings_and_flow_control, [parallel], [ settings_ack_sent_in_response, settings_initial_window_size_updates_streams, + settings_initial_window_size_reaches_idle_stream, + settings_initial_window_size_shrink_retains_negative_window, + settings_initial_window_size_overflow_is_connection_error, + settings_initial_window_size_emits_no_window_update_event, + zero_length_end_stream_data_is_sent_at_zero_window, settings_header_table_size_propagates_to_hpack, ping_ack_echoes_payload, connection_window_overflow_is_connection_error, @@ -229,6 +236,36 @@ window_update_on_idle_stream_is_connection_error(_Config) -> nhttp_h2:recv(Conn0, iolist_to_binary(Wu)), ok. +client_rejects_window_update_on_idle_stream(_Config) -> + Conn0 = nhttp_h2:new(client), + {ok, WuEven} = nhttp_h2_frame:window_update(2, 100), + {error, {connection_error, protocol_error, _}} = + nhttp_h2:recv(Conn0, iolist_to_binary(WuEven)), + {ok, WuUnopened} = nhttp_h2_frame:window_update(1, 100), + {error, {connection_error, protocol_error, _}} = + nhttp_h2:recv(Conn0, iolist_to_binary(WuUnopened)), + {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), + {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, minimal_request_headers(), fin), + RespBlock = encode_headers([{<<":status">>, <<"204">>}]), + {ok, Resp} = nhttp_h2_frame:headers(StreamId, fin, fin, RespBlock), + {ok, [{response, StreamId, _, fin}], Conn3} = nhttp_h2:recv(Conn2, iolist_to_binary(Resp)), + {ok, WuClosed} = nhttp_h2_frame:window_update(StreamId, 100), + {ok, [], _Conn4} = nhttp_h2:recv(Conn3, iolist_to_binary(WuClosed)), + {ok, WuNext} = nhttp_h2_frame:window_update(StreamId + 2, 100), + {error, {connection_error, protocol_error, _}} = + nhttp_h2:recv(Conn3, iolist_to_binary(WuNext)), + ok. + +client_refuses_stream_id_above_maximum(_Config) -> + Conn0 = nhttp_h2:new(client), + Index = maps:get(next_stream_id, record_positions(nhttp_h2, h2_conn)), + LastId = 16#7fffffff, + Conn1 = setelement(Index, Conn0, LastId), + {ok, LastId, Conn2} = nhttp_h2:open_stream(Conn1), + ?assertEqual(LastId + 2, element(Index, Conn2)), + {error, stream_ids_exhausted} = nhttp_h2:open_stream(Conn2), + ok. + server_rejects_even_client_stream_id(_Config) -> Conn0 = server_with_preface(), HeaderBlock = encode_headers(minimal_request_headers()), @@ -285,12 +322,103 @@ settings_initial_window_size_updates_streams(_Config) -> HeaderBlock = encode_headers(minimal_request_headers()), {ok, F1} = nhttp_h2_frame:headers(1, nofin, fin, HeaderBlock), {ok, _, Conn1} = nhttp_h2:recv(Conn0, iolist_to_binary(F1)), + ?assertEqual({ok, 65535}, nhttp_h2:stream_send_window(Conn1, 1)), {ok, S} = nhttp_h2_frame:settings(#{initial_window_size => 131070}), - Result = nhttp_h2:recv(Conn1, iolist_to_binary(S)), - {ok, Events, _Conn2, _OutData} = Result, - ?assert(lists:any(fun({window_update, 1, 65535}) -> true; (_) -> false end, Events)), + {ok, [{settings, #{initial_window_size := 131070}}], Conn2, _OutData} = + nhttp_h2:recv(Conn1, iolist_to_binary(S)), + ?assertEqual({ok, 131070}, nhttp_h2:stream_send_window(Conn2, 1)), ok. +settings_initial_window_size_reaches_idle_stream(_Config) -> + Conn0 = nhttp_h2:new(client), + {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), + ?assertEqual({ok, 65535}, nhttp_h2:stream_send_window(Conn1, StreamId)), + {ok, S} = nhttp_h2_frame:settings(#{initial_window_size => 100000, max_frame_size => 131072}), + {ok, [{settings, _}], Conn2, _} = nhttp_h2:recv(Conn1, iolist_to_binary(S)), + ?assertEqual({ok, 100000}, nhttp_h2:stream_send_window(Conn2, StreamId)), + {ok, Wu} = nhttp_h2_frame:window_update(100000), + {ok, [{window_update, 0, 100000}], Conn3} = nhttp_h2:recv(Conn2, iolist_to_binary(Wu)), + {ok, Conn4, _} = nhttp_h2:send_headers(Conn3, StreamId, minimal_request_headers(), nofin), + Body = binary:copy(<<$a>>, 100000), + {ok, Conn5, Frame} = nhttp_h2:send_data(Conn4, StreamId, Body, fin), + ?assertEqual(100000 + 9, iolist_size(Frame)), + ?assertEqual({ok, 0}, nhttp_h2:stream_send_window(Conn5, StreamId)), + ?assertEqual(65535, nhttp_h2:connection_send_window(Conn5)), + ok. + +settings_initial_window_size_shrink_retains_negative_window(_Config) -> + Conn0 = nhttp_h2:new(client), + {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), + {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, minimal_request_headers(), nofin), + {ok, Conn3, _} = nhttp_h2:send_data(Conn2, StreamId, binary:copy(<<0>>, 16384), nofin), + ?assertEqual({ok, 65535 - 16384}, nhttp_h2:stream_send_window(Conn3, StreamId)), + {ok, S} = nhttp_h2_frame:settings(#{initial_window_size => 1}), + {ok, [{settings, _}], Conn4, _} = nhttp_h2:recv(Conn3, iolist_to_binary(S)), + ?assertEqual({ok, 1 - 16384}, nhttp_h2:stream_send_window(Conn4, StreamId)), + {partial, Conn4, [], <<"x">>, nofin, -16383} = nhttp_h2:send_data(Conn4, StreamId, <<"x">>, nofin), + {ok, Wu} = nhttp_h2_frame:window_update(StreamId, 16384), + {ok, [{window_update, StreamId, 16384}], Conn5} = nhttp_h2:recv(Conn4, iolist_to_binary(Wu)), + ?assertEqual({ok, 1}, nhttp_h2:stream_send_window(Conn5, StreamId)), + {ok, _Conn6, Frame} = nhttp_h2:send_data(Conn5, StreamId, <<"x">>, nofin), + {ok, {data, StreamId, nofin, <<"x">>}, _} = nhttp_h2_frame:decode(iolist_to_binary(Frame)), + ok. + +settings_initial_window_size_overflow_is_connection_error(_Config) -> + Conn0 = nhttp_h2:new(client), + {ok, S1} = nhttp_h2_frame:settings(#{initial_window_size => 1000}), + {ok, [{settings, _}], Conn1, _} = nhttp_h2:recv(Conn0, iolist_to_binary(S1)), + {ok, StreamId, Conn2} = nhttp_h2:open_stream(Conn1), + {ok, Conn3, _} = nhttp_h2:send_headers(Conn2, StreamId, minimal_request_headers(), nofin), + {ok, Wu} = nhttp_h2_frame:window_update(StreamId, 16#7fffffff - 1000), + {ok, [{window_update, StreamId, _}], Conn4} = nhttp_h2:recv(Conn3, iolist_to_binary(Wu)), + {ok, S2} = nhttp_h2_frame:settings(#{initial_window_size => 16#7fffffff}), + {error, {connection_error, flow_control_error, _}} = + nhttp_h2:recv(Conn4, iolist_to_binary(S2)), + ok. + +settings_initial_window_size_emits_no_window_update_event(_Config) -> + Conn0 = nhttp_h2:new(client), + {ok, S1} = nhttp_h2_frame:settings(#{max_concurrent_streams => 1000}), + {ok, [{settings, _}], Conn1, _} = nhttp_h2:recv(Conn0, iolist_to_binary(S1)), + Conn2 = open_streams_with_headers(Conn1, 1000), + ?assertEqual(1000, maps:get(active, nhttp_h2:stream_stats(Conn2))), + {ok, S2} = nhttp_h2_frame:settings(#{initial_window_size => 131070}), + {ok, Events, Conn3, _} = nhttp_h2:recv(Conn2, iolist_to_binary(S2)), + ?assertMatch([{settings, #{initial_window_size := 131070}}], Events), + ?assertEqual({ok, 131070}, nhttp_h2:stream_send_window(Conn3, 1)), + ?assertEqual({ok, 131070}, nhttp_h2:stream_send_window(Conn3, 1999)), + ok. + +zero_length_end_stream_data_is_sent_at_zero_window(_Config) -> + Conn0 = nhttp_h2:new(client), + {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), + {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, minimal_request_headers(), nofin), + Conn3 = send_chunks(Conn2, StreamId, [16384, 16384, 16384, 16383]), + {ok, Conn4, Frame} = nhttp_h2:send_data(Conn3, StreamId, <<>>, fin), + {ok, {data, StreamId, fin, <<>>}, _} = nhttp_h2_frame:decode(iolist_to_binary(Frame)), + ?assertEqual(0, nhttp_h2:connection_send_window(Conn3)), + ?assertEqual(0, nhttp_h2:connection_send_window(Conn4)), + ?assertEqual({ok, 0}, nhttp_h2:stream_send_window(Conn4, StreamId)), + {error, {stream_closed, StreamId}} = nhttp_h2:send_data(Conn4, StreamId, <<"x">>, nofin), + RespBlock = encode_headers([{<<":status">>, <<"204">>}]), + {ok, Resp} = nhttp_h2_frame:headers(StreamId, fin, fin, RespBlock), + {ok, [{response, StreamId, #{status := 204}, fin}], _Conn5} = + nhttp_h2:recv(Conn4, iolist_to_binary(Resp)), + ok. + +open_streams_with_headers(Conn, 0) -> + Conn; +open_streams_with_headers(Conn0, N) -> + {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), + {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, minimal_request_headers(), nofin), + open_streams_with_headers(Conn2, N - 1). + +send_chunks(Conn, _StreamId, []) -> + Conn; +send_chunks(Conn0, StreamId, [Size | Sizes]) -> + {ok, Conn1, _} = nhttp_h2:send_data(Conn0, StreamId, binary:copy(<<0>>, Size), nofin), + send_chunks(Conn1, StreamId, Sizes). + settings_header_table_size_propagates_to_hpack(_Config) -> Conn0 = server_with_preface(), {ok, S} = nhttp_h2_frame:settings(#{header_table_size => 0}), @@ -803,6 +931,18 @@ encode_headers(Headers) -> minimal_request_headers() -> [{<<":method">>, <<"GET">>}, {<<":scheme">>, <<"https">>}, {<<":path">>, <<"/">>}]. +record_positions(Module, Record) -> + Beam = filename:join([code:lib_dir(nhttp_lib), "ebin", atom_to_list(Module) ++ ".beam"]), + {ok, {Module, [{abstract_code, {raw_abstract_v1, Forms}}]}} = + beam_lib:chunks(Beam, [abstract_code]), + [Fields] = [Fs || {attribute, _, record, {R, Fs}} <- Forms, R =:= Record], + Names = [record_field_name(Field) || Field <- Fields], + maps:from_list(lists:zip(Names, lists:seq(2, length(Names) + 1))). + +record_field_name({typed_record_field, Field, _Type}) -> record_field_name(Field); +record_field_name({record_field, _, {atom, _, Name}}) -> Name; +record_field_name({record_field, _, {atom, _, Name}, _Default}) -> Name. + assert_request_rejected(Headers) -> Conn0 = server_with_preface(), HeaderBlock = encode_headers(Headers), diff --git a/test/nhttp_h2_SUITE.erl b/test/nhttp_h2_SUITE.erl index 918eff3..f20c6e0 100644 --- a/test/nhttp_h2_SUITE.erl +++ b/test/nhttp_h2_SUITE.erl @@ -45,14 +45,17 @@ Tests RFC 9113 compliance for: settings_exchange/1, settings_ack_sent/1, settings_updates_peer/1, - settings_initial_window_size_update/1 + settings_initial_window_size_update/1, + peer_settings_reports_peer_values/1 ]). -export([ connection_window_update/1, stream_window_update/1, window_overflow_error/1, - data_consumes_window/1 + data_consumes_window/1, + send_data_empty_nofin_at_zero_window_is_partial/1, + send_window_update_recv_window_overflow/1 ]). -export([ @@ -200,13 +203,16 @@ groups() -> settings_exchange, settings_ack_sent, settings_updates_peer, - settings_initial_window_size_update + settings_initial_window_size_update, + peer_settings_reports_peer_values ]}, {flow_control, [parallel], [ connection_window_update, stream_window_update, window_overflow_error, - data_consumes_window + data_consumes_window, + send_data_empty_nofin_at_zero_window_is_partial, + send_window_update_recv_window_overflow ]}, {header_processing, [parallel], [ headers_single_frame, @@ -467,6 +473,25 @@ settings_initial_window_size_update(_Config) -> ?assertMatch([{settings, #{initial_window_size := 131072}} | _], Events), ok. +peer_settings_reports_peer_values(_Config) -> + Conn0 = nhttp_h2:new(client), + Defaults = nhttp_h2:peer_settings(Conn0), + ?assertEqual(65535, maps:get(initial_window_size, Defaults)), + ?assertEqual(16384, maps:get(max_frame_size, Defaults)), + Settings = #{ + initial_window_size => 1048576, + max_frame_size => 32768, + max_concurrent_streams => 1000 + }, + {ok, Frame} = nhttp_h2_frame:settings(Settings), + {ok, [{settings, Settings}], Conn1, _Ack} = nhttp_h2:recv(Conn0, iolist_to_binary(Frame)), + Peer = nhttp_h2:peer_settings(Conn1), + ?assertEqual(1048576, maps:get(initial_window_size, Peer)), + ?assertEqual(32768, maps:get(max_frame_size, Peer)), + ?assertEqual(1000, maps:get(max_concurrent_streams, Peer)), + ?assertEqual(4096, maps:get(header_table_size, Peer)), + ok. + %%%----------------------------------------------------------------------------- %%% FLOW CONTROL TESTS %%%----------------------------------------------------------------------------- @@ -698,9 +723,42 @@ send_data_on_closed_stream(_Config) -> send_window_update_unknown_stream(_Config) -> Conn0 = nhttp_h2:new(client), - {error, {stream_error, 99, protocol_error, _}} = nhttp_h2:send_window_update(Conn0, 99, 1000), + {ok, Conn0, []} = nhttp_h2:send_window_update(Conn0, 99, 1000), + ok. + +send_data_empty_nofin_at_zero_window_is_partial(_Config) -> + Conn0 = nhttp_h2:new(client), + {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), + Headers = [{<<":method">>, <<"POST">>}, {<<":path">>, <<"/">>}], + {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, Headers, nofin), + Conn3 = send_until_stream_window_is_zero(Conn2, StreamId), + ?assertEqual({ok, 0}, nhttp_h2:stream_send_window(Conn3, StreamId)), + {partial, Conn3, [], <<>>, nofin, 0} = nhttp_h2:send_data(Conn3, StreamId, <<>>, nofin), ok. +send_window_update_recv_window_overflow(_Config) -> + Conn0 = nhttp_h2:new(client), + {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), + RoomToMax = 16#7fffffff - 65535, + {ok, Conn2, ConnFrame} = nhttp_h2:send_window_update(Conn1, connection, RoomToMax), + ?assert(iolist_size(ConnFrame) > 0), + {error, {recv_window_overflow, connection}} = + nhttp_h2:send_window_update(Conn2, connection, 1), + {ok, Conn3, StreamFrame} = nhttp_h2:send_window_update(Conn2, StreamId, RoomToMax), + ?assert(iolist_size(StreamFrame) > 0), + {error, {recv_window_overflow, StreamId}} = nhttp_h2:send_window_update(Conn3, StreamId, 1), + ok. + +send_until_stream_window_is_zero(Conn, StreamId) -> + case nhttp_h2:stream_send_window(Conn, StreamId) of + {ok, 0} -> + Conn; + {ok, Window} -> + Chunk = binary:copy(<<0>>, min(Window, 16384)), + {ok, NewConn, _} = nhttp_h2:send_data(Conn, StreamId, Chunk, nofin), + send_until_stream_window_is_zero(NewConn, StreamId) + end. + open_stream_after_goaway_sent(_Config) -> Conn0 = nhttp_h2:new(server), {ok, Conn1, _} = nhttp_h2:send_goaway(Conn0, no_error, <<>>), diff --git a/test/nhttp_props_SUITE.erl b/test/nhttp_props_SUITE.erl index 81f8b3f..a0de45f 100644 --- a/test/nhttp_props_SUITE.erl +++ b/test/nhttp_props_SUITE.erl @@ -113,7 +113,8 @@ groups() -> h2_flow_control_consumes, h2_settings_roundtrip, h2_settings_update_peer, - h2_continuation_sequence + h2_continuation_sequence, + h2_send_within_credit ]}, {qpack_props, [parallel], [ qpack_roundtrip_static, @@ -416,6 +417,9 @@ h2_settings_update_peer(Config) -> h2_continuation_sequence(Config) -> run_property(nhttp_h2_props, prop_continuation_sequence, Config). +h2_send_within_credit(Config) -> + run_property(nhttp_h2_props, prop_send_within_credit, Config). + %%%----------------------------------------------------------------------------- %%% QPACK PROPERTY TESTS %%%----------------------------------------------------------------------------- diff --git a/test/property_test/nhttp_h2_props.erl b/test/property_test/nhttp_h2_props.erl index 8a2d7a5..dea55a6 100644 --- a/test/property_test/nhttp_h2_props.erl +++ b/test/property_test/nhttp_h2_props.erl @@ -472,3 +472,67 @@ setting_gen() -> {initial_window_size, int(1, 16#7fffffff)}, {max_frame_size, int(16384, 16777215)} ]). + +-spec prop_send_within_credit() -> triq:property(). +prop_send_within_credit() -> + ?FORALL( + Ops, + list(flow_op_gen()), + begin + Conn0 = nhttp_h2:new(client), + {ok, StreamId, Conn1} = nhttp_h2:open_stream(Conn0), + Headers = [ + {<<":method">>, <<"POST">>}, {<<":scheme">>, <<"https">>}, {<<":path">>, <<"/">>} + ], + {ok, Conn2, _} = nhttp_h2:send_headers(Conn1, StreamId, Headers, nofin), + Initial = 65535, + {Sent, StreamCredit, ConnCredit, Conn3} = lists:foldl( + fun(Op, Acc) -> apply_flow_op(Op, StreamId, Acc) end, + {0, Initial, Initial, Conn2}, + Ops + ), + {ok, StreamWindow} = nhttp_h2:stream_send_window(Conn3, StreamId), + Sent =< StreamCredit andalso + Sent =< ConnCredit andalso + StreamWindow =:= StreamCredit - Sent andalso + nhttp_h2:connection_send_window(Conn3) =:= ConnCredit - Sent + end + ). + +-spec flow_op_gen() -> triq_dom:domain(). +flow_op_gen() -> + oneof([ + {send, int(0, 40000)}, + {stream_credit, int(1, 100000)}, + {connection_credit, int(1, 100000)} + ]). + +-spec apply_flow_op( + {send | stream_credit | connection_credit, non_neg_integer()}, + nhttp_lib:stream_id(), + {non_neg_integer(), non_neg_integer(), non_neg_integer(), nhttp_h2:conn()} +) -> {non_neg_integer(), non_neg_integer(), non_neg_integer(), nhttp_h2:conn()}. +apply_flow_op({send, Size}, StreamId, {Sent, StreamCredit, ConnCredit, Conn}) -> + Data = binary:copy(<<0>>, Size), + case nhttp_h2:send_data(Conn, StreamId, Data, nofin) of + {ok, NewConn, Frame} -> + {Sent + payload_size(Frame), StreamCredit, ConnCredit, NewConn}; + {partial, NewConn, Frame, _Rest, nofin, _Window} -> + {Sent + payload_size(Frame), StreamCredit, ConnCredit, NewConn} + end; +apply_flow_op({stream_credit, Increment}, StreamId, {Sent, StreamCredit, ConnCredit, Conn}) -> + {ok, Frame} = nhttp_h2_frame:window_update(StreamId, Increment), + {ok, [{window_update, StreamId, Increment}], NewConn} = + nhttp_h2:recv(Conn, iolist_to_binary(Frame)), + {Sent, StreamCredit + Increment, ConnCredit, NewConn}; +apply_flow_op({connection_credit, Increment}, _StreamId, {Sent, StreamCredit, ConnCredit, Conn}) -> + {ok, Frame} = nhttp_h2_frame:window_update(Increment), + {ok, [{window_update, 0, Increment}], NewConn} = + nhttp_h2:recv(Conn, iolist_to_binary(Frame)), + {Sent, StreamCredit, ConnCredit + Increment, NewConn}. + +-spec payload_size(iodata()) -> non_neg_integer(). +payload_size([]) -> + 0; +payload_size(Frame) -> + iolist_size(Frame) - 9.