Forcola

CIHex.pmDocsLicense

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 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.1"}
]
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.

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, and BEAM death reaches it as stdin EOF.

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

ModeAPIUse
Bounded runForcola.run/2One-shot command with mandatory timeout
Line streamForcola.Stream.lines/2Line output consumed as an Enumerable
DaemonForcola.DaemonLong-running server under a supervision tree
DuplexForcola.DuplexBidirectional stdin/stdout session

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

Process groups and cleanup

The group kill covers the child and everything it keeps in its process group: ordinary grandchildren die with the command. Deliberate daemonizers, daemon control channels like docker, and work handed to system schedulers leave the group and are out of reach of any client-side mechanism. The process groups guide covers the kill sequence, the death-confirmed-before-return guarantee and its exception, 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.

License

MIT