# 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.4", optional: true}
  ]
end
```

Consumers 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:

```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}}`
  after the child's process group is confirmed dead, or marks the partial
  result `status: {:signal, :unconfirmed}` when bounded teardown could not
  prove that. Wrappers should preserve that distinction.
- 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 for bounded confirmation, warning if teardown remains
  unconfirmed.
- `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` or `shutdown/1`.
  Lines go in with `send_line/2`. By default output arrives as owner messages;
  `delivery: :pull` instead uses `recv/2` and bounds stdout and stderr delivery.
  `shutdown/1` and `await_terminal/2` retain child status and cleanup evidence
  separately. `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.4"}` 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.

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](alternatives.html) 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.
