From 1fa102b16a5e47fd6fa33e16cc7378ffb49a2384 Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:34:11 -0300 Subject: [PATCH 1/7] chore: speed-up grpc client test suite --- .../adapters/mint/connection_process_test.exs | 4 +- grpc/test/grpc/adapters/mint_test.exs | 19 ++- .../client/connection_supervised_test.exs | 5 +- grpc/test/grpc/client/connection_test.exs | 6 +- grpc/test/grpc/client/dns_resolver_test.exs | 109 +++++++++++------- .../test/grpc/integration/connection_test.exs | 14 ++- grpc/test/grpc/integration/server_test.exs | 27 +++-- grpc/test/grpc/integration/stub_test.exs | 6 +- grpc/test/support/integration_data_case.ex | 2 +- 9 files changed, 116 insertions(+), 76 deletions(-) diff --git a/grpc/test/grpc/adapters/mint/connection_process_test.exs b/grpc/test/grpc/adapters/mint/connection_process_test.exs index dd7fd4749..6c4bc2619 100644 --- a/grpc/test/grpc/adapters/mint/connection_process_test.exs +++ b/grpc/test/grpc/adapters/mint/connection_process_test.exs @@ -330,7 +330,7 @@ defmodule GRPC.Client.Adapters.Mint.ConnectionProcessTest do assert {:noreply, _new_state} = ConnectionProcess.handle_continue(:process_request_stream_queue, state) - refute_receive {:tag, :ok}, 500 + refute_received {:tag, :ok} end test "(window_size >= body_size) stream body, send end_stream message and check request_queue when queue is not empty", @@ -435,7 +435,7 @@ defmodule GRPC.Client.Adapters.Mint.ConnectionProcessTest do assert {:noreply, new_state} = ConnectionProcess.handle_info(tcp_message, state) assert new_state.conn.state != :closed assert new_state.retry_attempt == 0 - refute_receive {:elixir_grpc, :connection_down, _pid}, 200 + refute_received {:elixir_grpc, :connection_down, _pid} end end diff --git a/grpc/test/grpc/adapters/mint_test.exs b/grpc/test/grpc/adapters/mint_test.exs index 53dcdd977..e7af10a09 100644 --- a/grpc/test/grpc/adapters/mint_test.exs +++ b/grpc/test/grpc/adapters/mint_test.exs @@ -52,12 +52,16 @@ defmodule GRPC.Client.Adapters.MintTest do channel = build(:channel, adapter: Mint, port: port, host: "localhost") assert {:ok, result} = Mint.connect(channel, []) - # wait for settings to be pushed - Process.sleep(50) state = :sys.get_state(result.adapter_payload.conn_pid) + # Mint mirrors advertised client_settings onto conn at connect time. + assert state.connect_opts[:client_settings] == [ + initial_window_size: 8_000_000, + max_frame_size: 8_000_000 + ] + assert %{initial_window_size: 8_000_000, max_frame_size: 8_000_000} = - Map.get(state.conn, :client_settings) + Map.take(state.conn.client_settings, [:initial_window_size, :max_frame_size]) end test "allow client settings to be passed", %{port: port} do @@ -71,12 +75,15 @@ defmodule GRPC.Client.Adapters.MintTest do ] ) - # wait for settings to be pushed - Process.sleep(50) state = :sys.get_state(result.adapter_payload.conn_pid) + assert state.connect_opts[:client_settings] == [ + initial_window_size: 50_000, + max_frame_size: 50_000 + ] + assert %{initial_window_size: 50_000, max_frame_size: 50_000} = - Map.get(state.conn, :client_settings) + Map.take(state.conn.client_settings, [:initial_window_size, :max_frame_size]) end end diff --git a/grpc/test/grpc/client/connection_supervised_test.exs b/grpc/test/grpc/client/connection_supervised_test.exs index 03ecf8833..3eceb0b56 100644 --- a/grpc/test/grpc/client/connection_supervised_test.exs +++ b/grpc/test/grpc/client/connection_supervised_test.exs @@ -194,7 +194,7 @@ defmodule GRPC.Client.ConnectionSupervisedTest do caller = self() for _ <- 1..5 do - assert {:error, :timeout} = Connection.await_ready(name, 50) + assert {:error, :timeout} = Connection.await_ready(name, 10) end # Five starts prove the connection registered every call; each re-entry @@ -286,7 +286,8 @@ defmodule GRPC.Client.ConnectionSupervisedTest do other = spawn(fn -> :ok end) send(conn, {:EXIT, other, :some_crash}) - refute_receive {:resolver_init, _}, 200 + :sys.get_state(conn) + refute_received {:resolver_init, _} Process.exit(worker, :kill) assert_receive {:resolver_init, _new_worker}, 1_000 diff --git a/grpc/test/grpc/client/connection_test.exs b/grpc/test/grpc/client/connection_test.exs index 6360ff865..b413ce6b3 100644 --- a/grpc/test/grpc/client/connection_test.exs +++ b/grpc/test/grpc/client/connection_test.exs @@ -277,14 +277,14 @@ defmodule GRPC.Client.ConnectionTest do "supervisor memory grew: before=#{before_memory} after=#{after_memory}" end - test "500 cycles leave persistent_term clean and no per-LB tables leak", %{ + test "100 cycles leave persistent_term clean and no per-LB tables leak", %{ target: target, adapter: adapter } do before_table_count = length(:ets.all()) before_pt_count = connection_pt_count() - for _ <- 1..500 do + for _ <- 1..100 do ref = make_ref() {:ok, channel} = Connection.connect(target, adapter: adapter, name: ref) {:ok, _} = Connection.disconnect(channel) @@ -347,7 +347,7 @@ defmodule GRPC.Client.ConnectionTest do ) assert_receive :interceptor_init - refute_receive :interceptor_init, 100 + refute_received :interceptor_init Connection.disconnect(channel) end diff --git a/grpc/test/grpc/client/dns_resolver_test.exs b/grpc/test/grpc/client/dns_resolver_test.exs index d5770ae18..67f9ec430 100644 --- a/grpc/test/grpc/client/dns_resolver_test.exs +++ b/grpc/test/grpc/client/dns_resolver_test.exs @@ -30,9 +30,10 @@ defmodule GRPC.Client.ReResolveTest do alias GRPC.Channel alias GRPC.Client.Connection - @resolve_interval 50 - @wait @resolve_interval + 30 - @wait_after_backoff @resolve_interval * 2 + 50 + # @wait must land after the 1st failure tick and before the doubled 2nd. + @resolve_interval 25 + @wait @resolve_interval + 15 + @wait_after_backoff @resolve_interval * 2 + 20 setup do Mox.set_mox_global() @@ -55,7 +56,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{worker_pid: pid}} end) - stub(resolver, :update, fn state, _event -> {:ok, state} end) + stub(resolver, :update, &forward_resolve_now/2) stub(resolver, :shutdown, fn %{worker_pid: pid} when is_pid(pid) -> @@ -137,7 +138,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{worker_pid: pid}} end) - stub(resolver, :update, fn state, _event -> {:ok, state} end) + stub(resolver, :update, &forward_resolve_now/2) stub(resolver, :shutdown, fn %{worker_pid: pid} when is_pid(pid) -> @@ -174,6 +175,29 @@ defmodule GRPC.Client.ReResolveTest do :sys.get_state(worker_pid) end + defp forward_resolve_now(%{worker_pid: pid} = state, :resolve_now) when is_pid(pid) do + send(pid, :resolve_now) + {:ok, state} + end + + defp forward_resolve_now(state, _event), do: {:ok, state} + + # Drive a re-resolve without waiting on the timer: cast → sync conn → sync + # resolver → sync conn again so {:resolver_update, _} is applied. + defp re_resolve(channel) do + :ok = Connection.resolve_now(channel) + pid = whereis_name(channel.ref) + state = :sys.get_state(pid) + wp = state.resolver_state && state.resolver_state[:worker_pid] + + if is_pid(wp) and Process.alive?(wp) do + :sys.get_state(wp) + end + + :sys.get_state(pid) + :ok + end + describe "scale-up: new backends discovered" do test "adds channels for addresses that appear in DNS", ctx do {:ok, channel} = @@ -198,7 +222,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{addresses: new_addrs, service_config: nil}} end) - Process.sleep(@wait) + re_resolve(channel) state = get_state(ctx.ref) assert map_size(state.real_channels) == 2 @@ -229,7 +253,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{addresses: [%{address: "10.0.0.1", port: 50051}], service_config: nil}} end) - Process.sleep(@wait) + re_resolve(channel) state = get_state(ctx.ref) assert map_size(state.real_channels) == 1 @@ -254,7 +278,7 @@ defmodule GRPC.Client.ReResolveTest do state_before = get_state(ctx.ref) - Process.sleep(@wait) + re_resolve(channel) state_after = get_state(ctx.ref) assert state_before.real_channels == state_after.real_channels @@ -286,7 +310,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{addresses: new_addrs, service_config: nil}} end) - Process.sleep(@wait) + re_resolve(channel) state = get_state(ctx.ref) assert map_size(state.real_channels) == 2 @@ -314,7 +338,7 @@ defmodule GRPC.Client.ReResolveTest do stub(ctx.resolver, :resolve, fn _target -> {:error, :timeout} end) - Process.sleep(@wait) + re_resolve(channel) state = get_state(ctx.ref) assert map_size(state.real_channels) == 1 @@ -339,7 +363,7 @@ defmodule GRPC.Client.ReResolveTest do stub(ctx.resolver, :resolve, fn _target -> {:error, :nxdomain} end) - Process.sleep(@wait) + re_resolve(channel) assert map_size(get_state(ctx.ref).real_channels) == 1 stub(ctx.resolver, :resolve, fn _target -> @@ -353,7 +377,7 @@ defmodule GRPC.Client.ReResolveTest do }} end) - Process.sleep(@wait_after_backoff) + re_resolve(channel) assert map_size(get_state(ctx.ref).real_channels) == 2 disconnect_and_wait(channel) @@ -378,7 +402,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{addresses: [], service_config: nil}} end) - Process.sleep(@wait) + re_resolve(channel) state = get_state(ctx.ref) assert map_size(state.real_channels) == 2 @@ -404,7 +428,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{addresses: [], service_config: nil}} end) - Process.sleep(@wait) + re_resolve(channel) assert map_size(get_state(ctx.ref).real_channels) == 1 stub(ctx.resolver, :resolve, fn _target -> @@ -418,7 +442,7 @@ defmodule GRPC.Client.ReResolveTest do }} end) - Process.sleep(@wait_after_backoff) + re_resolve(channel) state = get_state(ctx.ref) assert map_size(state.real_channels) == 2 assert Map.has_key?(state.real_channels, "10.0.0.3:50051") @@ -451,7 +475,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{addresses: new_addrs, service_config: nil}} end) - Process.sleep(@wait) + re_resolve(channel) assert {:ok, picked} = Connection.pick_channel(channel) assert picked.host in ["10.0.0.1", "10.0.0.2"] @@ -479,7 +503,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{addresses: [%{address: "10.0.0.9", port: 50051}], service_config: nil}} end) - Process.sleep(@wait) + re_resolve(channel) {:ok, picked} = Connection.pick_channel(channel) assert picked.host == "10.0.0.9" @@ -527,7 +551,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{addresses: small, service_config: nil}} end) - Process.sleep(@wait) + re_resolve(channel) for _ <- 1..picker_count do assert_receive {:done, _, results}, 2_000 @@ -538,8 +562,6 @@ defmodule GRPC.Client.ReResolveTest do end end - Process.sleep(@wait) - hosts = for _ <- 1..20 do {:ok, picked} = Connection.pick_channel(channel) @@ -606,7 +628,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{addresses: [%{address: "10.0.0.99", port: 50051}], service_config: nil}} end) - Process.sleep(@wait) + re_resolve(channel) assert {:ok, picked} = Connection.pick_channel(channel) assert picked.host == "10.0.0.99" @@ -666,8 +688,6 @@ defmodule GRPC.Client.ReResolveTest do resolve_interval: 50 ) - Process.sleep(@wait) - assert {:ok, _} = Connection.pick_channel(channel) assert is_nil(get_state(ctx.ref).resolver_state) @@ -690,8 +710,6 @@ defmodule GRPC.Client.ReResolveTest do Connection.disconnect(channel) - Process.sleep(@wait) - assert {:error, :no_connection} = Connection.pick_channel(channel) end end @@ -720,7 +738,7 @@ defmodule GRPC.Client.ReResolveTest do }} end) - Process.sleep(@wait) + re_resolve(channel) state = get_state(ctx.ref) assert map_size(state.real_channels) == 2 @@ -753,7 +771,7 @@ defmodule GRPC.Client.ReResolveTest do }} end) - Process.sleep(@wait) + re_resolve(channel) state = get_state(ctx.ref) assert map_size(state.real_channels) == 1 @@ -783,7 +801,7 @@ defmodule GRPC.Client.ReResolveTest do resolver_state = get_resolver_state(ctx.ref) assert resolver_state.resolve_interval == @resolve_interval * 2 - Process.sleep(resolver_state.resolve_interval + 50) + Process.sleep(resolver_state.resolve_interval + 20) resolver_state = get_resolver_state(ctx.ref) assert resolver_state.resolve_interval == @resolve_interval * 4 @@ -843,10 +861,10 @@ defmodule GRPC.Client.ReResolveTest do Process.sleep(@wait) assert get_resolver_state(ctx.ref).resolve_interval == @resolve_interval * 2 - Process.sleep(@resolve_interval * 2 + 50) + Process.sleep(@resolve_interval * 2 + 20) assert get_resolver_state(ctx.ref).resolve_interval == max - Process.sleep(max + 50) + Process.sleep(max + 20) assert get_resolver_state(ctx.ref).resolve_interval == max disconnect_and_wait(channel) @@ -936,7 +954,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{addresses: new_addrs, service_config: nil}} end) - Process.sleep(@wait) + re_resolve(channel) assert_received {:telemetry, [:grpc, :client, :resolve, :stop], measurements, metadata} assert is_integer(measurements.duration) @@ -961,7 +979,7 @@ defmodule GRPC.Client.ReResolveTest do stub(ctx.resolver, :resolve, fn _target -> {:error, :timeout} end) - Process.sleep(@wait) + re_resolve(channel) assert_received {:telemetry, [:grpc, :client, :resolve, :error], measurements, metadata} assert is_integer(measurements.duration) @@ -988,7 +1006,7 @@ defmodule GRPC.Client.ReResolveTest do {:ok, %{addresses: [], service_config: nil}} end) - Process.sleep(@wait) + re_resolve(channel) assert_received {:telemetry, [:grpc, :client, :resolve, :error], _measurements, metadata} assert metadata.reason == :empty_addresses @@ -1041,7 +1059,7 @@ defmodule GRPC.Client.ReResolveTest do }} end) - Process.sleep(@wait) + re_resolve(channel) assert {:ok, picked} = Connection.pick_channel(channel) assert picked.host == "10.0.0.1" @@ -1085,7 +1103,7 @@ defmodule GRPC.Client.ReResolveTest do }} end) - Process.sleep(@wait) + re_resolve(channel) assert {:error, :no_connection} = Connection.pick_channel(channel) @@ -1145,7 +1163,7 @@ defmodule GRPC.Client.ReResolveTest do # Now make 10.0.0.2 reachable Agent.update(ctx.failing_hosts, fn _ -> [] end) - Process.sleep(@wait) + re_resolve(channel) # Both channels should now be healthy state = get_state(ctx.ref) @@ -1171,12 +1189,13 @@ defmodule GRPC.Client.ReResolveTest do # Simulate a resolver that takes long enough to overlap pick_channel stub(ctx.resolver, :resolve, fn _target -> - Process.sleep(200) + Process.sleep(50) {:ok, %{addresses: [%{address: "10.0.0.1", port: 50051}], service_config: nil}} end) - # Wait for re-resolve to fire (runs in DNSResolver process) - Process.sleep(@wait) + # Kick resolve without waiting for the slow worker to finish. + :ok = Connection.resolve_now(channel) + :sys.get_state(whereis_name(channel.ref)) assert {:ok, _} = Connection.pick_channel(channel) @@ -1259,8 +1278,7 @@ defmodule GRPC.Client.ReResolveTest do send(pid, :refresh) end - # Small sleep for messages to process - Process.sleep(50) + :sys.get_state(pid) assert Process.alive?(pid) assert {:ok, picked} = Connection.pick_channel(channel) @@ -1300,7 +1318,7 @@ defmodule GRPC.Client.ReResolveTest do assert is_nil(state.resolver_state) Connection.resolve_now(channel) - Process.sleep(50) + :sys.get_state(whereis_name(ctx.ref)) assert {:ok, _} = Connection.pick_channel(channel) @@ -1323,10 +1341,13 @@ defmodule GRPC.Client.ReResolveTest do original_pid = state.resolver_state.worker_pid assert Process.alive?(original_pid) + mon = Process.monitor(original_pid) Process.exit(original_pid, :kill) - Process.sleep(100) + assert_receive {:DOWN, ^mon, :process, ^original_pid, _} conn_pid = whereis_name(ctx.ref) + # Flush the Connection's :EXIT handling so re-init has finished. + :sys.get_state(conn_pid) assert Process.alive?(conn_pid) state = get_state(ctx.ref) @@ -1353,7 +1374,7 @@ defmodule GRPC.Client.ReResolveTest do stray_pid = spawn(fn -> :ok end) send(conn_pid, {:EXIT, stray_pid, :boom}) - Process.sleep(50) + :sys.get_state(conn_pid) assert Process.alive?(conn_pid) assert {:ok, _} = Connection.pick_channel(channel) diff --git a/grpc/test/grpc/integration/connection_test.exs b/grpc/test/grpc/integration/connection_test.exs index b16133e8e..e787fd46c 100644 --- a/grpc/test/grpc/integration/connection_test.exs +++ b/grpc/test/grpc/integration/connection_test.exs @@ -1,11 +1,19 @@ defmodule GRPC.Integration.ConnectionTest do use GRPC.Integration.TestCase + # Gun's adapter installs its own retry_fun (≈1s base); override for a fast reconnect. + def fast_retry_fun(retries, _opts), do: %{retries: retries - 1, timeout: 10} + test "reconnection works" do server = FeatureServer {:ok, _, port} = GRPC.Server.start(server, 0) point = %Routeguide.Point{latitude: 409_146_138, longitude: -746_188_906} - {:ok, channel} = GRPC.Stub.connect("localhost:#{port}", adapter_opts: [retry_timeout: 10]) + + {:ok, channel} = + GRPC.Stub.connect("localhost:#{port}", + adapter_opts: [retry_fun: &__MODULE__.fast_retry_fun/2] + ) + assert {:ok, _} = channel |> Routeguide.RouteGuide.Stub.get_feature(point) :ok = GRPC.Server.stop(server) {:ok, _, _} = reconnect_server(server, port) @@ -19,7 +27,9 @@ defmodule GRPC.Integration.ConnectionTest do File.rm(socket_path) {:ok, _, _} = GRPC.Server.start(server, 0, adapter_opts: [ip: {:local, socket_path}]) - {:ok, channel} = GRPC.Stub.connect(socket_path, adapter_opts: [retry_timeout: 10]) + + {:ok, channel} = + GRPC.Stub.connect(socket_path, adapter_opts: [retry_fun: &__MODULE__.fast_retry_fun/2]) point = %Routeguide.Point{latitude: 409_146_138, longitude: -746_188_906} assert {:ok, _} = channel |> Routeguide.RouteGuide.Stub.get_feature(point) diff --git a/grpc/test/grpc/integration/server_test.exs b/grpc/test/grpc/integration/server_test.exs index ed3fa09a8..e6b479fd6 100644 --- a/grpc/test/grpc/integration/server_test.exs +++ b/grpc/test/grpc/integration/server_test.exs @@ -180,7 +180,7 @@ defmodule GRPC.Integration.ServerTest do use GRPC.Server, service: Routeguide.RouteGuide.Service def list_features(_rectangle, _stream) do - Process.sleep(600) + Process.sleep(80) end end @@ -188,7 +188,7 @@ defmodule GRPC.Integration.ServerTest do use GRPC.Server, service: Routeguide.RouteGuide.Service def list_features(rectangle, materializer) do - Process.sleep(400) + Process.sleep(50) server_stream = Stream.each([rectangle.lo, rectangle.hi], fn point -> point end) server_stream @@ -244,9 +244,8 @@ defmodule GRPC.Integration.ServerTest do {:ok, conn_pid} = :gun.open(~c"localhost", port) stream_ref = :gun.get(conn_pid, "/status") - Process.sleep(100) - assert_received {:gun_response, ^conn_pid, ^stream_ref, :nofin, 200, _headers} + assert_receive {:gun_response, ^conn_pid, ^stream_ref, :nofin, 200, _headers}, 200 end, 0, adapter_opts: [status_handler: status_handler] @@ -423,7 +422,7 @@ defmodule GRPC.Integration.ServerTest do error = %GRPC.RPCError{message: "Deadline expired", status: 4} assert {:error, ^error} = - channel |> Routeguide.RouteGuide.Stub.list_features(rect, timeout: 500) + channel |> Routeguide.RouteGuide.Stub.list_features(rect, timeout: 50) end) end) @@ -437,7 +436,7 @@ defmodule GRPC.Integration.ServerTest do low = %Routeguide.Point{latitude: 400_000_000, longitude: -750_000_000} high = %Routeguide.Point{latitude: 420_000_000, longitude: -730_000_000} rect = %Routeguide.Rectangle{lo: low, hi: high} - {:ok, stream} = channel |> Routeguide.RouteGuide.Stub.list_features(rect, timeout: 500) + {:ok, stream} = channel |> Routeguide.RouteGuide.Stub.list_features(rect, timeout: 100) Enum.each(stream, fn {:ok, feature} -> assert feature @@ -764,7 +763,7 @@ defmodule GRPC.Integration.ServerTest do run_server([HelloServer], fn port -> {:ok, channel} = GRPC.Stub.connect("localhost:#{port}") - req = %Helloworld.HelloRequest{name: "delay", duration: 20} + req = %Helloworld.HelloRequest{name: "delay", duration: 5} assert {:ok, _} = Helloworld.Greeter.Stub.say_hello(channel, req) end) @@ -781,7 +780,7 @@ defmodule GRPC.Integration.ServerTest do assert_received {^stop_server_name, measurements, metadata} assert %{duration: duration} = measurements - assert System.convert_time_unit(duration, :native, :millisecond) >= 20 + assert System.convert_time_unit(duration, :native, :millisecond) >= 5 assert %{ server: HelloServer, @@ -803,7 +802,7 @@ defmodule GRPC.Integration.ServerTest do assert_received {^stop_client_name, measurements, metadata} assert %{duration: duration} = measurements - assert System.convert_time_unit(duration, :native, :millisecond) >= 20 + assert System.convert_time_unit(duration, :native, :millisecond) >= 5 assert %{ stream: %GRPC.Client.Stream{ @@ -813,7 +812,7 @@ defmodule GRPC.Integration.ServerTest do } } = metadata - refute_receive _ + refute_received _ end test "sends server start+exception events on success" do @@ -839,7 +838,7 @@ defmodule GRPC.Integration.ServerTest do run_server([HelloServer], fn port -> {:ok, channel} = GRPC.Stub.connect("localhost:#{port}") - req = %Helloworld.HelloRequest{name: "raise", duration: 20} + req = %Helloworld.HelloRequest{name: "raise", duration: 5} assert {:error, %GRPC.RPCError{status: 2}} = Helloworld.Greeter.Stub.say_hello(channel, req) @@ -857,7 +856,7 @@ defmodule GRPC.Integration.ServerTest do assert_received {^exception_server_name, measurements, metadata} assert %{duration: duration} = measurements - assert System.convert_time_unit(duration, :native, :millisecond) >= 20 + assert System.convert_time_unit(duration, :native, :millisecond) >= 5 assert %{ server: HelloServer, @@ -893,7 +892,7 @@ defmodule GRPC.Integration.ServerTest do assert_received {^stop_client_name, measurements, metadata} assert %{duration: duration} = measurements - assert System.convert_time_unit(duration, :native, :millisecond) >= 20 + assert System.convert_time_unit(duration, :native, :millisecond) >= 5 assert %{ stream: %GRPC.Client.Stream{ @@ -903,7 +902,7 @@ defmodule GRPC.Integration.ServerTest do } } = metadata - refute_receive _ + refute_received _ end end end diff --git a/grpc/test/grpc/integration/stub_test.exs b/grpc/test/grpc/integration/stub_test.exs index ca114e25a..b43305b86 100644 --- a/grpc/test/grpc/integration/stub_test.exs +++ b/grpc/test/grpc/integration/stub_test.exs @@ -13,7 +13,7 @@ defmodule GRPC.Integration.StubTest do use GRPC.Server, service: Helloworld.Greeter.Service def say_hello(_req, _stream) do - Process.sleep(1000) + Process.sleep(80) end end @@ -77,6 +77,8 @@ defmodule GRPC.Integration.StubTest do end test "use a channel name to send a message" do + on_exit(fn -> GRPC.Client.Connection.disconnect(:my_channel) end) + run_server(HelloServer, fn port -> {:ok, _channel} = GRPC.Client.Connection.connect("localhost:#{port}", @@ -147,7 +149,7 @@ defmodule GRPC.Integration.StubTest do %GRPC.RPCError{ message: "Deadline expired", status: GRPC.Status.deadline_exceeded() - }} == channel |> Helloworld.Greeter.Stub.say_hello(req, timeout: 500) + }} == channel |> Helloworld.Greeter.Stub.say_hello(req, timeout: 50) end) end end diff --git a/grpc/test/support/integration_data_case.ex b/grpc/test/support/integration_data_case.ex index 7422d9f08..b0b9eb110 100644 --- a/grpc/test/support/integration_data_case.ex +++ b/grpc/test/support/integration_data_case.ex @@ -78,7 +78,7 @@ defmodule GRPC.Integration.TestCase do Logger.warning("Got eaddrinuse when reconnecting to #{server}:#{port}. retry: #{retry}") if retry >= 1 do - Process.sleep(500) + Process.sleep(50) reconnect_server(server, port, retry - 1) else result From 4d62d9cc3fd2cb3e5d7ae440c60f7486e8028678 Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:39:17 -0300 Subject: [PATCH 2/7] speed up server tests --- grpc_server/test/grpc/stream_test.exs | 59 ++++++++++--------- .../test/support/integration_test_case.ex | 2 +- 2 files changed, 31 insertions(+), 30 deletions(-) diff --git a/grpc_server/test/grpc/stream_test.exs b/grpc_server/test/grpc/stream_test.exs index c0d5a5f3e..eb08c8e1a 100644 --- a/grpc_server/test/grpc/stream_test.exs +++ b/grpc_server/test/grpc/stream_test.exs @@ -258,8 +258,7 @@ defmodule GRPC.StreamTest do result = GRPC.Stream.from([:hello]) - # very short timeout - |> GRPC.Stream.ask(pid, 10) + |> GRPC.Stream.ask(pid, 1) |> GRPC.Stream.to_flow() |> Enum.to_list() @@ -486,48 +485,50 @@ defmodule GRPC.StreamTest do end describe "join_with/merge streams" do + test "merges input stream with a joined enumerable" do + result = + GRPC.Stream.from([1, 2, 3], join_with: [4, 5, 6]) + |> GRPC.Stream.map(& &1) + |> GRPC.Stream.to_flow() + |> Enum.to_list() + |> Enum.sort() + + assert result == [1, 2, 3, 4, 5, 6] + end + test "merges input stream with joined GenStage producer" do defmodule TestProducer do use GenStage - def start_link(items) do - GenStage.start_link(__MODULE__, items) - end - - def init(items) do - {:producer, items} - end + def start_link(items), do: GenStage.start_link(__MODULE__, items) + def init(items), do: {:producer, items} def handle_demand(demand, state) when demand > 0 do {events, remaining} = Enum.split(state, demand) + # Finite producer: stop after the last batch so Flow can complete. + if remaining == [] do + Process.send_after(self(), :stop, 0) + end + {:noreply, events, remaining} end - end - - elements = Enum.to_list(4..1000) - {:ok, producer_pid} = TestProducer.start_link(elements) - input = [1, 2, 3] + def handle_info(:stop, state), do: {:stop, :normal, state} + end - task = - Task.async(fn -> - GRPC.Stream.from(input, join_with: producer_pid, max_demand: 500) - |> GRPC.Stream.map(fn it -> it end) - |> GRPC.Stream.run_with(%GRPC.Server.Stream{}, dry_run: true) - end) + {:ok, producer_pid} = TestProducer.start_link([4, 5, 6]) + mon = Process.monitor(producer_pid) result = - case Task.yield(task, 1000) || Task.shutdown(task) do - {:ok, _} -> :ok - _ -> :ok - end - - if Process.alive?(producer_pid) do - Process.exit(producer_pid, :normal) - end + GRPC.Stream.from([1, 2, 3], join_with: producer_pid) + |> GRPC.Stream.map(& &1) + |> GRPC.Stream.to_flow() + |> Enum.to_list() + |> Enum.sort() - assert result == :ok + assert result == [1, 2, 3, 4, 5, 6] + assert_receive {:DOWN, ^mon, :process, ^producer_pid, :normal} end end diff --git a/grpc_server/test/support/integration_test_case.ex b/grpc_server/test/support/integration_test_case.ex index b51aecd19..c977c990c 100644 --- a/grpc_server/test/support/integration_test_case.ex +++ b/grpc_server/test/support/integration_test_case.ex @@ -54,7 +54,7 @@ defmodule GRPC.Integration.TestCase do Logger.warning("Got eaddrinuse when reconnecting to #{server}:#{port}. retry: #{retry}") if retry >= 1 do - Process.sleep(500) + Process.sleep(50) reconnect_server(server, port, retry - 1) else result From 8c8fca517cda50371009a449b3c7f95a413e1ea8 Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:41:02 -0300 Subject: [PATCH 3/7] repeat until failure for validation --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4754c23de..52f12b96e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,8 +61,9 @@ jobs: run: mix setup 1>/dev/null - name: Compile with warnings as errors run: MIX_ENV=test mix compile --warnings-as-errors + # TEMP: remove after flake hunt (target: 2026-08-10). Amplifies CI cost ×20. - name: Run Tests - run: mix test --warnings-as-errors + run: mix test --warnings-as-errors --repeat-until-failure 20 - name: Run Warning Tests run: mix test --only warning_test - name: Run :mint optional dependency test From 54b6fb18414876d26c4535ec17aeb5f4e8a5bdb0 Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:45:36 -0300 Subject: [PATCH 4/7] flake hunt --- .github/workflows/ci.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52f12b96e..d16d89f50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,9 +61,14 @@ jobs: run: mix setup 1>/dev/null - name: Compile with warnings as errors run: MIX_ENV=test mix compile --warnings-as-errors - # TEMP: remove after flake hunt (target: 2026-08-10). Amplifies CI cost ×20. + # TEMP: --repeat-until-failure needs Elixir ≥1.18; drop the branch after flake hunt (2026-08-10). - name: Run Tests - run: mix test --warnings-as-errors --repeat-until-failure 20 + run: | + if [[ "${{ matrix.elixir }}" == "1.20.x" ]]; then + mix test --warnings-as-errors --repeat-until-failure 20 + else + mix test --warnings-as-errors + fi - name: Run Warning Tests run: mix test --only warning_test - name: Run :mint optional dependency test From bc2472282a4d90a18eafb5eb3c55c9267d83357d Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Mon, 27 Jul 2026 03:24:48 -0300 Subject: [PATCH 5/7] fix flake --- .../test/grpc/server/adapters/cowboy_test.exs | 2 +- .../test/grpc/server/interceptors/cors_test.exs | 2 +- grpc_server/test/grpc/server/supervisor_test.exs | 2 +- grpc_server/test/support/feature_server.ex | 16 ++++++++++++++++ 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/grpc_server/test/grpc/server/adapters/cowboy_test.exs b/grpc_server/test/grpc/server/adapters/cowboy_test.exs index 9a5c19fdc..588e1964b 100644 --- a/grpc_server/test/grpc/server/adapters/cowboy_test.exs +++ b/grpc_server/test/grpc/server/adapters/cowboy_test.exs @@ -1,5 +1,5 @@ defmodule GRPC.Server.Adapters.CowboyTest do - use ExUnit.Case, async: false + use ExUnit.Case, async: true alias GRPC.Server.Adapters.Cowboy diff --git a/grpc_server/test/grpc/server/interceptors/cors_test.exs b/grpc_server/test/grpc/server/interceptors/cors_test.exs index 6a5792bf6..b8c28589d 100644 --- a/grpc_server/test/grpc/server/interceptors/cors_test.exs +++ b/grpc_server/test/grpc/server/interceptors/cors_test.exs @@ -21,7 +21,7 @@ defmodule GRPC.Server.Interceptors.CORSTest.Endpoint.BinaryConcatenation do end defmodule GRPC.Server.Interceptors.CORSTest do - use ExUnit.Case, async: false + use ExUnit.Case, async: true alias GRPC.Server.Interceptors.CORS, as: CORSInterceptor alias GRPC.Server.Stream diff --git a/grpc_server/test/grpc/server/supervisor_test.exs b/grpc_server/test/grpc/server/supervisor_test.exs index f6f5bf733..15c013207 100644 --- a/grpc_server/test/grpc/server/supervisor_test.exs +++ b/grpc_server/test/grpc/server/supervisor_test.exs @@ -1,5 +1,5 @@ defmodule GRPC.Server.SupervisorTest do - use ExUnit.Case, async: false + use ExUnit.Case, async: true alias GRPC.Server.Supervisor diff --git a/grpc_server/test/support/feature_server.ex b/grpc_server/test/support/feature_server.ex index d37225fc7..185ac852c 100644 --- a/grpc_server/test/support/feature_server.ex +++ b/grpc_server/test/support/feature_server.ex @@ -8,4 +8,20 @@ defmodule FeatureServer do {:error, "server error"} end end + + # Client-streaming: drain the enum so the HTTP/2 stream stays open until the + # client sends eof. Mint ConnectionProcess unit tests open RecordRoute and + # need the request_ref to remain in state.requests while they exercise + # stream_body/cancel — an unimplemented handler replies immediately and + # pops the ref (flake under --repeat-until-failure). + def record_route(req_enum, _stream) do + points = Enum.to_list(req_enum) + + %Routeguide.RouteSummary{ + point_count: length(points), + feature_count: 0, + distance: 0, + elapsed_time: 0 + } + end end From 78375b36720dfee7798983eb3404ba93578c291b Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:53:32 -0300 Subject: [PATCH 6/7] fix flaky test --- grpc/test/grpc/client/connection_test.exs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/grpc/test/grpc/client/connection_test.exs b/grpc/test/grpc/client/connection_test.exs index b413ce6b3..d9b0fe0b6 100644 --- a/grpc/test/grpc/client/connection_test.exs +++ b/grpc/test/grpc/client/connection_test.exs @@ -273,7 +273,8 @@ defmodule GRPC.Client.ConnectionTest do assert %{active: ^supervisor_children} = DynamicSupervisor.count_children(GRPC.Client.Supervisor) - assert after_memory <= before_memory + 100_000, + # Residual heap after one GC is typically tens of KB; real header leaks are MBs. + assert after_memory <= before_memory + 250_000, "supervisor memory grew: before=#{before_memory} after=#{after_memory}" end From 483c82370f0f4ce204d7bc6f4b094ad063dc7e6e Mon Sep 17 00:00:00 2001 From: Paulo Valente <16843419+polvalente@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:04:33 -0300 Subject: [PATCH 7/7] speed up CI --- .github/workflows/ci.yml | 86 ++++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d16d89f50..a9a599550 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,12 +21,10 @@ jobs: elixir-version: 1.20.x - name: Retrieve dependencies cache uses: actions/cache@v4 - id: mix-cache # id to use in retrieve action + id: mix-deps-cache with: path: deps - key: >- - v1-${{ matrix.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ - hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + key: v2-28.x-1.20.x-deps-${{ hashFiles('mix.lock') }} - name: Install Dependencies run: mix deps.get 1>/dev/null - name: Check format @@ -51,31 +49,55 @@ jobs: elixir-version: ${{matrix.elixir}} - name: Retrieve dependencies cache uses: actions/cache@v4 - id: mix-cache # id to use in retrieve action + id: mix-deps-cache with: - path: deps + path: | + grpc/deps + grpc_core/deps + grpc_server/deps + key: >- + v2-${{ matrix.otp }}-${{ matrix.elixir }}-deps-${{ + hashFiles('grpc/mix.lock', 'grpc_core/mix.lock', 'grpc_server/mix.lock') }} + - name: Retrieve build cache + uses: actions/cache@v4 + id: mix-build-cache + with: + path: | + grpc/_build + grpc_core/_build + grpc_server/_build key: >- - v1-${{ matrix.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ - hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + v2-${{ matrix.otp }}-${{ matrix.elixir }}-build-${{ + hashFiles('grpc/mix.lock', 'grpc_core/mix.lock', 'grpc_server/mix.lock') }} - name: Install Dependencies run: mix setup 1>/dev/null - name: Compile with warnings as errors run: MIX_ENV=test mix compile --warnings-as-errors - # TEMP: --repeat-until-failure needs Elixir ≥1.18; drop the branch after flake hunt (2026-08-10). - name: Run Tests - run: | - if [[ "${{ matrix.elixir }}" == "1.20.x" ]]; then - mix test --warnings-as-errors --repeat-until-failure 20 - else - mix test --warnings-as-errors - fi + run: mix test --warnings-as-errors - name: Run Warning Tests run: mix test --only warning_test + - name: Retrieve mint install cache + uses: actions/cache@v4 + id: mix-mint-cache + with: + path: ${{ runner.temp }}/mix-install-mint + key: >- + v2-${{ matrix.otp }}-${{ matrix.elixir }}-adapter-mint-${{ + hashFiles('grpc/mix.lock', 'grpc_core/mix.lock', 'grpc_server/mix.lock') }} - name: Run :mint optional dependency test - run: elixir script/adapter_test.exs mint + run: MIX_INSTALL_DIR="${{ runner.temp }}/mix-install-mint" elixir script/adapter_test.exs mint working-directory: ./grpc + - name: Retrieve gun install cache + uses: actions/cache@v4 + id: mix-gun-cache + with: + path: ${{ runner.temp }}/mix-install-gun + key: >- + v2-${{ matrix.otp }}-${{ matrix.elixir }}-adapter-gun-${{ + hashFiles('grpc/mix.lock', 'grpc_core/mix.lock', 'grpc_server/mix.lock') }} - name: Run :gun optional dependency test - run: elixir script/adapter_test.exs gun + run: MIX_INSTALL_DIR="${{ runner.temp }}/mix-install-gun" elixir script/adapter_test.exs gun working-directory: ./grpc interop-tests: runs-on: ubuntu-latest @@ -90,12 +112,16 @@ jobs: elixir-version: 1.20.x - name: Retrieve dependencies cache uses: actions/cache@v4 - id: mix-cache # id to use in retrieve action + id: mix-deps-cache with: - path: deps - key: >- - v1-${{ matrix.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ - hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + path: interop/deps + key: v2-28.x-1.20.x-interop-deps-${{ hashFiles('interop/mix.lock') }} + - name: Retrieve build cache + uses: actions/cache@v4 + id: mix-build-cache + with: + path: interop/_build + key: v2-28.x-1.20.x-interop-build-${{ hashFiles('interop/mix.lock') }} - name: Install Dependencies run: mix deps.get 1>/dev/null working-directory: ./interop @@ -124,12 +150,20 @@ jobs: elixir-version: ${{ matrix.elixir }} - name: Retrieve dependencies cache uses: actions/cache@v4 - id: mix-cache # id to use in retrieve action + id: mix-deps-cache with: - path: deps + path: interop/deps + key: >- + v2-${{ matrix.otp }}-${{ matrix.elixir }}-interop-deps-${{ + hashFiles('interop/mix.lock') }} + - name: Retrieve build cache + uses: actions/cache@v4 + id: mix-build-cache + with: + path: interop/_build key: >- - v1-${{ matrix.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-mix-${{ - hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} + v2-${{ matrix.otp }}-${{ matrix.elixir }}-interop-build-${{ + hashFiles('interop/mix.lock') }} - name: Install Dependencies run: mix deps.get 1>/dev/null working-directory: ./interop