Current section
Files
Jump to
Current section
Files
lib/net_runner.ex
defmodule NetRunner do
@moduledoc """
Safe OS process execution for Elixir.
Combines NIF-based async I/O with a persistent shepherd binary to guarantee
zero zombie processes, even under BEAM SIGKILL.
## Quick start
# Simple command execution
{output, 0} = NetRunner.run(~w(echo hello))
# Streaming with input
NetRunner.stream!(~w(cat), input: "hello world")
|> Enum.to_list()
# => ["hello world"]
# Piping data through a command
NetRunner.stream!(~w(wc -c), input: "hello")
|> Enum.join()
# => " 5\\n"
"""
alias NetRunner.Process, as: Proc
alias NetRunner.Stream, as: NRStream
@doc """
Runs a command and collects all output.
Accepts either a command list `[executable | args]` or a `%NetRunner.Command{}` struct.
Returns `{output, exit_status}` where output is the concatenated stdout.
## Options
* `:stderr` - `:consume` (default, drained internally so the child never
blocks on a full stderr pipe) or `:disabled`. In `:consume` mode only the
most-recent `:stderr_tail_bytes` of stderr are retained (see
`NetRunner.Process.stderr_tail/1`); the rest is drained and dropped.
* `:stderr_tail_bytes` - cap (bytes) on the retained stderr tail. Default
`8192`. `0` retains nothing. The tail is raw bytes and may begin
mid-character, so treat it as diagnostic text, not valid UTF-8.
* `:input` - data to write to stdin (binary or enumerable)
* `:timeout` - maximum wall-clock time in milliseconds. Sends SIGTERM then SIGKILL
on timeout. Returns `{:error, :timeout}` instead of `{output, exit_status}`.
* `:max_output_size` - maximum bytes to collect from stdout. Kills the process
and returns `{:error, {:max_output_exceeded, partial_output}}` if exceeded.
## Examples
{output, 0} = NetRunner.run(~w(echo hello))
{"hello\\n", 0} = {output, 0}
{output, 0} = NetRunner.run(~w(cat), input: "from stdin")
{:error, :timeout} = NetRunner.run(~w(sleep 100), timeout: 100)
{:error, {:max_output_exceeded, _partial}} =
NetRunner.run(["sh", "-c", "yes"], max_output_size: 1000)
# With a Command struct:
cmd = NetRunner.Command.new("echo", ["hello"], timeout: 5_000)
{output, 0} = NetRunner.run(cmd)
"""
@spec run(NetRunner.Command.t() | [String.t()], keyword()) ::
{binary(), non_neg_integer()} | {:error, term()}
def run(command, opts \\ [])
def run(%NetRunner.Command{} = command, opts) do
{cmd, args, merged_opts} = NetRunner.Command.to_cmd_args_opts(command, opts)
run_impl(cmd, args, merged_opts)
end
def run([cmd | args], opts) do
run_impl(cmd, args, opts)
end
defp run_impl(cmd, args, opts) do
input = Keyword.get(opts, :input, nil)
timeout = Keyword.get(opts, :timeout, nil)
max_output_size = Keyword.get(opts, :max_output_size, nil)
process_opts = Keyword.drop(opts, [:input, :timeout, :max_output_size])
case Proc.start(cmd, args, process_opts) do
{:ok, pid} ->
run_with_pid(pid, input, timeout, max_output_size)
{:error, _reason} = error ->
error
end
end
defp run_with_pid(pid, input, timeout, max_output_size) do
task = Task.async(fn -> run_io(pid, input, max_output_size) end)
effective_timeout = timeout || :infinity
case Task.yield(task, effective_timeout) || Task.shutdown(task) do
{:ok, {output, exit_status}} when is_binary(output) and is_integer(exit_status) ->
{output, exit_status}
{:ok, {:error, _} = error} ->
kill_and_cleanup(pid)
error
nil ->
kill_and_cleanup(pid)
{:error, :timeout}
{:exit, reason} ->
kill_and_cleanup(pid)
{:error, {:task_crashed, reason}}
end
end
@doc """
Creates a stream for incremental I/O with the command.
Accepts either a command list `[executable | args]` or a `%NetRunner.Command{}` struct.
Returns a `Stream` that yields stdout binary chunks.
Raises on process start failure.
## Options
* `:input` - data to write to stdin (binary, list, or Stream)
* `:stderr` - `:consume` (default) or `:disabled`
## Examples
# Stream through a command
NetRunner.stream!(~w(sort))
|> Enum.to_list()
# With input
NetRunner.stream!(~w(tr a-z A-Z), input: "hello")
|> Enum.join()
# => "HELLO"
# With a Command struct:
cmd = NetRunner.Command.new("cat", [], input: "hello")
NetRunner.stream!(cmd) |> Enum.to_list()
"""
@spec stream!(NetRunner.Command.t() | [String.t()], keyword()) :: Enumerable.t()
def stream!(command, opts \\ [])
def stream!(%NetRunner.Command{} = command, opts) do
{cmd, args, merged_opts} = NetRunner.Command.to_cmd_args_opts(command, opts)
NRStream.stream!(cmd, args, merged_opts)
end
def stream!([cmd | args], opts) do
NRStream.stream!(cmd, args, opts)
end
@doc """
Like `stream!/2` but returns `{:ok, stream}` or `{:error, reason}`.
Accepts either a command list `[executable | args]` or a `%NetRunner.Command{}` struct.
"""
@spec stream(NetRunner.Command.t() | [String.t()], keyword()) ::
{:ok, Enumerable.t()} | {:error, term()}
def stream(command, opts \\ [])
def stream(%NetRunner.Command{} = command, opts) do
{cmd, args, merged_opts} = NetRunner.Command.to_cmd_args_opts(command, opts)
NRStream.stream(cmd, args, merged_opts)
end
def stream([cmd | args], opts) do
NRStream.stream(cmd, args, opts)
end
# --- Private ---
defp write_all_input(pid, input) when is_binary(input) do
Proc.write(pid, input)
Proc.close_stdin(pid)
end
defp write_all_input(pid, input) when is_list(input) do
Enum.each(input, &Proc.write(pid, &1))
Proc.close_stdin(pid)
end
defp read_all_with_limits(pid, max_output_size) do
read_all_loop(pid, max_output_size, 0, [])
end
defp read_all_loop(pid, max_size, collected, acc) do
case Proc.read(pid) do
{:ok, data} ->
new_collected = collected + byte_size(data)
if max_size && new_collected > max_size do
overshoot = new_collected - max_size
keep = byte_size(data) - overshoot
truncated = binary_part(data, 0, keep)
partial = [truncated | acc] |> Enum.reverse() |> IO.iodata_to_binary()
{:error, {:max_output_exceeded, partial}}
else
read_all_loop(pid, max_size, new_collected, [data | acc])
end
:eof ->
{:ok, acc |> Enum.reverse() |> IO.iodata_to_binary()}
{:error, _} ->
{:ok, acc |> Enum.reverse() |> IO.iodata_to_binary()}
end
end
defp run_io(pid, input, max_output_size) do
if input do
write_all_input(pid, input)
else
Proc.close_stdin(pid)
end
case read_all_with_limits(pid, max_output_size) do
{:ok, output} ->
{:ok, exit_status} = Proc.await_exit(pid)
{output, exit_status}
{:error, _} = error ->
error
end
end
defp kill_and_cleanup(pid) do
Proc.kill(pid, :sigterm)
case Proc.await_exit(pid, 5_000) do
{:ok, _} -> :ok
_ -> Proc.kill(pid, :sigkill)
end
catch
:exit, _ -> :ok
end
end