Forcola

CI Hex.pm Docs License

Leak-free external process execution for the BEAM.

Forcola runs OS processes through a small Rust shim that puts each child in its own process group and kills the whole group, SIGTERM then SIGKILL, when the run times out or the BEAM dies. Children and grandchildren that remain in the group die with the command.

Named for the forcola, the carved oarlock of a Venetian gondola.

Installation

Add forcola to your dependencies:

def deps do
  [
    {:forcola, "~> 0.4"}
  ]
end

Requires Elixir 1.18+ and OTP 27+. No Rust toolchain is needed on the five precompiled targets (macOS arm64 and x86-64, Linux x86-64 and arm64 glibc, x86-64 musl): the shim binary is downloaded from the matching GitHub Release and verified against a SHA256 checksum at compile time. On other targets, or to opt out of the download, set FORCOLA_BUILD=1 to build from source with cargo. See the getting started guide.

For Mix escripts, include Forcola's priv files, extract the bundled shim to a trusted executable path, and pass shim_path: path to any execution mode. The escript installation example covers extraction, permissions, and cleanup.

Observe a bounded run's output

Pass output_observer: {observer_pid, run_ref} to Forcola.run/2 to receive raw output while the command is still running:

Forcola.run(["my-cli", "--json"],
  timeout_ms: 60_000,
  output_observer: {observer_pid, run_ref}
)
# observer_pid receives {run_ref, {:stdout, bytes}} or
# {run_ref, {:stderr, bytes}} for each output frame.

The target must be a local PID and the tag a reference. On legacy runs, the collector sends notifications before returning the same final Forcola.Result it would return without an observer. Exit, timeout, and cleanup behavior stay the same; a dead observer does not affect the run.

Frames are bytes, not lines or decoded messages. A frame can split a line or UTF-8 character. Notifications are opt-in and unacknowledged; the observer must drain its mailbox. They provide neither backpressure nor durable delivery. With merge_stderr: true, merged stderr is observed as stdout; leave merging disabled when the original channel matters.

Bound output, idle time, and environment

Forcola.run(["/absolute/path/to/cli", "--json"],
  timeout_ms: 60_000,
  idle_timeout_ms: 10_000,
  max_output_bytes: 1_048_576,
  env_mode: :replace,
  env: [{"PATH", "/usr/bin:/bin"}],
  interrupt_signal: :int,
  interrupt_grace_ms: 500
)

The new integer options must be positive and at most 4_294_967_295. Output bytes count stdout and stderr together, including partial lines. Exactly the limit is allowed; an additional byte cancels. The captured and observed prefix never exceeds it. Finite credits keep at most 8 KiB per output pipe in flight. The independent whole-run deadline never resets on output; the idle deadline does. Without max_output_bytes, captured output has no cumulative memory limit. env_mode: :inherit remains the default; :replace clears the environment before applying env. In every mode, {name, nil} and {name, false} remove a variable; other non-binary values raise ArgumentError before opening the shim. With strict Run options or explicit environment or interrupt options, env must be a list with binary names and binary, nil, or false values. Names must be nonempty without = or NUL, and string values must have no NUL. Plain calls retain env: nil, maps and atom names.

The optional signal/grace pair sends process-group SIGINT before the existing TERM/KILL sequence on timeout, explicit cancellation, and owner or BEAM death. It applies to Duplex close and shutdown, Stream halt, owner or BEAM death, Stream timeout, idle and output limits, and Daemon stop or readiness timeout. Cleanup waits include both graces and, with cgroup requested, the cgroup drain (another kill grace plus 500 ms). The Daemon child spec also includes the confirmation and port-close bounds so supervisor shutdown can wait for cleanup. A cooperative parent can use that opportunity to stop children in other groups. An uncooperative escaped child remains outside group cleanup on macOS.

These options require a matching shim capability exchange before child execution. Older shims return {:error, {:spawn, :incompatible_shim}}. Handshake stalls return {:error, {:spawn, :handshake_timeout}}, malformed replies return {:error, {:spawn, :invalid_handshake}}, and transport exit before SPAWN returns {:error, {:spawn, {:shim_exited, status}}}. With new options, timeout_ms (always an integer in 0..4_294_967_295) includes startup negotiation; SPAWN receives only its remaining budget. Cleanup and guard close can extend beyond that execution deadline (see Forcola.run/2). cgroup: :required alone keeps legacy collection and errors without HELLO; its existing required-containment wire encoding remains enforced. Duplex, Stream and Daemon accept the same env_mode and interrupt pair, requiring the existing bounded_run_v1 capability. Removals additionally require env_unset_v1; unsupported shims fail before SPAWN. These requirements compose with identity and structured spawn errors. Stream counts negotiation toward its timeout; Duplex and Daemon use a five-second handshake bound. Each mode keeps its own output and error shapes. Stream and Daemon reject max_output_bytes; Duplex supports it with delivery: :pull. Duplex and Daemon reject idle_timeout_ms; Stream keeps its existing idle option. Bounded failures return {:error, {reason, partial_result}}, with reason :timeout, :idle_timeout, :max_output_bytes, or :observer_overflow. Strict observers cancel at 1,024 queued messages rather than growing the recipient's mailbox indefinitely; notifications remain unacknowledged.

result.evidence keeps raw child_status and child_signal, termination, reason, output_truncated, cleanup, scope, and guard_close independent of the legacy status. Only explicit valid EXIT evidence confirms cleanup. Strict drain waits for pipe closure after root exit. Without descendant tracking, an escaped descendant holding a pipe can cause reason: :timeout or :idle_timeout even with child_status: 0; wrappers must consume those facts independently. :process_group confirmation never proves whole-tree containment. Early JSON on stdout is partial output until the terminal result arrives. See Forcola.Result for field values and Forcola.run/2 for error shapes.

The problem

The common Elixir timeout pattern leaks processes:

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 kills the BEAM task, which closes the Erlang port. Closing a port closes pipes; it sends no signal. The external process keeps running until it next writes to a closed pipe, and any children it spawned are never signaled at all. The caller gets {:error, :timeout} while the command keeps running.

The design

A port program, not a NIF: the shim is a separate OS process, so a bug in it cannot crash the BEAM. BEAM death closes the protocol pipes; tracking also checks for peer hang-up independently of stdin reads.

BEAM <--stdin/stdout pipes--> forcola_shim <--forks--> child (own process group)
                                   |                      |- grandchild
                                   |                      |- grandchild
                                   |
                     on timeout or stdin EOF:
                     kill(-pgid, SIGTERM), then SIGKILL

Execution modes

Mode API Use
Bounded run Forcola.run/2 One-shot command with mandatory timeout
Line stream Forcola.Stream.lines/2 Line output consumed as an Enumerable
Daemon Forcola.Daemon Long-running server under a supervision tree
Duplex Forcola.Duplex Bidirectional stdin/stdout session

The getting started guide has a runnable example, options, and return/message shapes for each mode.

Spawn errors

Pass structured_spawn_errors: true in any mode to distinguish a missing working directory from a missing executable, permission failures, credential changes, and cgroup failures. Errors carry a %Forcola.SpawnError{} with stage, reason, errno, path, and the original message. This requires capability negotiation; callers that leave it disabled keep string errors. See the spawn error guide for each mode's error shape.

File locks

Forcola.Lock holds a whole-file POSIX fcntl write lock, which the BEAM cannot take itself. The shim takes the lock without waiting and holds it until Forcola.Lock.release/1 or until the process that acquired it exits. Programs that lock the same file with fcntl or lockf see the conflict.

{:ok, lock} = Forcola.Lock.acquire("/run/user/1000/app/layer.lock")
{:error, {:held, os_pid}} = Forcola.Lock.acquire("/run/user/1000/app/layer.lock")
:ok = Forcola.Lock.release(lock)

The lock file is created if missing and is never truncated, removed, or replaced.

Process groups and cleanup

Pass spawn_identity: true to record the child's OS identity. One-shot results include pid, pgid, and start_time; Duplex sends {:forcola_spawned, session, identity} to its owner. Duplex and Daemon expose spawned/1 for polling. Daemon and Stream accept spawn_observer: {pid, reference}, which enables identity reporting and sends {reference, {:spawned, identity}} to that recipient before output delivery. Stream requires an observer when identity is enabled. Daemon sends no identity notifications unless an observer is supplied. The identity example shows how to record a Duplex child's identity before sending input.

The group kill covers the child and everything it keeps in its process group: ordinary grandchildren die with the command. Pass track_descendants: true in any mode to discover descendants that leave the group and apply the same INT/TERM/KILL sequence to their recorded groups on every cleanup path. Linux uses a child subreaper and reports :tree_contained; macOS samples the tree and reports :tree_sampled. A macOS descendant reparented before any sample can escape. Identity checks skip reused leaders, but checking and signalling are not atomic.

An optional track_observer: {pid, reference} enables tracking and receives {reference, {:tracked, identity}} as groups appear, starting with the root, and {reference, {:tracking_exit, evidence}}, where evidence includes scope, confirmation, contained and tracked_groups_truncated, when the Elixir process survives to report completion. Native cleanup continues on brutal owner or BEAM death, but no final observer event is guaranteed then. Run results, Duplex terminals and Stream errors retain tracked_groups and tracked_groups_truncated. History truncation is separate from native cleanup confirmation. Their cleanup evidence also exposes contained (true, false or :unknown) independently of the tree scope. Duplex and Daemon also expose tracked/1 for polling. Tracking negotiates the track_descendants_v1 capability before SPAWN and leaves defaults unchanged. The tracking guide describes platform limits and evidence.

On Linux, cgroup: true remains the stronger backstop when active; cgroup: :required refuses to execute without it. Daemon control channels like docker and work handed to system schedulers stay out of reach of any process-based mechanism. The process groups guide covers the kill sequence, cgroup containment, the confirmation guarantee and its exceptions, and the full "What group kill cannot reach" audit.

Adopting in a wrapper library

Forcola slots into existing CLI wrapper libraries without becoming a mandatory dependency: the wrapper defines a small runner behaviour, keeps its System.cmd/3 path as the default, and accepts a Forcola-backed one via config, with Forcola as an optional dep. The adoption guide covers the pattern, a worked example against a real wrapper, the mode mapping, and migration notes for erlexec-based wrappers.

Prior art

The alternatives guide compares these in detail.

Development

The local quality gate mirrors CI:

mix deps.get
mix format --check-formatted
mix compile --warnings-as-errors
mix test
mix credo --strict
mix dialyzer
cargo test --manifest-path native/forcola_shim/Cargo.toml
cargo clippy --manifest-path native/forcola_shim/Cargo.toml --all-targets -- -D warnings

CI runs the Elixir integration suite on Ubuntu and macOS, and runs the privilege-drop tests under passwordless sudo on its Linux runner. Real cgroup containment needs a delegated writable cgroup v2 subtree, which hosted runners do not always provide; the platform job reports availability in its job summary. Set FORCOLA_REQUIRE_ROOT_TESTS=1 or FORCOLA_REQUIRE_CGROUP=1 to turn either platform prerequisite into a hard failure on a suitable dedicated runner.

License

MIT