CmdPal: Prevent unsynchornized access to More commands (#46020)

## Summary of the Pull Request

This PR fixes a crash caused by unsynchronized access to a context menu:

- Fixes `System.ArgumentException: Destination array was not long
enough` crash in `CommandItemViewModel.AllCommands` caused by
`List<T>.AddRange()` racing with background `BuildAndInitMoreCommands`
mutations
- Replaces mutable `List<T>` public surfaces with immutable array
snapshots protected by a `Lock`; writers hold the lock, mutate the
backing list, then atomically publish new snapshots via `volatile`
fields that readers access lock-free
- Applies the same snapshot pattern to `ContentPageViewModel`, using a
bundled `CommandSnapshot` object for atomic publication (since
`PrimaryCommand` drives command invocation there, not just UI hints)
- Narrows `IContextMenuContext.MoreCommands` and `AllCommands` from
`List<T>`/`IEnumerable<T>` to `IReadOnlyList<T>` to prevent consumers
from casting back and mutating
- Moves `SafeCleanup()` calls outside locks in cleanup paths to avoid
holding the lock during cross-process RPC calls

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

- [x] Closes: #45975 
<!-- - [ ] 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-09 23:34:44 +01:00
committed by GitHub
parent 77355ef2fb
commit 90131e35d9
7 changed files with 431 additions and 137 deletions

View File

@@ -0,0 +1,79 @@
// 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.Tasks;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class CommandItemViewModelTests
{
private sealed class TestPageContext : IPageContext
{
public TaskScheduler Scheduler => TaskScheduler.Default;
public ICommandProviderContext ProviderContext => CommandProviderContext.Empty;
public void ShowException(Exception ex, string? extensionHint = null)
{
throw new AssertFailedException($"Unexpected exception from view model: {ex}");
}
}
[TestMethod]
public void MoreCommandsAndAllCommands_ReturnSnapshots()
{
// The public getters should return cached read-only snapshots, so
// repeated reads don't allocate a new list when the backing data hasn't
// changed.
var pageContext = new TestPageContext();
var item = new CommandItem(new NoOpCommand { Name = "Primary" })
{
Title = "Primary",
MoreCommands =
[
new CommandContextItem(new NoOpCommand { Name = "Secondary" }),
],
};
var viewModel = new CommandItemViewModel(new(item), new(pageContext), DefaultContextMenuFactory.Instance);
viewModel.SlowInitializeProperties();
var moreCommands = viewModel.MoreCommands;
var allCommands = viewModel.AllCommands;
Assert.AreSame(moreCommands, viewModel.MoreCommands);
Assert.AreSame(allCommands, viewModel.AllCommands);
Assert.AreEqual(1, moreCommands.Count);
Assert.AreEqual(2, allCommands.Count);
}
[TestMethod]
public void SecondaryCommand_IgnoresLeadingSeparators()
{
// SecondaryCommand/HasMoreCommands should be derived from the first actual command item,
// not from the raw first entry in MoreCommands.
var pageContext = new TestPageContext();
var item = new CommandItem(new NoOpCommand { Name = "Primary" })
{
Title = "Primary",
MoreCommands =
[
new Separator("Group"),
new CommandContextItem(new NoOpCommand { Name = "Secondary" }),
],
};
var viewModel = new CommandItemViewModel(new(item), new(pageContext), DefaultContextMenuFactory.Instance);
viewModel.SlowInitializeProperties();
Assert.IsTrue(viewModel.HasMoreCommands);
Assert.IsNotNull(viewModel.SecondaryCommand);
Assert.AreEqual("Secondary", viewModel.SecondaryCommand.Name);
}
}

View File

@@ -0,0 +1,104 @@
// 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.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 ContentPageViewModelTests
{
private sealed partial class TestAppExtensionHost : AppExtensionHost
{
public override string? GetExtensionDisplayName() => "Test Host";
}
private sealed partial class TestContentPage : ContentPage
{
public override IContent[] GetContent() => [];
}
private static CommandContextItem Command(string name) => new(new NoOpCommand { Name = name });
private static ContentPageViewModel CreateViewModel(TestContentPage page) =>
new(page, TaskScheduler.Default, new TestAppExtensionHost(), CommandProviderContext.Empty);
[TestMethod]
public void AllCommandsAndMoreCommands_ReturnCachedSnapshots()
{
// Content pages should expose stable snapshots, not the live Commands
// list, so repeated reads don't allocate and callers can't observe
// in-place list mutations.
var page = new TestContentPage
{
Id = "content.page",
Name = "Content Page",
Title = "Content Page",
Commands =
[
Command("Primary"),
Command("Secondary"),
],
};
var viewModel = CreateViewModel(page);
viewModel.InitializeProperties();
var allCommands = viewModel.AllCommands;
var moreCommands = viewModel.MoreCommands;
Assert.AreSame(allCommands, viewModel.AllCommands);
Assert.AreSame(moreCommands, viewModel.MoreCommands);
Assert.AreEqual(2, allCommands.Count);
Assert.AreEqual(1, moreCommands.Count);
Assert.AreEqual("Primary", viewModel.PrimaryCommand?.Name);
Assert.AreEqual("Secondary", viewModel.SecondaryCommand?.Name);
}
[TestMethod]
public void CommandsUpdate_RefreshesSnapshotsConsistently()
{
// Updating the model commands should swap in a new coherent snapshot.
// The old snapshots stay intact, and the new cached values agree on
// counts, primary/secondary commands, and "has more" state.
var page = new TestContentPage
{
Id = "content.page",
Name = "Content Page",
Title = "Content Page",
Commands =
[
Command("Primary"),
Command("Secondary"),
],
};
var viewModel = CreateViewModel(page);
viewModel.InitializeProperties();
var oldAllCommands = viewModel.AllCommands;
var oldMoreCommands = viewModel.MoreCommands;
page.Commands =
[
Command("Updated Primary"),
new Separator("Group"),
Command("Updated Secondary"),
];
Assert.AreEqual(2, oldAllCommands.Count);
Assert.AreEqual(1, oldMoreCommands.Count);
Assert.AreEqual(3, viewModel.AllCommands.Count);
Assert.AreEqual(2, viewModel.MoreCommands.Count);
Assert.IsTrue(viewModel.HasCommands);
Assert.IsTrue(viewModel.HasMoreCommands);
Assert.AreEqual("Updated Primary", viewModel.PrimaryCommand?.Name);
Assert.AreEqual("Updated Secondary", viewModel.SecondaryCommand?.Name);
Assert.AreEqual("Updated Secondary", viewModel.SecondaryCommandName);
}
}