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

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

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