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 SetShortcutContext → QueueShortcutResolution → Lifetime.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
- (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.
- 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.
- 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. |
Summary
ViewEntityViewModelTests.InitializeAsync_PopulatesShortcutsintermittently 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 inViewEntityViewModelTestshave executed first, indicating the failure depends on test-ordering / shared Avalonia dispatcher state. This can stallscripts\run-tests.ps1 -Mode fulland CI indefinitely. It was surfaced by the #1155 batch but is unrelated to that work:ViewEntityViewModel.csand its shortcut/dispatch paths were not modified there.Repro
.\scripts\run-tests.ps1 -Mode full— hangs onViewEntityViewModelTests.InitializeAsync_PopulatesShortcuts.ViewEntityViewModelTests— the first 17 tests pass, thenInitializeAsync_PopulatesShortcutshangs.Root Cause (suspected)
InitializeAsync_PopulatesShortcutsis 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— awaitsviewModel.InitializeAsync()under[Fact].ViewEntityViewModel.InitializeAsync(Phantom.Workspaces/ViewModels/ViewEntityViewModel.cs:53-59) awaitsentityCardNode.Card.ResolveShortcutsAsync().EntityCardViewModel.ResolveShortcutsAsync(Phantom.Workspaces/ViewModels/EntityCardViewModel.cs:423-460) enumeratesmanager.GetShortcutsForAsync(...)— whereTestShortcutHandler.ShouldApplyTodoesawait Task.Yield()and hops off the calling thread — then, becauseDispatcher.UIThread.CheckAccess()is false, awaits:Under a plain
[Fact]there is noAvalonia.Headlessapp hosting a runningDispatcher.UIThreadfor that test method. The delegate posted viaInvokeAsyncis queued to a dispatcher that is not being pumped, so the returnedTasknever completes — the test hangs forever inside theawait.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_DoesNotPushShortcutsButTreeCardStillShowsThemat line 190, and the query test at line 210) both correctly use[AvaloniaFact].Why intermittent
Dispatcher.UIThreadis a process-wide singleton. Whether the pendingInvokeAsynccontinuation 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
SetShortcutContext→QueueShortcutResolution→Lifetime.Run(...)(EntityCardViewModel.cs:462-479), which posts to the same dispatcher and can independently stall or leak, but the explicitawait viewModel.InitializeAsync()is what actually deadlocks the test thread.Mechanism resembles the headless UI-thread marshaling flakes in #654 (
OnActiveDockableChangedhang) and #791 (async shutdown hang), but this one is a missing[AvaloniaFact]attribute rather than a lifetime race.Affected Files
Phantom.Workspaces.Tests/ViewEntityViewModelTests.cs(line 173)[Fact]instead of[AvaloniaFact]Phantom.Workspaces/ViewModels/ViewEntityViewModel.cs(lines 53-59)InitializeAsyncawaits shortcut resolutionPhantom.Workspaces/ViewModels/EntityCardViewModel.cs(lines 423-460)ResolveShortcutsAsyncmarshals final assignment viaDispatcher.UIThread.InvokeAsyncImpact
run-tests.ps1 -Mode full) can hang indefinitely; CI wall-clock timeout is the only backstop.Proposed Solutions
[AvaloniaFact]. The code under test explicitly marshals ontoDispatcher.UIThreadviaInvokeAsync; the siblingInitializeAsync_DoesNotPushShortcutsButTreeCardStillShowsThemalready 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.ResolveShortcutsAsynctolerate a non-running dispatcher. InEntityCardViewModel.ResolveShortcutsAsync, ifDispatcher.UIThreadis not the current thread AND no dispatcher pump is running, fall back to a direct assignment. Riskier: changes production semantics around thread-affinity.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
InitializeAsync_PopulatesShortcutsViewEntityViewModelTestsShortcutscontainsShortcut.Open— no hang under any test ordering (verified by running the class in isolation and via full suite 3x consecutively).InitializeAsync_CompletesWithinTimeout_WhenDispatcherActiveViewEntityViewModelTestsawait viewModel.InitializeAsync()completes well under a 5-second bound so a future regression to the hang is caught fast.