mirror of https://github.com/icsharpcode/ILSpy.git
Browse Source
Wire the search pane to the shared ICSharpCode.ILSpyX.Search engine. Setting SearchTerm or SelectedSearchMode now cancels any in-flight run and starts a fresh one; cleared term tears the run down. Assisted-by: Claude:claude-opus-4-7:Claude Codepull/3755/head
5 changed files with 573 additions and 1 deletions
@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
|
||||
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
// software and associated documentation files (the "Software"), to deal in the Software
|
||||
// without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
// to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or
|
||||
// substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System; |
||||
using System.Linq; |
||||
using System.Threading.Tasks; |
||||
|
||||
using Avalonia.Headless.NUnit; |
||||
|
||||
using AwesomeAssertions; |
||||
|
||||
using ICSharpCode.ILSpyX.Search; |
||||
|
||||
using ILSpy.AppEnv; |
||||
using ILSpy.Search; |
||||
using ILSpy.ViewModels; |
||||
using ILSpy.Views; |
||||
|
||||
using NUnit.Framework; |
||||
|
||||
namespace ICSharpCode.ILSpy.Tests.Search; |
||||
|
||||
[TestFixture] |
||||
public class SearchPaneStreamingTests |
||||
{ |
||||
[AvaloniaTest] |
||||
public async Task Diagnostic_Direct_Strategy_Invocation_Surfaces_Type_Results() |
||||
{ |
||||
// Diagnostic: drive a MemberSearchStrategy directly to confirm the shared search
|
||||
// logic works for the fixture. If this fails, the bug is in the strategy / fixture;
|
||||
// if it passes but the orchestrator test below fails, the bug is in our wiring.
|
||||
|
||||
var window = AppComposition.Current.GetExport<MainWindow>(); |
||||
window.Show(); |
||||
var vm = (MainWindowViewModel)window.DataContext!; |
||||
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); |
||||
|
||||
var coreLibName = typeof(object).Assembly.GetName().Name!; |
||||
var assemblyNode = vm.AssemblyTreeModel.FindNode<global::ILSpy.TreeNodes.AssemblyTreeNode>(coreLibName); |
||||
var module = await assemblyNode.LoadedAssembly.GetMetadataFileAsync(); |
||||
|
||||
var queue = new System.Collections.Concurrent.ConcurrentQueue<ICSharpCode.ILSpyX.Search.SearchResult>(); |
||||
var language = AppComposition.Current.GetExport<global::ILSpy.Languages.LanguageService>().CurrentLanguage; |
||||
var request = new ICSharpCode.ILSpyX.Search.SearchRequest { |
||||
Mode = SearchMode.Type, |
||||
Keywords = new[] { "Enumerable" }, |
||||
SearchResultFactory = new global::ILSpy.Search.AvaloniaSearchResultFactory(language), |
||||
DecompilerSettings = new ICSharpCode.Decompiler.DecompilerSettings(), |
||||
FullNameSearch = false, |
||||
OmitGenerics = false, |
||||
InNamespace = null!, |
||||
InAssembly = null!, |
||||
}; |
||||
var strategy = new ICSharpCode.ILSpyX.Search.MemberSearchStrategy( |
||||
language, ICSharpCode.ILSpyX.ApiVisibility.All, request, queue, |
||||
ICSharpCode.ILSpyX.Search.MemberSearchKind.Type); |
||||
|
||||
strategy.Search(module, default); |
||||
queue.Count.Should().BeGreaterThan(0, |
||||
"if the strategy can't find Enumerable in the fixture, the orchestrator certainly won't either"); |
||||
} |
||||
|
||||
[AvaloniaTest] |
||||
public async Task Typing_A_Term_Surfaces_Matching_Type_Results_From_The_Loaded_AssemblyList() |
||||
{ |
||||
// End-to-end: set SearchTerm to "Enumerable", let the orchestrator run a TypeAndMember
|
||||
// search across the loaded fixture assemblies, and wait for at least one matching
|
||||
// result to land in the Results collection.
|
||||
|
||||
var window = AppComposition.Current.GetExport<MainWindow>(); |
||||
window.Show(); |
||||
var vm = (MainWindowViewModel)window.DataContext!; |
||||
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); |
||||
|
||||
var search = AppComposition.Current.GetExport<SearchPaneModel>(); |
||||
search.Results.Clear(); |
||||
search.SelectedSearchMode = search.SearchModes.First(m => m.Mode == SearchMode.Type); |
||||
search.SearchTerm = "Enumerable"; |
||||
|
||||
await Waiters.WaitForAsync( |
||||
() => search.Results.Any(r => r.Name.Contains("Enumerable", StringComparison.OrdinalIgnoreCase)), |
||||
timeout: TimeSpan.FromSeconds(30)); |
||||
|
||||
search.Results.Should().NotBeEmpty( |
||||
"the Type search strategy must have surfaced at least one match within the timeout"); |
||||
search.Results.Should().Contain(r => r.Name.Contains("Enumerable", StringComparison.OrdinalIgnoreCase)); |
||||
} |
||||
|
||||
[AvaloniaTest] |
||||
public async Task Clearing_The_Search_Term_Empties_The_Result_List() |
||||
{ |
||||
// Switching to an empty term tears down the running search and clears the visible
|
||||
// results. The user shouldn't see stale rows after they've explicitly cleared the box.
|
||||
|
||||
var window = AppComposition.Current.GetExport<MainWindow>(); |
||||
window.Show(); |
||||
var vm = (MainWindowViewModel)window.DataContext!; |
||||
await vm.AssemblyTreeModel.WaitForAssembliesAsync(minimumCount: 1); |
||||
|
||||
var search = AppComposition.Current.GetExport<SearchPaneModel>(); |
||||
search.Results.Clear(); |
||||
search.SelectedSearchMode = search.SearchModes.First(m => m.Mode == SearchMode.Type); |
||||
search.SearchTerm = "Enumerable"; |
||||
|
||||
await Waiters.WaitForAsync(() => search.Results.Count > 0, timeout: TimeSpan.FromSeconds(30)); |
||||
|
||||
search.SearchTerm = string.Empty; |
||||
await Waiters.WaitForAsync(() => search.Results.Count == 0, timeout: TimeSpan.FromSeconds(5)); |
||||
search.Results.Should().BeEmpty("an empty search term must clear stale results"); |
||||
} |
||||
} |
||||
@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
// software and associated documentation files (the "Software"), to deal in the Software
|
||||
// without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
// to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or
|
||||
// substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System; |
||||
|
||||
using ICSharpCode.Decompiler; |
||||
using ICSharpCode.Decompiler.Metadata; |
||||
using ICSharpCode.Decompiler.Output; |
||||
using ICSharpCode.Decompiler.TypeSystem; |
||||
using ICSharpCode.ILSpyX.Abstractions; |
||||
using ICSharpCode.ILSpyX.Search; |
||||
|
||||
using ILSpy.Languages; |
||||
|
||||
namespace ILSpy.Search |
||||
{ |
||||
/// <summary>
|
||||
/// Builds the <see cref="SearchResult"/> objects the search strategies stream into the
|
||||
/// pane's result queue. Mirrors WPF's <c>SearchResultFactory</c>: fitness ranking
|
||||
/// privileges short names (shorter == higher fitness == higher rank); compiler-generated
|
||||
/// names (those starting with <c><</c>) get fitness 0 so they sink to the bottom.
|
||||
/// </summary>
|
||||
internal sealed class AvaloniaSearchResultFactory : ISearchResultFactory |
||||
{ |
||||
readonly Language language; |
||||
|
||||
public AvaloniaSearchResultFactory(Language language) |
||||
{ |
||||
this.language = language; |
||||
} |
||||
|
||||
public MemberSearchResult Create(IEntity entity) |
||||
{ |
||||
var declaringType = entity.DeclaringTypeDefinition; |
||||
return new MemberSearchResult { |
||||
Member = entity, |
||||
Fitness = CalculateFitness(entity), |
||||
Name = GetLanguageSpecificName(entity), |
||||
Location = declaringType != null |
||||
? language.TypeToString(declaringType, ConversionFlags.UseFullyQualifiedEntityNames | ConversionFlags.UseFullyQualifiedTypeNames) |
||||
: entity.Namespace ?? string.Empty, |
||||
Assembly = entity.ParentModule?.FullAssemblyName ?? string.Empty, |
||||
ToolTip = entity.ParentModule?.MetadataFile?.FileName, |
||||
Image = GetIcon(entity), |
||||
LocationImage = declaringType != null ? Images.Images.Class : Images.Images.Namespace, |
||||
AssemblyImage = Images.Images.Assembly, |
||||
}; |
||||
} |
||||
|
||||
public ResourceSearchResult Create(MetadataFile module, Resource resource, ITreeNode node, ITreeNode parent) |
||||
{ |
||||
return new ResourceSearchResult { |
||||
Resource = resource, |
||||
Fitness = 1.0f / Math.Max(1, resource.Name.Length), |
||||
Image = Images.Images.Library, |
||||
Name = resource.Name, |
||||
LocationImage = Images.Images.Library, |
||||
Location = (parent.Text as string) ?? string.Empty, |
||||
Assembly = module.FullName, |
||||
ToolTip = module.FileName, |
||||
AssemblyImage = Images.Images.Assembly, |
||||
}; |
||||
} |
||||
|
||||
public AssemblySearchResult Create(MetadataFile module) |
||||
{ |
||||
return new AssemblySearchResult { |
||||
Module = module, |
||||
Fitness = 1.0f / Math.Max(1, module.Name.Length), |
||||
Name = module.Name, |
||||
Location = module.FileName, |
||||
Assembly = module.FullName, |
||||
ToolTip = module.FileName, |
||||
Image = Images.Images.Assembly, |
||||
LocationImage = Images.Images.Library, |
||||
AssemblyImage = Images.Images.Assembly, |
||||
}; |
||||
} |
||||
|
||||
public NamespaceSearchResult Create(MetadataFile module, INamespace ns) |
||||
{ |
||||
var name = ns.FullName.Length == 0 ? "-" : ns.FullName; |
||||
return new NamespaceSearchResult { |
||||
Namespace = ns, |
||||
Name = name, |
||||
Fitness = 1.0f / Math.Max(1, name.Length), |
||||
Location = module.Name, |
||||
Assembly = module.FullName, |
||||
Image = Images.Images.Namespace, |
||||
LocationImage = Images.Images.Assembly, |
||||
AssemblyImage = Images.Images.Assembly, |
||||
}; |
||||
} |
||||
|
||||
static float CalculateFitness(IEntity member) |
||||
{ |
||||
var text = member.Name; |
||||
if (text.StartsWith('<')) |
||||
return 0; |
||||
if (member.SymbolKind is SymbolKind.Constructor or SymbolKind.Destructor) |
||||
text = member.DeclaringType?.Name ?? text; |
||||
text = ReflectionHelper.SplitTypeParameterCountFromReflectionName(text); |
||||
return 1.0f / Math.Max(1, text.Length); |
||||
} |
||||
|
||||
string GetLanguageSpecificName(IEntity member) => member switch { |
||||
ITypeDefinition t => language.TypeToString(t, ConversionFlags.None), |
||||
IField or IProperty or IMethod or IEvent => language.EntityToString(member, ConversionFlags.ShowDeclaringType), |
||||
_ => member.Name, |
||||
}; |
||||
|
||||
static object GetIcon(IEntity member) => member switch { |
||||
ITypeDefinition => Images.Images.Class, |
||||
IField => Images.Images.Field, |
||||
IProperty => Images.Images.Property, |
||||
IMethod => Images.Images.Method, |
||||
IEvent => Images.Images.Event, |
||||
_ => Images.Images.Library, |
||||
}; |
||||
} |
||||
} |
||||
@ -0,0 +1,222 @@
@@ -0,0 +1,222 @@
|
||||
// Copyright (c) 2026 AlphaSierraPapa for the SharpDevelop Team
|
||||
//
|
||||
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
|
||||
// software and associated documentation files (the "Software"), to deal in the Software
|
||||
// without restriction, including without limitation the rights to use, copy, modify, merge,
|
||||
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons
|
||||
// to whom the Software is furnished to do so, subject to the following conditions:
|
||||
//
|
||||
// The above copyright notice and this permission notice shall be included in all copies or
|
||||
// substantial portions of the Software.
|
||||
//
|
||||
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
||||
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE
|
||||
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
||||
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
// DEALINGS IN THE SOFTWARE.
|
||||
|
||||
using System; |
||||
using System.Collections.Concurrent; |
||||
using System.Collections.Generic; |
||||
using System.Collections.ObjectModel; |
||||
using System.IO; |
||||
using System.Linq; |
||||
using System.Reflection; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
|
||||
using Avalonia.Threading; |
||||
|
||||
using ICSharpCode.Decompiler; |
||||
using ICSharpCode.Decompiler.Metadata; |
||||
using ICSharpCode.ILSpyX; |
||||
using ICSharpCode.ILSpyX.Search; |
||||
|
||||
using ILSpy.Languages; |
||||
|
||||
namespace ILSpy.Search |
||||
{ |
||||
/// <summary>
|
||||
/// Orchestrates one search across the loaded <see cref="AssemblyList"/>. Builds a
|
||||
/// <see cref="SearchRequest"/>, picks the matching <see cref="AbstractSearchStrategy"/>,
|
||||
/// runs it on a background task, and streams each emitted <see cref="SearchResult"/>
|
||||
/// into the supplied UI-thread <see cref="ObservableCollection{T}"/> via
|
||||
/// <see cref="Dispatcher.UIThread"/>.
|
||||
/// </summary>
|
||||
internal sealed class RunningSearch |
||||
{ |
||||
const int MaxResults = 1000; |
||||
|
||||
readonly IReadOnlyList<LoadedAssembly> assemblies; |
||||
readonly SearchMode mode; |
||||
readonly string searchTerm; |
||||
readonly Language language; |
||||
readonly ApiVisibility apiVisibility; |
||||
readonly ISearchResultFactory resultFactory; |
||||
readonly ObservableCollection<SearchResult> sink; |
||||
readonly ConcurrentQueue<SearchResult> queue = new(); |
||||
readonly CancellationTokenSource cts = new(); |
||||
Task? runTask; |
||||
|
||||
public RunningSearch( |
||||
IReadOnlyList<LoadedAssembly> assemblies, |
||||
string searchTerm, |
||||
SearchMode mode, |
||||
Language language, |
||||
ApiVisibility apiVisibility, |
||||
ISearchResultFactory resultFactory, |
||||
ObservableCollection<SearchResult> sink) |
||||
{ |
||||
this.assemblies = assemblies; |
||||
this.searchTerm = searchTerm; |
||||
this.mode = mode; |
||||
this.language = language; |
||||
this.apiVisibility = apiVisibility; |
||||
this.resultFactory = resultFactory; |
||||
this.sink = sink; |
||||
} |
||||
|
||||
public bool IsCompleted => runTask is { IsCompleted: true }; |
||||
|
||||
public void Start() |
||||
{ |
||||
var token = cts.Token; |
||||
// Task.Run with an async lambda wraps the inner Task so runTask only completes
|
||||
// after RunSearchAsync actually finishes — without the wrapping, runTask would
|
||||
// complete the moment Task.Run returned the inner Task to the caller, and the
|
||||
// drain loop would exit before any results landed.
|
||||
runTask = Task.Run(async () => await RunSearchAsync(token).ConfigureAwait(false), token); |
||||
_ = DrainQueueAsync(token); |
||||
} |
||||
|
||||
public void Cancel() |
||||
{ |
||||
cts.Cancel(); |
||||
} |
||||
|
||||
async Task RunSearchAsync(CancellationToken ct) |
||||
{ |
||||
try |
||||
{ |
||||
var request = BuildRequest(); |
||||
var strategy = GetStrategy(request); |
||||
if (strategy == null) |
||||
return; |
||||
foreach (var assembly in assemblies) |
||||
{ |
||||
ct.ThrowIfCancellationRequested(); |
||||
// Force the load so search hits the metadata even for assemblies the user
|
||||
// hasn't expanded in the tree yet. Failed loads are already surfaced in
|
||||
// the assembly tree with the AssemblyWarning icon — don't double-report
|
||||
// here, just skip so the search keeps streaming results from the
|
||||
// healthy assemblies.
|
||||
MetadataFile? module; |
||||
try |
||||
{ |
||||
module = await assembly.GetMetadataFileAsync().ConfigureAwait(false); |
||||
} |
||||
catch (Exception ex) when (IsExpectedLoadFailure(ex)) |
||||
{ |
||||
continue; |
||||
} |
||||
if (module == null) |
||||
continue; |
||||
strategy.Search(module, ct); |
||||
} |
||||
} |
||||
catch (OperationCanceledException) |
||||
{ |
||||
// Expected on user-driven term/mode change.
|
||||
} |
||||
} |
||||
|
||||
static bool IsExpectedLoadFailure(Exception ex) => ex is |
||||
BadImageFormatException |
||||
or IOException |
||||
or InvalidOperationException |
||||
or UnauthorizedAccessException |
||||
or NotSupportedException |
||||
or ReflectionTypeLoadException; |
||||
|
||||
SearchRequest BuildRequest() |
||||
{ |
||||
// Minimal keyword parser: split on whitespace. The WPF pane supports a richer
|
||||
// prefix DSL (inassembly:, t:, /regex/, =exact, ~fuzzy, …) — that lands as a
|
||||
// follow-up. Plain-keyword search covers the common case end-to-end.
|
||||
var keywords = (searchTerm ?? string.Empty) |
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); |
||||
return new SearchRequest { |
||||
Mode = mode, |
||||
Keywords = keywords, |
||||
SearchResultFactory = resultFactory, |
||||
// MemberSearchStrategy.Search resolves a type system via
|
||||
// module.GetTypeSystemWithDecompilerSettingsOrNull(request.DecompilerSettings);
|
||||
// passing null short-circuits to zero results. Default settings are fine for
|
||||
// search — we only need the type system to materialise, not specific
|
||||
// decompiler behaviour.
|
||||
DecompilerSettings = new DecompilerSettings(), |
||||
FullNameSearch = false, |
||||
OmitGenerics = false, |
||||
// IsInNamespaceOrAssembly treats null as "no filter, accept everything";
|
||||
// the EMPTY STRING would restrict to the global namespace (Namespace.Length
|
||||
// == 0), which matches almost nothing. The DSL prefixes that flip these on
|
||||
// (innamespace:, inassembly:) are out of scope for the minimal parser.
|
||||
InNamespace = null!, |
||||
InAssembly = null!, |
||||
}; |
||||
} |
||||
|
||||
AbstractSearchStrategy? GetStrategy(SearchRequest request) |
||||
{ |
||||
if (request.Keywords.Length == 0 && request.RegEx is null) |
||||
return null; |
||||
return mode switch { |
||||
SearchMode.TypeAndMember => new MemberSearchStrategy(language, apiVisibility, request, queue), |
||||
SearchMode.Type => new MemberSearchStrategy(language, apiVisibility, request, queue, MemberSearchKind.Type), |
||||
SearchMode.Member => new MemberSearchStrategy(language, apiVisibility, request, queue, MemberSearchKind.Member), |
||||
SearchMode.Method => new MemberSearchStrategy(language, apiVisibility, request, queue, MemberSearchKind.Method), |
||||
SearchMode.Field => new MemberSearchStrategy(language, apiVisibility, request, queue, MemberSearchKind.Field), |
||||
SearchMode.Property => new MemberSearchStrategy(language, apiVisibility, request, queue, MemberSearchKind.Property), |
||||
SearchMode.Event => new MemberSearchStrategy(language, apiVisibility, request, queue, MemberSearchKind.Event), |
||||
SearchMode.Literal => new LiteralSearchStrategy(language, apiVisibility, request, queue), |
||||
SearchMode.Token => new MetadataTokenSearchStrategy(language, apiVisibility, request, queue), |
||||
SearchMode.Assembly => new AssemblySearchStrategy(request, queue, AssemblySearchKind.NameOrFileName), |
||||
SearchMode.Namespace => new NamespaceSearchStrategy(request, queue), |
||||
// Resource search needs ITreeNodeFactory infrastructure — deferred to a follow-up.
|
||||
_ => null, |
||||
}; |
||||
} |
||||
|
||||
async Task DrainQueueAsync(CancellationToken ct) |
||||
{ |
||||
try |
||||
{ |
||||
while (!ct.IsCancellationRequested) |
||||
{ |
||||
var batch = new List<SearchResult>(); |
||||
while (queue.TryDequeue(out var result) && batch.Count < 100) |
||||
batch.Add(result); |
||||
if (batch.Count > 0) |
||||
{ |
||||
Dispatcher.UIThread.Post(() => { |
||||
foreach (var r in batch) |
||||
{ |
||||
if (sink.Count >= MaxResults) |
||||
break; |
||||
sink.Add(r); |
||||
} |
||||
}); |
||||
} |
||||
if (runTask is { IsCompleted: true } && queue.IsEmpty) |
||||
return; |
||||
await Task.Delay(50, ct).ConfigureAwait(false); |
||||
} |
||||
} |
||||
catch (OperationCanceledException) |
||||
{ |
||||
// Expected when the run is replaced or cleared.
|
||||
} |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue