Describe the bug
With "test-runner": "mtp", mutants that are provably equivalent (no test can possibly detect them) are intermittently reported Killed when the code-under-test holds process-global state — e.g. any CSLA BusinessBase, whose validation rules are registered once per process and kept in a static cache.
Two independent levers each drive the false-kills to zero:
- running the same solution at
--concurrency 1, and
- removing the process-global state — an otherwise-identical stateless codebase never misattributes, even at ~8,600 mutants / 17 min.
This looks like a per-mutant isolation problem: the mtp shared test-server is reused across concurrently-evaluated mutants, so a genuine failure produced by one mutant is attributed to another — here, an equivalent one → false "Killed". Consequences: the mutation score is inflated (equivalent mutants counted as detected) and the per-mutant survivor set is not reproducible between identical runs.
Minimal repro: https://github.com/gem4511/stryker-mtp-isolation-repro — two runnable solutions (a CSLA case and a stateless control):
| solution |
process-global state |
concurrency |
provably-equivalent "canary" mutants reported Killed |
| CSLA (100 business objects) |
CSLA per-type rule cache |
default |
~9–18 of 200 (varies run-to-run) |
| CSLA |
same |
--concurrency 1 |
0 of 200 |
| stateless control (100 functions) |
none |
default |
0 of 200 |
| stateless, scaled ~8,600 mutants / 17 min |
none |
default |
0 of ~1,700 |
Each "canary" is a covered-but-unobservable mutation. In the CSLA case it is the removal of context.AddSuccessResult(true) from a single-rule success branch — equivalent because CSLA clears that rule's prior broken result on every re-run regardless. The README includes a one-command proof (delete every canary line, dotnet test → all 300 pass), so the canaries are demonstrably unkillable and any Killed count > 0 is a false positive.
Notes that rule out the obvious alternative explanations:
- Tests are stateless and run with
[assembly: CollectionBehavior(DisableTestParallelization = true)], so this is not intra-suite test parallelism.
- The repro sets
"additional-timeout": 30000, and the misattributed mutants are reported Killed, not Timeout — so this is not the "timeouts are scored as killed" behavior.
Expected behavior
Each mutant is evaluated in isolation. A mutant that no test can detect (equivalent) must be reported Survived, deterministically, regardless of --concurrency.
Logs
The repro reproduces in ~30 s, so running it directly may be easiest — but I'm happy to attach -L logs from a run if useful; just let me know.
Desktop (please complete the following information):
- OS: Windows 11
- Type of project: core (
net10.0)
- Framework Version: .NET SDK 10.0.301; xunit.v3 3.2.2; Microsoft.NET.Test.Sdk 18.5.1; runner
mtp
- Stryker Version: 4.16.0
Additional context
The CSLA half of the mechanism is documented, not guessed. CSLA registers each business-object type's rules once per process and caches them in static state — Rockford Lhotka: "Validation rules are loaded once per-AppDomain per business object type. They are kept in a static cache … they are never unloaded — that is intentional" (performance). In source, BusinessRuleManager holds a static Lazy<ConcurrentDictionary<…>> keyed by Type, and BusinessBase.InitializeBusinessRules() calls AddBusinessRules() exactly once per type under an Initialized-guarded double-checked lock. So CSLA's caching is intentional and thread-safe for registration; the isolation gap appears to be on the runner side — a shared test-server reused across concurrently-evaluated mutants. (Consistent with mtp not yet doing per-test coverage, i.e. the server being heavily shared.)
The precise leak across the shared server is the one inferred step; the CSLA static cache and the two levers (stateless → 0, --concurrency 1 → 0) are directly demonstrated in the repro. Happy to run any diagnostic you suggest.
Possible mitigation: per-mutant process isolation (avoid reusing one test-server across concurrently-evaluated mutants). CSLA exposes no public API to reset its rule cache, so cache-clearing is not a clean option. --concurrency 1 restores correct results today, at a large wall-clock cost.
Describe the bug
With
"test-runner": "mtp", mutants that are provably equivalent (no test can possibly detect them) are intermittently reported Killed when the code-under-test holds process-global state — e.g. any CSLABusinessBase, whose validation rules are registered once per process and kept in a static cache.Two independent levers each drive the false-kills to zero:
--concurrency 1, andThis looks like a per-mutant isolation problem: the
mtpshared test-server is reused across concurrently-evaluated mutants, so a genuine failure produced by one mutant is attributed to another — here, an equivalent one → false "Killed". Consequences: the mutation score is inflated (equivalent mutants counted as detected) and the per-mutant survivor set is not reproducible between identical runs.Minimal repro: https://github.com/gem4511/stryker-mtp-isolation-repro — two runnable solutions (a CSLA case and a stateless control):
--concurrency 1Each "canary" is a covered-but-unobservable mutation. In the CSLA case it is the removal of
context.AddSuccessResult(true)from a single-rule success branch — equivalent because CSLA clears that rule's prior broken result on every re-run regardless. The README includes a one-command proof (delete every canary line,dotnet test→ all 300 pass), so the canaries are demonstrably unkillable and any Killed count > 0 is a false positive.Notes that rule out the obvious alternative explanations:
[assembly: CollectionBehavior(DisableTestParallelization = true)], so this is not intra-suite test parallelism."additional-timeout": 30000, and the misattributed mutants are reportedKilled, notTimeout— so this is not the "timeouts are scored as killed" behavior.Expected behavior
Each mutant is evaluated in isolation. A mutant that no test can detect (equivalent) must be reported Survived, deterministically, regardless of
--concurrency.Logs
The repro reproduces in ~30 s, so running it directly may be easiest — but I'm happy to attach
-Llogs from a run if useful; just let me know.Desktop (please complete the following information):
net10.0)mtpAdditional context
The CSLA half of the mechanism is documented, not guessed. CSLA registers each business-object type's rules once per process and caches them in static state — Rockford Lhotka: "Validation rules are loaded once per-AppDomain per business object type. They are kept in a static cache … they are never unloaded — that is intentional" (performance). In source,
BusinessRuleManagerholds astatic Lazy<ConcurrentDictionary<…>>keyed byType, andBusinessBase.InitializeBusinessRules()callsAddBusinessRules()exactly once per type under anInitialized-guarded double-checked lock. So CSLA's caching is intentional and thread-safe for registration; the isolation gap appears to be on the runner side — a shared test-server reused across concurrently-evaluated mutants. (Consistent withmtpnot yet doing per-test coverage, i.e. the server being heavily shared.)The precise leak across the shared server is the one inferred step; the CSLA static cache and the two levers (stateless → 0,
--concurrency 1→ 0) are directly demonstrated in the repro. Happy to run any diagnostic you suggest.Possible mitigation: per-mutant process isolation (avoid reusing one test-server across concurrently-evaluated mutants). CSLA exposes no public API to reset its rule cache, so cache-clearing is not a clean option.
--concurrency 1restores correct results today, at a large wall-clock cost.