Skip to content

Blocked: #805/#888 blocked by test timeouts in GitWorktreeReviewWorkspaceTabViewModelTests #924

Description

@JoshuaRowePhantom

Summary

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

Root Cause

The value-equality suppression

Phantom.Workspaces/ViewModels/ViewModelBase.cs:15–24

protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
    if (EqualityComparer<T>.Default.Equals(field, value))
    {
        return false;                  // ← no PropertyChanged raised
    }
    field = value;
    this.RaisePropertyChanged(propertyName);
    return true;
}

Setting IsRefreshing = true when it is already true does 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–247
this.refreshCts?.Cancel();
this.refreshCts?.Dispose();
this.refreshCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
var token = this.refreshCts.Token;

try
{
    this.IsRefreshing = true;                                 // (A) raises PC (first time only)
    var newCommitList = new GitWorktreeCommitListViewModel();
    await newCommitList.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):

  1. Constructor runs → Refresh Bump actions/checkout from 4 to 7 #1 sets IsRefreshing = true (event fires, but there is no subscriber yet).
  2. 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.
  3. Test adds sentinel to vm.CommitList.Commits.
  4. Test attaches its PropertyChanged handler. wasRefreshing starts false.
  5. Test sets vm.TargetBranch = "develop" → property setter calls Lifetime.Run(this.RefreshAsync) for Refresh Bump actions/upload-artifact from 4 to 7 #2 (line 76).
  6. Refresh Bump actions/upload-artifact from 4 to 7 #2 begins synchronously:
    • this.refreshCts?.Cancel(); cancels Refresh Bump actions/checkout from 4 to 7 #1's linked token.
    • Creates a new linked CTS.
    • this.IsRefreshing = true;value already true, SetProperty returns false, no PropertyChanged is raised. wasRefreshing stays false.
    • Suspends inside Task.Run.
  7. 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.
  8. 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.
  9. 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.

Affected Files

File Role in the bug
Phantom.Workspaces/ViewModels/ViewModelBase.cs (SetProperty, lines 15–24) Suppresses PropertyChanged on equal values. Correct behavior, but interacts badly with IsRefreshing as a coalesced flag.
Phantom.Workspaces/ViewModels/GitWorktreeReviewWorkspaceTabViewModel.cs (worktree 6, lines 201–247) 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.
Phantom.Workspaces.Tests/GitWorktreeReviewWorkspaceTabViewModelTests.cs (ChangingTargetBranchTriggersCommitListRefresh lines 205–258; BranchDropdown_SelectBranch_UpdatesTargetBranch lines 1170–1219) 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.
Phantom.Workspaces.Gui.Shared/Utilities/ViewModelLifetime.cs (Run / RunCoreAsync) 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:

private Task? currentRefresh;

public Task? CurrentRefresh => this.currentRefresh;   // for tests and diagnostics

public Task RefreshAsync(CancellationToken ct = default)
{
    return this.currentRefresh = RefreshCoreAsync(ct);
}

private async Task RefreshCoreAsync(CancellationToken ct)
{
    // existing body of RefreshAsync
}

Test rewrite (both failing tests) — no wasRefreshing sentinel needed:

// wait for the constructor's initial refresh
Assert.NotNull(vm.CurrentRefresh);
await vm.CurrentRefresh!;

vm.CommitList.Commits.Add(sentinel);
vm.TargetBranch = "develop";
await vm.CurrentRefresh!;                     // the new one written by the setter

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

await Task.Yield();

with a real "wait for the initial refresh to finish" helper:

await WaitForRefreshQuiescenceAsync(vm);

where WaitForRefreshQuiescenceAsync polls IsRefreshing == false via a PropertyChanged subscription (and handles the case where it is already false 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.

Test Purpose
RefreshAsync_ConcurrentInvocations_DoesNotMissIsRefreshingTrueEvent Attach PropertyChanged while IsRefreshing == true (constructor refresh in flight), trigger a second refresh, assert the observer eventually sees false and that a completion signal derived from CurrentRefresh fires. Regression guard for this issue.
TargetBranch_Set_TriggersCommitListRefreshAndClearsPreviousCommits Rewrite of ChangingTargetBranchTriggersCommitListRefresh using await vm.CurrentRefresh instead of IsRefreshing sentinel.
BranchDropdown_SelectBranch_UpdatesTargetBranchAndCommitList Rewrite of BranchDropdown_SelectBranch_UpdatesTargetBranch using await vm.CurrentRefresh.
RefreshAsync_CancelledMidFlight_LeavesIsRefreshingConsistent 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.
RefreshAsync_SecondCallCancelsFirst_OldTaskCompletesWithoutMutatingVisibleState Guards the atomic-swap invariant from #888: cancelled refresh #1 must not mutate this.CommitList / this.FileList / this.FileDiffs.
CurrentRefresh_AfterConstructor_IsNonNullAndAwaitable Contract test for the new public property.
CurrentRefresh_AfterTargetBranchChange_IsReplacedWithNewTask Ensures observers awaiting the previous refresh don't spuriously "complete late" callers.

Background / Considered

Preserved from the original blocked report:

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdiagnosedRoot cause identifiedfailed-verificationBug failed automated verificationverified-locallyImplementation has been verified locally

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions