Skip to content

fix(mint): standardize adapter_payload shape on disconnect#511

Merged
sleipnir merged 2 commits intoelixir-grpc:masterfrom
arctarus:fix/mint-nil-adapter-payload-on-disconnect
Mar 23, 2026
Merged

fix(mint): standardize adapter_payload shape on disconnect#511
sleipnir merged 2 commits intoelixir-grpc:masterfrom
arctarus:fix/mint-nil-adapter-payload-on-disconnect

Conversation

@arctarus
Copy link
Copy Markdown
Contributor

@arctarus arctarus commented Mar 23, 2026

Problem

The Mint adapter sets adapter_payload: nil after disconnect/1, while the Gun adapter sets adapter_payload: %{conn_pid: nil}. This inconsistency causes a crash in GRPC.Stub.call/5, which accesses ch.adapter_payload.conn_pid without guarding against a nil adapter_payload:

# lib/grpc/stub.ex — before fix
if Process.alive?(ch.adapter_payload.conn_pid) do
  # ...
end

When using the Mint adapter, any RPC call made after disconnect crashes with:

  • BadMapError: expected a map, got: nil — when adapter_payload is nil and .conn_pid is accessed on it.
  • ArgumentError: Can't perform a request without a connection process — when the Mint adapter's own guard catches the nil first.
  • Process exit — when the fallback virtual channel has conn_pid: nil and GenServer.call is attempted on it.

The Gun adapter never hits this path because it preserves the map shape %{conn_pid: nil} on disconnect.

Root cause

In GRPC.Client.Adapters.Mint.disconnect/1:

# Before (broken)
def disconnect(%{adapter_payload: %{conn_pid: pid}} = channel) when is_pid(pid) do
  :ok = ConnectionProcess.disconnect(pid)
  {:ok, %{channel | adapter_payload: nil}}            # ← sets nil
end

vs. GRPC.Client.Adapters.Gun.disconnect/1:

# Gun (correct)
def disconnect(%{adapter_payload: %{conn_pid: gun_pid}} = channel) when is_pid(gun_pid) do
  :ok = :gun.shutdown(gun_pid)
  {:ok, %{channel | adapter_payload: %{conn_pid: nil}}}  # ← keeps map shape
end

Fix

1. Standardize Mint's disconnect/1 to match Gun's convention

adapter_payload now stays as %{conn_pid: nil} after disconnect instead of being set to nil:

def disconnect(%{adapter_payload: %{conn_pid: pid}} = channel) when is_pid(pid) do
  :ok = ConnectionProcess.disconnect(pid)
  {:ok, %{channel | adapter_payload: %{conn_pid: nil}}}
end

def disconnect(%{adapter_payload: %{conn_pid: nil}} = channel) do
  {:ok, channel}
end

2. Update Mint's guard clauses

send_request/3 and send_headers/2 now match on %{conn_pid: nil} instead of adapter_payload: nil:

def send_request(%{channel: %{adapter_payload: %{conn_pid: nil}}}, _message, _opts),
  do: raise(ArgumentError, "Can't perform a request without a connection process")

def send_headers(%{channel: %{adapter_payload: %{conn_pid: nil}}}, _opts),
  do: raise("Can't start a client stream without a connection process")

3. Defense-in-depth in Stub.call/5

Added a pattern matching guard on the pick_channel result so that only channels with a valid map adapter_payload enter the Process.alive? path. Channels with nil or missing adapter_payload fall through to the catch-all, which logs a warning and falls back to the stream's channel:

case Connection.pick_channel(channel, opts) do
  {:ok, %Channel{adapter_payload: adapter_payload} = ch} when is_map(adapter_payload) ->
    conn_pid = Map.get(adapter_payload, :conn_pid)

    if is_pid(conn_pid) and Process.alive?(conn_pid) do
      ch
    else
      Logger.warning(...)
      channel
    end

  _ ->
    channel
end

Files changed

File Change
grpc_client/lib/grpc/client/adapters/mint.ex Return %{conn_pid: nil} on disconnect; update guard clauses
grpc_client/lib/grpc/stub.ex Pattern matching guard on adapter_payload to prevent BadMapError
grpc_client/test/grpc/adapters/gun_test.exs Add disconnect/1 tests (shape + idempotency)
grpc_client/test/grpc/adapters/mint_test.exs Add disconnect/1 tests (shape + idempotency + error on nil conn_pid)

Test plan

  • mix test test/grpc/adapters/gun_test.exs — disconnect keeps %{conn_pid: nil}
  • mix test test/grpc/adapters/mint_test.exs — disconnect keeps %{conn_pid: nil}, raises ArgumentError on RPC
  • mix test — full suite passes (219 tests, 0 failures)

@arctarus arctarus changed the title fix(mint): standardize adapter_payload shape on disconnect to prevent… fix(mint): standardize adapter_payload shape on disconnect Mar 23, 2026
@arctarus arctarus force-pushed the fix/mint-nil-adapter-payload-on-disconnect branch from 57650d6 to 3e88ee2 Compare March 23, 2026 18:41
… BadMapError

The Mint adapter set `adapter_payload: nil` on disconnect, while Gun kept
`adapter_payload: %{conn_pid: nil}`. This inconsistency caused
`GRPC.Stub.call/5` to crash with a BadMapError when accessing
`ch.adapter_payload.conn_pid` after a Mint disconnect.

- Mint.disconnect/1 now returns `%{conn_pid: nil}` matching Gun's convention
- Mint's nil guards (send_request/3, send_headers/2) match `%{conn_pid: nil}`
- Stub.call/5 uses pattern matching guard on adapter_payload as defense
- Added disconnect/1 tests to both adapter test files

Made-with: Cursor
@arctarus arctarus force-pushed the fix/mint-nil-adapter-payload-on-disconnect branch from 3e88ee2 to 6723431 Compare March 23, 2026 18:43
@sleipnir sleipnir merged commit abc5e1e into elixir-grpc:master Mar 23, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants