defmodule BEAMNotify do @moduledoc """ Send a message to the BEAM from a shell script """ use GenServer require Logger @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.warning("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_join(" ", "e_string/1) %{"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