mirror of
https://github.com/livebook-dev/livebook.git
synced 2024-09-20 10:05:57 +08:00
Run Livebook Desktop without EPMD (#2591)
This commit is contained in:
parent
eb4887657a
commit
6d7f416f18
50
.github/workflows/test.yml
vendored
50
.github/workflows/test.yml
vendored
|
@ -6,10 +6,17 @@ on:
|
|||
- main
|
||||
- "v*.*"
|
||||
jobs:
|
||||
main:
|
||||
linux:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
MIX_ENV: test
|
||||
strategy:
|
||||
matrix:
|
||||
build:
|
||||
- name: default
|
||||
- name: epmdless
|
||||
env: LIVEBOOK_EPMDLESS=true ELIXIR_ERL_OPTIONS="-epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0"
|
||||
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
|
@ -41,23 +48,7 @@ jobs:
|
|||
run: mix compile --warnings-as-errors
|
||||
- name: Run tests
|
||||
run: mix test
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18.x"
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
- name: Install npm dependencies
|
||||
run: npm ci --prefix assets
|
||||
- name: Check assets formatting
|
||||
run: npm run format-check --prefix assets
|
||||
- name: Run assets tests
|
||||
run: npm test --prefix assets
|
||||
|
||||
windows:
|
||||
runs-on: windows-latest
|
||||
if: github.event_name == 'push'
|
||||
|
@ -134,3 +125,26 @@ jobs:
|
|||
key: ${{ runner.os }}-elixir-${{ env.elixir }}
|
||||
- name: Build the app
|
||||
run: .github/scripts/app/build_macos.sh
|
||||
|
||||
assets:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
uses: actions/checkout@v3
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "18.x"
|
||||
- name: Cache npm dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
- name: Install npm dependencies
|
||||
run: npm ci --prefix assets
|
||||
- name: Check assets formatting
|
||||
run: npm run format-check --prefix assets
|
||||
- name: Run assets tests
|
||||
run: npm test --prefix assets
|
||||
|
|
3
.gitignore
vendored
3
.gitignore
vendored
|
@ -33,3 +33,6 @@ npm-debug.log
|
|||
|
||||
# The built Escript
|
||||
/livebook
|
||||
|
||||
# The priv directory with the EPMD file
|
||||
/priv/epmd
|
||||
|
|
|
@ -224,7 +224,10 @@ The following environment variables can be used to configure Livebook on boot:
|
|||
cluster. Must be "name" (long names) or "sname" (short names). Note that this
|
||||
sets RELEASE_DISTRIBUTION if present when creating a release. Defaults to "sname".
|
||||
|
||||
* `LIVEBOOK_FIPS` - if set to "true" will try to enable the FIPS mode on startup.
|
||||
* `LIVEBOOK_EPMDLESS` - if set to "true", it disables the usage of EPMD. This is
|
||||
only supported within releases and defaults to true for the Desktop app.
|
||||
|
||||
* `LIVEBOOK_FIPS` - if set to "true", it enables the FIPS mode on startup.
|
||||
See more details in [the documentation](https://hexdocs.pm/livebook/fips.html).
|
||||
|
||||
* `LIVEBOOK_FORCE_SSL_HOST` - sets a host to redirect to if the request is not over HTTPS.
|
||||
|
|
|
@ -24,20 +24,21 @@ config :mime, :types, %{
|
|||
}
|
||||
|
||||
config :livebook,
|
||||
teams_url: "https://teams.livebook.dev",
|
||||
agent_name: "livebook-agent",
|
||||
allowed_uri_schemes: [],
|
||||
app_service_name: nil,
|
||||
app_service_url: nil,
|
||||
authentication_mode: :token,
|
||||
aws_credentials: false,
|
||||
epmdless: false,
|
||||
feature_flags: [],
|
||||
force_ssl_host: nil,
|
||||
learn_notebooks: [],
|
||||
plugs: [],
|
||||
shutdown_callback: nil,
|
||||
teams_url: "https://teams.livebook.dev",
|
||||
update_instructions_url: nil,
|
||||
within_iframe: false,
|
||||
allowed_uri_schemes: [],
|
||||
aws_credentials: false
|
||||
within_iframe: false
|
||||
|
||||
config :livebook, Livebook.Apps.Manager, retry_backoff_base_ms: 5_000
|
||||
|
||||
|
|
|
@ -149,6 +149,10 @@ defmodule Livebook do
|
|||
config :livebook, :aws_credentials, true
|
||||
end
|
||||
|
||||
if Livebook.Config.boolean!("LIVEBOOK_EPMDLESS", false) do
|
||||
config :livebook, :epmdless, true
|
||||
end
|
||||
|
||||
config :livebook,
|
||||
:default_runtime,
|
||||
Livebook.Config.default_runtime!("LIVEBOOK_DEFAULT_RUNTIME") ||
|
||||
|
|
|
@ -6,8 +6,16 @@ defmodule Livebook.Application do
|
|||
setup_optional_dependencies()
|
||||
ensure_directories!()
|
||||
set_local_file_system!()
|
||||
ensure_distribution!()
|
||||
validate_hostname_resolution!()
|
||||
|
||||
if Application.fetch_env!(:livebook, :epmdless) do
|
||||
validate_epmdless!()
|
||||
ensure_distribution!()
|
||||
else
|
||||
ensure_epmd!()
|
||||
ensure_distribution!()
|
||||
validate_hostname_resolution!()
|
||||
end
|
||||
|
||||
set_cookie()
|
||||
|
||||
children =
|
||||
|
@ -38,7 +46,7 @@ defmodule Livebook.Application do
|
|||
# Start the tracker server for sessions and apps on this node
|
||||
{Livebook.Tracker, pubsub_server: Livebook.PubSub},
|
||||
# Start the node pool for managing node names
|
||||
Livebook.Runtime.NodePool,
|
||||
Livebook.EPMD.NodePool,
|
||||
# Start the server responsible for associating files with sessions
|
||||
Livebook.Session.FileGuard,
|
||||
# Start the supervisor dynamically managing sessions
|
||||
|
@ -117,7 +125,21 @@ defmodule Livebook.Application do
|
|||
:persistent_term.put(:livebook_local_file_system, local_file_system)
|
||||
end
|
||||
|
||||
defp ensure_distribution!() do
|
||||
defp validate_epmdless!() do
|
||||
with {:ok, [[~c"Elixir.Livebook.EPMD"]]} <- :init.get_argument(:epmd_module),
|
||||
{:ok, [[~c"false"]]} <- :init.get_argument(:start_epmd),
|
||||
{:ok, [[~c"0"]]} <- :init.get_argument(:erl_epmd_port) do
|
||||
:ok
|
||||
else
|
||||
_ ->
|
||||
Livebook.Config.abort!("""
|
||||
You must specify ELIXIR_ERL_OPTIONS=\"-epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0\" with LIVEBOOK_EPMDLESS. \
|
||||
The epmd module can be found inside #{Application.app_dir(:livebook, "priv/ebin")}.
|
||||
""")
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_epmd!() do
|
||||
unless Node.alive?() do
|
||||
case System.cmd("epmd", ["-daemon"]) do
|
||||
{_, 0} ->
|
||||
|
@ -125,7 +147,7 @@ defmodule Livebook.Application do
|
|||
|
||||
_ ->
|
||||
Livebook.Config.abort!("""
|
||||
Could not start epmd (Erlang Port Mapper Driver). Livebook uses epmd to \
|
||||
Could not start epmd (Erlang Port Mapper Daemon). Livebook uses epmd to \
|
||||
talk to different runtimes. You may have to start epmd explicitly by calling:
|
||||
|
||||
epmd -daemon
|
||||
|
@ -137,7 +159,11 @@ defmodule Livebook.Application do
|
|||
Then you can try booting Livebook again
|
||||
""")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp ensure_distribution!() do
|
||||
unless Node.alive?() do
|
||||
{type, name} = get_node_type_and_name()
|
||||
|
||||
case Node.start(name, type) do
|
||||
|
@ -393,6 +419,11 @@ defmodule Livebook.Application do
|
|||
})
|
||||
end
|
||||
|
||||
# We set ELIXIR_ERL_OPTIONS when LIVEBOOK_EPMDLESS is set to true.
|
||||
# By design, we don't allow ELIXIR_ERL_OPTIONS to pass through.
|
||||
# Use ERL_AFLAGS and ERL_ZFLAGS if you want to configure both
|
||||
# Livebook and spawned runtimes.
|
||||
defp config_env_var?("ELIXIR_ERL_OPTIONS"), do: true
|
||||
defp config_env_var?("LIVEBOOK_" <> _), do: true
|
||||
defp config_env_var?("RELEASE_" <> _), do: true
|
||||
defp config_env_var?("MIX_ENV"), do: true
|
||||
|
|
|
@ -471,8 +471,8 @@ defmodule Livebook.Config do
|
|||
def port!(env) do
|
||||
if port = System.get_env(env) do
|
||||
case Integer.parse(port) do
|
||||
{port, ""} -> port
|
||||
:error -> abort!("expected #{env} to be an integer, got: #{inspect(port)}")
|
||||
{port, ""} when port >= 0 -> port
|
||||
:error -> abort!("expected #{env} to be a non-negative integer, got: #{inspect(port)}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
111
lib/livebook/epmd.ex
Normal file
111
lib/livebook/epmd.ex
Normal file
|
@ -0,0 +1,111 @@
|
|||
defmodule Livebook.EPMD do
|
||||
# A custom EPMD module used to bypass the epmd OS daemon
|
||||
# on both Livebook and the runtimes.
|
||||
@after_compile __MODULE__
|
||||
|
||||
# From Erlang/OTP 23+
|
||||
@epmd_dist_version 6
|
||||
@external_resource "priv/epmd/Elixir.Livebook.EPMD.beam"
|
||||
|
||||
@doc """
|
||||
Gets a random child node name.
|
||||
"""
|
||||
def random_child_node do
|
||||
String.to_atom(Livebook.EPMD.NodePool.get_name())
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the port information for the given node.
|
||||
"""
|
||||
def update_child_node(node, port) do
|
||||
Livebook.EPMD.NodePool.update_name(Atom.to_string(node), port)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the Livebook distribution port, if Livebook.EPMD is running, otherwise 0.
|
||||
"""
|
||||
def dist_port do
|
||||
:persistent_term.get(:livebook_dist_port, 0)
|
||||
end
|
||||
|
||||
# Custom EPMD callbacks
|
||||
|
||||
# Custom callback that registers the parent information.
|
||||
# We read this information when trying to connect to the parent.
|
||||
def start_link() do
|
||||
with {:ok, [[node, port]]} <- :init.get_argument(:livebook_parent) do
|
||||
[name, host] = :string.split(node, ~c"@")
|
||||
|
||||
:persistent_term.put(
|
||||
:livebook_parent,
|
||||
{name, host, List.to_atom(node), List.to_integer(port)}
|
||||
)
|
||||
end
|
||||
|
||||
:erl_epmd.start_link()
|
||||
end
|
||||
|
||||
# Custom callback to register our current node port.
|
||||
def register_node(name, port), do: register_node(name, port, :inet)
|
||||
|
||||
def register_node(name, port, family) do
|
||||
:persistent_term.put(:livebook_dist_port, port)
|
||||
:erl_epmd.register_node(name, port, family)
|
||||
end
|
||||
|
||||
# Custom callback that accesses the parent information.
|
||||
def port_please(name, host), do: port_please(name, host, :infinity)
|
||||
|
||||
def port_please(name, host, timeout) do
|
||||
case livebook_port(name) do
|
||||
0 -> :erl_epmd.port_please(name, host, timeout)
|
||||
port -> {:port, port, @epmd_dist_version}
|
||||
end
|
||||
end
|
||||
|
||||
# If we are running inside a Livebook Runtime,
|
||||
# we should be able to reach the parent directly
|
||||
# or reach siblings through the parent.
|
||||
defp livebook_port(name) do
|
||||
case :persistent_term.get(:livebook_parent, nil) do
|
||||
{parent_name, parent_host, parent_node, parent_port} ->
|
||||
case match_name(name, parent_name) do
|
||||
:parent -> parent_port
|
||||
:sibling -> sibling_port(parent_node, name, parent_host)
|
||||
:none -> 0
|
||||
end
|
||||
|
||||
_ ->
|
||||
0
|
||||
end
|
||||
end
|
||||
|
||||
defp match_name([x | name], [x | parent_name]), do: match_name(name, parent_name)
|
||||
defp match_name([?-, ?- | _name], _parent), do: :sibling
|
||||
defp match_name([], []), do: :parent
|
||||
defp match_name(_name, _parent), do: :none
|
||||
|
||||
defp sibling_port(parent_node, name, host) do
|
||||
:gen_server.call(
|
||||
{Livebook.EPMD.NodePool, parent_node},
|
||||
{:get_port, :erlang.list_to_binary(name ++ [?@] ++ host)},
|
||||
5000
|
||||
)
|
||||
catch
|
||||
_, _ -> 0
|
||||
end
|
||||
|
||||
# Default EPMD callbacks
|
||||
|
||||
defdelegate listen_port_please(name, host), to: :erl_epmd
|
||||
defdelegate names(host_name), to: :erl_epmd
|
||||
defdelegate address_please(name, host, address_family), to: :erl_epmd
|
||||
|
||||
# Store .beam file in priv as well
|
||||
|
||||
def __after_compile__(_env, binary) do
|
||||
File.mkdir_p!("priv/epmd")
|
||||
File.write!("priv/epmd/Elixir.Livebook.EPMD.beam", binary)
|
||||
Mix.Project.build_structure()
|
||||
end
|
||||
end
|
142
lib/livebook/epmd/node_pool.ex
Normal file
142
lib/livebook/epmd/node_pool.ex
Normal file
|
@ -0,0 +1,142 @@
|
|||
defmodule Livebook.EPMD.NodePool do
|
||||
use GenServer
|
||||
|
||||
# A pool with generated node names.
|
||||
#
|
||||
# The names are randomly generated, however to avoid atom exhaustion
|
||||
# unused names return back to the pool and can be reused later.
|
||||
|
||||
@default_time 60_000
|
||||
|
||||
# Client interface
|
||||
|
||||
@doc """
|
||||
Starts the GenServer from a Supervision tree
|
||||
|
||||
## Options
|
||||
|
||||
* `:name` - the name to register the pool process under. Defaults
|
||||
to `Livebook.Runtime.NodePool`
|
||||
|
||||
* `:buffer_time` - the time that is awaited before a disconnected
|
||||
node's name is added to pool. Defaults to 1 minute
|
||||
|
||||
"""
|
||||
def start_link(opts) do
|
||||
name = opts[:name] || __MODULE__
|
||||
buffer_time = opts[:buffer_time] || @default_time
|
||||
|
||||
GenServer.start_link(
|
||||
__MODULE__,
|
||||
%{buffer_time: buffer_time},
|
||||
name: name
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a node name.
|
||||
|
||||
Generates a new name if pool is empty, or takes one from pool.
|
||||
"""
|
||||
def get_name(server \\ __MODULE__) do
|
||||
GenServer.call(server, :get_name, :infinity)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns port for the given name.
|
||||
"""
|
||||
def get_port(server \\ __MODULE__, name) do
|
||||
GenServer.call(server, {:get_port, name}, :infinity)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates a port for a name.
|
||||
"""
|
||||
def update_name(server \\ __MODULE__, name, port) do
|
||||
GenServer.call(server, {:update_name, name, port}, :infinity)
|
||||
end
|
||||
|
||||
# Server side code
|
||||
|
||||
@impl GenServer
|
||||
def init(opts) do
|
||||
:net_kernel.monitor_nodes(true, node_type: :all)
|
||||
[name, host] = node() |> Atom.to_string() |> :binary.split("@")
|
||||
|
||||
state = %{
|
||||
buffer_time: opts.buffer_time,
|
||||
active_names: %{},
|
||||
free_names: [],
|
||||
prefix: name,
|
||||
host: host
|
||||
}
|
||||
|
||||
{:ok, state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_call(:get_name, _, state) do
|
||||
{name, state} = server_get_name(state)
|
||||
{:reply, name, put_in(state.active_names[name], 0)}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_call({:get_port, name}, _, state) do
|
||||
{:reply, Map.get(state.active_names, name, 0), state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_call({:update_name, name, port}, _, state) do
|
||||
{:reply, :ok, server_update_name(name, port, state)}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_info({:nodedown, node, _info}, state) do
|
||||
case state.buffer_time do
|
||||
0 -> send(self(), {:release_node, node})
|
||||
t -> Process.send_after(self(), {:release_node, node}, t)
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_info({:nodeup, _node, _info}, state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_info({:release_node, node}, state) do
|
||||
{:noreply, server_release_name(Atom.to_string(node), state)}
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp server_get_name(state) do
|
||||
case state.free_names do
|
||||
[] -> {server_generate_name(state), state}
|
||||
[name | free_names] -> {name, %{state | free_names: free_names}}
|
||||
end
|
||||
end
|
||||
|
||||
defp server_update_name(name, port, state) do
|
||||
case state.active_names do
|
||||
%{^name => _} -> put_in(state.active_names[name], port)
|
||||
%{} -> state
|
||||
end
|
||||
end
|
||||
|
||||
defp server_generate_name(%{prefix: prefix, host: host}) do
|
||||
"#{prefix}--#{Livebook.Utils.random_short_id()}@#{host}"
|
||||
end
|
||||
|
||||
defp server_release_name(name, state) do
|
||||
{port, state} = pop_in(state.active_names[name])
|
||||
|
||||
if port do
|
||||
%{state | free_names: [name | state.free_names]}
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
end
|
|
@ -39,12 +39,9 @@ defmodule Livebook.Runtime.ElixirStandalone do
|
|||
"""
|
||||
@spec connect(t()) :: {:ok, t()} | {:error, String.t()}
|
||||
def connect(runtime) do
|
||||
parent_node = node()
|
||||
child_node = child_node_name(parent_node)
|
||||
child_node = Livebook.EPMD.random_child_node()
|
||||
|
||||
Utils.temporarily_register(self(), child_node, fn ->
|
||||
argv = [parent_node]
|
||||
|
||||
init_opts = [
|
||||
runtime_server_opts: [
|
||||
extra_smart_cell_definitions: Livebook.Runtime.Definitions.smart_cell_definitions()
|
||||
|
@ -52,7 +49,7 @@ defmodule Livebook.Runtime.ElixirStandalone do
|
|||
]
|
||||
|
||||
with {:ok, elixir_path} <- find_elixir_executable(),
|
||||
port = start_elixir_node(elixir_path, child_node, child_node_eval_string(), argv),
|
||||
port = start_elixir_node(elixir_path, child_node),
|
||||
{:ok, server_pid} <- parent_init_sequence(child_node, port, init_opts: init_opts) do
|
||||
runtime = %{runtime | node: child_node, server_pid: server_pid}
|
||||
{:ok, runtime}
|
||||
|
@ -62,7 +59,7 @@ defmodule Livebook.Runtime.ElixirStandalone do
|
|||
end)
|
||||
end
|
||||
|
||||
defp start_elixir_node(elixir_path, node_name, eval, argv) do
|
||||
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,
|
||||
|
@ -71,7 +68,7 @@ defmodule Livebook.Runtime.ElixirStandalone do
|
|||
# to the terminal
|
||||
:nouse_stdio,
|
||||
:hide,
|
||||
args: elixir_flags(node_name) ++ ["--eval", eval, "--" | Enum.map(argv, &to_string/1)]
|
||||
args: elixir_flags(node_name)
|
||||
])
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,108 +0,0 @@
|
|||
defmodule Livebook.Runtime.NodePool do
|
||||
use GenServer
|
||||
|
||||
# A pool with generated node names.
|
||||
#
|
||||
# The names are randomly generated, however to avoid atom exhaustion
|
||||
# unused names return back to the pool and can be reused later.
|
||||
|
||||
@default_time 60_000
|
||||
|
||||
# Client interface
|
||||
|
||||
@doc """
|
||||
Starts the GenServer from a Supervision tree
|
||||
|
||||
## Options
|
||||
|
||||
* `:name` - the name to register the pool process under. Defaults
|
||||
to `Livebook.Runtime.NodePool`
|
||||
|
||||
* `:buffer_time` - the time that is awaited before a disconnected
|
||||
node's name is added to pool. Defaults to 1 minute
|
||||
|
||||
"""
|
||||
def start_link(opts) do
|
||||
name = opts[:name] || __MODULE__
|
||||
buffer_time = opts[:buffer_time] || @default_time
|
||||
|
||||
GenServer.start_link(
|
||||
__MODULE__,
|
||||
%{buffer_time: buffer_time},
|
||||
name: name
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns a node name.
|
||||
|
||||
Generates a new name if pool is empty, or takes one from pool.
|
||||
"""
|
||||
def get_name(server \\ __MODULE__, basename) do
|
||||
GenServer.call(server, {:get_name, basename})
|
||||
end
|
||||
|
||||
# Server side code
|
||||
|
||||
@impl GenServer
|
||||
def init(opts) do
|
||||
:net_kernel.monitor_nodes(true, node_type: :all)
|
||||
{:ok, %{buffer_time: opts.buffer_time, generated_names: MapSet.new(), free_names: []}}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_call({:get_name, basename}, _, state) do
|
||||
{name, new_state} = name(state, basename)
|
||||
{:reply, name, new_state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_info({:nodedown, node, _info}, state) do
|
||||
case state.buffer_time do
|
||||
0 -> send(self(), {:add_node, node})
|
||||
t -> Process.send_after(self(), {:add_node, node}, t)
|
||||
end
|
||||
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_info({:nodeup, _node, _info}, state) do
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
@impl GenServer
|
||||
def handle_info({:add_node, node}, state) do
|
||||
{:noreply, add_node(state, node)}
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp name(state, basename) do
|
||||
if Enum.empty?(state.free_names) do
|
||||
generate_name(state, basename)
|
||||
else
|
||||
get_existing_name(state)
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_name(state, basename) do
|
||||
new_name = :"#{Livebook.Utils.random_short_id()}-#{basename}"
|
||||
generated_names = MapSet.put(state.generated_names, new_name)
|
||||
{new_name, %{state | generated_names: generated_names}}
|
||||
end
|
||||
|
||||
defp get_existing_name(state) do
|
||||
[name | free_names] = state.free_names
|
||||
{name, %{state | free_names: free_names}}
|
||||
end
|
||||
|
||||
defp add_node(state, node) do
|
||||
if MapSet.member?(state.generated_names, node) do
|
||||
free_names = [node | state.free_names]
|
||||
%{state | free_names: free_names}
|
||||
else
|
||||
state
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,17 +1,8 @@
|
|||
defmodule Livebook.Runtime.StandaloneInit do
|
||||
# TODO: Move logic inside ElixirStandalone module.
|
||||
# Generic functionality related to starting and setting up
|
||||
# a new Elixir system process. It's used by ElixirStandalone.
|
||||
|
||||
alias Livebook.Runtime.NodePool
|
||||
|
||||
@doc """
|
||||
Returns a random name for a dynamically spawned node.
|
||||
"""
|
||||
@spec child_node_name(atom()) :: atom()
|
||||
def child_node_name(parent) do
|
||||
NodePool.get_name(parent)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Tries locating Elixir executable in PATH.
|
||||
"""
|
||||
|
@ -23,30 +14,6 @@ defmodule Livebook.Runtime.StandaloneInit do
|
|||
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.longname(), do: "--name", else: "--sname"),
|
||||
to_string(node_name),
|
||||
"--erl",
|
||||
# Minimize schedulers busy wait threshold,
|
||||
# so that they go to sleep immediately after evaluation.
|
||||
# Increase the default stack for dirty io threads (cuda requires it).
|
||||
# Enable ANSI escape codes as we handle them with HTML.
|
||||
# Disable stdin, so that the system process never tries to read
|
||||
# any input from the terminal.
|
||||
"+sbwt none +sbwtdcpu none +sbwtdio none +sssdio 128 -elixir ansi_enabled true -noinput",
|
||||
# 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())
|
||||
]
|
||||
end
|
||||
|
||||
# ---
|
||||
#
|
||||
# Once the new node is spawned we need to establish a connection,
|
||||
|
@ -88,9 +55,9 @@ defmodule Livebook.Runtime.StandaloneInit do
|
|||
|
||||
loop = fn loop ->
|
||||
receive do
|
||||
{:node_started, init_ref, ^child_node, primary_pid} ->
|
||||
{: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, opts[:init_opts] || [])
|
||||
|
||||
send(primary_pid, {:node_initialized, init_ref})
|
||||
|
@ -117,13 +84,18 @@ defmodule Livebook.Runtime.StandaloneInit do
|
|||
# so the string cannot have constructs newlines nor strings. That's why we pass
|
||||
# the parent node name as ARGV and write the code avoiding newlines.
|
||||
#
|
||||
# This boot script must be kept in sync with Livebook.EPMD.
|
||||
#
|
||||
# Also note that we explicitly halt, just in case `System.no_halt(true)` is
|
||||
# called within the runtime.
|
||||
@child_node_eval_string """
|
||||
[parent_node] = System.argv();\
|
||||
{:ok, [[mode, node]]} = :init.get_argument(:livebook_current);\
|
||||
{:ok, _} = :net_kernel.start(List.to_atom(node), %{name_domain: List.to_atom(mode)});\
|
||||
{:ok, [[parent_node, _port]]} = :init.get_argument(:livebook_parent);\
|
||||
dist_port = :persistent_term.get(:livebook_dist_port, 0);\
|
||||
init_ref = make_ref();\
|
||||
parent_process = {node(), String.to_atom(parent_node)};\
|
||||
send(parent_process, {:node_started, init_ref, node(), self()});\
|
||||
parent_process = {node(), List.to_atom(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;\
|
||||
|
@ -138,12 +110,42 @@ defmodule Livebook.Runtime.StandaloneInit do
|
|||
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. The executed code expects
|
||||
the parent_node on ARGV. The process on the parent node is assumed
|
||||
to have the same name as the child node.
|
||||
A list of common flags used for spawned Elixir runtimes.
|
||||
"""
|
||||
def child_node_eval_string(), do: @child_node_eval_string
|
||||
@spec elixir_flags(node()) :: list()
|
||||
def elixir_flags(node_name) do
|
||||
parent_name = node()
|
||||
parent_port = Livebook.EPMD.dist_port()
|
||||
|
||||
mode = if Livebook.Config.longname(), do: :longnames, else: :shortnames
|
||||
|
||||
epmdless_flags =
|
||||
if parent_port != 0 do
|
||||
"-epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0 "
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
[
|
||||
"--erl",
|
||||
# Minimize schedulers busy wait threshold,
|
||||
# so that they go to sleep immediately after evaluation.
|
||||
# Increase the default stack for dirty io threads (cuda requires it).
|
||||
# Enable ANSI escape codes as we handle them with HTML.
|
||||
# Disable stdin, so that the system process never tries to read terminal input.
|
||||
"+sbwt none +sbwtdcpu none +sbwtdio none +sssdio 128 -elixir ansi_enabled true -noinput " <>
|
||||
epmdless_flags <>
|
||||
"-livebook_parent #{parent_name} #{parent_port} -livebook_current #{mode} #{node_name}",
|
||||
# Add the location of Livebook.EPMD
|
||||
"-pa",
|
||||
Application.app_dir(:livebook, "priv/epmd"),
|
||||
# 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",
|
||||
@child_node_eval_string
|
||||
]
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,13 +2,22 @@ if exist "!USERPROFILE!\.livebookdesktop.bat" (
|
|||
call "!USERPROFILE!\.livebookdesktop.bat"
|
||||
)
|
||||
|
||||
if not defined LIVEBOOK_EPMDLESS set LIVEBOOK_EPMDLESS=true
|
||||
if "!LIVEBOOK_EPMDLESS!"=="1" goto epmdless
|
||||
if "!LIVEBOOK_EPMDLESS!"=="true" goto epmdless
|
||||
goto continue
|
||||
|
||||
:epmdless
|
||||
set ELIXIR_ERL_OPTIONS=!ELIXIR_ERL_OPTIONS! -epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0
|
||||
|
||||
:continue
|
||||
for /f "skip=1" %%X in ('wmic os get localdatetime') do if not defined TIMESTAMP set TIMESTAMP=%%X
|
||||
|
||||
if defined LIVEBOOK_DISTRIBUTION set RELEASE_DISTRIBUTION="!LIVEBOOK_DISTRIBUTION!"
|
||||
if not defined RELEASE_DISTRIBUTION set RELEASE_DISTRIBUTION="sname"
|
||||
if defined LIVEBOOK_DISTRIBUTION set RELEASE_DISTRIBUTION=!LIVEBOOK_DISTRIBUTION!
|
||||
if not defined RELEASE_DISTRIBUTION set RELEASE_DISTRIBUTION=sname
|
||||
|
||||
if defined LIVEBOOK_NODE set RELEASE_NODE="!LIVEBOOK_NODE!"
|
||||
if not defined RELEASE_NODE set RELEASE_NODE="livebook-app-!TIMESTAMP:~8,6!-!RANDOM!"
|
||||
if defined LIVEBOOK_NODE set RELEASE_NODE=!LIVEBOOK_NODE!
|
||||
if not defined RELEASE_NODE set RELEASE_NODE=livebook-app-!TIMESTAMP:~8,6!-!RANDOM!
|
||||
|
||||
set RELEASE_MODE=interactive
|
||||
set MIX_ARCHIVES=!RELEASE_ROOT!\vendor\archives
|
||||
|
|
|
@ -2,22 +2,21 @@ if [ -f "$HOME/.livebookdesktop.sh" ]; then
|
|||
. "$HOME/.livebookdesktop.sh"
|
||||
fi
|
||||
|
||||
hostname=`hostname`
|
||||
if [[ "$hostname" =~ " " ]]; then
|
||||
echo "[error] system hostname ($hostname) cannot contain whitespaces"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
export RELEASE_DISTRIBUTION=${LIVEBOOK_DISTRIBUTION:-${RELEASE_DISTRIBUTION:-"sname"}}
|
||||
export RELEASE_NODE=${LIVEBOOK_NODE:-${RELEASE_NODE:-"livebook-app-$(cat /dev/urandom | env LC_ALL=C tr -dc 'a-zA-Z0-9' | fold -w 8 | head -n 1)"}}
|
||||
export RELEASE_MODE=interactive
|
||||
export MIX_ARCHIVES="${RELEASE_ROOT}/vendor/archives"
|
||||
export MIX_REBAR3="${RELEASE_ROOT}/vendor/rebar3"
|
||||
export LIVEBOOK_EPMDLESS=${LIVEBOOK_EPMDLESS:-true}
|
||||
export LIVEBOOK_SHUTDOWN_ENABLED=${LIVEBOOK_SHUTDOWN_ENABLED:-true}
|
||||
export LIVEBOOK_DESKTOP=true
|
||||
[ -z "$LIVEBOOK_PORT" ] && export LIVEBOOK_PORT=0
|
||||
export PATH="$RELEASE_ROOT/vendor/otp/erts-<%= @release.erts_version%>/bin:$RELEASE_ROOT/vendor/otp/bin:$RELEASE_ROOT/vendor/elixir/bin:$PATH"
|
||||
|
||||
if [[ "$LIVEBOOK_EPMDLESS" == "true" || "$LIVEBOOK_EPMDLESS" == "1" ]]; then
|
||||
export ELIXIR_ERL_OPTIONS="${ELIXIR_ERL_OPTIONS} -epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0"
|
||||
fi
|
||||
|
||||
cookie_path="${RELEASE_ROOT}/releases/COOKIE"
|
||||
if [ ! -f $cookie_path ]; then
|
||||
RELEASE_COOKIE=$(cat /dev/urandom | env LC_ALL=C tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1)
|
||||
|
|
|
@ -2,6 +2,14 @@ if exist "!RELEASE_ROOT!\user\env.bat" (
|
|||
call "!RELEASE_ROOT!\user\env.bat"
|
||||
)
|
||||
|
||||
if "!LIVEBOOK_EPMDLESS!"=="1" goto epmdless
|
||||
if "!LIVEBOOK_EPMDLESS!"=="true" goto epmdless
|
||||
goto continue
|
||||
|
||||
:epmdless
|
||||
set ELIXIR_ERL_OPTIONS=!ELIXIR_ERL_OPTIONS! -epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0
|
||||
|
||||
:continue
|
||||
set RELEASE_MODE=interactive
|
||||
if defined LIVEBOOK_NODE set RELEASE_NODE="!LIVEBOOK_NODE!"
|
||||
if not defined RELEASE_NODE set RELEASE_NODE=livebook_server
|
||||
|
|
|
@ -19,6 +19,10 @@ export RELEASE_MODE=interactive
|
|||
export RELEASE_NODE=${LIVEBOOK_NODE:-${RELEASE_NODE:-${NODE_DEFAULT}}}
|
||||
export RELEASE_DISTRIBUTION=${LIVEBOOK_DISTRIBUTION:-${RELEASE_DISTRIBUTION:-${DISTRIBUTION_DEFAULT}}}
|
||||
|
||||
if [[ "$LIVEBOOK_EPMDLESS" == "true" || "$LIVEBOOK_EPMDLESS" == "1" ]]; then
|
||||
export ELIXIR_ERL_OPTIONS="${ELIXIR_ERL_OPTIONS} -epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0"
|
||||
fi
|
||||
|
||||
if [ ! -z "${LIVEBOOK_COOKIE}" ]; then export RELEASE_COOKIE=${LIVEBOOK_COOKIE}; fi
|
||||
cookie_path="${RELEASE_ROOT}/releases/COOKIE"
|
||||
if [ ! -f $cookie_path ] && [ -z "$RELEASE_COOKIE" ]; then
|
||||
|
|
96
test/livebook/epmd/node_pool_test.exs
Normal file
96
test/livebook/epmd/node_pool_test.exs
Normal file
|
@ -0,0 +1,96 @@
|
|||
defmodule Livebook.EPMD.NodePoolTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Livebook.EPMD.NodePool
|
||||
|
||||
# Note we do not spawn actual nodes as it can be time
|
||||
# intensive (on low spec machines) and is generally
|
||||
# complicated.
|
||||
|
||||
describe "start_link" do
|
||||
test "correctly starts a registered GenServer", config do
|
||||
start_supervised!({NodePool, name: config.test})
|
||||
|
||||
# Verify Process is running
|
||||
assert Process.whereis(config.test)
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_name/2" do
|
||||
test "creates a new node name if pool is empty", config do
|
||||
start_supervised!({NodePool, name: config.test})
|
||||
|
||||
result = NodePool.get_name(config.test)
|
||||
assert is_binary(result)
|
||||
[name, host] = node() |> Atom.to_string() |> :binary.split("@")
|
||||
assert String.starts_with?(result, name)
|
||||
assert String.ends_with?(result, "@" <> host)
|
||||
assert result != Atom.to_string(node())
|
||||
end
|
||||
|
||||
test "returns an existing name if pool is not empty", config do
|
||||
start_supervised!({NodePool, name: config.test, buffer_time: 0})
|
||||
|
||||
name = NodePool.get_name(config.test)
|
||||
nodedown(config.test, String.to_atom(name))
|
||||
|
||||
assert NodePool.get_name(config.test) == name
|
||||
end
|
||||
|
||||
test "removes an existing name when used", config do
|
||||
start_supervised!({NodePool, name: config.test, buffer_time: 0})
|
||||
|
||||
name = NodePool.get_name(config.test)
|
||||
nodedown(config.test, String.to_atom(name))
|
||||
|
||||
name = NodePool.get_name(config.test)
|
||||
assert NodePool.get_name(config.test) != name
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_name/get_port" do
|
||||
test "updates name info and gets port for name", config do
|
||||
start_supervised!({NodePool, name: config.test})
|
||||
|
||||
result = NodePool.get_name(config.test)
|
||||
assert NodePool.get_port(config.test, result) == 0
|
||||
:ok = NodePool.update_name(config.test, result, 12345)
|
||||
assert NodePool.get_port(config.test, result) == 12345
|
||||
end
|
||||
|
||||
test "erases port info on node down", config do
|
||||
start_supervised!({NodePool, name: config.test, buffer_time: 0})
|
||||
|
||||
result = NodePool.get_name(config.test)
|
||||
:ok = NodePool.update_name(config.test, result, 12345)
|
||||
assert NodePool.get_port(config.test, result) == 12345
|
||||
nodedown(config.test, String.to_atom(result))
|
||||
assert NodePool.get_port(config.test, result) == 0
|
||||
end
|
||||
|
||||
test "returns no port for unknown names", config do
|
||||
start_supervised!({NodePool, name: config.test})
|
||||
assert NodePool.get_port(config.test, "never-a-name") == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "on nodedown" do
|
||||
test "does not add node name to pool if not in generated_names", config do
|
||||
start_supervised!({NodePool, name: config.test, buffer_time: 0})
|
||||
nodedown(config.test, :some_foo)
|
||||
assert NodePool.get_name(config.test) != :some_foo
|
||||
end
|
||||
end
|
||||
|
||||
# Emulate node down and make sure it is processed
|
||||
defp nodedown(process, node) when is_atom(node) do
|
||||
send(process, {:nodedown, node, {}})
|
||||
|
||||
# Make sure the send was processed
|
||||
_ = :sys.get_status(process)
|
||||
# Make sure the send after message processed
|
||||
_ = :sys.get_status(process)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
18
test/livebook/epmd_test.exs
Normal file
18
test/livebook/epmd_test.exs
Normal file
|
@ -0,0 +1,18 @@
|
|||
defmodule Livebook.EPMDTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
describe "with epmd" do
|
||||
@describetag :with_epmd
|
||||
test "has a random dist port" do
|
||||
assert Livebook.EPMD.dist_port() == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "without epmd" do
|
||||
@describetag :without_epmd
|
||||
|
||||
test "has a custom dist port" do
|
||||
assert Livebook.EPMD.dist_port() != 0
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,8 +1,8 @@
|
|||
defmodule Livebook.RemoteIntellisenseTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Livebook.Intellisense
|
||||
|
||||
@moduletag :with_epmd
|
||||
@tmp_dir "tmp/test/remote_intellisense"
|
||||
|
||||
# Returns intellisense context resulting from evaluating
|
||||
|
|
|
@ -1,67 +0,0 @@
|
|||
defmodule Livebook.Runtime.NodePoolTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Livebook.Runtime.NodePool
|
||||
|
||||
# Note we do not spawn actual nodes as it can be time
|
||||
# intensive (on low spec machines) and is generally
|
||||
# complicated.
|
||||
|
||||
describe "start_link" do
|
||||
test "correctly starts a registered GenServer", config do
|
||||
start_supervised!({NodePool, name: config.test})
|
||||
|
||||
# Verify Process is running
|
||||
assert Process.whereis(config.test)
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_name/2" do
|
||||
test "creates a new node name if pool is empty", config do
|
||||
start_supervised!({NodePool, name: config.test})
|
||||
|
||||
result = NodePool.get_name(config.test, node())
|
||||
assert is_atom(result)
|
||||
assert result |> Atom.to_string() |> String.ends_with?(Atom.to_string(node()))
|
||||
end
|
||||
|
||||
test "returns an existing name if pool is not empty", config do
|
||||
start_supervised!({NodePool, name: config.test, buffer_time: 0})
|
||||
|
||||
name = NodePool.get_name(config.test, node())
|
||||
nodedown(config.test, name)
|
||||
|
||||
assert NodePool.get_name(config.test, node()) == name
|
||||
end
|
||||
|
||||
test "removes an existing name when used", config do
|
||||
start_supervised!({NodePool, name: config.test, buffer_time: 0})
|
||||
|
||||
name = NodePool.get_name(config.test, node())
|
||||
nodedown(config.test, name)
|
||||
|
||||
name = NodePool.get_name(config.test, node())
|
||||
assert NodePool.get_name(config.test, node()) != name
|
||||
end
|
||||
end
|
||||
|
||||
describe "on nodedown" do
|
||||
test "does not add node name to pool if not in generated_names", config do
|
||||
start_supervised!({NodePool, name: config.test, buffer_time: 0})
|
||||
nodedown(config.test, :some_foo)
|
||||
assert NodePool.get_name(config.test, node()) != :some_foo
|
||||
end
|
||||
end
|
||||
|
||||
# Emulate node down and make sure it is processed
|
||||
defp nodedown(process, node) do
|
||||
send(process, {:nodedown, node, {}})
|
||||
|
||||
# Make sure the send was processed
|
||||
_ = :sys.get_status(process)
|
||||
# Make sure the send after message processed
|
||||
_ = :sys.get_status(process)
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
|
@ -57,21 +57,29 @@ windows? = match?({:win32, _}, :os.type())
|
|||
|
||||
erl_docs_exclude =
|
||||
if match?({:error, _}, Code.fetch_docs(:gen_server)) do
|
||||
[erl_docs: true]
|
||||
[:erl_docs]
|
||||
else
|
||||
[]
|
||||
end
|
||||
|
||||
windows_exclude = if windows?, do: [unix: true], else: []
|
||||
windows_exclude = if windows?, do: [:unix], else: []
|
||||
|
||||
teams_exclude =
|
||||
if not Livebook.TeamsServer.available?() do
|
||||
[teams_integration: true]
|
||||
else
|
||||
if Livebook.TeamsServer.available?() do
|
||||
[]
|
||||
else
|
||||
[:teams_integration]
|
||||
end
|
||||
|
||||
# ELIXIR_ERL_OPTIONS="-epmd_module Elixir.Livebook.EPMD -start_epmd false -erl_epmd_port 0" LIVEBOOK_EPMDLESS=true mix test
|
||||
epmd_exclude =
|
||||
if Application.fetch_env!(:livebook, :epmdless) do
|
||||
[:with_epmd, :teams_integration]
|
||||
else
|
||||
[:without_epmd]
|
||||
end
|
||||
|
||||
ExUnit.start(
|
||||
assert_receive_timeout: if(windows?, do: 2_500, else: 1_500),
|
||||
exclude: erl_docs_exclude ++ windows_exclude ++ teams_exclude
|
||||
exclude: erl_docs_exclude ++ windows_exclude ++ teams_exclude ++ epmd_exclude
|
||||
)
|
||||
|
|
Loading…
Reference in a new issue