defmodule BEAMNotify do
use GenServer
require Logger
@moduledoc """
Send a message to the BEAM from a shell script
"""
@typedoc """
Callback for dispatching notifications
BEAMNotify calls the dispatcher function whenever a message comes in. The
first parameter is the list of arguments passed to `$BEAM_NOTIFY`. The
second argument is a map containing environment variables. Whether or
not the map is populated depends on the options to `start_link/1`.
"""
@type dispatcher() :: ([String.t()], %{String.t() => String.t()} -> :ok)
@typedoc """
BEAMNotify takes the following options
* `:name` - a unique name for this notifier. This is required if you expect
to run multiple BEAMNotify GenServers at a time.
* `:dispatcher` - a function to call when a notification comes in
* `:path` - the path to use for the named socket. A path in the system
temporary directory is the default.
* `:mode` - the permissions to apply to the socket. Should be an octal number
eg: 0o660 for read/write owner/group, no access to everyone else
* `:report_env` - set to `true` to report environment variables in addition
to commandline argument. Defaults to `false`
* `:recbuf` - receive buffer size. If you're sending a particular large
amount of data and getting errors from `:erlang.binary_to_term(data)`, try
making this bigger. Defaults to 8192.
"""
@type options() :: [
name: binary() | atom(),
path: Path.t(),
mode: non_neg_integer(),
dispatcher: dispatcher(),
report_env: boolean(),
recbuf: non_neg_integer()
]
@doc """
Start the BEAMNotify message receiver
"""
@spec start_link(options()) :: GenServer.on_start()
def start_link(options) do
name = options |> name_from_options() |> gen_server_name()
GenServer.start_link(__MODULE__, options, name: name)
end
@doc """
Return the OS environment needed to call `$BEAM_NOTIFY`
This returns a map that can be passed directly to `System.cmd/3` via its
`:env` option.
This function can be passed different things based on what's convenient.
1. If you're setting up `child_spec`'s for a supervision tree and need the
environment to pass in another `child_spec`, call this with the same
options that you'd pass to `start_link/1`. This is a very common use.
2. If you called `start_link/1` manually and have the pid, call it with
the pid.
3. If you only have the name that was passed to `start_link/1`, then call
it with the name. The name alone is insufficient for returning the
`$BEAM_NOTIFY_OPTIONS` environment variable, so the `BEAMNotify`
GenServer must be running. If you're in a chicken-and-egg situation
where you're setting up a supervision tree, but it hasn't been started
yet, see option 1.
"""
@spec env(pid() | binary() | atom() | keyword()) :: Enumerable.t()
def env(options) when is_list(options) do
options_to_env(options)
end
def env(pid) when is_pid(pid) do
GenServer.call(pid, :env)
end
def env(name) when is_binary(name) or is_atom(name) do
GenServer.call(gen_server_name(name), :env)
end
@doc """
Return the path to `beam_notify`
"""
@spec bin_path() :: Path.t()
def bin_path() do
Application.app_dir(:beam_notify, ["priv", "beam_notify"])
end
@impl GenServer
def init(options) do
socket_path = socket_path(options)
dispatcher = Keyword.get(options, :dispatcher, &null_dispatcher/2)
recbuf = Keyword.get(options, :recbuf, 8192)
mode = Keyword.get(options, :mode)
# Blindly try to remove an old file just in case it exists from a previous run
_ = File.rm(socket_path)
_ = File.mkdir_p(Path.dirname(socket_path))
{:ok, socket} =
:gen_udp.open(0, [
:local,
:binary,
{:active, true},
{:ip, {:local, socket_path}},
{:recbuf, recbuf}
])
if mode, do: File.chmod!(socket_path, mode)
state = %{
socket_path: socket_path,
socket: socket,
options: options,
dispatcher: dispatcher
}
{:ok, state}
end
@impl GenServer
def handle_call(:env, _from, state) do
{:reply, options_to_env(state.options), state}
end
@impl GenServer
def handle_info({:udp, socket, _, 0, data}, %{socket: socket} = state) do
{args, env} = :erlang.binary_to_term(data)
state.dispatcher.(args, env)
{:noreply, state}
end
@impl GenServer
def terminate(_reason, state) do
# Try to clean up
_ = File.rm(state.socket_path)
end
defp gen_server_name(name) do
Module.concat(__MODULE__, name)
end
defp name_from_options(options) do
Keyword.get(options, :name, :unnamed)
end
defp null_dispatcher(args, env) do
Logger.warn("beam_notify called with no dispatcher: #{inspect(args)}, #{inspect(env)}")
end
defp options_to_env(options) do
bn_options = options_to_cmdline(options) |> Enum.map("e_string/1) |> Enum.join(" ")
%{"BEAM_NOTIFY" => bin_path(), "BEAM_NOTIFY_OPTIONS" => bn_options}
end
defp options_to_cmdline(options) do
["-p", socket_path(options)] ++ report_env_arg(options)
end
defp report_env_arg(options) do
if Keyword.get(options, :report_env) do
["-e"]
else
[]
end
end
defp quote_string(s) do
if String.contains?(s, " ") do
"\"" <> s <> "\""
else
s
end
end
defp socket_path(options) do
case Keyword.get(options, :path) do
nil ->
safe_name = name_from_options(options) |> :erlang.phash2() |> Integer.to_string()
name = "beam_notify-" <> safe_name
Path.join(System.tmp_dir!(), name)
path ->
path
end
end
end