mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-10-27 22:06:38 +08:00
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
This commit is contained in:
parent
d2cd541ce1
commit
663ec3283e
23 changed files with 804 additions and 287 deletions
|
|
@ -8,12 +8,14 @@ import ContentEditable from "./content_editable";
|
|||
import Cell from "./cell";
|
||||
import Session from "./session";
|
||||
import FocusOnUpdate from "./focus_on_update";
|
||||
import ScrollOnUpdate from "./scroll_on_update";
|
||||
|
||||
const Hooks = {
|
||||
ContentEditable,
|
||||
Cell,
|
||||
Session,
|
||||
FocusOnUpdate,
|
||||
ScrollOnUpdate,
|
||||
};
|
||||
|
||||
const csrfToken = document
|
||||
|
|
|
|||
19
assets/js/scroll_on_update/index.js
Normal file
19
assets/js/scroll_on_update/index.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* A hook used to scroll to the bottom of an element
|
||||
* whenever it receives LV update.
|
||||
*/
|
||||
const ScrollOnUpdate = {
|
||||
mounted() {
|
||||
this.__scroll();
|
||||
},
|
||||
|
||||
updated() {
|
||||
this.__scroll();
|
||||
},
|
||||
|
||||
__scroll() {
|
||||
this.el.scrollTop = this.el.scrollHeight;
|
||||
},
|
||||
};
|
||||
|
||||
export default ScrollOnUpdate;
|
||||
|
|
@ -7,7 +7,7 @@ defmodule LiveBook.Runtime.Attached do
|
|||
# LiveBook doesn't manage its lifetime in any way
|
||||
# and only loads/unloads the necessary elements.
|
||||
# The node can be an oridinary Elixir runtime,
|
||||
# a mix project shell, a running release or anything else.
|
||||
# a Mix project shell, a running release or anything else.
|
||||
|
||||
defstruct [:node]
|
||||
|
||||
|
|
|
|||
93
lib/live_book/runtime/elixir_standalone.ex
Normal file
93
lib/live_book/runtime/elixir_standalone.ex
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
defmodule LiveBook.Runtime.ElixirStandalone do
|
||||
defstruct [:node, :primary_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.
|
||||
|
||||
import LiveBook.Runtime.StandaloneInit
|
||||
|
||||
alias LiveBook.Utils
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
node: node(),
|
||||
primary_pid: pid()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Starts a new Elixir node (i.e. a system process) and initializes
|
||||
it with LiveBook-specific modules and processes.
|
||||
|
||||
If no process calls `Runtime.connect/1` for a period of time,
|
||||
the node automatically terminates. Whoever connects, becomes the owner
|
||||
and as soon as it terminates, the node terminates as well.
|
||||
The node may also be terminated manually by using `Runtime.disconnect/1`.
|
||||
|
||||
Note: to start the node it is required that `elixir` is a recognised
|
||||
executable within the system.
|
||||
"""
|
||||
@spec init() :: {:ok, t()} | {:error, String.t()}
|
||||
def init() do
|
||||
parent_node = node()
|
||||
child_node = random_node_name()
|
||||
parent_process_name = random_process_name()
|
||||
|
||||
Utils.temporarily_register(self(), parent_process_name, fn ->
|
||||
with {:ok, elixir_path} <- find_elixir_executable(),
|
||||
eval <- child_node_ast(parent_node, parent_process_name) |> Macro.to_string(),
|
||||
port <- start_elixir_node(elixir_path, child_node, eval),
|
||||
{:ok, primary_pid} <- parent_init_sequence(child_node, port) do
|
||||
runtime = %__MODULE__{
|
||||
node: child_node,
|
||||
primary_pid: primary_pid
|
||||
}
|
||||
|
||||
{:ok, runtime}
|
||||
else
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp start_elixir_node(elixir_path, node_name, eval) do
|
||||
# Here we create a port to start the system process in a non-blocking way.
|
||||
Port.open({:spawn_executable, elixir_path}, [
|
||||
:nouse_stdio,
|
||||
args: elixir_flags(node_name) ++ ["--eval", eval]
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
defimpl LiveBook.Runtime, for: LiveBook.Runtime.ElixirStandalone do
|
||||
alias LiveBook.Runtime.ErlDist
|
||||
|
||||
def connect(runtime) do
|
||||
ErlDist.Manager.set_owner(runtime.node, self())
|
||||
Process.monitor({ErlDist.Manager, runtime.node})
|
||||
end
|
||||
|
||||
def disconnect(runtime) do
|
||||
ErlDist.Manager.stop(runtime.node)
|
||||
end
|
||||
|
||||
def evaluate_code(runtime, code, container_ref, evaluation_ref, prev_evaluation_ref \\ :initial) do
|
||||
ErlDist.Manager.evaluate_code(
|
||||
runtime.node,
|
||||
code,
|
||||
container_ref,
|
||||
evaluation_ref,
|
||||
prev_evaluation_ref
|
||||
)
|
||||
end
|
||||
|
||||
def forget_evaluation(runtime, container_ref, evaluation_ref) do
|
||||
ErlDist.Manager.forget_evaluation(runtime.node, container_ref, evaluation_ref)
|
||||
end
|
||||
|
||||
def drop_container(runtime, container_ref) do
|
||||
ErlDist.Manager.drop_container(runtime.node, container_ref)
|
||||
end
|
||||
end
|
||||
|
|
@ -8,7 +8,7 @@ defmodule LiveBook.Runtime.ErlDist do
|
|||
# code evaluation may take place in a separate Elixir runtime,
|
||||
# which also makes it easy to terminate the whole
|
||||
# evaluation environment without stopping LiveBook.
|
||||
# This is what both `Runtime.Standalone` and `Runtime.Attached` do
|
||||
# This is what both `Runtime.ElixirStandalone` and `Runtime.Attached` do
|
||||
# and this module containes the shared functionality they need.
|
||||
#
|
||||
# To work with a separate node, we have to inject the necessary
|
||||
|
|
|
|||
129
lib/live_book/runtime/mix_standalone.ex
Normal file
129
lib/live_book/runtime/mix_standalone.ex
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
defmodule LiveBook.Runtime.MixStandalone do
|
||||
defstruct [:node, :primary_pid, :project_path]
|
||||
|
||||
# A runtime backed by a standalone Elixir node managed by LiveBook.
|
||||
#
|
||||
# This runtime is similar to `LiveBook.Runtime.ElixirStandalone`,
|
||||
# but the node is started in the context of a Mix project.
|
||||
|
||||
import LiveBook.Runtime.StandaloneInit
|
||||
|
||||
alias LiveBook.Utils
|
||||
alias LiveBook.Utils.Emitter
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
node: node(),
|
||||
primary_pid: pid(),
|
||||
project_path: String.t()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Starts a new Elixir node (i.e. a system process) and initializes
|
||||
it with LiveBook-specific modules and processes.
|
||||
|
||||
The node is started together with a Mix environment appropriate
|
||||
for the given `project_path`. The setup may involve
|
||||
long-running steps (like fetching dependencies, compiling the project),
|
||||
so the initialization is asynchronous. This function spawns and links
|
||||
a process responsible for initialization, which then uses `emitter`
|
||||
to emit the following notifications:
|
||||
|
||||
* `{:output, string}` - arbitrary output/info sent as the initialization proceeds
|
||||
* `{:ok, runtime}` - a final message indicating successful initialization
|
||||
* `{:error, message}` - a final message indicating failure
|
||||
|
||||
If no process calls `Runtime.connect/1` for a period of time,
|
||||
the node automatically terminates. Whoever connects, becomes the owner
|
||||
and as soon as it terminates, the node terminates as well.
|
||||
The node may also be terminated manually by using `Runtime.disconnect/1`.
|
||||
|
||||
Note: to start the node it is required that both `elixir` and `mix` are
|
||||
recognised executables within the system.
|
||||
"""
|
||||
@spec init_async(String.t(), Emitter.t()) :: :ok
|
||||
def init_async(project_path, emitter) do
|
||||
output_emitter = Emitter.mapper(emitter, fn output -> {:output, output} end)
|
||||
|
||||
spawn_link(fn ->
|
||||
parent_node = node()
|
||||
child_node = random_node_name()
|
||||
parent_process_name = random_process_name()
|
||||
|
||||
Utils.temporarily_register(self(), parent_process_name, fn ->
|
||||
with {:ok, elixir_path} <- find_elixir_executable(),
|
||||
:ok <- run_mix_task("deps.get", project_path, output_emitter),
|
||||
:ok <- run_mix_task("compile", project_path, output_emitter),
|
||||
eval <- child_node_ast(parent_node, parent_process_name) |> Macro.to_string(),
|
||||
port <- start_elixir_mix_node(elixir_path, child_node, eval, project_path),
|
||||
{:ok, primary_pid} <- parent_init_sequence(child_node, port, output_emitter) do
|
||||
runtime = %__MODULE__{
|
||||
node: child_node,
|
||||
primary_pid: primary_pid,
|
||||
project_path: project_path
|
||||
}
|
||||
|
||||
Emitter.emit(emitter, {:ok, runtime})
|
||||
else
|
||||
{:error, error} ->
|
||||
Emitter.emit(emitter, {:error, error})
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
defp run_mix_task(task, project_path, output_emitter) do
|
||||
Emitter.emit(output_emitter, "Running mix #{task}...\n")
|
||||
|
||||
case System.cmd("mix", [task],
|
||||
cd: project_path,
|
||||
stderr_to_stdout: true,
|
||||
into: output_emitter
|
||||
) do
|
||||
{_callback, 0} -> :ok
|
||||
{_callback, _status} -> {:error, "running mix #{task} failed, see output for more details"}
|
||||
end
|
||||
end
|
||||
|
||||
defp start_elixir_mix_node(elixir_path, node_name, eval, project_path) do
|
||||
# Here we create a port to start the system process in a non-blocking way.
|
||||
Port.open({:spawn_executable, elixir_path}, [
|
||||
:binary,
|
||||
:stderr_to_stdout,
|
||||
args: elixir_flags(node_name) ++ ["-S", "mix", "run", "--eval", eval],
|
||||
cd: project_path
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
defimpl LiveBook.Runtime, for: LiveBook.Runtime.MixStandalone do
|
||||
alias LiveBook.Runtime.ErlDist
|
||||
|
||||
def connect(runtime) do
|
||||
ErlDist.Manager.set_owner(runtime.node, self())
|
||||
Process.monitor({ErlDist.Manager, runtime.node})
|
||||
end
|
||||
|
||||
def disconnect(runtime) do
|
||||
ErlDist.Manager.stop(runtime.node)
|
||||
end
|
||||
|
||||
def evaluate_code(runtime, code, container_ref, evaluation_ref, prev_evaluation_ref \\ :initial) do
|
||||
ErlDist.Manager.evaluate_code(
|
||||
runtime.node,
|
||||
code,
|
||||
container_ref,
|
||||
evaluation_ref,
|
||||
prev_evaluation_ref
|
||||
)
|
||||
end
|
||||
|
||||
def forget_evaluation(runtime, container_ref, evaluation_ref) do
|
||||
ErlDist.Manager.forget_evaluation(runtime.node, container_ref, evaluation_ref)
|
||||
end
|
||||
|
||||
def drop_container(runtime, container_ref) do
|
||||
ErlDist.Manager.drop_container(runtime.node, container_ref)
|
||||
end
|
||||
end
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
defmodule LiveBook.Runtime.Standalone do
|
||||
defstruct [:node, :primary_pid, :init_ref]
|
||||
|
||||
# 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.
|
||||
|
||||
alias LiveBook.Utils
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
node: node(),
|
||||
primary_pid: pid(),
|
||||
init_ref: reference()
|
||||
}
|
||||
|
||||
@doc """
|
||||
Starts a new Elixir node (i.e. a system process) and initializes
|
||||
it with LiveBook-specific modules and processes.
|
||||
|
||||
The new node monitors the given owner process and terminates
|
||||
as soon as it terminates. It may also be terminated manually
|
||||
by using `Runtime.disconnect/1`.
|
||||
|
||||
Note: to start the node it is required that `elixir` is a recognised
|
||||
executable within the system.
|
||||
"""
|
||||
@spec init(pid()) :: {:ok, t()} | {:error, :no_elixir_executable | :timeout}
|
||||
def init(owner_pid) do
|
||||
case System.find_executable("elixir") do
|
||||
nil ->
|
||||
{:error, :no_elixir_executable}
|
||||
|
||||
elixir_path ->
|
||||
id = Utils.random_short_id()
|
||||
|
||||
node = Utils.node_from_name("live_book_runtime_#{id}")
|
||||
|
||||
# The new Elixir node receives a code to evaluate
|
||||
# and we have to pass the current pid there, but since pid
|
||||
# is not a type we can include directly in the code,
|
||||
# we temporarily register the current process under a name.
|
||||
waiter = :"live_book_waiter_#{id}"
|
||||
Process.register(self(), waiter)
|
||||
|
||||
eval = child_node_eval(waiter, node()) |> Macro.to_string()
|
||||
|
||||
# Here we create a port to start the system process in a non-blocking way.
|
||||
Port.open({:spawn_executable, elixir_path}, [
|
||||
# Don't use stdio, so that the caller does not receive
|
||||
# unexpected messages if the process produces some output.
|
||||
:nouse_stdio,
|
||||
args: [
|
||||
if(LiveBook.Config.shortnames?(), do: "--sname", else: "--name"),
|
||||
to_string(node),
|
||||
"--eval",
|
||||
eval,
|
||||
# Minimize shedulers busy wait threshold,
|
||||
# so that they go to sleep immediately after evaluation.
|
||||
# Enable ANSI escape codes as we handle them with HTML.
|
||||
"--erl",
|
||||
"+sbwt none +sbwtdcpu none +sbwtdio none -elixir ansi_enabled true"
|
||||
]
|
||||
])
|
||||
|
||||
receive do
|
||||
{:node_started, init_ref, ^node, primary_pid} ->
|
||||
# Unregister the temporary name as it's no longer needed.
|
||||
Process.unregister(waiter)
|
||||
# Having the other process pid we can send the owner pid as a message.
|
||||
send(primary_pid, {:node_acknowledged, init_ref, owner_pid})
|
||||
|
||||
# There should be no problem initializing the new node
|
||||
:ok = LiveBook.Runtime.ErlDist.initialize(node)
|
||||
|
||||
{:ok, %__MODULE__{node: node, primary_pid: primary_pid, init_ref: init_ref}}
|
||||
after
|
||||
10_000 ->
|
||||
{:error, :timeout}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp child_node_eval(waiter, parent_node) do
|
||||
# This is the code that's gonna be evaluated in the newly
|
||||
# spawned Elixir runtime. This is the primary process
|
||||
# and as soon as it finishes, the runtime terminates.
|
||||
quote do
|
||||
# Initiate communication with the waiting process on the parent node.
|
||||
init_ref = make_ref()
|
||||
send({unquote(waiter), unquote(parent_node)}, {:node_started, init_ref, node(), self()})
|
||||
|
||||
receive do
|
||||
{:node_acknowledged, ^init_ref, owner_pid} ->
|
||||
owner_ref = Process.monitor(owner_pid)
|
||||
|
||||
# Wait until either the owner process terminates
|
||||
# or we receives an explicit stop request.
|
||||
receive do
|
||||
{:DOWN, ^owner_ref, :process, _object, _reason} ->
|
||||
:ok
|
||||
|
||||
{:stop, ^init_ref} ->
|
||||
:ok
|
||||
end
|
||||
after
|
||||
10_000 -> :timeout
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defimpl LiveBook.Runtime, for: LiveBook.Runtime.Standalone do
|
||||
alias LiveBook.Runtime.ErlDist
|
||||
|
||||
def connect(runtime) do
|
||||
ErlDist.Manager.set_owner(runtime.node, self())
|
||||
Process.monitor({ErlDist.Manager, runtime.node})
|
||||
end
|
||||
|
||||
def disconnect(runtime) do
|
||||
ErlDist.Manager.stop(runtime.node)
|
||||
# Instruct the other node to terminate
|
||||
send(runtime.primary_pid, {:stop, runtime.init_ref})
|
||||
end
|
||||
|
||||
def evaluate_code(runtime, code, container_ref, evaluation_ref, prev_evaluation_ref \\ :initial) do
|
||||
ErlDist.Manager.evaluate_code(
|
||||
runtime.node,
|
||||
code,
|
||||
container_ref,
|
||||
evaluation_ref,
|
||||
prev_evaluation_ref
|
||||
)
|
||||
end
|
||||
|
||||
def forget_evaluation(runtime, container_ref, evaluation_ref) do
|
||||
ErlDist.Manager.forget_evaluation(runtime.node, container_ref, evaluation_ref)
|
||||
end
|
||||
|
||||
def drop_container(runtime, container_ref) do
|
||||
ErlDist.Manager.drop_container(runtime.node, container_ref)
|
||||
end
|
||||
end
|
||||
149
lib/live_book/runtime/standalone_init.ex
Normal file
149
lib/live_book/runtime/standalone_init.ex
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
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
|
||||
|
|
@ -528,7 +528,7 @@ defmodule LiveBook.Session do
|
|||
# Checks if a runtime already set, and if that's not the case
|
||||
# starts a new standlone one.
|
||||
defp ensure_runtime(%{data: %{runtime: nil}} = state) do
|
||||
with {:ok, runtime} <- Runtime.Standalone.init(self()) do
|
||||
with {:ok, runtime} <- Runtime.ElixirStandalone.init() do
|
||||
runtime_monitor_ref = Runtime.connect(runtime)
|
||||
|
||||
{:ok,
|
||||
|
|
|
|||
|
|
@ -32,4 +32,15 @@ defmodule LiveBook.Utils do
|
|||
:"#{name}@#{host}"
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Registers the given process under `name` for the time of `fun` evaluation.
|
||||
"""
|
||||
@spec temporarily_register(pid(), atom(), (... -> any())) :: any()
|
||||
def temporarily_register(pid, name, fun) do
|
||||
Process.register(pid, name)
|
||||
fun.()
|
||||
after
|
||||
Process.unregister(name)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
59
lib/live_book/utils/emitter.ex
Normal file
59
lib/live_book/utils/emitter.ex
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
defmodule LiveBook.Utils.Emitter do
|
||||
@moduledoc false
|
||||
|
||||
# A wrapper struct for sending messages to the specified process.
|
||||
|
||||
defstruct [:target_pid, :ref, :mapper]
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
target_pid: pid(),
|
||||
ref: reference(),
|
||||
mapper: mapper()
|
||||
}
|
||||
|
||||
@type mapper :: (term() -> term())
|
||||
|
||||
@doc """
|
||||
Builds a new structure where `target_pid` represents
|
||||
the process that will receive all emitted items.
|
||||
"""
|
||||
@spec new(pid()) :: t()
|
||||
def new(target_pid) do
|
||||
%__MODULE__{target_pid: target_pid, ref: make_ref(), mapper: &Function.identity/1}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sends {:emitter, ref, item} message to the `target_pid`.
|
||||
|
||||
Note that item may be transformed with emitter's `mapper`
|
||||
if there is one, see `Emitter.mapper/2`.
|
||||
"""
|
||||
@spec emit(t(), term()) :: :ok
|
||||
def emit(emitter, item) do
|
||||
message = {:emitter, emitter.ref, emitter.mapper.(item)}
|
||||
send(emitter.target_pid, message)
|
||||
:ok
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a new emitter that maps all emitted items with `mapper`.
|
||||
"""
|
||||
@spec mapper(t(), mapper()) :: t()
|
||||
def mapper(emitter, mapper) do
|
||||
mapper = fn x -> mapper.(emitter.mapper.(x)) end
|
||||
%{emitter | mapper: mapper}
|
||||
end
|
||||
end
|
||||
|
||||
defimpl Collectable, for: LiveBook.Utils.Emitter do
|
||||
alias LiveBook.Utils.Emitter
|
||||
|
||||
def into(emitter) do
|
||||
collector_fun = fn
|
||||
:ok, {:cont, item} -> Emitter.emit(emitter, item)
|
||||
:ok, _ -> :ok
|
||||
end
|
||||
|
||||
{:ok, collector_fun}
|
||||
end
|
||||
end
|
||||
|
|
@ -31,6 +31,7 @@ defmodule LiveBookWeb.HomeLive do
|
|||
<%= live_component @socket, LiveBookWeb.PathSelectComponent,
|
||||
id: "path_select",
|
||||
path: @path,
|
||||
extnames: [LiveMarkdown.extension()],
|
||||
running_paths: paths(@session_summaries),
|
||||
target: nil %>
|
||||
<div class="flex justify-end space-x-2">
|
||||
|
|
|
|||
|
|
@ -6,11 +6,10 @@ defmodule LiveBookWeb.PathSelectComponent do
|
|||
# * `path` - the currently entered path
|
||||
# * `running_paths` - the list of notebook paths that are already linked to running sessions
|
||||
# * `target` - id of the component to send update events to or nil to send to the parent LV
|
||||
# * `extnames` - a list of file extensions that should be shown
|
||||
#
|
||||
# The target receives `set_path` events with `%{"path" => path}` payload.
|
||||
|
||||
alias LiveBook.LiveMarkdown
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
|
|
@ -26,7 +25,7 @@ defmodule LiveBookWeb.PathSelectComponent do
|
|||
</form>
|
||||
<div class="h-80 -m-1 p-1 overflow-y-auto tiny-scrollbar">
|
||||
<div class="grid grid-cols-4 gap-2">
|
||||
<%= for file <- list_matching_files(@path, @running_paths) do %>
|
||||
<%= for file <- list_matching_files(@path, @extnames, @running_paths) do %>
|
||||
<%= render_file(file, @target) %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
|
@ -59,7 +58,7 @@ defmodule LiveBookWeb.PathSelectComponent do
|
|||
"""
|
||||
end
|
||||
|
||||
defp list_matching_files(path, running_paths) do
|
||||
defp list_matching_files(path, extnames, running_paths) do
|
||||
# Note: to provide an intuitive behavior when typing the path
|
||||
# we enter a new directory when it has a trailing slash,
|
||||
# so given "/foo/bar" we list files in "foo" and given "/foo/bar/
|
||||
|
|
@ -93,7 +92,7 @@ defmodule LiveBookWeb.PathSelectComponent do
|
|||
end)
|
||||
|> Enum.filter(fn file ->
|
||||
not hidden?(file.name) and String.starts_with?(file.name, basename) and
|
||||
(file.is_dir or notebook_file?(file.name))
|
||||
(file.is_dir or valid_extension?(file.name, extnames))
|
||||
end)
|
||||
|> Enum.sort_by(fn file -> {!file.is_dir, file.name} end)
|
||||
|
||||
|
|
@ -118,8 +117,8 @@ defmodule LiveBookWeb.PathSelectComponent do
|
|||
String.starts_with?(filename, ".")
|
||||
end
|
||||
|
||||
defp notebook_file?(filename) do
|
||||
String.ends_with?(filename, LiveMarkdown.extension())
|
||||
defp valid_extension?(filename, extnames) do
|
||||
Path.extname(filename) in extnames
|
||||
end
|
||||
|
||||
defp split_path(path) do
|
||||
|
|
|
|||
|
|
@ -525,6 +525,7 @@ defmodule LiveBookWeb.SessionLive do
|
|||
end
|
||||
|
||||
defp runtime_description(nil), do: "No runtime"
|
||||
defp runtime_description(%Runtime.Standalone{}), do: "Standalone runtime"
|
||||
defp runtime_description(%Runtime.ElixirStandalone{}), do: "Elixir standalone runtime"
|
||||
defp runtime_description(%Runtime.MixStandalone{}), do: "Mix standalone runtime"
|
||||
defp runtime_description(%Runtime.Attached{}), do: "Attached runtime"
|
||||
end
|
||||
|
|
|
|||
66
lib/live_book_web/live/session_live/attached_live.ex
Normal file
66
lib/live_book_web/live/session_live/attached_live.ex
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
defmodule LiveBookWeb.SessionLive.AttachedLive do
|
||||
use LiveBookWeb, :live_view
|
||||
|
||||
alias LiveBook.{Session, Runtime, Utils}
|
||||
|
||||
@impl true
|
||||
def mount(_params, %{"session_id" => session_id}, socket) do
|
||||
{:ok, assign(socket, session_id: session_id, error_message: nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="flex-col space-y-3">
|
||||
<%= if @error_message do %>
|
||||
<div class="mb-3 rounded-md px-4 py-2 bg-red-100 text-red-400 font-medium">
|
||||
<%= @error_message %>
|
||||
</div>
|
||||
<% end %>
|
||||
<p class="text-gray-500">
|
||||
Connect the session to an already running node
|
||||
and evaluate code in the context of that node.
|
||||
Thanks to this approach you can work with
|
||||
an arbitrary Elixir runtime.
|
||||
Make sure to give the node a name, for example:
|
||||
</p>
|
||||
<div class="text-gray-500 markdown">
|
||||
<%= if LiveBook.Config.shortnames? do %>
|
||||
<pre><code>iex --sname test</code></pre>
|
||||
<% else %>
|
||||
<pre><code>iex --name test@127.0.0.1</code></pre>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="text-gray-500">
|
||||
Then enter the name of the node below:
|
||||
</p>
|
||||
<%= f = form_for :node, "#", phx_submit: "init" %>
|
||||
<%= text_input f, :name, class: "input-base shadow",
|
||||
placeholder: if(LiveBook.Config.shortnames?, do: "test", else: "test@127.0.0.1") %>
|
||||
|
||||
<%= submit "Connect", class: "mt-3 button-base button-sm" %>
|
||||
</form>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("init", %{"node" => %{"name" => name}}, socket) do
|
||||
node = Utils.node_from_name(name)
|
||||
|
||||
case Runtime.Attached.init(node) do
|
||||
{:ok, runtime} ->
|
||||
Session.connect_runtime(socket.assigns.session_id, runtime)
|
||||
{:noreply, assign(socket, error_message: nil)}
|
||||
|
||||
{:error, error} ->
|
||||
message = runtime_error_to_message(error)
|
||||
{:noreply, assign(socket, error_message: message)}
|
||||
end
|
||||
end
|
||||
|
||||
defp runtime_error_to_message(:unreachable), do: "Node unreachable"
|
||||
|
||||
defp runtime_error_to_message(:already_in_use),
|
||||
do: "Another session is already connected to this node"
|
||||
end
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
defmodule LiveBookWeb.SessionLive.ElixirStandaloneLive do
|
||||
use LiveBookWeb, :live_view
|
||||
|
||||
alias LiveBook.{Session, Runtime}
|
||||
|
||||
@impl true
|
||||
def mount(_params, %{"session_id" => session_id}, socket) do
|
||||
{:ok, assign(socket, session_id: session_id, output: nil)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="flex-col space-y-3">
|
||||
<p class="text-gray-500">
|
||||
Start a new local node to handle code evaluation.
|
||||
This is the default runtime and is started automatically
|
||||
as soon as you evaluate the first cell.
|
||||
</p>
|
||||
<button class="button-base button-sm" phx-click="init">
|
||||
Connect
|
||||
</button>
|
||||
<%= if @output do %>
|
||||
<div class="markdown max-h-20 overflow-y-auto tiny-scrollbar">
|
||||
<pre><code><%= @output %></code></pre>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("init", _params, socket) do
|
||||
{:ok, runtime} = Runtime.ElixirStandalone.init()
|
||||
Session.connect_runtime(socket.assigns.session_id, runtime)
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
88
lib/live_book_web/live/session_live/mix_standalone_live.ex
Normal file
88
lib/live_book_web/live/session_live/mix_standalone_live.ex
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
defmodule LiveBookWeb.SessionLive.MixStandaloneLive do
|
||||
use LiveBookWeb, :live_view
|
||||
|
||||
alias LiveBook.{Session, Runtime, Utils}
|
||||
|
||||
@type status :: :initial | :initializing | :finished
|
||||
|
||||
@impl true
|
||||
def mount(_params, %{"session_id" => session_id}, socket) do
|
||||
{:ok,
|
||||
assign(socket,
|
||||
session_id: session_id,
|
||||
status: :initial,
|
||||
path: default_path(),
|
||||
outputs: [],
|
||||
emitter: nil
|
||||
), temporary_assigns: [outputs: []]}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="flex-col space-y-3">
|
||||
<p class="text-gray-500">
|
||||
Start a new local node in the context of a Mix project.
|
||||
This way all your code and dependencies will be available
|
||||
within the notebook.
|
||||
</p>
|
||||
<%= if @status == :initial do %>
|
||||
<%= live_component @socket, LiveBookWeb.PathSelectComponent,
|
||||
id: "path_select",
|
||||
path: @path,
|
||||
extnames: [],
|
||||
running_paths: [],
|
||||
target: nil %>
|
||||
<%= content_tag :button, "Connect", class: "button-base button-sm", phx_click: "init", disabled: not mix_project_root?(@path) %>
|
||||
<% end %>
|
||||
<%= if @status != :initial do %>
|
||||
<div class="markdown">
|
||||
<pre><code class="max-h-40 overflow-y-auto tiny-scrollbar"
|
||||
id="mix-standalone-init-output"
|
||||
phx-update="append"
|
||||
phx-hook="ScrollOnUpdate"
|
||||
><%= for {output, i} <- @outputs do %><span id="output-<%= i %>"><%= ansi_string_to_html(output) %></span><% end %></code></pre>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("set_path", %{"path" => path}, socket) do
|
||||
{:noreply, assign(socket, path: path)}
|
||||
end
|
||||
|
||||
def handle_event("init", _params, socket) do
|
||||
emitter = Utils.Emitter.new(self())
|
||||
Runtime.MixStandalone.init_async(socket.assigns.path, emitter)
|
||||
{:noreply, assign(socket, status: :initializing, emitter: emitter)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:emitter, ref, message}, %{assigns: %{emitter: %{ref: ref}}} = socket) do
|
||||
case message do
|
||||
{:output, output} ->
|
||||
{:noreply, add_output(socket, output)}
|
||||
|
||||
{:ok, runtime} ->
|
||||
Session.connect_runtime(socket.assigns.session_id, runtime)
|
||||
{:noreply, socket |> assign(status: :finished) |> add_output("Connected successfully")}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply, socket |> assign(status: :finished) |> add_output("Error: #{error}")}
|
||||
end
|
||||
end
|
||||
|
||||
def handle_info(_, socket), do: {:noreply, socket}
|
||||
|
||||
defp add_output(socket, output) do
|
||||
assign(socket, outputs: socket.assigns.outputs ++ [{output, Utils.random_id()}])
|
||||
end
|
||||
|
||||
defp default_path(), do: File.cwd!() <> "/"
|
||||
|
||||
defp mix_project_root?(path) do
|
||||
File.dir?(path) and File.exists?(Path.join(path, "mix.exs"))
|
||||
end
|
||||
end
|
||||
|
|
@ -12,7 +12,7 @@ defmodule LiveBookWeb.SessionLive.PersistenceComponent do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="p-6 pb-4 max-w-4xl w-screen flex flex-col space-y-4">
|
||||
<div class="p-6 pb-4 max-w-4xl w-screen flex flex-col space-y-3">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Configure file
|
||||
</h3>
|
||||
|
|
@ -39,6 +39,7 @@ defmodule LiveBookWeb.SessionLive.PersistenceComponent do
|
|||
<%= live_component @socket, LiveBookWeb.PathSelectComponent,
|
||||
id: "path_select",
|
||||
path: @path,
|
||||
extnames: [LiveMarkdown.extension()],
|
||||
running_paths: paths(@session_summaries),
|
||||
target: @myself %>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,144 +1,106 @@
|
|||
defmodule LiveBookWeb.SessionLive.RuntimeComponent do
|
||||
use LiveBookWeb, :live_component
|
||||
|
||||
alias LiveBook.{Session, Runtime, Utils}
|
||||
alias LiveBook.{Session, Runtime}
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, error_message: nil)}
|
||||
{:ok, assign(socket, type: "elixir_standalone")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~L"""
|
||||
<div class="p-6 sm:max-w-2xl sm:w-full">
|
||||
<%= if @error_message do %>
|
||||
<div class="mb-3 rounded-md px-4 py-2 bg-red-100 text-red-400 text-sm font-medium">
|
||||
<%= @error_message %>
|
||||
</div>
|
||||
<% end %>
|
||||
<div class="p-6 pb-4 max-w-4xl w-screen flex flex-col space-y-3">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Runtime
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
The code is evaluated in a separate Elixir runtime (node),
|
||||
which you can configure yourself here.
|
||||
</p>
|
||||
<div class="shadow rounded-md p-2 my-4">
|
||||
<%= if @runtime do %>
|
||||
<table class="w-full text-center text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-1/3">Type</th>
|
||||
<th class="w-1/3">Node name</th>
|
||||
<th class="w-1/3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><%= runtime_type_label(@runtime) %></td>
|
||||
<td><%= @runtime.node %></td>
|
||||
<td>
|
||||
<button class="button-base text-sm button-sm button-danger"
|
||||
type="button"
|
||||
phx-click="disconnect"
|
||||
phx-target="<%= @myself %>">
|
||||
Disconnect
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p class="p-2 text-sm text-gray-500">
|
||||
No connected node
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="<%= if @runtime, do: "opacity-50 pointer-events-none" %>">
|
||||
<h3 class="text-lg font-medium text-gray-900">
|
||||
Standalone
|
||||
</h3>
|
||||
<div class="flex-col space-y-3">
|
||||
<p class="text-sm text-gray-500">
|
||||
You can start a new local node to handle code evaluation.
|
||||
This happens automatically as soon as you evaluate the first cell.
|
||||
</p>
|
||||
<button class="button-base text-sm"
|
||||
type="button"
|
||||
phx-click="init_standalone"
|
||||
phx-target="<%= @myself %>">
|
||||
Connect
|
||||
</button>
|
||||
</div>
|
||||
<h3 class="text-lg font-medium text-gray-900 mt-4">
|
||||
Attached
|
||||
</h3>
|
||||
<div class="flex-col space-y-3">
|
||||
<p class="text-sm text-gray-500">
|
||||
You can connect the session to an already running node
|
||||
and evaluate code in the context of that node.
|
||||
This is especially handy when developing mix projects.
|
||||
Make sure to give the node a name:
|
||||
</p>
|
||||
<div class="text-sm text-gray-500 markdown">
|
||||
<%= if LiveBook.Config.shortnames? do %>
|
||||
<pre><code>iex --sname test -S mix</code></pre>
|
||||
<div class="w-full flex-col space-y-3">
|
||||
<p class="text-gray-500">
|
||||
The code is evaluated in a separate Elixir runtime (node),
|
||||
which you can configure yourself here.
|
||||
</p>
|
||||
<div class="shadow rounded-md p-2">
|
||||
<%= if @runtime do %>
|
||||
<table class="w-full text-center text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="w-1/3">Type</th>
|
||||
<th class="w-1/3">Node name</th>
|
||||
<th class="w-1/3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><%= runtime_type_label(@runtime) %></td>
|
||||
<td><%= @runtime.node %></td>
|
||||
<td>
|
||||
<button class="button-base text-sm button-sm button-danger"
|
||||
type="button"
|
||||
phx-click="disconnect"
|
||||
phx-target="<%= @myself %>">
|
||||
Disconnect
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<pre><code>iex --name test@127.0.0.1 -S mix</code></pre>
|
||||
<p class="p-2 text-sm text-gray-500">
|
||||
No connected node
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<form phx-change="set_runtime_type" phx-target="<%= @myself %>">
|
||||
<div class="radio-button-group">
|
||||
<label class="radio-button">
|
||||
<%= tag :input, class: "radio-button__input", type: "radio", name: "type", value: "elixir_standalone", checked: @type == "elixir_standalone" %>
|
||||
<span class="radio-button__label">Elixir standalone</span>
|
||||
</label>
|
||||
<label class="radio-button">
|
||||
<%= tag :input, class: "radio-button__input", type: "radio", name: "type", value: "mix_standalone", checked: @type == "mix_standalone" %>
|
||||
<span class="radio-button__label">Mix standalone</span>
|
||||
</label>
|
||||
<label class="radio-button">
|
||||
<%= tag :input, class: "radio-button__input", type: "radio", name: "type", value: "attached", checked: @type == "attached" %>
|
||||
<span class="radio-button__label">Attached node</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">
|
||||
Then enter the name of the node below:
|
||||
</p>
|
||||
<%= f = form_for :node, "#", phx_target: @myself, phx_submit: "init_attached" %>
|
||||
<%= text_input f, :name, class: "input-base text-sm shadow",
|
||||
placeholder: if(LiveBook.Config.shortnames?, do: "test", else: "test@127.0.0.1") %>
|
||||
|
||||
<%= submit "Connect", class: "mt-3 button-base text-sm" %>
|
||||
</form>
|
||||
</form>
|
||||
<div>
|
||||
<%= if @type == "elixir_standalone" do %>
|
||||
<%= live_render @socket, LiveBookWeb.SessionLive.ElixirStandaloneLive,
|
||||
id: :elixir_standalone_runtime,
|
||||
session: %{"session_id" => @session_id} %>
|
||||
<% end %>
|
||||
<%= if @type == "mix_standalone" do %>
|
||||
<%= live_render @socket, LiveBookWeb.SessionLive.MixStandaloneLive,
|
||||
id: :mix_standalone_runtime,
|
||||
session: %{"session_id" => @session_id} %>
|
||||
<% end %>
|
||||
<%= if @type == "attached" do %>
|
||||
<%= live_render @socket, LiveBookWeb.SessionLive.AttachedLive,
|
||||
id: :attached_runtime,
|
||||
session: %{"session_id" => @session_id} %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp runtime_type_label(%Runtime.Standalone{}), do: "Standalone"
|
||||
defp runtime_type_label(%Runtime.ElixirStandalone{}), do: "Elixir standalone"
|
||||
defp runtime_type_label(%Runtime.MixStandalone{}), do: "Mix standalone"
|
||||
defp runtime_type_label(%Runtime.Attached{}), do: "Attached"
|
||||
|
||||
@impl true
|
||||
def handle_event("set_runtime_type", %{"type" => type}, socket) do
|
||||
{:noreply, assign(socket, type: type)}
|
||||
end
|
||||
|
||||
def handle_event("disconnect", _params, socket) do
|
||||
Session.disconnect_runtime(socket.assigns.session_id)
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
def handle_event("init_standalone", _params, socket) do
|
||||
session_pid = Session.get_pid(socket.assigns.session_id)
|
||||
handle_runtime_init_result(socket, Runtime.Standalone.init(session_pid))
|
||||
end
|
||||
|
||||
def handle_event("init_attached", %{"node" => %{"name" => name}}, socket) do
|
||||
node = Utils.node_from_name(name)
|
||||
handle_runtime_init_result(socket, Runtime.Attached.init(node))
|
||||
end
|
||||
|
||||
defp handle_runtime_init_result(socket, {:ok, runtime}) do
|
||||
Session.connect_runtime(socket.assigns.session_id, runtime)
|
||||
{:noreply, assign(socket, error_message: nil)}
|
||||
end
|
||||
|
||||
defp handle_runtime_init_result(socket, {:error, error}) do
|
||||
message = runtime_error_to_message(error)
|
||||
{:noreply, assign(socket, error_message: message)}
|
||||
end
|
||||
|
||||
defp runtime_error_to_message(:unreachable), do: "Node unreachable"
|
||||
defp runtime_error_to_message(:no_elixir_executable), do: "No Elixir executable found in PATH"
|
||||
defp runtime_error_to_message(:timeout), do: "Connection timed out"
|
||||
|
||||
defp runtime_error_to_message(:already_in_use),
|
||||
do: "Another session is already connected to this node"
|
||||
|
||||
defp runtime_error_to_message(_), do: "Something went wrong"
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,32 +1,27 @@
|
|||
defmodule LiveBook.Runtime.StandaloneTest do
|
||||
defmodule LiveBook.Runtime.ElixirStandaloneTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias LiveBook.Runtime
|
||||
|
||||
describe "init/1" do
|
||||
test "starts a new Elixir runtime in distribution mode and ties its lifetime to the given owner process" do
|
||||
owner =
|
||||
spawn(fn ->
|
||||
receive do
|
||||
:stop -> :ok
|
||||
end
|
||||
end)
|
||||
|
||||
assert {:ok, %{node: node}} = Runtime.Standalone.init(owner)
|
||||
test "starts a new Elixir runtime in distribution mode and ties its lifetime to the Manager process" do
|
||||
assert {:ok, %{node: node} = runtime} = Runtime.ElixirStandalone.init()
|
||||
Runtime.connect(runtime)
|
||||
|
||||
# Make sure the node is running.
|
||||
Node.monitor(node, true)
|
||||
assert :pong = Node.ping(node)
|
||||
|
||||
# Tell the owner process to stop.
|
||||
send(owner, :stop)
|
||||
LiveBook.Runtime.ErlDist.Manager.stop(node)
|
||||
|
||||
# Once the owner process terminates, the node should terminate as well.
|
||||
# Once Manager terminates, the node should terminate as well.
|
||||
assert_receive {:nodedown, ^node}
|
||||
end
|
||||
|
||||
test "loads necessary modules and starts manager process" do
|
||||
assert {:ok, %{node: node}} = Runtime.Standalone.init(self())
|
||||
assert {:ok, %{node: node} = runtime} = Runtime.ElixirStandalone.init()
|
||||
Runtime.connect(runtime)
|
||||
|
||||
assert evaluator_module_loaded?(node)
|
||||
assert manager_started?(node)
|
||||
|
|
@ -34,7 +29,8 @@ defmodule LiveBook.Runtime.StandaloneTest do
|
|||
end
|
||||
|
||||
test "Runtime.disconnect/1 makes the node terminate" do
|
||||
assert {:ok, %{node: node} = runtime} = Runtime.Standalone.init(self())
|
||||
assert {:ok, %{node: node} = runtime} = Runtime.ElixirStandalone.init()
|
||||
Runtime.connect(runtime)
|
||||
|
||||
# Make sure the node is running.
|
||||
Node.monitor(node, true)
|
||||
|
|
@ -240,15 +240,15 @@ defmodule LiveBook.SessionTest do
|
|||
test "if the runtime node goes down, notifies the subscribers" do
|
||||
session_id = Utils.random_id()
|
||||
{:ok, _} = Session.start_link(id: session_id)
|
||||
{:ok, runtime} = Runtime.Standalone.init(self())
|
||||
{:ok, runtime} = Runtime.ElixirStandalone.init()
|
||||
|
||||
Phoenix.PubSub.subscribe(LiveBook.PubSub, "sessions:#{session_id}")
|
||||
|
||||
# Wait for the runtime to best set
|
||||
# Wait for the runtime to be set
|
||||
Session.connect_runtime(session_id, runtime)
|
||||
assert_receive {:operation, {:set_runtime, ^runtime}}
|
||||
|
||||
# Terminate the other node, the session should detect that.
|
||||
# Terminate the other node, the session should detect that
|
||||
Node.spawn(runtime.node, System, :halt, [])
|
||||
|
||||
assert_receive {:operation, {:set_runtime, nil}}
|
||||
|
|
|
|||
45
test/live_book/utils/emitter_test.exs
Normal file
45
test/live_book/utils/emitter_test.exs
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
defmodule LiveBook.Utils.EmitterTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias LiveBook.Utils.Emitter
|
||||
|
||||
describe "emit/2" do
|
||||
test "sends the item as a message to the specified process" do
|
||||
emitter = Emitter.new(self())
|
||||
ref = emitter.ref
|
||||
Emitter.emit(emitter, :hey)
|
||||
|
||||
assert_receive {:emitter, ^ref, :hey}
|
||||
end
|
||||
end
|
||||
|
||||
describe "map/2" do
|
||||
test "returns a modified emitter that transforms items before they are sent" do
|
||||
emitter = Emitter.new(self())
|
||||
ref = emitter.ref
|
||||
string_emitter = Emitter.mapper(emitter, &to_string/1)
|
||||
Emitter.emit(string_emitter, :hey)
|
||||
|
||||
assert_receive {:emitter, ^ref, "hey"}
|
||||
end
|
||||
|
||||
test "supports chaining" do
|
||||
emitter = Emitter.new(self())
|
||||
ref = emitter.ref
|
||||
string_emitter = Emitter.mapper(emitter, &to_string/1)
|
||||
duplicate_emitter = Emitter.mapper(string_emitter, fn x -> {x, x} end)
|
||||
Emitter.emit(duplicate_emitter, :hey)
|
||||
|
||||
assert_receive {:emitter, ^ref, {"hey", "hey"}}
|
||||
end
|
||||
end
|
||||
|
||||
test "implements Collectable so that it emits every item" do
|
||||
emitter = Emitter.new(self())
|
||||
ref = emitter.ref
|
||||
for x <- ["a", "b"], into: emitter, do: x
|
||||
|
||||
assert_receive {:emitter, ^ref, "a"}
|
||||
assert_receive {:emitter, ^ref, "b"}
|
||||
end
|
||||
end
|
||||
|
|
@ -39,7 +39,10 @@ defmodule LiveBookWeb.PathSelectComponentTest do
|
|||
end
|
||||
|
||||
defp attrs(attrs) do
|
||||
Keyword.merge([id: 1, path: "/", running_paths: [], target: nil], attrs)
|
||||
Keyword.merge(
|
||||
[id: 1, path: "/", extnames: [".livemd"], running_paths: [], target: nil],
|
||||
attrs
|
||||
)
|
||||
end
|
||||
|
||||
defp notebooks_path() do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue