You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Bug: eliminate headless cross-thread flake by deleting custom Avalonia test harness and using stock [AvaloniaFact] (fixes cascade via Avalonia 12.1.0) #1101
The intermittent headless cross-thread flake (System.InvalidOperationException: The calling thread cannot access this object because a different thread owns it from DefaultRenderLoop.Add → Dispatcher.VerifyAccess, followed by a cascade of HeadlessUnitTestSession queue processor crashed before this test was dispatched across ~149–160 tests) is fully diagnosed and now fixable by deletion, not by more mitigation.
Two things changed the calculus:
The cascade is already fixed upstream, in the Avalonia version we ship. Avalonia 12.1.0 (pinned in features/Directory.Packages.props) contains commit 9448ef8a91 — "Fix hang if exception is thrown during headless session app construction" (Fix hang if exception is thrown during headless session app construction AvaloniaUI/Avalonia#21688) — which wraps the app-construction call in HeadlessUnitTestSession.DispatchCore in a try/catch so a construction failure fails only that one test and the dispatch loop survives. That is exactly the cascade our custom reflection safety nets were built to catch.
Our custom harness is a net contributor to the root-cause race.PhantomAvaloniaFact runs each test body on a second thread (StaTaskScheduler) that is distinct from the session's single dispatch thread. That second thread is what opens the window for DefaultRenderLoop to be constructed on a thread other than the one that later owns Dispatcher.UIThread.
The fix is to delete the custom harness entirely and use the stock, now-fixed Avalonia harness, run the headless assemblies non-parallel, and prefer PerTest isolation. This removes the crash surface and the cascade surface deterministically, and deletes a large amount of reflection-based code that reaches into Avalonia internals.
This issue supersedes the residual-variant framing of #815/#1012: the residual variant is resolved by the upstream fix plus removal of our own second-thread harness.
Root Cause
Mechanism of the crash.HeadlessUnitTestSession.DispatchCore (Avalonia.Headless) runs every dispatched test on a single background dispatch thread (_dispatchTask), which is where Dispatcher.UIThread is owned. On that thread, EnsureIsolatedApplication / EnsureSharedApplication calls AppBuilder.SetupUnsafe() → AvaloniaHeadlessPlatform.Initialize → constructs ServerCompositor + DefaultRenderLoop. DefaultRenderLoop.Add calls Dispatcher.VerifyAccess(). If the render loop is constructed on, or first touched from, a thread other than the one that owns Dispatcher.UIThread, VerifyAccess() throws the cross-thread InvalidOperationException.
Why our harness makes this happen.Phantom.Workspaces.Testing.Gui\PhantomAvaloniaFact.cs does not run tests on the session's dispatch thread. Instead:
It runs the inner AvaloniaTestCase on a per-assembly StaTaskScheduler thread (PhantomAvaloniaFact.cs:104-119, scheduler defined :311-357).
It separately calls HeadlessUnitTestSession.GetOrStartForAssembly(assembly) on whatever thread xUnit invoked Run on (PhantomAvaloniaFact.cs:151-153).
The result is two threads interacting with a single shared session and its DefaultRenderLoop: the session's own dispatch thread and our StaTaskScheduler thread. Under parallel discovery/load, construction/first-touch of the render loop can land on a different thread than the one that ends up owning Dispatcher.UIThread → the cross-thread throw. Because the throw historically happened outside the per-dispatch try in Avalonia's loop, it faulted _dispatchTask, abandoning every queued test → the "queue processor crashed" cascade.
What upstream fixed (already in 12.1.0). In HeadlessUnitTestSession.DispatchCore, app construction was hoisted into a guarded block:
IDisposableapplication=null!;try{application=_isolated?EnsureIsolatedApplication():EnsureSharedApplication();}catch(Exceptionex){tcs.TrySetException(ex);return;// exit this action; keep the dispatcher loop alive}
a4bfae1c29 — Headless AvaloniaTestIsolationLevel AvaloniaUI/Avalonia#20000, "Headless AvaloniaTestIsolationLevel" (in 12.1.0). AvaloniaTestIsolationLevel.PerTest/PerAssembly is now a first-class, supported feature — the thing we hacked in via a custom attribute.
Containment verified against the local Avalonia clone: git merge-base --is-ancestor 9448ef8a91 12.1.0 → in 12.1.0; git tag --contains 9448ef8a91 → 12.1.0.
Net: with 12.1.0, the cascade is impossible from a construction failure, and running all work on the session's single dispatch thread (i.e. using the stock harness with no second StaTaskScheduler) removes the cross-thread construction window itself. Avalonia's own docs additionally state that with shared-application isolation, concurrent test execution is not supported — so parallelism must be disabled for these assemblies regardless of isolation level.
Delete. Custom discoverer + StaTaskScheduler second thread + _dispatchTask/_cancellationTokenSource reflection safety nets. The second thread is a root-cause contributor; the safety nets are superseded by Avalonia#21688.
Single [PhantomAvaloniaStaFact(Timeout = 15_000)] usage (MainWindow_ContentLevelDocumentTabStrip_HasHeaderTemplate_AfterTabOpened). Retarget to [AvaloniaFact(Timeout = 15_000)]; it is a headless render test, not a native-WebView test.
All headless test sources (Agent.Gui.Tests, Gui.Shared.Tests, Tests)
No change. Native WebView tests already use a real Win32 app on a dedicated STA thread via ICollectionFixture; they are unaffected by #1101 and remain the pattern for native-control tests.
Design / Fix
Chosen fix — delete the custom harness; use the stock, fixed Avalonia harness; serialize; prefer PerTest.
Replace all [PhantomAvaloniaFact] (873) and the single [PhantomAvaloniaStaFact] with stock [AvaloniaFact] (and [AvaloniaTheory] where a theory equivalent is needed). With the stock harness, every test runs on the session's single dispatch thread where Dispatcher.UIThread is owned — the cross-thread construction window cannot arise from a second thread.
Delete PhantomAvaloniaFact.cs, PhantomAvaloniaStaFact.cs, and (if unreferenced) SingleThreadPump.cs. The reflection safety nets they contain are superseded by Avalonia#21688 (construction failures no longer cascade) and #21223 (clean UI-thread hand-off between tests).
Avalonia documents that shared-isolation execution is not concurrency-safe; serialization removes the last load-driven trigger and is required for reliability.
Prefer PerTest isolation (Avalonia's supported default) for all three assemblies — remove the [assembly: AvaloniaTestIsolation(PerAssembly)] from Agent.Gui.Tests. PerTest gives full per-test isolation and, crucially, means a failure can never be shared/cascaded across a batch. PerAssembly may be retained only if a measured runtime regression is unacceptable and the stress gate below stays green; reliability is the deciding requirement.
Keep native WebView tests on the existing WebViewAppFixture collection-fixture pattern. Do not introduce Xunit.StaFact/[UIFact]: those provide an STA thread + pumping SynchronizationContext but no Avalonia Dispatcher/Application, so they do not replace the fixture and are unnecessary here.
Considered / Background (not chosen)
These were the earlier exploratory options while the residual variant was still believed to require in-repo mitigation. They are retained as context; the upstream fix in 12.1.0 makes them unnecessary.
(a) Pin one Application/Dispatcher/RenderLoop per assembly and never rebuild. Superseded: PerTest on the stock single-thread harness is simpler and more isolated.
(b) Serialize headless construction/first-touch under a lock guaranteeing the render loop is built on the owning thread. Superseded: the stock harness already builds and touches the render loop on its single dispatch thread; our second thread was the problem.
(c) Marshal session construction onto the StaTaskScheduler thread. Superseded by deleting StaTaskScheduler entirely.
(d) Bump/patch Avalonia for the lifecycle bug. Effectively realized: the relevant fixes (#21688, #21223, #20000) are already in the pinned 12.1.0; the action is to rely on them, not bump.
New harness-behavior tests live in Phantom.Workspaces.Tests (new class HeadlessHarnessTests), matching the Subject_Scenario_ExpectedOutcome convention. The primary acceptance signal, however, is the Special Verification stress gate below: the full existing suite (~873 migrated [AvaloniaFact] tests) running green repeatedly under load with zero cross-thread faults and zero "queue processor crashed" messages.
A test body under stock [AvaloniaFact] executes on the thread that owns Dispatcher.UIThread (Dispatcher.UIThread.CheckAccess() is true), proving no second-thread window.
A dispatched action that forces a construction-time exception fails that single test while a subsequent dispatched action still runs to completion — asserts Avalonia#21688 behavior (no _dispatchTask fault, no cascade).
After a failing headless test, Dispatcher.UIThread is accessible for the next test with no leftover cross-thread ownership — asserts Avalonia#21223 dispose-before-signal behavior.
Reflection assertion that each headless test assembly carries [assembly: CollectionBehavior(DisableTestParallelization = true)], preventing accidental reintroduction of parallelism.
Special Verification — Stress Gate (MUST pass before committing the fix)
A cross-thread flake cannot be proven fixed by a single green run. The following stress procedure is a required gate; the fix may not be committed until every step is green. Run on Windows (the affected platform).
Baseline capture (pre-change). On the current harness, run the three headless suites and record the failure signature for comparison:
# from features/
dotnet test Phantom.Workspaces.Agent.Gui.Tests --no-restore
Confirm the known signature can appear (cross-thread InvalidOperationException and/or "queue processor crashed"). This documents the starting point.
Apply the fix on a worktree branch (harness deleted, attributes migrated, CollectionBehavior added, PerTest selected).
Repeat/stress run — the core gate. Execute each affected suite at least 25 consecutive iterations with no filtering, failing fast on the first bad run:
# from features/$suites=@(
'Phantom.Workspaces.Agent.Gui.Tests','Phantom.Workspaces.Gui.Shared.Tests','Phantom.Workspaces.Tests'
)
foreach ($sin$suites) {
for ($i=1; $i-le25; $i++) {
Write-Host"== $s iteration $i =="
dotnet test $s--no-build --logger "console;verbosity=minimal"2>&1|Tee-Object-FilePath "stress-$s-$i.log"if ($LASTEXITCODE-ne0) { throw"FAIL: $s iteration $i (see stress-$s-$i.log)" }
}
}
Pass criteria: all 25×3 iterations exit 0.
Grep every stress log for the forbidden signatures. Zero occurrences required across all logs:
$hits=Select-String-Path stress-*.log -Pattern `'different thread owns it','queue processor crashed','HeadlessUnitTestSession was (disposed|cancelled)'if ($hits) { $hits; throw"Forbidden signature found in stress logs" }
Load/parallel amplification. Run all three suites concurrently (separate processes) for 10 iterations to reproduce the original parallel/load conditions, and re-grep as in step 4. This specifically stresses the discovery/first-touch timing that produced the residual variant.
Isolation-level guard. Assert (via HeadlessTestProjects_DeclareNonParallelCollectionBehavior) that parallelization is disabled, and confirm no assembly re-declares AvaloniaTestIsolationLevel.PerAssembly unless explicitly justified in the PR.
Only after steps 3–6 are green, delete the reflection safety-net code (already removed with PhantomAvaloniaFact.cs, but confirm no residual references) and re-run step 3 once more (single 25× pass on Agent.Gui.Tests) to prove the native harness alone holds.
Attach the stress logs (or their pass/zero-hit summary) to the PR. Any single cross-thread fault or "queue processor crashed" line across the entire gate is a hard failure — do not merge.
Summary
The intermittent headless cross-thread flake (
System.InvalidOperationException: The calling thread cannot access this object because a different thread owns itfromDefaultRenderLoop.Add→Dispatcher.VerifyAccess, followed by a cascade ofHeadlessUnitTestSession queue processor crashed before this test was dispatchedacross ~149–160 tests) is fully diagnosed and now fixable by deletion, not by more mitigation.Two things changed the calculus:
features/Directory.Packages.props) contains commit9448ef8a91— "Fix hang if exception is thrown during headless session app construction" (Fix hang if exception is thrown during headless session app construction AvaloniaUI/Avalonia#21688) — which wraps the app-construction call inHeadlessUnitTestSession.DispatchCorein atry/catchso a construction failure fails only that one test and the dispatch loop survives. That is exactly the cascade our custom reflection safety nets were built to catch.PhantomAvaloniaFactruns each test body on a second thread (StaTaskScheduler) that is distinct from the session's single dispatch thread. That second thread is what opens the window forDefaultRenderLoopto be constructed on a thread other than the one that later ownsDispatcher.UIThread.The fix is to delete the custom harness entirely and use the stock, now-fixed Avalonia harness, run the headless assemblies non-parallel, and prefer
PerTestisolation. This removes the crash surface and the cascade surface deterministically, and deletes a large amount of reflection-based code that reaches into Avalonia internals.This issue supersedes the residual-variant framing of #815/#1012: the residual variant is resolved by the upstream fix plus removal of our own second-thread harness.
Root Cause
Mechanism of the crash.
HeadlessUnitTestSession.DispatchCore(Avalonia.Headless) runs every dispatched test on a single background dispatch thread (_dispatchTask), which is whereDispatcher.UIThreadis owned. On that thread,EnsureIsolatedApplication/EnsureSharedApplicationcallsAppBuilder.SetupUnsafe()→AvaloniaHeadlessPlatform.Initialize→ constructsServerCompositor+DefaultRenderLoop.DefaultRenderLoop.AddcallsDispatcher.VerifyAccess(). If the render loop is constructed on, or first touched from, a thread other than the one that ownsDispatcher.UIThread,VerifyAccess()throws the cross-threadInvalidOperationException.Why our harness makes this happen.
Phantom.Workspaces.Testing.Gui\PhantomAvaloniaFact.csdoes not run tests on the session's dispatch thread. Instead:AvaloniaTestCaseon a per-assemblyStaTaskSchedulerthread (PhantomAvaloniaFact.cs:104-119, scheduler defined:311-357).HeadlessUnitTestSession.GetOrStartForAssembly(assembly)on whatever thread xUnit invokedRunon (PhantomAvaloniaFact.cs:151-153).The result is two threads interacting with a single shared session and its
DefaultRenderLoop: the session's own dispatch thread and ourStaTaskSchedulerthread. Under parallel discovery/load, construction/first-touch of the render loop can land on a different thread than the one that ends up owningDispatcher.UIThread→ the cross-thread throw. Because the throw historically happened outside the per-dispatchtryin Avalonia's loop, it faulted_dispatchTask, abandoning every queued test → the "queue processor crashed" cascade.What upstream fixed (already in 12.1.0). In
HeadlessUnitTestSession.DispatchCore, app construction was hoisted into a guarded block:9448ef8a91— Fix hang if exception is thrown during headless session app construction AvaloniaUI/Avalonia#21688, "Fix hang if exception is thrown during headless session app construction" (in12.1.0). Converts the cascade into a single failing test.c0a41d8812— Fix headless cleanup race: dispose before signalling TCS (#20664) AvaloniaUI/Avalonia#21223 / #20664, "Fix headless cleanup race: dispose before signalling TCS" (in12.1.0). Ensuresapplication.Dispose()(which resets UI-thread ownership) runs before the test task returns, so the next test/thread does not observe leftovers_uiThread— the Bug: flaky test suite — TerminalControlTests HeadlessUnitTestSession crashes (recurrence) #815 "leftover ownership → cross-thread in the next test" mechanism.a4bfae1c29— Headless AvaloniaTestIsolationLevel AvaloniaUI/Avalonia#20000, "Headless AvaloniaTestIsolationLevel" (in12.1.0).AvaloniaTestIsolationLevel.PerTest/PerAssemblyis now a first-class, supported feature — the thing we hacked in via a custom attribute.Containment verified against the local Avalonia clone:
git merge-base --is-ancestor 9448ef8a91 12.1.0→ in12.1.0;git tag --contains 9448ef8a91→12.1.0.Net: with 12.1.0, the cascade is impossible from a construction failure, and running all work on the session's single dispatch thread (i.e. using the stock harness with no second
StaTaskScheduler) removes the cross-thread construction window itself. Avalonia's own docs additionally state that with shared-application isolation, concurrent test execution is not supported — so parallelism must be disabled for these assemblies regardless of isolation level.Affected Files
Phantom.Workspaces.Testing.Gui\PhantomAvaloniaFact.csStaTaskSchedulersecond thread +_dispatchTask/_cancellationTokenSourcereflection safety nets. The second thread is a root-cause contributor; the safety nets are superseded by Avalonia#21688.Phantom.Workspaces.Testing.Gui\SingleThreadPump.csPhantom.Workspaces.Tests\PhantomAvaloniaStaFact.csPhantom.Workspaces.Tests\MainWindowIntegrationTests.cs[PhantomAvaloniaStaFact(Timeout = 15_000)]usage (MainWindow_ContentLevelDocumentTabStrip_HasHeaderTemplate_AfterTabOpened). Retarget to[AvaloniaFact(Timeout = 15_000)]; it is a headless render test, not a native-WebView test.Agent.Gui.Tests,Gui.Shared.Tests,Tests)[PhantomAvaloniaFact]sites[PhantomAvaloniaFact]→[AvaloniaFact](stockAvalonia.Headless.XUnit).Phantom.Workspaces.Agent.Gui.Tests\AvaloniaXUnitSetup.cs[assembly: AvaloniaTestIsolation(PerAssembly)](preferPerTest); add[assembly: CollectionBehavior(DisableTestParallelization = true, MaxParallelThreads = 1)].Phantom.Workspaces.Gui.Shared.Tests\AvaloniaXUnitSetup.csPerTest; add theCollectionBehaviorattribute.Phantom.Workspaces.Tests\AvaloniaXUnitSetup.csPerTest; add theCollectionBehaviorattribute.features\Directory.Packages.propsAvalonia.* = 12.1.0Phantom.Workspaces.Agent.Gui.WebViewTests\WebViewAppFixture.csICollectionFixture; they are unaffected by #1101 and remain the pattern for native-control tests.Design / Fix
Chosen fix — delete the custom harness; use the stock, fixed Avalonia harness; serialize; prefer
PerTest.[PhantomAvaloniaFact](873) and the single[PhantomAvaloniaStaFact]with stock[AvaloniaFact](and[AvaloniaTheory]where a theory equivalent is needed). With the stock harness, every test runs on the session's single dispatch thread whereDispatcher.UIThreadis owned — the cross-thread construction window cannot arise from a second thread.PhantomAvaloniaFact.cs,PhantomAvaloniaStaFact.cs, and (if unreferenced)SingleThreadPump.cs. The reflection safety nets they contain are superseded by Avalonia#21688 (construction failures no longer cascade) and #21223 (clean UI-thread hand-off between tests).PerTestisolation (Avalonia's supported default) for all three assemblies — remove the[assembly: AvaloniaTestIsolation(PerAssembly)]fromAgent.Gui.Tests.PerTestgives full per-test isolation and, crucially, means a failure can never be shared/cascaded across a batch.PerAssemblymay be retained only if a measured runtime regression is unacceptable and the stress gate below stays green; reliability is the deciding requirement.WebViewAppFixturecollection-fixture pattern. Do not introduceXunit.StaFact/[UIFact]: those provide an STA thread + pumpingSynchronizationContextbut no AvaloniaDispatcher/Application, so they do not replace the fixture and are unnecessary here.Considered / Background (not chosen)
These were the earlier exploratory options while the residual variant was still believed to require in-repo mitigation. They are retained as context; the upstream fix in 12.1.0 makes them unnecessary.
Application/Dispatcher/RenderLoopper assembly and never rebuild. Superseded:PerTeston the stock single-thread harness is simpler and more isolated.StaTaskSchedulerthread. Superseded by deletingStaTaskSchedulerentirely._dispatchTask/_cancellationTokenSourcereflection safety nets (Hang: Phantom.Workspaces.Tests host hangs on shutdown — PhantomAvaloniaTestCase tasks pending with no Avalonia dispatcher #643/Hang: Phantom.Workspaces.Tests — 20 AvaloniaTestCase threads all block at Task.InternalWait (UI thread deadlock) #660). Superseded by #21688 (per-test construction failure) and #21223 (clean cleanup). Remove after the stress gate confirms no regressions.Expected Tests
New harness-behavior tests live in
Phantom.Workspaces.Tests(new classHeadlessHarnessTests), matching theSubject_Scenario_ExpectedOutcomeconvention. The primary acceptance signal, however, is the Special Verification stress gate below: the full existing suite (~873 migrated[AvaloniaFact]tests) running green repeatedly under load with zero cross-thread faults and zero "queue processor crashed" messages.AvaloniaFact_TestBody_RunsOnDispatcherOwningThreadHeadlessHarnessTests[AvaloniaFact]executes on the thread that ownsDispatcher.UIThread(Dispatcher.UIThread.CheckAccess()is true), proving no second-thread window.HeadlessSession_ConstructionFailure_FailsOnlyThatTestWithoutCascadingHeadlessHarnessTests_dispatchTaskfault, no cascade).HeadlessSession_AfterFailedTest_UiThreadOwnershipIsReleasedHeadlessHarnessTestsDispatcher.UIThreadis accessible for the next test with no leftover cross-thread ownership — asserts Avalonia#21223 dispose-before-signal behavior.MainWindow_ContentLevelDocumentTabStrip_HasHeaderTemplate_AfterTabOpenedMainWindowIntegrationTests[PhantomAvaloniaStaFact]to[AvaloniaFact].HeadlessTestProjects_DeclareNonParallelCollectionBehaviorHeadlessHarnessTests[assembly: CollectionBehavior(DisableTestParallelization = true)], preventing accidental reintroduction of parallelism.Special Verification — Stress Gate (MUST pass before committing the fix)
A cross-thread flake cannot be proven fixed by a single green run. The following stress procedure is a required gate; the fix may not be committed until every step is green. Run on Windows (the affected platform).
Baseline capture (pre-change). On the current harness, run the three headless suites and record the failure signature for comparison:
Confirm the known signature can appear (cross-thread
InvalidOperationExceptionand/or "queue processor crashed"). This documents the starting point.Apply the fix on a worktree branch (harness deleted, attributes migrated,
CollectionBehavioradded,PerTestselected).Repeat/stress run — the core gate. Execute each affected suite at least 25 consecutive iterations with no filtering, failing fast on the first bad run:
Pass criteria: all 25×3 iterations exit 0.
Grep every stress log for the forbidden signatures. Zero occurrences required across all logs:
Load/parallel amplification. Run all three suites concurrently (separate processes) for 10 iterations to reproduce the original parallel/load conditions, and re-grep as in step 4. This specifically stresses the discovery/first-touch timing that produced the residual variant.
Isolation-level guard. Assert (via
HeadlessTestProjects_DeclareNonParallelCollectionBehavior) that parallelization is disabled, and confirm no assembly re-declaresAvaloniaTestIsolationLevel.PerAssemblyunless explicitly justified in the PR.Only after steps 3–6 are green, delete the reflection safety-net code (already removed with
PhantomAvaloniaFact.cs, but confirm no residual references) and re-run step 3 once more (single 25× pass onAgent.Gui.Tests) to prove the native harness alone holds.Attach the stress logs (or their pass/zero-hit summary) to the PR. Any single cross-thread fault or "queue processor crashed" line across the entire gate is a hard failure — do not merge.
Related Issues
PerTestrebuild crash) and residual-variant predictor. Resolved here by moving to the stock harness on the single dispatch thread + upstream #21223.