# Adopting Forcola in a wrapper library
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:
```elixir
task = Task.async(fn -> System.cmd(binary, args) end)
case Task.yield(task, timeout) || Task.shutdown(task) do
{:ok, result} -> result
nil -> {:error, :timeout}
end
```
`Task.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](process_groups.html) 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:
1. A behaviour whose callback returns exactly what the wrapper's call sites
already consume, so parsing code does not change.
2. A default implementation wrapping the existing `System.cmd/3` call.
Existing consumers see no change in behavior and gain no new mandatory
dependencies.
3. A Forcola-backed implementation, selected via application config, with
Forcola declared optional:
```elixir
# mix.exs of the wrapper library
defp deps do
[
{:forcola, "~> 0.1", optional: true}
]
end
```
Consumers 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:
```elixir
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
end
```
On 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.
```elixir
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
end
```
### Step 2: the default implementation is the current code, moved
```elixir
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
end
```
### Step 3: the Forcola-backed implementation
```elixir
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
end
```
The `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_ms` is 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{}}`, matching `System.cmd/3`, so
`parse_output/2` keeps receiving the exit code and decides what it means.
- `merge_stderr: true` is the equivalent of `stderr_to_stdout: true`; use it
when the wrapper's current `System.cmd/3` options do.
- `:cd` and `:env` carry over. `:env` is a list of `{name, value}` string
tuples, the same shape `System.cmd/3` takes.
- `{:signal, _}` in `:status` has no `System.cmd/3` equivalent (the child
died from a signal); surface it as an error rather than inventing an exit
code.
### Step 4: dispatch through the behaviour
```elixir
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
end
```
That 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_ms` is mandatory; a non-zero exit is
`{:ok, %Forcola.Result{}}`, not an error.
- `Forcola.Stream.lines/2`: `:timeout_ms` is mandatory and bounds the whole
run, not the gap between lines. A non-zero exit, death by signal, timeout,
or spawn failure raises `Forcola.Stream.Error` after 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 raises `ArgumentError`);
the daemon's bound is its supervisor. Supports a `:ready` check so
`start_link` blocks until the server accepts connections, and `:output`
routing 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](process_groups.html)).
- `Forcola.Duplex`: no `:timeout_ms` (passing one raises `ArgumentError`);
the session is bounded by its owner process and `close/1`. Lines go in
with `send_line/2`, arrive as `{:forcola_line, session, line}` messages,
and `send_eof/1` closes 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](process_groups.html) section "What group kill cannot reach" covers
this class.
The [alternatives guide](alternatives.html) 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:
1. Add `{:forcola, "~> 0.1"}` and swap the erlexec calls for their Forcola
counterparts; bounded runs become `Forcola.run/2` with `:timeout_ms`.
2. 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.
3. 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.