From b2b82ce6a8f06c494e5cd6ddc4695d96ef09879e Mon Sep 17 00:00:00 2001 From: Siegfried Pammer Date: Mon, 17 Aug 2026 09:23:39 +0200 Subject: [PATCH] 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 --- ICSharpCode.ILSpyX/AssemblyList.cs | 9 +++ ICSharpCode.ILSpyX/AssemblyListSnapshot.cs | 58 +++++++++++++++---- .../TreeNodes/TypeSystemSharingTests.cs | 4 +- ILSpy/Controls/Omnibar/OmnibarViewModel.cs | 2 +- ILSpy/Search/RunningSearch.cs | 21 ++++--- ILSpy/Search/SearchPaneModel.cs | 2 +- 6 files changed, 72 insertions(+), 24 deletions(-) diff --git a/ICSharpCode.ILSpyX/AssemblyList.cs b/ICSharpCode.ILSpyX/AssemblyList.cs index 86b0e1cc8..d53f91a19 100644 --- a/ICSharpCode.ILSpyX/AssemblyList.cs +++ b/ICSharpCode.ILSpyX/AssemblyList.cs @@ -161,6 +161,15 @@ namespace ICSharpCode.ILSpyX return GetSnapshot().GetAllAssembliesAsync(); } + /// + /// Streaming variant of , for consumers that can act on each + /// assembly as it loads instead of waiting for the whole list. + /// + public IAsyncEnumerable EnumerateAllAssemblies(CancellationToken cancellationToken = default) + { + return GetSnapshot().EnumerateAllAssembliesAsync(cancellationToken); + } + public int Count { get { lock (lockObj) diff --git a/ICSharpCode.ILSpyX/AssemblyListSnapshot.cs b/ICSharpCode.ILSpyX/AssemblyListSnapshot.cs index 5a1b6edcf..5c520fc83 100644 --- a/ICSharpCode.ILSpyX/AssemblyListSnapshot.cs +++ b/ICSharpCode.ILSpyX/AssemblyListSnapshot.cs @@ -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 public async Task> GetAllAssembliesAsync() { var results = new List(assemblies.Length); + await foreach (var asm in EnumerateAllAssembliesAsync().ConfigureAwait(false)) + { + results.Add(asm); + } + return results; + } + /// + /// Streaming variant of : 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. + /// + public async IAsyncEnumerable 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 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; } } } diff --git a/ILSpy.Tests/TreeNodes/TypeSystemSharingTests.cs b/ILSpy.Tests/TreeNodes/TypeSystemSharingTests.cs index 119e4aa96..87c488623 100644 --- a/ILSpy.Tests/TreeNodes/TypeSystemSharingTests.cs +++ b/ILSpy.Tests/TreeNodes/TypeSystemSharingTests.cs @@ -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 var language = AppComposition.Current.GetExport().CurrentLanguage; var search = new RunningSearch( - new[] { fixture }, FixtureAssembly.TypeName, SearchMode.TypeAndMember, language, + AppComposition.Current.GetExport().AssemblyList!, + FixtureAssembly.TypeName, SearchMode.TypeAndMember, language, ApiVisibility.PublicAndInternal, new AvaloniaSearchResultFactory(language), new ObservableCollection(), SearchResult.ComparerByName); var request = search.BuildRequest(); diff --git a/ILSpy/Controls/Omnibar/OmnibarViewModel.cs b/ILSpy/Controls/Omnibar/OmnibarViewModel.cs index 1f9099978..3486429f0 100644 --- a/ILSpy/Controls/Omnibar/OmnibarViewModel.cs +++ b/ILSpy/Controls/Omnibar/OmnibarViewModel.cs @@ -213,7 +213,7 @@ namespace ICSharpCode.ILSpy.Controls.Omnibar ?? ApiVisibility.PublicOnly; var run = new RunningSearch( - assemblyList.GetAssemblies(), + assemblyList, term, SearchMode.TypeAndMember, language, diff --git a/ILSpy/Search/RunningSearch.cs b/ILSpy/Search/RunningSearch.cs index 9b73dd479..2801571fd 100644 --- a/ILSpy/Search/RunningSearch.cs +++ b/ILSpy/Search/RunningSearch.cs @@ -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 assemblies; + readonly AssemblyList assemblyList; readonly SearchMode mode; readonly string searchTerm; readonly Language language; @@ -70,7 +70,7 @@ namespace ICSharpCode.ILSpy.Search bool completedRaised; public RunningSearch( - IReadOnlyList assemblies, + AssemblyList assemblyList, string searchTerm, SearchMode mode, Language language, @@ -79,7 +79,7 @@ namespace ICSharpCode.ILSpy.Search ObservableCollection sink, IComparer sortComparer) { - this.assemblies = assemblies; + this.assemblyList = assemblyList; this.searchTerm = searchTerm; this.mode = mode; this.language = language; @@ -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 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) { diff --git a/ILSpy/Search/SearchPaneModel.cs b/ILSpy/Search/SearchPaneModel.cs index 08094f1e3..56aca2131 100644 --- a/ILSpy/Search/SearchPaneModel.cs +++ b/ILSpy/Search/SearchPaneModel.cs @@ -251,7 +251,7 @@ namespace ICSharpCode.ILSpy.Search ? SearchResult.ComparerByFitness : SearchResult.ComparerByName; var run = new RunningSearch( - assemblyList.GetAssemblies(), + assemblyList, term, SelectedSearchMode.Mode, language,