Current section
Files
Jump to
Current section
Files
lib/lacca.ex
defmodule Lacca do
use GenServer
alias Lacca.Protocol.Encoder
require Logger
#
# API
#
@doc """
Starts a server which will run the executable located at `exec_path`
with the specified command line arguments. The returned handle, of the form
`{:ok, pid}`, can be used to interact w/ the program.
Note that `stdout` and `stderr` from the process are captured inside
`StringIO` buffers internally. This data will remain in-memory until
this server is either killed, or the buffers are flushed using the
respective API functions.
## Errors
This method will raise `ArgumentError` if the `resin` daemon cannot be
found on your system's executable `PATH`. The configuration key located
at `:resin, :daemon_path` can be used to force this process to run the
daemon from a non-standard location.
"""
def start(exec_path, args)
when is_binary(exec_path) and is_list(args) do
GenServer.start_link(__MODULE__, [path: exec_path, args: args])
end
@doc """
Attempts to terminate the process immediately. Caller should expect that
the process will not be gracefully terminated; similarly to calling SIGKILL
on a POSIX operating system.
"""
def kill(pid) do
GenServer.call(pid, :kill)
end
def alive?(pid) do
GenServer.call(pid, :is_alive)
end
@doc """
Returns `{:ok, binary}` which includes any data received from the
child's `stdout` file descriptor. _Note that the internal buffer is then
cleared, such that subsequent reads will not return this same data again._
"""
def read_stdout(pid) do
GenServer.call(pid, :read)
end
@doc """
Returns `{:ok, binary}` which includes any data received from the
child's `stderr` file descriptor. _Note that the internal buffer is then
cleared, such that subsequent reads will not return this same data again._
"""
def read_stderr(pid) do
GenServer.call(pid, :read_err)
end
@doc """
Returns `:ok` if the data has been sent to the underlying `resin` daemon.
Note that this function returns immediately after having sent the packet
to the daemon, no guarantees as to the delivery to the child process are
afforded. (i.e: the child may have closed its `stdin` prematurely, the child
may have exited in the interim, it may be deadlocked and not processing stdin,
etc.)
"""
def write_stdin(pid, data) when is_binary(data) do
GenServer.call(pid, {:write, data})
end
@doc """
Requests that the `resin` daemon send `SIGTERM` or equivalent to forcefully
terminate the running child process. This function returns immediately, and
the signal is sent asynchronously.
Use `await/1` if you wish to block on the child process actually terminating.
"""
def stop_child(_pid) do
{:error, :not_implemented}
end
@doc """
Waits for the child process to exit, and returns a result struct which
includes the status code (if applicable), and any remaining data from the
`stdout` or `stderr` buffers for this process.
When this function returns `pid` will have exited.
"""
def await(_pid) do
{:error, :not_implemented}
end
#
# Callbacks
#
def init(opts) do
Logger.debug "opening port"
{path, _} = Keyword.pop(opts, :path)
{args, _} = Keyword.pop(opts, :args, [])
# check we have a valid executable path
if is_nil(path) or !is_binary(path) do
raise ArgumentError, "expected opts[:path] to be a string"
end
if is_nil(args) or !is_list(args) do
raise ArgumentError, "expected opts[:args] to be a list of arguments"
end
resin_daemon = Application.app_dir(:lacca, "priv/resin/resind")
unless File.exists? resin_daemon do
raise RuntimeError, "Could not locate `resind` daemon at: #{resin_daemon}"
end
# open the port to `resin` daemon
port = Port.open({:spawn, resin_daemon}, [:binary, :exit_status, {:packet, 2}])
p_ref = Port.monitor(port)
# TODO: negotiate protocol verison
Encoder.write_start_process(path, args)
|> Enum.map(fn packet ->
Logger.debug "sending packet: #{inspect packet}"
Port.command(port, packet)
end)
# open IO streams
{:ok, p_child_err} = StringIO.open("")
{:ok, p_child_out} = StringIO.open("")
{:ok, p_child_in} = StringIO.open("")
{:ok, %{
port: port,
monitor_ref: p_ref,
child_err: p_child_err,
child_out: p_child_out,
child_in: p_child_in,
is_alive: true,
exit_status: nil,
}}
end
# check if the port is still open
def handle_call(:is_alive, _from, state) do
{:reply, state.is_alive, state}
end
# reads the currently buffered `stdout` of the child process
def handle_call(:read, _from, state = %{port: port}) when not is_nil(port) do
buf = StringIO.flush(state.child_out)
{:reply, {:ok, buf}, state}
end
# reads the currently buffered `stderr` of the child process
def handle_call(:read_err, _from, state = %{port: port}) when not is_nil(port) do
buf = StringIO.flush(state.child_err)
{:reply, {:ok, buf}, state}
end
def handle_call({:write, data}, _from, state = %{port: port}) when not is_nil(port) do
Encoder.write_data_packet(data)
|> Enum.map(fn packet ->
Logger.debug "sending packet: #{inspect packet}"
Port.command(port, packet)
end)
{:reply, :ok, state}
end
# tell inferior process it's time *to die.*
def handle_call(:kill, _from, state = %{port: port}) when not is_nil(port) do
Encoder.write_exit_packet()
|> Enum.map(fn packet ->
Logger.debug "sending packet: #{inspect packet}"
Port.command(port, packet)
end)
{:reply, :ok, state}
end
# `resin` daemon exited, RIP us...
def handle_info({port, {:exit_status, status}}, state) when is_port(port) do
Logger.debug "resin daemon hung-up w/ code: #{status}"
{:noreply, state}
end
# handle packet received from `resin` daemon ...
def handle_info({port, {:data, data}}, state) when is_port(port) do
decoded_data = CBOR.decode(data)
case decoded_data do
{:ok, %{"DataOut" => %{"ty" => "Stdout", "buf" => buf }}, _} ->
IO.write(state.child_out, buf)
{:noreply, state}
{:ok, %{"DataOut" => %{"ty" => "Stderr", "buf" => buf}}, _} ->
IO.write(state.child_err, buf)
{:noreply, state}
{:ok, %{"ExitStatus" => %{"code" => code}}, _} ->
Port.close(state.port)
{:noreply, %{state | exit_status: code}}
packet ->
Logger.warn "unhandled data packet: #{inspect packet}"
end
{:noreply, state}
end
def handle_info({:DOWN, _ref, :port, port, reason}, state) when is_port(port) do
Logger.debug "port going down: #{inspect port}"
Logger.debug "got message of port leaving: #{inspect reason}"
Logger.debug "port info: #{inspect Port.info(port)}"
{:noreply, %{state | is_alive: false}}
end
# if the port is alive: at least *try* to shut `resin` down cleanly.
def terminate(_reason, state) do
unless is_nil(state.port) do
Encoder.write_exit_packet()
|> Enum.map(fn packet ->
Logger.debug "sending packet: #{inspect packet}"
Port.command(state.port, packet)
end)
{:ok, :port_shutdown}
end
end
end