diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ff0d8..9341c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [0.5.0] - 2026-06-26 + +### Changed +- **grpc 1.0 support.** Bumped `grpc` to `~> 1.0` (was `0.11.5`). grpc 1.0 is + client-only (`GRPC.Server` was removed) and makes `:gun` optional, so `gun ~> 2.2` + is now a direct dependency (the pool uses the default Gun adapter). `:public_key` + and `:ssl` are declared in `extra_applications` (used by `Config`). +- **BREAKING (telemetry): the `[:grpc_connection_pool, :channel, :gun_down]` and + `[:grpc_connection_pool, :channel, :gun_error]` events were removed.** Under + grpc 1.0 the Gun adapter owns the socket in its own process, so those gun + messages never reach the pool. A single adapter-agnostic + `[:grpc_connection_pool, :channel, :connection_down]` event (metadata + `pool_name`, `reason`) is emitted instead. `:disconnected` and + `:reconnect_scheduled` are unchanged. Consumers handling `:gun_down`/`:gun_error` + should switch to `:connection_down` (or `:disconnected`). +- **Disconnect detection reworked.** grpc 1.0's Gun adapter keeps the live + connection inside its own `ConnectionProcess`, which lingers as a zombie after a + drop, so the old `gun_down`/`gun_error` message handlers were dead code. The + worker now monitors the inner gun process and pairs it with + `adapter_opts: [retry: 0]` so gun fails fast on a drop and the pool's own + `Backoff` governs reconnection. This also restores fast-fail connects (a dead + endpoint now errors in ~0ms instead of stalling ~5s per attempt). + +### Fixed +- **License declaration** now correctly reports **Apache-2.0** (matching the committed + `LICENSE` file) instead of MIT in `mix.exs` and README — resolves the Hex.pm + mismatch (#6). + +## [0.4.0] - 2026-06-01 ### Changed - **Security:** production configs now default to verifying TLS. `Config.production/1` diff --git a/README.md b/README.md index 9ec957b..418fa8e 100644 --- a/README.md +++ b/README.md @@ -68,12 +68,18 @@ Add `grpc_connection_pool` to your dependencies in `mix.exs`: ```elixir def deps do [ - {:grpc_connection_pool, "~> 0.3.0"}, - {:grpc, "~> 0.11.5"} # Required peer dependency + {:grpc_connection_pool, "~> 0.5.0"}, + {:grpc, "~> 1.0"}, # Required peer dependency + {:gun, "~> 2.2"} # grpc >= 1.0 makes gun optional; the default Gun adapter needs it ] end ``` +> **grpc 1.0:** this library targets grpc `~> 1.0`, which is client-only and makes +> `:gun` an optional dependency. Since the pool uses the default Gun adapter, add +> `:gun` explicitly. See the [Changelog](CHANGELOG.md) for the 0.5.0 migration notes +> (telemetry event change, disconnect-detection rework). + ## Quick Start ### 1. Basic Usage @@ -146,7 +152,7 @@ The library supports flexible configuration through `GrpcConnectionPool.Config`: keepalive: 30_000, # HTTP/2 keepalive interval ping_interval: 25_000, # Ping interval to keep connections warm health_check: true, # Enable connection health monitoring - suppress_connection_errors: false # Suppress gun_down/gun_error logs (useful for GCP endpoints) + suppress_connection_errors: false # Suppress connection-down logs (useful for GCP endpoints) ] ]) ``` @@ -723,10 +729,17 @@ The library emits telemetry events for observability. Attach handlers to monitor | `[:grpc_connection_pool, :channel, :connection_failed]` | `duration` | `pool_name`, `error` | | `[:grpc_connection_pool, :channel, :disconnected]` | `duration` | `pool_name`, `reason` | | `[:grpc_connection_pool, :channel, :ping]` | `duration` | `pool_name`, `result` (`:ok` or `:error`) | -| `[:grpc_connection_pool, :channel, :gun_down]` | - | `pool_name`, `reason`, `protocol` | -| `[:grpc_connection_pool, :channel, :gun_error]` | - | `pool_name`, `reason` | +| `[:grpc_connection_pool, :channel, :connection_down]` | - | `pool_name`, `reason` | | `[:grpc_connection_pool, :channel, :reconnect_scheduled]` | `delay_ms`, `attempt` | `pool_name`, `reason` | +> **Changed in 0.5.0 (grpc 1.0):** the `:gun_down` and `:gun_error` events were +> removed. Under grpc 1.0 the Gun adapter owns the socket in its own process and +> those gun messages never reach the pool. A single adapter-agnostic +> `:channel, :connection_down` event (metadata `pool_name`, `reason`) is now emitted +> when the monitored connection process dies. The `:disconnected` and +> `:reconnect_scheduled` events are unchanged. If you handled `:gun_down`/`:gun_error`, +> switch to `:connection_down` (or `:disconnected`). + #### Example: Attaching Telemetry Handlers ```elixir @@ -971,7 +984,7 @@ If you see frequent reconnections: ### Connection Error Messages with GCP Services -GCP gRPC endpoints have hardcoded timeouts that periodically close connections, causing `gun_down` error messages. This is normal behavior and the connection pool will automatically replace the workers. To suppress these expected error messages: +GCP gRPC endpoints have hardcoded timeouts that periodically close connections, causing connection-down error messages. This is normal behavior and the connection pool will automatically replace the workers. To suppress these expected error messages: ```elixir config :my_app, GrpcConnectionPool, @@ -981,7 +994,7 @@ config :my_app, GrpcConnectionPool, port: 443 ], connection: [ - suppress_connection_errors: true # Suppress expected gun_down errors from GCP + suppress_connection_errors: true # Suppress expected connection-down errors from GCP ] ``` @@ -1009,7 +1022,7 @@ mix test ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. +This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details. ## Acknowledgments diff --git a/lib/grpc_connection_pool/config.ex b/lib/grpc_connection_pool/config.ex index 9e4ef2f..1ff92ea 100644 --- a/lib/grpc_connection_pool/config.ex +++ b/lib/grpc_connection_pool/config.ex @@ -28,6 +28,10 @@ defmodule GrpcConnectionPool.Config do - `:health_check` - Whether to enable connection health checks (default: true) - `:ping_interval` - Interval to send ping to keep connection warm (default: 25_000) + > Note: gun's internal reconnect is disabled (`adapter_opts: [retry: 0]`) so the + > pool's own backoff governs reconnection and connects to a dead endpoint fail + > fast instead of stalling. This is not user-configurable. + ## Examples ### Production Configuration @@ -377,6 +381,12 @@ defmodule GrpcConnectionPool.Config do defp build_connection_opts(endpoint, connection) do base_opts = [ adapter_opts: [ + # Disable gun's internal reconnect (default 100 retries). Under grpc 1.0 the + # adapter's internal retry keeps a dead connection's owner process alive and + # makes connects to a dead endpoint block ~5s per attempt. With retry: 0 gun + # fails fast and dies on a drop, so this pool's own Backoff governs reconnects + # and the connection monitor in Worker can detect drops. See SPIKE-1. + retry: 0, http2_opts: %{keepalive: connection.keepalive} ] ] diff --git a/lib/grpc_connection_pool/worker.ex b/lib/grpc_connection_pool/worker.ex index c6d1e4e..ba4ca1b 100644 --- a/lib/grpc_connection_pool/worker.ex +++ b/lib/grpc_connection_pool/worker.ex @@ -9,10 +9,26 @@ defmodule GrpcConnectionPool.Worker do Features: - Channels stored in ETS for O(1) pool access (no GenServer.call in hot path) - Automatic reconnection with exponential backoff and jitter - - Active disconnect detection (gun_down/gun_error messages) + - Active disconnect detection by monitoring the gRPC connection process - Self-registration in Registry for health tracking - Optional periodic ping to keep connections warm - Configurable max reconnect attempts before crash + + ## Disconnect detection (grpc 1.0) + + grpc 1.0's Gun adapter owns the socket inside its own supervised + `GRPC.Client.Adapters.Gun.ConnectionProcess`, so gun's `:gun_down`/`:gun_error` + messages no longer reach this worker. Worse, when the connection drops the inner + gun process dies but the `ConnectionProcess` (the `conn_pid` in + `channel.adapter_payload`) lingers as a zombie — so monitoring `conn_pid` cannot + detect a drop. + + Instead we monitor the **inner gun process**, read out of the adapter's + `ConnectionProcess` state, and pair it with `adapter_opts: [retry: 0]` (see + `GrpcConnectionPool.Config`) so gun gives up immediately on a drop and this + pool's own `Backoff` governs reconnection. Reaching into the adapter state + couples us to a grpc internal; it is guarded and falls back to monitoring + `conn_pid` if the state shape changes. """ use GenServer require Logger @@ -32,7 +48,8 @@ defmodule GrpcConnectionPool.Worker do :ets_table, :connection_start, :reconnect_attempt, - :slot_index + :slot_index, + :conn_monitor_ref ] end @@ -87,7 +104,8 @@ defmodule GrpcConnectionPool.Worker do ets_table: ets_table, connection_start: nil, reconnect_attempt: 0, - slot_index: nil + slot_index: nil, + conn_monitor_ref: nil } {:ok, state} @@ -112,6 +130,10 @@ defmodule GrpcConnectionPool.Worker do Logger.debug("gRPC connection established for pool #{inspect(state.pool_name)}") + # Monitor the connection so a drop triggers reconnection. We monitor the + # inner gun process (which dies on drop), not the lingering conn_pid. + conn_monitor_ref = monitor_connection(channel) + # Register in Registry for health tracking Registry.register(state.registry_name, :channels, nil) @@ -148,7 +170,8 @@ defmodule GrpcConnectionPool.Worker do backoff_state: new_backoff, connection_start: now, reconnect_attempt: 0, - slot_index: slot + slot_index: slot, + conn_monitor_ref: conn_monitor_ref }} {:error, reason} -> @@ -217,50 +240,31 @@ defmodule GrpcConnectionPool.Worker do end end - # Active disconnect detection — Gun adapter - def handle_info({:gun_down, _conn_pid, protocol, reason, _killed_streams}, state) do + # Active disconnect detection — the monitored gRPC connection process died. + def handle_info( + {:DOWN, ref, :process, _pid, reason}, + %State{conn_monitor_ref: ref} = state + ) do :telemetry.execute( - [:grpc_connection_pool, :channel, :gun_down], - %{}, - %{pool_name: state.pool_name, reason: reason, protocol: protocol} - ) - - unless state.config.connection.suppress_connection_errors do - Logger.debug( - "gRPC connection closed by server for pool #{inspect(state.pool_name)}: #{inspect(reason)}" - ) - end - - handle_disconnect(state, {:gun_down, reason}) - end - - def handle_info({:gun_error, _conn_pid, reason}, state) do - :telemetry.execute( - [:grpc_connection_pool, :channel, :gun_error], + [:grpc_connection_pool, :channel, :connection_down], %{}, %{pool_name: state.pool_name, reason: reason} ) unless state.config.connection.suppress_connection_errors do - Logger.error( - "Gun connection error for pool #{inspect(state.pool_name)}: #{inspect(reason)}" + Logger.debug( + "gRPC connection down for pool #{inspect(state.pool_name)}: #{inspect(reason)}" ) end - handle_disconnect(state, {:gun_error, reason}) + handle_disconnect(state, {:connection_down, reason}) end - def handle_info({:gun_up, _conn_pid, _protocol}, state) do - Logger.debug("Gun connection is up for pool #{inspect(state.pool_name)}") + # A DOWN we no longer care about (already torn down / demonitored late). Ignore. + def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do {:noreply, state} end - # Active disconnect detection — Mint adapter - def handle_info({:elixir_grpc, :connection_down, _pid}, state) do - Logger.debug("Mint gRPC connection down for pool #{inspect(state.pool_name)}") - handle_disconnect(state, :mint_connection_down) - end - def handle_info({:EXIT, _pid, _reason}, state) do {:noreply, state} end @@ -279,13 +283,22 @@ defmodule GrpcConnectionPool.Worker do GRPC.Stub.connect("#{host}:#{port}", opts) end - defp send_ping(channel) do - case channel do - %GRPC.Channel{adapter_payload: %{conn_pid: pid}} when is_pid(pid) -> - if Process.alive?(pid), do: :ok, else: :error + # The connection monitor is the primary drop detector; this ping is a warm-keep + # plus backstop. It checks the inner gun process (the thing that actually dies on + # a drop) rather than conn_pid, which lingers as a zombie. + defp send_ping(%GRPC.Channel{adapter_payload: %{conn_pid: conn_pid}}) when is_pid(conn_pid) do + if Process.alive?(conn_pid), do: gun_alive?(conn_pid), else: :error + end - _ -> - :error + defp send_ping(_channel), do: :error + + # Liveness of the inner gun process. If it can't be introspected we return :ok to + # avoid false reconnects — the connection monitor (on the gun pid, set at connect) + # is the primary drop detector; this ping is only a warm-keep + backstop. + defp gun_alive?(conn_pid) do + case gun_pid(conn_pid) do + pid when is_pid(pid) -> if Process.alive?(pid), do: :ok, else: :error + _ -> :ok end end @@ -310,18 +323,13 @@ defmodule GrpcConnectionPool.Worker do defp cleanup_connection(%State{channel: channel, ping_timer: timer}) do cancel_ping_timer(timer) + # GRPC.Stub.disconnect/1 is the correct teardown under grpc 1.0 — it stops the + # adapter's ConnectionProcess (reaping the zombie left behind after a drop) and + # is safe/idempotent on an already-dropped channel. Guarded because this is a + # best-effort teardown path that must never crash terminate/2. try do - case channel do - %GRPC.Channel{adapter_payload: %{conn_pid: pid}} when is_pid(pid) -> - if Process.alive?(pid) do - :gun.close(pid) - end - - _ -> - GRPC.Stub.disconnect(channel) - end + GRPC.Stub.disconnect(channel) rescue - FunctionClauseError -> :ok _ -> :ok catch :exit, _ -> :ok @@ -353,6 +361,10 @@ defmodule GrpcConnectionPool.Worker do defp handle_disconnect(%State{} = state, reason) do now = System.monotonic_time() + # Drop the connection monitor and flush any DOWN already in the mailbox so a + # stale DOWN can't trigger a second disconnect after we've torn down. + demonitor_connection(state) + # Remove channel from ETS and Registry remove_channel_from_ets(state) @@ -392,7 +404,42 @@ defmodule GrpcConnectionPool.Worker do backoff_state: new_backoff, connection_start: nil, reconnect_attempt: new_attempt, - slot_index: nil + slot_index: nil, + conn_monitor_ref: nil }} end + + # Monitors the connection so a drop triggers reconnection. grpc 1.0 keeps the live + # gun process *inside* the Gun adapter's ConnectionProcess and never exposes it; + # on a drop the gun process dies but ConnectionProcess lingers as a zombie, so we + # monitor gun directly. gun also dies when its owner (conn_pid) dies, so this + # covers conn_pid crashes too. Falls back to monitoring conn_pid if the inner gun + # pid can't be read (grpc internal state shape changed). + defp monitor_connection(%GRPC.Channel{adapter_payload: %{conn_pid: conn_pid}}) + when is_pid(conn_pid) do + Process.monitor(gun_pid(conn_pid) || conn_pid) + end + + defp monitor_connection(_channel), do: nil + + defp demonitor_connection(%State{conn_monitor_ref: nil}), do: :ok + + defp demonitor_connection(%State{conn_monitor_ref: ref}) do + Process.demonitor(ref, [:flush]) + :ok + end + + # Reads the inner gun pid out of the Gun adapter's ConnectionProcess state. This + # couples us to a grpc internal (state shape %{gun_pid: pid, ...}); guarded and + # short-timed so a busy/dead ConnectionProcess can't block or crash the worker. + defp gun_pid(conn_pid) do + case :sys.get_state(conn_pid, 1_000) do + %{gun_pid: gun_pid} when is_pid(gun_pid) -> gun_pid + _ -> nil + end + rescue + _ -> nil + catch + :exit, _ -> nil + end end diff --git a/mix.exs b/mix.exs index 9c84450..abc09c6 100644 --- a/mix.exs +++ b/mix.exs @@ -1,7 +1,7 @@ defmodule GrpcConnectionPool.MixProject do use Mix.Project - @version "0.3.5" + @version "0.5.0" @source_url "https://github.com/nyo16/grpc_connection_pool" def project do @@ -10,6 +10,7 @@ defmodule GrpcConnectionPool.MixProject do version: @version, elixir: "~> 1.14", start_permanent: Mix.env() == :prod, + elixirc_paths: elixirc_paths(Mix.env()), deps: deps(), docs: docs(), package: package(), @@ -25,10 +26,15 @@ defmodule GrpcConnectionPool.MixProject do def application do [ - extra_applications: [:logger] + extra_applications: [:logger, :public_key, :ssl] ] end + # test/support holds shared test helpers (e.g. the Cowboy h2c server) compiled + # only in the test environment. + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + def cli do [ preferred_envs: [ @@ -53,7 +59,7 @@ defmodule GrpcConnectionPool.MixProject do [ description: description(), files: ~w(lib .formatter.exs mix.exs README* LICENSE*), - licenses: ["MIT"], + licenses: ["Apache-2.0"], links: %{ "GitHub" => @source_url, "Documentation" => "https://hexdocs.pm/grpc_connection_pool", @@ -94,7 +100,9 @@ defmodule GrpcConnectionPool.MixProject do defp deps do [ {:backoff, "~> 1.1"}, - {:grpc, "~> 0.11.5"}, + {:grpc, "~> 1.0"}, + # grpc >= 1.0 makes gun optional; we use the default Gun adapter, so require it explicitly + {:gun, "~> 2.2"}, {:telemetry, "~> 1.0"}, {:ex_doc, "~> 0.31", only: :dev, runtime: false}, {:excoveralls, "~> 0.18", only: :test}, @@ -105,7 +113,10 @@ defmodule GrpcConnectionPool.MixProject do {:goth, "~> 1.4", only: :test}, # Optional dependencies for testing and development - {:googleapis_proto_ex, "~> 0.3.3", only: :test} + {:googleapis_proto_ex, "~> 0.4.0", only: :test}, + # grpc >= 1.0 is client-only (no GRPC.Server); tests stand up a bare HTTP/2 + # listener with Cowboy to exercise the real connect path. + {:cowboy, "~> 2.14", only: :test} ] end end diff --git a/mix.lock b/mix.lock index 1ac61de..0c25426 100644 --- a/mix.lock +++ b/mix.lock @@ -1,40 +1,41 @@ %{ "backoff": {:hex, :backoff, "1.1.6", "83b72ed2108ba1ee8f7d1c22e0b4a00cfe3593a67dbc792799e8cce9f42f796b", [:rebar3], [], "hexpm", "cf0cfff8995fb20562f822e5cc47d8ccf664c5ecdc26a684cbe85c225f9d7c39"}, - "benchee": {:hex, :benchee, "1.5.0", "4d812c31d54b0ec0167e91278e7de3f596324a78a096fd3d0bea68bb0c513b10", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "5b075393aea81b8ae74eadd1c28b1d87e8a63696c649d8293db7c4df3eb67535"}, + "benchee": {:hex, :benchee, "1.5.1", "b95cbc36c4b98969a5c592a246e171041eb683c56bad1cb4f49a3b081ba66087", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}, {:statistex, "~> 1.1", [hex: :statistex, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a539301f8dfd4efc5c5123bfb9d47ebde20092a863a5b5b16c2a60d2243dfce7"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, "cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"}, "cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"}, - "credo": {:hex, :credo, "1.7.17", "f92b6aa5b26301eaa5a35e4d48ebf5aa1e7094ac00ae38f87086c562caf8a22f", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1eb5645c835f0b6c9b5410f94b5a185057bcf6d62a9c2b476da971cde8749645"}, - "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, + "credo": {:hex, :credo, "1.7.19", "cc52129665fc7c15143d47838fda0f9cd6dac9ceced7bf4da6f85fcbfe64b12a", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "2d8bc95d5a7bb99dd2613621d4f08c6a3575c3fd4b62e6a2b48a100352a557b8"}, + "deep_merge": {:hex, :deep_merge, "1.0.2", "476aa7ea61c54de96220051b998d893869069094da65b96101aebf79416f8a1e", [:mix], [], "hexpm", "737a53cdc9758fedbb608bdc213969e65729466c4ef3cd8e8726d0335dff116c"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.45", "cba8369ab2a1342e419bc2760eec731b17be828941dcf494045d44766227e1d5", [:mix], [], "hexpm", "d3ec045bf122965db20c0bdb420e19ee1415843135327124918473feb4b328e8"}, "erlex": {:hex, :erlex, "0.2.8", "cd8116f20f3c0afe376d1e8d1f0ae2452337729f68be016ea544a72f767d9c12", [:mix], [], "hexpm", "9d66ff9fedf69e49dc3fd12831e12a8a37b76f8651dd21cd45fcf5561a8a7590"}, - "ex_doc": {:hex, :ex_doc, "0.40.1", "67542e4b6dde74811cfd580e2c0149b78010fd13001fda7cfeb2b2c2ffb1344d", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "bcef0e2d360d93ac19f01a85d58f91752d930c0a30e2681145feea6bd3516e00"}, + "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, "excoveralls": {:hex, :excoveralls, "0.18.5", "e229d0a65982613332ec30f07940038fe451a2e5b29bce2a5022165f0c9b157e", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "523fe8a15603f86d64852aab2abe8ddbd78e68579c8525ae765facc5eae01562"}, "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, "finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"}, "flow": {:hex, :flow, "1.2.4", "1dd58918287eb286656008777cb32714b5123d3855956f29aa141ebae456922d", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}], "hexpm", "874adde96368e71870f3510b91e35bc31652291858c86c0e75359cbdd35eb211"}, "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"}, "googleapis": {:hex, :googleapis, "0.1.0", "13770f3f75f5b863fb9acf41633c7bc71bad788f3f553b66481a096d083ee20e", [:mix], [{:protobuf, "~> 0.12", [hex: :protobuf, repo: "hexpm", optional: false]}], "hexpm", "1989a7244fd17d3eb5f3de311a022b656c3736b39740db46506157c4604bd212"}, - "googleapis_proto_ex": {:hex, :googleapis_proto_ex, "0.3.3", "0a592b610efe2f8514aef67a7380e0c2ef365292d38ac8b71e770db4ac765c7d", [:mix], [{:grpc, "~> 0.11.5", [hex: :grpc, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.15.0", [hex: :protobuf, repo: "hexpm", optional: false]}], "hexpm", "7f4735c0a96e757baa3b288214b364e53b41c58943b01c883d5be405b4dab5e8"}, + "googleapis_proto_ex": {:hex, :googleapis_proto_ex, "0.4.0", "6c52a02fa3525a92087b0988bb0c024ef3616746bdec1286986483c376c6cd1f", [:mix], [{:grpc, "~> 1.0", [hex: :grpc, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.17", [hex: :protobuf, repo: "hexpm", optional: false]}], "hexpm", "4a679297d370a2b93e7c589436c70980115e3000faea17ec4957ecc41145b69e"}, "goth": {:hex, :goth, "1.4.5", "ee37f96e3519bdecd603f20e7f10c758287088b6d77c0147cd5ee68cf224aade", [:mix], [{:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:jose, "~> 1.11", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "0fc2dce5bd710651ed179053d0300ce3a5d36afbdde11e500d57f05f398d5ed5"}, - "grpc": {:hex, :grpc, "0.11.5", "5dbde9420718b58712779ad98fff1ef50349ca0fa7cc0858ae0f826015068654", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:cowboy, "~> 2.10", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowlib, "~> 2.12", [hex: :cowlib, repo: "hexpm", optional: false]}, {:flow, "~> 1.2", [hex: :flow, repo: "hexpm", optional: false]}, {:googleapis, "~> 0.1.0", [hex: :googleapis, repo: "hexpm", optional: false]}, {:gun, "~> 2.0", [hex: :gun, repo: "hexpm", optional: false]}, {:jason, ">= 0.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mint, "~> 1.5", [hex: :mint, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.14", [hex: :protobuf, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "0a5d8673ef16649bef0903bca01c161acfc148e4d269133b6834b2af1f07f45e"}, + "grpc": {:hex, :grpc, "1.0.1", "21ee63982445313b3ec1972e191f0eacade437f11411579b3515fbd697197832", [:mix], [{:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:grpc_core, "~> 1.0.1", [hex: :grpc_core, repo: "hexpm", optional: false]}, {:gun, "~> 2.2.0", [hex: :gun, repo: "hexpm", optional: true]}, {:mint, "~> 1.9", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "4f2d517542410bf93e4e846b54f5f708f29d3935feb1251dc250c77f2e7425ad"}, + "grpc_core": {:hex, :grpc_core, "1.0.1", "7484d6f016fa9956bac57a164473e651683bbb735a056c0e89db54d04c776266", [:mix], [{:googleapis, "~> 0.1.0", [hex: :googleapis, repo: "hexpm", optional: false]}, {:jason, ">= 0.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.17", [hex: :protobuf, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "caebb9c12bd963b1a7750ced1b95feebcec9a7b78f5c1ee8d6fab3f6ff288822"}, "gun": {:hex, :gun, "2.2.0", "b8f6b7d417e277d4c2b0dc3c07dfdf892447b087f1cc1caff9c0f556b884e33d", [:make, :rebar3], [{:cowlib, ">= 2.15.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}], "hexpm", "76022700c64287feb4df93a1795cff6741b83fb37415c40c34c38d2a4645261a"}, "hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"}, - "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, - "makeup_erlang": {:hex, :makeup_erlang, "1.0.3", "4252d5d4098da7415c390e847c814bad3764c94a814a0b4245176215615e1035", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "953297c02582a33411ac6208f2c6e55f0e870df7f80da724ed613f10e6706afd"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, - "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, + "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "poolex": {:hex, :poolex, "1.4.2", "e721e99b25b4f2aa834235712ccca72ba0b2b75d4ee43c83e097185850e1ece4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:telemetry_poller, "~> 1.0", [hex: :telemetry_poller, repo: "hexpm", optional: false]}], "hexpm", "57e861bcb085a77e631478f1c41bf6419d4c16253d41b2ede9031c7ea216ebf0"}, - "protobuf": {:hex, :protobuf, "0.15.0", "c9fc1e9fc1682b05c601df536d5ff21877b55e2023e0466a3855cc1273b74dcb", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "5d7bb325319db1d668838d2691c31c7b793c34111aec87d5ee467a39dac6e051"}, + "protobuf": {:hex, :protobuf, "0.17.0", "39e24e43c9648e148feba16ed51100b5b2028ea900b55460377b0476f6e10613", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "ca6c91f6f63e2c147b47f03eefd10b80538aa6fc55ff4b12b795efb786b0152f"}, "ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"}, - "statistex": {:hex, :statistex, "1.1.0", "7fec1eb2f580a0d2c1a05ed27396a084ab064a40cfc84246dbfb0c72a5c761e5", [:mix], [], "hexpm", "f5950ea26ad43246ba2cce54324ac394a4e7408fdcf98b8e230f503a0cba9cf5"}, - "telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"}, + "statistex": {:hex, :statistex, "1.1.1", "73612aa7f79e53c30569be065fd121e380f1cf57bc4c2da5b41be9246da18df9", [:mix], [], "hexpm", "310c4b49b34adf683de3103639006bed233ab54c08a4add65a531448e653857c"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, } diff --git a/test/grpc_connection_pool/connect_path_test.exs b/test/grpc_connection_pool/connect_path_test.exs index b5c40d8..1e4708e 100644 --- a/test/grpc_connection_pool/connect_path_test.exs +++ b/test/grpc_connection_pool/connect_path_test.exs @@ -1,24 +1,34 @@ defmodule GrpcConnectionPool.ConnectPathTest do @moduledoc """ Exercises the real connect path — worker → `GRPC.Stub.connect` → slot claim → - `get_channel/1` — against an in-process gRPC server, with no external emulator. + `get_channel/1` — against an in-process HTTP/2 server, with no external emulator. - The pool only needs the HTTP/2 connection to come up to register a channel; it - never dispatches an RPC here, so the embedded server implements no methods. + grpc >= 1.0 is client-only (`GRPC.Server` was removed), so we stand up a bare + Cowboy HTTP/2 (h2c) listener instead. The pool only needs the HTTP/2 connection + to come up to register a channel; it never dispatches an RPC here, so the + handler is a stub that is never invoked. """ use ExUnit.Case, async: false alias GrpcConnectionPool.{Config, Pool} - # Reuses a service definition from googleapis_proto_ex (test-only dep). No RPC - # methods are implemented because the pool never calls one in these tests. - defmodule TestServer do - use GRPC.Server, service: Google.Pubsub.V1.Publisher.Service + @listener __MODULE__.Listener + + # Cowboy handler stub — the pool never sends an RPC, so this is never called. + defmodule Handler do + def init(req, state), do: {:ok, :cowboy_req.reply(200, %{}, "", req), state} end setup do # Port 0 → OS-assigned free port (avoids cross-test port collisions). - {:ok, _pid, port} = GRPC.Server.start(TestServer, 0) + # `start_clear` auto-detects the HTTP/2 connection preface (h2c prior + # knowledge), which is what the gRPC Gun client speaks over plain HTTP. + dispatch = :cowboy_router.compile([{:_, [{:_, Handler, []}]}]) + + {:ok, _} = + :cowboy.start_clear(@listener, [{:port, 0}], %{env: %{dispatch: dispatch}}) + + port = :ranch.get_port(@listener) pool_name = :"connect_path_#{System.unique_integer([:positive])}" on_exit(fn -> @@ -28,7 +38,7 @@ defmodule GrpcConnectionPool.ConnectPathTest do :exit, _ -> :ok end - GRPC.Server.stop(TestServer) + :cowboy.stop_listener(@listener) end) %{port: port, pool_name: pool_name} diff --git a/test/grpc_connection_pool/disconnect_fix_test.exs b/test/grpc_connection_pool/disconnect_fix_test.exs deleted file mode 100644 index e23cbd1..0000000 --- a/test/grpc_connection_pool/disconnect_fix_test.exs +++ /dev/null @@ -1,191 +0,0 @@ -defmodule GrpcConnectionPool.DisconnectFixTest do - @moduledoc """ - Tests for the GRPC v0.11.5 disconnect fix that handles FunctionClauseError - by manually closing Gun connections when needed. - """ - use ExUnit.Case, async: true - - describe "GRPC v0.11.5 disconnect fix verification" do - test "pattern matching works for Gun channels" do - # Create a test process to simulate Gun connection - gun_pid = - spawn(fn -> - receive do - :close -> :ok - end - end) - - # Test our pattern matching logic - gun_channel = %GRPC.Channel{ - adapter: GRPC.Client.Adapters.Gun, - adapter_payload: %{conn_pid: gun_pid}, - host: "localhost", - port: 9999, - scheme: "http" - } - - # This is the same pattern matching used in the fix - result = - case gun_channel do - %GRPC.Channel{adapter_payload: %{conn_pid: pid}} when is_pid(pid) -> - if Process.alive?(pid) do - send(pid, :close) - :gun_close_attempted - else - :gun_already_dead - end - - _ -> - :fallback_to_grpc_disconnect - end - - assert result == :gun_close_attempted - - # Clean up - send(gun_pid, :close) - end - - test "pattern matching falls back for non-Gun channels" do - # Test with Mint adapter - mint_channel = %GRPC.Channel{ - adapter: GRPC.Client.Adapters.Mint, - adapter_payload: %{connection: :some_mint_connection}, - host: "localhost", - port: 9999, - scheme: "http" - } - - # This should fall through to the fallback case - result = - case mint_channel do - %GRPC.Channel{adapter_payload: %{conn_pid: pid}} when is_pid(pid) -> - :unexpected_gun_match - - _ -> - :fallback_to_grpc_disconnect - end - - assert result == :fallback_to_grpc_disconnect - end - - test "pattern matching handles dead Gun process" do - # Create and kill a process - dead_pid = spawn(fn -> :ok end) - # Ensure it's dead - Process.sleep(10) - refute Process.alive?(dead_pid) - - dead_gun_channel = %GRPC.Channel{ - adapter: GRPC.Client.Adapters.Gun, - adapter_payload: %{conn_pid: dead_pid}, - host: "localhost", - port: 9999, - scheme: "http" - } - - result = - case dead_gun_channel do - %GRPC.Channel{adapter_payload: %{conn_pid: pid}} when is_pid(pid) -> - if Process.alive?(pid) do - :gun_close_attempted - else - :gun_already_dead - end - - _ -> - :fallback_to_grpc_disconnect - end - - assert result == :gun_already_dead - end - - test "FunctionClauseError rescue block works" do - # Test the rescue pattern from our fix - result = - try do - # Simulate the GRPC v0.11.5 error - error = %FunctionClauseError{ - module: GRPC.Client.Connection, - function: :handle_call, - arity: 3 - } - - raise error - rescue - FunctionClauseError -> :caught_function_clause_error - _ -> :caught_other_error - catch - :exit, _ -> :caught_exit - end - - assert result == :caught_function_clause_error - end - - test "Gun :close API compatibility" do - # Test that :gun.close/1 behaves as expected - test_pid = - spawn(fn -> - receive do - :stop -> :ok - end - end) - - # This should not raise an error - try do - result = :gun.close(test_pid) - # Gun can return various results, we just ensure it doesn't crash - assert result in [:ok, {:error, :not_connected}, {:error, :closed}] || - is_tuple(result) || is_atom(result) - catch - # Gun might not be available in test environment - :error, :undef -> - # This is acceptable - Gun module might not be loaded - assert true - end - - send(test_pid, :stop) - end - - test "complete disconnect fix logic simulation" do - # Simulate the complete fix logic - gun_pid = - spawn(fn -> - receive do - :gun_close -> :ok - end - end) - - channel = %GRPC.Channel{ - adapter: GRPC.Client.Adapters.Gun, - adapter_payload: %{conn_pid: gun_pid}, - host: "localhost", - port: 9999, - scheme: "http" - } - - # This simulates the exact logic from our fix - cleanup_result = - try do - case channel do - %GRPC.Channel{adapter_payload: %{conn_pid: pid}} when is_pid(pid) -> - if Process.alive?(pid) do - # Simulate :gun.close(pid) without actually calling Gun - send(pid, :gun_close) - :gun_close_success - end - - _ -> - # Would call GRPC.Stub.disconnect(channel) here - :grpc_stub_disconnect - end - rescue - FunctionClauseError -> :function_clause_error_handled - _ -> :other_error_handled - catch - :exit, _ -> :exit_handled - end - - assert cleanup_result == :gun_close_success - end - end -end diff --git a/test/grpc_connection_pool/telemetry_test.exs b/test/grpc_connection_pool/telemetry_test.exs index dbf9ac5..b2066d5 100644 --- a/test/grpc_connection_pool/telemetry_test.exs +++ b/test/grpc_connection_pool/telemetry_test.exs @@ -1,280 +1,112 @@ defmodule GrpcConnectionPool.TelemetryTest do + @moduledoc """ + Telemetry tests driven by REAL connects and disconnects against a Cowboy h2c + server. Under grpc 1.0 the worker no longer receives gun messages, so disconnect + telemetry is exercised by killing the server-side connection (a genuine drop) + rather than synthesizing `:gun_down`/`:gun_error` messages. + """ use ExUnit.Case, async: false alias GrpcConnectionPool.{Config, Pool, Worker} + alias GrpcConnectionPool.TestServer @moduletag :telemetry - describe "Pool telemetry events" do - test "pool init telemetry event is emitted" do - pool_name = :"TelemetryTestPool_#{:erlang.unique_integer([:positive])}" - test_pid = self() - handler_id = "test-pool-init-handler-#{:erlang.unique_integer([:positive])}" - - :telemetry.attach( - handler_id, - [:grpc_connection_pool, :pool, :init], - fn event_name, measurements, metadata, _config -> - send(test_pid, {:telemetry, event_name, measurements, metadata}) - end, - nil - ) - - {:ok, config} = - Config.local( - host: "localhost", - port: 9999, - pool_size: 2, - pool: [name: pool_name, telemetry_interval: 60_000] - ) - - {:ok, _pid} = Pool.start_link(config, name: pool_name) + describe "pool telemetry events" do + test "pool init event is emitted" do + pool_name = :"TelemetryInit_#{System.unique_integer([:positive])}" + attach([[:grpc_connection_pool, :pool, :init]]) - assert_receive {:telemetry, [:grpc_connection_pool, :pool, :init], measurements, metadata}, - 1000 + {:ok, config} = Config.local(host: "localhost", port: 9999, pool_size: 2) + {:ok, _} = Pool.start_link(config, name: pool_name) + on_exit(fn -> safe_stop(pool_name) end) - assert measurements.pool_size == 2 - assert metadata.pool_name == pool_name - assert metadata.endpoint == "localhost:9999" - - :telemetry.detach(handler_id) - Pool.stop(pool_name) + assert_receive {:telemetry, [:grpc_connection_pool, :pool, :init], meas, meta}, 1_000 + assert meas.pool_size == 2 + assert meta.pool_name == pool_name + assert meta.endpoint == "localhost:9999" end end - describe "Worker telemetry events" do + describe "channel connect/disconnect telemetry (real drop)" do setup do - registry_name = :"WorkerTelemetryTestRegistry_#{:erlang.unique_integer([:positive])}" - pool_name = :"WorkerTelemetryTestPool_#{:erlang.unique_integer([:positive])}" - {:ok, _} = Registry.start_link(keys: :duplicate, name: registry_name) - - {:ok, config} = - Config.local( - host: "localhost", - port: 9999, - pool_size: 1, - connection: [ping_interval: 100, max_reconnect_attempts: 50] - ) + {ref, port} = TestServer.start!() + pool_name = :"TelemetryDrop_#{System.unique_integer([:positive])}" on_exit(fn -> - case Process.whereis(registry_name) do - pid when is_pid(pid) -> - Process.unlink(pid) - Process.exit(pid, :kill) - - _ -> - :ok - end + safe_stop(pool_name) + TestServer.stop(ref) end) - %{config: config, registry_name: registry_name, pool_name: pool_name} + %{ref: ref, port: port, pool_name: pool_name} end - @tag timeout: 20_000 - test "gun_down telemetry event is emitted", %{ - config: config, - registry_name: registry_name, - pool_name: pool_name - } do - test_pid = self() - handler_id = "test-gun-down-handler-#{:erlang.unique_integer([:positive])}" - - :telemetry.attach( - handler_id, - [:grpc_connection_pool, :channel, :gun_down], - fn event_name, measurements, metadata, _config -> - send(test_pid, {:telemetry, event_name, measurements, metadata}) - end, - nil - ) - - worker_opts = [ - config: config, - registry_name: registry_name, - pool_name: pool_name - ] - - {:ok, worker_pid} = Worker.start_link(worker_opts) - # Wait for connection attempt to complete/timeout (GRPC timeout is ~5s) - Process.sleep(6000) - - # Now the worker is ready to process messages - send(worker_pid, {:gun_down, make_ref(), :http2, :closed, []}) + @tag :capture_log + test "a real drop emits connection_down, disconnected, and reconnect_scheduled", ctx do + attach([ + [:grpc_connection_pool, :channel, :connected], + [:grpc_connection_pool, :channel, :connection_down], + [:grpc_connection_pool, :channel, :disconnected], + [:grpc_connection_pool, :channel, :reconnect_scheduled] + ]) - assert_receive {:telemetry, [:grpc_connection_pool, :channel, :gun_down], measurements, - metadata}, - 2000 + start_pool!(ctx.port, ctx.pool_name) + assert_receive {:telemetry, [:grpc_connection_pool, :channel, :connected], _, _}, 5_000 - assert measurements == %{} - assert metadata.pool_name == pool_name - assert metadata.reason == :closed - assert metadata.protocol == :http2 + TestServer.drop_connections(ctx.ref) - :telemetry.detach(handler_id) - GenServer.stop(worker_pid) - end - - @tag timeout: 20_000 - test "gun_error telemetry event is emitted", %{ - config: config, - registry_name: registry_name, - pool_name: pool_name - } do - test_pid = self() - handler_id = "test-gun-error-handler-#{:erlang.unique_integer([:positive])}" - - :telemetry.attach( - handler_id, - [:grpc_connection_pool, :channel, :gun_error], - fn event_name, measurements, metadata, _config -> - send(test_pid, {:telemetry, event_name, measurements, metadata}) - end, - nil - ) + assert_receive {:telemetry, [:grpc_connection_pool, :channel, :connection_down], cd_meas, + cd_meta}, + 2_000 - worker_opts = [ - config: config, - registry_name: registry_name, - pool_name: pool_name - ] + assert cd_meas == %{} + assert cd_meta.pool_name == ctx.pool_name + assert match?({:shutdown, _}, cd_meta.reason) - {:ok, worker_pid} = Worker.start_link(worker_opts) - # Wait for connection attempt to complete/timeout (GRPC timeout is ~5s) - Process.sleep(6000) - - send(worker_pid, {:gun_error, make_ref(), :stream_error}) - - assert_receive {:telemetry, [:grpc_connection_pool, :channel, :gun_error], measurements, - metadata}, - 2000 - - assert measurements == %{} - assert metadata.pool_name == pool_name - assert metadata.reason == :stream_error - - :telemetry.detach(handler_id) - GenServer.stop(worker_pid) - end - - @tag timeout: 20_000 - test "reconnect_scheduled telemetry event is emitted", %{ - config: config, - registry_name: registry_name, - pool_name: pool_name - } do - test_pid = self() - handler_id = "test-reconnect-scheduled-handler-#{:erlang.unique_integer([:positive])}" - - :telemetry.attach( - handler_id, - [:grpc_connection_pool, :channel, :reconnect_scheduled], - fn event_name, measurements, metadata, _config -> - send(test_pid, {:telemetry, event_name, measurements, metadata}) - end, - nil - ) + assert_receive {:telemetry, [:grpc_connection_pool, :channel, :disconnected], dis_meas, + dis_meta}, + 2_000 - worker_opts = [ - config: config, - registry_name: registry_name, - pool_name: pool_name - ] - - {:ok, worker_pid} = Worker.start_link(worker_opts) - # Wait for connection attempt to complete/timeout (GRPC timeout is ~5s) - Process.sleep(6000) - - # Trigger disconnect which schedules reconnection - send(worker_pid, {:gun_down, make_ref(), :http2, :closed, []}) + assert is_integer(dis_meas.duration) + assert dis_meta.pool_name == ctx.pool_name + assert match?({:connection_down, _}, dis_meta.reason) assert_receive {:telemetry, [:grpc_connection_pool, :channel, :reconnect_scheduled], - measurements, metadata}, - 2000 - - assert is_integer(measurements.delay_ms) - assert measurements.delay_ms > 0 - assert measurements.attempt >= 1 - assert metadata.pool_name == pool_name - assert metadata.reason == {:gun_down, :closed} + rs_meas, rs_meta}, + 2_000 - :telemetry.detach(handler_id) - GenServer.stop(worker_pid) + assert is_integer(rs_meas.delay_ms) and rs_meas.delay_ms > 0 + assert rs_meas.attempt >= 1 + assert rs_meta.pool_name == ctx.pool_name + assert match?({:connection_down, _}, rs_meta.reason) end - @tag timeout: 30_000 - test "reconnect_attempt increments on successive disconnects", %{ - config: config, - registry_name: registry_name, - pool_name: pool_name - } do - test_pid = self() - handler_id = "test-reconnect-increment-handler-#{:erlang.unique_integer([:positive])}" - - :telemetry.attach( - handler_id, - [:grpc_connection_pool, :channel, :reconnect_scheduled], - fn event_name, measurements, metadata, _config -> - send(test_pid, {:telemetry, event_name, measurements, metadata}) - end, - nil - ) + @tag :capture_log + test "the pool reconnects to the live server after a drop", ctx do + attach([[:grpc_connection_pool, :channel, :connected]]) - worker_opts = [ - config: config, - registry_name: registry_name, - pool_name: pool_name - ] - - {:ok, worker_pid} = Worker.start_link(worker_opts) - # Wait for connection attempt to complete/timeout (GRPC timeout is ~5s) - Process.sleep(6000) - - # First disconnect - send(worker_pid, {:gun_down, make_ref(), :http2, :closed, []}) - - assert_receive {:telemetry, [:grpc_connection_pool, :channel, :reconnect_scheduled], - measurements1, _metadata}, - 2000 + start_pool!(ctx.port, ctx.pool_name) + # initial connect + assert_receive {:telemetry, [:grpc_connection_pool, :channel, :connected], _, _}, 5_000 - # Attempt count depends on how many connection failures occurred before - # the disconnect. The important thing is it increments. - first_attempt = measurements1.attempt - assert first_attempt >= 1 + TestServer.drop_connections(ctx.ref) - # Wait for the reconnect attempt to fire and the connection to timeout. - # The backoff delay (~1-3s) + connect timeout (~5s) means we need to wait - # until the worker is idle again. We verify by calling status (GenServer.call - # will only return when the worker is not blocked in handle_info). - wait_for_worker_idle(worker_pid, 15_000) + # reconnect to the still-listening server + assert_receive {:telemetry, [:grpc_connection_pool, :channel, :connected], _, _}, 5_000 - # Second disconnect - send(worker_pid, {:gun_error, make_ref(), :timeout}) - - assert_receive {:telemetry, [:grpc_connection_pool, :channel, :reconnect_scheduled], - measurements2, _metadata}, - 2000 - - assert measurements2.attempt == first_attempt + 1 - - :telemetry.detach(handler_id) - GenServer.stop(worker_pid) + assert TestServer.wait_until( + fn -> match?({:ok, %GRPC.Channel{}}, Pool.get_channel(ctx.pool_name)) end, + 2_000 + ) end end - describe "Ping telemetry events" do + describe "ping telemetry events" do setup do - registry_name = :"PingTelemetryTestRegistry_#{:erlang.unique_integer([:positive])}" - pool_name = :"PingTelemetryTestPool_#{:erlang.unique_integer([:positive])}" + registry_name = :"PingTelemetryRegistry_#{System.unique_integer([:positive])}" + pool_name = :"PingTelemetryPool_#{System.unique_integer([:positive])}" {:ok, _} = Registry.start_link(keys: :duplicate, name: registry_name) - {:ok, config} = - Config.local( - host: "localhost", - port: 9999, - pool_size: 1, - connection: [ping_interval: 50] - ) - on_exit(fn -> case Process.whereis(registry_name) do pid when is_pid(pid) -> @@ -286,112 +118,72 @@ defmodule GrpcConnectionPool.TelemetryTest do end end) - %{config: config, registry_name: registry_name, pool_name: pool_name} + %{registry_name: registry_name, pool_name: pool_name} end - @tag timeout: 20_000 - test "ping telemetry event is not emitted when channel is nil (no-op)", %{ - config: config, - registry_name: registry_name, - pool_name: pool_name - } do - test_pid = self() - handler_id = "test-ping-handler-#{:erlang.unique_integer([:positive])}" - - :telemetry.attach( - handler_id, - [:grpc_connection_pool, :channel, :ping], - fn event_name, measurements, metadata, _config -> - send(test_pid, {:telemetry, event_name, measurements, metadata}) - end, - nil - ) - - worker_opts = [ - config: config, - registry_name: registry_name, - pool_name: pool_name - ] + test "ping is a no-op (no event) when no channel is connected", ctx do + attach([[:grpc_connection_pool, :channel, :ping]]) - {:ok, worker_pid} = Worker.start_link(worker_opts) + {:ok, config} = + Config.new( + endpoint: [type: :local, host: "localhost", port: 9999], + pool: [size: 1], + connection: [ping_interval: 50, max_reconnect_attempts: 1_000] + ) - # Wait for connection attempt to complete/timeout (GRPC timeout is ~5s) - Process.sleep(6000) + {:ok, worker_pid} = + Worker.start_link( + config: config, + registry_name: ctx.registry_name, + pool_name: ctx.pool_name + ) - # Manually trigger ping - # When no channel is connected, ping on nil channel returns early - # without telemetry + # Dead port + retry: 0 -> connect fails fast -> channel stays nil. + assert TestServer.wait_until(fn -> Worker.status(worker_pid) == :disconnected end, 1_000) send(worker_pid, :ping) + refute_receive {:telemetry, [:grpc_connection_pool, :channel, :ping], _, _}, 300 - # Give time for the message to be processed - Process.sleep(100) - - # Since no channel is connected, ping on nil channel returns early - # without telemetry. Let's verify by waiting briefly - refute_receive {:telemetry, [:grpc_connection_pool, :channel, :ping], _, _}, 500 - - :telemetry.detach(handler_id) GenServer.stop(worker_pid) end end - describe "Telemetry event structure validation" do - test "all new telemetry events follow the expected structure" do - # This test documents the expected telemetry event structure - - # [:grpc_connection_pool, :channel, :ping] - ping_measurements = %{duration: 12_345} - ping_metadata = %{pool_name: :test_pool, result: :ok} - assert Map.has_key?(ping_measurements, :duration) - assert Map.has_key?(ping_metadata, :pool_name) - assert Map.has_key?(ping_metadata, :result) - assert ping_metadata.result in [:ok, :error] - - # [:grpc_connection_pool, :channel, :gun_down] - gun_down_measurements = %{} - gun_down_metadata = %{pool_name: :test_pool, reason: :closed, protocol: :http2} - assert gun_down_measurements == %{} - assert Map.has_key?(gun_down_metadata, :pool_name) - assert Map.has_key?(gun_down_metadata, :reason) - assert Map.has_key?(gun_down_metadata, :protocol) - - # [:grpc_connection_pool, :channel, :gun_error] - gun_error_measurements = %{} - gun_error_metadata = %{pool_name: :test_pool, reason: :stream_error} - assert gun_error_measurements == %{} - assert Map.has_key?(gun_error_metadata, :pool_name) - assert Map.has_key?(gun_error_metadata, :reason) - - # [:grpc_connection_pool, :channel, :reconnect_scheduled] - reconnect_measurements = %{delay_ms: 1000, attempt: 1} - reconnect_metadata = %{pool_name: :test_pool, reason: {:gun_down, :closed}} - assert Map.has_key?(reconnect_measurements, :delay_ms) - assert Map.has_key?(reconnect_measurements, :attempt) - assert is_integer(reconnect_measurements.delay_ms) - assert is_integer(reconnect_measurements.attempt) - assert Map.has_key?(reconnect_metadata, :pool_name) - assert Map.has_key?(reconnect_metadata, :reason) - - # [:grpc_connection_pool, :pool, :init] - init_measurements = %{pool_size: 5} - init_metadata = %{pool_name: :test_pool, endpoint: "localhost:9999"} - assert Map.has_key?(init_measurements, :pool_size) - assert is_integer(init_measurements.pool_size) - assert Map.has_key?(init_metadata, :pool_name) - assert Map.has_key?(init_metadata, :endpoint) - assert is_binary(init_metadata.endpoint) - end + # Note: the live event shapes (connection_down, disconnected, reconnect_scheduled, + # connected, pool init) are asserted directly in the real-drop and pool-init tests + # above, so there is no separate hand-built "structure" test (it would be tautological). + + # --- helpers --- + + defp start_pool!(port, pool_name) do + {:ok, config} = + Config.new( + endpoint: [type: :local, host: "localhost", port: port], + pool: [size: 1, name: pool_name], + # No ping (monitor is the detector) + short backoff so reconnect is quick. + connection: [ping_interval: nil, backoff_min: 200] + ) + + {:ok, _} = Pool.start_link(config, name: pool_name) + assert :ok = Pool.await_ready(pool_name, 5_000) end - # Waits until the worker GenServer is idle (not blocked in a synchronous - # handle_info like GRPC.Stub.connect). A GenServer.call will only return - # once the worker has finished processing the current message. - defp wait_for_worker_idle(pid, timeout) do - task = - Task.async(fn -> - Worker.status(pid) - end) + defp attach(events) do + test_pid = self() + handler_id = "tele-#{System.unique_integer([:positive])}" + + :telemetry.attach_many( + handler_id, + events, + fn event, meas, meta, _ -> send(test_pid, {:telemetry, event, meas, meta}) end, + nil + ) + + on_exit(fn -> :telemetry.detach(handler_id) end) + :ok + end - Task.await(task, timeout) + defp safe_stop(pool_name) do + Pool.stop(pool_name) + catch + :exit, _ -> :ok end end diff --git a/test/grpc_connection_pool/worker_test.exs b/test/grpc_connection_pool/worker_test.exs index 42b47b1..d07aef2 100644 --- a/test/grpc_connection_pool/worker_test.exs +++ b/test/grpc_connection_pool/worker_test.exs @@ -1,17 +1,24 @@ defmodule GrpcConnectionPool.WorkerTest do + @moduledoc """ + Worker lifecycle tests against a real Cowboy h2c server. + + grpc 1.0's Gun adapter owns the socket in its own process, so the worker no + longer receives `:gun_down`/`:gun_error` messages; disconnect detection is done + by monitoring the inner gun process. These tests exercise the real connect → + drop → reconnect path and graceful teardown rather than synthesizing adapter + messages. + """ use ExUnit.Case, async: false alias GrpcConnectionPool.Config + alias GrpcConnectionPool.TestServer alias GrpcConnectionPool.Worker - describe "Worker termination with GRPC v0.11.5 disconnect fix" do + describe "worker teardown" do setup do - # Create a unique registry name for each test - registry_name = :"WorkerTestRegistry_#{:erlang.unique_integer()}" + registry_name = :"WorkerTestRegistry_#{System.unique_integer([:positive])}" {:ok, _} = Registry.start_link(keys: :duplicate, name: registry_name) - {:ok, config} = Config.local(host: "localhost", port: 9999, pool_size: 1) - on_exit(fn -> case Process.whereis(registry_name) do pid when is_pid(pid) -> @@ -23,292 +30,46 @@ defmodule GrpcConnectionPool.WorkerTest do end end) - %{config: config, registry_name: registry_name} - end - - test "Worker handles termination with Gun connection gracefully", %{ - config: config, - registry_name: registry_name - } do - # Start worker - worker_opts = [ - config: config, - registry_name: registry_name, - pool_name: :test_pool - ] - - {:ok, worker_pid} = Worker.start_link(worker_opts) - - # Let worker attempt connection (will fail but that's expected) - Process.sleep(100) - - # Terminate worker gracefully - this should trigger cleanup_connection - GenServer.stop(worker_pid, :normal) - - # Worker should terminate without crashing - refute Process.alive?(worker_pid) - end - - test "Worker handles Gun disconnect messages properly", %{ - config: config, - registry_name: registry_name - } do - # Start worker - worker_opts = [ - config: config, - registry_name: registry_name, - pool_name: :test_pool - ] - - {:ok, worker_pid} = Worker.start_link(worker_opts) - - # Let worker attempt connection - Process.sleep(100) - - # Simulate Gun disconnect message that could trigger the FunctionClauseError - # Send gun_down message to worker - send(worker_pid, {:gun_down, make_ref(), :http, :closed, []}) - - # Worker should handle this gracefully and not crash - Process.sleep(100) - assert Process.alive?(worker_pid) - - # Send gun_error message - send(worker_pid, {:gun_error, make_ref(), :stream_error}) - - # Worker should still be alive - Process.sleep(100) - assert Process.alive?(worker_pid) - - # Clean up - GenServer.stop(worker_pid) + %{registry_name: registry_name} end - test "Worker handles Mint disconnect messages properly", %{ - config: config, + @tag :capture_log + test "terminates gracefully when the endpoint is unreachable", %{ registry_name: registry_name } do - # Start worker - worker_opts = [ - config: config, - registry_name: registry_name, - pool_name: :test_pool - ] - - {:ok, worker_pid} = Worker.start_link(worker_opts) - - # Let worker attempt connection - Process.sleep(100) - - # Simulate Mint disconnect message - send(worker_pid, {:gun_down, make_ref(), :http, :connection_down, []}) - - # Worker should handle this gracefully - Process.sleep(100) - assert Process.alive?(worker_pid) - - # Clean up - GenServer.stop(worker_pid) - end - end - - describe "GRPC disconnect fix edge cases" do - setup do - registry_name = :"WorkerEdgeCaseTest_#{:erlang.unique_integer()}" - {:ok, _} = Registry.start_link(keys: :duplicate, name: registry_name) - + # retry: 0 makes a dead-port connect fail fast, so the worker just keeps its + # channel nil. Termination must not crash even with no live connection. {:ok, config} = Config.local(host: "localhost", port: 9999, pool_size: 1) - on_exit(fn -> - case Process.whereis(registry_name) do - pid when is_pid(pid) -> - Process.unlink(pid) - Process.exit(pid, :kill) + {:ok, worker_pid} = + Worker.start_link(config: config, registry_name: registry_name, pool_name: :worker_test) - _ -> - :ok - end - end) - - %{config: config, registry_name: registry_name} - end - - test "Worker survives FunctionClauseError during GRPC operations", %{ - config: config, - registry_name: registry_name - } do - # Start worker that will encounter connection issues - worker_opts = [ - config: config, - registry_name: registry_name, - pool_name: :test_pool - ] - - {:ok, worker_pid} = Worker.start_link(worker_opts) - - # Let worker attempt initial connection - Process.sleep(200) - - # Worker should be alive even if connection fails - assert Process.alive?(worker_pid) - - # Trigger multiple reconnection attempts by sending disconnect signals - for _i <- 1..3 do - send(worker_pid, {:gun_down, make_ref(), :http, :closed, []}) - Process.sleep(50) - assert Process.alive?(worker_pid) - end - - # Clean up - GenServer.stop(worker_pid) - end - - test "Worker handles rapid Gun connection state changes", %{ - config: config, - registry_name: registry_name - } do - worker_opts = [ - config: config, - registry_name: registry_name, - pool_name: :test_pool - ] - - {:ok, worker_pid} = Worker.start_link(worker_opts) - Process.sleep(100) - - # Send rapid Gun state changes that could trigger race conditions - gun_ref = make_ref() - - # Rapid fire of different Gun messages - messages = [ - {:gun_down, gun_ref, :http, :closed, []}, - {:gun_error, gun_ref, :stream_error}, - {:gun_down, gun_ref, :http, :timeout, []}, - {:gun_error, gun_ref, :connection_error} - ] - - for msg <- messages do - send(worker_pid, msg) - # Small delay to avoid overwhelming - Process.sleep(10) - end + assert TestServer.wait_until(fn -> Worker.status(worker_pid) == :disconnected end, 1_000) - # Worker should survive all messages - Process.sleep(100) - assert Process.alive?(worker_pid) - - GenServer.stop(worker_pid) - end - - @tag timeout: 20_000 - test "Worker cleanup is idempotent", %{config: config, registry_name: registry_name} do - # Test that worker can be terminated gracefully multiple times - worker_opts = [ - config: config, - registry_name: registry_name, - pool_name: :test_pool - ] - - {:ok, worker_pid} = Worker.start_link(worker_opts) - - # Wait for connection attempt to timeout (GRPC connect ~5s) - Process.sleep(6000) - - # First termination should work - GenServer.stop(worker_pid, :normal, 5000) - - # Worker should be dead + GenServer.stop(worker_pid, :normal, 5_000) refute Process.alive?(worker_pid) end - end - describe "GRPC v0.11.5 compatibility verification" do - test "Gun :close call pattern matches expected interface" do - # Verify that :gun.close/1 exists and accepts a PID - # This test ensures the fix uses the correct Gun API + @tag :capture_log + test "cleanup is idempotent after a real drop", %{registry_name: registry_name} do + {ref, port} = TestServer.start!() + on_exit(fn -> TestServer.stop(ref) end) - # Create a dummy process to test Gun close - test_pid = - spawn(fn -> - receive do - :stop -> :ok - end - end) + {:ok, config} = Config.local(host: "localhost", port: port, pool_size: 1) - # This should not raise an error (Gun API compatibility check) - try do - result = :gun.close(test_pid) - # Gun close can return various results, we just want to ensure it doesn't crash - assert result in [:ok, {:error, :not_connected}, {:error, :closed}] || - is_tuple(result) || is_atom(result) - catch - # If Gun is not available, that's fine for this test - :error, :undef -> :ok - end - - send(test_pid, :stop) - end + {:ok, worker_pid} = + Worker.start_link(config: config, registry_name: registry_name, pool_name: :worker_test) - test "GRPC Channel structure matches expected pattern" do - # Test the pattern matching used in the fix - gun_channel = %GRPC.Channel{ - adapter: GRPC.Client.Adapters.Gun, - adapter_payload: %{conn_pid: self()}, - host: "localhost", - port: 9999, - scheme: "http" - } + assert TestServer.wait_until(fn -> Worker.status(worker_pid) == :connected end, 5_000) - # Verify our pattern matching works - case gun_channel do - %GRPC.Channel{adapter_payload: %{conn_pid: pid}} when is_pid(pid) -> - assert is_pid(pid) - assert pid == self() + # Real drop: kill the server-side connection so the client's gun process dies. + TestServer.drop_connections(ref) + assert TestServer.wait_until(fn -> Worker.status(worker_pid) == :disconnected end, 2_000) - _ -> - flunk("Pattern matching failed for Gun channel structure") - end - - # Test non-Gun channel (fallback case) - mint_channel = %GRPC.Channel{ - adapter: GRPC.Client.Adapters.Mint, - adapter_payload: %{other_field: "value"}, - host: "localhost", - port: 9999, - scheme: "http" - } - - case mint_channel do - %GRPC.Channel{adapter_payload: %{conn_pid: _pid}} -> - flunk("Should not match Gun pattern for Mint channel") - - _ -> - # This is expected - fallback to standard disconnect - assert true - end - end - - test "FunctionClauseError handling works correctly" do - # Test that our rescue block catches FunctionClauseError - result = - try do - # Simulate a function that raises FunctionClauseError - # Note: In Elixir 1.19+, raise/2 with FunctionClauseError might behave differently - error = %FunctionClauseError{ - module: GRPC.Client.Connection, - function: :handle_call, - arity: 3 - } - - raise error - rescue - FunctionClauseError -> :caught_function_clause_error - _ -> :caught_other_error - catch - :exit, _ -> :caught_exit - end - - assert result == :caught_function_clause_error + # terminate/2 runs cleanup_connection again — must not crash on the already + # torn-down channel. + GenServer.stop(worker_pid, :normal, 5_000) + refute Process.alive?(worker_pid) end end end diff --git a/test/grpc_connection_pool_test.exs b/test/grpc_connection_pool_test.exs index dac30be..04cc07b 100644 --- a/test/grpc_connection_pool_test.exs +++ b/test/grpc_connection_pool_test.exs @@ -30,9 +30,11 @@ defmodule GrpcConnectionPoolTest do describe "pool management" do setup do - # Create a config for local testing - {:ok, config} = Config.local(host: "localhost", port: 8085, pool_size: 2) - pool_name = :"test_pool_#{:rand.uniform(10000)}" + # Port 9999 must be closed: these tests assert "no server running" behavior. + # (8085 was used previously but collides with the Pub/Sub emulator; with the + # grpc 1.0 fast-fail connect a live 8085 now connects before the assertion.) + {:ok, config} = Config.local(host: "localhost", port: 9999, pool_size: 2) + pool_name = :"test_pool_#{System.unique_integer([:positive])}" %{config: config, pool_name: pool_name} end diff --git a/test/support/grpc_test_server.ex b/test/support/grpc_test_server.ex new file mode 100644 index 0000000..4e76b23 --- /dev/null +++ b/test/support/grpc_test_server.ex @@ -0,0 +1,92 @@ +defmodule GrpcConnectionPool.TestServer do + @moduledoc """ + A bare Cowboy HTTP/2 (h2c) listener for exercising the real connect/disconnect + path in tests. + + grpc >= 1.0 is client-only (`GRPC.Server` was removed), so tests stand up a + plain Cowboy h2c listener for the gRPC Gun client to connect to. The pool only + needs the HTTP/2 connection to come up; it never dispatches an RPC here, so the + handler is a stub that is never invoked. + """ + + # Cowboy handler stub — the pool never sends an RPC, so this is never called. + defmodule Handler do + @moduledoc false + def init(req, state), do: {:ok, :cowboy_req.reply(200, %{}, "", req), state} + end + + @doc """ + Starts an h2c listener on an OS-assigned free port. Returns `{ref, port}`. + + `start_clear` auto-detects the HTTP/2 connection preface (h2c prior knowledge), + which is what the gRPC Gun client speaks over plain HTTP. + """ + @spec start!(atom()) :: {atom(), :inet.port_number()} + def start!(ref \\ unique_ref()) do + dispatch = :cowboy_router.compile([{:_, [{:_, Handler, []}]}]) + {:ok, _} = :cowboy.start_clear(ref, [{:port, 0}], %{env: %{dispatch: dispatch}}) + {ref, :ranch.get_port(ref)} + end + + @doc "Stops the listener. Safe to call if already stopped." + @spec stop(atom()) :: :ok + def stop(ref) do + :cowboy.stop_listener(ref) + :ok + catch + :exit, _ -> :ok + end + + @doc """ + Force-drops all established connections by killing the ranch connection + processes, simulating a server-side crash/RST. + + This is the reliable way to surface a real disconnect to the client: unlike + `stop_listener/1` (which only stops the acceptor and leaves established + connections open), killing the connection process closes the live socket, so + the client's gun process dies and the pool detects the drop. + """ + @spec drop_connections(atom()) :: :ok + def drop_connections(ref) do + case :ranch.procs(ref, :connections) do + [] -> + # Surface an ordering bug loudly: call this only after the client has + # connected, otherwise there is nothing to drop and the test would + # silently miss the disconnect it means to trigger. + raise "TestServer.drop_connections/1: no established connections on #{inspect(ref)}" + + conns -> + Enum.each(conns, &Process.exit(&1, :kill)) + end + + :ok + end + + @doc """ + Polls `fun` until it returns true; returns `true` if it did, `false` on timeout. + + Returns a boolean (rather than `:ok`) so callers can `assert` on it — a silent + `:ok` on timeout would let a test proceed against an unmet precondition. + """ + @spec wait_until((-> boolean()), non_neg_integer()) :: boolean() + def wait_until(fun, timeout) do + deadline = System.monotonic_time(:millisecond) + timeout + do_wait_until(fun, deadline) + end + + defp do_wait_until(fun, deadline) do + cond do + fun.() -> + true + + System.monotonic_time(:millisecond) >= deadline -> + false + + true -> + Process.sleep(25) + do_wait_until(fun, deadline) + end + end + + defp unique_ref, do: :"grpc_test_server_#{System.unique_integer([:positive])}" +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 2ad4302..be2814b 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,5 +1,5 @@ -# Start the GRPC Client Supervisor as required by grpc >= 0.11.0 -{:ok, _pid} = DynamicSupervisor.start_link(strategy: :one_for_one, name: GRPC.Client.Supervisor) +# grpc >= 1.0 starts GRPC.Client.Supervisor itself via GRPC.Client.Application, +# so no manual supervisor start is needed here. # `:emulator`-tagged tests are opt-in integration tests that require a live # Google Pub/Sub emulator on localhost:8085. Exclude them by default so a plain