From 6d7f416f1848c26211653d987cc07147e259a8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Wed, 8 May 2024 10:05:01 +0200 Subject: [PATCH] Run Livebook Desktop without EPMD (#2591) --- .github/workflows/test.yml | 50 +++++--- .gitignore | 3 + README.md | 5 +- config/config.exs | 9 +- lib/livebook.ex | 4 + lib/livebook/application.ex | 41 +++++- lib/livebook/config.ex | 4 +- lib/livebook/epmd.ex | 111 ++++++++++++++++ lib/livebook/epmd/node_pool.ex | 142 +++++++++++++++++++++ lib/livebook/runtime/elixir_standalone.ex | 11 +- lib/livebook/runtime/node_pool.ex | 108 ---------------- lib/livebook/runtime/standalone_init.ex | 94 +++++++------- rel/app/env.bat.eex | 17 ++- rel/app/env.sh.eex | 11 +- rel/server/env.bat.eex | 8 ++ rel/server/env.sh.eex | 4 + test/livebook/epmd/node_pool_test.exs | 96 ++++++++++++++ test/livebook/epmd_test.exs | 18 +++ test/livebook/remote_intellisense_test.exs | 2 +- test/livebook/runtime/node_pool_test.exs | 67 ---------- test/test_helper.exs | 20 ++- 21 files changed, 550 insertions(+), 275 deletions(-) create mode 100644 lib/livebook/epmd.ex create mode 100644 lib/livebook/epmd/node_pool.ex delete mode 100644 lib/livebook/runtime/node_pool.ex create mode 100644 test/livebook/epmd/node_pool_test.exs create mode 100644 test/livebook/epmd_test.exs delete mode 100644 test/livebook/runtime/node_pool_test.exs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2ff00d910..bd1710b7c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/.gitignore b/.gitignore index 8bdd34dd8..64bb3abe0 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,6 @@ npm-debug.log # The built Escript /livebook + +# The priv directory with the EPMD file +/priv/epmd diff --git a/README.md b/README.md index 481f17e61..c9fc4f472 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/config/config.exs b/config/config.exs index 3c0944d92..bd644bf65 100644 --- a/config/config.exs +++ b/config/config.exs @@ -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 diff --git a/lib/livebook.ex b/lib/livebook.ex index c02036ad8..714865e54 100644 --- a/lib/livebook.ex +++ b/lib/livebook.ex @@ -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") || diff --git a/lib/livebook/application.ex b/lib/livebook/application.ex index ac0aa1024..8342cdb18 100644 --- a/lib/livebook/application.ex +++ b/lib/livebook/application.ex @@ -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 diff --git a/lib/livebook/config.ex b/lib/livebook/config.ex index 8df969822..aa27e547b 100644 --- a/lib/livebook/config.ex +++ b/lib/livebook/config.ex @@ -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 diff --git a/lib/livebook/epmd.ex b/lib/livebook/epmd.ex new file mode 100644 index 000000000..4af7f96ea --- /dev/null +++ b/lib/livebook/epmd.ex @@ -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 diff --git a/lib/livebook/epmd/node_pool.ex b/lib/livebook/epmd/node_pool.ex new file mode 100644 index 000000000..38536ca1a --- /dev/null +++ b/lib/livebook/epmd/node_pool.ex @@ -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 diff --git a/lib/livebook/runtime/elixir_standalone.ex b/lib/livebook/runtime/elixir_standalone.ex index 5d5b34e58..6537a6350 100644 --- a/lib/livebook/runtime/elixir_standalone.ex +++ b/lib/livebook/runtime/elixir_standalone.ex @@ -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 diff --git a/lib/livebook/runtime/node_pool.ex b/lib/livebook/runtime/node_pool.ex deleted file mode 100644 index 8296819d2..000000000 --- a/lib/livebook/runtime/node_pool.ex +++ /dev/null @@ -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 diff --git a/lib/livebook/runtime/standalone_init.ex b/lib/livebook/runtime/standalone_init.ex index a49a210e9..51bffdea6 100644 --- a/lib/livebook/runtime/standalone_init.ex +++ b/lib/livebook/runtime/standalone_init.ex @@ -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 diff --git a/rel/app/env.bat.eex b/rel/app/env.bat.eex index 5c5078304..28adc6ec7 100644 --- a/rel/app/env.bat.eex +++ b/rel/app/env.bat.eex @@ -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 diff --git a/rel/app/env.sh.eex b/rel/app/env.sh.eex index a442e2e83..7005409f7 100644 --- a/rel/app/env.sh.eex +++ b/rel/app/env.sh.eex @@ -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) diff --git a/rel/server/env.bat.eex b/rel/server/env.bat.eex index d17e576a9..89bc43f6b 100644 --- a/rel/server/env.bat.eex +++ b/rel/server/env.bat.eex @@ -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 diff --git a/rel/server/env.sh.eex b/rel/server/env.sh.eex index f03a3bb72..8043f4d07 100644 --- a/rel/server/env.sh.eex +++ b/rel/server/env.sh.eex @@ -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 diff --git a/test/livebook/epmd/node_pool_test.exs b/test/livebook/epmd/node_pool_test.exs new file mode 100644 index 000000000..361f7d907 --- /dev/null +++ b/test/livebook/epmd/node_pool_test.exs @@ -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 diff --git a/test/livebook/epmd_test.exs b/test/livebook/epmd_test.exs new file mode 100644 index 000000000..2508b49e3 --- /dev/null +++ b/test/livebook/epmd_test.exs @@ -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 diff --git a/test/livebook/remote_intellisense_test.exs b/test/livebook/remote_intellisense_test.exs index 9f7078a40..cf1bb3965 100644 --- a/test/livebook/remote_intellisense_test.exs +++ b/test/livebook/remote_intellisense_test.exs @@ -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 diff --git a/test/livebook/runtime/node_pool_test.exs b/test/livebook/runtime/node_pool_test.exs deleted file mode 100644 index 8c8cd1be0..000000000 --- a/test/livebook/runtime/node_pool_test.exs +++ /dev/null @@ -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 diff --git a/test/test_helper.exs b/test/test_helper.exs index 0e5b0130c..0278527ed 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -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 )