@@ -500,6 +500,19 @@ private void UpdateSearchTextCore(string oldSearch, string newSearch, bool isUse
500500 }
501501
502502 var commands = _tlcManager . TopLevelCommands ;
503+
504+ // Materialized inputs captured under the lock so the heavy scoring below can run OFF the
505+ // lock. Scoring no longer converts directly into blocked settle/render time, because
506+ // GetItems() takes the SAME lock and now only contends with the short snapshot/publish
507+ // sections rather than the whole multi-thousand-item scoring pass.
508+ IReadOnlyList < IListItem > itemsSource ;
509+ IReadOnlyList < IListItem > appsSource ;
510+ IReadOnlyList < IListItem > fallbackSource ;
511+ IListItem [ ] globalFallbackSources ;
512+ bool includeAppsSnapshot ;
513+ bool tookFullCatalog = false ;
514+
515+ // ===== SNAPSHOT PHASE (under lock) =====
503516 lock ( commands )
504517 {
505518 if ( token . IsCancellationRequested )
@@ -553,52 +566,28 @@ private void UpdateSearchTextCore(string oldSearch, string newSearch, bool isUse
553566 return ;
554567 }
555568
556- // If the new string doesn't start with the old string, then we can't
557- // re-use previous results. Reset _filteredItems, and keep er moving.
558- if ( ! newSearch . StartsWith ( oldSearch , StringComparison . CurrentCultureIgnoreCase ) )
559- {
560- ClearResults ( ) ;
561- }
562-
563- // If the internal state has changed, reset _filteredItems to reset the list.
564- if ( _filteredItemsIncludesApps != _includeApps )
565- {
566- ClearResults ( ) ;
567- }
568-
569- if ( token . IsCancellationRequested )
570- {
571- return ;
572- }
573-
574- var newFilteredItems = Enumerable . Empty < IListItem > ( ) ;
575- var newFallbacks = Enumerable . Empty < IListItem > ( ) ;
576- var newApps = Enumerable . Empty < IListItem > ( ) ;
577-
578- if ( _filteredItems is not null )
579- {
580- newFilteredItems = _filteredItems . Select ( s => s . Item ) ;
581- }
582-
583- if ( token . IsCancellationRequested )
584- {
585- return ;
586- }
587-
588- if ( _filteredApps is not null )
589- {
590- newApps = _filteredApps . Select ( s => s . Item ) ;
591- }
592-
593- if ( token . IsCancellationRequested )
594- {
595- return ;
596- }
597-
598- if ( _fallbackItems is not null )
599- {
600- newFallbacks = _fallbackItems . Select ( s => s . Item ) ;
601- }
569+ includeAppsSnapshot = _includeApps ;
570+
571+ // If the new string doesn't start with the old string we can't re-use previous
572+ // results; likewise if the app-inclusion state changed. Either way we rebuild from the
573+ // full catalog. On an extend (the new query starts with the old and app state is
574+ // unchanged) we re-score only the previously matched subset, not the whole catalog.
575+ var reset = ! newSearch . StartsWith ( oldSearch , StringComparison . CurrentCultureIgnoreCase )
576+ || _filteredItemsIncludesApps != includeAppsSnapshot ;
577+
578+ var prevFilteredItems = reset ? null : _filteredItems ;
579+ var prevApps = reset ? null : _filteredApps ;
580+ var prevFallbacks = reset ? null : _fallbackItems ;
581+
582+ IEnumerable < IListItem > newFilteredItems = prevFilteredItems is not null
583+ ? prevFilteredItems . Select ( s => s . Item )
584+ : Enumerable . Empty < IListItem > ( ) ;
585+ IEnumerable < IListItem > newApps = prevApps is not null
586+ ? prevApps . Select ( s => s . Item )
587+ : Enumerable . Empty < IListItem > ( ) ;
588+ IEnumerable < IListItem > newFallbacks = prevFallbacks is not null
589+ ? prevFallbacks . Select ( s => s . Item )
590+ : Enumerable . Empty < IListItem > ( ) ;
602591
603592 if ( token . IsCancellationRequested )
604593 {
@@ -609,6 +598,7 @@ private void UpdateSearchTextCore(string oldSearch, string newSearch, bool isUse
609598 // with a list of all our commands & apps.
610599 if ( ! newFilteredItems . Any ( ) && ! newApps . Any ( ) )
611600 {
601+ tookFullCatalog = true ;
612602 newFilteredItems = commands . Where ( s => ! s . IsFallback ) ;
613603
614604 // Fallbacks are always included in the list, even if they
@@ -621,9 +611,7 @@ private void UpdateSearchTextCore(string oldSearch, string newSearch, bool isUse
621611 return ;
622612 }
623613
624- _filteredItemsIncludesApps = _includeApps ;
625-
626- if ( _includeApps )
614+ if ( includeAppsSnapshot )
627615 {
628616 var allNewApps = AllAppsCommandProvider . Page . GetItems ( ) . Cast < AppListItem > ( ) . ToList ( ) ;
629617
@@ -646,49 +634,88 @@ private void UpdateSearchTextCore(string oldSearch, string newSearch, bool isUse
646634 }
647635 }
648636
649- var searchQuery = _fuzzyMatcherProvider . Current . PrecomputeQuery ( SearchText ) ;
637+ // Materialize every source sequence while still under the lock. After this point the
638+ // scoring passes never touch the live TopLevelCommands collection or the app provider,
639+ // so they are free to run off the lock. globalFallbackSources reuses the specialFallbacks
640+ // list already built above, dropping the redundant second commands.Where(...) pass.
641+ itemsSource = MaterializeSource ( newFilteredItems ) ;
642+ appsSource = MaterializeSource ( newApps ) ;
643+ fallbackSource = MaterializeSource ( newFallbacks ) ;
644+ globalFallbackSources = [ .. specialFallbacks ] ;
645+ }
650646
651- // Produce a list of everything that matches the current filter.
652- _filteredItems = InternalListHelpers . FilterListWithScores ( newFilteredItems , searchQuery , _scoringFunction ) ;
647+ if ( token . IsCancellationRequested )
648+ {
649+ return ;
650+ }
653651
654- if ( token . IsCancellationRequested )
655- {
656- return ;
657- }
652+ // ===== SCORING PHASE (OFF the lock) =====
653+ // The dominant apps pass is parallelized; commands and fallbacks stay serial. All of this
654+ // now runs without holding the TopLevelCommands lock, so it no longer blocks GetItems()/render.
655+ var searchQuery = _fuzzyMatcherProvider . Current . PrecomputeQuery ( SearchText ) ;
658656
659- // Snapshot the global fallbacks and query, but do NOT score them here. Their dynamic
660- // titles are updated asynchronously by the fallback update manager (BeginUpdate, above),
661- // which runs off the typing path. Scoring is deferred to the render path so late fallback
662- // resolutions fold in with fresh scores instead of a value frozen against a stale title.
663- _globalFallbackSources = commands . Where ( s => s . IsFallback && configuredGlobalFallbackIds . Contains ( s . Id ) ) . ToArray ( ) ;
664- _globalFallbackQuery = searchQuery ;
657+ var scoredFilteredItems = InternalListHelpers . FilterListWithScores ( itemsSource , searchQuery , _scoringFunction ) ;
658+
659+ if ( token . IsCancellationRequested )
660+ {
661+ return ;
662+ }
663+
664+ var scoredFallbackItems = InternalListHelpers . FilterListWithScores ( fallbackSource , searchQuery , _fallbackScoringFunction ) ;
665+
666+ if ( token . IsCancellationRequested )
667+ {
668+ return ;
669+ }
670+
671+ RoScored < IListItem > [ ] ? scoredApps = null ;
672+ if ( appsSource . Count > 0 )
673+ {
674+ // Build the frecency lookup once, single-threaded, before the parallel pass reads it.
675+ // The lazy dictionary build in RecentCommandsManager is not thread-safe.
676+ _appStateService . State . RecentCommands . PrewarmIndex ( ) ;
677+
678+ scoredApps = InternalListHelpers . FilterListWithScoresParallel ( appsSource , searchQuery , _scoringFunction ) ;
665679
666680 if ( token . IsCancellationRequested )
667681 {
668682 return ;
669683 }
684+ }
670685
671- _fallbackItems = InternalListHelpers . FilterListWithScores < IListItem > ( newFallbacks ?? [ ] , searchQuery , _fallbackScoringFunction ) ;
686+ #if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
687+ var filterDoneTimestamp = stopwatch . ElapsedMilliseconds ;
688+ #endif
672689
690+ // ===== PUBLISH PHASE (under lock): atomic swap, supersede-safe =====
691+ lock ( commands )
692+ {
693+ // A newer keystroke cancels this token before doing its own work, so a stale snapshot's
694+ // finished results can never overwrite a newer query's. Skip the swap when superseded.
673695 if ( token . IsCancellationRequested )
674696 {
675697 return ;
676698 }
677699
678- // Produce a list of filtered apps with the appropriate limit
679- if ( newApps . Any ( ) )
700+ if ( tookFullCatalog )
680701 {
681- _filteredApps = InternalListHelpers . FilterListWithScores ( newApps , searchQuery , _scoringFunction ) ;
682-
683- if ( token . IsCancellationRequested )
684- {
685- return ;
686- }
702+ _filteredItemsIncludesApps = includeAppsSnapshot ;
687703 }
688704
689- #if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
690- var filterDoneTimestamp = stopwatch . ElapsedMilliseconds ;
691- #endif
705+ _filteredItems = scoredFilteredItems ;
706+ _fallbackItems = scoredFallbackItems ;
707+
708+ // Snapshot the global fallbacks and query, but do NOT score them here. Their dynamic
709+ // titles are updated asynchronously by the fallback update manager (BeginUpdate, above),
710+ // which runs off the typing path. Scoring is deferred to the render path so late fallback
711+ // resolutions fold in with fresh scores instead of a value frozen against a stale title.
712+ _globalFallbackSources = globalFallbackSources ;
713+ _globalFallbackQuery = searchQuery ;
714+
715+ // Apps are only re-scored when there is an apps source (either the full catalog with
716+ // apps included, or a retained subset on the extend path). When there is none, publish
717+ // null so a rebuild clears any stale set - matching the previous ClearResults behavior.
718+ _filteredApps = appsSource . Count > 0 ? scoredApps : null ;
692719
693720 // Queue a settled-search telemetry event. This measures the deterministic first-paint
694721 // results (commands + capped apps) and the latency to produce them, at this boundary -
@@ -727,6 +754,13 @@ private void UpdateSearchTextCore(string oldSearch, string newSearch, bool isUse
727754 }
728755 }
729756
757+ // Materializes a source sequence into a stable, indexable snapshot so the scoring passes can
758+ // run off the TopLevelCommands lock. Sequences that are already an IReadOnlyList (our own
759+ // freshly built lists and empty arrays) are used as-is; lazy LINQ over the live collection or
760+ // over previous results is copied so later mutation of the source can't affect scoring.
761+ private static IReadOnlyList < IListItem > MaterializeSource ( IEnumerable < IListItem > items )
762+ => items as IReadOnlyList < IListItem > ?? items . ToArray ( ) ;
763+
730764 private bool ActuallyLoading ( )
731765 {
732766 var allApps = AllAppsCommandProvider . Page ;
0 commit comments