Browse Source

Stream the search's assembly walk instead of materializing it

Searching inside bundles and packages means expanding them, and the expansion
has to await each assembly's load result - which is what triggers the lazy
load in the first place. Building the full list up front (as the WPF pane did)
therefore means a search on a freshly restored list produces nothing at all
until the last assembly is off disk, and the blocking wait for it ignored the
cancellation token the pane fires on every keystroke.

The snapshot is still taken eagerly, before the first element is yielded, so
the set cannot change under a running walk; a failing assembly or an
unreadable package entry skips itself rather than abandoning the rest.

Assisted-by: Claude:claude-opus-5[1m]:Claude Code
pull/4023/head
Siegfried Pammer 1 month ago
parent
commit
b2b82ce6a8
  1. 9
      ICSharpCode.ILSpyX/AssemblyList.cs
  2. 58
      ICSharpCode.ILSpyX/AssemblyListSnapshot.cs
  3. 4
      ILSpy.Tests/TreeNodes/TypeSystemSharingTests.cs
  4. 2
      ILSpy/Controls/Omnibar/OmnibarViewModel.cs
  5. 21
      ILSpy/Search/RunningSearch.cs
  6. 2
      ILSpy/Search/SearchPaneModel.cs

9
ICSharpCode.ILSpyX/AssemblyList.cs

@ -161,6 +161,15 @@ namespace ICSharpCode.ILSpyX @@ -161,6 +161,15 @@ namespace ICSharpCode.ILSpyX
return GetSnapshot().GetAllAssembliesAsync();
}
/// <summary>
/// Streaming variant of <see cref="GetAllAssemblies"/>, for consumers that can act on each
/// assembly as it loads instead of waiting for the whole list.
/// </summary>
public IAsyncEnumerable<LoadedAssembly> EnumerateAllAssemblies(CancellationToken cancellationToken = default)
{
return GetSnapshot().EnumerateAllAssembliesAsync(cancellationToken);
}
public int Count {
get {
lock (lockObj)

58
ICSharpCode.ILSpyX/AssemblyListSnapshot.cs

@ -22,6 +22,8 @@ using System; @@ -22,6 +22,8 @@ using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using ICSharpCode.Decompiler.Metadata;
@ -171,48 +173,80 @@ namespace ICSharpCode.ILSpyX @@ -171,48 +173,80 @@ namespace ICSharpCode.ILSpyX
public async Task<IList<LoadedAssembly>> GetAllAssembliesAsync()
{
var results = new List<LoadedAssembly>(assemblies.Length);
await foreach (var asm in EnumerateAllAssembliesAsync().ConfigureAwait(false))
{
results.Add(asm);
}
return results;
}
/// <summary>
/// Streaming variant of <see cref="GetAllAssembliesAsync"/>: yields each assembly as soon
/// as it is known. Awaiting the load result is what triggers the lazy load, so a consumer
/// that materializes the whole sequence first waits for every assembly on the list to be
/// read off disk before it can do any work.
/// </summary>
public async IAsyncEnumerable<LoadedAssembly> EnumerateAllAssembliesAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
foreach (var asm in assemblies)
{
LoadResult result;
cancellationToken.ThrowIfCancellationRequested();
LoadResult? result = null;
try
{
result = await asm.GetLoadResultAsync().ConfigureAwait(false);
}
catch
{
results.Add(asm);
continue;
// Load failure: still yield the assembly so the consumer can surface it.
}
if (result == null)
{
yield return asm;
}
if (result.Package != null)
else if (result.Package != null)
{
AddDescendants(result.Package.RootFolder);
foreach (var descendant in EnumerateDescendants(result.Package.RootFolder))
{
yield return descendant;
}
}
else if (result.MetadataFile != null)
{
results.Add(asm);
yield return asm;
}
}
void AddDescendants(PackageFolder folder)
static IEnumerable<LoadedAssembly> EnumerateDescendants(PackageFolder folder)
{
foreach (var subFolder in folder.Folders)
{
AddDescendants(subFolder);
foreach (var descendant in EnumerateDescendants(subFolder))
{
yield return descendant;
}
}
foreach (var entry in folder.Entries)
{
if (!entry.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && !entry.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
continue;
var asm = folder.ResolveFileName(entry.Name);
LoadedAssembly? asm;
try
{
asm = folder.ResolveFileName(entry.Name);
}
catch
{
// One unreadable entry must not abandon the rest of the package.
continue;
}
if (asm == null)
continue;
results.Add(asm);
yield return asm;
}
}
return results;
}
}
}

4
ILSpy.Tests/TreeNodes/TypeSystemSharingTests.cs

@ -24,6 +24,7 @@ using Avalonia.Headless.NUnit; @@ -24,6 +24,7 @@ using Avalonia.Headless.NUnit;
using AwesomeAssertions;
using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Languages;
using ICSharpCode.ILSpy.Search;
using ICSharpCode.ILSpy.TreeNodes;
@ -75,7 +76,8 @@ public class TypeSystemSharingTests @@ -75,7 +76,8 @@ public class TypeSystemSharingTests
var language = AppComposition.Current.GetExport<LanguageService>().CurrentLanguage;
var search = new RunningSearch(
new[] { fixture }, FixtureAssembly.TypeName, SearchMode.TypeAndMember, language,
AppComposition.Current.GetExport<AssemblyTreeModel>().AssemblyList!,
FixtureAssembly.TypeName, SearchMode.TypeAndMember, language,
ApiVisibility.PublicAndInternal, new AvaloniaSearchResultFactory(language),
new ObservableCollection<SearchResult>(), SearchResult.ComparerByName);
var request = search.BuildRequest();

2
ILSpy/Controls/Omnibar/OmnibarViewModel.cs

@ -213,7 +213,7 @@ namespace ICSharpCode.ILSpy.Controls.Omnibar @@ -213,7 +213,7 @@ namespace ICSharpCode.ILSpy.Controls.Omnibar
?? ApiVisibility.PublicOnly;
var run = new RunningSearch(
assemblyList.GetAssemblies(),
assemblyList,
term,
SearchMode.TypeAndMember,
language,

21
ILSpy/Search/RunningSearch.cs

@ -53,7 +53,7 @@ namespace ICSharpCode.ILSpy.Search @@ -53,7 +53,7 @@ namespace ICSharpCode.ILSpy.Search
// when the queue is jammed with thousands of late-arriving hits.
const int RefreshTimeBudgetMs = 10;
readonly IReadOnlyList<LoadedAssembly> assemblies;
readonly AssemblyList assemblyList;
readonly SearchMode mode;
readonly string searchTerm;
readonly Language language;
@ -70,7 +70,7 @@ namespace ICSharpCode.ILSpy.Search @@ -70,7 +70,7 @@ namespace ICSharpCode.ILSpy.Search
bool completedRaised;
public RunningSearch(
IReadOnlyList<LoadedAssembly> assemblies,
AssemblyList assemblyList,
string searchTerm,
SearchMode mode,
Language language,
@ -79,7 +79,7 @@ namespace ICSharpCode.ILSpy.Search @@ -79,7 +79,7 @@ namespace ICSharpCode.ILSpy.Search
ObservableCollection<SearchResult> sink,
IComparer<SearchResult> sortComparer)
{
this.assemblies = assemblies;
this.assemblyList = assemblyList;
this.searchTerm = searchTerm;
this.mode = mode;
this.language = language;
@ -123,7 +123,7 @@ namespace ICSharpCode.ILSpy.Search @@ -123,7 +123,7 @@ namespace ICSharpCode.ILSpy.Search
RaiseCompletedIfFirst();
}
void RunSearch(CancellationToken ct)
async Task RunSearch(CancellationToken ct)
{
try
{
@ -131,20 +131,23 @@ namespace ICSharpCode.ILSpy.Search @@ -131,20 +131,23 @@ namespace ICSharpCode.ILSpy.Search
var strategy = GetStrategy(request);
if (strategy == null)
return;
// Streaming enumeration: expanding bundles/packages into their contained
// assemblies triggers the lazy load of every entry on the list, so awaiting
// the whole set up front would mean no results at all until the last
// assembly is off disk. Enumerating starts the walk here on the worker
// thread, not at construction time on the UI thread.
//
// Serial walk: per-assembly metadata walk is allocation-dominated, and 4
// parallel producers fighting for the ConcurrentQueue + the resulting UI
// batching jitter end up slower than serial in practice.
foreach (var assembly in assemblies)
await foreach (var assembly in assemblyList.EnumerateAllAssemblies(ct).ConfigureAwait(false))
{
if (ct.IsCancellationRequested)
break;
MetadataFile? module;
try
{
// Block here — we're already on a worker thread (Task.Run) and
// this matches the WPF call shape. ConfigureAwait in an async
// state machine would just add overhead for the same effect.
module = assembly.GetMetadataFileAsync().GetAwaiter().GetResult();
module = await assembly.GetMetadataFileAsync().ConfigureAwait(false);
}
catch (OperationCanceledException)
{

2
ILSpy/Search/SearchPaneModel.cs

@ -251,7 +251,7 @@ namespace ICSharpCode.ILSpy.Search @@ -251,7 +251,7 @@ namespace ICSharpCode.ILSpy.Search
? SearchResult.ComparerByFitness
: SearchResult.ComparerByName;
var run = new RunningSearch(
assemblyList.GetAssemblies(),
assemblyList,
term,
SelectedSearchMode.Mode,
language,

Loading…
Cancel
Save