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
the variables selecting the mutant (Mutare.Selector.environment/1) are always
set; the rest of a run's environment comes from named run options
(run_opts/0) — an optional wall-clock :cap (a mutation can turn a
terminating loop infinite), the compile's own cap and compiler switches, the
coverage probe's capture vars, the heap cap, and the per-worker partition entry.
This module implements run invocation: the environment it
runs under (environment/2, the one builder every sandbox mix goes through;
mix_env/0; the self-hosting isolation vars), the raw spawn
(mix/4/timed_mix/4), and the timeout enforcement mechanism — the env var
used to pass the cap (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 — is
Mutare.Sandbox.Command; the codes both halves share are the leaf
Mutare.Sandbox.Command.Exit, and Mutare.Sandbox.Command.timed_test/4
composes the two (run here, decode there).
The reserved variable set is the builder's key set
Every variable Mutare sets on a sandbox mix is emitted by environment/2 —
there is no raw env passthrough — so reserved_env_names/0 is derived from it
(the builder run with every option armed), not maintained alongside it. A new run
option cannot be forgotten from the reserved set, because the set is whatever the
builder emits. The one key deliberately outside it is the :partition entry: its
name is the user's :partition_env, appended last, and the reserved set is what
Mutare.Options validates that name against.
Self-halt watchers
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.Exit.timeout/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.Exit.owner_lost/0 as soon as EOF is read,
so no sandbox mix outlives the run that spawned it.
Summary
Types
The named run options every sandbox mix is invoked with. Each one maps to the
environment entries environment/2 emits for it
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 one ELIXIR_ERL_OPTIONS entry carrying the emulator flags a sandbox run is
armed with — :max_heap_mb and :schedulers of run_opts/0 — or [] when
neither is armed. One entry for both, since they share the variable.
The full environment a sandbox mix for mutant_id runs under, given its run
options (run_opts/0) — the one builder mix/4 uses and reserved_env_names/0
derives from.
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 keys of
environment/2 with every run option armed (:partition excepted — its name is
the user's, and this set is what it is validated against).
Like mix/4, but wall-clock-timed: returns {elapsed_ms, output, exit_status}.
opts are the run options (run_opts/0), passed straight through.
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.
Types
@type run_opts() :: [ cap: pos_integer() | nil, compile_cap: pos_integer() | nil, compile: boolean(), coverage: {Path.t(), Path.t()} | nil, max_heap_mb: pos_integer() | nil, schedulers: pos_integer() | :all | nil, partition: [{String.t(), String.t()}] ]
The named run options every sandbox mix is invoked with. Each one maps to the
environment entries environment/2 emits for it:
:cap(ms, ornil) — the run's wall-clock cap, handed to the injected timeout watcher (timeout_env/0), which halts the run itself if it overruns.:compile_cap(ms, ornil) — the one metamutant compile's wall-clock cap (compile_timeout_env/0), armed only on that invocation.:compile(boolean) — the compiler switches that speed the one compile (Mutare.Sandbox.CompilerOptions.compiler_env/0).:coverage({dump_path, root}, ornil) — the coverage probe's capture flag, dump path and path-normalisation root (Mutare.Coverage.Recorder).:max_heap_mb(MB, ornil) — the per-process heap cap (emulator_flags_env/1).:schedulers(a count,:all, ornil) — the run's scheduler threads (emulator_flags_env/1).:partition([{name, id}]or[]) — the user-named partition entry (Mutare.Runner.Partitions.entry/2), appended last.
Every option is optional. An absent :cap, :compile_cap or :coverage clears
its variables explicitly rather than emitting nothing, so a value inherited from
Mutare's own environment cannot enable a watcher or probe omitted from this invocation's
options. The remaining absent options emit nothing.
Functions
@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).
@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.Exit.timeout/0 instead of blocking the run
indefinitely, and every other sandbox boot evaluates the watcher inert.
The one ELIXIR_ERL_OPTIONS entry carrying the emulator flags a sandbox run is
armed with — :max_heap_mb and :schedulers of run_opts/0 — or [] when
neither is armed. One entry for both, since they share the variable.
A pre-existing ELIXIR_ERL_OPTIONS in Mutare's own environment is preserved
and the flags appended after it (later emulator flags win), so a user's flags
survive with Mutare's applied on top. With nothing armed the inherited value
reaches the run untouched.
:max_heap_mb — +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.
One limitation: 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.
:schedulers — +S <n>:<n>
Trims the run's BEAM to n scheduler threads, all online. :workers such runs
execute at once, and an untrimmed BEAM takes every core for itself, so without
this the per-mutant phase oversubscribes the CPU by the worker count. Dirty CPU
schedulers follow the count, and so does whatever the suite derives from
System.schedulers_online/0 — ExUnit's default max_cases above all, so a
trimmed run executes fewer async tests at once. :all (and nil) emit no flag.
The baseline runs under the same count as the mutants, so it checks the suite is green at the concurrency they get, and times it at their speed — the per-mutant cap is a multiple of that time. The one compile and the coverage probe run alone on the machine and are not trimmed.
@spec environment(Mutare.RuntimeId.t(), run_opts()) :: [ {String.t(), String.t() | nil} ]
The full environment a sandbox mix for mutant_id runs under, given its run
options (run_opts/0) — the one builder mix/4 uses and reserved_env_names/0
derives from.
Always present: MIX_ENV, the owner-death watcher's arming variable, the two
self-hosting isolation variables, and the selector variables for mutant_id. Then
one group per armed option, in run_opts/0 order, and the :partition entry
last. Pure apart from reading Mutare's own environment where an entry merges
with an inherited value (emulator_flags_env/1, CompilerOptions.compiler_env/0).
@spec mix(Path.t(), [String.t()], Mutare.RuntimeId.t(), run_opts()) :: {String.t(), non_neg_integer()}
Run mix <args> in sandbox as a fresh OS process, returning
{output, exit_status}.
The environment is environment/2 of mutant_id and opts (see run_opts/0).
mutant_id is a runtime identity (Mutare.Selector.baseline/0 for a baseline
run); for a schema mutant, Selector.environment/1 splits its {file, local_id}
into the namespace and integer environment variables, and integer calls clear any
inherited namespace.
@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.
@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.Exit.owner_lost/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
project prefix, 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. Only the first armed evaluation starts a watcher.
No data is sent on stdin 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 receives the same input as without the watcher —
a silent, open pipe.
@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.
@spec reserved_env_names() :: [String.t()]
The environment variable names Mutare itself sets on a sandbox mix: the keys of
environment/2 with every run option armed (:partition excepted — its name is
the user's, and this set is what it is validated against).
Derived, not listed: a run option added to environment/2 is reserved by
construction. Mutare.Options rejects a :partition_env that collides with one
of these, since the partition entry is appended to the environment and a
duplicate key's resolution is unspecified (it would silently clobber e.g.
MIX_ENV).
@spec timed_mix(Path.t(), [String.t()], Mutare.RuntimeId.t(), run_opts()) :: {non_neg_integer(), String.t(), non_neg_integer()}
Like mix/4, but wall-clock-timed: returns {elapsed_ms, output, exit_status}.
opts are the run options (run_opts/0), passed straight through.
@spec timeout_env() :: String.t()
Env var the runner sets to give a mutant run its wall-clock cap (ms).
@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.Exit.timeout/0 — so the run halts itself and there is
no process tree to kill. Mutare.Sandbox renders this AST into the target
project prefix and test-bootstrap fallback, alongside Mutare.Selector.bootstrap_ast/0,
so the target needs nothing platform-specific and no dependency on Mutare.
The first armed evaluation starts the deadline; later prefixes cannot reset it.