Packages

Leak-free external process execution: process-group kill on timeout or BEAM death via a precompiled Rust shim

Current section

Files

Jump to
forcola lib forcola.ex
Raw

lib/forcola.ex

defmodule Forcola do
@moduledoc """
Leak-free external process execution.
Every command runs under a small Rust shim (a port program, not a NIF)
that places the child in its own process group via `setsid` and kills
the whole group, SIGTERM then SIGKILL, when the run times out or the
BEAM dies. The shim detects BEAM death as stdin EOF, so cleanup happens
even on `kill -9` of the VM.
## Why not `System.cmd` in a `Task`?
`Task.shutdown` closes the Erlang port, and closing a port closes pipes;
it sends no signal. The external process runs on until it next touches a
closed pipe, and its children are never signaled at all. See the [process
groups guide](process_groups.html) for the full mechanism.
## Modes
* `Forcola.run/2` - bounded one-shot run, mandatory timeout
* `Forcola.Stream` - line-by-line output as an `Enumerable`
* `Forcola.Daemon` - long-running server under a supervision tree
* `Forcola.Duplex` - bidirectional stdin/stdout session
Maintainers of CLI wrapper libraries who want to offer Forcola-backed
execution without a mandatory dependency: see the [adoption
guide](adopting_forcola.html).
"""
alias Forcola.{Result, Shim}
@typedoc """
Errors a bounded run can return.
The spawn reason is one of `:shim_not_found`, a reason string reported
by the shim, or `{:shim_exited, %Forcola.Result{}}`; see `run/2`.
"""
@type run_error :: {:timeout, Result.t()} | {:spawn, term()}
# Margin added on top of the shim's own kill_grace_ms when computing
# the Elixir-side backstop deadline: the shim already enforces
# timeout_ms and confirms group death within kill_grace_ms before
# sending EXIT, so this only fires if the shim itself never reports
# back (e.g. it's wedged or the BEAM<->shim pipe is stuck).
@backstop_margin_ms 5_000
@default_kill_grace_ms 5_000
@doc """
Run `argv` (`[binary | args]`) to completion under the shim.
## Options
* `:timeout_ms` - required. On expiry the child's process group is
killed (SIGTERM, then SIGKILL after the kill grace) and
`{:error, {:timeout, partial_result}}` is returned with output
captured so far. The group is confirmed dead before the call
returns, with one exception: if the shim itself never reports back,
an Elixir-side backstop returns a result whose status is
`{:signal, :unconfirmed}`, meaning death was not confirmed (see
`Forcola.Result`). A child that exits exactly at the timeout
boundary can be reported as a timeout whose result carries the
normal exit status, including `status: 0`.
* `:kill_grace_ms` - SIGTERM-to-SIGKILL grace in milliseconds,
default `5_000`. Also accepted by `Forcola.Stream.lines/2`.
* `:cd` - working directory.
* `:env` - list of `{name, value}` strings.
* `:merge_stderr` - route stderr into stdout; default `false`.
Any exit status is `{:ok, %Forcola.Result{}}`; callers branch on
`:status`. A non-zero exit is a result, not an error.
## Spawn errors
* `{:error, {:spawn, :shim_not_found}}` - no shim binary exists for
this target (neither downloaded nor built).
* `{:error, {:spawn, reason}}` where `reason` is a string - the shim
reported the spawn failure, for example a missing or
non-executable command.
* `{:error, {:spawn, {:shim_exited, %Forcola.Result{}}}}` - the shim
exited without reporting an exit or error, for example because it
was SIGKILLed. The result's status is `{:signal, :unconfirmed}`. A
SIGKILLed shim gets no chance to kill the group, so the child may
survive, reparented to pid 1.
"""
@spec run([String.t(), ...], keyword()) :: {:ok, Result.t()} | {:error, run_error()}
def run([_binary | _] = argv, opts) do
timeout_ms = Keyword.fetch!(opts, :timeout_ms)
kill_grace_ms = Keyword.get(opts, :kill_grace_ms, @default_kill_grace_ms)
case Shim.open() do
{:ok, port} ->
try do
spawn_and_collect(port, argv, opts, timeout_ms, kill_grace_ms)
after
close_port(port)
end
{:error, :not_found} ->
{:error, {:spawn, :shim_not_found}}
end
end
defp spawn_and_collect(port, argv, opts, timeout_ms, kill_grace_ms) do
payload = Shim.encode_spawn(argv, Keyword.put(opts, :kill_grace_ms, kill_grace_ms))
Shim.send_frame(port, Shim.tag_spawn(), payload)
# Elixir-side backstop only: the shim itself enforces timeout_ms and
# confirms group death within kill_grace_ms before reporting EXIT, so
# under normal operation the shim's own EXIT frame arrives first.
# This deadline exists in case the shim never reports back at all.
deadline =
System.monotonic_time(:millisecond) + timeout_ms + kill_grace_ms + @backstop_margin_ms
collect(port, %{stdout: [], stderr: []}, deadline)
end
defp collect(port, acc, deadline) do
remaining = deadline - System.monotonic_time(:millisecond)
receive do
{^port, {:data, <<tag, payload::binary>>}} ->
handle_frame(port, tag, payload, acc, deadline)
{^port, {:exit_status, _status}} ->
# The shim exited without ever sending EXIT/ERROR (e.g. it
# crashed). Death is implied by the OS reaping the port program
# itself, but the child's own group state cannot be confirmed
# from here.
{:error, {:spawn, {:shim_exited, result(acc, {:signal, :unconfirmed})}}}
after
max(remaining, 0) ->
{:error, {:timeout, result(acc, {:signal, :unconfirmed})}}
end
end
defp handle_frame(port, tag, payload, acc, deadline) do
cond do
tag == Shim.tag_stdout() ->
collect(port, %{acc | stdout: [acc.stdout | payload]}, deadline)
tag == Shim.tag_stderr() ->
collect(port, %{acc | stderr: [acc.stderr | payload]}, deadline)
tag == Shim.tag_exit() ->
{status, timed_out} = Shim.decode_exit(payload)
res = result(acc, status)
if timed_out, do: {:error, {:timeout, res}}, else: {:ok, res}
tag == Shim.tag_error() ->
reason = Shim.decode_error(payload)
{:error, {:spawn, reason}}
true ->
collect(port, acc, deadline)
end
end
defp result(acc, status) do
%Result{
status: status,
stdout: IO.iodata_to_binary(acc.stdout),
stderr: IO.iodata_to_binary(acc.stderr)
}
end
defp close_port(port) do
if port_open?(port) do
Port.close(port)
end
catch
:error, :badarg -> :ok
end
defp port_open?(port) do
Port.info(port) != nil
end
end