Skip to content

fix(client): prune a channel when its adapter connection process dies - #570

Open
pfitz wants to merge 1 commit into
elixir-grpc:masterfrom
pfitz:fix/prune-dead-channel-on-adapter-exit
Open

fix(client): prune a channel when its adapter connection process dies#570
pfitz wants to merge 1 commit into
elixir-grpc:masterfrom
pfitz:fix/prune-dead-channel-on-adapter-exit

Conversation

@pfitz

@pfitz pfitz commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Fixes #569.

Problem

Adapter connection processes are linked to GRPC.Client.Connection, but the {:EXIT, …} handler only logged a warning. The channel stayed {:connected, ch} in real_channels and stayed in the load balancer's list, so pick_channel/2 kept handing out a channel whose conn_pid was dead.

Callers then fail with :noproc — the adapters guard only against conn_pid: nil (mint.ex:84), and a dead pid is not nil, so it falls through to GenServer.call(dead_pid, …).

lb_mod.update/2 has a single call site, reachable only from the re-resolution path, so recovery depended entirely on a resolver tick. Only Resolver.DNS implements the optional init/2 that starts that timer, so for ipv4: / ipv6: / unix: targets no tick is ever coming and the dead channel was served for the lifetime of the connection.

Change

In the exit handler, when the exiting pid backs one of our channels:

  1. Mark that channel {:failed, reason}.
  2. Run it through the existing rebalance_after_reconcile/2, so the balancer stops picking it.
  3. If it was the last connected channel, clear established? and schedule :retry_establish.

Step 3 matters: pruning alone would swap "serves a dead channel" for "serves nothing" on literal-address targets, since nothing else would ever rebuild it. :retry_establish is a no-op while established? is true, so that flag has to be cleared for the retry to run.

Exits from pids that don't back a channel keep the previous behaviour (log and ignore) — including the resolver worker, whose gating in the clause above is untouched.

Two smaller things that follow from the change:

  • Emits [:grpc, :client, :connection, :channel_pruned] (measurement :remaining, metadata adds :reason and :address), documented in the moduledoc alongside the existing events. It makes the prune observable, and it's what lets the tests synchronise without polling — the exit signal originates in the dying process, so it isn't ordered against anything the test process sends.
  • "No healthy channels available after re-resolution""No healthy channels available", since that branch is now reachable outside re-resolution.

Tests

New GRPC.Test.ProcessClientAdapter backs each channel with a real linked process recorded as conn_pid, the way Gun and Mint do — GRPC.Test.ClientAdapter returns the channel untouched, so there was no process to kill.

Three tests in a new "adapter connection process exits" block:

Test Asserts
stops picking a channel whose adapter process died two addresses, kill one → the survivor is picked, never the dead pid
re-establishes when the last remaining channel's adapter process dies one address, kill it → a second :connected event, then a live pid
an unrelated linked exit leaves the channels alone a stray {:EXIT, …} prunes nothing

The first two fail on master and pass with the change; the third passes either way and is there as a regression guard.

mix test in grpc/: 370 passed, 2 skipped. mix format --check-formatted clean.

Verified end to end

Against the reproduction in #569, ipv4: target — before, step 3 returned the dead pid and step 5 still did after a full resolve_interval. After:

1. picked        conn_pid=#PID<0.3399.0> alive?=true
[warning] GRPC.Client.Connection adapter connection #PID<0.3399.0> exited: :killed, pruning its channel from the load balancer
[warning] No healthy channels available
2. killed it.    alive?=false
3. picked again  conn_pid=#PID<0.3401.0> alive?=true (same? false)
4. waiting 35s for a resolver tick...
5. after tick    conn_pid=#PID<0.3401.0> alive?=true (still dead pid? false)

The dns:// target recovers the same way, now in ~100ms rather than up to 30s.

Not addressed here

When several addresses are connected and one dies, the failed address is pruned but not re-dialled until the next reconcile — so on a literal-address target it stays out. That felt like a separate change (it needs per-address retry state), and the current behaviour is still strictly better than serving a dead channel. Happy to fold it in if you'd rather.

This also doesn't touch the missing failure-feedback callback on GRPC.Client.LoadBalancing, which is the deeper gap described in #569 — that's a design call for you rather than something to slip into a bug fix.

Adapter connection processes are linked to `GRPC.Client.Connection`, but
the `{:EXIT, ...}` handler only logged a warning. The channel stayed
`{:connected, ch}` in `real_channels` and stayed in the load balancer's
list, so `pick_channel/2` kept handing out a channel whose `conn_pid` was
dead. Callers then fail with `:noproc` from `GenServer.call/3` -- the
adapters only guard against `conn_pid: nil`, and a dead pid is not nil.

`lb_mod.update/2` has a single call site, reachable only from the
re-resolution path, so recovery depended entirely on a resolver tick.
Only `Resolver.DNS` implements the optional `init/2` that starts that
timer, so for `ipv4:`, `ipv6:` and `unix:` targets no tick ever comes and
the dead channel was served for the lifetime of the connection.

Handle the exit instead: mark the channel failed, rebalance the load
balancer so it stops being picked, and -- when it was the last connected
channel -- clear `established?` and restart the establish loop, which is
otherwise a no-op while `established?` is true.

Emit `[:grpc, :client, :connection, :channel_pruned]` so the prune is
observable, and generalise the "no healthy channels" warning, which is
now reachable outside re-resolution.
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.

GRPC.Client.Connection never prunes a dead channel — permanent for non-DNS targets

1 participant