Browse Source

Add the max_concurrent_streams h2 option

Loïc Hoguin 7 years ago
parent
commit
7373822b86
4 changed files with 104 additions and 46 deletions
  1. 7 1
      doc/src/manual/cowboy_http2.asciidoc
  2. 20 3
      src/cowboy_http2.erl
  3. 2 1
      test/h2spec_SUITE.erl
  4. 75 41
      test/rfc7540_SUITE.erl

+ 7 - 1
doc/src/manual/cowboy_http2.asciidoc

@@ -21,6 +21,7 @@ opts() :: #{
     enable_connect_protocol => boolean(),
     enable_connect_protocol => boolean(),
     env                     => cowboy_middleware:env(),
     env                     => cowboy_middleware:env(),
     inactivity_timeout      => timeout(),
     inactivity_timeout      => timeout(),
+    max_concurrent_streams  => non_neg_integer() | infinity,
     max_decode_table_size   => non_neg_integer(),
     max_decode_table_size   => non_neg_integer(),
     max_encode_table_size   => non_neg_integer(),
     max_encode_table_size   => non_neg_integer(),
     middlewares             => [module()],
     middlewares             => [module()],
@@ -55,6 +56,9 @@ env (#{})::
 inactivity_timeout (300000)::
 inactivity_timeout (300000)::
     Time in ms with nothing received at all before Cowboy closes the connection.
     Time in ms with nothing received at all before Cowboy closes the connection.
 
 
+max_concurrent_streams (infinity)::
+    Maximum number of concurrent streams allowed on the connection.
+
 max_decode_table_size (4096)::
 max_decode_table_size (4096)::
     Maximum header table size used by the decoder. This is the value advertised
     Maximum header table size used by the decoder. This is the value advertised
     to the client. The client can then choose a header table size equal or lower
     to the client. The client can then choose a header table size equal or lower
@@ -79,7 +83,9 @@ stream_handlers ([cowboy_stream_h])::
 
 
 == Changelog
 == Changelog
 
 
-* *2.4*: Add the options `max_decode_table_size` and `max_encode_table_size`.
+* *2.4*: Add the options `max_concurrent_streams`,
+         `max_decode_table_size` and `max_encode_table_size`
+         to configure HTTP/2 SETTINGS.
 * *2.4*: Add the experimental option `enable_connect_protocol`.
 * *2.4*: Add the experimental option `enable_connect_protocol`.
 * *2.0*: Protocol introduced.
 * *2.0*: Protocol introduced.
 
 

+ 20 - 3
src/cowboy_http2.erl

@@ -27,6 +27,7 @@
 	enable_connect_protocol => boolean(),
 	enable_connect_protocol => boolean(),
 	env => cowboy_middleware:env(),
 	env => cowboy_middleware:env(),
 	inactivity_timeout => timeout(),
 	inactivity_timeout => timeout(),
+	max_concurrent_streams => non_neg_integer() | infinity,
 	max_decode_table_size => non_neg_integer(),
 	max_decode_table_size => non_neg_integer(),
 	max_encode_table_size => non_neg_integer(),
 	max_encode_table_size => non_neg_integer(),
 	middlewares => [module()],
 	middlewares => [module()],
@@ -206,11 +207,12 @@ init(Parent, Ref, Socket, Transport, Opts, Peer, Sock, Cert, Buffer, _Settings,
 settings_init(State, Opts) ->
 settings_init(State, Opts) ->
 	S0 = setting_from_opt(#{}, Opts, max_decode_table_size,
 	S0 = setting_from_opt(#{}, Opts, max_decode_table_size,
 		header_table_size, 4096),
 		header_table_size, 4096),
-	%% @todo max_concurrent_streams + enforce it
+	S1 = setting_from_opt(S0, Opts, max_concurrent_streams,
+		max_concurrent_streams, infinity),
 	%% @todo initial_window_size
 	%% @todo initial_window_size
 	%% @todo max_frame_size
 	%% @todo max_frame_size
 	%% @todo max_header_list_size
 	%% @todo max_header_list_size
-	Settings = setting_from_opt(S0, Opts, enable_connect_protocol,
+	Settings = setting_from_opt(S1, Opts, enable_connect_protocol,
 		enable_connect_protocol, false),
 		enable_connect_protocol, false),
 	State#state{next_settings=Settings}.
 	State#state{next_settings=Settings}.
 
 
@@ -868,13 +870,24 @@ terminate_all_streams([#stream{id=StreamID, state=StreamState}|Tail], Reason) ->
 stream_decode_init(State=#state{decode_state=DecodeState0}, StreamID, IsFin, HeaderBlock) ->
 stream_decode_init(State=#state{decode_state=DecodeState0}, StreamID, IsFin, HeaderBlock) ->
 	try cow_hpack:decode(HeaderBlock, DecodeState0) of
 	try cow_hpack:decode(HeaderBlock, DecodeState0) of
 		{Headers, DecodeState} ->
 		{Headers, DecodeState} ->
-			stream_pseudo_headers_init(State#state{decode_state=DecodeState},
+			stream_enforce_concurrency_limit(State#state{decode_state=DecodeState},
 				StreamID, IsFin, Headers)
 				StreamID, IsFin, Headers)
 	catch _:_ ->
 	catch _:_ ->
 		terminate(State, {connection_error, compression_error,
 		terminate(State, {connection_error, compression_error,
 			'Error while trying to decode HPACK-encoded header block. (RFC7540 4.3)'})
 			'Error while trying to decode HPACK-encoded header block. (RFC7540 4.3)'})
 	end.
 	end.
 
 
+stream_enforce_concurrency_limit(State=#state{opts=Opts, streams=Streams},
+		StreamID, IsFin, Headers) ->
+	MaxConcurrentStreams = maps:get(max_concurrent_streams, Opts, infinity),
+	case length(Streams) < MaxConcurrentStreams of
+		true ->
+			stream_pseudo_headers_init(State, StreamID, IsFin, Headers);
+		false ->
+			stream_refused(State, StreamID,
+				'Maximum number of concurrent streams has been reached. (RFC7540 5.1.2)')
+	end.
+
 stream_pseudo_headers_init(State=#state{local_settings=LocalSettings},
 stream_pseudo_headers_init(State=#state{local_settings=LocalSettings},
 		StreamID, IsFin, Headers0) ->
 		StreamID, IsFin, Headers0) ->
 	IsExtendedConnectEnabled = maps:get(enable_connect_protocol, LocalSettings, false),
 	IsExtendedConnectEnabled = maps:get(enable_connect_protocol, LocalSettings, false),
@@ -1045,6 +1058,10 @@ stream_malformed(State=#state{socket=Socket, transport=Transport}, StreamID, _)
 	Transport:send(Socket, cow_http2:rst_stream(StreamID, protocol_error)),
 	Transport:send(Socket, cow_http2:rst_stream(StreamID, protocol_error)),
 	State.
 	State.
 
 
+stream_refused(State=#state{socket=Socket, transport=Transport}, StreamID, _) ->
+	Transport:send(Socket, cow_http2:rst_stream(StreamID, refused_stream)),
+	State.
+
 stream_early_error(State0=#state{ref=Ref, opts=Opts, peer=Peer,
 stream_early_error(State0=#state{ref=Ref, opts=Opts, peer=Peer,
 		local_settings=#{initial_window_size := RemoteWindow},
 		local_settings=#{initial_window_size := RemoteWindow},
 		remote_settings=#{initial_window_size := LocalWindow},
 		remote_settings=#{initial_window_size := LocalWindow},

+ 2 - 1
test/h2spec_SUITE.erl

@@ -30,7 +30,8 @@ init_per_suite(Config) ->
 			skip;
 			skip;
 		_ ->
 		_ ->
 			cowboy_test:init_http2(h2spec, #{
 			cowboy_test:init_http2(h2spec, #{
-				env => #{dispatch => init_dispatch()}
+				env => #{dispatch => init_dispatch()},
+				max_concurrent_streams => 100
 			}, Config)
 			}, Config)
 	end.
 	end.
 
 

+ 75 - 41
test/rfc7540_SUITE.erl

@@ -49,6 +49,7 @@ init_routes(_) -> [
 	{"localhost", [
 	{"localhost", [
 		{"/", hello_h, []},
 		{"/", hello_h, []},
 		{"/echo/:key", echo_h, []},
 		{"/echo/:key", echo_h, []},
+		{"/long_polling", long_polling_h, []},
 		{"/resp/:key[/:arg]", resp_h, []}
 		{"/resp/:key[/:arg]", resp_h, []}
 	]}
 	]}
 ].
 ].
@@ -2263,34 +2264,6 @@ reject_streamid_lower(Config) ->
 %   frame so that the client is forced to open a new connection for new
 %   frame so that the client is forced to open a new connection for new
 %   streams.
 %   streams.
 
 
-%% @todo We need this option too. (RFC7540 5.1.2)
-%   A peer can limit the number of concurrently active streams using the
-%   SETTINGS_MAX_CONCURRENT_STREAMS parameter (see Section 6.5.2) within
-%   a SETTINGS frame.  The maximum concurrent streams setting is specific
-%   to each endpoint and applies only to the peer that receives the
-%   setting.  That is, clients specify the maximum number of concurrent
-%   streams the server can initiate, and servers specify the maximum
-%   number of concurrent streams the client can initiate.
-%
-%   Streams that are in the "open" state or in either of the "half-
-%   closed" states count toward the maximum number of streams that an
-%   endpoint is permitted to open.  Streams in any of these three states
-%   count toward the limit advertised in the
-%   SETTINGS_MAX_CONCURRENT_STREAMS setting.  Streams in either of the
-%   "reserved" states do not count toward the stream limit.
-%
-%   Endpoints MUST NOT exceed the limit set by their peer.  An endpoint
-%   that receives a HEADERS frame that causes its advertised concurrent
-%   stream limit to be exceeded MUST treat this as a stream error
-%   (Section 5.4.2) of type PROTOCOL_ERROR or REFUSED_STREAM.  The choice
-%   of error code determines whether the endpoint wishes to enable
-%   automatic retry (see Section 8.1.4) for details).
-%
-%   An endpoint that wishes to reduce the value of
-%   SETTINGS_MAX_CONCURRENT_STREAMS to a value that is below the current
-%   number of open streams can either close streams that exceed the new
-%   value or allow streams to complete.
-
 %% (RFC7540 5.2.1)
 %% (RFC7540 5.2.1)
 %   3.  Flow control is directional with overall control provided by the
 %   3.  Flow control is directional with overall control provided by the
 %       receiver.  A receiver MAY choose to set any window size that it
 %       receiver.  A receiver MAY choose to set any window size that it
@@ -2539,21 +2512,72 @@ settings_header_table_size_server(Config0) ->
 %      0.  An endpoint that has both set this parameter to 0 and had it
 %      0.  An endpoint that has both set this parameter to 0 and had it
 %      acknowledged MUST treat the receipt of a PUSH_PROMISE frame as a
 %      acknowledged MUST treat the receipt of a PUSH_PROMISE frame as a
 %      connection error (Section 5.4.1) of type PROTOCOL_ERROR.
 %      connection error (Section 5.4.1) of type PROTOCOL_ERROR.
+%% @todo settings_disable_push
+
+settings_max_concurrent_streams(Config0) ->
+	doc("The SETTINGS_MAX_CONCURRENT_STREAMS setting can be used to "
+		"restrict the number of concurrent streams. (RFC7540 5.1.2, RFC7540 6.5.2)"),
+	%% Create a new listener that allows only a single concurrent stream.
+	Config = cowboy_test:init_http(name(), #{
+		env => #{dispatch => cowboy_router:compile(init_routes(Config0))},
+		max_concurrent_streams => 1
+	}, Config0),
+	{ok, Socket} = do_handshake(Config),
+	%% Send two HEADERS frames as two separate streams.
+	Headers = [
+		{<<":method">>, <<"GET">>},
+		{<<":scheme">>, <<"http">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<":path">>, <<"/long_polling">>}
+	],
+	{ReqHeadersBlock1, EncodeState} = cow_hpack:encode(Headers),
+	{ReqHeadersBlock2, _} = cow_hpack:encode(Headers, EncodeState),
+	ok = gen_tcp:send(Socket, [
+		cow_http2:headers(1, fin, ReqHeadersBlock1),
+		cow_http2:headers(3, fin, ReqHeadersBlock2)
+	]),
+	%% Receive a REFUSED_STREAM stream error.
+	{ok, << _:24, 3:8, _:8, 3:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000),
+	ok.
+
+settings_max_concurrent_streams_0(Config0) ->
+	doc("The SETTINGS_MAX_CONCURRENT_STREAMS setting can be set to "
+		"0 to refuse all incoming streams. (RFC7540 5.1.2, RFC7540 6.5.2)"),
+	%% Create a new listener that allows only a single concurrent stream.
+	Config = cowboy_test:init_http(name(), #{
+		env => #{dispatch => cowboy_router:compile(init_routes(Config0))},
+		max_concurrent_streams => 0
+	}, Config0),
+	{ok, Socket} = do_handshake(Config),
+	%% Send a HEADERS frame.
+	{HeadersBlock, _} = cow_hpack:encode([
+		{<<":method">>, <<"GET">>},
+		{<<":scheme">>, <<"http">>},
+		{<<":authority">>, <<"localhost">>}, %% @todo Correct port number.
+		{<<":path">>, <<"/long_polling">>}
+	]),
+	ok = gen_tcp:send(Socket, cow_http2:headers(1, fin, HeadersBlock)),
+	%% Receive a REFUSED_STREAM stream error.
+	{ok, << _:24, 3:8, _:8, 1:32, 7:32 >>} = gen_tcp:recv(Socket, 13, 6000),
+	ok.
+
+%% @todo The client can limit the number of concurrent streams too. (RFC7540 5.1.2)
 %
 %
-%   SETTINGS_MAX_CONCURRENT_STREAMS (0x3):  Indicates the maximum number
-%      of concurrent streams that the sender will allow.  This limit is
-%      directional: it applies to the number of streams that the sender
-%      permits the receiver to create.  Initially, there is no limit to
-%      this value.  It is recommended that this value be no smaller than
-%      100, so as to not unnecessarily limit parallelism.
-%
-%      A value of 0 for SETTINGS_MAX_CONCURRENT_STREAMS SHOULD NOT be
-%      treated as special by endpoints.  A zero value does prevent the
-%      creation of new streams; however, this can also happen for any
-%      limit that is exhausted with active streams.  Servers SHOULD only
-%      set a zero value for short durations; if a server does not wish to
-%      accept requests, closing the connection is more appropriate.
+%   A peer can limit the number of concurrently active streams using the
+%   SETTINGS_MAX_CONCURRENT_STREAMS parameter (see Section 6.5.2) within
+%   a SETTINGS frame.  The maximum concurrent streams setting is specific
+%   to each endpoint and applies only to the peer that receives the
+%   setting.  That is, clients specify the maximum number of concurrent
+%   streams the server can initiate, and servers specify the maximum
+%   number of concurrent streams the client can initiate.
 %
 %
+%   Endpoints MUST NOT exceed the limit set by their peer.  An endpoint
+%   that receives a HEADERS frame that causes its advertised concurrent
+%   stream limit to be exceeded MUST treat this as a stream error
+%   (Section 5.4.2) of type PROTOCOL_ERROR or REFUSED_STREAM.  The choice
+%   of error code determines whether the endpoint wishes to enable
+%   automatic retry (see Section 8.1.4) for details).
+
 %   SETTINGS_INITIAL_WINDOW_SIZE (0x4):
 %   SETTINGS_INITIAL_WINDOW_SIZE (0x4):
 %      Values above the maximum flow-control window size of 2^31-1 MUST
 %      Values above the maximum flow-control window size of 2^31-1 MUST
 %      be treated as a connection error (Section 5.4.1) of type
 %      be treated as a connection error (Section 5.4.1) of type
@@ -2566,6 +2590,16 @@ settings_header_table_size_server(Config0) ->
 %      Values outside this range MUST be treated as a connection error
 %      Values outside this range MUST be treated as a connection error
 %      (Section 5.4.1) of type PROTOCOL_ERROR.
 %      (Section 5.4.1) of type PROTOCOL_ERROR.
 %
 %
+%   SETTINGS_MAX_HEADER_LIST_SIZE (0x6):  This advisory setting informs a
+%      peer of the maximum size of header list that the sender is
+%      prepared to accept, in octets.  The value is based on the
+%      uncompressed size of header fields, including the length of the
+%      name and value in octets plus an overhead of 32 octets for each
+%      header field.
+%
+%      For any given request, a lower limit than what is advertised MAY
+%      be enforced.  The initial value of this setting is unlimited.
+%
 %   An endpoint that receives a SETTINGS frame with any unknown or
 %   An endpoint that receives a SETTINGS frame with any unknown or
 %   unsupported identifier MUST ignore that setting. (6.5.2 and 6.5.3)
 %   unsupported identifier MUST ignore that setting. (6.5.2 and 6.5.3)