Current section

Files

Jump to
safe_exec_env lib safe_exec_env.ex
Raw

lib/safe_exec_env.ex

defmodule SafeExecEnv do
@moduledoc """
It is often desirable to run natively compiled code (C, C++, Rust, ..) from a BEAM
application via a binding. The motivation may be better performance or simply to access
an external library that already exists. The problem is that if that native code crashes
then, unlike with native BEAM processest, he entire VM will crash, taking your
application along with it,
SafeExecEnv provides a safe(r) way to run natively compiled code from a BEAM application.
If the native code in question can be reasonably expected to never crash, then this
precaution is unecessary, but if native code that might crash is being run then this
library provides a layer of insulation between the code being executed and the virtual
machine the main application is being run on.
Using SafeExecEnv is easy; simply add it to a Supervisor:
defmodule MyApplication do
def start(_type, _args) do
children = [SafeExecEnv]
opts = [strategy: :one_for_one, name: MyApplication.Supervisor]
Supervisor.start_link(children, opts)
end
end
Then you may run functions safely by calling SafeExecEnv::exec with the function. Captured,
anonymous, and Module / Function / Arguments (MFA) style function passing is supported.
SafeExecEnv works by spawning a second BEAM VM to run the functions in. If that VM crashes
then the SafeExecEnv server will also crash. When supervised, this will cause the SafeExenv
to be restarted, and the external VM will be started again. Calls to SafeExecEnv may
fail during that time, and will need to be tried again once available.
"""
require Logger
use GenServer
use Private
@ets_table_name :safe_exec_env
@safe_exec_node_name "SafeExecEnv_"
@node_unreachable_timeout 1000
@doc """
Executes a function in the safe executable environment and returns the result.
The SafeExecEnv server is presumed to be started.
By default the global SafeExecEnv is used, but an execution can be scheduled
for a specific SafeExecEnv instance by providing the name used to start the
module. @see start_link/1
"""
@spec exec(fun :: function, env_name :: :global | String.t()) :: any | {:error, reason :: String.t()}
def exec(fun, env_name \\ :global) when is_function(fun) do
exec_remote(fun, env_name)
end
@doc """
Executes a function with the provided argument (in usual "MFA" form) in the safe
executable environment and returns the result. The SafeExecEnv server is presumed to be
started.
By default the global SafeExecEnv is used, but an execution can be scheduled
for a specific SafeExecEnv instance by providing the name used to start the
module. @see start_link/1
"""
@spec exec(module :: atom, fun :: atom, args :: list, env_name :: :global| String.t()) :: any | {:error, reason :: String.t()}
def exec(module, fun, args, env_name \\ :global) do
exec_remote(fn -> apply(module, fun, args) end, env_name)
end
@doc "Returns the name of the node being used as the safe exec environment"
@spec get() :: String.t()
def get(name \\ :global) do
process_name = generate_process_name(name)
case Process.whereis(process_name) do
nil -> {:error, :safe_exec_env_not_running}
_ ->
case node_name(name) do
nil -> GenServer.call(process_name, :start_node)
node -> node
end
end
end
@doc "Returns true if the node is running and reachable, otherwise false"
@spec is_alive?(name :: String.t() | atom) :: boolean
def is_alive?(name \\ :global) do
name
|> generate_node_name()
|> SafeExecEnv.ExternNode.fully_qualified_name()
|> Node.ping() == :pong
end
@doc """
Starts a Safe Exec Environment server. This server will launch the external node
used to run functions in and monitor it. When the Safe Exec Environment crashes
this server will as well. This provides a simple way to ensure a Safe Exec Environment
is available by supervising the SafeExecEnv server: if the external node crashes,
this local server will crash, the supervisor will restart it, and the external node
will be started again for use.
A SafeExecEnv server may be started with no arguments, creating a global node that
can be used without a name, or with a name allowing multiple Safe Exec Enviroments
to be created if desired.
"""
@spec start_link() :: {:ok, pid}
def start_link(), do: start_link(%{name: :global})
@spec start_link(args :: %{:name => String.t() | atom} | any) :: {:ok, pid}
def start_link(%{name: name}) do
process_name = generate_process_name(name)
GenServer.start_link(__MODULE__, name, name: process_name)
end
def start_link(_) do
GenServer.start_link(__MODULE__, [], name: __MODULE__)
end
@impl GenServer
def init(name) when is_bitstring(name) or is_atom(name) do
state = %{name: name}
start_node(state)
{:ok, state}
end
def init(_args) do
state = %{name: :global}
start_node(state)
{:ok, state}
end
@impl GenServer
def handle_call(:start_node, _, state) do
node = start_node(state)
{:reply, node, state}
end
private do
def start_ets() do
try do
@ets_table_name = :ets.new(@ets_table_name,
[
:named_table,
:public,
:set,
{:read_concurrency, true}
]
)
rescue
ArgumentError -> true
end
end
defp start_node(%{name: name}) do
check_distribution()
start_ets()
{:ok, node} =
name
|> generate_node_name()
|> SafeExecEnv.ExternNode.start(self())
:ets.insert(@ets_table_name, {{:safe_exec_node, name}, node})
node
end
end
defp check_distribution(), do: :erlang.get_cookie() |> has_cookie?()
defp has_cookie?(:nocookie) do
Logger.error(
"SafeExecEnv can not start as the BEAM was not started in distributed mode. Start the vm with the name or sname command line option."
)
Process.exit(self(), :kill)
false
end
defp has_cookie?(_), do: true
defp node_name(name) do
try do
case :ets.lookup(@ets_table_name, {:safe_exec_node, name}) do
[] -> {:error, :safe_exec_env_not_running}
[{_key, value}] -> value
end
rescue
_ -> {:error, :safe_exec_env_ets_fail}
end
end
defp generate_node_name(name) when is_atom(name) do
name
|> Atom.to_string()
|> generate_node_name()
end
defp generate_node_name(name) do
node()
|> Atom.to_string()
|> String.split("@")
|> (fn [x | _]->
if (name != :global) do
name <> "_" <> x
else
x
end
end).()
|> (fn x -> @safe_exec_node_name <> x end).()
|> String.to_charlist()
end
defp generate_process_name(:global), do: __MODULE__
defp generate_process_name(name) when is_atom(name) do
Atom.to_string(name)
|> generate_process_name()
end
defp generate_process_name(name) when is_bitstring(name) do
("SafeExecEnv_" <> name)
|> String.to_atom()
end
defp exec_remote(fun, name) do
respond_to = self()
remote_fun = fn ->
send(respond_to, :safe_exec_launched)
rv =
try do
fun.()
rescue
e -> {:error, e}
end
send(respond_to, rv)
end
try do
case node_name(name) do
{:error, _} = error -> error
node ->
Node.monitor(node, true)
Node.spawn(node, remote_fun)
rv = wait_for_spawn_success_message(node)
Node.monitor(node, false)
rv
end
rescue
e -> {:error, e}
end
end
defp wait_for_spawn_success_message(node) do
receive do
{:nodedown, ^node} -> {:error, :safe_exec_env_gone}
:safe_exec_launched -> wait_for_function_response(node)
after
@node_unreachable_timeout -> {:error, :safe_exec_env_unreachable}
end
end
defp wait_for_function_response(node) do
receive do
{:nodedown, ^node} -> {:error, :safe_exec_env_gone}
{:error, e} = error ->
Logger.debug("SafeExecEnv function execution failed on #{inspect node}: #{inspect e}")
error
rv -> rv
end
end
end