lib/gtimer.ex

defmodule Gtimer do
  @moduledoc """
  A small library which provides a global timer facility in Elixir (or Erlang). 

  ## Example: 
  iex> timer_ref = Gtimer.new_timer(timeout,timeout_action \\ nil)

  Starts a new timer running for `timeout` millseconds, returns a "timer reference".

  iex> Gtimer.cancel_timer(timer_ref)

  Cancels a running timer.

  If a running timer is not cancelled when the timer expires the `timeout_action` function 
  will be called with the process identifier of the process that created the timer
  as a single argument. If the timeout_action is not a function of arity 1, then the 
  default action of terminating the process that created the timer, 
  and displaying an informative message, will be taken.
  
  Timer management is done in a separate process. Inside the process
  timers are stored in a priority queue, and using a map, which
  affords logarithmic worst-case time complexity for both function
  calls (in terms of the number of timers running).

  ## Example:

  iex> timer_ref = Gtimer.new_timer(1000,fn pid -> Process.exit(pid,:because) end)

  This will start a new timer which when it expires kills the process which invoked
  the call to `new_timer`.
  """
  use GenServer

  @doc false
  def init(_) do
    {:ok,pqueue} = :epqueue.new()
    {:ok, %{pqueue: pqueue, map: %{}, counter: 0}}
  end

  def handle_call({:new_timer, timeout, pid, timeout_action}, _from, state) do
    timer_ref = state[:counter]
    timeout_time = :os.system_time(:millisecond)+timeout
    queue_item = %{timer_ref: timer_ref, timeout: timeout_time, pid: pid, timeout_action: timeout_action}
    {:ok, item_ref} = :epqueue.insert(state[:pqueue], queue_item, timeout_time)
    map_item = Map.put(queue_item,:pqueue_ref,item_ref)
    new_state =
      %{ state |
         map: Map.put(state[:map],timer_ref,map_item),
         counter: state[:counter]+1 }
    case calculate_timeout(new_state) do
      {:ok, next_time} ->
        {:reply, timer_ref, new_state, next_time}
      _ ->
        {:reply, timer_ref, new_state}
    end
  end

  def handle_call({:cancel_timer, timer_ref}, _from, state) do
    new_state = 
      case Map.get(state[:map],timer_ref,:undefined) do
        :undefined ->
          state
        map_item ->
          pqueue_ref = map_item[:pqueue_ref]
          :epqueue.remove(state[:pqueue],pqueue_ref)
          %{ state | map: Map.drop(state[:map],[timer_ref]) }
      end
    case calculate_timeout(new_state) do
      {:ok, next_time} ->
        {:reply, :ok, new_state, next_time}
      _ ->
        {:reply, :ok, new_state}
    end
  end

  def handle_info(:timeout, state) do
    {:ok, queue_item, _} = :epqueue.pop(state[:pqueue])
    # We should do a user defined action here...
    pid = queue_item[:pid]
    timeout_action = queue_item[:timeout_action]
    if is_function(timeout_action,1) do
      try do
        timeout_action.(pid)
      rescue
        _ -> IO.puts("*** ERROR: Gtimer: timeout_function raised an exception")
      end
    else
      if Process.alive?(pid) do
        IO.puts("Pid #{inspect pid} timed out; terminating...")
        Process.exit(pid,:timed_out)
      end
    end
    new_state = %{ state | map: Map.drop(state[:map],[queue_item[:timer_ref]])}
    case calculate_timeout(new_state) do
      {:ok, next_time} ->
        {:noreply, new_state, next_time}
      _ ->
        {:noreply, new_state}
    end
  end

  def handle_info(other, state) do
    IO.puts("*** WARNING: Gtimer: unexpected message #{inspect other} received")
    {:noreply, state}
  end

  defp calculate_timeout(state) do
    case :epqueue.size(state[:pqueue]) do
      n when n>0 ->
        {:ok, _, timeout_time} = :epqueue.peek(state[:pqueue])
        current_time = :os.system_time(:millisecond)
        next_timeout = max(0,timeout_time-current_time)
        {:ok, next_timeout}
      _ ->
        nil
    end
  end

  
  @doc """
  Starts a new timer running for `timeout` millseconds, returns a "timer reference".
  If a running timer is not cancelled when the timer expires the timeout_action function 
  will be called with the process identifier of the process that created the timer as 
  a single argument. If the timeout_action is not a function of arity 1, then 
  the default action of terminating the process that created the timer, 
  and displaying an informative message, will be taken.
  """
  def new_timer(timeout,timeout_action \\ nil) do
    case Process.whereis(:gtimer) do
      nil ->
        # Do not link to the calling process; we are likely to kill it!
        GenServer.start(Gtimer, [], [{:name, :gtimer}])
        new_timer(timeout)
      _pid ->
        GenServer.call(:gtimer,{:new_timer, timeout, self(), timeout_action})
    end
  end

  @doc """
  Cancels a running timer.
  """
  def cancel_timer(time_ref) do
    case Process.whereis(:gtimer) do
      nil ->
        # Do not link to the calling process; we are likely to kill it!
        GenServer.start(Gtimer, [], [{:name, :gtimer}])
        cancel_timer(time_ref)
      _pid ->
        GenServer.call(:gtimer,{:cancel_timer, time_ref})
    end
  end
end