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
return GetSnapshot().GetAllAssembliesAsync(); 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 { public int Count {
get { get {
lock (lockObj) lock (lockObj)

58
ICSharpCode.ILSpyX/AssemblyListSnapshot.cs

@ -22,6 +22,8 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using ICSharpCode.Decompiler.Metadata; using ICSharpCode.Decompiler.Metadata;
@ -171,48 +173,80 @@ namespace ICSharpCode.ILSpyX
public async Task<IList<LoadedAssembly>> GetAllAssembliesAsync() public async Task<IList<LoadedAssembly>> GetAllAssembliesAsync()
{ {
var results = new List<LoadedAssembly>(assemblies.Length); 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) foreach (var asm in assemblies)
{ {
LoadResult result; cancellationToken.ThrowIfCancellationRequested();
LoadResult? result = null;
try try
{ {
result = await asm.GetLoadResultAsync().ConfigureAwait(false); result = await asm.GetLoadResultAsync().ConfigureAwait(false);
} }
catch catch
{ {
results.Add(asm); // Load failure: still yield the assembly so the consumer can surface it.
continue; }
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) 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) foreach (var subFolder in folder.Folders)
{ {
AddDescendants(subFolder); foreach (var descendant in EnumerateDescendants(subFolder))
{
yield return descendant;
}
} }
foreach (var entry in folder.Entries) foreach (var entry in folder.Entries)
{ {
if (!entry.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && !entry.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) if (!entry.Name.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && !entry.Name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
continue; 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) if (asm == null)
continue; continue;
results.Add(asm); yield return asm;
} }
} }
return results;
} }
} }
} }

4
ILSpy.Tests/TreeNodes/TypeSystemSharingTests.cs

@ -24,6 +24,7 @@ using Avalonia.Headless.NUnit;
using AwesomeAssertions; using AwesomeAssertions;
using ICSharpCode.ILSpy.AppEnv; using ICSharpCode.ILSpy.AppEnv;
using ICSharpCode.ILSpy.AssemblyTree;
using ICSharpCode.ILSpy.Languages; using ICSharpCode.ILSpy.Languages;
using ICSharpCode.ILSpy.Search; using ICSharpCode.ILSpy.Search;
using ICSharpCode.ILSpy.TreeNodes; using ICSharpCode.ILSpy.TreeNodes;
@ -75,7 +76,8 @@ public class TypeSystemSharingTests
var language = AppComposition.Current.GetExport<LanguageService>().CurrentLanguage; var language = AppComposition.Current.GetExport<LanguageService>().CurrentLanguage;
var search = new RunningSearch( 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), ApiVisibility.PublicAndInternal, new AvaloniaSearchResultFactory(language),
new ObservableCollection<SearchResult>(), SearchResult.ComparerByName); new ObservableCollection<SearchResult>(), SearchResult.ComparerByName);
var request = search.BuildRequest(); var request = search.BuildRequest();

2
ILSpy/Controls/Omnibar/OmnibarViewModel.cs

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

21
ILSpy/Search/RunningSearch.cs

@ -53,7 +53,7 @@ namespace ICSharpCode.ILSpy.Search
// when the queue is jammed with thousands of late-arriving hits. // when the queue is jammed with thousands of late-arriving hits.
const int RefreshTimeBudgetMs = 10; const int RefreshTimeBudgetMs = 10;
readonly IReadOnlyList<LoadedAssembly> assemblies; readonly AssemblyList assemblyList;
readonly SearchMode mode; readonly SearchMode mode;
readonly string searchTerm; readonly string searchTerm;
readonly Language language; readonly Language language;
@ -70,7 +70,7 @@ namespace ICSharpCode.ILSpy.Search
bool completedRaised; bool completedRaised;
public RunningSearch( public RunningSearch(
IReadOnlyList<LoadedAssembly> assemblies, AssemblyList assemblyList,
string searchTerm, string searchTerm,
SearchMode mode, SearchMode mode,
Language language, Language language,
@ -79,7 +79,7 @@ namespace ICSharpCode.ILSpy.Search
ObservableCollection<SearchResult> sink, ObservableCollection<SearchResult> sink,
IComparer<SearchResult> sortComparer) IComparer<SearchResult> sortComparer)
{ {
this.assemblies = assemblies; this.assemblyList = assemblyList;
this.searchTerm = searchTerm; this.searchTerm = searchTerm;
this.mode = mode; this.mode = mode;
this.language = language; this.language = language;
@ -123,7 +123,7 @@ namespace ICSharpCode.ILSpy.Search
RaiseCompletedIfFirst(); RaiseCompletedIfFirst();
} }
void RunSearch(CancellationToken ct) async Task RunSearch(CancellationToken ct)
{ {
try try
{ {
@ -131,20 +131,23 @@ namespace ICSharpCode.ILSpy.Search
var strategy = GetStrategy(request); var strategy = GetStrategy(request);
if (strategy == null) if (strategy == null)
return; 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 // Serial walk: per-assembly metadata walk is allocation-dominated, and 4
// parallel producers fighting for the ConcurrentQueue + the resulting UI // parallel producers fighting for the ConcurrentQueue + the resulting UI
// batching jitter end up slower than serial in practice. // 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) if (ct.IsCancellationRequested)
break; break;
MetadataFile? module; MetadataFile? module;
try try
{ {
// Block here — we're already on a worker thread (Task.Run) and module = await assembly.GetMetadataFileAsync().ConfigureAwait(false);
// 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();
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {

2
ILSpy/Search/SearchPaneModel.cs

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

Loading…
Cancel
Save