Skip to content

Flaky hang: ViewEntityViewModelTests.InitializeAsync_PopulatesShortcuts stalls test host (missing [AvaloniaFact] -> Dispatcher.UIThread.InvokeAsync never completes) #1165

Description

@JoshuaRowePhantom

Summary

ViewEntityViewModelTests.InitializeAsync_PopulatesShortcuts intermittently hangs the test host (no assertion failure, no exception — just a timeout) during full-suite runs. When re-run in isolation, it still hangs, but only after the 17 sibling tests in ViewEntityViewModelTests have executed first, indicating the failure depends on test-ordering / shared Avalonia dispatcher state. This can stall scripts\run-tests.ps1 -Mode full and CI indefinitely. It was surfaced by the #1155 batch but is unrelated to that work: ViewEntityViewModel.cs and its shortcut/dispatch paths were not modified there.

Repro

  • .\scripts\run-tests.ps1 -Mode full — hangs on ViewEntityViewModelTests.InitializeAsync_PopulatesShortcuts.
  • Isolated: run only ViewEntityViewModelTests — the first 17 tests pass, then InitializeAsync_PopulatesShortcuts hangs.
  • Non-deterministic: sometimes completes in other orderings.

Root Cause (suspected)

InitializeAsync_PopulatesShortcuts is declared as a plain [Fact] (not [AvaloniaFact]) but the code path it awaits requires an active Avalonia headless dispatcher pump:

  • Test: Phantom.Workspaces.Tests/ViewEntityViewModelTests.cs:173-188 — awaits viewModel.InitializeAsync() under [Fact].

  • ViewEntityViewModel.InitializeAsync (Phantom.Workspaces/ViewModels/ViewEntityViewModel.cs:53-59) awaits entityCardNode.Card.ResolveShortcutsAsync().

  • EntityCardViewModel.ResolveShortcutsAsync (Phantom.Workspaces/ViewModels/EntityCardViewModel.cs:423-460) enumerates manager.GetShortcutsForAsync(...) — where TestShortcutHandler.ShouldApplyTo does await Task.Yield() and hops off the calling thread — then, because Dispatcher.UIThread.CheckAccess() is false, awaits:

    await Dispatcher.UIThread.InvokeAsync(() => this.Shortcuts = resolved);

Under a plain [Fact] there is no Avalonia.Headless app hosting a running Dispatcher.UIThread for that test method. The delegate posted via InvokeAsync is queued to a dispatcher that is not being pumped, so the returned Task never completes — the test hangs forever inside the await.

Every sibling [Fact] in this class (lines 27-171) is synchronous and never touches the dispatcher, so they pass. The two async tests that exercise this same path (InitializeAsync_DoesNotPushShortcutsButTreeCardStillShowsThem at line 190, and the query test at line 210) both correctly use [AvaloniaFact].

Why intermittent

Dispatcher.UIThread is a process-wide singleton. Whether the pending InvokeAsync continuation ever runs depends on whether some other test's [AvaloniaFact] headless app is currently pumping the dispatcher at that moment (xUnit v3 test ordering / parallelism across classes). When it happens to be pumped by a concurrent Avalonia-headless test elsewhere, this test passes; when it isn't (typical when running this class in isolation after all the sync tests, or when the previous [AvaloniaFact] has already torn down), it hangs.

There is also a fire-and-forget resolution kicked off in the constructor via SetShortcutContextQueueShortcutResolutionLifetime.Run(...) (EntityCardViewModel.cs:462-479), which posts to the same dispatcher and can independently stall or leak, but the explicit await viewModel.InitializeAsync() is what actually deadlocks the test thread.

Mechanism resembles the headless UI-thread marshaling flakes in #654 (OnActiveDockableChanged hang) and #791 (async shutdown hang), but this one is a missing [AvaloniaFact] attribute rather than a lifetime race.

Affected Files

File Role
Phantom.Workspaces.Tests/ViewEntityViewModelTests.cs (line 173) Test declared with plain [Fact] instead of [AvaloniaFact]
Phantom.Workspaces/ViewModels/ViewEntityViewModel.cs (lines 53-59) InitializeAsync awaits shortcut resolution
Phantom.Workspaces/ViewModels/EntityCardViewModel.cs (lines 423-460) ResolveShortcutsAsync marshals final assignment via Dispatcher.UIThread.InvokeAsync

Impact

Proposed Solutions

  1. (Preferred) Promote the test to [AvaloniaFact]. The code under test explicitly marshals onto Dispatcher.UIThread via InvokeAsync; the sibling InitializeAsync_DoesNotPushShortcutsButTreeCardStillShowsThem already uses [AvaloniaFact] for exactly this reason. Change line 173 from [Fact] to [AvaloniaFact] and the dispatcher will be pumped for the duration of the test. Zero behavior change to production code.
  2. Make ResolveShortcutsAsync tolerate a non-running dispatcher. In EntityCardViewModel.ResolveShortcutsAsync, if Dispatcher.UIThread is not the current thread AND no dispatcher pump is running, fall back to a direct assignment. Riskier: changes production semantics around thread-affinity.
  3. Add a hang-detection timeout in the test. Wrap the await in Assert.True(await Task.WhenAny(init, Task.Delay(TimeSpan.FromSeconds(5))) == init). Converts the hang into a fast failure but does not fix the underlying missing-attribute defect; use only as a defensive backup alongside solution 1.

Expected Tests

Test Name Class What It Verifies
InitializeAsync_PopulatesShortcuts ViewEntityViewModelTests After fix, completes deterministically and asserts Shortcuts contains Shortcut.Open — no hang under any test ordering (verified by running the class in isolation and via full suite 3x consecutively).
InitializeAsync_CompletesWithinTimeout_WhenDispatcherActive ViewEntityViewModelTests Optional guard: await viewModel.InitializeAsync() completes well under a 5-second bound so a future regression to the hang is caught fast.

Metadata

Metadata

Assignees

No one assigned

    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