Ghoul

An undead cleanup crew for your processes.

Build StatusHex.pmBuild Docs

{:ghoul, "~> 0.1"},

Motivation

Ghoul solves two problems for the OTP developer:

  1. Robust execution of cleanup code after a process exits
  2. Robust termination of a process that has exceeded timing expectations

Both of these problems can be handled in one-off manners, and the :timeout set of responses for GenServer provides a builtin solution for simple use cases. Ghoul steps in once the builtin functionality is no longer sufficient.

Cleanup Example

Hardware interaction is a common motivation for wanting cleanup code. This is a simple, notional example of tying an LED to the lifecycle of a particular GenServer:

defmodule LedExample do
  use GenServer

  # ...snip...

  def init([]) do
    Ghoul.summon(LedExample, on_death: &cleanup/3)
    turn_on_led()
    {:ok, %State{}}
  end

  def cleanup(LedExample, _reason, _ghoul_state) do
    turn_off_led()
  end

  # ...snip...
end

It is important to note that Ghoul.summon/2 will block during subsequent calls for a given process_key (in this example, LedServer) until the cleanup code has completed. Thus, the call to Ghoul.summon/2 should happen before any side-effect code (e.g. turn_on_led/0), and any side-effect code in the cleanup method should be synchronous to avoid race-conditions when, e.g., a Supervisor restarts the GenServer in question.

Timeout Example

In this highly notional example, a GenServer managing an external server transitions between multiple states with varying timeout rules and cleanup logic.

The server should boot within 100ms, initialize within 50ms, and then respond to a test request within 20ms

defmodule FsmExample do
  use GenServer
  import ShorterMaps

  defmodule State do
    defstruct [port: nil, fsm: :not_init]
  end

  def init([]) do
    Ghoul.summon(FsmExample, on_death: &cleanup/3)
    # start the external server
    {:ok, port} = start_external_server()
    # provide the port to Ghoul for use during cleanup:
    Ghoul.set_state(FsmExample, port)
    # schedule this process for destruction if the external server fails to boot
    # within the specified timeout of 100ms.
    Ghoul.reap_in(FsmExample, :boot_timeout, 100)
    {:ok, ~M{%State port, fsm: :booting}}
  end


  def handle_info({port, "BOOTED"}, ~M{port, fsm: :booting}) do
    :ok = initialize_server(port)
    # this cancels the boot reaping, and replaces it with an init reaping:
    Ghoul.reap_in(FsmExample, :init_timeout, 50)
    {:noreply, %{state|fsm: :initing}}
  end
  def handle_info({port, "INIT COMPLETE"}, ~M{port, fsm: :initing}) do
    send_test_query(port)
    Ghoul.reap_in(FsmExample, :example_timeout, 20)
    {:noreply, %{state|fsm: :testing}}
  end
  def handle_info({port, "TEST COMPLETE"}, ~M{port, fsm: :testing}) do
    # prevent killing this process
    Ghoul.cancel_reap(FsmExample)
    {:noreply, %{state|fsm: :ready}}
  end

  def cleanup(FsmExample, :boot_timeout, port) do
    # server didn't boot, just close the port:
    close_server_port(port)
  end
  def cleanup(FsmExample, _reason, port) do
    disconnect_server(port)
    close_server_port(port)
  end
  # ...snip...
end

Installation

Add {:ghoul, "~> 0.1"}, to your mix deps