livebook/lib/live_book/runtime/standalone_init.ex
Jonatan Kłosko 663ec3283e
Support starting runtime in Mix context (#61)
* Prototype standalone mode with mix

* Move runtime initialization into separate LiveViews

* Make standalone node initialization async

* Refactor async node initialization

* Automatically scroll to the bottom of output

* Refactor streaming output

* Move MessageEmitter under Utils

* Add path selector to the mix runtime picker

* Update runtime descriptions

* Add successful or error message at the end of output

* Run formatter

* Rename Standalone to ElixirStandalone for consistency

* Show only directories when looking for a mix project

* Update docs

* Extract shared standalone logic

* Make the remote primary process monitor Manager instead of session

* Further refactoring and docs

* Add tests for collectable Callback

* Add missing macro doc

* Apply review suggestions

* Decouple sending asynchronous notifications from the runtime initialization

* Apply suggestions
2021-02-26 20:53:29 +01:00

149 lines
4.7 KiB
Elixir

defmodule LiveBook.Runtime.StandaloneInit do
@moduledoc false
# Generic functionality related to starting and setting up
# a new Elixir system process. It's used by both ElixirStandalone
# and MixStandalone runtimes.
alias LiveBook.Utils
alias LiveBook.Utils.Emitter
@doc """
Returns a random name for a dynamically spawned node.
"""
@spec random_node_name() :: atom()
def random_node_name() do
Utils.node_from_name("live_book_runtime_#{Utils.random_short_id()}")
end
@doc """
Returns random name to register a process under.
We have to pass parent process pid to the new Elixir node.
The node receives code to evaluate as string, so we cannot
directly embed the pid there, but we can temporarily register
the process under a random name and pass this name to the child node.
"""
@spec random_process_name() :: atom()
def random_process_name() do
:"live_book_parent_process_name_#{Utils.random_short_id()}"
end
@doc """
Tries locating Elixir executable in PATH.
"""
@spec find_elixir_executable() :: {:ok, String.t()} | {:error, String.t()}
def find_elixir_executable() do
case System.find_executable("elixir") do
nil -> {:error, "no Elixir executable found in PATH"}
path -> {:ok, path}
end
end
@doc """
A list of common flags used for spawned Elixir runtimes.
"""
@spec elixir_flags(node()) :: list()
def elixir_flags(node_name) do
[
if(LiveBook.Config.shortnames?(), do: "--sname", else: "--name"),
to_string(node_name),
"--erl",
# Minimize shedulers busy wait threshold,
# so that they go to sleep immediately after evaluation.
# Enable ANSI escape codes as we handle them with HTML.
"+sbwt none +sbwtdcpu none +sbwtdio none -elixir ansi_enabled true"
]
end
# ---
#
# Once the new node is spawned we need to establish a connection,
# initialize it and make sure it correctly reacts to the parent node terminating.
#
# The procedure goes as follows:
#
# 1. The child sends {:node_initialized, ref} message to the parent
# to communicate it's ready for initialization.
#
# 2. The parent initializes the child node - loads necessary modules
# and starts the Manager process.
#
# 3. The parent sends {:node_initialized, ref} message back to the child,
# to communicate successful initialization.
#
# 4. The child starts monitoring the Manager process and freezes
# until the Manager process terminates. The Manager process
# serves as the leading remote process and represents the node from now on.
#
# The nodes either successfully go through this flow or return an error,
# either if the other node dies or is not responding for too long.
#
# ---
@doc """
Performs the parent side of the initialization contract.
Should be called by the initializing process on the parent node.
"""
@spec parent_init_sequence(node(), port(), Emitter.t() | nil) ::
{:ok, pid()} | {:error, String.t()}
def parent_init_sequence(child_node, port, emitter \\ nil) do
port_ref = Port.monitor(port)
loop = fn loop ->
receive do
{:node_started, init_ref, ^child_node, primary_pid} ->
Port.demonitor(port_ref)
# We've just created the node, so it is surely not in use
:ok = LiveBook.Runtime.ErlDist.initialize(child_node)
send(primary_pid, {:node_initialized, init_ref})
{:ok, primary_pid}
{^port, {:data, output}} ->
# Pass all the outputs through the given emitter.
emitter && Emitter.emit(emitter, output)
loop.(loop)
{:DOWN, ^port_ref, :port, _object, _reason} ->
{:error, "Elixir process terminated unexpectedly"}
after
10_000 ->
{:error, "connection timed out"}
end
end
loop.(loop)
end
@doc """
Performs the child side of the initialization contract.
This function returns AST that should be evaluated in primary
process on the newly spawned child node.
"""
def child_node_ast(parent_node, parent_process_name) do
# This is the primary process, so as soon as it finishes, the runtime terminates.
quote do
# Initiate communication with the parent process (on the parent node).
init_ref = make_ref()
parent_process = {unquote(parent_process_name), unquote(parent_node)}
send(parent_process, {:node_started, init_ref, node(), self()})
receive do
{:node_initialized, ^init_ref} ->
manager_ref = Process.monitor(LiveBook.Runtime.ErlDist.Manager)
# Wait until the Manager process terminates.
receive do
{:DOWN, ^manager_ref, :process, _object, _reason} -> :ok
end
after
10_000 -> :timeout
end
end
end
end