diff --git a/lib/mint/http2.ex b/lib/mint/http2.ex
index 5c84e8d8..f48cd556 100644
--- a/lib/mint/http2.ex
+++ b/lib/mint/http2.ex
@@ -191,6 +191,9 @@ defmodule Mint.HTTP2 do
# Fields of the connection.
buffer: "",
+ # Highest stream ID the server has promised through PUSH_PROMISE. Promised IDs must
+ # increase, and it is the last server-initiated stream reported in GOAWAY frames.
+ last_promised_stream_id: 0,
# `send_window_size` is the client *send* window for the connection
# — how much request-body data we're allowed to send to the server
# before it refills the window with a WINDOW_UPDATE frame.
@@ -348,7 +351,9 @@ defmodule Mint.HTTP2 do
* `:too_many_concurrent_requests` - when the maximum number of concurrent requests
allowed by the server is reached. To find out what this limit is, use `get_setting/2`
- with the `:max_concurrent_streams` setting name.
+ with the `:max_concurrent_streams` setting name. It's also returned for a promised
+ request whose pushed response is refused because it would exceed the client's
+ `:max_concurrent_streams` setting.
* `{:max_header_list_size_exceeded, size, max_size}` - when the maximum size of
the header list is reached. `size` is the actual value of the header list size,
@@ -390,8 +395,9 @@ defmodule Mint.HTTP2 do
When this error is returned, it means that the server hasn't processed the request at all,
so it's safe to retry the given request on a different or new connection.
- * `{:server_closed_request, error_code}` - when the server closes the request.
- `error_code` is the reason why the request was closed.
+ * `{:server_closed_request, error_code}` - when the server closes the request before
+ the response is complete. `error_code` is the reason why the request was closed,
+ which can be `:no_error` when the server ends a response early.
* `{:server_closed_connection, reason, debug_data}` - when the server closes the connection
gracefully or because of an error. In HTTP/2, this corresponds to a `GOAWAY` frame.
@@ -1409,6 +1415,13 @@ defmodule Mint.HTTP2 do
end
defp encode_stream_body_request_payload(conn, stream_id, {:eof, trailers}) do
+ stream = fetch_stream!(conn, stream_id)
+
+ if stream.state != :open do
+ error = wrap_error(:request_is_not_streaming)
+ throw({:mint, conn, error})
+ end
+
trailers = Headers.from_raw(trailers)
if unallowed_trailer_header = Headers.find_unallowed_trailer(trailers) do
@@ -1417,7 +1430,10 @@ defmodule Mint.HTTP2 do
end
trailer_headers = Headers.to_raw(trailers, _case_sensitive = false)
- encode_headers(conn, stream_id, trailer_headers, [:end_headers, :end_stream])
+ enabled_flags = [:end_headers, :end_stream]
+ {conn, payload} = encode_header_block(conn, stream_id, trailer_headers, enabled_flags)
+ conn = put_in(conn.streams[stream_id].state, :half_closed_local)
+ {conn, payload}
end
defp encode_stream_body_request_payload(conn, stream_id, iodata) do
@@ -1439,12 +1455,7 @@ defmodule Mint.HTTP2 do
end
defp encode_headers(conn, stream_id, headers, enabled_flags) do
- assert_headers_smaller_than_max_header_list_size(conn, headers)
-
- headers = Enum.map(headers, fn {name, value} -> {:store_name, name, value} end)
- {hbf, conn} = get_and_update_in(conn.encode_table, &HPAX.encode(headers, &1))
-
- payload = headers_to_encoded_frames(conn, stream_id, hbf, enabled_flags)
+ {conn, payload} = encode_header_block(conn, stream_id, headers, enabled_flags)
stream_state = if :end_stream in enabled_flags, do: :half_closed_local, else: :open
@@ -1454,6 +1465,15 @@ defmodule Mint.HTTP2 do
{conn, payload}
end
+ defp encode_header_block(conn, stream_id, headers, enabled_flags) do
+ assert_headers_smaller_than_max_header_list_size(conn, headers)
+
+ headers = Enum.map(headers, fn {name, value} -> {:store_name, name, value} end)
+ {hbf, conn} = get_and_update_in(conn.encode_table, &HPAX.encode(headers, &1))
+
+ {conn, headers_to_encoded_frames(conn, stream_id, hbf, enabled_flags)}
+ end
+
defp assert_headers_smaller_than_max_header_list_size(
%{server_settings: %{max_header_list_size: :infinity}},
_headers
@@ -1855,13 +1875,22 @@ defmodule Mint.HTTP2 do
:ok
end
- # RFC 9113 5.1: PRIORITY is the only frame the server can send on an idle stream.
- # Client streams are opened in order, so odd stream IDs from next_stream_id on are
- # idle.
+ # RFC 9113 5.1: PRIORITY frames are allowed on idle streams. Client streams are
+ # opened in order, so odd stream IDs from next_stream_id on are idle and the server
+ # can't send other frames on them. Server streams are only opened through
+ # PUSH_PROMISE (RFC 9113 8.4 and 5.1.1), so even stream IDs above the last promised
+ # one are idle too.
defp assert_stream_id_is_allowed(_conn, :priority, _stream_id), do: :ok
defp assert_stream_id_is_allowed(conn, _frame, stream_id) do
- if Integer.is_odd(stream_id) and stream_id >= conn.next_stream_id do
+ idle? =
+ cond do
+ stream_id == 0 -> false
+ Integer.is_odd(stream_id) -> stream_id >= conn.next_stream_id
+ true -> stream_id > conn.last_promised_stream_id
+ end
+
+ if idle? do
debug_data = "frame with stream ID #{inspect(stream_id)} has not been opened yet"
send_connection_error!(conn, :protocol_error, debug_data)
else
@@ -2080,6 +2109,13 @@ defmodule Mint.HTTP2 do
# https://httpwg.org/specs/rfc9113.html#HttpFraming
[{":status", <1, _, _>> = status} | headers] ->
cond do
+ # RFC 9113 8.6: HTTP/2 does not support the 101 status code.
+ status == "101" ->
+ conn = close_stream!(conn, stream.id, :protocol_error)
+ debug_data = "the 101 (Switching Protocols) status code is not supported in HTTP/2"
+ error = wrap_error({:protocol_error, debug_data})
+ {conn, [{:error, stream.ref, error} | responses]}
+
end_stream? ->
conn = close_stream!(conn, stream.id, :protocol_error)
debug_data = "informational response (1xx) must not have the END_STREAM flag set"
@@ -2088,53 +2124,42 @@ defmodule Mint.HTTP2 do
{conn, responses}
true ->
- assert_stream_in_state(conn, stream, [:open, :half_closed_local])
- status = String.to_integer(status)
- headers = join_cookie_headers(headers)
- new_responses = [{:headers, ref, headers}, {:status, ref, status} | responses]
- {conn, new_responses}
+ case open_promised_stream(conn, stream) do
+ {:ok, conn} ->
+ status = String.to_integer(status)
+ headers = join_cookie_headers(headers)
+ new_responses = [{:headers, ref, headers}, {:status, ref, status} | responses]
+ {conn, new_responses}
+
+ {:refused, conn} ->
+ error = wrap_error(:too_many_concurrent_requests)
+ {conn, [{:error, ref, error} | responses]}
+ end
end
[{":status", status} | headers] when not received_first_headers? ->
status = String.to_integer(status)
headers = join_cookie_headers(headers)
- case response_content_length(stream, status, headers) do
- {:ok, content_length} ->
- conn =
- update_in(
- conn.streams[stream.id],
- &%{&1 | received_first_headers?: true, content_length: content_length}
- )
-
- new_responses = [{:headers, ref, headers}, {:status, ref, status} | responses]
-
- cond do
- # :reserved_remote means that this was a promised stream. As soon as headers come,
- # the stream goes in the :half_closed_local state (unless it's not allowed because
- # of the client's max concurrent streams limit, or END_STREAM is set).
- stream.state == :reserved_remote ->
- cond do
- conn.open_server_stream_count >= conn.client_settings.max_concurrent_streams ->
- conn = close_stream!(conn, stream.id, :refused_stream)
- {conn, responses}
-
- end_stream? ->
- end_remote_stream(conn, stream, new_responses)
-
- true ->
- conn = update_in(conn.open_server_stream_count, &(&1 + 1))
- conn = update_in(conn.reserved_server_stream_count, &(&1 - 1))
- conn = put_in(conn.streams[stream.id].state, :half_closed_local)
- {conn, new_responses}
- end
-
- end_stream? ->
- end_remote_stream(conn, stream, new_responses)
-
- true ->
- {conn, new_responses}
- end
+ with {:ok, content_length} <- response_content_length(stream, status, headers),
+ {:ok, conn} <- open_promised_stream(conn, stream) do
+ conn =
+ update_in(
+ conn.streams[stream.id],
+ &%{&1 | received_first_headers?: true, content_length: content_length}
+ )
+
+ new_responses = [{:headers, ref, headers}, {:status, ref, status} | responses]
+
+ if end_stream? do
+ end_remote_stream(conn, stream, new_responses)
+ else
+ {conn, new_responses}
+ end
+ else
+ {:refused, conn} ->
+ error = wrap_error(:too_many_concurrent_requests)
+ {conn, [{:error, ref, error} | responses]}
{:error, reason} ->
conn = close_stream!(conn, stream.id, :protocol_error)
@@ -2167,6 +2192,21 @@ defmodule Mint.HTTP2 do
end
end
+ # RFC 9113 5.1: HEADERS frames move a stream reserved by a PUSH_PROMISE to the
+ # half-closed (local) state, where it counts against the client's concurrency
+ # limit. Streams that don't fit within the limit are refused.
+ defp open_promised_stream(conn, %{state: :reserved_remote} = stream) do
+ if conn.open_server_stream_count >= conn.client_settings.max_concurrent_streams do
+ {:refused, close_stream!(conn, stream.id, :refused_stream)}
+ else
+ conn = update_in(conn.open_server_stream_count, &(&1 + 1))
+ conn = update_in(conn.reserved_server_stream_count, &(&1 - 1))
+ {:ok, put_in(conn.streams[stream.id].state, :half_closed_local)}
+ end
+ end
+
+ defp open_promised_stream(conn, _stream), do: {:ok, conn}
+
defp decode_hbf(conn, hbf) do
case HPAX.decode(hbf, conn.decode_table) do
{:ok, headers, decode_table} ->
@@ -2217,14 +2257,32 @@ defmodule Mint.HTTP2 do
cond do
not valid_field_name?(name) -> {:error, {:invalid_header_name, name}}
not valid_field_value?(value) -> {:error, {:invalid_header_value, name, value}}
+ connection_specific?(name) -> {:error, connection_specific_error(name)}
true -> validate_response_headers(rest, trailers?, status?, true)
end
end
- # RFC 9110 15: status-code = 3DIGIT
- defp valid_status?(<>) when a in ?0..?9 and b in ?0..?9 and c in ?0..?9, do: true
+ # RFC 9110 15: status-code = 3DIGIT, with values in the range 100-999.
+ defp valid_status?(<>) when a in ?1..?9 and b in ?0..?9 and c in ?0..?9, do: true
defp valid_status?(_other), do: false
+ # RFC 9113 8.2.2: a message with connection-specific header fields is malformed.
+ # "te" is only allowed in requests, with the "trailers" value.
+ @connection_specific_headers [
+ "connection",
+ "keep-alive",
+ "proxy-connection",
+ "te",
+ "transfer-encoding",
+ "upgrade"
+ ]
+
+ defp connection_specific?(name), do: name in @connection_specific_headers
+
+ defp connection_specific_error(name) do
+ {:protocol_error, "connection-specific header #{inspect(name)} is not allowed in HTTP/2"}
+ end
+
# RFC 9113 8.2.1: a field name must not contain characters in 0x00-0x20, 0x41-0x5A
# (uppercase letters) or 0x7F-0xFF, and only a pseudo-header field can contain a colon.
defp valid_field_name?(<<>>), do: false
@@ -2333,14 +2391,11 @@ defmodule Mint.HTTP2 do
# If we receive RST_STREAM then the stream is definitely closed.
# We won't send anything else on the stream so we can simply delete
# it, so that if we get things like DATA on that stream we error out.
+ # Streams are removed as soon as the server ends them, so a RST_STREAM on a
+ # stream we still track means the response is incomplete, whatever the code.
conn = delete_stream(conn, stream)
-
- if error_code == :no_error do
- {conn, [{:done, stream.ref} | responses]}
- else
- error = wrap_error({:server_closed_request, error_code})
- {conn, [{:error, stream.ref, error} | responses]}
- end
+ error = wrap_error({:server_closed_request, error_code})
+ {conn, [{:error, stream.ref, error} | responses]}
:error ->
{conn, responses}
@@ -2502,10 +2557,18 @@ defmodule Mint.HTTP2 do
hbf: hbf
) = frame
- assert_valid_promised_stream_id(conn, promised_stream_id)
+ assert_valid_push_promise_stream_ids(conn, stream_id, promised_stream_id)
+ conn = put_in(conn.last_promised_stream_id, promised_stream_id)
- stream = fetch_stream!(conn, stream_id)
- assert_stream_in_state(conn, stream, [:open, :half_closed_local])
+ # RFC 9113 6.6: the stream may already be closed because the client reset it
+ # before the server processed the RST_STREAM, so a missing stream is not an
+ # error. The header block still has to be decoded to keep the HPACK table in
+ # sync and the promised stream has to be reset.
+ stream = Map.get(conn.streams, stream_id)
+
+ if stream do
+ assert_stream_in_state(conn, stream, [:open, :half_closed_local])
+ end
if flag_set?(flags, :push_promise, :end_headers) do
decode_push_promise_headers_and_add_response(
@@ -2540,27 +2603,45 @@ defmodule Mint.HTTP2 do
# with the HEADERS that would open them.
server_stream_count = conn.open_server_stream_count + conn.reserved_server_stream_count
- if server_stream_count >= conn.client_settings.max_concurrent_streams do
- conn = refuse_promised_stream(conn, promised_stream_id)
- {conn, responses}
- else
- promised_stream = %{
- id: promised_stream_id,
- ref: make_ref(),
- state: :reserved_remote,
- send_window_size: conn.server_settings.initial_window_size,
- receive_window_size: conn.client_settings.initial_window_size,
- receive_window_remaining: conn.client_settings.initial_window_size,
- received_first_headers?: false,
- method: promised_method(headers),
- content_length: nil,
- body_size: 0
- }
-
- conn = put_in(conn.streams[promised_stream.id], promised_stream)
- conn = update_in(conn.reserved_server_stream_count, &(&1 + 1))
- new_response = {:push_promise, stream.ref, promised_stream.ref, headers}
- {conn, [new_response | responses]}
+ cond do
+ is_nil(stream) ->
+ log(
+ conn,
+ :debug,
+ "Received PUSH_PROMISE frame on closed stream, resetting the promised stream"
+ )
+
+ conn = reset_promised_stream(conn, promised_stream_id, :cancel)
+ {conn, responses}
+
+ debug_data = promised_headers_error(headers) ->
+ log(conn, :debug, "Resetting promised stream #{promised_stream_id}: #{debug_data}")
+ conn = reset_promised_stream(conn, promised_stream_id, :protocol_error)
+ {conn, responses}
+
+ server_stream_count >= conn.client_settings.max_concurrent_streams ->
+ conn = reset_promised_stream(conn, promised_stream_id, :refused_stream)
+ {conn, responses}
+
+ true ->
+ promised_stream = %{
+ id: promised_stream_id,
+ ref: make_ref(),
+ state: :reserved_remote,
+ send_window_size: conn.server_settings.initial_window_size,
+ receive_window_size: conn.client_settings.initial_window_size,
+ receive_window_remaining: conn.client_settings.initial_window_size,
+ received_first_headers?: false,
+ method: promised_method(headers),
+ content_length: nil,
+ body_size: 0
+ }
+
+ conn = put_in(conn.streams[promised_stream.id], promised_stream)
+ conn = put_in(conn.ref_to_stream_id[promised_stream.ref], promised_stream.id)
+ conn = update_in(conn.reserved_server_stream_count, &(&1 + 1))
+ new_response = {:push_promise, stream.ref, promised_stream.ref, headers}
+ {conn, [new_response | responses]}
end
end
@@ -2571,25 +2652,33 @@ defmodule Mint.HTTP2 do
end
end
- defp refuse_promised_stream(conn, promised_stream_id) do
+ defp reset_promised_stream(conn, promised_stream_id, error_code) do
if open?(conn) do
- rst_stream_frame = rst_stream(stream_id: promised_stream_id, error_code: :refused_stream)
+ rst_stream_frame = rst_stream(stream_id: promised_stream_id, error_code: error_code)
send!(conn, Frame.encode(rst_stream_frame))
else
conn
end
end
- defp assert_valid_promised_stream_id(conn, promised_stream_id) do
+ # RFC 9113 8.4: PUSH_PROMISE frames are only allowed on client-initiated streams.
+ # RFC 9113 5.1.1: server-initiated streams have even identifiers, 0 is reserved for
+ # the connection, and the identifier of a new stream must be greater than all the
+ # streams the server has already opened or reserved.
+ defp assert_valid_push_promise_stream_ids(conn, stream_id, promised_stream_id) do
cond do
- not is_integer(promised_stream_id) or Integer.is_odd(promised_stream_id) ->
+ Integer.is_even(stream_id) ->
+ debug_data = "PUSH_PROMISE frame on server-initiated stream #{stream_id}"
+ send_connection_error!(conn, :protocol_error, debug_data)
+
+ promised_stream_id == 0 or Integer.is_odd(promised_stream_id) ->
debug_data = "invalid promised stream ID: #{inspect(promised_stream_id)}"
send_connection_error!(conn, :protocol_error, debug_data)
- Map.has_key?(conn.streams, promised_stream_id) ->
+ promised_stream_id <= conn.last_promised_stream_id ->
debug_data =
- "stream with ID #{inspect(promised_stream_id)} already exists and can't be " <>
- "reserved by the server"
+ "promised stream ID #{promised_stream_id} is not greater than the last " <>
+ "promised stream ID #{conn.last_promised_stream_id}"
send_connection_error!(conn, :protocol_error, debug_data)
@@ -2598,6 +2687,84 @@ defmodule Mint.HTTP2 do
end
end
+ @promised_pseudo_headers [":method", ":scheme", ":authority", ":path"]
+
+ # RFC 9113 8.4.1: a promised request must be cacheable and safe and must not have
+ # content, and RFC 9113 8.4 and 8.3.1 require the :method, :scheme, :authority and
+ # :path pseudo-headers. Field names and values follow the same rules as response
+ # headers, except that "te" is allowed with the "trailers" value (RFC 9113 8.2.2).
+ defp promised_headers_error(headers) do
+ case validate_promised_fields(headers, _pseudo = %{}, _regular? = false) do
+ {:error, debug_data} ->
+ debug_data
+
+ {:ok, pseudo} ->
+ cond do
+ not Map.has_key?(pseudo, ":method") ->
+ "missing :method pseudo-header in promised request"
+
+ pseudo[":scheme"] in [nil, ""] ->
+ "missing or empty :scheme pseudo-header in promised request"
+
+ pseudo[":authority"] in [nil, ""] ->
+ "missing or empty :authority pseudo-header in promised request"
+
+ not String.starts_with?(pseudo[":path"] || "", "/") ->
+ "missing or invalid :path pseudo-header in promised request"
+
+ pseudo[":method"] not in ["GET", "HEAD"] ->
+ "promised request method #{inspect(pseudo[":method"])} is not safe and cacheable"
+
+ true ->
+ case content_length(headers) do
+ {:ok, content_length} when content_length in [nil, 0] -> nil
+ {:ok, _content_length} -> "promised request must not have content"
+ {:error, _reason} -> "invalid content-length header in promised request"
+ end
+ end
+ end
+ end
+
+ defp validate_promised_fields([], pseudo, _regular?), do: {:ok, pseudo}
+
+ defp validate_promised_fields([{":" <> _ = name, value} | rest], pseudo, regular?) do
+ cond do
+ regular? ->
+ {:error, "pseudo-header #{inspect(name)} must appear before regular header fields"}
+
+ name not in @promised_pseudo_headers ->
+ {:error, "undefined pseudo-header #{inspect(name)} in promised request"}
+
+ Map.has_key?(pseudo, name) ->
+ {:error, "the #{name} pseudo-header appears more than once"}
+
+ not valid_field_value?(value) ->
+ {:error, "invalid value for pseudo-header #{inspect(name)}"}
+
+ true ->
+ validate_promised_fields(rest, Map.put(pseudo, name, value), regular?)
+ end
+ end
+
+ defp validate_promised_fields([{name, value} | rest], pseudo, _regular?) do
+ cond do
+ not valid_field_name?(name) ->
+ {:error, "invalid header name #{inspect(name)}"}
+
+ not valid_field_value?(value) ->
+ {:error, "invalid value for header #{inspect(name)}"}
+
+ name == "te" and String.downcase(value, :ascii) == "trailers" ->
+ validate_promised_fields(rest, pseudo, true)
+
+ connection_specific?(name) ->
+ {:error, elem(connection_specific_error(name), 1)}
+
+ true ->
+ validate_promised_fields(rest, pseudo, true)
+ end
+ end
+
# PING
defp handle_ping(conn, Frame.ping() = frame, responses) do
@@ -2639,9 +2806,12 @@ defmodule Mint.HTTP2 do
# We gather all the unprocessed requests and form {:error, _, _} tuples for each one.
# At the same time, we delete all the unprocessed requests from the stream set.
+ # RFC 9113 6.8: the last stream ID only covers streams initiated by the client, so
+ # server-initiated (even) streams are never unprocessed.
{unprocessed_request_responses, conn} =
Enum.flat_map_reduce(conn.streams, conn, fn
- {stream_id, _stream}, conn_acc when stream_id <= last_stream_id ->
+ {stream_id, _stream}, conn_acc
+ when Integer.is_even(stream_id) or stream_id <= last_stream_id ->
{[], conn_acc}
{_stream_id, stream}, conn_acc ->
@@ -2773,7 +2943,12 @@ defmodule Mint.HTTP2 do
defp send_connection_error!(conn, error_code, debug_data) do
frame =
- goaway(stream_id: 0, last_stream_id: 2, error_code: error_code, debug_data: debug_data)
+ goaway(
+ stream_id: 0,
+ last_stream_id: conn.last_promised_stream_id,
+ error_code: error_code,
+ debug_data: debug_data
+ )
# Try to send the GOAWAY frame and close connection.
# If the frame fails to send, we still want to set the close
@@ -2887,9 +3062,10 @@ defmodule Mint.HTTP2 do
end
def format_error(:too_many_concurrent_requests) do
- "the number of max concurrent HTTP/2 requests supported by the server has been reached. " <>
- "Use Mint.HTTP2.get_server_setting/2 with the :max_concurrent_streams setting name " <>
- "to find out the maximum number of concurrent requests supported by the server."
+ "the maximum number of concurrent HTTP/2 streams has been reached. For requests, use " <>
+ "Mint.HTTP2.get_server_setting/2 with the :max_concurrent_streams setting name to find " <>
+ "out the limit supported by the server. For pushed responses, the limit is the " <>
+ ":max_concurrent_streams client setting."
end
def format_error({:max_header_list_size_exceeded, size, max_size}) do
diff --git a/test/mint/http2/conn_test.exs b/test/mint/http2/conn_test.exs
index 8b4310b2..6a7d7f47 100644
--- a/test/mint/http2/conn_test.exs
+++ b/test/mint/http2/conn_test.exs
@@ -359,6 +359,36 @@ defmodule Mint.HTTP2Test do
end
describe "closed streams" do
+ for phase <- [:before_the_headers, :during_the_body] do
+ test "RST_STREAM with NO_ERROR #{phase} is an error", %{conn: conn} do
+ {conn, ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ conn =
+ if unquote(phase) == :during_the_body do
+ assert {:ok, %HTTP2{} = conn,
+ [{:status, ^ref, 200}, {:headers, ^ref, _}, {:data, ^ref, "x"}]} =
+ stream_frames(conn, [
+ {:headers, stream_id, [{":status", "200"}, {"content-length", "5"}],
+ [:end_headers]},
+ data(stream_id: stream_id, data: "x")
+ ])
+
+ conn
+ else
+ conn
+ end
+
+ assert {:ok, %HTTP2{} = conn, [{:error, ^ref, error}]} =
+ stream_frames(conn, [rst_stream(stream_id: stream_id, error_code: :no_error)])
+
+ assert_http2_error error, {:server_closed_request, :no_error}
+ refute Map.has_key?(conn.streams, stream_id)
+ assert HTTP2.open?(conn)
+ end
+ end
+
test "server closes a stream with RST_STREAM", %{conn: conn} do
{conn, ref} = open_request(conn)
@@ -1231,7 +1261,7 @@ defmodule Mint.HTTP2Test do
end
describe "response header validation" do
- for status <- ["abc", "", "+200", "2000", "20", "200 ", " 200", "1ab"] do
+ for status <- ["abc", "", "+200", "2000", "20", "200 ", " 200", "1ab", "000", "099"] do
test "an invalid :status of #{inspect(status)} is a stream error", %{conn: conn} do
{conn, ref} = open_request(conn)
@@ -1250,6 +1280,89 @@ defmodule Mint.HTTP2Test do
end
end
+ for {name, value} <- [
+ {"connection", "close"},
+ {"keep-alive", "timeout=5"},
+ {"proxy-connection", "keep-alive"},
+ {"transfer-encoding", "chunked"},
+ {"upgrade", "websocket"},
+ {"te", "gzip"},
+ {"te", "trailers"}
+ ] do
+ test "the connection-specific header #{name}: #{value} is a stream error", %{conn: conn} do
+ {conn, ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, responses} =
+ stream_frames(conn, [
+ {:headers, stream_id, [{":status", "200"}, {unquote(name), unquote(value)}],
+ [:end_headers]}
+ ])
+
+ assert [{:error, ^ref, error}] = responses
+ assert_http2_error error, {:protocol_error, debug_data}
+ assert debug_data =~ "connection-specific header #{inspect(unquote(name))}"
+
+ assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)]
+ assert HTTP2.open?(conn)
+ end
+ end
+
+ test "a connection-specific header in an informational response is a stream error",
+ %{conn: conn} do
+ {conn, ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, responses} =
+ stream_frames(conn, [
+ {:headers, stream_id, [{":status", "103"}, {"te", "trailers"}], [:end_headers]}
+ ])
+
+ assert [{:error, ^ref, error}] = responses
+ assert_http2_error error, {:protocol_error, debug_data}
+ assert debug_data =~ "connection-specific header \"te\""
+
+ assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)]
+ assert HTTP2.open?(conn)
+ end
+
+ test "a connection-specific header in trailers is a stream error", %{conn: conn} do
+ {conn, ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, responses} =
+ stream_frames(conn, [
+ {:headers, stream_id, [{":status", "200"}], [:end_headers]},
+ {:headers, stream_id, [{"te", "trailers"}], [:end_headers, :end_stream]}
+ ])
+
+ assert [{:status, ^ref, 200}, {:headers, ^ref, []}, {:error, ^ref, error}] = responses
+ assert_http2_error error, {:protocol_error, debug_data}
+ assert debug_data =~ "connection-specific header \"te\""
+
+ assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)]
+ assert HTTP2.open?(conn)
+ end
+
+ test "a 101 status is a stream error", %{conn: conn} do
+ {conn, ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, responses} =
+ stream_frames(conn, [{:headers, stream_id, [{":status", "101"}], [:end_headers]}])
+
+ assert [{:error, ^ref, error}] = responses
+ assert_http2_error error, {:protocol_error, debug_data}
+ assert debug_data =~ "the 101 (Switching Protocols) status code is not supported in HTTP/2"
+
+ assert_recv_frames [rst_stream(stream_id: ^stream_id, error_code: :protocol_error)]
+ assert HTTP2.open?(conn)
+ end
+
for name <- ["Foo", "fo o", "", "foo:bar", "f\x7Fo", "f\xC3\xA4"] do
test "an invalid header name #{inspect(name)} is a stream error", %{conn: conn} do
{conn, ref} = open_request(conn)
@@ -1694,7 +1807,7 @@ defmodule Mint.HTTP2Test do
info_hbf =
server_encode_headers([
- {":status", "101"},
+ {":status", "102"},
{"x-info-header1", "this is an info"}
])
@@ -1830,7 +1943,7 @@ defmodule Mint.HTTP2Test do
assert_recv_frames [headers(stream_id: stream_id)]
- promised_hbf = server_encode_headers([{":method", "GET"}])
+ promised_hbf = server_encode_headers(promised_headers())
hbf1 = server_encode_headers([{":status", "200"}])
hbf2 = server_encode_headers([{":status", "200"}])
trailer_hbf = server_encode_headers([{"x-trailer", "some value"}])
@@ -1862,7 +1975,7 @@ defmodule Mint.HTTP2Test do
])
assert [
- {:push_promise, ^ref, promised_ref, [{":method", "GET"}]},
+ {:push_promise, ^ref, promised_ref, promised_headers},
{:status, ^ref, 200},
{:headers, ^ref, []},
{:done, ^ref},
@@ -1872,6 +1985,7 @@ defmodule Mint.HTTP2Test do
{:done, promised_ref}
] = responses
+ assert promised_headers == promised_headers()
assert HTTP2.open?(conn)
end
@@ -1949,6 +2063,406 @@ defmodule Mint.HTTP2Test do
end
describe "server pushes" do
+ test "interim responses on a promised stream", %{conn: conn} do
+ {conn, ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, promised_ref, _}]} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: server_encode_headers(promised_headers()),
+ promised_stream_id: 2,
+ flags: set_flags(:push_promise, [:end_headers])
+ )
+ ])
+
+ assert {:ok, %HTTP2{} = conn, responses} =
+ stream_frames(conn, [
+ {:headers, 2, [{":status", "103"}, {"link", "; rel=preload"}],
+ [:end_headers]},
+ {:headers, 2, [{":status", "200"}], [:end_headers, :end_stream]}
+ ])
+
+ assert [
+ {:status, ^promised_ref, 103},
+ {:headers, ^promised_ref, [{"link", "; rel=preload"}]},
+ {:status, ^promised_ref, 200},
+ {:headers, ^promised_ref, []},
+ {:done, ^promised_ref}
+ ] = responses
+
+ refute_receive {:ssl, _socket, _data}, 100
+ assert HTTP2.open_request_count(conn) == 1
+ assert HTTP2.open?(conn)
+ end
+
+ test "cancelling a promised request resets the promised stream", %{conn: conn} do
+ {conn, ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, promised_ref, _}]} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: server_encode_headers(promised_headers()),
+ promised_stream_id: 2,
+ flags: set_flags(:push_promise, [:end_headers])
+ )
+ ])
+
+ assert {:ok, %HTTP2{} = conn} = HTTP2.cancel_request(conn, promised_ref)
+ assert_recv_frames [rst_stream(stream_id: 2, error_code: :cancel)]
+
+ assert {:ok, %HTTP2{} = conn, []} =
+ stream_frames(conn, [
+ {:headers, 2, [{":status", "200"}], [:end_headers, :end_stream]}
+ ])
+
+ assert HTTP2.open?(conn)
+ assert HTTP2.open_request_count(conn) == 1
+ end
+
+ test "a pushed response ending with its HEADERS frame sends no RST_STREAM", %{conn: conn} do
+ {conn, ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, responses} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: server_encode_headers(promised_headers()),
+ promised_stream_id: 2,
+ flags: set_flags(:push_promise, [:end_headers])
+ ),
+ {:headers, 2, [{":status", "304"}], [:end_headers, :end_stream]}
+ ])
+
+ assert [
+ {:push_promise, ^ref, promised_ref, _},
+ {:status, promised_ref, 304},
+ {:headers, promised_ref, []},
+ {:done, promised_ref}
+ ] = responses
+
+ refute_receive {:ssl, _socket, _data}, 100
+ assert HTTP2.open_request_count(conn) == 1
+ end
+
+ for {variant, fields} <- [
+ missing_scheme: [{":method", "GET"}, {":authority", "localhost"}, {":path", "/"}],
+ empty_scheme: [
+ {":method", "GET"},
+ {":scheme", ""},
+ {":authority", "localhost"},
+ {":path", "/"}
+ ],
+ missing_authority: [{":method", "GET"}, {":scheme", "https"}, {":path", "/"}],
+ empty_authority: [
+ {":method", "GET"},
+ {":scheme", "https"},
+ {":authority", ""},
+ {":path", "/"}
+ ],
+ empty_path: [
+ {":method", "GET"},
+ {":scheme", "https"},
+ {":authority", "localhost"},
+ {":path", ""}
+ ],
+ asterisk_path: [
+ {":method", "GET"},
+ {":scheme", "https"},
+ {":authority", "localhost"},
+ {":path", "*"}
+ ],
+ relative_path: [
+ {":method", "GET"},
+ {":scheme", "https"},
+ {":authority", "localhost"},
+ {":path", "style.css"}
+ ],
+ unsafe_method: [
+ {":method", "POST"},
+ {":scheme", "https"},
+ {":authority", "localhost"},
+ {":path", "/"}
+ ],
+ duplicate_method: [
+ {":method", "GET"},
+ {":method", "GET"},
+ {":scheme", "https"},
+ {":authority", "localhost"},
+ {":path", "/"}
+ ],
+ uppercase_name: [
+ {":method", "GET"},
+ {":scheme", "https"},
+ {":authority", "localhost"},
+ {":path", "/"},
+ {"Foo", "bar"}
+ ],
+ control_in_value: [
+ {":method", "GET"},
+ {":scheme", "https"},
+ {":authority", "localhost"},
+ {":path", "/"},
+ {"foo", "a\nb"}
+ ],
+ connection_header: [
+ {":method", "GET"},
+ {":scheme", "https"},
+ {":authority", "localhost"},
+ {":path", "/"},
+ {"connection", "keep-alive"}
+ ],
+ te_other_than_trailers: [
+ {":method", "GET"},
+ {":scheme", "https"},
+ {":authority", "localhost"},
+ {":path", "/"},
+ {"te", "gzip"}
+ ],
+ content: [
+ {":method", "GET"},
+ {":scheme", "https"},
+ {":authority", "localhost"},
+ {":path", "/"},
+ {"content-length", "1"}
+ ],
+ invalid_content_length: [
+ {":method", "GET"},
+ {":scheme", "https"},
+ {":authority", "localhost"},
+ {":path", "/"},
+ {"content-length", "zero"}
+ ]
+ ] do
+ test "a PUSH_PROMISE with #{variant} in the promised request resets the promised stream",
+ %{conn: conn} do
+ {conn, _ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, []} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: server_encode_headers(unquote(fields)),
+ promised_stream_id: 2,
+ flags: set_flags(:push_promise, [:end_headers])
+ )
+ ])
+
+ assert_recv_frames [rst_stream(stream_id: 2, error_code: :protocol_error)]
+ refute Map.has_key?(conn.streams, 2)
+ assert HTTP2.open?(conn)
+ end
+ end
+
+ for {variant, fields} <- [
+ te_trailers: [{"te", "trailers"}],
+ mixed_case_te_trailers: [{"te", "Trailers"}],
+ zero_content_length: [{"content-length", "00"}]
+ ] do
+ test "a PUSH_PROMISE with #{variant} in the promised request is accepted",
+ %{conn: conn} do
+ {conn, ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ promised_headers = promised_headers() ++ unquote(fields)
+
+ assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, _promised_ref, ^promised_headers}]} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: server_encode_headers(promised_headers),
+ promised_stream_id: 2,
+ flags: set_flags(:push_promise, [:end_headers])
+ )
+ ])
+
+ refute_receive {:ssl, _socket, _data}, 100
+ assert HTTP2.open?(conn)
+ end
+ end
+
+ for {name, promised_stream_id} <- [
+ {"zero", 0},
+ {"odd", 3},
+ {"not greater than the previous promised ID", 2}
+ ] do
+ test "a PUSH_PROMISE with a #{name} promised stream ID is a connection error",
+ %{conn: conn} do
+ {conn, _ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, [{:push_promise, _, _, _}]} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: server_encode_headers(promised_headers()),
+ promised_stream_id: 4,
+ flags: set_flags(:push_promise, [:end_headers])
+ )
+ ])
+
+ assert {:error, %HTTP2{} = conn, error, []} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: server_encode_headers(promised_headers()),
+ promised_stream_id: unquote(promised_stream_id),
+ flags: set_flags(:push_promise, [:end_headers])
+ )
+ ])
+
+ assert_http2_error error, {:protocol_error, debug_data}
+ assert debug_data =~ "promised stream ID"
+ assert_recv_frames [goaway(last_stream_id: 4, error_code: :protocol_error)]
+ refute HTTP2.open?(conn)
+ end
+ end
+
+ test "a PUSH_PROMISE on a server-initiated stream is a connection error", %{conn: conn} do
+ {conn, _ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, [{:push_promise, _, promised_ref, _}]} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: server_encode_headers(promised_headers()),
+ promised_stream_id: 2,
+ flags: set_flags(:push_promise, [:end_headers])
+ )
+ ])
+
+ assert {:ok, %HTTP2{} = conn,
+ [{:status, ^promised_ref, 200}, {:headers, ^promised_ref, []}]} =
+ stream_frames(conn, [{:headers, 2, [{":status", "200"}], [:end_headers]}])
+
+ assert {:error, %HTTP2{} = conn, error, []} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: 2,
+ hbf: server_encode_headers(promised_headers()),
+ promised_stream_id: 4,
+ flags: set_flags(:push_promise, [:end_headers])
+ )
+ ])
+
+ assert_http2_error error, {:protocol_error, debug_data}
+ assert debug_data =~ "PUSH_PROMISE frame on server-initiated stream 2"
+ refute HTTP2.open?(conn)
+ end
+
+ test "a pushed response in flight is not affected by a GOAWAY", %{conn: conn} do
+ {conn, ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, promised_ref, _}]} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: server_encode_headers(promised_headers()),
+ promised_stream_id: 4,
+ flags: set_flags(:push_promise, [:end_headers])
+ )
+ ])
+
+ assert {:ok, %HTTP2{} = conn,
+ [{:status, ^promised_ref, 200}, {:headers, ^promised_ref, []}]} =
+ stream_frames(conn, [{:headers, 4, [{":status", "200"}], [:end_headers]}])
+
+ assert {:ok, %HTTP2{} = conn, []} =
+ stream_frames(conn, [
+ goaway(last_stream_id: stream_id, error_code: :no_error, debug_data: "")
+ ])
+
+ assert {:ok, %HTTP2{}, [{:data, ^promised_ref, "body"}, {:done, ^promised_ref}]} =
+ stream_frames(conn, [
+ data(stream_id: 4, data: "body", flags: set_flags(:data, [:end_stream]))
+ ])
+ end
+
+ test "a PUSH_PROMISE on a stream the client cancelled resets the promised stream",
+ %{conn: conn} do
+ {conn, ref} = open_request(conn)
+ {:ok, conn} = HTTP2.cancel_request(conn, ref)
+
+ assert_recv_frames [
+ headers(stream_id: stream_id),
+ rst_stream(stream_id: stream_id, error_code: :cancel)
+ ]
+
+ hbf = server_encode_headers([{":method", "GET"}, {"x-promised", "value"}])
+
+ assert {:ok, %HTTP2{} = conn, []} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: hbf,
+ promised_stream_id: 2,
+ flags: set_flags(:push_promise, [:end_headers])
+ ),
+ ping(opaque_data: <<0::64>>)
+ ])
+
+ assert HTTP2.open?(conn)
+
+ assert_recv_frames [
+ rst_stream(stream_id: 2, error_code: :cancel),
+ ping(opaque_data: <<0::64>>)
+ ]
+
+ # The header block was decoded, so the HPACK table is still in sync.
+ {conn, ref} = open_request(conn)
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{}, responses} =
+ stream_frames(conn, [
+ {:headers, stream_id, [{":status", "200"}, {"x-promised", "value"}],
+ [:end_headers, :end_stream]}
+ ])
+
+ assert [{:status, ^ref, 200}, {:headers, ^ref, [{"x-promised", "value"}]}, {:done, ^ref}] =
+ responses
+ end
+
+ test "a PUSH_PROMISE with CONTINUATIONs on a stream the client cancelled resets the promised stream",
+ %{conn: conn} do
+ {conn, ref} = open_request(conn)
+ {:ok, conn} = HTTP2.cancel_request(conn, ref)
+
+ assert_recv_frames [
+ headers(stream_id: stream_id),
+ rst_stream(stream_id: stream_id, error_code: :cancel)
+ ]
+
+ <> = server_encode_headers([{":method", "GET"}, {"a", "b"}])
+
+ assert {:ok, %HTTP2{} = conn, []} =
+ stream_frames(conn, [
+ push_promise(stream_id: stream_id, hbf: hbf1, promised_stream_id: 2),
+ continuation(
+ stream_id: stream_id,
+ hbf: hbf2,
+ flags: set_flags(:continuation, [:end_headers])
+ )
+ ])
+
+ assert HTTP2.open?(conn)
+ assert_recv_frames [rst_stream(stream_id: 2, error_code: :cancel)]
+ end
+
test "a PUSH_PROMISE frame and a few CONTINUATION frames are received",
%{conn: conn} do
promised_stream_id = 4
@@ -1958,7 +2472,7 @@ defmodule Mint.HTTP2Test do
assert_recv_frames [headers(stream_id: stream_id)]
# Promised headers.
- headers = [{":method", "GET"}, {"foo", "bar"}, {"baz", "bong"}]
+ headers = promised_headers() ++ [{"foo", "bar"}, {"baz", "bong"}]
<> = server_encode_headers(headers)
@@ -1993,7 +2507,7 @@ defmodule Mint.HTTP2Test do
] = responses
assert is_reference(promised_ref)
- assert headers == [{":method", "GET"}, {"foo", "bar"}, {"baz", "bong"}]
+ assert headers == promised_headers() ++ [{"foo", "bar"}, {"baz", "bong"}]
assert {:ok, %HTTP2{} = conn, responses} =
stream_frames(conn, [
@@ -2026,7 +2540,7 @@ defmodule Mint.HTTP2Test do
assert_recv_frames [headers(stream_id: stream_id)]
- hbf = server_encode_headers([{":method", "GET"}])
+ hbf = server_encode_headers(promised_headers())
assert {:error, %HTTP2{} = conn, error, []} =
stream_frames(conn, [
@@ -2051,7 +2565,7 @@ defmodule Mint.HTTP2Test do
assert_recv_frames [headers(stream_id: stream_id)]
- promised_headers_hbf = server_encode_headers([{":method", "GET"}])
+ promised_headers_hbf = server_encode_headers(promised_headers())
normal_headers_hbf = server_encode_headers([{":status", "200"}])
assert {:error, %HTTP2{} = conn, error, _responses} =
@@ -2076,7 +2590,9 @@ defmodule Mint.HTTP2Test do
])
assert_http2_error error, {:protocol_error, debug_data}
- assert debug_data =~ "stream with ID 4 already exists and can't be reserved by the server"
+
+ assert debug_data =~
+ "promised stream ID 4 is not greater than the last promised stream ID 4"
refute HTTP2.open?(conn)
end
@@ -2088,7 +2604,7 @@ defmodule Mint.HTTP2Test do
assert_recv_frames [headers(stream_id: stream_id)]
- promised_headers_hbf = server_encode_headers([{":method", "GET"}])
+ promised_headers_hbf = server_encode_headers(promised_headers())
normal_headers_hbf = server_encode_headers([{":status", "200"}])
assert {:ok, %HTTP2{} = conn, responses} =
@@ -2130,6 +2646,40 @@ defmodule Mint.HTTP2Test do
assert HTTP2.open?(conn)
end
+ for {variant, status} <- [final: "200", interim: "103"] do
+ test "a promised stream refused at #{variant} HEADERS time returns an error",
+ %{conn: conn} do
+ {conn, ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, promised_ref, _}]} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: server_encode_headers(promised_headers()),
+ promised_stream_id: 2,
+ flags: set_flags(:push_promise, [:end_headers])
+ )
+ ])
+
+ {:ok, conn} = HTTP2.put_settings(conn, max_concurrent_streams: 0)
+ assert_recv_frames [settings()]
+
+ assert {:ok, %HTTP2{} = conn, responses} =
+ stream_frames(conn, [
+ settings(flags: set_flags(:settings, [:ack]), params: []),
+ {:headers, 2, [{":status", unquote(status)}], [:end_headers]}
+ ])
+
+ assert [{:error, ^promised_ref, error}] = responses
+ assert_http2_error error, :too_many_concurrent_requests
+ assert_recv_frames [rst_stream(stream_id: 2, error_code: :refused_stream)]
+ refute Map.has_key?(conn.streams, 2)
+ assert HTTP2.open?(conn)
+ end
+ end
+
@tag connect_options: [client_settings: [max_concurrent_streams: 5]]
test "a flood of PUSH_PROMISE frames cannot grow the streams map past max_concurrent_streams",
%{conn: conn} do
@@ -2137,7 +2687,7 @@ defmodule Mint.HTTP2Test do
assert_recv_frames [headers(stream_id: stream_id)]
- promised_headers_hbf = server_encode_headers([{":method", "GET"}])
+ promised_headers_hbf = server_encode_headers(promised_headers())
# The server promises many more streams than the client's limit but never
# follows up with the response HEADERS for any of them. Each promise must
@@ -2179,6 +2729,28 @@ defmodule Mint.HTTP2Test do
end
describe "misbehaving server" do
+ test "the GOAWAY sent on a connection error carries the last promised stream ID",
+ %{conn: conn} do
+ {conn, _ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, [{:push_promise, _, _, _}]} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: server_encode_headers(promised_headers()),
+ promised_stream_id: 6,
+ flags: set_flags(:push_promise, [:end_headers])
+ )
+ ])
+
+ data = IO.iodata_to_binary(encode_raw(_ping = 0x06, 0x00, 3, <<0::64>>))
+ assert {:error, %HTTP2{}, _error, []} = HTTP2.stream(conn, {:ssl, conn.socket, data})
+
+ assert_recv_frames [goaway(last_stream_id: 6, error_code: :protocol_error)]
+ end
+
test "an extension frame in the middle of a header block is a connection error",
%{conn: conn} do
{conn, _ref} = open_request(conn)
@@ -2210,6 +2782,29 @@ defmodule Mint.HTTP2Test do
refute HTTP2.open?(conn)
end
+ for {frame_name, frame} <- [
+ headers: quote(do: {:headers, 2, [{":status", "200"}], [:end_headers]}),
+ data: quote(do: data(stream_id: 2, data: "some data")),
+ rst_stream: quote(do: rst_stream(stream_id: 2, error_code: :cancel)),
+ window_update: quote(do: window_update(stream_id: 2, window_size_increment: 1))
+ ] do
+ test "a #{frame_name} frame on a server stream that was never promised is a connection error",
+ %{conn: conn} do
+ {conn, _ref} = open_request(conn)
+
+ assert_recv_frames [headers(stream_id: _stream_id)]
+
+ assert {:error, %HTTP2{} = conn, error, []} = stream_frames(conn, [unquote(frame)])
+
+ assert_http2_error error, {:protocol_error, debug_data}
+ assert debug_data =~ "frame with stream ID 2 has not been opened yet"
+
+ assert_recv_frames [goaway(error_code: :protocol_error)]
+
+ refute HTTP2.open?(conn)
+ end
+ end
+
test "PRIORITY frames on idle streams are ignored", %{conn: conn} do
{conn, _ref} = open_request(conn)
@@ -3430,6 +4025,62 @@ defmodule Mint.HTTP2Test do
assert server_decode_headers(trailer_hbf1 <> trailer_hbf2) == trailer_headers
end
+ test "trailers keep the open request count", %{conn: conn} do
+ {conn, ref} = open_request(conn, :stream)
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn} =
+ HTTP2.stream_request_body(conn, ref, {:eof, [{"x-trailer", "value"}]})
+
+ assert_recv_frames [headers(stream_id: ^stream_id)]
+ assert HTTP2.open_request_count(conn) == 1
+
+ assert {:ok, %HTTP2{} = conn, [{:status, ^ref, 200}, {:headers, ^ref, []}, {:done, ^ref}]} =
+ stream_frames(conn, [
+ {:headers, stream_id, [{":status", "200"}], [:end_headers, :end_stream]}
+ ])
+
+ assert HTTP2.open_request_count(conn) == 0
+ end
+
+ test "trailers on a request whose body has ended return an error", %{conn: conn} do
+ {conn, ref} = open_request(conn)
+ assert_recv_frames [headers(stream_id: _stream_id)]
+
+ assert {:error, %HTTP2{} = conn, error} =
+ HTTP2.stream_request_body(conn, ref, {:eof, [{"x-trailer", "value"}]})
+
+ assert_http2_error error, :request_is_not_streaming
+ refute_receive {:ssl, _socket, _data}, 100
+ assert HTTP2.open_request_count(conn) == 1
+ end
+
+ test "trailers on a promised request return an error", %{conn: conn} do
+ {conn, ref} = open_request(conn)
+ assert_recv_frames [headers(stream_id: stream_id)]
+
+ assert {:ok, %HTTP2{} = conn, [{:push_promise, ^ref, promised_ref, _}]} =
+ stream_frames(conn, [
+ push_promise(
+ stream_id: stream_id,
+ hbf: server_encode_headers(promised_headers()),
+ promised_stream_id: 2,
+ flags: set_flags(:push_promise, [:end_headers])
+ )
+ ])
+
+ assert {:error, %HTTP2{} = conn, error} =
+ HTTP2.stream_request_body(conn, promised_ref, {:eof, [{"x-trailer", "value"}]})
+
+ assert_http2_error error, :request_is_not_streaming
+ refute_receive {:ssl, _socket, _data}, 100
+
+ assert {conn.open_client_stream_count, conn.open_server_stream_count,
+ conn.reserved_server_stream_count} == {1, 0, 1}
+
+ assert conn.streams[2].state == :reserved_remote
+ end
+
test "unallowed trailer headers cause an error", %{conn: conn} do
{conn, ref} = open_request(conn, :stream)
@@ -3903,6 +4554,10 @@ defmodule Mint.HTTP2Test do
headers
end
+ defp promised_headers do
+ [{":method", "GET"}, {":scheme", "https"}, {":authority", "localhost"}, {":path", "/"}]
+ end
+
defp open_request(conn, body \\ nil) do
assert {:ok, %HTTP2{} = conn, ref} = HTTP2.request(conn, "GET", "/", [], body)
assert is_reference(ref)