refactor: pattern-match ShutdownHandler + add tests

- handle_call(:shutdown, _, state): split into a shutting_down=true
  fast-path clause and a normal initiate_shutdown clause. No more
  `if state.shutting_down` inside the callback body.
- terminate/2: extract graceful_shutdown_needed?/1 (four function
  heads on the reason tag) and maybe_graceful_drain/2 (guarded on
  state.shutting_down) so the shutdown side-effects are isolated to
  a single, pattern-matched entry point.

Adds 4 unit tests for the pure callback paths
(shutting_down?, shutdown-already-in-progress, EXIT pass-through,
rescue fallback on unavailable GenServer).
This commit is contained in:
Graham McIntire 2026-04-23 14:21:10 -05:00
parent dd0fe397f1
commit 282d3c1ccb
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 76 additions and 32 deletions

View file

@ -51,50 +51,44 @@ defmodule Aprsme.ShutdownHandler do
# Server callbacks
def handle_call(:shutdown, _from, %{shutting_down: true} = state) do
{:reply, :already_shutting_down, state}
end
def handle_call(:shutdown, _from, state) do
if state.shutting_down do
{:reply, :already_shutting_down, state}
else
Logger.info("Initiating graceful shutdown...")
new_state = initiate_shutdown(state)
{:reply, :ok, new_state}
end
Logger.info("Initiating graceful shutdown...")
{:reply, :ok, initiate_shutdown(state)}
end
def handle_call(:shutting_down?, _from, state) do
{:reply, state.shutting_down, state}
end
# Handle terminate callback from OTP when SIGTERM is received
# Handle terminate callback from OTP when SIGTERM is received.
def terminate(reason, state) do
Logger.info("ShutdownHandler terminating: #{inspect(reason)}")
# Only initiate shutdown for specific reasons, not during normal shutdown
should_graceful_shutdown =
case reason do
# Normal shutdown, don't interfere
:shutdown -> false
# Normal shutdown with reason
{:shutdown, _} -> false
# Normal termination
:normal -> false
# Abnormal termination, do graceful shutdown
_ -> true
end
if should_graceful_shutdown and not state.shutting_down do
# Mark unhealthy so load balancer stops sending traffic, then
# block for drain_timeout to let existing connections finish.
# Note: Process.send_after is pointless here since no messages
# are processed during terminate — we just sleep directly.
mark_unhealthy()
stop_accepting_connections()
Process.sleep(state.drain_timeout)
end
maybe_graceful_drain(graceful_shutdown_needed?(reason), state)
:ok
end
# Normal exits don't need the drain-and-sleep dance.
defp graceful_shutdown_needed?(:shutdown), do: false
defp graceful_shutdown_needed?({:shutdown, _reason}), do: false
defp graceful_shutdown_needed?(:normal), do: false
defp graceful_shutdown_needed?(_abnormal), do: true
# Mark unhealthy so load balancers stop sending traffic, then block for
# drain_timeout to let existing connections finish. Process.send_after
# is pointless inside terminate/2 — no messages are processed during
# termination, so we sleep directly.
defp maybe_graceful_drain(true, %{shutting_down: false} = state) do
mark_unhealthy()
stop_accepting_connections()
Process.sleep(state.drain_timeout)
end
defp maybe_graceful_drain(_, _state), do: :ok
def handle_info({:EXIT, _pid, reason}, state) do
Logger.debug("Received EXIT signal: #{inspect(reason)}")
{:noreply, state}

View file

@ -0,0 +1,50 @@
defmodule Aprsme.ShutdownHandlerTest do
# async: false — shares app env and the named ShutdownHandler process.
use ExUnit.Case, async: false
alias Aprsme.ShutdownHandler
describe "handle_call(:shutting_down?, _, state)" do
test "reflects the shutting_down flag in state" do
# Pure callback invocation — no side effects.
assert {:reply, false, %{shutting_down: false}} =
ShutdownHandler.handle_call(:shutting_down?, self(), %{
shutting_down: false,
drain_timeout: 0
})
assert {:reply, true, %{shutting_down: true}} =
ShutdownHandler.handle_call(:shutting_down?, self(), %{
shutting_down: true,
drain_timeout: 0
})
end
end
describe "handle_call(:shutdown, _, state)" do
test "returns :already_shutting_down when already in progress" do
state = %{shutting_down: true, drain_timeout: 0}
assert {:reply, :already_shutting_down, ^state} =
ShutdownHandler.handle_call(:shutdown, self(), state)
end
end
describe "handle_info({:EXIT, pid, reason}, state)" do
test "is a no-op that keeps state intact" do
state = %{shutting_down: false, drain_timeout: 0}
assert {:noreply, ^state} =
ShutdownHandler.handle_info({:EXIT, self(), :normal}, state)
end
end
describe "shutting_down?/0 without the GenServer running" do
test "returns false via the rescue clause" do
# The supervised ShutdownHandler may or may not be running. If the
# GenServer.call raises, the `rescue _ -> false` kicks in and we get
# a boolean back either way.
assert is_boolean(ShutdownHandler.shutting_down?())
end
end
end