Fix [ClassCleanup] leak when ITestFilter drops the last test of an initialized class#9503
Conversation
There was a problem hiding this comment.
Pull request overview
Fixes a regression in the MSTest adapter’s new ITestFilter “drop/skip before type-load” path where [ClassCleanup] could be skipped when the last-in-order test of an already-initialized class was filtered out (common in per-worker sharding scenarios).
Changes:
- Update
UnitTestRunner.FinishFilteredOutTestAsyncto execute[ClassCleanup]when the filtered-out test is the last test in the class and the class was previously initialized in the current worker. - Add an acceptance test that reproduces the leak (drop last-in-order test of a partially-run class) and verifies no over-correction for fully-dropped classes.
Show a summary per file
| File | Description |
|---|---|
| test/IntegrationTests/MSTest.Acceptance.IntegrationTests/TestFilterClassCleanupTests.cs | Adds an acceptance regression test covering the “last-in-order test dropped” class-cleanup leak and the “fully dropped class” non-init/non-cleanup guard. |
| src/Adapter/MSTestAdapter.PlatformServices/Execution/UnitTestRunner.cs | Runs class cleanup from the filter drop/skip tail path when the class was initialized earlier in the worker and the dropped/skipped test is the last test in its class. |
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0
- Review effort level: Low
🧪 Test quality grade — PR #9503
This advisory comment was generated automatically. Grades are heuristic
|
Evangelink
left a comment
There was a problem hiding this comment.
Note
🤖 Automated review by GitHub Copilot. Posted via a maintainer's GitHub token, so it appears under their account — the account owner did not write or approve this content personally. Generated by the Expert Code Review workflow. To request a follow-up action, reply by tagging @copilot directly.
| # | Dimension | Verdict |
|---|---|---|
| 13 | Test Completeness & Coverage | 🟡 1 MODERATE |
| 16 | Naming & Conventions | 💬 1 NIT |
| 17 | Documentation Accuracy | 💬 1 NIT |
✅ 19/22 dimensions clean.
- Test Completeness — the
cleanupResult is not nullbranch (cleanup fails in the drop path) has no acceptance-test coverage;AssociatedUnitTestElementattribution is unvalidated - Naming — the
Test_A_Run/Test_Z_Droppedalphabetical-ordering assumption is undocumented - Documentation — successful
[ClassCleanup]output is silently discarded whenfilterResultis empty; no comment explains this
Overall assessment: The fix is algorithmically correct and well-reasoned. The _lastRunnableTestByClass sentinel is the right signal: it's populated only when both conditions are true — the class has an executable cleanup method and at least one non-filtered test ran in this worker — so a fully-dropped class is still correctly skipped. The ExecuteClassCleanupAsync semaphore and IsClassCleanupExecuted flag in TestClassInfo prevent any risk of double execution even under concurrent test scheduling. Thread safety is maintained via ConcurrentDictionary.TryGetValue. The ConfigureAwait(false) and try/finally disposal patterns mirror the surrounding code correctly. The three items above are polish suggestions; none blocks the merge.
…alized class is dropped FinishFilteredOutTestAsync assumed a filtered-out test never loaded its type and therefore skipped class cleanup. That is false when a sibling test of the same class already ran in this worker: [ClassInitialize] executed and [ClassCleanup] is still owed. Because ClassCleanupManager counts down over the full (pre-filter) test set, isLastTestInClass can land on a dropped test, leaking the class cleanup whenever a class's last-in-order test is filtered out. Fix: when the dropped test is the last in its class, run [ClassCleanup] if the class was initialized in this worker (detected via _lastRunnableTestByClass, which is only populated for classes that have an executable cleanup method and ran a non-filtered test). The TestClassInfo is already cached, so no new type is loaded. Adds an acceptance test reproducing the leak. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
7c1cdff to
dc17bcd
Compare
Summary
Targets
dev/amauryleve/custom-test-filter(#8896). Fixes a[ClassCleanup]leak in the newITestFilterdrop path, reported by an internal team using per-worker test sharding (each worker enumerates the full set per class, runs its subset, andDrops the rest).The bug
UnitTestRunner.FinishFilteredOutTestAsyncassumed a filtered-out test never loaded its type and therefore skipped class cleanup, only callingMarkClassComplete. That assumption is false when a sibling test of the same class already ran in this worker:[ClassInitialize]executed and[ClassCleanup]is still owed.Because
ClassCleanupManagercounts down over the full (pre-filter) test set,isLastTestInClassis independent of run-vs-dropped. So whenever a class's last-in-order test is dropped — while earlier tests of that class ran — the worker leaks the class cleanup.ForceCleanupis not a safety net (it only fires onIsGracefulStopRequested).The analogous assembly-cleanup asymmetry was already guarded by
_assemblyInitializeWasExecuted, which is why only class cleanup leaked.The fix
When the dropped test is the last in its class, run
[ClassCleanup]if the class was initialized in this worker. Detection reuses the existing_lastRunnableTestByClassmap, which is populated only for classes that both have an executable cleanup method and ran a non-filtered test. TheTestClassInfois already cached in theTypeCache, so resolving it loads no new type — preserving the feature's "aDroppays zero type-load / init cost" guarantee. A fully-dropped class (type never loaded) still correctly skips both init and cleanup.Validation
TestFilterClassCleanupTestsreproducing the leak: a filter drops the last-in-order test of an initialized class and the only test of a fully-dropped class.ClassInitialize+ the run test but noClassCleanup, exactly the reported symptom.MSTestAdapter.PlatformServices.UnitTestsgreen across net462/net48/net8.0/net9.0/net8.0-windows.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com