diff --git a/.dialyzer_ignore b/.dialyzer_ignore index 00387569..dc446208 100644 --- a/.dialyzer_ignore +++ b/.dialyzer_ignore @@ -1,5 +1,5 @@ lib/mint/tunnel_proxy.ex:50 -lib/mint/http1.ex:1069 +lib/mint/http1.ex:1104 lib/mint/unsafe_proxy.ex:173 lib/mint/unsafe_proxy.ex:198 test/support diff --git a/lib/mint/http1.ex b/lib/mint/http1.ex index 96ca7c32..aa5790d7 100644 --- a/lib/mint/http1.ex +++ b/lib/mint/http1.ex @@ -43,6 +43,19 @@ defmodule Mint.HTTP1 do * `:request_body_is_streaming` - when you call `request/5` to send a new request but another request is already streaming. + * `:request_is_not_streaming` - when you call `stream_request_body/3` for a + request whose body is not being streamed, for example after sending `:eof`. + + * `:unknown_request_to_stream` - when you call `stream_request_body/3` with a + request reference that doesn't belong to this connection. + + * `:unprocessed` - when a pipelined request gets no response because the server + answered a previous request with a `connection: close` header. The server didn't + process the request, so it can be retried on a new connection. Requests pipelined + behind a response that closes the connection in other ways get a + `Mint.TransportError` with reason `:closed` instead, since the server might have + processed them. + * `{:unexpected_data, data}` - when unexpected data is received from the server. * `:invalid_status_line` - when the HTTP/1 status line is invalid. @@ -350,17 +363,19 @@ defmodule Mint.HTTP1 do ), :ok <- transport.send(socket, iodata) do request_ref = make_ref() - request = new_request(request_ref, method, body, encoding) - - case request.state do - {:stream_request, _} -> - conn = %{conn | streaming_request: request} - {:ok, conn, request_ref} + conn = enqueue_request(conn, new_request(request_ref, method)) + + # The request is enqueued right away so that a response the server + # sends before the body is complete (such as 100 Continue or an early + # 413) is parsed rather than treated as unexpected data. + conn = + if body == :stream do + %{conn | streaming_request: %{ref: request_ref, encoding: encoding}} + else + conn + end - _ -> - conn = enqueue_request(conn, request) - {:ok, conn, request_ref} - end + {:ok, conn, request_ref} else {:error, %TransportError{reason: :closed} = error} -> conn = internal_close(conn) @@ -405,18 +420,20 @@ defmodule Mint.HTTP1 do iodata() | :eof | {:eof, trailer_headers :: Types.headers()} ) :: {:ok, t()} | {:error, t(), Types.error()} + def stream_request_body(%__MODULE__{state: :closed} = conn, _request_ref, _chunk) do + {:error, conn, wrap_error(:closed)} + end + def stream_request_body( - %__MODULE__{streaming_request: %{state: {:stream_request, :identity}, ref: ref}} = conn, + %__MODULE__{streaming_request: %{encoding: :identity, ref: ref}} = conn, ref, :eof ) do - request = %{conn.streaming_request | state: :status} - conn = enqueue_request(%{conn | streaming_request: nil}, request) - {:ok, conn} + {:ok, %{conn | streaming_request: nil}} end def stream_request_body( - %__MODULE__{streaming_request: %{state: {:stream_request, :identity}, ref: ref}} = conn, + %__MODULE__{streaming_request: %{encoding: :identity, ref: ref}} = conn, ref, {:eof, _trailer_headers} ) do @@ -424,7 +441,7 @@ defmodule Mint.HTTP1 do end def stream_request_body( - %__MODULE__{streaming_request: %{state: {:stream_request, :identity}, ref: ref}} = conn, + %__MODULE__{streaming_request: %{encoding: :identity, ref: ref}} = conn, ref, body ) do @@ -442,25 +459,16 @@ defmodule Mint.HTTP1 do end def stream_request_body( - %__MODULE__{streaming_request: %{state: {:stream_request, :chunked}, ref: ref}} = conn, + %__MODULE__{streaming_request: %{encoding: :chunked, ref: ref}} = conn, ref, chunk ) do with {:ok, chunk} <- validate_chunk(conn, chunk), :ok <- conn.transport.send(conn.socket, Request.encode_chunk(chunk)) do case chunk do - :eof -> - request = %{conn.streaming_request | state: :status} - conn = enqueue_request(%{conn | streaming_request: nil}, request) - {:ok, conn} - - {:eof, _trailer_headers} -> - request = %{conn.streaming_request | state: :status} - conn = enqueue_request(%{conn | streaming_request: nil}, request) - {:ok, conn} - - _other -> - {:ok, conn} + :eof -> {:ok, %{conn | streaming_request: nil}} + {:eof, _trailer_headers} -> {:ok, %{conn | streaming_request: nil}} + _other -> {:ok, conn} end else :empty_chunk -> @@ -475,6 +483,19 @@ defmodule Mint.HTTP1 do end end + def stream_request_body(%__MODULE__{} = conn, request_ref, _chunk) + when is_reference(request_ref) do + known? = + (conn.request != nil and conn.request.ref == request_ref) or + Enum.any?(:queue.to_list(conn.requests), &(&1.ref == request_ref)) + + if known? do + {:error, conn, wrap_error(:request_is_not_streaming)} + else + {:error, conn, wrap_error(:unknown_request_to_stream)} + end + end + defp validate_chunk(conn, {:eof, trailers}) do trailers = Headers.from_raw(trailers) @@ -554,16 +575,16 @@ defmodule Mint.HTTP1 do end end - defp handle_close(%__MODULE__{request: request} = conn) do - conn = internal_close(conn) - conn = request_done(conn) + defp handle_close(%__MODULE__{request: %{body: :until_closed} = request} = conn) do + conn = pop_request(conn) + responses = [{:done, request.ref}] + {conn, responses} = close_after_response(conn, responses, conn.transport.wrap_error(:closed)) + {:ok, conn, Enum.reverse(responses)} + end - if request && request.body == :until_closed do - conn = put_in(conn.state, :closed) - {:ok, conn, [{:done, request.ref}]} - else - {:error, conn, conn.transport.wrap_error(:closed), []} - end + defp handle_close(conn) do + conn = conn |> internal_close() |> pop_request() + {:error, conn, conn.transport.wrap_error(:closed), []} end defp handle_transport_error(conn, error) do @@ -635,10 +656,8 @@ defmodule Mint.HTTP1 do @spec open_request_count(t()) :: non_neg_integer() def open_request_count(%__MODULE__{} = conn) do case conn do - %{request: nil, streaming_request: nil} -> 0 - %{request: nil} -> 1 - %{streaming_request: nil} -> 1 + :queue.len(conn.requests) - _ -> 2 + :queue.len(conn.requests) + %{request: nil} -> 0 + _ -> 1 + :queue.len(conn.requests) end end @@ -815,11 +834,23 @@ defmodule Mint.HTTP1 do end end - defp decode_body(:none, conn, data, request_ref, responses) do - conn = put_in(conn.buffer, data) - conn = request_done(conn) - responses = [{:done, request_ref} | responses] - {:ok, conn, responses} + # A successful CONNECT switches the connection to tunnel mode, so bytes after + # the header section belong to the tunnel rather than to another response. + defp decode_body( + :none, + %{request: %{method: "CONNECT", status: status}} = conn, + data, + _request_ref, + responses + ) + when status in 200..299 do + {conn, responses} = request_done(conn, responses) + {:ok, %{conn | buffer: data}, responses} + end + + defp decode_body(:none, conn, data, _request_ref, responses) do + {conn, responses} = request_done(conn, responses) + next_request(conn, data, responses) end # Informational (1xx) responses have no body and must not finalize the @@ -844,10 +875,9 @@ defmodule Mint.HTTP1 do decode(:status, conn, data, responses) end - defp decode_body(:single, conn, data, request_ref, responses) do + defp decode_body(:single, conn, data, _request_ref, responses) do {conn, responses} = add_body(conn, data, responses) - conn = request_done(conn) - responses = [{:done, request_ref} | responses] + {conn, responses} = request_done(conn, responses) {:ok, conn, responses} end @@ -856,7 +886,7 @@ defmodule Mint.HTTP1 do {:ok, conn, responses} end - defp decode_body({:content_length, length}, conn, data, request_ref, responses) do + defp decode_body({:content_length, length}, conn, data, _request_ref, responses) do cond do length > byte_size(data) -> conn = put_in(conn.request.body, {:content_length, length - byte_size(data)}) @@ -866,8 +896,7 @@ defmodule Mint.HTTP1 do length <= byte_size(data) -> {body, rest} = :erlang.split_binary(data, length) {conn, responses} = add_body(conn, body, responses) - conn = request_done(conn) - responses = [{:done, request_ref} | responses] + {conn, responses} = request_done(conn, responses) next_request(conn, rest, responses) end end @@ -966,12 +995,8 @@ defmodule Mint.HTTP1 do {:ok, _request} -> headers = Headers.remove_unallowed_trailer(headers) - responses = [ - {:done, conn.request.ref} - | add_trailer_headers(headers, conn.request.ref, responses) - ] - - conn = request_done(conn) + responses = add_trailer_headers(headers, conn.request.ref, responses) + {conn, responses} = request_done(conn, responses) next_request(conn, rest, responses) {:error, reason} -> @@ -1033,10 +1058,20 @@ defmodule Mint.HTTP1 do end end + # A response that closes the connection is the last one the server sends on + # it, so anything after it can't be a response to a queued request. + defp next_request(%{state: :closed} = conn, _data, responses) do + {:ok, %{conn | buffer: ""}, responses} + end + + defp next_request(%{request: nil} = conn, "", responses) do + {:ok, %{conn | buffer: ""}, responses} + end + + # Bytes left over after the last in-flight response would otherwise be + # delivered as the response to whichever request is issued next. defp next_request(%{request: nil} = conn, data, responses) do - # TODO: Figure out if we should keep buffering even though there are no - # requests in flight - {:ok, %{conn | buffer: data}, responses} + {:error, conn, wrap_error({:unexpected_data, data}), responses} end defp next_request(conn, data, responses) do @@ -1137,23 +1172,46 @@ defmodule Mint.HTTP1 do # lifetime of the underlying socket. In particular, HTTP/1.0 responses are # otherwise treated as non-persistent and would close the newly-established # tunnel before the caller can use it. - defp request_done(%{request: %{method: "CONNECT", status: status}} = conn) + defp request_done(%{request: %{method: "CONNECT", status: status} = request} = conn, responses) when status in 200..299 do - pop_request(conn) + {pop_request(conn), [{:done, request.ref} | responses]} end - defp request_done(%{request: request} = conn) do + defp request_done(%{request: request} = conn, responses) do conn = pop_request(conn) + responses = [{:done, request.ref} | responses] cond do - !request -> conn - "close" in request.connection -> internal_close(conn) - request.version >= {1, 1} -> conn - "keep-alive" in request.connection -> conn - true -> internal_close(conn) + # The server doesn't process any requests after one it answers with + # "Connection: close" (RFC 9112 section 9.6), so the queued requests can + # be retried. + "close" in request.connection -> + close_after_response(conn, responses, wrap_error(:unprocessed)) + + request.version >= {1, 1} -> + {conn, responses} + + "keep-alive" in request.connection -> + {conn, responses} + + true -> + close_after_response(conn, responses, conn.transport.wrap_error(:closed)) end end + # Requests pipelined behind a response that closes the connection never get a + # response of their own. + defp close_after_response(conn, responses, error) do + requests = if conn.request, do: [conn.request | :queue.to_list(conn.requests)], else: [] + + responses = + Enum.reduce(requests, responses, fn request, responses -> + [{:error, request.ref, error} | responses] + end) + + {internal_close(%{conn | request: nil, requests: :queue.new()}), responses} + end + defp pop_request(conn) do case :queue.out(conn.requests) do {{:value, request}, requests} -> @@ -1254,17 +1312,10 @@ defmodule Mint.HTTP1 do :ok end - defp new_request(ref, method, body, encoding) do - state = - if body == :stream do - {:stream_request, encoding} - else - :status - end - + defp new_request(ref, method) do %{ ref: ref, - state: state, + state: :status, method: method, version: nil, status: nil, @@ -1344,10 +1395,23 @@ defmodule Mint.HTTP1 do "the connection is closed" end + def format_error(:unprocessed) do + "request was not processed because the server answered a previous request with " <> + "\"connection: close\", so it's safe to retry on a new connection" + end + def format_error(:request_body_is_streaming) do "a request body is currently streaming, so no new requests can be issued" end + def format_error(:request_is_not_streaming) do + "can't send more data on a request that is not streaming its body" + end + + def format_error(:unknown_request_to_stream) do + "can't stream the request body because the request is not known to this connection" + end + def format_error({:unexpected_data, data}) do "received unexpected data: " <> inspect(data) end diff --git a/test/mint/http1/conn_test.exs b/test/mint/http1/conn_test.exs index 975c1a14..376378e4 100644 --- a/test/mint/http1/conn_test.exs +++ b/test/mint/http1/conn_test.exs @@ -141,9 +141,9 @@ defmodule Mint.HTTP1Test do HTTP1.stream(conn, {:tcp, conn.socket, "012345678"}) assert {:ok, conn, [{:data, ^ref, "9"}, {:done, ^ref}]} = - HTTP1.stream(conn, {:tcp, conn.socket, "9XXX"}) + HTTP1.stream(conn, {:tcp, conn.socket, "9"}) - assert conn.buffer == "XXX" + assert conn.buffer == "" assert HTTP1.open?(conn) end @@ -151,9 +151,9 @@ defmodule Mint.HTTP1Test do {:ok, conn, ref} = HTTP1.request(conn, "HEAD", "/", [], nil) assert {:ok, conn, [_status, _headers, {:done, ^ref}]} = - HTTP1.stream(conn, {:tcp, conn.socket, "HTTP/1.1 200 OK\r\n\r\nXXX"}) + HTTP1.stream(conn, {:tcp, conn.socket, "HTTP/1.1 200 OK\r\n\r\n"}) - assert conn.buffer == "XXX" + assert conn.buffer == "" end test "no body in 2xx response to CONNECT request", %{conn: conn} do @@ -217,6 +217,31 @@ defmodule Mint.HTTP1Test do assert_closed_and_released(conn) end + test "data after the last in-flight response is an error", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + response = "HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\nX" + extra = "HTTP/1.1 404 Not Found\r\ncontent-length: 1\r\n\r\nY" + + assert {:error, conn, %HTTPError{reason: {:unexpected_data, ^extra}}, responses} = + HTTP1.stream(conn, {:tcp, conn.socket, response <> extra}) + + assert [{:status, ^ref, 200}, {:headers, ^ref, _}, {:data, ^ref, "X"}, {:done, ^ref}] = + responses + + assert_closed_and_released(conn) + end + + test "data after the last in-flight bodiless response is an error", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "HEAD", "/", [], nil) + response = "HTTP/1.1 200 OK\r\ncontent-length: 1\r\n\r\n" + + assert {:error, conn, %HTTPError{reason: {:unexpected_data, "X"}}, responses} = + HTTP1.stream(conn, {:tcp, conn.socket, response <> "X"}) + + assert [{:status, ^ref, 200}, {:headers, ^ref, _}, {:done, ^ref}] = responses + assert_closed_and_released(conn) + end + test "responses before an error are returned in order", %{conn: conn} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) response = "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n5\r\nhello\r\nXX" @@ -373,12 +398,40 @@ defmodule Mint.HTTP1Test do refute HTTP1.open?(conn) end + test "pipelined response after a bodiless response in the same message", %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "HEAD", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref3} = HTTP1.request(conn, "GET", "/", [], nil) + + responses = + "HTTP/1.1 200 OK\r\ncontent-length: 5\r\n\r\n" <> + "HTTP/1.1 204 No Content\r\n\r\n" <> + "HTTP/1.1 200 OK\r\ncontent-length: 5\r\n\r\nXXXXX" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, responses}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:done, ^ref1}, + {:status, ^ref2, 204}, + {:headers, ^ref2, []}, + {:done, ^ref2}, + {:status, ^ref3, 200}, + {:headers, ^ref3, _}, + {:data, ^ref3, "XXXXX"}, + {:done, ^ref3} + ] = responses + + assert conn.buffer == "" + end + test "body with chunked transfer-encoding", %{conn: conn} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) response = "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n" <> - "2\r\n01\r\n2\r\n23\r\n0\r\n\r\nXXX" + "2\r\n01\r\n2\r\n23\r\n0\r\n\r\n" assert {:ok, conn, [status, headers, data1, data2, done]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) @@ -389,7 +442,7 @@ defmodule Mint.HTTP1Test do assert data2 == {:data, ref, "23"} assert done == {:done, ref} - assert conn.buffer == "XXX" + assert conn.buffer == "" end for chunk_size <- ["+5", "+0", "-0"] do @@ -580,7 +633,7 @@ defmodule Mint.HTTP1Test do response = "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n" <> - "2;meta\r\n01\r\n2\r\n23\r\n0;meta\r\nmy-trailer: value\r\n\r\nXXX" + "2;meta\r\n01\r\n2\r\n23\r\n0;meta\r\nmy-trailer: value\r\n\r\n" assert {:ok, conn, [status, headers, data1, data2, trailers, done]} = HTTP1.stream(conn, {:tcp, conn.socket, response}) @@ -592,7 +645,7 @@ defmodule Mint.HTTP1Test do assert trailers == {:headers, ref, [{"my-trailer", "value"}]} assert done == {:done, ref} - assert conn.buffer == "XXX" + assert conn.buffer == "" end test "limits the size of a chunked trailer section", %{port: port} do @@ -1216,6 +1269,79 @@ defmodule Mint.HTTP1Test do end describe "streaming requests" do + test "streaming a body for a request that is not streaming returns an error", + %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], nil) + + assert {:error, conn, %HTTPError{reason: :request_is_not_streaming}} = + HTTP1.stream_request_body(conn, ref, "hello") + + assert {:error, conn, %HTTPError{reason: :unknown_request_to_stream}} = + HTTP1.stream_request_body(conn, make_ref(), "hello") + + assert HTTP1.open?(conn) + end + + test "streaming a body after :eof returns an error", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "POST", "/", [], :stream) + {:ok, conn} = HTTP1.stream_request_body(conn, ref, :eof) + + assert {:error, conn, %HTTPError{reason: :request_is_not_streaming}} = + HTTP1.stream_request_body(conn, ref, "hello") + + assert HTTP1.open?(conn) + end + + test "streaming a body on a closed connection returns an error", %{conn: conn} do + {:ok, conn, ref} = HTTP1.request(conn, "POST", "/", [], :stream) + {:ok, conn} = HTTP1.close(conn) + + assert {:error, _conn, %HTTPError{reason: :closed}} = + HTTP1.stream_request_body(conn, ref, "hello") + end + + test "response arriving before the request body is complete", + %{conn: conn, server_socket: server_socket} do + {:ok, conn, ref} = HTTP1.request(conn, "POST", "/", [{"content-length", "10"}], :stream) + _ = receive_request_string(server_socket) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, "hello") + assert receive_request_string(server_socket) == "hello" + + response = "HTTP/1.1 413 Payload Too Large\r\ncontent-length: 0\r\n\r\n" + + assert {:ok, conn, [{:status, ^ref, 413}, {:headers, ^ref, _}, {:done, ^ref}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert HTTP1.open?(conn) + assert HTTP1.open_request_count(conn) == 0 + + assert {:error, conn, %HTTPError{reason: :request_body_is_streaming}} = + HTTP1.request(conn, "GET", "/", [], nil) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, :eof) + assert {:ok, _conn, _ref} = HTTP1.request(conn, "GET", "/", [], nil) + end + + test "100 Continue before the request body is streamed", + %{conn: conn, server_socket: server_socket} do + headers = [{"expect", "100-continue"}, {"content-length", "5"}] + {:ok, conn, ref} = HTTP1.request(conn, "POST", "/", headers, :stream) + _ = receive_request_string(server_socket) + + assert {:ok, conn, [{:status, ^ref, 100}, {:headers, ^ref, []}]} = + HTTP1.stream(conn, {:tcp, conn.socket, "HTTP/1.1 100 Continue\r\n\r\n"}) + + {:ok, conn} = HTTP1.stream_request_body(conn, ref, "hello") + assert receive_request_string(server_socket) == "hello" + {:ok, conn} = HTTP1.stream_request_body(conn, ref, :eof) + + response = "HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n" + + assert {:ok, _conn, [{:status, ^ref, 200}, {:headers, ^ref, _}, {:done, ^ref}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + end + test "transfer-encoding is set to chunked if not set already, and content is chunked", %{conn: conn, server_socket: server_socket, port: port} do {:ok, conn, ref} = HTTP1.request(conn, "GET", "/", [], :stream) @@ -1450,6 +1576,115 @@ defmodule Mint.HTTP1Test do end end + test "pipelined requests behind a Connection: close response get an :unprocessed error", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref3} = HTTP1.request(conn, "GET", "/", [], nil) + + response = "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nhi" + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, [{"connection", "close"}, {"content-length", "2"}]}, + {:data, ^ref1, "hi"}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :unprocessed}}, + {:error, ^ref3, %HTTPError{reason: :unprocessed}} + ] = responses + + assert HTTP1.open_request_count(conn) == 0 + assert_closed_and_released(conn) + end + + test "bytes after a bodiless response that closes the connection are not parsed", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "HEAD", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n" <> + "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nhi" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, [{"connection", "close"}]}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :unprocessed}} + ] = responses + + assert conn.buffer == "" + assert_closed_and_released(conn) + end + + test "bytes after a response body that closes the connection are not parsed", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = + "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 2\r\n\r\nhi" <> + "HTTP/1.1 200 OK\r\n" + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:data, ^ref1, "hi"}, + {:done, ^ref1}, + {:error, ^ref2, %HTTPError{reason: :unprocessed}} + ] = responses + + assert conn.buffer == "" + assert_closed_and_released(conn) + end + + test "pipelined requests behind an HTTP/1.0 response get a :closed error", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + + response = "HTTP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nhi" + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert [ + {:status, ^ref1, 200}, + {:headers, ^ref1, _}, + {:data, ^ref1, "hi"}, + {:done, ^ref1}, + {:error, ^ref2, %TransportError{reason: :closed}} + ] = responses + + assert_closed_and_released(conn) + end + + test "pipelined requests behind a close-delimited response get a :closed error", + %{conn: conn} do + {:ok, conn, ref1} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref2} = HTTP1.request(conn, "GET", "/", [], nil) + {:ok, conn, ref3} = HTTP1.request(conn, "GET", "/", [], nil) + + response = "HTTP/1.1 200 OK\r\n\r\nhi" + + assert {:ok, conn, [{:status, ^ref1, 200}, {:headers, ^ref1, []}, {:data, ^ref1, "hi"}]} = + HTTP1.stream(conn, {:tcp, conn.socket, response}) + + assert {:ok, conn, responses} = HTTP1.stream(conn, {:tcp_closed, conn.socket}) + + assert [ + {:done, ^ref1}, + {:error, ^ref2, %TransportError{reason: :closed}}, + {:error, ^ref3, %TransportError{reason: :closed}} + ] = responses + + assert HTTP1.open_request_count(conn) == 0 + assert_closed_and_released(conn) + end + defp request_string(string) do String.replace(string, "\n", "\r\n") end