From bf250d3de8e4615591d560c70a38b57d117fa1fe Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 28 Sep 2026 08:03:44 -0400 Subject: [PATCH 1/2] chore(coverage): stop adapter coverage from depending on packet timing Signed-off-by: Yordis Prieto --- test/tesla/adapter/finch_test.exs | 10 +++- test/tesla/adapter/gun_test.exs | 19 ++++++ test/tesla/adapter/mint_test.exs | 99 +++++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/test/tesla/adapter/finch_test.exs b/test/tesla/adapter/finch_test.exs index 657413f0..b684d74f 100644 --- a/test/tesla/adapter/finch_test.exs +++ b/test/tesla/adapter/finch_test.exs @@ -51,17 +51,21 @@ defmodule Tesla.Adapter.FinchTest do # Before the fix, an error during streaming would cause: # (CaseClauseError) no case clause matching: {:error, error, nil} - assert {:error, _} = + {:ok, listen} = :gen_tcp.listen(0, ip: {127, 0, 0, 1}) + {:ok, port} = :inet.port(listen) + :ok = :gen_tcp.close(listen) + + assert {:error, :econnrefused} = Tesla.Adapter.Finch.call( %Tesla.Env{ method: :get, - url: "http://nonexistent.invalid", + url: "http://127.0.0.1:#{port}", body: nil, headers: [] }, name: @finch_name, response: :stream, - receive_timeout: 1000 + receive_timeout: 5_000 ) end diff --git a/test/tesla/adapter/gun_test.exs b/test/tesla/adapter/gun_test.exs index 9e27daf0..74c8db5f 100644 --- a/test/tesla/adapter/gun_test.exs +++ b/test/tesla/adapter/gun_test.exs @@ -504,6 +504,25 @@ defmodule Tesla.Adapter.GunTest do assert Enum.join(stream) == "firstsecond" end + test "joins a plain body that arrives in more than one part" do + url = + start_raw_server(fn socket -> + :gen_tcp.send(socket, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + Process.sleep(50) + :gen_tcp.send(socket, "5\r\nfirst\r\n") + Process.sleep(50) + :gen_tcp.send(socket, "6\r\nsecond\r\n") + Process.sleep(50) + :gen_tcp.send(socket, "0\r\n\r\n") + Process.sleep(50) + :gen_tcp.close(socket) + end) + + request = %Env{method: :get, url: url} + + assert {:ok, %Env{status: 200, body: "firstsecond"}} = call(request, timeout: 2_000) + end + defp start_raw_server(on_request, opts \\ []) do {:ok, listen_socket} = :gen_tcp.listen(0, [:binary, packet: :raw, active: false, reuseaddr: true]) diff --git a/test/tesla/adapter/mint_test.exs b/test/tesla/adapter/mint_test.exs index 5958b81b..53c6b1eb 100644 --- a/test/tesla/adapter/mint_test.exs +++ b/test/tesla/adapter/mint_test.exs @@ -14,6 +14,8 @@ defmodule Tesla.Adapter.MintTest do @large_http2_request_size 70_000 @default_connection_window_size 65_535 @wide_stream_window_size 1_048_576 + @chunked_head "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n" + @empty_head "HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n" use Tesla.AdapterCase.SSL, transport_opts: [ @@ -1466,6 +1468,103 @@ defmodule Tesla.Adapter.MintTest do end end + describe "responses split into scripted packets" do + setup do + {:ok, listen_socket} = :gen_tcp.listen(0, [:binary, active: false, reuseaddr: true]) + {:ok, port} = :inet.port(listen_socket) + {:ok, conn} = Mint.HTTP.connect(:http, "localhost", port, mode: :active) + + on_exit(fn -> + Mint.HTTP.close(conn) + :gen_tcp.close(listen_socket) + end) + + {:ok, conn: conn, url: "http://localhost:#{port}", original: "localhost:#{port}"} + end + + test "streams a body that arrived whole with the headers", context do + assert {:ok, %Env{body: body}} = + call_with_packets(context, [@chunked_head <> "5\r\nhello\r\n0\r\n\r\n"], + body_as: :stream + ) + + assert Enum.to_list(body) == ["hello"] + end + + test "streams nothing when the headers finish the response", context do + assert {:ok, %Env{body: body}} = + call_with_packets(context, [@empty_head], body_as: :stream) + + assert Enum.to_list(body) == [] + end + + test "streams data that came with the headers before the final packet", context do + assert {:ok, %Env{body: body}} = + call_with_packets(context, [@chunked_head <> "5\r\nhello\r\n", "0\r\n\r\n"], + body_as: :stream + ) + + assert Enum.to_list(body) == ["hello"] + end + + test "streams data from packets that follow the headers", context do + packets = [@chunked_head, "5", "\r\nhello\r\n", "5\r\nworld\r\n0\r\n\r\n"] + + assert {:ok, %Env{body: body}} = call_with_packets(context, packets, body_as: :stream) + + assert Enum.to_list(body) == ["hello", "world"] + end + + test "returns the first chunk the headers packet finished", context do + assert {:ok, %Env{body: %{body: {:fin, "hello"}}}} = + call_with_packets(context, [@chunked_head <> "5\r\nhello\r\n0\r\n\r\n"], + body_as: :chunks + ) + end + + test "returns the first chunk the headers packet left open", context do + assert {:ok, %Env{body: %{body: {:nofin, "hello"}}}} = + call_with_packets(context, [@chunked_head <> "5\r\nhello\r\n"], body_as: :chunks) + end + + test "returns an empty final chunk when the headers finish the response", context do + assert {:ok, %Env{body: %{body: {:fin, ""}}}} = + call_with_packets(context, [@empty_head], body_as: :chunks) + end + + test "reads the chunks of packets that follow the headers", context do + packets = [@chunked_head, "5\r\nhello\r\n", "0\r\n\r\n"] + + assert {:ok, %Env{body: %{conn: conn, ref: ref, opts: opts, body: {:nofin, ""}}}} = + call_with_packets(context, packets, body_as: :chunks) + + assert {:nofin, conn, "hello"} = Tesla.Adapter.Mint.read_chunk(conn, ref, opts) + assert {:fin, _conn, ""} = Tesla.Adapter.Mint.read_chunk(conn, ref, opts) + end + + test "joins a plain body delivered across packets", context do + packets = [@chunked_head, "5\r\nhello\r\n", "5\r\nworld\r\n", "0\r\n\r\n"] + + assert {:ok, %Env{body: "helloworld"}} = call_with_packets(context, packets, max_body: 10) + end + + test "rejects a plain body that outgrows max_body across packets", context do + packets = [@chunked_head, "5\r\nhello\r\n", "5\r\nworld\r\n", "0\r\n\r\n"] + + assert {:error, :body_too_large} = call_with_packets(context, packets, max_body: 7) + end + end + + defp call_with_packets(%{conn: conn, url: url, original: original}, packets, opts) do + socket = Mint.HTTP.get_socket(conn) + Enum.each(packets, &send(self(), {:tcp, socket, &1})) + + call( + %Env{method: :get, url: url}, + [conn: conn, original: original, mode: :active, close_conn: false] ++ opts + ) + end + defp streamed_dispatch do :cowboy_router.compile([ {:_, From f9133883de0cc1302c69307ce25f24428858f8bf Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 28 Sep 2026 08:21:06 -0400 Subject: [PATCH 2/2] chore(coverage): stop gun's multi-part body coverage from depending on TCP segmentation Signed-off-by: Yordis Prieto --- test/tesla/adapter/gun_test.exs | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/test/tesla/adapter/gun_test.exs b/test/tesla/adapter/gun_test.exs index 74c8db5f..45939ad3 100644 --- a/test/tesla/adapter/gun_test.exs +++ b/test/tesla/adapter/gun_test.exs @@ -504,23 +504,34 @@ defmodule Tesla.Adapter.GunTest do assert Enum.join(stream) == "firstsecond" end - test "joins a plain body that arrives in more than one part" do + test "joins a plain body that gun delivers in more than one message" do + test_pid = self() + splitter = spawn_link(fn -> split_final_data(test_pid) end) + url = start_raw_server(fn socket -> - :gen_tcp.send(socket, "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") - Process.sleep(50) - :gen_tcp.send(socket, "5\r\nfirst\r\n") - Process.sleep(50) - :gen_tcp.send(socket, "6\r\nsecond\r\n") - Process.sleep(50) - :gen_tcp.send(socket, "0\r\n\r\n") + :gen_tcp.send(socket, "HTTP/1.1 200 OK\r\ncontent-length: 11\r\n\r\nfirstsecond") Process.sleep(50) :gen_tcp.close(socket) end) request = %Env{method: :get, url: url} - assert {:ok, %Env{status: 200, body: "firstsecond"}} = call(request, timeout: 2_000) + assert {:ok, %Env{status: 200, body: "firstsecond"}} = call(request, reply_to: splitter) + end + + # Relays every Gun message to `owner`, turning the final data message into a + # :nofin part and an empty :fin, so the body always takes more than one read. + defp split_final_data(owner) do + receive do + {:gun_data, pid, stream, :fin, data} -> + send(owner, {:gun_data, pid, stream, :nofin, data}) + send(owner, {:gun_data, pid, stream, :fin, ""}) + + message -> + send(owner, message) + split_final_data(owner) + end end defp start_raw_server(on_request, opts \\ []) do