CmdPal: Add adaptive parallel fallback processing and consistent updates (#42273)

## Summary of the Pull Request

This PR replaces sequential fallback processing with an adaptive
parallel dispatch model and isolates fallback work onto a dedicated
thread pool, preventing misbehaving extensions from starving the .NET
ThreadPool on blocked synchronous COM RPC call.

The other major change is allowing MainListPage to allow take control
over the debounce of search updates directly, reducing latency and
improving smoothness of the search.

- Adds `DedicatedThreadPool` — an elastic pool of background threads
(min 2, max 32) that expand on demand when all threads are blocked in
COM calls and shrink after 30s idle.
- Extracts all fallback dispatch machinery (adaptive workers,
per-command inflight tracking, pending-retry slots) from MainListPage
into a standalone FallbackUpdateManager.
- Prevents one fallback from monopolizing all threads by capping
concurrent in-flight calls per fallback to 4.
- Starts with low degree of parallelism (2) and gently scales up to half
of CPU cores per batch. If a fallback takes more than 200ms, another
worker is spawned so remaining commands aren't blocked.
- Adds `ThrottledDebouncedAction` to coalesce rapid `RaiseItemsChanged`
calls from fallback completions and user input (100ms for external
events, 50ms adjusted for keystrokes), replacing unbatched direct calls.
- Bypasses the UI-layer debounce timer for the main list page since it
now handles its own throttling, eliminating double-debounce latency.
- Introduces diagnostics for fallbacks and timing hidden behind feature
flags.


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

- [x] Closes: #42286
- [x] Related to: #44407
- [ ] **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-05 21:34:24 +01:00
committed by GitHub
parent f651d1a611
commit f0134e4448
10 changed files with 898 additions and 62 deletions

View File

@@ -2,13 +2,17 @@
// 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.Collections.Immutable;
/*
#define CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
*/
using System.Collections.Specialized;
using System.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.CmdPal.UI.ViewModels.Commands;
@@ -25,8 +29,17 @@ namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
/// </summary>
public sealed partial class MainListPage : DynamicListPage,
IRecipient<ClearSearchMessage>,
IRecipient<UpdateFallbackItemsMessage>, IDisposable
IRecipient<UpdateFallbackItemsMessage>,
IDisposable
{
// Throttle for raising items changed events from external sources
private static readonly TimeSpan RaiseItemsChangedThrottle = TimeSpan.FromMilliseconds(100);
// Throttle for raising items changed events from user input - we want this to feel more responsive, so a shorter throttle.
private static readonly TimeSpan RaiseItemsChangedThrottleForUserInput = TimeSpan.FromMilliseconds(50);
private readonly FallbackUpdateManager _fallbackUpdateManager;
private readonly ThrottledDebouncedAction _refreshThrottledDebouncedAction;
private readonly TopLevelCommandManager _tlcManager;
private readonly AliasManager _aliasManager;
private readonly SettingsModel _settings;
@@ -54,11 +67,16 @@ public sealed partial class MainListPage : DynamicListPage,
private int AppResultLimit => AllAppsCommandProvider.TopLevelResultLimit;
private InterlockedBoolean _fullRefreshRequested;
private InterlockedBoolean _refreshRunning;
private InterlockedBoolean _refreshRequested;
private CancellationTokenSource? _cancellationTokenSource;
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
private DateTimeOffset _last = DateTimeOffset.UtcNow;
#endif
public MainListPage(
TopLevelCommandManager topLevelCommandManager,
SettingsModel settings,
@@ -82,16 +100,52 @@ public sealed partial class MainListPage : DynamicListPage,
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
_refreshThrottledDebouncedAction = new ThrottledDebouncedAction(
() =>
{
try
{
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
var delta = DateTimeOffset.UtcNow - _last;
_last = DateTimeOffset.UtcNow;
Logger.LogDebug($"UpdateFallbacks: RaiseItemsChanged, delta {delta}");
var sw = Stopwatch.StartNew();
#endif
if (_fullRefreshRequested.Clear())
{
// full refresh
RaiseItemsChanged();
}
else
{
// preserve selection
RaiseItemsChanged(ListViewModel.IncrementalRefresh);
}
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
Logger.LogInfo($"UpdateFallbacks: RaiseItemsChanged took {sw.Elapsed}");
#endif
}
catch (Exception ex)
{
Logger.LogError("Unhandled exception in MainListPage refresh debounced action", ex);
}
},
RaiseItemsChangedThrottle);
_fallbackUpdateManager = new FallbackUpdateManager(() => RequestRefresh(fullRefresh: false));
// The all apps page will kick off a BG thread to start loading apps.
// We just want to know when it is done.
var allApps = AllAppsCommandProvider.Page;
allApps.PropChanged += (s, p) =>
{
if (p.PropertyName == nameof(allApps.IsLoading))
{
if (p.PropertyName == nameof(allApps.IsLoading))
{
IsLoading = ActuallyLoading();
}
};
IsLoading = ActuallyLoading();
}
};
WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateFallbackItemsMessage>(this);
@@ -120,10 +174,20 @@ public sealed partial class MainListPage : DynamicListPage,
}
else
{
RaiseItemsChanged(ListViewModel.IncrementalRefresh);
RequestRefresh(fullRefresh: false);
}
}
private void RequestRefresh(bool fullRefresh, TimeSpan? interval = null)
{
if (fullRefresh)
{
_fullRefreshRequested.Set();
}
_refreshThrottledDebouncedAction.Invoke(interval);
}
private void ReapplySearchInBackground()
{
_refreshRequested.Set();
@@ -151,7 +215,7 @@ public sealed partial class MainListPage : DynamicListPage,
}
var currentSearchText = SearchText;
UpdateSearchText(currentSearchText, currentSearchText);
UpdateSearchTextCore(currentSearchText, currentSearchText, isUserInput: false);
}
while (_refreshRequested.Value);
}
@@ -243,6 +307,11 @@ public sealed partial class MainListPage : DynamicListPage,
}
public override void UpdateSearchText(string oldSearch, string newSearch)
{
UpdateSearchTextCore(oldSearch, newSearch, isUserInput: true);
}
private void UpdateSearchTextCore(string oldSearch, string newSearch, bool isUserInput)
{
var stopwatch = Stopwatch.StartNew();
@@ -297,7 +366,7 @@ public sealed partial class MainListPage : DynamicListPage,
// prefilter fallbacks
var globalFallbacks = _settings.GetGlobalFallbacks();
var specialFallbacks = new List<TopLevelViewModel>(globalFallbacks.Length);
var commonFallbacks = new List<TopLevelViewModel>();
var commonFallbacks = new List<TopLevelViewModel>(commands.Count - globalFallbacks.Length);
foreach (var s in commands)
{
@@ -316,10 +385,7 @@ public sealed partial class MainListPage : DynamicListPage,
}
}
// start update of fallbacks; update special fallbacks separately,
// so they can finish faster
UpdateFallbacks(SearchText, specialFallbacks, token);
UpdateFallbacks(SearchText, commonFallbacks, token);
_fallbackUpdateManager.BeginUpdate(SearchText, [.. specialFallbacks, .. commonFallbacks], token);
if (token.IsCancellationRequested)
{
@@ -327,11 +393,13 @@ public sealed partial class MainListPage : DynamicListPage,
}
// Cleared out the filter text? easy. Reset _filteredItems, and bail out.
if (string.IsNullOrEmpty(newSearch))
if (string.IsNullOrWhiteSpace(newSearch))
{
_filteredItemsIncludesApps = _includeApps;
ClearResults();
RaiseItemsChanged();
var wasAlreadyEmpty = string.IsNullOrWhiteSpace(oldSearch);
RequestRefresh(fullRefresh: true, interval: wasAlreadyEmpty ? null : TimeSpan.Zero);
return;
}
@@ -466,49 +534,35 @@ public sealed partial class MainListPage : DynamicListPage,
}
}
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
Logger.LogDebug($"Filter with '{newSearch}' in {filterDoneTimestamp}ms");
#endif
if (isUserInput)
{
// Make sure that the throttle delay is consistent from the user's perspective, even if filtering
// takes a long time. If we always use the full throttle duration, then a slow filter could make the UI feel sluggish.
var adjustedInterval = RaiseItemsChangedThrottleForUserInput - stopwatch.Elapsed;
if (adjustedInterval < TimeSpan.Zero)
{
adjustedInterval = TimeSpan.Zero;
}
RaiseItemsChanged();
RequestRefresh(fullRefresh: true, adjustedInterval);
}
else
{
RequestRefresh(fullRefresh: true);
}
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
var listPageUpdatedTimestamp = stopwatch.ElapsedMilliseconds;
Logger.LogDebug($"Render items with '{newSearch}' in {listPageUpdatedTimestamp}ms /d {listPageUpdatedTimestamp - filterDoneTimestamp}ms");
#endif
stopwatch.Stop();
}
}
private void UpdateFallbacks(string newSearch, IReadOnlyList<TopLevelViewModel> commands, CancellationToken token)
{
_ = Task.Run(
() =>
{
var needsToUpdate = false;
foreach (var command in commands)
{
if (token.IsCancellationRequested)
{
return;
}
var changedVisibility = command.SafeUpdateFallbackTextSynchronous(newSearch);
needsToUpdate = needsToUpdate || changedVisibility;
}
if (needsToUpdate)
{
if (token.IsCancellationRequested)
{
return;
}
RaiseItemsChanged(ListViewModel.IncrementalRefresh);
}
},
token);
}
private bool ActuallyLoading()
{
var allApps = AllAppsCommandProvider.Page;
@@ -644,7 +698,10 @@ public sealed partial class MainListPage : DynamicListPage,
public void Receive(ClearSearchMessage message) => SearchText = string.Empty;
public void Receive(UpdateFallbackItemsMessage message) => RaiseItemsChanged(_tlcManager.TopLevelCommands.Count);
public void Receive(UpdateFallbackItemsMessage message)
{
RequestRefresh(fullRefresh: false);
}
private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender);
@@ -654,6 +711,7 @@ public sealed partial class MainListPage : DynamicListPage,
{
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
_fallbackUpdateManager.Dispose();
_tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;