From 335184a6068dbda69e04eae7e1186a1613caa62f Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 1 May 2019 11:57:35 -0400 Subject: [PATCH] Add ManualResetValueTaskSourceCore / AsyncIterateMethodBuilder to Microsoft.Bcl.AsyncInterfaces (dotnet/corefx#37320) * Add ManualResetValueTaskSourceCore / AsyncIterateMethodBuilder to Microsoft.Bcl.AsyncInterfaces These two types needed modifications to target .NET Standard 2.0 and are the necessary pieces to enable the compiler to compile async iterators. - Copied ManualResetValueTaskSourceCore.cs from coreclr and tweaked it. I opted to do this rather than ifdef because the changes are not localized and I didn't want to significantly perturb the primary implementation. - Added a few ifdefs to the shared AsyncIteratorMethodBuilder. It already had ifdefs, so I just added to it. - Added a test project, and included the existing ManualResetValueTaskSourceCore tests. I had to disable two of the tests because of some of the optimization differences. - Augmented those tests to validate that the compiler is able to successfully generate iterators and await foreach them. * Address PR feedback Commit migrated from https://github.com/dotnet/corefx/commit/07b6760a1a84251a50f744e585e4da5f71a25e68 --- .../ref/Microsoft.Bcl.AsyncInterfaces.Forwards.cs | 2 + .../ref/Microsoft.Bcl.AsyncInterfaces.cs | 26 ++ .../ref/Microsoft.Bcl.AsyncInterfaces.csproj | 1 + .../src/Microsoft.Bcl.AsyncInterfaces.csproj | 4 +- .../CompilerServices/AsyncIteratorMethodBuilder.cs | 62 +++++ .../Sources/ManualResetValueTaskSourceCore.cs | 272 +++++++++++++++++++++ .../tests/Configurations.props | 7 + .../Microsoft.Bcl.AsyncInterfaces.Tests.csproj | 14 ++ .../tests/ManualResetValueTaskSourceTests.cs | 66 +++-- 9 files changed, 427 insertions(+), 27 deletions(-) create mode 100644 src/libraries/Microsoft.Bcl.AsyncInterfaces/src/System/Runtime/CompilerServices/AsyncIteratorMethodBuilder.cs create mode 100644 src/libraries/Microsoft.Bcl.AsyncInterfaces/src/System/Threading/Tasks/Sources/ManualResetValueTaskSourceCore.cs create mode 100644 src/libraries/Microsoft.Bcl.AsyncInterfaces/tests/Configurations.props create mode 100644 src/libraries/Microsoft.Bcl.AsyncInterfaces/tests/Microsoft.Bcl.AsyncInterfaces.Tests.csproj diff --git a/src/libraries/Microsoft.Bcl.AsyncInterfaces/ref/Microsoft.Bcl.AsyncInterfaces.Forwards.cs b/src/libraries/Microsoft.Bcl.AsyncInterfaces/ref/Microsoft.Bcl.AsyncInterfaces.Forwards.cs index 8f737d6..6b61d3d 100644 --- a/src/libraries/Microsoft.Bcl.AsyncInterfaces/ref/Microsoft.Bcl.AsyncInterfaces.Forwards.cs +++ b/src/libraries/Microsoft.Bcl.AsyncInterfaces/ref/Microsoft.Bcl.AsyncInterfaces.Forwards.cs @@ -5,6 +5,8 @@ [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.IAsyncDisposable))] [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Collections.Generic.IAsyncEnumerable<>))] [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Collections.Generic.IAsyncEnumerator<>))] +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.AsyncIteratorMethodBuilder))] [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.AsyncIteratorStateMachineAttribute))] [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.ConfiguredAsyncDisposable))] [assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.ConfiguredCancelableAsyncEnumerable<>))] +[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Threading.Tasks.Sources.ManualResetValueTaskSourceCore<>))] diff --git a/src/libraries/Microsoft.Bcl.AsyncInterfaces/ref/Microsoft.Bcl.AsyncInterfaces.cs b/src/libraries/Microsoft.Bcl.AsyncInterfaces/ref/Microsoft.Bcl.AsyncInterfaces.cs index fefe5c5..caa86d3 100644 --- a/src/libraries/Microsoft.Bcl.AsyncInterfaces/ref/Microsoft.Bcl.AsyncInterfaces.cs +++ b/src/libraries/Microsoft.Bcl.AsyncInterfaces/ref/Microsoft.Bcl.AsyncInterfaces.cs @@ -26,6 +26,15 @@ namespace System.Collections.Generic } namespace System.Runtime.CompilerServices { + public partial struct AsyncIteratorMethodBuilder + { + private object _dummy; + public void AwaitOnCompleted(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : System.Runtime.CompilerServices.INotifyCompletion where TStateMachine : System.Runtime.CompilerServices.IAsyncStateMachine { } + public void AwaitUnsafeOnCompleted(ref TAwaiter awaiter, ref TStateMachine stateMachine) where TAwaiter : System.Runtime.CompilerServices.ICriticalNotifyCompletion where TStateMachine : System.Runtime.CompilerServices.IAsyncStateMachine { } + public void Complete() { } + public static System.Runtime.CompilerServices.AsyncIteratorMethodBuilder Create() { throw null; } + public void MoveNext(ref TStateMachine stateMachine) where TStateMachine : System.Runtime.CompilerServices.IAsyncStateMachine { } + } [System.AttributeUsageAttribute(System.AttributeTargets.Method, Inherited=false, AllowMultiple=false)] public sealed partial class AsyncIteratorStateMachineAttribute : System.Runtime.CompilerServices.StateMachineAttribute { @@ -54,3 +63,20 @@ namespace System.Runtime.CompilerServices } } } +namespace System.Threading.Tasks.Sources +{ + public partial struct ManualResetValueTaskSourceCore + { + private TResult _result; + private object _dummy; + private int _dummyPrimitive; + public bool RunContinuationsAsynchronously { get { throw null; } set { } } + public short Version { get { throw null; } } + public TResult GetResult(short token) { throw null; } + public System.Threading.Tasks.Sources.ValueTaskSourceStatus GetStatus(short token) { throw null; } + public void OnCompleted(System.Action continuation, object state, short token, System.Threading.Tasks.Sources.ValueTaskSourceOnCompletedFlags flags) { } + public void Reset() { } + public void SetException(System.Exception error) { } + public void SetResult(TResult result) { } + } +} diff --git a/src/libraries/Microsoft.Bcl.AsyncInterfaces/ref/Microsoft.Bcl.AsyncInterfaces.csproj b/src/libraries/Microsoft.Bcl.AsyncInterfaces/ref/Microsoft.Bcl.AsyncInterfaces.csproj index 3c58faf..e61b496 100644 --- a/src/libraries/Microsoft.Bcl.AsyncInterfaces/ref/Microsoft.Bcl.AsyncInterfaces.csproj +++ b/src/libraries/Microsoft.Bcl.AsyncInterfaces/ref/Microsoft.Bcl.AsyncInterfaces.csproj @@ -13,5 +13,6 @@ + \ No newline at end of file diff --git a/src/libraries/Microsoft.Bcl.AsyncInterfaces/src/Microsoft.Bcl.AsyncInterfaces.csproj b/src/libraries/Microsoft.Bcl.AsyncInterfaces/src/Microsoft.Bcl.AsyncInterfaces.csproj index 2e98b39..798c0e1 100644 --- a/src/libraries/Microsoft.Bcl.AsyncInterfaces/src/Microsoft.Bcl.AsyncInterfaces.csproj +++ b/src/libraries/Microsoft.Bcl.AsyncInterfaces/src/Microsoft.Bcl.AsyncInterfaces.csproj @@ -5,6 +5,8 @@ true + + ProductionCode\Common\CoreLib\System\Collections\Generic\IAsyncEnumerable.cs @@ -26,7 +28,7 @@ - + \ No newline at end of file diff --git a/src/libraries/Microsoft.Bcl.AsyncInterfaces/src/System/Runtime/CompilerServices/AsyncIteratorMethodBuilder.cs b/src/libraries/Microsoft.Bcl.AsyncInterfaces/src/System/Runtime/CompilerServices/AsyncIteratorMethodBuilder.cs new file mode 100644 index 0000000..08e8e31 --- /dev/null +++ b/src/libraries/Microsoft.Bcl.AsyncInterfaces/src/System/Runtime/CompilerServices/AsyncIteratorMethodBuilder.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// NOTE: This is a copy of +// https://github.com/dotnet/coreclr/blame/07b3afc27304800f00975c8fd4836b319aaa8820/src/System.Private.CoreLib/shared/System/Runtime/CompilerServices/AsyncIteratorMethodBuilder.cs +// modified to be compilable against .NET Standard 2.0. Key differences: +// - Uses the wrapped AsyncTaskMethodBuilder for Create and MoveNext. +// - Uses a custom object for the debugger identity. +// - Nullable annotations removed. + +using System.Runtime.InteropServices; +using System.Threading; + +namespace System.Runtime.CompilerServices +{ + /// Represents a builder for asynchronous iterators. + [StructLayout(LayoutKind.Auto)] + public struct AsyncIteratorMethodBuilder + { + private AsyncTaskMethodBuilder _methodBuilder; // mutable struct; do not make it readonly + private object _id; + + /// Creates an instance of the struct. + /// The initialized instance. + public static AsyncIteratorMethodBuilder Create() => + new AsyncIteratorMethodBuilder() { _methodBuilder = AsyncTaskMethodBuilder.Create() }; + + /// Invokes on the state machine while guarding the . + /// The type of the state machine. + /// The state machine instance, passed by reference. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void MoveNext(ref TStateMachine stateMachine) where TStateMachine : IAsyncStateMachine => + _methodBuilder.Start(ref stateMachine); + + /// Schedules the state machine to proceed to the next action when the specified awaiter completes. + /// The type of the awaiter. + /// The type of the state machine. + /// The awaiter. + /// The state machine. + public void AwaitOnCompleted(ref TAwaiter awaiter, ref TStateMachine stateMachine) + where TAwaiter : INotifyCompletion + where TStateMachine : IAsyncStateMachine => + _methodBuilder.AwaitOnCompleted(ref awaiter, ref stateMachine); + + /// Schedules the state machine to proceed to the next action when the specified awaiter completes. + /// The type of the awaiter. + /// The type of the state machine. + /// The awaiter. + /// The state machine. + public void AwaitUnsafeOnCompleted(ref TAwaiter awaiter, ref TStateMachine stateMachine) + where TAwaiter : ICriticalNotifyCompletion + where TStateMachine : IAsyncStateMachine => + _methodBuilder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine); + + /// Marks iteration as being completed, whether successfully or otherwise. + public void Complete() => _methodBuilder.SetResult(); + + /// Gets an object that may be used to uniquely identify this builder to the debugger. + internal object ObjectIdForDebugger => _id ?? Interlocked.CompareExchange(ref _id, new object(), null) ?? _id; + } +} diff --git a/src/libraries/Microsoft.Bcl.AsyncInterfaces/src/System/Threading/Tasks/Sources/ManualResetValueTaskSourceCore.cs b/src/libraries/Microsoft.Bcl.AsyncInterfaces/src/System/Threading/Tasks/Sources/ManualResetValueTaskSourceCore.cs new file mode 100644 index 0000000..06d0da1 --- /dev/null +++ b/src/libraries/Microsoft.Bcl.AsyncInterfaces/src/System/Threading/Tasks/Sources/ManualResetValueTaskSourceCore.cs @@ -0,0 +1,272 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +// NOTE: This is a copy of +// https://github.com/dotnet/coreclr/blame/07b3afc27304800f00975c8fd4836b319aaa8820/src/System.Private.CoreLib/shared/System/Threading/Tasks/Sources/ManualResetValueTaskSourceCore.cs, +// modified to be compilable against .NET Standard 2.0. It is missing optimizations present in the .NET Core implementation and should +// only be used when a .NET Standard 2.0 implementation is required. Key differences: +// - ThrowHelper call sites are replaced by normal exception throws. +// - ThreadPool.{Unsafe}QueueUserWorkItem calls that accepted Action/object/bool arguments are replaced by Task.Factory.StartNew usage. +// - ExecutionContext.RunInternal are replaced by ExecutionContext.Run. +// - Nullability annotations are removed. + +using System.Diagnostics; +using System.Runtime.ExceptionServices; +using System.Runtime.InteropServices; + +namespace System.Threading.Tasks.Sources +{ + /// Provides the core logic for implementing a manual-reset or . + /// + [StructLayout(LayoutKind.Auto)] + public struct ManualResetValueTaskSourceCore + { + /// + /// The callback to invoke when the operation completes if was called before the operation completed, + /// or if the operation completed before a callback was supplied, + /// or null if a callback hasn't yet been provided and the operation hasn't yet completed. + /// + private Action _continuation; + /// State to pass to . + private object _continuationState; + /// to flow to the callback, or null if no flowing is required. + private ExecutionContext _executionContext; + /// + /// A "captured" or with which to invoke the callback, + /// or null if no special context is required. + /// + private object _capturedContext; + /// Whether the current operation has completed. + private bool _completed; + /// The result with which the operation succeeded, or the default value if it hasn't yet completed or failed. + private TResult _result; + /// The exception with which the operation failed, or null if it hasn't yet completed or completed successfully. + private ExceptionDispatchInfo _error; + /// The current version of this value, used to help prevent misuse. + private short _version; + + /// Gets or sets whether to force continuations to run asynchronously. + /// Continuations may run asynchronously if this is false, but they'll never run synchronously if this is true. + public bool RunContinuationsAsynchronously { get; set; } + + /// Resets to prepare for the next operation. + public void Reset() + { + // Reset/update state for the next use/await of this instance. + _version++; + _completed = false; + _result = default!; // TODO-NULLABLE-GENERIC + _error = null; + _executionContext = null; + _capturedContext = null; + _continuation = null; + _continuationState = null; + } + + /// Completes with a successful result. + /// The result. + public void SetResult(TResult result) + { + _result = result; + SignalCompletion(); + } + + /// Complets with an error. + /// + public void SetException(Exception error) + { + _error = ExceptionDispatchInfo.Capture(error); + SignalCompletion(); + } + + /// Gets the operation version. + public short Version => _version; + + /// Gets the status of the operation. + /// Opaque value that was provided to the 's constructor. + public ValueTaskSourceStatus GetStatus(short token) + { + ValidateToken(token); + return + _continuation == null || !_completed ? ValueTaskSourceStatus.Pending : + _error == null ? ValueTaskSourceStatus.Succeeded : + _error.SourceException is OperationCanceledException ? ValueTaskSourceStatus.Canceled : + ValueTaskSourceStatus.Faulted; + } + + /// Gets the result of the operation. + /// Opaque value that was provided to the 's constructor. + public TResult GetResult(short token) + { + ValidateToken(token); + if (!_completed) + { + throw new InvalidOperationException(); + } + + _error?.Throw(); + return _result; + } + + /// Schedules the continuation action for this operation. + /// The continuation to invoke when the operation has completed. + /// The state object to pass to when it's invoked. + /// Opaque value that was provided to the 's constructor. + /// The flags describing the behavior of the continuation. + public void OnCompleted(Action continuation, object state, short token, ValueTaskSourceOnCompletedFlags flags) // TODO-NULLABLE: https://github.com/dotnet/roslyn/issues/26761 + { + if (continuation == null) + { + throw new ArgumentNullException(nameof(continuation)); + } + ValidateToken(token); + + if ((flags & ValueTaskSourceOnCompletedFlags.FlowExecutionContext) != 0) + { + _executionContext = ExecutionContext.Capture(); + } + + if ((flags & ValueTaskSourceOnCompletedFlags.UseSchedulingContext) != 0) + { + SynchronizationContext sc = SynchronizationContext.Current; + if (sc != null && sc.GetType() != typeof(SynchronizationContext)) + { + _capturedContext = sc; + } + else + { + TaskScheduler ts = TaskScheduler.Current; + if (ts != TaskScheduler.Default) + { + _capturedContext = ts; + } + } + } + + // We need to set the continuation state before we swap in the delegate, so that + // if there's a race between this and SetResult/Exception and SetResult/Exception + // sees the _continuation as non-null, it'll be able to invoke it with the state + // stored here. However, this also means that if this is used incorrectly (e.g. + // awaited twice concurrently), _continuationState might get erroneously overwritten. + // To minimize the chances of that, we check preemptively whether _continuation + // is already set to something other than the completion sentinel. + + object oldContinuation = _continuation; + if (oldContinuation == null) + { + _continuationState = state; + oldContinuation = Interlocked.CompareExchange(ref _continuation, continuation, null); + } + + if (oldContinuation != null) + { + // Operation already completed, so we need to queue the supplied callback. + if (!ReferenceEquals(oldContinuation, ManualResetValueTaskSourceCoreShared.s_sentinel)) + { + throw new InvalidOperationException(); + } + + switch (_capturedContext) + { + case null: + Task.Factory.StartNew(continuation, state, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + break; + + case SynchronizationContext sc: + sc.Post(s => + { + var tuple = (Tuple, object>)s; + tuple.Item1(tuple.Item2); + }, Tuple.Create(continuation, state)); + break; + + case TaskScheduler ts: + Task.Factory.StartNew(continuation, state, CancellationToken.None, TaskCreationOptions.DenyChildAttach, ts); + break; + } + } + } + + /// Ensures that the specified token matches the current version. + /// The token supplied by . + private void ValidateToken(short token) + { + if (token != _version) + { + throw new InvalidOperationException(); + } + } + + /// Signals that the operation has completed. Invoked after the result or error has been set. + private void SignalCompletion() + { + if (_completed) + { + throw new InvalidOperationException(); + } + _completed = true; + + if (_continuation != null || Interlocked.CompareExchange(ref _continuation, ManualResetValueTaskSourceCoreShared.s_sentinel, null) != null) + { + if (_executionContext != null) + { + ExecutionContext.Run( + _executionContext, + s => ((ManualResetValueTaskSourceCore)s).InvokeContinuation(), + this); + } + else + { + InvokeContinuation(); + } + } + } + + /// + /// Invokes the continuation with the appropriate captured context / scheduler. + /// This assumes that if is not null we're already + /// running within that . + /// + private void InvokeContinuation() + { + Debug.Assert(_continuation != null); + + switch (_capturedContext) + { + case null: + if (RunContinuationsAsynchronously) + { + Task.Factory.StartNew(_continuation, _continuationState, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + else + { + _continuation(_continuationState); + } + break; + + case SynchronizationContext sc: + sc.Post(s => + { + var state = (Tuple, object>)s; + state.Item1(state.Item2); + }, Tuple.Create(_continuation, _continuationState)); + break; + + case TaskScheduler ts: + Task.Factory.StartNew(_continuation, _continuationState, CancellationToken.None, TaskCreationOptions.DenyChildAttach, ts); + break; + } + } + } + + internal static class ManualResetValueTaskSourceCoreShared // separated out of generic to avoid unnecessary duplication + { + internal static readonly Action s_sentinel = CompletionSentinel; + private static void CompletionSentinel(object _) // named method to aid debugging + { + Debug.Fail("The sentinel delegate should never be invoked."); + throw new InvalidOperationException(); + } + } +} diff --git a/src/libraries/Microsoft.Bcl.AsyncInterfaces/tests/Configurations.props b/src/libraries/Microsoft.Bcl.AsyncInterfaces/tests/Configurations.props new file mode 100644 index 0000000..581054d --- /dev/null +++ b/src/libraries/Microsoft.Bcl.AsyncInterfaces/tests/Configurations.props @@ -0,0 +1,7 @@ + + + + netstandard; + + + \ No newline at end of file diff --git a/src/libraries/Microsoft.Bcl.AsyncInterfaces/tests/Microsoft.Bcl.AsyncInterfaces.Tests.csproj b/src/libraries/Microsoft.Bcl.AsyncInterfaces/tests/Microsoft.Bcl.AsyncInterfaces.Tests.csproj new file mode 100644 index 0000000..572955e --- /dev/null +++ b/src/libraries/Microsoft.Bcl.AsyncInterfaces/tests/Microsoft.Bcl.AsyncInterfaces.Tests.csproj @@ -0,0 +1,14 @@ + + + {72E21903-0FBA-444E-9855-3B4F05DFC1F9} + netstandard-Debug;netstandard-Release + + + + Common\tests\System\Threading\Tasks\Sources\ManualResetValueTaskSource.cs + + + System.Threading.Tasks.Extensions\tests\ManualResetValueTaskSourceTests.cs + + + \ No newline at end of file diff --git a/src/libraries/System.Threading.Tasks.Extensions/tests/ManualResetValueTaskSourceTests.cs b/src/libraries/System.Threading.Tasks.Extensions/tests/ManualResetValueTaskSourceTests.cs index 9242c2c..15d7f1c 100644 --- a/src/libraries/System.Threading.Tasks.Extensions/tests/ManualResetValueTaskSourceTests.cs +++ b/src/libraries/System.Threading.Tasks.Extensions/tests/ManualResetValueTaskSourceTests.cs @@ -169,6 +169,7 @@ namespace System.Threading.Tasks.Sources.Tests Assert.Same(e, Assert.Throws(() => mrvts.GetResult(0))); } + [SkipOnTargetFramework(~TargetFrameworkMonikers.Netcoreapp)] [Theory] [InlineData(false)] [InlineData(true)] @@ -192,6 +193,7 @@ namespace System.Threading.Tasks.Sources.Tests mres.Wait(); } + [SkipOnTargetFramework(~TargetFrameworkMonikers.Netcoreapp)] [Theory] [InlineData(false)] [InlineData(true)] @@ -399,43 +401,55 @@ namespace System.Threading.Tasks.Sources.Tests protected override IEnumerable GetScheduledTasks() => null; } - [Fact] - public async Task AsyncEnumerable_Success() + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task AsyncEnumerable_Success(bool awaitForeach, bool asyncIterator) { - // Equivalent to: - // int total = 0; - // foreach async(int i in CountAsync(20)) - // { - // total += i; - // } - // Assert.Equal(190, i); - - IAsyncEnumerator enumerator = CountAsync(20).GetAsyncEnumerator(); - try + IAsyncEnumerable enumerable = asyncIterator ? + CountCompilerAsync(20) : + CountManualAsync(20); + + if (awaitForeach) { int total = 0; - while (await enumerator.MoveNextAsync()) + await foreach (int i in enumerable) { - total += enumerator.Current; + total += i; } Assert.Equal(190, total); } - finally + else + { + IAsyncEnumerator enumerator = enumerable.GetAsyncEnumerator(); + try + { + int total = 0; + while (await enumerator.MoveNextAsync()) + { + total += enumerator.Current; + } + Assert.Equal(190, total); + } + finally + { + await enumerator.DisposeAsync(); + } + } + } + + internal static async IAsyncEnumerable CountCompilerAsync(int items) + { + for (int i = 0; i < items; i++) { - await enumerator.DisposeAsync(); + await Task.Delay(i).ConfigureAwait(false); + yield return i; } } - // Approximate compiler-generated code for: - // internal static AsyncEnumerable CountAsync(int items) - // { - // for (int i = 0; i < items; i++) - // { - // await Task.Delay(i).ConfigureAwait(false); - // yield return i; - // } - // } - internal static IAsyncEnumerable CountAsync(int items) => + internal static IAsyncEnumerable CountManualAsync(int items) => new CountAsyncEnumerable(items); private sealed class CountAsyncEnumerable : -- 2.7.4