Decode what a mix test mutant run did, and orchestrate one typed run.
Every mutant is exercised by its own mix test process (spawned by
Mutare.Sandbox.Command.Invocation). This module implements the decoding side of the
exit-code contract — reading a run's exit code and output into a mutant
verdict — plus the one entry point that runs a mutant and hands back a typed
Mutare.Sandbox.Command.Result. The neighbouring modules implement the rest of what was
once one file:
Mutare.Sandbox.Command.Exit— the exit codes themselves and their by-code reading (Exit.decode/1), the leaf both sides of the contract read from.Mutare.Sandbox.Command.Invocation— process execution, the environment a run gets (one builder per run option, from which the reserved variable set derives), and the watcher ASTs (the producing side of the timeout and owner-death codes).Mutare.Sandbox.Command.Output— every pattern that readsmix's human-readable output, including the discriminators used byoutcome/2below.Mutare.Sandbox.CompilerOptions— the env that speeds the one metamutant compile.
The exit-code contract
The codes themselves — and the total by-code reading, decode/1 — live in the
leaf Mutare.Sandbox.Command.Exit, which both the watcher ASTs (the producing
side, in Invocation) and this module (the decoding side) read from.
outcome/2 refines Exit.decode/1's ambiguous "anything else" case with the
run's output (via the Mutare.Sandbox.Command.Output discriminators). Three refinements recover
detected-mutant cases from the otherwise-:harness_error bucket:
- a mutation that broke the test suite's own compilation (it ran at the
test modules' compile time) exits
1with a test-script compile-error banner (Output.suite_compile_error?/1) — a kill, not infra. - a mutation that minted unbounded atoms (an unterminated search building a
fresh
:"#{x}_#{i}"per step) crashes the BEAM when the global atom table fills (Output.atom_exhausted?/1) — a resource-divergence exactly like a CPU-bound timeout (the suite can never pass with it), so also a kill. The VM aborts before the in-process timeout watcher can self-halt, which is why it surfaces here rather than as a cleanExit.timeout/0. - a mutation reachable from application startup stops
mix testbefore it loads a single test: the sandbox selects the mutant in themix.exsprefix, project evaluation fails (Output.project_failure?/1), runtime configuration raises (Output.config_failure?/1), orapp.startraises and Mix exits1withCould not start application …(Output.app_start_failure?/1). The baseline boots the same sandbox green, so the mutation is what broke it — a kill, on the same reasoning as the suite-compile case.Mutare.Runner.MutantRunspends the boot-contention retry budget first, so only a persistent one is charged.
A fourth refinement does not change the verdict — it stays a harness error —
but names a known-transient cause so the runner can message and retry it
better (Output.boot_failure?/1 → :boot_failure): the sandbox node died during
boot and its own diagnostic was erased by a secondary :standard_error
failure (a torn-down IO device). This provides no verdict on the mutation: concurrent
workers are contending on shared singletons at startup — so it is kept out of the
score like any harness error, but it is recognised here so the engine stops
pointing at output that can't help (the real cause is unrecoverable) and retries
it harder (see Mutare.Runner).
timed_test/4 applies the --exit-status flag (test_argv/1), runs the mutant via
Mutare.Sandbox.Command.Invocation.timed_mix/4, and returns a typed
Mutare.Sandbox.Command.Result decoded via outcome/2.
Kill detection stops at the first failure
A mutant is killed the moment any test fails — the verdict is killed-vs-survived,
not which tests fail — so timed_test/4 also forces --max-failures 1. ExUnit
then stops scheduling tests at the first failure, which is a strict speedup on the
kill path (the common case for a healthy suite) and changes nothing else: the
exit-status path is driven solely by failures > 0 (so one failure still exits
Exit.failure/0 → :failed), and an all-pass survivor run never reaches the cap,
so it still runs the whole (selected) suite to confirm survival. This is the kill
path only — the baseline (Mutare.Runner.Baseline, a whole-suite green check and
the timing source) and the coverage probe (Mutare.Runner.CoverageProbe, which
must run every test to capture coverage) bypass timed_test/4 and are unaffected.
Summary
Functions
Decode an exit status refined by the run's output — the only place this contract looks past the exit code, and only to split one ambiguous case.
Build the mix test argv for a mutant kill-detection run from test_args.
Run the suite against mutant mutant_id, wall-clock-timed, and return a typed
Mutare.Sandbox.Command.Result.
Types
@type outcome() ::
:passed
| :failed
| :timeout
| :harness_error
| :suite_compile_error
| :atom_exhausted
| :app_start_failure
| :boot_failure
| :sigkilled
What a mix test mutant run did, decoded from its exit status (and, for the
last case, its output):
:passed— exit0: every test passed despite the mutation.:failed— exitExit.failure/0: a test failed (a clean ExUnit failure).:timeout— exitExit.timeout/0: the watcher self-halted an overrun.:harness_error— any other exit: the suite never ran to a verdict (a compile error, a missing dependency, a filesystem race, an OS signal). Not a statement about the mutation — the harness itself failed.:suite_compile_error— a refinement of:harness_error: the test suite failed to compile because the mutation broke code that runs at the test modules' compile time (aPlug.Routerroute macro calling a mutatedPlug.Router.Utilshelper, anEEx/use-time call, a compile-time@attrexpression…). The mutation was detected — the suite can't even build with it — so the runner counts it as a kill, not an infra failure (seeoutcome/2).:atom_exhausted— a refinement of:harness_error: the mutation made the program mint unbounded atoms and the BEAM aborted when the atom table filled. A resource-divergence like a timeout (the suite can never pass with it), so the runner counts it as a kill — seeoutcome/2andOutput.atom_exhausted?/1.:app_start_failure— a refinement of:harness_error: Mix refused to start the target's OTP application because the mutation broke project evaluation (Output.project_failure?/1), configuration (Output.config_failure?/1) or code reachable fromApplication.start/2(Output.app_start_failure?/1). The suite never ran, but the mutation was detected — the app can't even boot with it — so the runner counts it as a kill once the boot-contention retries are spent (seeoutcome/2andMutare.Runner.MutantRun).:boot_failure— a refinement of:harness_errorthat is still not a kill: the sandbox node died during boot and its own diagnostic was erased by a secondary:standard_errorfailure (Output.boot_failure?/1). A known- transient contention signature (concurrent workers stampeding shared services at startup), kept out of the score like any harness error but named so the runner messages it actionably and retries it harder.:sigkilled— exitExit.sigkill/0: the OS SIGKILLed the run. A harness error by verdict, but recognised so the runner never retries it: the dominant cause is the kernel OOM killer reaping a mutant made to allocate unboundedly, and re-running such a mutant re-detonates the same memory blowup on the host (seeMutare.Runnerand the:max_heap_mboption).
Functions
@spec outcome(non_neg_integer(), String.t()) :: outcome()
Decode an exit status refined by the run's output — the only place this contract looks past the exit code, and only to split one ambiguous case.
Exit 1 covers both a real harness failure (a missing dep, an infra compile
error) and a mutation that broke the test suite's own compilation. They are
indistinguishable by code, but they are not the same verdict: the latter means
the mutation was detected (the suite can't build with it), so it is a kill,
not an infra failure left out of the score.
We can tell them apart because the metamutant lib is compiled once before
any mutant runs (poison handled at baseline), so a fresh compilation error
during a per-mutant mix test can't come from the lib — it can only be a
re-evaluated .exs test file the mutation broke at load time. So when an
otherwise-:harness_error run's output reports a compilation error in a test
script (Output.suite_compile_error?/1), it is :suite_compile_error. A second
refinement recovers :atom_exhausted — a VM abort from the mutation minting
unbounded atoms (Output.atom_exhausted?/1), a detected resource-divergence. Both
are kills. So is a third — :app_start_failure (Output.app_start_failure?/1) —
where Mix refused to start the application because the mutation broke project evaluation
(Output.project_failure?/1), configuration (Output.config_failure?/1), or code
reachable from Application.start/2;
the baseline boots the same sandbox green,
so the mutation is what stopped it. A fourth — :boot_failure
(Output.boot_failure?/1) — stays a harness error but names a known-transient
boot-time contention crash, so the runner can message and retry it better.
Everything else (a lib-file compile error, a missing dep, no marker at all) stays
:harness_error — fail safe: an ambiguous failure is never a kill.
The two boot markers are ordered :boot_failure first, deliberately. A node that
died mid-boot with its own diagnostic erased is unambiguously infrastructure,
whatever Mix printed on the way down; a Could not start application banner on its
own is not. Startup contention that does reach Mix therefore still lands on
:app_start_failure — which is why Mutare.Runner.MutantRun spends the dedicated
boot-contention retry budget on it before recording the kill, rather than charging
the first attempt.
:sigkilled (exit Exit.sigkill/0) deliberately bypasses the output
refinements: a SIGKILLed run's output is truncated wherever the kill landed, so
matching output markers in it would be unreliable — and none of these markers'
causes exits via SIGKILL anyway (a compile error exits 1; atom exhaustion is
the VM aborting itself).
Build the mix test argv for a mutant kill-detection run from test_args.
Always prepends test --exit-status 101 --max-failures 1 plus the
boot-skip flags --no-compile --no-deps-check --no-archives-check:
--exit-status 101makes a clean test failure (a kill) distinguishable from a harness error — see the moduledoc.--max-failures 1stops ExUnit at the first failure, since one failing test is enough to declare a kill (also see the moduledoc).--no-compile --no-deps-check --no-archives-checkskip mix startup checks that are pure overhead under the one-compile invariant (the lib is built once, sources never change between runs) — see@boot_skip_flags.
test_args are the extra arguments ([] = whole suite, file-granular args
otherwise), appended last so file-granular selection stays at the tail. Pure, so
the contract is unit-testable without spawning mix.
@spec timed_test( Path.t(), [String.t()], Mutare.RuntimeId.t(), Mutare.Sandbox.Command.Invocation.run_opts() ) :: Mutare.Sandbox.Command.Result.t()
Run the suite against mutant mutant_id, wall-clock-timed, and return a typed
Mutare.Sandbox.Command.Result.
test_args are extra mix test arguments ([] = whole suite, file-granular
args otherwise); they are folded into the kill-detection argv by test_argv/1
(forcing --exit-status 101 and --max-failures 1). opts are the
run options of Mutare.Sandbox.Command.Invocation.mix/4 — :cap bounds an
overrun via the watcher, :max_heap_mb caps the heap, :schedulers trims the
run's scheduler threads, :partition carries the per-worker partition entry; []
sets none.