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.1", optional: true}
]
endConsumers who want leak-free execution add {:forcola, "~> 0.1"} 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}}only after the child's process group is confirmed dead, so{:error, :timeout}now means "git is gone", not "git may still be running".- 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 until it is confirmed dead.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/1. Lines go in withsend_line/2, arrive as{:forcola_line, session, line}messages, andsend_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.1"}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.