fix(mtp): run static mutants in a dedicated test-server process - #3695
Open
npnelson wants to merge 1 commit into
Open
fix(mtp): run static mutants in a dedicated test-server process#3695npnelson wants to merge 1 commit into
npnelson wants to merge 1 commit into
Conversation
The MTP runner reuses one test-host process per runner for all mutant sessions, switching the active mutant through the control file. Static state in the code under test initializes once per process, under whichever mutant is active when the type first loads. That is wrong in both directions: * False Killed: when the first session's mutant sits in a static initializer, the mutated value is baked into the host for its lifetime. The tests it breaks keep failing in every later session, so later mutants on that runner are reported Killed - including provably surviving ones and mutants in code no test executes. As MTP coverage currently assesses every mutant against every test (stryker-mutator#3629), one baked-in failure kills everything that follows. * False Survived: a static mutant tested later on a warm host can never activate, because its type is already initialized. This is the static-state cross-pollution risk called out in the stryker-mutator#3094 design discussion before the runner was built; the carve-out proposed there ("we know when mutants are in a static context... reusing the testrunner for all mutants except those that have to be isolated") was never implemented - the MTP path consults neither IsStaticValue nor MustBeTestedInIsolation. Minimal repro (verified on 4.16.0 and master; runnable copy with a self-checking script at https://gist.github.com/npnelson/0945e1a7d9bf5887c70d764a868005da): a classlib containing public static readonly Dictionary<string, int> Map = new() { ["A"] = 1, ["B"] = 2 }; public static bool IsPositive(int x) => x > 0; plus four xunit.v3 facts (Lookup("A") == 1, Lookup("B") == 2, IsPositive(5), !IsPositive(-5)), run with `dotnet stryker --test-runner mtp --concurrency 1`. The dictionary string mutants are tested first and poison the host, so the survivable `x > 0` -> `x >= 0` mutant (never observed at 0) is reported Killed: score 100.00%. The script then hand-applies that mutant and the full suite passes under plain `dotnet test`. With this fix it is reported Survived (score 75.00%, the exact truth) and the string mutants are still killed - genuinely, each in its own process where it can activate. On a larger project (46 xunit.v3 tests, 53 testable mutants, 8 in static initializers, per-mutant truth established by hand-applying each disputed mutant and running the suite): config before after default 90.57-92.45% (run-to-run) 84.91% --concurrency 1 98.11% (reproducible) 84.91% concurrency 1 + coverage off 100.00% (reproducible) 84.91% 84.91% is the hand-verified true score; after the fix every per-mutant status matches it and all configs agree across repeated runs. RPC traces confirmed the mechanism: before the fix, once a static mutant's session had run, every later runTests call on that host reported the same baseline failures regardless of the active mutant. The fix implements the stryker-mutator#3094 carve-out using flags Stryker already computes: mutants flagged IsStaticValue (set at compile time, so this also works with coverage-analysis off) or MustBeTestedInIsolation (set by coverage analysis) run in a dedicated server, one mutant per session - the file-based control channel activates a single id, so an isolated batch is split rather than silently run unmutated; if a split session crashes or times out, the merged batch result is flagged but carries empty test lists, because the executor re-analyzes flagged batches with the flags dropped - sibling results must not decide a crashed mutant's verdict, and empty lists route it into the single-mutant retry path instead. Servers reset before each run (the fresh host reads the mutant id at startup, so the mutation is active during static initialization, matching the VsTest runner's early-activation semantics) and after it, with the control file cleared first so a server started by any later code path cannot boot under a stale static id. Non-static mutants keep full process reuse. The unit suite and all five MTP integration categories pass with baselines unchanged; ResetServerAsync is virtual so the regression tests can observe reset ordering and the activated mutant id without real test hosts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR addresses a correctness flaw in the Microsoft Testing Platform (MTP) runner where reusing a single test-host process across mutant sessions can “poison” static initialization state, leading to false Killed or false Survived verdicts. The change introduces a carve-out so mutants that require early activation (static initializer context / MustBeTestedInIsolation) run in a fresh dedicated test-server process per mutant session, while keeping process reuse for the normal path.
Changes:
- Route mutants flagged
IsStaticValueorMustBeTestedInIsolationthrough a per-mutant isolated execution path that resets servers before/after each run and clears the mutant control file. - Split isolated batches to one-mutant-per-session (single-id control channel), and merge split results while preserving executor semantics for flagged sessions.
- Add unit-test coverage that asserts reset ordering, activated mutant id, and crash/timeout merge behavior; make
ResetServerAsyncvirtualto enable test seams.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/Stryker.TestRunner.MicrosoftTestPlatform/SingleMicrosoftTestPlatformRunner.cs | Adds “process isolation” execution path for static/isolated mutants (reset-before/after, control-file clearing) and merged-result behavior; makes ResetServerAsync virtual for testability. |
| src/Stryker.TestRunner.MicrosoftTestPlatform.UnitTest/SingleMicrosoftTestPlatformRunnerIsolationTests.cs | Adds regression tests validating isolated execution ordering, control-file activation/clearing, batch splitting, and crash/timeout merge semantics. |
Comment on lines
+149
to
+153
| /// the union: the executor re-analyzes every mutant of a flagged multi-mutant batch against the | ||
| /// returned lists with the session flags dropped, so a union would let one mutant's failures | ||
| /// (or a fully executed sibling run) overwrite the others' verdicts. Empty lists leave the | ||
| /// affected mutants Pending, which routes them into the executor's single-mutant retry path | ||
| /// where the flags are honored per mutant. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Sorry if there is too much AI slop in here. This is mostly Claude Fable's work with GPT 5.5 review. I think I understand most of what's going on here. Hopefully it fits in with the bigger picture and I'm not wasting your time.
The preview MTP runner reuses one test-host process per runner for all mutant sessions, switching the active mutant through the control file. Static state in the code under test initializes once per process — under whichever mutant is active when the type first loads — which breaks verdicts in both directions:
--concurrency 1and as run-to-run score wobble at default concurrency.This is the static-state cross-pollution risk raised in the #3094 design discussion before the runner was built (warning: "if you get rid of per-process isolation there is no isolation left, and you cannot guarantee that mutations to static state won't bleed into next iterations"), together with the intended answer (proposed carve-out: "we know when mutants are in a static context. We can offer different levels of isolation with the least isolated method reusing the testrunner for all mutants except those that have to be isolated"). The MTP path currently consults neither
IsStaticValuenorMustBeTestedInIsolation; this PR implements that carve-out.Reproduction
Self-checking ~30-line repro (script + sources): https://gist.github.com/npnelson/0945e1a7d9bf5887c70d764a868005da — one static dictionary,
IsPositive(x) => x > 0, four facts, none probing 0. Output on 4.16.0:On a larger project (46 xunit.v3 tests, 53 testable mutants, 8 in static initializers; per-mutant ground truth established by hand-applying each disputed mutant and running the plain suite):
84.91% is the hand-verified true score; with the fix every per-mutant status matches it, bit-identically across configs and repeated runs. RPC traces confirmed the mechanism: before the fix, once a static mutant's session had run, every later
runTestscall on that host reported the same baseline failures regardless of the active mutant.What the fix does
Mutants flagged
IsStaticValue(set at compile time by mutation placement, so this also works withcoverage-analysis: off) orMustBeTestedInIsolation(set by coverage analysis) run one-per-session in a dedicated test-server process:Validation
Stryker.vstest.slnfunit suites: 1,826 passed. MTP unit project: 202 passed, including 11 new regression tests that observe reset ordering and the activated mutant id through the real control file (crash and timeout flavors of the split-batch merge each covered).VERDICT: no bug(75.00%, id 3 Survived).Notes for reviewers
ResetServerAsyncbecamevirtualas the regression-test seam.MustBeTestedInIsolationgets set more completely and more of this class is routed into isolation.TestMultipleMutantsAsync_RegularBatch_CurrentlyRunsUnmutatedis a deliberate canary, not an endorsement: real per-test coverage is exactly what makes disjoint assessing sets — and therefore real multi-mutant batches — possible, and the single-id channel would report every batched mutant falsely Survived. The test's comment carries instructions for both futures; static-containing batches are already handled here by splitting.TargetProjectwould ripple through the exact-count baselines of many categories; a dedicated category looks like the clean home for such a fixture if maintainers want one. Until then, the gist's self-checking repro covers the end-to-end behavior (it printsVERDICT: no bugagainst a fixed build).Related