Fix async TestInitialize/TestCleanup causing COMException in WinUI UITestMethod#8119
Conversation
|
@copilot Let's disable the lean agentic workflow |
…ation Co-authored-by: Evangelink <[email protected]>
Could you clarify what you'd like me to disable? I see the
Please let me know the specific change you'd like made. |
There was a problem hiding this comment.
Pull request overview
Fixes a threading bug in MSTestAdapter’s async execution pipeline where ConfigureAwait(false) caused the continuation after an async TestInitialize to resume on a thread pool thread, losing the UI-thread SynchronizationContext required by WinUI [UITestMethod] scenarios (leading to COMExceptions when creating WinUI objects).
Changes:
- Preserve the active
SynchronizationContextinTestMethodInfo.ExecuteInternalAsyncby removingConfigureAwait(false)from theTestInitializeand test-method invocation awaits. - Add a unit test that simulates UI-thread dispatch with a dedicated-thread
SynchronizationContextand verifies the context is still present inside the test method after an asyncTestInitialize.
Show a summary per file
| File | Description |
|---|---|
src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs |
Removes ConfigureAwait(false) in key awaits to keep the UI synchronization context across async initialization and invocation. |
test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs |
Adds regression test and a dedicated-thread SynchronizationContext helper to validate context preservation. |
Copilot's findings
- Files reviewed: 2/2 changed files
- Comments generated: 2
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Test Expert Reviewer 🧪
Date: 2026-05-11
Repository: microsoft/testfx
Key Findings
The new test TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationContextForTestMethod correctly simulates the WinUI UI-thread scenario and verifies the production fix. The SingleThreadedSynchronizationContextForTesting helper is well-structured and properly disposed. However, three reliability gaps were found:
-
[Flakiness – Hang Risk]
await tcs.Taskhas no timeout. If the sync context's dedicated thread dies before setting the TCS (e.g., due to a synchronous exception in the callback), the test hangs indefinitely and blocks the CI run. Add.WaitAsync(TimeSpan.FromSeconds(30)). -
[Flakiness – Silent Thread Death]
SingleThreadedSynchronizationContextForTesting's thread loop callsd(state)without exception handling. An uncaught exception kills the thread silently, causing the TCS to never complete. The loop should catch and re-surface exceptions so the test fails clearly rather than hanging. -
[Coverage] The PR title and description mention fixing both
TestInitializeandTestCleanup, but only theTestInitialize → TestMethodexecution path is tested. TheTestMethod → TestCleanuppath is unverified — a regression in TestCleanup's context preservation would go undetected.
Recommendations
- Wrap
await tcs.TaskwithWaitAsync(TimeSpan.FromSeconds(30))to bound CI hang risk. - Add exception handling around
d(state)inSingleThreadedSynchronizationContextForTesting(or in the callback passed toPost) so any synchronous throw propagates to the TCS. - Add a sibling test that verifies
SynchronizationContext.Currentis preserved inside an asyncTestCleanup.
Generated by Test Expert Reviewer
🧪 Test quality reviewed by Test Expert Reviewer 🧪
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Expert Code Reviewer 🧠
Date: 2026-05-11
Repository: microsoft/testfx
Key Findings
[Correctness] Comment overstates TestCleanup context preservation (inline comment)
The added comment at line 121 claims "the test method and TestCleanup run on the same thread as the SynchronizationContext." However, RunTestCleanupMethodAsync internally uses .ConfigureAwait(false) on InvokeCleanupMethodAsync, meaning async portions of TestCleanup continue on the thread pool, not on the UI thread. A user relying on this guarantee for an async [TestCleanup] that creates WinUI objects would still get a COMException. The comment should be narrowed to what is actually guaranteed: that the test method begins executing on the UI thread.
[Threading] GlobalTestInitializations loop still uses ConfigureAwait(false) (not in diff)
In ExecuteInternalAsync (~line 87, outside the diff), the loop:
foreach ((MethodInfo method, TimeoutInfo? timeoutInfo) in Parent.Parent.GlobalTestInitializations)
{
await InvokeGlobalInitializeMethodAsync(method, timeoutInfo, timeoutTokenSource).ConfigureAwait(false);
}runs before the now-fixed code. If any GlobalTestInitializations entry is async, .ConfigureAwait(false) discards the DispatcherQueueSynchronizationContext at that point — making the fix for RunTestInitializeMethodAsync a no-op. For WinUI test classes that define async assembly-level or class-level fixture methods, the fix silently does nothing.
Recommendation: Either remove ConfigureAwait(false) from this loop (consistent with the fix) or document that the fix only applies when GlobalTestInitializations is empty or synchronous.
[Correctness] Fix applies only to the _executionContext is null branch
When _executionContext is non-null (tests with non-cooperative timeout fixtures), the else branch uses ExecutionContextHelpers.RunOnContext(_executionContext, async () => { ... await invokeResult.ConfigureAwait(false); }), which was not changed. This means the SynchronizationContext is still lost for the test method body in that scenario. This is a pre-existing limitation but is worth documenting so future maintainers understand the scope.
Positive Observations
- The core fix is mechanically correct: removing
ConfigureAwait(false)fromawait RunTestInitializeMethodAsync(...)inExecuteInternalAsynccorrectly causes the continuation to post back to the capturedSynchronizationContext(theDispatcherQueueSynchronizationContext), restoring the UI-thread semantics for the test method body. - The
SynchronizationContextPreservingTask/SynchronizationContextPreservingAsyncTaskMethodBuilderdesign is well-understood and complements this fix correctly (handling the synchronous-completion case where the BCL would otherwise restore the old context). - The
#pragma warning disable CA2007with clear justification is the right approach for a deliberate deviation from theConfigureAwait(false)norm. - The new test is a well-structured simulation of the WinUI dispatch scenario and effectively validates the fix for the primary reported case.
🧠 Reviewed by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
The test was using _testMethodInfo which has a 1-hour timeout set, causing IsTimeoutSet to be true. This routes through ExecuteInternalWithTimeoutAsync's non-cooperative path which runs ExecuteInternalAsync on a separate thread pool thread where SynchronizationContext.Current is null. Create a local TestMethodInfo without a timeout so the test goes through the direct ExecuteInternalAsync path on the SC's thread, which is the code path the production fix targets.
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Expert Code Reviewer 🧠
Date: 2026-05-11
Repository: microsoft/testfx
New commit (576c046) — Follow-up review
Evangelink's latest commit correctly addresses the test routing problem identified after the previous review: the original test used _testMethodInfo (which has a 1-hour timeout set), causing IsTimeoutSet = true and routing through the non-cooperative timeout path where ExecuteInternalAsync runs on a plain thread-pool thread with SynchronizationContext.Current == null. The fix creates a fresh TestMethodInfo without a timeout, correctly routing through the _executionContext is null branch that the production fix targets.
Key Findings
[Correctness] ContinueWith missing IsCanceled handler (inline comment above)
The else { tcs.SetResult(t.Result); } branch throws AggregateException if the antecedent task is cancelled, because the exception escapes into the discarded continuation task, leaving tcs permanently unset and the test hanging. A explicit else if (t.IsCanceled) { tcs.SetCanceled(); } arm is needed.
Still Outstanding from Prior Review
GlobalTestInitializationsloop (ConfigureAwait(false)at line ~81): For WinUI classes with async assembly/class-level fixtures, the fix is still silently ineffective. Either document the limitation or apply the same treatment to that loop.- Comment accuracy (line 121): The comment claims
TestCleanupalso runs on the SynchronizationContext thread, butRunTestCleanupMethodAsyncinternally usesConfigureAwait(false), so asyncTestCleanupcontinuations still run on the thread pool.
Positive Observations
- The routing fix is mechanically correct and the test now exercises the exact code path that was patched.
SingleThreadedSynchronizationContextForTestingfaithfully mirrorsDispatcherQueuesemantics and its lifecycle (CompleteAdding → Join → Dispose) is sound.- The
_fire-and-forget pattern insidePostis correct becauseInvokeAsyncis designed to never throw synchronously.
🧠 Reviewed by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Test Expert Reviewer 🧪
Date: 2026-05-11
Repository: microsoft/testfx
Re-review after commit 576c0466
Evangelink's latest commit correctly addresses the structural flaw flagged in the previous review: the test was using _testMethodInfo (which has a 1-hour timeout), routing through ExecuteInternalWithTimeoutAsync's non-cooperative path on a thread-pool thread where SynchronizationContext.Current is null. The new no-timeout TestMethodInfo ensures the test exercises the exact code path the production fix targets. This is a meaningful improvement.
Remaining Open Issues (from previous review, still unresolved)
The three inline comments posted in the previous run are still unresolved:
-
[Flakiness – Hang Risk]
await tcs.Task(line ~703) has no timeout. If the sync context's thread dies before completing the TCS — e.g., due to a synchronous exception in thePostcallback — the test hangs indefinitely and blocks the CI run. Fix:await tcs.Task.WaitAsync(TimeSpan.FromSeconds(30)). -
[Flakiness – Silent Thread Death] The
SingleThreadedSynchronizationContextForTestingthread loop callsd(state)without a try/catch (line ~1687). Any synchronous exception kills the thread silently, making the TCS unreachable and confirming issue #1 (hang). Fix: wrapd(state)in try/catch that callstcs.SetExceptionor at minimum swallows to keep the thread alive. -
[Coverage] The PR title says "Fix async TestInitialize/TestCleanup..." but only the
TestInitialize → TestMethodpath is tested. TheTestMethod → TestCleanupcontext-preservation path is unverified. A regression in TestCleanup context handling would go undetected.
Recommendations
- Add
.WaitAsync(TimeSpan.FromSeconds(30))to bound CI hang risk. - Add a try/catch around
d(state)in the helper's thread loop. - Add a sibling test verifying
SynchronizationContext.Currentinside an asyncTestCleanup.
Generated by Test Expert Reviewer 🧪
🧪 Test quality reviewed by Test Expert Reviewer 🧪
🧪 Test quality reviewed by Test Expert Reviewer 🧪
|
@copilot address review comments |
…handling, TestCleanup test, and fix comment accuracy Co-authored-by: Evangelink <[email protected]>
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Expert Code Reviewer 🧠
Date: 2026-05-11
Repository: microsoft/testfx
Key Findings
The fix is correct and complete for the stated scenario. No blocking issues found.
Correctness — ✅ Sound
The root cause analysis is accurate. ConfigureAwait(false) on await RunTestInitializeMethodAsync(...) discarded the captured DispatcherQueueSynchronizationContext, causing the test-method body to resume on the thread pool. Switching to ConfigureAwait(true) re-enables context capture so the continuation is posted back to the UI thread.
The interaction with SynchronizationContextPreservingAsyncTaskMethodBuilder is critical and correct: its Start() override skips BCL's AsyncMethodBuilderCore.Start which normally saves/restores the SynchronizationContext. This means that even when RunTestInitializeMethodAsync internally uses ConfigureAwait(false) for every inner await, the outer ConfigureAwait(true) on the SynchronizationContextPreservingTask<bool> returned to ExecuteInternalAsync correctly captures and posts the continuation to the UI context.
The _executionContext is not null path (when [TestClassInitialize] ran) is also correctly handled: ExecutionContextHelpers.RunOnContext uses ExecutionContext.Run on the calling thread (the UI thread after ConfigureAwait(true) returned), so the test method starts on the UI thread in that path too.
For the cleanup test: RunTestCleanupMethodAsync also uses SynchronizationContextPreservingAsyncTaskMethodBuilder, so its Start() executes synchronously on the calling thread (UI thread). InvokeCleanupMethodAsync likewise starts synchronously on the UI thread, meaning the cleanup body's opening code always runs with the UI SynchronizationContext set — confirming the test assertion is valid.
Threading — ✅ No regressions
When SynchronizationContext.Current is null (all standard, non-WinUI tests), ConfigureAwait(true) behaves identically to ConfigureAwait(false) — both schedule continuations on the default ThreadPool. No performance or behavioral regression.
Noted Pre-existing Limitation (out of scope for this PR)
The foreach loop over Parent.Parent.GlobalTestInitializations immediately above the fixed code uses .ConfigureAwait(false). If a WinUI project has any async global test initializations ([AssemblyInitialize]-level), the UI SynchronizationContext would be captured as null before the fixed code is reached, making ConfigureAwait(true) a no-op for those tests. This is pre-existing and not introduced or worsened by this PR — the common WinUI test scenario without global initializations is fully fixed.
Test Quality — ✅ Well-structured
SingleThreadedSynchronizationContextForTesting correctly models a DispatcherQueueSynchronizationContext: a BlockingCollection driven by a dedicated background thread with Post() correctly enqueuing work. Timeout handling in RunInvokeAsyncOnContextAsync (30 s CancellationTokenSource + Task.WhenAny) is safe — a timeout causes an immediate assertion failure before await tcs.Task is reached, and Dispose() gracefully handles the stuck thread via _thread.Join(5s).
Recommendations
No changes required. The stated bug is fixed correctly and tested appropriately.
Generated by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
|
@copilot resolve the merge conflicts in this pull request |
|
/backport to rel/4.2 |
|
Started backporting to rel/4.2: https://github.com/microsoft/testfx/actions/runs/25719974018 |
|
@Evangelink backporting to rel/4.2 failed, the patch most likely resulted in conflicts: $ git am --3way --empty=keep --ignore-whitespace --keep-non-patch changes.patch
Creating an empty commit: Initial plan
Applying: Add unit test for async TestInitialize SynchronizationContext preservation
Using index info to reconstruct a base tree...
A src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs
M test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs
Falling back to patching base and 3-way merge...
CONFLICT (modify/delete): src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs deleted in HEAD and modified in Add unit test for async TestInitialize SynchronizationContext preservation. Version Add unit test for async TestInitialize SynchronizationContext preservation of src/Adapter/MSTestAdapter.PlatformServices/Execution/TestMethodInfo.Execution.cs left in tree.
Auto-merging test/UnitTests/MSTestAdapter.PlatformServices.UnitTests/Execution/TestMethodInfoTests.cs
error: Failed to merge in the changes.
hint: Use 'git am --show-current-patch=diff' to see the failed patch
hint: When you have resolved this problem, run "git am --continue".
hint: If you prefer to skip this patch, run "git am --skip" instead.
hint: To restore the original branch and stop patching, run "git am --abort".
hint: Disable this message with "git config set advice.mergeConflict false"
Patch failed at 0002 Add unit test for async TestInitialize SynchronizationContext preservation
Error: The process '/usr/bin/git' failed with exit code 128NOTE: A PR will be created, but needs to be revised manually! |
Resolved conflict in DesktopTestSourceHostTests.cs by accepting the main branch's fix using (IRunSettings?)null instead of null! for better null handling. Co-authored-by: Evangelink <[email protected]>
Youssef1313
left a comment
There was a problem hiding this comment.
Small suggestion on comment so that it's easier to read and more correct.
I don't see any immediate issues with the change, but be aware that it's risky and might (or might not) introduce unexpected regressions.
…dInfo.Execution.cs Co-authored-by: Youssef Fahmy <[email protected]>
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Test Expert Reviewer 🧪
Date: 2026-05-12
Repository: microsoft/testfx
Key Findings
No new test issues found. The test file (TestMethodInfoTests.cs) has not changed since the previous Test Expert review at 2026-05-11T21:35:06Z. The only PR-specific commit since then is a production-code comment update (863563e) co-authored by Youssef Fahmy — no test code was affected.
The two new tests added in this PR remain well-structured:
TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationContextForTestMethod— correctly exercises the fixed code path using aSingleThreadedSynchronizationContextForTestingthat mirrorsDispatcherQueueSynchronizationContextsemantics. The assertioncapturedContextInTestMethod.Should().BeSameAs(syncContext)directly verifies the bug is fixed.TestMethodInfo_AsyncTestCleanup_PreservesSynchronizationContextForTestCleanup— verifies the analogous cleanup scenario.
Infrastructure quality:
using var syncContextensures reliable disposal- 30-second
CancellationTokenSource+Task.WhenAnyprevents CI hangs without blocking indefinitely BlockingCollectionbackground thread usesIsBackground = trueas a safety netTaskCreationOptions.RunContinuationsAsynchronouslyon theTaskCompletionSourceprevents the context thread from being blocked by synchronous continuations- Static
DummyTestClasshook fields are set before use in each test, consistent with the established convention in this file
Recommendations
None. The tests accurately verify the production fix and guard against regression.
Generated by Test Expert Reviewer 🧪
🧪 Test quality reviewed by Test Expert Reviewer 🧪
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Expert Code Reviewer 🧠
Date: 2026-05-12
Repository: microsoft/testfx
Key Findings
The core fix — changing ConfigureAwait(false) to ConfigureAwait(true) on await RunTestInitializeMethodAsync(...) (line 122) and await invokeResult (line 129) — is correct and safe for the common case:
- When
SynchronizationContext.Currentisnull(thread-pool tests),ConfigureAwait(true)is a no-op, so there's no behavioral change or performance impact for the vast majority of tests. - When
SynchronizationContext.Currentis the WinUIDispatcherQueueSynchronizationContext, continuations are correctly posted back to the UI thread, so both the test method body and (via the call chain) TestCleanup start on the UI thread. - The
SynchronizationContextPreservingAsyncTaskMethodBuildercorrectly avoids the BCL's sync-context save/restore inStart(), allowing the sync context set by aUITestMethoddispatcher to flow into the test lifecycle without being silently discarded.
Threading — Incomplete fix for the _executionContext is not null path
await tcs.Task.ConfigureAwait(false) at line 164 (the else branch for non-null _executionContext) was not changed. After that await, result.Outcome = UnitTestOutcome.Passed and RunTestCleanupMethodAsync (line 226) execute on a thread-pool thread. A WinUI UITestMethod where any fixture in the hierarchy used a non-cooperative [Timeout] — which sets _executionContext via CaptureExecutionContextAfterFixtureIfNeeded — would still deliver TestCleanup on the wrong thread. The two new tests only exercise the _executionContext is null path.
Positive observations
- The
SingleThreadedSynchronizationContextForTestinghelper is a well-designed, realistic proxy forDispatcherQueueSynchronizationContext. TheBlockingCollection-based message loop, background-thread flag, andCompleteAdding-before-Jointeardown are all correct. RunInvokeAsyncOnContextAsynccorrectly usesTaskCreationOptions.RunContinuationsAsynchronouslyandTaskScheduler.Defaultfor theContinueWithso TCS completion can't re-enter the single-threaded loop.- The 30-second watchdog timer prevents CI hangs if the single-threaded context stalls.
Recommendations
- Change
await tcs.Task.ConfigureAwait(false)→ConfigureAwait(true)at line 164 to align the_executionContext is not nullpath with the fix applied to thenullpath. - Add a third test that sets
_executionContexton theTestMethodInfo(simulating a prior non-cooperative fixture) to cover this code branch.
Generated by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
Bug Fix
What was the bug?
When
[UITestMethod]dispatches a test to the UI thread viaDispatcherQueue.TryEnqueue, theDispatcherQueueSynchronizationContextis set asSynchronizationContext.Current. However, if[TestInitialize]is async (e.g.,await Task.Delay(...)), the continuation afterawait RunTestInitializeMethodAsync(...).ConfigureAwait(false)resumes on a thread pool thread, discarding the UI-thread synchronization context. The test method then runs on the wrong thread, causing aCOMExceptionwhen creating WinUI objects likeGrid.How did you fix it?
Removed
ConfigureAwait(false)from two awaits inExecuteInternalAsync:This is safe for the non-WinUI case: when
SynchronizationContext.Currentisnull(standard thread pool threads), removingConfigureAwait(false)has no behavioral effect.Testing
Added comprehensive tests to
TestMethodInfoTests.cs:TestMethodInfo_AsyncTestInitialize_PreservesSynchronizationContextForTestMethod: Verifies thatSynchronizationContext.Currentinside the test method matches the original context even after an asyncTestInitializethat yields viaTask.Delay.TestMethodInfo_AsyncTestCleanup_PreservesSynchronizationContextForTestCleanup: Verifies thatSynchronizationContext.Currentat the start of an asyncTestCleanupmatches the original context.Both tests use a
SingleThreadedSynchronizationContextForTesting(aSynchronizationContextbacked by a dedicated thread, mirroringDispatcherQueuesemantics) with robust timeout and exception handling to prevent CI hangs.Original prompt
Created from VS Code.