A GenServer is a generic server process in Elixir that abstracts and manages the lifecycle and state of a server process. It handles incoming requests in sequential order, which is particularly useful for stateful operations.

See the code

Read the docs

Starting the GenServer

def start_link(opts \\ []) do
  topic = Keyword.get(opts, :topic, @default_topic)
  GenServer.start_link(__MODULE__, initial_state(topic), opts)
end

def init(state) do
  broadcast_update(:update, state)
  {:ok, state}
end
  • start_link: Checks the options for a topic and starts the GenServer. On success, it returns the process identifier (pid).

  • init/1: Called on startup, init broadcasts the initial state.

GenServer Message Functions

Synchronous (handle_call)

Synchronous functions handle requests where the caller expects a reply. When a synchronous message is sent, the caller blocks and waits for a response.

def handle_call(:get_state, _from, state) do
  {:reply, state, state}
end
  • :get_state: Retrieves and returns the current state of the GenServer.

Asynchronous (handle_cast)

Asynchronous functions handle fire-and-forget requests from the caller. The caller sends the message and continues execution without waiting for a response.

def handle_cast({:mark, position}, state) do
  case Map.get(state.board, position) do
    nil ->
      new_board = Map.put(state.board, position, state.current_player)
      {new_player, new_winner, new_game_status} = GameLogic.update_game(state, new_board)

      new_state = %{
        state
        | board: new_board,
          current_player: new_player,
          win: new_winner,
          game_status: new_game_status
      }

      broadcast_update(:update, new_state)
      {:noreply, new_state}

    _ ->
      {:noreply, state}
  end
end
  • :mark: Marks a position on the Tic Tac Toe board. If the position is occupied, the request is ignored. If the position is empty, the board is updated, and the new state is broadcast.

GenServer Public Functions

In Elixir, a GenServer encapsulates its behaviors within a public-facing API. This design pattern shields the GenServer from direct message-passing and prevents unauthorized access or manipulation of the server’s state.

def reset(pid) do
  GenServer.stop(pid, :normal)
end

def get_state(pid) do
  GenServer.call(pid, :get_state)
end

def mark(pid, position) when position in @valid_positions do
  GenServer.cast(pid, {:mark, position})
end

def mark(_pid, _position), do: {:error, :invalid_position}

Public API Functions:

  • reset/1: Terminates the GenServer process gracefully. This allows the supervisor to restart and initialize a new server process.

  • get_state/1: Retrieves the current state of the GenServer through a synchronous call. This ensures the calling process waits for a response before proceeding.

  • mark/2: Sends an asynchronous message to mark a position on the board if the position is valid. If the position is invalid, it returns an error.

This public API resembles the functionality of Elixir’s Agent module but adds the requirement for a process identifier (pid). The pid specifies which GenServer instance to interact with, allowing multiple game sessions to run concurrently and independently.

Registry

The Agent version of TicTacToe created a single global process where all clients shared the same process and state. In this version, we generate three processes (:tic, :tac, and :toe) and let the client choose which one to use.

Each time a process boots (or reboots), it receives a new process identifier (pid), making it difficult for clients to know which pid to use. To address this, we use a Registry.

defmodule GameApp.Games.TicTacToe.Registry do
  use GenServer

  alias GameApp.Games.TicTacToe.GenServer, as: TicTacToe

  @tic_tac_toe_processes [:tic, :tac, :toe]

  def start_link(_) do
    GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
  end

  def init(state) do
    Enum.each(@tic_tac_toe_processes, fn name ->
      {:ok, _pid} = start_tic_tac_toe(name)
    end)

    {:ok, state}
  end

  def start_tic_tac_toe(name) do
    topic = Atom.to_string(name)

    DynamicSupervisor.start_child(
      GameApp.DynamicSupervisor,
      {TicTacToe, [topic: topic, name: name]}
    )
  end

  def reset(name) do
    GenServer.stop(name, :normal)
  end

  def crash_server(name) do
    GenServer.cast(name, :crash)
  end

  def get_state(name) do
    GenServer.call(name, :get_state)
  end

  def mark(name, position) do
    GenServer.cast(name, {:mark, position})
  end
end

The Registry provides similar behaviors to the GenServer (reset, get_state, mark), but instead of calling by pid, it calls by a known name (:tic, :tac, or :toe). This allows clients to choose from a fixed list of names rather than dealing with continually changing process identifiers.

By using a Registry, we can manage multiple game instances and ensure clients can reliably find and interact with the correct process, even if processes are restarted and their pids change. This setup improves the scalability and fault tolerance of the application by allowing multiple independent game instances to run concurrently.

Client

Instead of calling the GenServer directly, the client calls the Registry with a known server name. This approach allows clients to interact with specific instances of the TicTacToe game.

defmodule GameAppWeb.TicTacToeLive do
  use GameAppWeb, :live_view
  alias GameApp.Games.TicTacToe.Registry

  def mount(%{"server" => server}, _session, socket) do
    server_atom = String.to_existing_atom(server)
    state = Registry.get_state(server_atom)

    Phoenix.PubSub.subscribe(GameApp.PubSub, state.topic)

    {:ok, assign(socket, game_state: state, tictactoe_server: server_atom)}
  end

  def handle_event("reset", _, socket) do
    Registry.reset(socket.assigns.tictactoe_server)
    {:noreply, socket}
  end

  def handle_event("mark", %{"position" => position}, socket) do
    Registry.mark(socket.assigns.tictactoe_server, String.to_atom(position))
    {:noreply, socket}
  end

  def handle_info({:update, new_state}, socket) do
    {:noreply, assign(socket, game_state: new_state)}
  end
end

This approach ensures that clients can reliably find and interact with the correct process, improving scalability and fault tolerance. The client module provides a clean and organized way to handle game events and updates, maintaining a responsive and interactive user experience.

Crashing

To purposefully crash the GenServer, raise an exception.

GenServer Crash

def crash_server(pid) do
  GenServer.cast(pid, :crash)
end

def handle_cast(:crash, state) do
  broadcast_update(:crash, state)
  raise "BOMB!!!"
end

Registry Crash

Add the crash logic to the registry:

def crash_server(name) do
  GenServer.cast(name, :crash)
end

Client Crash

Add the crash handling logic to the client:

def handle_event("crash", _, socket) do
  Registry.crash_server(socket.assigns.tictactoe_server)
  {:noreply, socket}
end

  def handle_info({:crash, _state}, socket) do
    {:noreply,
     socket
     |> put_flash(:error, "Server crashed!")}
  end

When the GenServer crashes, the supervisor will automatically restart it, generating a new pid. The Registry will seamlessly update to target this new process for subsequent named calls.

Unlike the Agent example, the client’s call to crash the process is a fire-and-forget message. Due to this loose coupling, the server must send a crash message.

Testing

Now that we have processes operating in isolation, tests can run asynchronously.

See the code