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

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

run_opts()

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

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

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.Exit.timeout/0 instead of blocking the run indefinitely, and every other sandbox boot evaluates the watcher inert.

emulator_flags_env(opts)

@spec emulator_flags_env(run_opts()) :: [{String.t(), String.t()}]

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.

environment(mutant_id, opts \\ [])

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

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

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.

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

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

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

Like mix/4, but wall-clock-timed: returns {elapsed_ms, output, exit_status}. opts are the run options (run_opts/0), passed straight through.

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