Current section

Files

Jump to
livebook lib livebook runtime standalone.ex
Raw

lib/livebook/runtime/standalone.ex

defmodule Livebook.Runtime.Standalone do
defstruct [:node, :server_pid]
# A runtime backed by a standalone Elixir node managed by Livebook.
#
# Livebook is responsible for starting and terminating the node.
# Most importantly we have to make sure the started node doesn't
# stay in the system when the session or the entire Livebook
# terminates.
#
# Note: this runtime requires `elixir` executable to be available in
# the system.
#
# ## Connecting
#
# Connecting the runtime starts a new Elixir node (a system process).
# That child node connects back to the parent and notifies that it
# is ready by sending a `:node_started` message. Next, the parent
# initializes the child node by loading the necessary modules and
# starting processes, in particular the node manager and one runtime
# server. Once done, the parent sends a `:node_initialized` message
# to the child, and the child starts monitoring the node manager.
# Once the node manager terminates, the node shuts down.
#
# If no process calls `Livebook.Runtime.take_ownership/1` for a
# period of time, the node automatically terminates. Whoever takes
# the ownership, becomes the owner and as soon as it terminates,
# the node shuts down. The node may also be shut down by calling
# `Livebook.Runtime.disconnect/1`.
alias Livebook.Utils
@type t :: %__MODULE__{
node: node() | nil,
server_pid: pid() | nil
}
@doc """
Returns a new runtime instance.
"""
@spec new() :: t()
def new() do
%__MODULE__{}
end
def __connect__(runtime) do
caller = self()
{:ok, pid} =
DynamicSupervisor.start_child(
Livebook.RuntimeSupervisor,
{Task, fn -> do_connect(runtime, caller) end}
)
pid
end
defp do_connect(runtime, caller) do
child_node = Livebook.EPMD.random_child_node()
Utils.temporarily_register(self(), child_node, fn ->
init_opts = [
runtime_server_opts: [
extra_smart_cell_definitions: Livebook.Runtime.Definitions.smart_cell_definitions()
]
]
with {:ok, elixir_path} <- find_elixir_executable(),
port = start_elixir_node(elixir_path, child_node),
{:ok, server_pid} <- parent_init_sequence(child_node, port, init_opts) do
runtime = %{runtime | node: child_node, server_pid: server_pid}
send(caller, {:runtime_connect_done, self(), {:ok, runtime}})
else
{:error, error} ->
send(caller, {:runtime_connect_done, self(), {:error, error}})
end
end)
end
defp find_elixir_executable() do
case System.find_executable("elixir") do
nil -> {:error, "no Elixir executable found in PATH"}
path -> {:ok, path}
end
end
defp start_elixir_node(elixir_path, node_name) do
# Here we create a port to start the system process in a non-blocking way.
Port.open({:spawn_executable, elixir_path}, [
:binary,
# We don't communicate with the system process via stdio,
# contrarily, we want any non-captured output to go directly
# to the terminal
:nouse_stdio,
:hide,
args: elixir_flags(node_name)
])
end
defp parent_init_sequence(child_node, port, init_opts) do
port_ref = Port.monitor(port)
loop = fn loop ->
receive do
{:node_started, init_ref, ^child_node, child_port, primary_pid} ->
Port.demonitor(port_ref)
Livebook.EPMD.update_child_node(child_node, child_port)
server_pid = Livebook.Runtime.ErlDist.initialize(child_node, init_opts)
send(primary_pid, {:node_initialized, init_ref})
{:ok, server_pid}
{^port, {:data, _output}} ->
loop.(loop)
{:DOWN, ^port_ref, :port, _object, reason} ->
{:error,
"Elixir terminated unexpectedly, please check your logs for errors. Reason: #{inspect(reason)}"}
after
# Use a longer timeout to account for longer child node startup.
30_000 ->
{:error, "connection timed out"}
end
end
loop.(loop)
end
defp child_node_eval_string(node, parent_node, parent_port) do
# We pass the child node code as --eval argument. Windows handles
# escaped quotes and newlines differently from Unix, so to avoid
# those kind of issues, we encode the string in base 64 and pass
# as positional argument. Then, we use a simple --eval that decodes
# and evaluates the string.
quote do
node = unquote(node)
parent_node = unquote(parent_node)
parent_port = unquote(parent_port)
# We start distribution here, rather than on node boot, so that
# -pa takes effect and Livebook.EPMD is available
{:ok, _} = :net_kernel.start(node, %{name_domain: :longnames})
Livebook.Runtime.EPMD.register_parent(parent_node, parent_port)
dist_port = Livebook.Runtime.EPMD.dist_port()
init_ref = make_ref()
parent_process = {node(), parent_node}
send(parent_process, {:node_started, init_ref, node(), dist_port, self()})
receive do
{:node_initialized, ^init_ref} ->
manager_ref = Process.monitor(Livebook.Runtime.ErlDist.NodeManager)
receive do
{:DOWN, ^manager_ref, :process, _object, _reason} -> :ok
end
after
10_000 -> :timeout
end
# We explicitly halt at the end, just in case `System.no_halt(true)`
# is called within the runtime
System.halt()
end
|> Macro.to_string()
|> Base.encode64()
end
defp elixir_flags(node_name) do
parent_name = node()
parent_port = Livebook.EPMD.dist_port()
[
"--erl",
# Note: keep these flags in sync with the remote runtime.
#
# * minimize schedulers busy wait threshold, so that they go
# to sleep immediately after evaluation
#
# * increase the default stack for dirty IO threads, necessary
# for CUDA
#
# * enable ANSI escape codes as we handle them with HTML
#
# * disable stdin, so that the system process never tries to
# read terminal input
#
# * specify a custom EPMD module and disable automatic EPMD
# startup
#
"+sbwt none +sbwtdcpu none +sbwtdio none +sssdio 128 -elixir ansi_enabled true -noinput " <>
"-epmd_module Elixir.Livebook.Runtime.EPMD",
# Add the location of Livebook.Runtime.EPMD
"-pa",
epmd_module_path!(),
# Make the node hidden, so it doesn't automatically join the cluster
"--hidden",
# Use the cookie in Livebook
"--cookie",
Atom.to_string(Node.get_cookie()),
"--eval",
"System.argv() |> hd() |> Base.decode64!() |> Code.eval_string()",
child_node_eval_string(node_name, parent_name, parent_port)
]
end
defp epmd_module_path!() do
# We need to make the custom Livebook.Runtime.EPMD module available
# before the child node starts distrubtion. We persist the module
# into a temporary directory and add to the code paths. Note that
# we could persist it to priv/ at build time, however for Escript
# priv/ is packaged into the archive, so it is not accessible in
# the file system.
epmd_path = Path.join(Livebook.Config.tmp_path(), "epmd")
File.rm_rf!(epmd_path)
File.mkdir_p!(epmd_path)
{_module, binary, path} = :code.get_object_code(Livebook.Runtime.EPMD)
File.write!(Path.join(epmd_path, Path.basename(path)), binary)
epmd_path
end
end
defimpl Livebook.Runtime, for: Livebook.Runtime.Standalone do
alias Livebook.Runtime.ErlDist.RuntimeServer
def describe(runtime) do
[{"Type", "Standalone"}] ++
if runtime.node do
[{"Node name", Atom.to_string(runtime.node)}]
else
[]
end
end
def connect(runtime) do
Livebook.Runtime.Standalone.__connect__(runtime)
end
def take_ownership(runtime, opts \\ []) do
RuntimeServer.attach(runtime.server_pid, self(), opts)
Process.monitor(runtime.server_pid)
end
def disconnect(runtime) do
:ok = RuntimeServer.stop(runtime.server_pid)
end
def duplicate(_runtime) do
Livebook.Runtime.Standalone.new()
end
def evaluate_code(runtime, language, code, locator, parent_locators, opts \\ []) do
RuntimeServer.evaluate_code(
runtime.server_pid,
language,
code,
locator,
parent_locators,
opts
)
end
def forget_evaluation(runtime, locator) do
RuntimeServer.forget_evaluation(runtime.server_pid, locator)
end
def drop_container(runtime, container_ref) do
RuntimeServer.drop_container(runtime.server_pid, container_ref)
end
def handle_intellisense(runtime, send_to, request, parent_locators, node) do
RuntimeServer.handle_intellisense(runtime.server_pid, send_to, request, parent_locators, node)
end
def read_file(runtime, path) do
RuntimeServer.read_file(runtime.server_pid, path)
end
def transfer_file(runtime, path, file_id, callback) do
RuntimeServer.transfer_file(runtime.server_pid, path, file_id, callback)
end
def relabel_file(runtime, file_id, new_file_id) do
RuntimeServer.relabel_file(runtime.server_pid, file_id, new_file_id)
end
def revoke_file(runtime, file_id) do
RuntimeServer.revoke_file(runtime.server_pid, file_id)
end
def start_smart_cell(runtime, kind, ref, attrs, parent_locators) do
RuntimeServer.start_smart_cell(runtime.server_pid, kind, ref, attrs, parent_locators)
end
def set_smart_cell_parent_locators(runtime, ref, parent_locators) do
RuntimeServer.set_smart_cell_parent_locators(runtime.server_pid, ref, parent_locators)
end
def stop_smart_cell(runtime, ref) do
RuntimeServer.stop_smart_cell(runtime.server_pid, ref)
end
def fixed_dependencies?(_runtime), do: false
def add_dependencies(_runtime, code, dependencies) do
Livebook.Runtime.Dependencies.add_dependencies(code, dependencies)
end
def has_dependencies?(runtime, dependencies) do
RuntimeServer.has_dependencies?(runtime.server_pid, dependencies)
end
def snippet_definitions(_runtime) do
Livebook.Runtime.Definitions.snippet_definitions()
end
def search_packages(_runtime, send_to, search) do
Livebook.Runtime.Dependencies.search_packages_on_hex(send_to, search)
end
def put_system_envs(runtime, envs) do
RuntimeServer.put_system_envs(runtime.server_pid, envs)
end
def delete_system_envs(runtime, names) do
RuntimeServer.delete_system_envs(runtime.server_pid, names)
end
def restore_transient_state(runtime, transient_state) do
RuntimeServer.restore_transient_state(runtime.server_pid, transient_state)
end
def register_clients(runtime, clients) do
RuntimeServer.register_clients(runtime.server_pid, clients)
end
def unregister_clients(runtime, client_ids) do
RuntimeServer.unregister_clients(runtime.server_pid, client_ids)
end
def fetch_proxy_handler_spec(runtime) do
RuntimeServer.fetch_proxy_handler_spec(runtime.server_pid)
end
def disconnect_node(runtime, node) do
RuntimeServer.disconnect_node(runtime.server_pid, node)
end
end