Skip to content

fix: stop leaking client supervisor children on failed and closed connections#554

Merged
polvalente merged 6 commits into
elixir-grpc:masterfrom
mvanhorn:fix/531-grpc-client-supervisor-memory-leak
Jul 27, 2026
Merged

fix: stop leaking client supervisor children on failed and closed connections#554
polvalente merged 6 commits into
elixir-grpc:masterfrom
mvanhorn:fix/531-grpc-client-supervisor-memory-leak

Conversation

@mvanhorn

Copy link
Copy Markdown
Contributor

Summary

Short-lived client connections no longer leak GRPC.Client.Supervisor children. Orchestrator processes that fail to finalize a connection are terminated instead of being left under the DynamicSupervisor, and disconnected connections are fully cleaned up, so the supervisor's binary memory stays flat under connect/disconnect churn.

Why this matters

Reported in #531: a user running hundreds of short-lived connections (GRPC.Stub.connect/2 then GRPC.Stub.disconnect/1) on 1.0.0-rc.1 sees GRPC.Client.Supervisor top :recon.proc_count(:memory, 10) and :recon.bin_leak(10) with persistent growth; downgrading to 0.10.2 (no DynamicSupervisor in the client path) eliminates it, and a collaborator confirmed the diagnosis and labeled it a bug. The regression is in the 0.11+/1.0 client connection architecture: GRPC.Client.Connection.connect/2 leaves the orchestrator child running when finalize_connection/2 returns {:error, :no_connection}, and the disconnect path leaves per-connection state behind.

Changes

  • connect/2 now terminates the orchestrator child when connection finalization fails, so error paths cannot accumulate supervised processes.
  • The disconnect path releases the connection's supervised resources instead of leaving the child alive under GRPC.Client.Supervisor.

Testing

Added regression tests that drive repeated connect/disconnect cycles and connect-failure cycles, asserting DynamicSupervisor.count_children/1 on GRPC.Client.Supervisor returns to baseline after each cycle. Ran the client connection test files with mix test.

Fixes #531

@sleipnir

Copy link
Copy Markdown
Collaborator

@mvanhorn Some tests are failing, could you please check them?

@mvanhorn

Copy link
Copy Markdown
Contributor Author

The OTP-matrix failures were the connection tests asserting active == 0 on the shared GRPC.Client.Supervisor — under the full suite other tests leave children behind (CI showed active: 44), so the assertions failed even though the lifecycle fix works. 5a6f752 captures the supervisor's pre-existing child count in setup and asserts the count returns to that baseline instead. All 14 connection tests pass locally.

@polvalente

Copy link
Copy Markdown
Contributor

@mvanhorn could you resolve the merge conflicts?

mvanhorn added 2 commits July 24, 2026 21:38
A connection that reports ready but cannot hand out a channel stays under
GRPC.Client.Supervisor forever: connect/2 returns {:error, :no_connection}
to the caller and nothing terminates the child. Terminate it on the
finalize-failure path, mirroring the existing terminate_child on the
ready_status error path.

Also give GRPC.Client.Supervisor hibernate_after and a fullsweep_after
spawn_opt so long-lived connection processes release binary-heavy heaps
instead of accumulating them.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Adds a resolver that establishes and then drops its LB table so connect/2
takes the finalize-failure path, and asserts the child is gone afterwards.

Supervisor assertions capture the pre-existing child count in setup rather
than asserting zero on the shared GRPC.Client.Supervisor, which fails under
the full suite where other tests leave children behind.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@mvanhorn
mvanhorn force-pushed the fix/531-grpc-client-supervisor-memory-leak branch from 5a6f752 to e4fdaae Compare July 25, 2026 04:41
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Rebased onto master — conflicts resolved, mix test is green (366 passed, 2 skipped) and mix format --check-formatted is clean.

Worth flagging what the rebase surfaced: #557 already added terminate_child on the ready_status error path, so half of this PR landed independently. What's left is the finalize_connection failure path — a connection that reports ready but can't hand out a channel still returns {:error, :no_connection} to the caller while staying under GRPC.Client.Supervisor. That's the leak this now fixes, in the shape connect/2 has after #557.

I also dropped my restart: :temporary change and the test that asserted a crashed connection is not restarted. #557's supervised connections deliberately restart on crash (and terminate/2 documents relying on it), so :transient stays as you have it.

Remaining diff is the finalize-path cleanup plus a hibernate_after / fullsweep_after spawn_opt on the client supervisor so long-lived connection processes release binary-heavy heaps.

Comment thread grpc/lib/grpc/client/application.ex
Comment thread grpc/lib/grpc/client/connection.ex Outdated
# A connection that reported ready but cannot hand out a
# channel is unusable; leaving it under the supervisor is the
# leak this fixes.
_ = DynamicSupervisor.terminate_child(GRPC.Client.Supervisor, pid)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we hard-match on the result?

end
end

defp assert_eventually(fun, 0), do: assert(fun.())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's not add asssert_eventually in favor of more robust strategies. I believe waiting for the DOWN message should be enough with other assertions happening after that.

mvanhorn added 2 commits July 25, 2026 14:33
- Make hibernate_after / fullsweep_after configurable via Application env,
  defaulting to the previous hardcoded values.
- Hard-match the DynamicSupervisor.terminate_child result on the
  finalize_connection error path.
- Drop assert_eventually in favor of monitoring the connection process and
  waiting on the :DOWN message before asserting registry and child counts.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
The OTP matrix was failing at connection_test.exs:155 with the registry
still holding the entry after the connection process had exited.

Registry removes an entry from its own monitor of the registered process.
That monitor and the one the test installs both fire on the same exit with
no ordering guarantee, so receiving :DOWN does not imply the registry has
processed its copy. The read was simply racing the cleanup.

A system call to the partition that owns the monitor drains that message
before the table is read. A local harness that repeats the register /
kill / read cycle 3000 times reports 303 stale reads unsynchronized, 0
with this barrier, so this is deterministic rather than a narrower window.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Good call on dropping assert_eventually -- chasing the resulting CI failure turned up an actual race rather than just a slow assertion.

Waiting on the :DOWN isn't sufficient on its own here. Registry removes the entry from its monitor of the connection process, and that monitor and the test's fire on the same exit with no ordering guarantee, so the lookup can legitimately still see the entry. That's the OTP-matrix failure at connection_test.exs:155 (left: [{#PID<...>, nil}]).

d4ad461 keeps the :DOWN wait and adds a synchronous call to the partition holding the monitor, which drains that message before the table is read. I put a small harness on it locally -- register / kill / read, 3000 iterations: 303 stale reads without the barrier, 0 with it. So it's deterministic rather than a narrower window, which I think is the "more robust strategy" you were after.

The assertions after the :DOWN are unchanged. Full suite is 366 passed / 2 skipped and mix format --check-formatted is clean.

Comment thread grpc/lib/grpc/client/connection.ex Outdated
Comment on lines +73 to +74
Application.put_env(:grpc, NoPickResolver, self())
on_exit(fn -> Application.delete_env(:grpc, NoPickResolver) end)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not for this PR, but I think this test suite needs refactoring so we can speed it up

Comment thread grpc/test/grpc/client/connection_test.exs Outdated
@polvalente
polvalente merged commit 72522c7 into elixir-grpc:master Jul 27, 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.

GRPC.Client.Supervisor persistent memory growth

3 participants