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
ChangingTargetBranchTriggersCommitListRefresh and BranchDropdown_SelectBranch_UpdatesTargetBranch (and ~20 other tests in GitWorktreeReviewWorkspaceTabViewModelTests that use the same "wait for IsRefreshing true→false" pattern) time out after 8 s and are then killed by the outer 10 s PhantomAvaloniaFact timeout. This blocks completion of #805 and #888.
The root cause is not a scheduler / dispatcher hang and not the atomic-swap refactor. It is a missed PropertyChanged notification caused by overlapping refreshes coalescing on a single IsRefreshingbool that uses value-equality suppression. The test observer therefore only ever sees IsRefreshing = false and its wasRefreshing sentinel never becomes true, so the TaskCompletionSource is never signalled.
protectedboolSetProperty<T>(refTfield,Tvalue,[CallerMemberName]string?propertyName=null){if(EqualityComparer<T>.Default.Equals(field,value)){returnfalse;// ← no PropertyChanged raised}field=value;this.RaisePropertyChanged(propertyName);returntrue;}
Setting IsRefreshing = true when it is alreadytruedoes not raise PropertyChanged — that's the whole point of the helper.
The overlapping-refresh sequence
Phantom.Workspaces/ViewModels/GitWorktreeReviewWorkspaceTabViewModel.cs in worktree 6 (fix/805-888).
Refresh #1 is kicked off from the constructor (line 63) Lifetime.Run(this.RefreshAsync). It executes synchronously up to its first await:
// RefreshAsync, lines 201–247this.refreshCts?.Cancel();this.refreshCts?.Dispose();this.refreshCts=CancellationTokenSource.CreateLinkedTokenSource(ct);vartoken=this.refreshCts.Token;try{this.IsRefreshing=true;// (A) raises PC (first time only)varnewCommitList=newGitWorktreeCommitListViewModel();awaitnewCommitList.RefreshAsync(...);// suspends inside Task.Run
...}catch(OperationCanceledException){}finally{if(!token.IsCancellationRequested){this.IsRefreshing=false;// (B) only reached if not cancelled}}
Now trace the failing test (ChangingTargetBranchTriggersCommitListRefresh, lines 205–258):
Test does await Task.Yield() — this yields once; it does not drain the dispatcher, and Refresh Bump actions/checkout from 4 to 7 #1's Task.Run continuation has not yet run. IsRefreshing is still true.
Test adds sentinel to vm.CommitList.Commits.
Test attaches its PropertyChanged handler. wasRefreshing starts false.
this.IsRefreshing = true; — value already true, SetProperty returns false, no PropertyChanged is raised. wasRefreshing stays false.
Suspends inside Task.Run.
Refresh Bump actions/checkout from 4 to 7 #1's continuation eventually resumes on the UI thread with a cancelled token, throws OperationCanceledException, is caught, and the finally block sees token.IsCancellationRequested == true so it does not set IsRefreshing = false.
Refresh Bump actions/upload-artifact from 4 to 7 #2's continuations run to completion. AttachCommitList(newCommitList) swaps in an empty list (this part of the atomic-swap fix works correctly — the sentinel is orphaned). Its finally block runs this.IsRefreshing = false → PropertyChanged fires.
Handler runs: IsRefreshing == false, but wasRefreshing == false, so the else if (wasRefreshing) branch is skipped. refreshCompleted.TrySetResult(true) is never called, and the test hangs on WaitAsync(TimeSpan.FromSeconds(8)).
The 8 s wait expires, WaitAsync throws TimeoutException, and the test fails.
Why it looked like a scheduler / Task.Run issue
The symptom (IsRefreshing "never cycles") superficially resembles a headless-dispatcher hang like #877, but the dispatcher is pumping correctly and Refresh #2 actually completes end-to-end (vm.CommitList.Commits is in fact empty at the point of timeout). The problem is purely a missed notification: the true transition happens before the subscriber attaches, and the second true write is suppressed by value equality.
RefreshAsync sets IsRefreshing = true synchronously and only sets it back to false if its token is not cancelled. Concurrent invocations share a single bool, so the first refresh's true "steals" the observable transition from the second refresh.
Uses a wasRefreshing sentinel that requires observing a false → true transition after subscription. The single await Task.Yield() is not sufficient to drain the constructor's fire-and-forget refresh; the handler is subscribed while IsRefreshing is still true.
Fire-and-forget; the caller has no handle on completion, so the tests must observe side-effects instead of awaiting the refresh directly.
Design / Fix
The atomic-swap in #888 is correct and should be kept. The fix targets the missed notification. There are three concrete options; any one of them makes the failing tests pass. Option 1 is recommended — smallest change, no test churn.
Option 1 (recommended): expose a RefreshCompleted event / awaitable, and update the tests to await it
Have GitWorktreeReviewWorkspaceTabViewModel publish the currently in-flight refresh Task so tests can await the actual refresh rather than inferring completion from a flag:
privateTask?currentRefresh;publicTask?CurrentRefresh=>this.currentRefresh;// for tests and diagnosticspublicTaskRefreshAsync(CancellationTokenct=default){returnthis.currentRefresh=RefreshCoreAsync(ct);}privateasyncTaskRefreshCoreAsync(CancellationTokenct){// existing body of RefreshAsync}
Test rewrite (both failing tests) — no wasRefreshing sentinel needed:
// wait for the constructor's initial refreshAssert.NotNull(vm.CurrentRefresh);awaitvm.CurrentRefresh!;vm.CommitList.Commits.Add(sentinel);vm.TargetBranch="develop";awaitvm.CurrentRefresh!;// the new one written by the setterAssert.Empty(vm.CommitList.Commits);
Pro: unambiguous, no dependence on notification semantics, mirrors what production callers actually need (they can chain off CurrentRefresh for follow-up work).
Con: exposes a Task as public API; document that observers must be tolerant of null and of task swap-out.
Option 2: reference-count IsRefreshing
Change IsRefreshing from bool to a computed property backed by an int activeRefreshCount. Increment at the top of RefreshCoreAsync, decrement in the finally. IsRefreshing is activeRefreshCount > 0. Raise PropertyChanged only on 0↔positive transitions.
This preserves the current test contract (true→false cycle observed) but requires the tests to first observe IsRefreshing == false after the constructor's refresh before mutating state. The failing tests would still need the "wait for initial refresh to complete" step (see below).
Option 3: drain the constructor's initial refresh in the tests
Purely a test-side fix. Replace:
awaitTask.Yield();
with a real "wait for the initial refresh to finish" helper:
awaitWaitForRefreshQuiescenceAsync(vm);
where WaitForRefreshQuiescenceAsync polls IsRefreshing == false via a PropertyChanged subscription (and handles the case where it is alreadyfalse at subscription time).
This alone (without changing production code) resolves the immediate timeout because it guarantees the handler is subscribed while IsRefreshing == false, so the subsequent false → true → false cycle fires two events. However, it is racy in principle: any refresh that gets cancelled mid-flight by a later refresh will not restore IsRefreshing to false, so quiescence-polling can deadlock on future edits. Only viable if combined with Option 2.
Recommendation
Ship Option 1. It is:
The smallest production-code delta (~10 lines).
Independent of SetProperty's value-equality semantics.
The pattern already established for other async-VM tests in the repository (e.g. AgentManifestLaunchpadViewModel exposes completion via Task fields).
Easy to reason about across arbitrary overlap and cancellation.
Expected Tests
Add to GitWorktreeReviewWorkspaceTabViewModelTests.cs. Rename the two failing tests to the Subject_Scenario_ExpectedOutcome convention used elsewhere in the test project.
Attach PropertyChanged while IsRefreshing == true (constructor refresh in flight), trigger a second refresh, assert the observer eventually sees falseand that a completion signal derived from CurrentRefresh fires. Regression guard for this issue.
Force cancellation of an in-flight refresh via a follow-up refresh, then assert CurrentRefresh completes and IsRefreshing == false at the end of the latest refresh.
Summary
ChangingTargetBranchTriggersCommitListRefreshandBranchDropdown_SelectBranch_UpdatesTargetBranch(and ~20 other tests inGitWorktreeReviewWorkspaceTabViewModelTeststhat use the same "wait forIsRefreshingtrue→false" pattern) time out after 8 s and are then killed by the outer 10 sPhantomAvaloniaFacttimeout. This blocks completion of #805 and #888.The root cause is not a scheduler / dispatcher hang and not the atomic-swap refactor. It is a missed
PropertyChangednotification caused by overlapping refreshes coalescing on a singleIsRefreshingboolthat uses value-equality suppression. The test observer therefore only ever seesIsRefreshing = falseand itswasRefreshingsentinel never becomestrue, so theTaskCompletionSourceis never signalled.Root Cause
The value-equality suppression
Phantom.Workspaces/ViewModels/ViewModelBase.cs:15–24Setting
IsRefreshing = truewhen it is alreadytruedoes not raisePropertyChanged— that's the whole point of the helper.The overlapping-refresh sequence
Phantom.Workspaces/ViewModels/GitWorktreeReviewWorkspaceTabViewModel.csin worktree 6 (fix/805-888).Refresh #1 is kicked off from the constructor (line 63)
Lifetime.Run(this.RefreshAsync). It executes synchronously up to its firstawait:Now trace the failing test (
ChangingTargetBranchTriggersCommitListRefresh, lines 205–258):IsRefreshing = true(event fires, but there is no subscriber yet).await Task.Yield()— this yields once; it does not drain the dispatcher, and Refresh Bump actions/checkout from 4 to 7 #1'sTask.Runcontinuation has not yet run.IsRefreshingis stilltrue.vm.CommitList.Commits.PropertyChangedhandler.wasRefreshingstartsfalse.vm.TargetBranch = "develop"→ property setter callsLifetime.Run(this.RefreshAsync)for Refresh Bump actions/upload-artifact from 4 to 7 #2 (line 76).this.refreshCts?.Cancel();cancels Refresh Bump actions/checkout from 4 to 7 #1's linked token.this.IsRefreshing = true;— value alreadytrue,SetPropertyreturns false, noPropertyChangedis raised.wasRefreshingstaysfalse.Task.Run.OperationCanceledException, is caught, and thefinallyblock seestoken.IsCancellationRequested == trueso it does not setIsRefreshing = false.AttachCommitList(newCommitList)swaps in an empty list (this part of the atomic-swap fix works correctly — the sentinel is orphaned). Itsfinallyblock runsthis.IsRefreshing = false→ PropertyChanged fires.IsRefreshing == false, butwasRefreshing == false, so theelse if (wasRefreshing)branch is skipped.refreshCompleted.TrySetResult(true)is never called, and the test hangs onWaitAsync(TimeSpan.FromSeconds(8)).The 8 s wait expires,
WaitAsyncthrowsTimeoutException, and the test fails.Why it looked like a scheduler /
Task.RunissueThe symptom (
IsRefreshing"never cycles") superficially resembles a headless-dispatcher hang like #877, but the dispatcher is pumping correctly and Refresh #2 actually completes end-to-end (vm.CommitList.Commitsis in fact empty at the point of timeout). The problem is purely a missed notification: thetruetransition happens before the subscriber attaches, and the secondtruewrite is suppressed by value equality.Affected Files
Phantom.Workspaces/ViewModels/ViewModelBase.cs(SetProperty, lines 15–24)PropertyChangedon equal values. Correct behavior, but interacts badly withIsRefreshingas a coalesced flag.Phantom.Workspaces/ViewModels/GitWorktreeReviewWorkspaceTabViewModel.cs(worktree 6, lines 201–247)RefreshAsyncsetsIsRefreshing = truesynchronously and only sets it back tofalseif its token is not cancelled. Concurrent invocations share a singlebool, so the first refresh'strue"steals" the observable transition from the second refresh.Phantom.Workspaces.Tests/GitWorktreeReviewWorkspaceTabViewModelTests.cs(ChangingTargetBranchTriggersCommitListRefreshlines 205–258;BranchDropdown_SelectBranch_UpdatesTargetBranchlines 1170–1219)wasRefreshingsentinel that requires observing afalse → truetransition after subscription. The singleawait Task.Yield()is not sufficient to drain the constructor's fire-and-forget refresh; the handler is subscribed whileIsRefreshingis stilltrue.Phantom.Workspaces.Gui.Shared/Utilities/ViewModelLifetime.cs(Run/RunCoreAsync)Design / Fix
The atomic-swap in #888 is correct and should be kept. The fix targets the missed notification. There are three concrete options; any one of them makes the failing tests pass. Option 1 is recommended — smallest change, no test churn.
Option 1 (recommended): expose a
RefreshCompletedevent / awaitable, and update the tests to await itHave
GitWorktreeReviewWorkspaceTabViewModelpublish the currently in-flight refresh Task so tests can await the actual refresh rather than inferring completion from a flag:Test rewrite (both failing tests) — no wasRefreshing sentinel needed:
Pro: unambiguous, no dependence on notification semantics, mirrors what production callers actually need (they can chain off
CurrentRefreshfor follow-up work).Con: exposes a
Taskas public API; document that observers must be tolerant ofnulland of task swap-out.Option 2: reference-count
IsRefreshingChange
IsRefreshingfromboolto a computed property backed by anint activeRefreshCount. Increment at the top ofRefreshCoreAsync, decrement in thefinally.IsRefreshingisactiveRefreshCount > 0. RaisePropertyChangedonly on 0↔positive transitions.This preserves the current test contract (true→false cycle observed) but requires the tests to first observe
IsRefreshing == falseafter the constructor's refresh before mutating state. The failing tests would still need the "wait for initial refresh to complete" step (see below).Option 3: drain the constructor's initial refresh in the tests
Purely a test-side fix. Replace:
with a real "wait for the initial refresh to finish" helper:
where
WaitForRefreshQuiescenceAsyncpollsIsRefreshing == falsevia aPropertyChangedsubscription (and handles the case where it is alreadyfalseat subscription time).This alone (without changing production code) resolves the immediate timeout because it guarantees the handler is subscribed while
IsRefreshing == false, so the subsequentfalse → true → falsecycle fires two events. However, it is racy in principle: any refresh that gets cancelled mid-flight by a later refresh will not restoreIsRefreshingtofalse, so quiescence-polling can deadlock on future edits. Only viable if combined with Option 2.Recommendation
Ship Option 1. It is:
SetProperty's value-equality semantics.AgentManifestLaunchpadViewModelexposes completion viaTaskfields).Expected Tests
Add to
GitWorktreeReviewWorkspaceTabViewModelTests.cs. Rename the two failing tests to theSubject_Scenario_ExpectedOutcomeconvention used elsewhere in the test project.RefreshAsync_ConcurrentInvocations_DoesNotMissIsRefreshingTrueEventPropertyChangedwhileIsRefreshing == true(constructor refresh in flight), trigger a second refresh, assert the observer eventually seesfalseand that a completion signal derived fromCurrentRefreshfires. Regression guard for this issue.TargetBranch_Set_TriggersCommitListRefreshAndClearsPreviousCommitsChangingTargetBranchTriggersCommitListRefreshusingawait vm.CurrentRefreshinstead ofIsRefreshingsentinel.BranchDropdown_SelectBranch_UpdatesTargetBranchAndCommitListBranchDropdown_SelectBranch_UpdatesTargetBranchusingawait vm.CurrentRefresh.RefreshAsync_CancelledMidFlight_LeavesIsRefreshingConsistentCurrentRefreshcompletes andIsRefreshing == falseat the end of the latest refresh.RefreshAsync_SecondCallCancelsFirst_OldTaskCompletesWithoutMutatingVisibleStatethis.CommitList/this.FileList/this.FileDiffs.CurrentRefresh_AfterConstructor_IsNonNullAndAwaitableCurrentRefresh_AfterTargetBranchChange_IsReplacedWithNewTaskBackground / Considered
Preserved from the original blocked report:
Task.Run. That code path is correct and is not the source of the timeout.CommitList/FileList/FileDiffs. Also correct; the swap does execute and the sentinel is orphaned by the time the test'sAssert.Emptywould run. The failure is strictly on the wait, not on the assertion.DelayScheduledhook — not applicable. The scheduler is pumping; the bug is a missedINotifyPropertyChangedevent, not a missed dispatcher job.C:\dev\Phantom.Workspaces-Skills\worktrees\6(branchfix/805-888, uncommitted changes to the three VMs).