Adopting Forcola in a wrapper library

Copy Markdown View Source

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}
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 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:
# 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:

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.

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

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

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

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 shapeExampleForcola mode
Bounded subcommandgit subcommands; claude -p / codex exec one-shot; docker sync paths (ps, build)Forcola.run/2
Line/NDJSON streamingclaude/codex stream-json output; docker logs -f, docker eventsForcola.Stream.lines/2
Managed serverredis_server_wrapper managed modeForcola.Daemon
Interactive sessionclaude duplex stream-json over stdinForcola.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).
  • 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 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:

  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 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.