CmdPal: Harden ListViewModel fetch synchronization (#46429)

## Summary of the Pull Request

This PR improves fetching of list items in ListViewModel:
- Fixes _vmCache concurrency with copy-on-write cache publication.
- Preserves latest-fetch-wins behavior across overlapping RPC GetItems()
calls.
- Prevents stale or canceled fetches from publishing and makes them
abort promptly.
- Improves cancellation cleanup for abandoned item view models and
replaced token sources.
- Updates empty-state tracking to follow overlapping fetch activity
correctly.
- Reduces hot-path cache overhead by removing per-item cache locking and
full cache rebuilds.
- Adds guard against re-entry, to prevent situations like #46329:
- Defers ItemsChanged-triggered fetches raised during GetItems() until
the call unwinds;
- Uses a thread-local reentry guard so unrelated cross-thread fetches
are not delayed;
- Adds a regression test covering recursive GetItems() refresh behavior.
- Make sure we never invoke FetchItems on UI thread, and be loud in
debug when we are.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #46331
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
This commit is contained in:
Jiří Polášek
2026-03-31 04:27:08 +02:00
committed by GitHub
parent 7d171a4428
commit c34fb7f953
2 changed files with 416 additions and 114 deletions

View File

@@ -0,0 +1,89 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public partial class ListViewModelTests
{
private sealed partial class TestAppExtensionHost : AppExtensionHost
{
public override string? GetExtensionDisplayName() => "Test Host";
}
private sealed partial class RecursiveItemsChangedPage : ListPage
{
private int _getItemsCallCount;
private int _recursiveItemsChangedRaised;
private TaskCompletionSource<bool> _deferredFetchObserved = NewDeferredFetchObserved();
public int GetItemsCallCount => Volatile.Read(ref _getItemsCallCount);
public Task DeferredFetchObserved => _deferredFetchObserved.Task;
public bool RaiseItemsChangedDuringGetItems { get; set; }
public override IListItem[] GetItems()
{
var callCount = Interlocked.Increment(ref _getItemsCallCount);
if (callCount >= 2)
{
_deferredFetchObserved.TrySetResult(true);
}
if (RaiseItemsChangedDuringGetItems && Interlocked.Exchange(ref _recursiveItemsChangedRaised, 1) == 0)
{
RaiseItemsChanged(0);
}
return [new ListItem(new NoOpCommand() { Name = $"Item {callCount}" })];
}
public void PrepareRecursiveFetch()
{
Volatile.Write(ref _getItemsCallCount, 0);
Volatile.Write(ref _recursiveItemsChangedRaised, 0);
RaiseItemsChangedDuringGetItems = true;
_deferredFetchObserved = NewDeferredFetchObserved();
}
public void TriggerItemsChanged(int totalItems = 0) => RaiseItemsChanged(totalItems);
private static TaskCompletionSource<bool> NewDeferredFetchObserved() => new(TaskCreationOptions.RunContinuationsAsynchronously);
}
private static ListViewModel CreateViewModel(RecursiveItemsChangedPage page) =>
new(page, TaskScheduler.Default, new TestAppExtensionHost(), CommandProviderContext.Empty, DefaultContextMenuFactory.Instance);
[TestMethod]
public async Task RecursiveItemsChangedDuringGetItems_IsDeferredUntilGetItemsReturns()
{
var page = new RecursiveItemsChangedPage
{
Id = "list.page",
Name = "List Page",
Title = "List Page",
};
var viewModel = CreateViewModel(page);
viewModel.InitializeProperties();
page.PrepareRecursiveFetch();
page.TriggerItemsChanged();
var completed = await Task.WhenAny(page.DeferredFetchObserved, Task.Delay(TimeSpan.FromSeconds(2)));
Assert.AreSame(page.DeferredFetchObserved, completed);
Assert.AreEqual(2, page.GetItemsCallCount);
viewModel.SafeCleanup();
viewModel.Dispose();
}
}