Mutare.Sandbox.Command.Invocation (mutare v0.1.0)

Copy Markdown View Source

Spawn mix against a materialised sandbox as a fresh OS process.

Every mutant is exercised by its own mix test process: the sources never change between runs, so mix's incremental compiler finds nothing to rebuild and the per-mutant cost is process boot plus the suite. MIX_ENV=test and MUTARE_ACTIVE_MUTANT=<mutant_id> are always set; an optional cap (ms) bounds a run that overruns (a mutation can turn a terminating loop infinite).

This module owns everything about how a run is invoked: the environment it runs under (mix_env/0, the reserved variable set in reserved_env_names/0, the self-hosting isolation vars), the raw spawn (mix/4/timed_mix/5), and the timeout enforcement mechanism — the env var the cap travels in (timeout_env/0) and the dependency-free watcher AST (watcher_ast/0) that Mutare.Sandbox renders into the target's test bootstrap. The matching half — decoding what a run did from its exit code, including the timeout_exit/0 the watcher signals — is Mutare.Sandbox.Command; Mutare.Sandbox.Command.timed_test/5 composes the two (run here, decode there).

The cap is not enforced by killing a process tree (which needs platform-specific signals); instead the watcher reads timeout_env/0 and, after the deadline, System.halt/1s the run itself with Mutare.Sandbox.Command.timeout_exit/0.

A second, structurally identical self-halt guards against the opposite failure: the owner dying rather than the run overrunning. Every run's stdin is a pipe whose write end only the spawning Mutare process holds, so if that process dies — however abruptly — the pipe hits EOF. The owner-death watcher (owner_watch_ast/0, gated by owner_watch_env/0, rendered by Mutare.Sandbox into the sandbox's config and test bootstrap) blocks reading stdin and halts the run with Mutare.Sandbox.Command.owner_lost_exit/0 the moment that EOF arrives, so no sandbox mix outlives the run that spawned it.

Summary

Functions

Env var the runner sets to give the one metamutant compile its wall-clock cap (ms).

Dependency-free watcher that enforces the one metamutant compile's wall-clock cap.

The env entry that caps every BEAM process's heap in a sandbox run, or [] when mb is nil (the :max_heap_mb default — no cap).

Run mix <args> in sandbox as a fresh OS process, returning {output, exit_status}.

The MIX_ENV every sandbox mix runs under ("test"). The single home for the value, so Mutare.Sandbox (which builds _build/<env>/lib paths) and Mutare.Transform.Uses share it rather than re-hardcoding the string.

Dependency-free watcher that halts a sandbox run whose owner died.

Env var that arms the owner-death watcher (owner_watch_ast/0).

The environment variable names Mutare itself sets on a sandbox mix — the base env in mix/4 plus the cap (timeout_env/0) and the coverage-probe vars folded in via :env. The authoritative reserved set: Mutare.Options rejects a :partition_env that collides with one of these, since the partition entry is appended to this list and a duplicate key's resolution is unspecified (it would silently clobber e.g. MIX_ENV). Sourced from the same accessors the env is built from, so it can't drift.

Like mix/4, but wall-clock-timed: returns {elapsed_ms, output, exit_status}.

Env var the runner sets to give a mutant run its wall-clock cap (ms).

Dependency-free watcher that enforces a mutant run's wall-clock cap.

Functions

compile_timeout_env()

@spec compile_timeout_env() :: String.t()

Env var the runner sets to give the one metamutant compile its wall-clock cap (ms).

A sibling of timeout_env/0 with its own name on purpose: the watcher that reads it (compile_watcher_ast/0) lives in the sandbox config/config.exs prefix, which mix evaluates on every sandbox boot — so it must be armed only when Mutare.Runner sets this variable on the compile invocation, and stay inert on the baseline, the coverage probe, and every per-mutant mix test (whose cap is timeout_env/0, armed from the test bootstrap instead).

compile_watcher_ast()

@spec compile_watcher_ast() :: Macro.t()

Dependency-free watcher that enforces the one metamutant compile's wall-clock cap.

The same self-halt watcher as watcher_ast/0, armed by compile_timeout_env/0 instead: Mutare.Sandbox renders it into the sandbox's config/config.exs prefix (mix evaluates config before the compilers run, the same property the owner-death watcher uses), and Mutare.Runner sets the variable only on the compile invocation — so a thrashing compile halts itself with Mutare.Sandbox.Command.timeout_exit/0 instead of blocking the run indefinitely, and every other sandbox boot evaluates the watcher inert.

heap_cap_env(mb)

@spec heap_cap_env(pos_integer() | nil) :: [{String.t(), String.t()}]

The env entry that caps every BEAM process's heap in a sandbox run, or [] when mb is nil (the :max_heap_mb default — no cap).

The cap rides in ELIXIR_ERL_OPTIONS as +hmax <words> (the emulator's default per-process max_heap_size, which kills the offending process when exceeded). This is the memory analogue of the wall-clock watcher, and like it needs nothing platform-specific: no cgroups, no ulimit, no process tree to hunt down. A mutation that makes code allocate without bound (the motivating incident: a dropped guard turning a function unconditionally self-recursive, ~25GB RSS in under a second, OOM-killed) then dies as an ordinary, fast, attributable test failure inside the run — the growing heap belongs to the test process exercising the mutant — instead of racing the kernel's OOM killer for the whole host.

A pre-existing ELIXIR_ERL_OPTIONS in Mutare's own environment is preserved and the cap appended after it (later emulator flags win), so a user's flags survive with the cap applied on top.

One honest limit: max_heap_size counts the process heap — lists, tuples, maps, small binaries (the incident's growth shape, and the common one for runaway recursion). Large (refc) binaries live off-heap and are not counted, so a pure binary-append runaway is not contained by this cap.

Not applied to the one metamutant compile: the metamutant is ~25× the source and the compiler's per-process memory is legitimately large — a cap sized for the suite's runtime could sink the build. The runtime runs (baseline, coverage probe, every per-mutant mix test) all get it — the baseline doubles as validation that the suite itself fits under the cap, so a too-small value surfaces as a red baseline up front rather than as false kills mid-run.

mix(sandbox, args, mutant_id, opts \\ [])

@spec mix(Path.t(), [String.t()], non_neg_integer(),
  cap: pos_integer() | nil,
  env: [{String.t(), String.t()}]
) :: {String.t(), non_neg_integer()}

Run mix <args> in sandbox as a fresh OS process, returning {output, exit_status}.

MIX_ENV=test and MUTARE_ACTIVE_MUTANT=<mutant_id> are always set; mutant_id is the integer the metamutant switches on (Mutare.Selector.baseline/0 for a baseline run), rendered into the env var here. opts:

  • :cap (ms, or nil) — handed to the injected timeout watcher, which halts the run itself if it overruns, so there is no process tree to kill and nothing platform-specific.
  • :env — further environment variables (the coverage probe sets its capture flag this way).

mix_env()

@spec mix_env() :: String.t()

The MIX_ENV every sandbox mix runs under ("test"). The single home for the value, so Mutare.Sandbox (which builds _build/<env>/lib paths) and Mutare.Transform.Uses share it rather than re-hardcoding the string.

owner_watch_ast()

@spec owner_watch_ast() :: Macro.t()

Dependency-free watcher that halts a sandbox run whose owner died.

Reads owner_watch_env/0: unset (a manual run in a kept sandbox, CI with a closed stdin) it is inert, otherwise it spawns a process that blocks reading stdin and, on :eof, System.halt/1s the run with Mutare.Sandbox.Command.owner_lost_exit/0. Under mix/4 the run's stdin is a pipe whose write end only the owning Mutare process holds; when that process dies — clean exit, crash, or SIGKILL (the kernel closes its descriptors) — the pipe hits EOF and the run reaps itself, instead of surviving re-parented (a compute-bound mix never touches stdout, so it would otherwise run on untouched). Mutare.Sandbox renders this AST into the sandbox's config/config.exs (mix evaluates config before compiling, so the one-time metamutant compile and every run's boot phase are covered) and into the test bootstrap alongside watcher_ast/0 (covering suites under a config layout the config injection can't reach). Like the timeout watcher, it needs nothing platform-specific and no dependency on Mutare — the same self-halt primitive pointed at a second hazard.

Data on stdin never arrives under mix/4 (Mutare writes nothing to the pipe), so the watcher simply re-blocks on anything that isn't :eof; a target suite that reads stdin itself sees exactly what it would without the watcher — a silent, open pipe.

owner_watch_env()

@spec owner_watch_env() :: String.t()

Env var that arms the owner-death watcher (owner_watch_ast/0).

Set by mix/4 on every sandbox run, and only there: the watcher halts the run the moment stdin hits EOF, which is the owner-died signal only when stdin is the spawning process's pipe. A sandbox mix run by hand (or by CI with stdin at /dev/null) must stay unaffected, so the watcher is inert unless this variable is set.

reserved_env_names()

@spec reserved_env_names() :: [String.t()]

The environment variable names Mutare itself sets on a sandbox mix — the base env in mix/4 plus the cap (timeout_env/0) and the coverage-probe vars folded in via :env. The authoritative reserved set: Mutare.Options rejects a :partition_env that collides with one of these, since the partition entry is appended to this list and a duplicate key's resolution is unspecified (it would silently clobber e.g. MIX_ENV). Sourced from the same accessors the env is built from, so it can't drift.

timed_mix(sandbox, args, mutant_id, cap \\ nil, env \\ [])

@spec timed_mix(Path.t(), [String.t()], non_neg_integer(), pos_integer() | nil, [
  {String.t(), String.t()}
]) :: {non_neg_integer(), String.t(), non_neg_integer()}

Like mix/4, but wall-clock-timed: returns {elapsed_ms, output, exit_status}.

env is extra environment passed straight through to mix/4 (the runner uses it to set a per-worker partition var, e.g. MIX_TEST_PARTITION); [] adds none.

timeout_env()

@spec timeout_env() :: String.t()

Env var the runner sets to give a mutant run its wall-clock cap (ms).

watcher_ast()

@spec watcher_ast() :: Macro.t()

Dependency-free watcher that enforces a mutant run's wall-clock cap.

Reads timeout_env/0: with no cap it is inert, otherwise it spawns a process that sleeps for the cap and then System.halt/1s the run with Mutare.Sandbox.Command.timeout_exit/0 — so the run halts itself and there is no process tree to kill. Mutare.Sandbox renders this AST into the target project's test bootstrap, mirroring how it renders Mutare.Selector.bootstrap_ast/0, so the target needs nothing platform-specific and no dependency on Mutare.