Debugging GenServer state with `:sys.get_state`

almirsarajcic

almirsarajcic

23 hours ago

Ever wondered what’s actually inside your GenServer’s state when things go wrong? Instead of adding debug prints or crashing the process, you can peek inside any running GenServer with Elixir’s built-in :sys.get_state/1.

# Peek inside any GenServer without stopping it
:sys.get_state(MyApp.UserCache)
# => %{users: %{"123" => %User{name: "Alice"}}, last_updated: ~U[2024...]}

# Works with PIDs too
pid = GenServer.whereis(MyApp.GameServer)
:sys.get_state(pid)
# => %{players: ["alice", "bob"], status: :waiting, round: 1}

This is incredibly useful for debugging production issues, understanding why your GenServer isn’t behaving as expected, or just exploring how your application’s state evolves over time.

Beyond basic inspection

You can also replace the state entirely for testing scenarios:

# Temporarily modify state for debugging
new_state = %{users: %{}, cache_hits: 0}
:sys.replace_state(MyApp.UserCache, fn _old_state -> new_state end)

# Or just update part of it
:sys.replace_state(MyApp.GameServer, fn state ->
  %{state | status: :game_over}
end)

The :sys module is part of OTP and works with any process that implements the gen_* behaviors. This gives you direct access to process internals without stopping or modifying the running code - essential for debugging live systems.

Testing LiveView socket assigns

This becomes incredibly powerful in tests where you need to inspect LiveView socket assigns:

test "user data loads correctly on mount", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/profile/123")

  # Peek inside the LiveView process to see socket assigns
  view_state = :sys.get_state(view.pid)
  assigns = view_state.socket.assigns

  assert assigns.current_user.id == "123"
  refute assigns.loading
end

test "form assigns update after validation", %{conn: conn} do
  {:ok, view, _html} = live(conn, "/users/new")

  # Submit invalid form
  view
  |> form("#user-form", user: %{email: "invalid"})
  |> render_submit()

  # Check the assigns contain validation errors
  view_state = :sys.get_state(view.pid)
  changeset = view_state.socket.assigns.changeset

  refute changeset.valid?
  assert changeset.errors[:email]
end