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,