Skip to content

Bug: eliminate headless cross-thread flake by deleting custom Avalonia test harness and using stock [AvaloniaFact] (fixes cascade via Avalonia 12.1.0) #1101

Description

@JoshuaRowePhantom

Summary

The intermittent headless cross-thread flake (System.InvalidOperationException: The calling thread cannot access this object because a different thread owns it from DefaultRenderLoop.AddDispatcher.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:

  1. 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.
  2. 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:

IDisposable application = null!;
try
{
    application = _isolated ? EnsureIsolatedApplication() : EnsureSharedApplication();
}
catch (Exception ex)
{
    tcs.TrySetException(ex);
    return; // exit this action; keep the dispatcher loop alive
}

Containment verified against the local Avalonia clone: git merge-base --is-ancestor 9448ef8a91 12.1.0 → in 12.1.0; git tag --contains 9448ef8a9112.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

File Line(s) Contribution / Action
Phantom.Workspaces.Testing.Gui\PhantomAvaloniaFact.cs whole file 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.
Phantom.Workspaces.Testing.Gui\SingleThreadPump.cs whole file Delete if unused after harness removal (verify no other references first).
Phantom.Workspaces.Tests\PhantomAvaloniaStaFact.cs whole file Delete. Half-measure STA discoverer; its own comment admits the inner test still uses the session's dispatch loop.
Phantom.Workspaces.Tests\MainWindowIntegrationTests.cs 3570 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) 873 [PhantomAvaloniaFact] sites Replace [PhantomAvaloniaFact][AvaloniaFact] (stock Avalonia.Headless.XUnit).
Phantom.Workspaces.Agent.Gui.Tests\AvaloniaXUnitSetup.cs isolation attr Drop [assembly: AvaloniaTestIsolation(PerAssembly)] (prefer PerTest); add [assembly: CollectionBehavior(DisableTestParallelization = true, MaxParallelThreads = 1)].
Phantom.Workspaces.Gui.Shared.Tests\AvaloniaXUnitSetup.cs Already defaults to PerTest; add the CollectionBehavior attribute.
Phantom.Workspaces.Tests\AvaloniaXUnitSetup.cs Already defaults to PerTest; add the CollectionBehavior attribute.
features\Directory.Packages.props Avalonia.* = 12.1.0 No change required — 12.1.0 already contains all three upstream fixes. Keep pinned at ≥ 12.1.0.
Phantom.Workspaces.Agent.Gui.WebViewTests\WebViewAppFixture.cs 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.

  1. 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.
  2. 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).
  3. Add to each headless test assembly:
    [assembly: CollectionBehavior(DisableTestParallelization = true, MaxParallelThreads = 1)]
    Avalonia documents that shared-isolation execution is not concurrency-safe; serialization removes the last load-driven trigger and is required for reliability.
  4. 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.
  5. 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.

Expected Tests

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.

Test Name Class What It Verifies
AvaloniaFact_TestBody_RunsOnDispatcherOwningThread HeadlessHarnessTests A test body under stock [AvaloniaFact] executes on the thread that owns Dispatcher.UIThread (Dispatcher.UIThread.CheckAccess() is true), proving no second-thread window.
HeadlessSession_ConstructionFailure_FailsOnlyThatTestWithoutCascading HeadlessHarnessTests 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).
HeadlessSession_AfterFailedTest_UiThreadOwnershipIsReleased HeadlessHarnessTests 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.
MainWindow_ContentLevelDocumentTabStrip_HasHeaderTemplate_AfterTabOpened MainWindowIntegrationTests Existing #88 regression continues to pass after retargeting from [PhantomAvaloniaStaFact] to [AvaloniaFact].
HeadlessTestProjects_DeclareNonParallelCollectionBehavior HeadlessHarnessTests 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).

  1. 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.

  2. Apply the fix on a worktree branch (harness deleted, attributes migrated, CollectionBehavior added, PerTest selected).

  3. 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 ($s in $suites) {
      for ($i = 1; $i -le 25; $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 -ne 0) { throw "FAIL: $s iteration $i (see stress-$s-$i.log)" }
      }
    }

    Pass criteria: all 25×3 iterations exit 0.

  4. 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" }
  5. 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.

  6. 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.

  7. 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.

Related Issues

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't workingdiagnosedRoot cause identifiedverified-locallyImplementation has been verified locally

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions