Part #3 of our series on building web console for Elixir & Phoenix applications with Phoenix LiveView.
In this series:
- Part 1: Project set up and simple code executor
- Part 2: Making imports and macros work
- Part 3: Managing multiple supervised sessions
- 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.Supervisor
s, 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.Log
Agent
:
# 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:
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.