This guide is for libraries that wrap a CLI (git, docker, redis-server, agent
CLIs) and shell out with System.cmd/3 today. It shows how such a library
adopts Forcola for leak-free process control without making Forcola a
mandatory dependency for its existing consumers.
The starting point
Most wrapper libraries implement timeouts with some variant of:
task = Task.async(fn -> System.cmd(binary, args) end)
case Task.yield(task, timeout) || Task.shutdown(task) do
{:ok, result} -> result
nil -> {:error, :timeout}
endTask.shutdown/1 kills the BEAM task, which closes the Erlang port. Closing
a port closes pipes; it sends no signal. The external process keeps running,
and any children it spawned are never signaled at all. The process groups
guide describes the full mechanism. Forcola closes the
leak by running the command in its own process group and killing the whole
group, SIGTERM then SIGKILL, on timeout or BEAM death.
The Runner behaviour pattern
Making Forcola a hard dependency of a wrapper library would impose it on
every consumer, including those who accept the current behavior. Instead the
wrapper defines a small behaviour for "run this command, return the shapes I
already use", keeps its current System.cmd/3 path as the default
implementation, and accepts a Forcola-backed implementation through
configuration.
Three pieces:
- A behaviour whose callback returns exactly what the wrapper's call sites already consume, so parsing code does not change.
- A default implementation wrapping the existing
System.cmd/3call. Existing consumers see no change in behavior and gain no new mandatory dependencies. - A Forcola-backed implementation, selected via application config, with Forcola declared optional:
# mix.exs of the wrapper library
defp deps do
[
{:forcola, "~> 0.4", optional: true}
]
endConsumers who want leak-free execution add {:forcola, "~> 0.4"} to their
own deps and set one config line. Everyone else is untouched.
Worked example: git_wrapper_ex
Git.Command.run/3 in git_wrapper_ex (lib/git/command.ex) is the smallest
real case: one function that builds an argument list, executes git, and hands
the output to a parser. Its current core is the leaky pattern above:
def run(mod, command, %Config{} = config) do
all_args = Config.base_args(config) ++ mod.args(command)
opts = Config.cmd_opts(config)
task =
Task.async(fn ->
System.cmd(config.binary, all_args, opts)
end)
case Task.yield(task, config.timeout) || Task.shutdown(task) do
{:ok, {stdout, exit_code}} ->
mod.parse_output(stdout, exit_code)
nil ->
{:error, :timeout}
end
endOn timeout the caller gets {:error, :timeout} while git, and anything git
spawned (hooks, credential helpers), may still be running.
Step 1: define the behaviour
The callback returns what the call site already consumes: System.cmd/3's
{stdout, exit_code} on completion, {:error, :timeout} on timeout.
defmodule Git.Runner do
@moduledoc """
How git commands are executed.
The default is `Git.Runner.Port`, the `System.cmd/3` path. For leak-free
execution add `:forcola` to your deps and configure:
config :git_wrapper_ex, runner: Git.Runner.Forcola
"""
@callback run(binary :: String.t(), args :: [String.t()], opts :: keyword()) ::
{:ok, {stdout :: String.t(), exit_code :: non_neg_integer()}}
| {:error, term()}
def impl do
Application.get_env(:git_wrapper_ex, :runner, Git.Runner.Port)
end
endStep 2: the default implementation is the current code, moved
defmodule Git.Runner.Port do
@behaviour Git.Runner
@impl true
def run(binary, args, opts) do
{timeout, cmd_opts} = Keyword.pop!(opts, :timeout)
task = Task.async(fn -> System.cmd(binary, args, cmd_opts) end)
case Task.yield(task, timeout) || Task.shutdown(task) do
{:ok, result} -> {:ok, result}
nil -> {:error, :timeout}
end
end
endStep 3: the Forcola-backed implementation
if Code.ensure_loaded?(Forcola) do
defmodule Git.Runner.Forcola do
@behaviour Git.Runner
@impl true
def run(binary, args, opts) do
{timeout, cmd_opts} = Keyword.pop!(opts, :timeout)
forcola_opts =
[timeout_ms: timeout, merge_stderr: true] ++
Keyword.take(cmd_opts, [:cd, :env])
case Forcola.run([binary | args], forcola_opts) do
{:ok, %Forcola.Result{status: status, stdout: stdout}} when is_integer(status) ->
{:ok, {stdout, status}}
{:ok, %Forcola.Result{status: {:signal, signal}}} ->
{:error, {:signal, signal}}
{:error, {:timeout, _partial}} ->
{:error, :timeout}
{:error, {:spawn, reason}} ->
{:error, {:spawn, reason}}
end
end
end
endThe Code.ensure_loaded?/1 guard keeps the module from compiling when
Forcola is not present, so the optional dependency stays optional.
Mapping notes, each verified against Forcola.run/2:
:timeout_msis mandatory. The wrapper's existing timeout value maps onto it directly. On expiry Forcola returns{:error, {:timeout, partial}}after the child's process group is confirmed dead, or marks the partial resultstatus: {:signal, :unconfirmed}when bounded teardown could not prove that. Wrappers should preserve that distinction.- A non-zero exit is
{:ok, %Forcola.Result{}}, matchingSystem.cmd/3, soparse_output/2keeps receiving the exit code and decides what it means. merge_stderr: trueis the equivalent ofstderr_to_stdout: true; use it when the wrapper's currentSystem.cmd/3options do.:cdand:envcarry over.:envis a list of{name, value}string tuples, the same shapeSystem.cmd/3takes.{:signal, _}in:statushas noSystem.cmd/3equivalent (the child died from a signal); surface it as an error rather than inventing an exit code.
Step 4: dispatch through the behaviour
def run(mod, command, %Config{} = config) do
all_args = Config.base_args(config) ++ mod.args(command)
opts = Keyword.put(Config.cmd_opts(config), :timeout, config.timeout)
case Git.Runner.impl().run(config.binary, all_args, opts) do
{:ok, {stdout, exit_code}} -> mod.parse_output(stdout, exit_code)
{:error, reason} -> {:error, reason}
end
endThat is the whole migration: one behaviour, the old code as the default, an adapter, and a config switch.
Mode mapping for the wrapper family
Each wrapper's call shapes map onto one of Forcola's four modes:
| Wrapper call shape | Example | Forcola mode |
|---|---|---|
| Bounded subcommand | git subcommands; claude -p / codex exec one-shot; docker sync paths (ps, build) | Forcola.run/2 |
| Line/NDJSON streaming | claude/codex stream-json output; docker logs -f, docker events | Forcola.Stream.lines/2 |
| Managed server | redis_server_wrapper managed mode | Forcola.Daemon |
| Interactive session | claude duplex stream-json over stdin | Forcola.Duplex |
Per-mode notes:
Forcola.run/2::timeout_msis mandatory; a non-zero exit is{:ok, %Forcola.Result{}}, not an error.Forcola.Stream.lines/2::timeout_msis mandatory and bounds the whole run, not the gap between lines. A non-zero exit, death by signal, timeout, or spawn failure raisesForcola.Stream.Errorafter every line produced before death has been emitted; halting the stream early kills the process group and blocks for bounded confirmation, warning if teardown remains unconfirmed.Forcola.Daemon: no:timeout_ms(passing one raisesArgumentError); the daemon's bound is its supervisor. Supports a:readycheck sostart_linkblocks until the server accepts connections, and:outputrouting for logs. Run the server in foreground mode; a daemonize flag escapes the process group (see "What group kill cannot reach" in the process groups guide).Forcola.Duplex: no:timeout_ms(passing one raisesArgumentError); the session is bounded by its owner process andclose/1orshutdown/1. Lines go in withsend_line/2. By default output arrives as owner messages;delivery: :pullinstead usesrecv/2and bounds stdout and stderr delivery.shutdown/1andawait_terminal/2retain child status and cleanup evidence separately.send_eof/1closes stdin for CLIs that exit when input ends.
One caveat for docker-shaped wrappers: the docker CLI is a control channel
for a daemon. Forcola kills the client reliably, but that never stops the
container or build running under the daemon; pair Forcola with the tool's
own teardown (docker run --rm, docker kill). The process groups
guide section "What group kill cannot reach" covers
this class.
The alternatives guide compares Forcola with erlexec,
MuonTrap, exile, Porcelain, Rambo, and plain System.cmd/3, and lists when
to choose each.
Migrating from erlexec
For a wrapper that uses erlexec today for the same contract (group kill on timeout, cleanup on BEAM death), the migration is mechanical:
- Add
{:forcola, "~> 0.4"}and swap the erlexec calls for their Forcola counterparts; bounded runs becomeForcola.run/2with:timeout_ms. - Run your existing test suite; it is the acceptance bar. Forcola's own suite covers the group-kill contract cases, including the SIGTERM-ignoring child.
- Drop the erlexec dependency. This removes the C++ compile erlexec runs on each consumer's machine; Forcola ships precompiled shim binaries with checksum verification instead.
This mapping covers the shared group-kill contract, not erlexec's broader PTY, privilege, cgroup resource-limit, or shared-port APIs. Check those needs against the alternatives guide before migrating.
Bounded one-shot adapter contract
Forcola.run/2 keeps its existing {:ok, %Forcola.Result{}} shape for all child
exit codes and signals, and its existing timeout/spawn tuples on legacy runs.
Result adds a defaulted evidence: nil field; run/2 fills it. Struct patterns
continue to work; wrappers doing exact map comparisons should account for the
new field.
Adapters may pass positive idle_timeout_ms and max_output_bytes (combined
stdout/stderr), env_mode: :inherit | :replace, and the paired
interrupt_signal: :int, interrupt_grace_ms: positive_integer. Each new integer
is at most 4_294_967_295. Invalid options raise before opening a shim. The
trusted shim_path: override remains available. Explicit new options
require protocol 1 / bounded_run_v1 capability support
before SPAWN; incompatible shims cannot silently execute with ignored options.
Stream, Duplex, and Daemon reject env_mode and the interrupt pair before
opening a shim. Stream and Daemon also reject max_output_bytes; Duplex
keeps its existing support with delivery: :pull. Duplex and Daemon reject
idle_timeout_ms; Stream keeps its existing idle-timeout support. These
existing mode options keep their own validation and semantics.
With any new option, explicit env: must be a list of binary string pairs
with nonempty names, no = in names, and no NUL in names or values. Without
new options, legacy env: nil, maps, and atom keys retain their existing
encoding and native spawn-error behavior.
Handle {:error, {reason, partial_result}} for :timeout, :idle_timeout,
:max_output_bytes, and :observer_overflow. With new options, a reported
spawn failure after SPAWN is {:error, {:spawn, {reason, partial_result}}}.
Malformed EXIT is {:error, {:spawn, {:invalid_exit, partial_result}}} and
transport loss remains {:error, {:spawn, {:shim_exited, partial_result}}}.
Pre-execution discovery/handshake errors keep {:error, {:spawn, reason}}:
:incompatible_shim for a rejected HELLO or valid incompatible capabilities,
:handshake_timeout for a stalled exchange, :invalid_handshake for malformed
or unexpected replies, and {:shim_exited, status} for transport exit before
SPAWN. Here status is an OS exit code, {:shim_error, reason} for a driver
failure, or :unknown if unavailable; it is not a partial child result.
cgroup: :required alone preserves legacy collection and spawn errors without
HELLO. Its existing "required" wire encoding still refuses unsupported
containment. Adding a new option requires capabilities as usual.
A bounded failure retains its original reason even if transport is then lost.
With new options, timeout_ms must be an integer in 0..4_294_967_295.
Its deadline starts before opening the shim; negotiation consumes that same
budget and SPAWN receives only the remainder. Idle timing starts after
negotiation. Port opening is synchronous, so its OS latency and final guard
close may exceed the deadline. After SPAWN, cancellation allows interrupt
grace plus kill grace and a 5-second collection backstop; final guard close
has a separate 5-second bound. Handshake failure closes the guard without
spawning a child and does not restart an execution allowance.
Strict draining waits for both output pipes after root exit and remains
bounded by the whole-run or idle cancellation deadline. If an escaped
descendant retains a pipe after the root exits zero, the final run can report
reason: :timeout or :idle_timeout with child_status: 0. Read these fields
independently. Group cleanup evidence does not prove escaped descendants died.
Treat result.evidence as independent fields, not a success flag. It records
raw direct-child child_status/child_signal, protocol/transport termination,
bounded reason, output_truncated (boolean or :unknown), cleanup
(:confirmed | :unconfirmed | :unknown), scope
(:process_group | :active_cgroup | :unknown), and guard_close
(:ok | {:error, :guard_close_timeout}). Missing confirmation stays unknown
even when a legacy status projects to success for compatibility. A raw child
exit remains available when cleanup was explicitly unconfirmed.
Observers receive only the retained prefix. Strict runs cancel with
:observer_overflow when the recipient's total queue already has 1,024
messages. Other senders can grow that queue independently. Delivery remains
unacknowledged; wrappers needing durable output should consume the result.
Never accept an early provider-like JSON frame as success before terminal
evidence, or equate process-group confirmation with whole-tree safety.