Part #3 of our series on building web console for Elixir & Phoenix applications with Phoenix LiveView.

In this series:

  1. Part 1: Project set up and simple code executor
  2. Part 2: Making imports and macros work
  3. Part 3: Managing multiple supervised sessions
  4. Part 4: Making it real-time

Video version

Watch the video version below or or directly on YouTube for a complete step-by-step walkthrough.

The road so far

In the previous episode we extended our simple web console with ability to use imports and macros, we also made it capture and display formatted exceptions. The code lacks, however, the ability to capture text sent to standard output, as it shows up in the terminal - not on the web interface - when called.

We are not going to fix that issue just yet. Before we do so, we need to restructure our code slightly, and allow starting and stopping multiple sessions. This will ensure that the output capturing solution will not be only temporary, and will save us some time in the future on refactoring that we no longer have to perform.

Simultaneous web console sessions

Elixir inherits concept of supervisor trees from Erlang. It also slightly expands upon it by introducing DynamicSupervisor and Registry. Both tools are relatively new additions to the standard library, will save us some time and trouble trying to implement a supervisor with a variable number of children and also module handling processes registration respectively.

In our case we are going to create the following supervision tree:

                                     backdoor (OTP application)
                                                |
     |------------------------------------------|------------------------------| 
Backdoor.Session.Registry     Backdoor.Session.DynamicSupervisor    Backdoor.Session.Counter
                                                |
                       |--------------------------------------------------|
           Backdoor.Session.Supervisor                        Backdoor.Session.Supervisor
                       |                                                  |
           |-----------------------|                          |-----------------------|
Backdoor.Session.CodeRunner Backdoor.Session.Log   Backdoor.Session.CodeRunner Backdoor.Session.Log

The above example has two interactive sessions started. DynamicSupervisor is responsible for watching over multiple Backdoor.Session.Supervisors, each of which has precisely two child processes: CodeRunner - to execute Elixir code and maintain environment and bindings, and Log - to preserve user input, output and errors.

Public API

I like to break down my code into public and private API parts. Public API does not necessarily mean it’s exposed outside of application, but it may be public in context of exposing given functionality to different parts of the same application. In the context of our web console, the “public API” will be exposed from our back-end to LiveView user interface which uses that back-end to start, stop, list and execute code in a given session.

Thinking about what we need I came up with the following public API:

# lib/backdoor/session.ex 
defmodule Backdoor.Session do
  def start_session() do
    # returns {:ok, 1}, i.e. ok tuple with integer session number
  end

  def stop_session(session_id) do
    # returns :ok or error tuple if attempting to stop non-existent session
  end

  def session_ids() do
    # returns list of integers, i.e. [1,2,4,9]
  end

  def execute(session_id, code) do
    # returns [{:input, code}, {:result, value}] or, in case of error
    # [{:input, code]}, {:error, kind, error, stacktrace}]
  end

  def get_logs(session_id) do
    # returns the historical list of the above results from execute/2 function, i.e.
    # [{:input, "a = 1"}, {:result, 1}, {:input, "a + 2"}, {:result, 3}]
  end
end

Only this code will be called from our LiveView, ensuring that our business logic and implementation details are neatly encapsulated, and can evolve independently from the user interface, provided that the same API remains unchanged.

LiveView

There are several changes needed to our Backdoor.BackdoorLive to use the above API.

For once, we will now keep the list of logs not as simple strings - but as tuples - and also need to initially load the list of running sessions when our LiveView is mounted:

# lib/backdoor/live/backdoor_live.ex
...
def mount(_params, _session, socket) do
  {:ok,
   socket
   |> assign(current_session_id: nil, session_ids: Backdoor.Session.session_ids(), logs: [])}
end

We also need to update our sidebar section of HTML to display the list of running sessions, a button to start new session and controls to switch to them, or close running session:

# lib/backdoor/live/backdoor_live.ex
...
  <!-- Sidebar: -->
  <div class="flex-none w-1/6 hidden md:block p-4">
    <%= link "New session", to: "#", phx_click: :start_session, class: "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" %>
    <ul class="py-4">
      <%= for session_id <- @session_ids do %>
        <li>
          <%= link to: "#", class: "float-right", phx_click: :stop_session, phx_value_session_id: session_id do %>
            [x]
          <% end %>
          <%= link "##{session_id}", to: "#", phx_click: :switch_session, phx_value_session_id: session_id %>
        </li>
      <% end %>
    </ul>
  </div>

The above links provide us with ability to start/stop and switch current session, but we need to have handle_event/3 callback variants to support them:

# lib/backdoor/live/backdoor_live.ex
...
def handle_event("stop_session", %{"session-id" => sid}, socket) do
  {session_id, ""} = Integer.parse(sid)
  Backdoor.Session.stop_session(session_id)

  if socket.assigns.current_session_id == session_id do
    {:noreply,
     socket
     |> assign(current_session_id: nil, logs: [], session_ids: Backdoor.Session.session_ids())}
  else
    {:noreply, socket |> assign(session_ids: Backdoor.Session.session_ids())}
  end
end

def handle_event("switch_session", %{"session-id" => sid}, socket) do
  {session_id, ""} = Integer.parse(sid)

  {:noreply,
   socket |> assign(current_session_id: session_id, logs: Backdoor.Session.get_logs(session_id))}
end

The loop to render list of output needs to be also altered to handle new structure, and the code executor needs to call Session.execute/2, which you can see in full version of resulting LiveView.

Handling processes registration in Registry

We need to alter our application callback module to start a Registry where processes will be able to register themselves. This is done by adding the following line:

# lib/backdoor/application.ex
...
{Registry, keys: :unique, name: Backdoor.Session.Registry}
...

to the application callback module.

Once processes register themselves, they can be found by looking them up by key in the registry. To make the process easier, we can use Registry with :via tuples.

I created simple helper function, imported throughout our backend modules, to help with registration this way:

# lib/backdoor/session/via_tuple.ex
...
def via_tuple(module, session_id) do
  {:via, Registry, {Backdoor.Session.Registry, {module, session_id}}}
end

Counting sessions and using Registry

Our Backdoor.Session.Counter is a simple Agent, which we can use to ask for next, sequential integer, that we will use to identify sessions with:

# lib/backdoor/session/counter.ex
defmodule Backdoor.Session.Counter do
  use Agent

  # Public API

  def start_link(name: name) do
    Agent.start_link(fn -> 0 end, name: name)
  end

  def next_id(agent \\ __MODULE__) do
    Agent.get_and_update(agent, &{&1 + 1, &1 + 1})
  end
end

We will start single instance of such Counter per Erlang node in the cluster, also in application callback module, by addin this line:

# lib/backdoor/application.ex
...
{Backdoor.Session.Counter, name: Backdoor.Session.Counter}
...

Using the Counter is simple:

iex> Backdoor.Session.Counter.next_id()
1
iex> Backdoor.Session.Counter.next_id()
2
iex> Backdoor.Session.Counter.next_id()
3
iex> Backdoor.Session.Counter.next_id()
4

The advantage of using Agent is that it gives us a subset of functionality provided by GenServer, resulting in slightly less amounts of code. The updates to state are also atomic, which guarantees the obtained IDs are never repeated, and we won’t have any gaps between requests either.

Starting sessions

Let’s go back to our public API module: Backdoor.Sessions.

We need to implement start_session/0 function. The algorithm is to obtain next available session ID from Counter, prepare child_spec - a recipe for Supervisor to use to start a child, and synchronously start the child under DynamicSupervisor.

# lib/backdoor/session.ex
...
def start_session() do
  with session_id <- Backdoor.Session.Counter.next_id(),
       spec <- %{
         id: :ignored,
         start: {Backdoor.Session.Supervisor, :start_link, [session_id]},
         restart: :transient,
         type: :supervisor
       },
       {:ok, _pid} <- DynamicSupervisor.start_child(Backdoor.Session.DynamicSupervisor, spec) do
    {:ok, session_id}
  end
end

The child, started under DynamicSupervisor is in turns a module-based Supervisor, whose children - Backdoor.Session.Log and Backdoor.Session.CodeRunner provide persisting history and code execution facilities respectively.

# lib/backdoor/session/supervisor.ex 
defmodule Backdoor.Session.Supervisor do
  use Supervisor
  import Backdoor.Session.ViaTuple

  def start_link(session_id) do
    Supervisor.start_link(__MODULE__, session_id, name: via_tuple(__MODULE__, session_id))
  end

  def init(session_id) do
    children = [
      {Backdoor.Session.Log, [name: via_tuple(Backdoor.Session.Log, session_id)]},
      {Backdoor.Session.CodeRunner,
       [session_id, name: via_tuple(Backdoor.Session.CodeRunner, session_id)]}
    ]

    Supervisor.init(children, strategy: :one_for_one)
  end
end

The start_link/1 function above uses the via_tuple/2 registration method, in order to register Backdoor.Session.Supervisor under {Backdoor.Session.Supervisor, session_id} key. We also register Supervisor’s children under: {Backdoor.Session.Log, session_id} and {Backdoor.Session.CodeRunner, session_id} respectively.

Listing sessions

We will use Registry.select/2 to obtain IDs of currently running sessions:

# lib/backdoor/session.ex
...
def session_ids() do
  Backdoor.Session.Registry
  |> Registry.select([{{{Backdoor.Session.Supervisor, :"$1"}, :"$2", :"$3"}, [], [{{:"$1"}}]}])
  |> Enum.map(&elem(&1, 0))
  |> Enum.sort()
end

The code above asks Backdoor.Session.Registry to perform a match, searching for keys matching {Backdoor.Session.Supervisor, session_id}, and return a tuple containing a single element: session_id. Extracting this element from tuple and sorting, we are getting the function that we can use to ask for running session IDs:

iex> Backdoor.Session.start_session()
{:ok, 1}
iex> Backdoor.Session.start_session()
{:ok, 2}
iex> Backdoor.Session.session_ids()
[1, 2]

Stopping session

Stopping session is relatively simple, and we just query the Registry for PID of Supervisor registered under {Backdoor.Session.Supervisor, session_id}, and synchronously stop the thing and all of it’s children:

# lib/backdoor/session.ex
...
def stop_session(session_id) do
  with [{supervisor_pid, _}] <-
         Registry.lookup(Backdoor.Session.Registry, {Backdoor.Session.Supervisor, session_id}) do
    Supervisor.stop(supervisor_pid, :normal)
  else
    [] ->
      {:error, :not_found}

    err ->
      err
  end
end

In case attempting to stop an already stopped, or non-existent session, it will return an error tuple.

Executing code

We need to alter our LiveView to use our public API to execute code, by altering it’s handle_event/3 callback:

# lib/backdoor/live/backdoor_live.ex
...
def handle_event("execute", %{"command" => %{"text" => command}}, socket) do
  logs = Backdoor.Session.execute(socket.assigns.current_session_id, command)

  {:noreply,
   socket
   |> push_event("command", %{text: ""})
   |> assign(logs: socket.assigns.logs ++ logs)}
end

It doesn’t do much, just delegates to Backdoor.Session.execute/2, and appends the result to session logs kept on the socket’s assigns.

Our public API’s execute/2 again doesn’t do much, just delegates further - this time to Backdoor.Session.CodeRunner that is registered under matching session_id in our Registry:

# lib/backdoor/session.ex
...
def execute(session_id, code) do
  GenServer.call(via_tuple(Backdoor.Session.CodeRunner, session_id), {:execute, code})
end

The Backdoor.Session.CodeRunner hasn’t changed much from part-2, the only changes we have to make are: persisting it’s session_id on state, name registration and sending the input and code execution results to associated Backdoor.Session.LogAgent:

# lib/backdoor/code_runner.ex
... 
def start_link([session_id, name: name]) do
  GenServer.start_link(__MODULE__, session_id, name: name)
end

def init(session_id) do
  {:ok, %{session_id: session_id, bindings: [], env: init_env()}}
end

def handle_call({:execute, code}, _from, state) do
  try do
    log(state.session_id, {:input, code})
    {result, bindings, env} = do_execute(code, state.bindings, state.env)
    log(state.session_id, {:result, result})
    {:reply, [{:input, code}, {:result, result}], %{state | bindings: bindings, env: env}}
  catch
    kind, error ->
      log(state.session_id, {:error, kind, error, __STACKTRACE__})
      {:reply, [{:input, code}, {:error, kind, error, __STACKTRACE__}], state}
  end
end
...
defp log(session_id, value) do
  Backdoor.Session.Log.put_log(via_tuple(Backdoor.Session.Log, session_id), value)
end

That’s it!

We managed to build in multiple, independent and supervised session management into our web console application! We can now start, stop and switch between sessions and our user interface lists running sessions in left sidebar, giving us ability to control them and switch between:

Backdoor with multiple sessions!

The road ahead

In the future episodes we will focus on improving our code execution capabilities: support multi-line Elixir expressions, capture and display output, improve our UI and write meaningful tests. Stay tuned!

Notes

Watch the screencast directly on YouTube

You can find this and future videos on our YouTube channel.

The full code from this episode can be found on GitHub

Post by Hubert Łępicki

Hubert is partner at AmberBit. Rails, Elixir and functional programming are his areas of expertise.