diff --git a/Funcky.Test/Monads/OptionAwaiterTest.cs b/Funcky.Test/Monads/OptionAwaiterTest.cs index 6248da60..af6644c6 100644 --- a/Funcky.Test/Monads/OptionAwaiterTest.cs +++ b/Funcky.Test/Monads/OptionAwaiterTest.cs @@ -39,66 +39,268 @@ public async Task OptionOfValueTaskIsAwaitable() } [Fact] - public async Task OnCompletedInvokesTheContinuationForNone() + public async Task OptionOfIncompleteTaskIsAwaitable() { - await AssertContinuationIsInvoked(continuation => Option>.None.GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option>.None.GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.None.GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.None.GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + var source = new TaskCompletionSource(); + var awaiting = AwaitAsync(Option.Some(source.Task)); + source.SetResult(10); + Assert.Some(10, await awaiting); + + static async Task> AwaitAsync(Option> option) => await option; + } + +#pragma warning disable xUnit1031 // GetResult on the awaiter of None or of an already completed task returns immediately. + [Fact] + public void AwaiterOfNoneIsCompletedWithNone() + { + var taskAwaiter = Option>.None.GetAwaiter(); + Assert.True(taskAwaiter.IsCompleted); + Assert.None(taskAwaiter.GetResult()); + + var configuredTaskAwaiter = Option>.None.ConfigureAwait(false).GetAwaiter(); + Assert.True(configuredTaskAwaiter.IsCompleted); + Assert.None(configuredTaskAwaiter.GetResult()); + + var valueTaskAwaiter = Option>.None.GetAwaiter(); + Assert.True(valueTaskAwaiter.IsCompleted); + Assert.None(valueTaskAwaiter.GetResult()); + + var configuredValueTaskAwaiter = Option>.None.ConfigureAwait(false).GetAwaiter(); + Assert.True(configuredValueTaskAwaiter.IsCompleted); + Assert.None(configuredValueTaskAwaiter.GetResult()); + } + + [Fact] + public void AwaiterOfVoidNoneIsCompleted() + { + var taskAwaiter = Option.None.GetAwaiter(); + Assert.True(taskAwaiter.IsCompleted); + taskAwaiter.GetResult(); + + var configuredTaskAwaiter = Option.None.ConfigureAwait(false).GetAwaiter(); + Assert.True(configuredTaskAwaiter.IsCompleted); + configuredTaskAwaiter.GetResult(); + + var valueTaskAwaiter = Option.None.GetAwaiter(); + Assert.True(valueTaskAwaiter.IsCompleted); + valueTaskAwaiter.GetResult(); + + var configuredValueTaskAwaiter = Option.None.ConfigureAwait(false).GetAwaiter(); + Assert.True(configuredValueTaskAwaiter.IsCompleted); + configuredValueTaskAwaiter.GetResult(); + } + + [Fact] + public void AwaiterOfCompletedSomeIsCompletedWithTheResult() + { + var taskAwaiter = Option.Some(Task.FromResult(10)).GetAwaiter(); + Assert.True(taskAwaiter.IsCompleted); + Assert.Some(10, taskAwaiter.GetResult()); + + var configuredTaskAwaiter = Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter(); + Assert.True(configuredTaskAwaiter.IsCompleted); + Assert.Some(10, configuredTaskAwaiter.GetResult()); + + var valueTaskAwaiter = Option.Some(ValueTask.FromResult(10)).GetAwaiter(); + Assert.True(valueTaskAwaiter.IsCompleted); + Assert.Some(10, valueTaskAwaiter.GetResult()); + + var configuredValueTaskAwaiter = Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter(); + Assert.True(configuredValueTaskAwaiter.IsCompleted); + Assert.Some(10, configuredValueTaskAwaiter.GetResult()); + } + + [Fact] + public void AwaiterOfCompletedVoidSomeIsCompleted() + { + var taskAwaiter = Option.Some(Task.CompletedTask).GetAwaiter(); + Assert.True(taskAwaiter.IsCompleted); + taskAwaiter.GetResult(); + + var configuredTaskAwaiter = Option.Some(Task.CompletedTask).ConfigureAwait(false).GetAwaiter(); + Assert.True(configuredTaskAwaiter.IsCompleted); + configuredTaskAwaiter.GetResult(); + + var valueTaskAwaiter = Option.Some(ValueTask.CompletedTask).GetAwaiter(); + Assert.True(valueTaskAwaiter.IsCompleted); + valueTaskAwaiter.GetResult(); + + var configuredValueTaskAwaiter = Option.Some(ValueTask.CompletedTask).ConfigureAwait(false).GetAwaiter(); + Assert.True(configuredValueTaskAwaiter.IsCompleted); + configuredValueTaskAwaiter.GetResult(); } +#pragma warning restore xUnit1031 [Fact] - public async Task UnsafeOnCompletedInvokesTheContinuationForNone() + public void OnCompletedInvokesTheContinuationWhenTheTaskCompletes() { - await AssertContinuationIsInvoked(continuation => Option>.None.GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option>.None.GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.None.GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.None.GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(source.Task).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(source.Task).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(new ValueTask(source.Task)).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(new ValueTask(source.Task)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(source.Task).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(source.Task).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(new ValueTask(source.Task)).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(new ValueTask(source.Task)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); } [Fact] - public async Task OnCompletedInvokesTheContinuationForSome() + public void UnsafeOnCompletedInvokesTheContinuationWhenTheTaskCompletes() { - await AssertContinuationIsInvoked(continuation => Option.Some(Task.FromResult(10)).GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.FromResult(10)).GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(Task.CompletedTask).GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(Task.CompletedTask).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.CompletedTask).GetAwaiter().OnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.CompletedTask).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(source.Task).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(source.Task).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(new ValueTask(source.Task)).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(new ValueTask(source.Task)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(source.Task).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(source.Task).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(new ValueTask(source.Task)).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedOnCompletion((source, continuation) => Option.Some(new ValueTask(source.Task)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); } [Fact] - public async Task UnsafeOnCompletedInvokesTheContinuationForSome() - { - await AssertContinuationIsInvoked(continuation => Option.Some(Task.FromResult(10)).GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.FromResult(10)).GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(Task.CompletedTask).GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(Task.CompletedTask).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.CompletedTask).GetAwaiter().UnsafeOnCompleted(continuation)); - await AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.CompletedTask).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - } - - private static async Task AssertContinuationIsInvoked(Action registerContinuation) - { - // Awaiters are allowed to schedule the continuation asynchronously. A completed Task posts it to the - // current synchronization context, which xunit limits to one thread per core, so this must not block - // that thread while waiting: a blocked thread would starve the very continuation it waits for. - var invoked = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - registerContinuation(() => invoked.TrySetResult(true)); - var completed = await Task.WhenAny(invoked.Task, Task.Delay(TimeSpan.FromSeconds(5))); - Assert.True(completed == invoked.Task, "the continuation was not invoked"); + public void ContinuationForCompletedSomeIsPostedToTheCurrentSynchronizationContext() + { + AssertContinuationIsPosted(continuation => Option.Some(Task.FromResult(10)).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsPosted(continuation => Option.Some(Task.FromResult(10)).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsPosted(continuation => Option.Some(ValueTask.FromResult(10)).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsPosted(continuation => Option.Some(ValueTask.FromResult(10)).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsPosted(continuation => Option.Some(Task.CompletedTask).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsPosted(continuation => Option.Some(Task.CompletedTask).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsPosted(continuation => Option.Some(ValueTask.CompletedTask).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsPosted(continuation => Option.Some(ValueTask.CompletedTask).GetAwaiter().UnsafeOnCompleted(continuation)); + } + + // The awaiters of Option> and Option> hold an Option of the underlying awaiter and invoke the + // continuation of None inline, whereas the awaiters of Option and Option replace None by a completed + // task, whose awaiter posts the continuation to the current synchronization context like any completed task does. + [Fact] + public void ContinuationForGenericNoneIsInvokedInline() + { + AssertContinuationIsInvokedInline(continuation => Option>.None.GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedInline(continuation => Option>.None.GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedInline(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedInline(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedInline(continuation => Option>.None.GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedInline(continuation => Option>.None.GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedInline(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedInline(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedInline(continuation => Option.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedInline(continuation => Option.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvokedInline(continuation => Option.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvokedInline(continuation => Option.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + } + + [Fact] + public void ContinuationForVoidNoneIsPostedToTheCurrentSynchronizationContext() + { + AssertContinuationIsPosted(continuation => Option.None.GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsPosted(continuation => Option.None.GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsPosted(continuation => Option.None.GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsPosted(continuation => Option.None.GetAwaiter().UnsafeOnCompleted(continuation)); + } + + [Fact] + public void ConfigureAwaitFalseBypassesTheSynchronizationContextOfTheRegistration() + { + AssertContinuationIsNotPosted((source, continuation) => Option.Some(source.Task).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsNotPosted((source, continuation) => Option.Some(source.Task).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsNotPosted((source, continuation) => Option.Some(new ValueTask(source.Task)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsNotPosted((source, continuation) => Option.Some(new ValueTask(source.Task)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsNotPosted((source, continuation) => Option.Some(source.Task).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsNotPosted((source, continuation) => Option.Some(source.Task).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsNotPosted((source, continuation) => Option.Some(new ValueTask(source.Task)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsNotPosted((source, continuation) => Option.Some(new ValueTask(source.Task)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + } + + // None of the helpers below waits for a scheduler. A continuation registered on an already completed task is + // handed to the current synchronization context, or to the thread pool when there is none, and both are shared + // with every other test in the process: xunit's MaxConcurrencySyncContext has one worker per core and a loaded + // two-core runner starved both of them for more than five seconds (#1004). The helpers therefore either complete + // the task themselves on a thread without synchronization context, where the BCL runs the await continuation + // inline within SetResult, or install a recording context whose Post runs the callback inline. + private static void AssertContinuationIsInvokedOnCompletion(Action, Action> registerContinuation) + => WithoutSynchronizationContext(() => + { + var source = new TaskCompletionSource(); + var invoked = false; + registerContinuation(source, () => invoked = true); + Assert.False(invoked, "the continuation was invoked before the task completed"); + source.SetResult(default!); + Assert.True(invoked, "the continuation was not invoked when the task completed"); + }); + + private static void AssertContinuationIsInvokedInline(Action registerContinuation) + => WithSynchronizationContext(new RecordingSynchronizationContext(), context => + { + var invoked = false; + registerContinuation(() => invoked = true); + Assert.True(invoked, "the continuation was not invoked"); + Assert.Equal(0, context.Posted); + }); + + private static void AssertContinuationIsPosted(Action registerContinuation) + => WithSynchronizationContext(new RecordingSynchronizationContext(), context => + { + var invoked = false; + registerContinuation(() => invoked = true); + Assert.Equal(1, context.Posted); + Assert.True(invoked, "the continuation was not invoked"); + }); + + // The context is only installed while registering: a continuation that did capture it would post to it when the + // task completes, one that did not runs inline within SetResult because no context is installed by then. + private static void AssertContinuationIsNotPosted(Action, Action> registerContinuation) + => WithoutSynchronizationContext(() => + { + var source = new TaskCompletionSource(); + var invoked = false; + var context = new RecordingSynchronizationContext(); + WithSynchronizationContext(context, _ => registerContinuation(source, () => invoked = true)); + source.SetResult(default!); + Assert.True(invoked, "the continuation was not invoked when the task completed"); + Assert.Equal(0, context.Posted); + }); + + private static void WithoutSynchronizationContext(Action action) + { + var original = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(null); + try + { + action(); + } + finally + { + SynchronizationContext.SetSynchronizationContext(original); + } + } + + private static void WithSynchronizationContext(TContext context, Action action) + where TContext : SynchronizationContext + { + var original = SynchronizationContext.Current; + SynchronizationContext.SetSynchronizationContext(context); + try + { + action(context); + } + finally + { + SynchronizationContext.SetSynchronizationContext(original); + } + } + + private sealed class RecordingSynchronizationContext : SynchronizationContext + { + private int _posted; + + public int Posted => _posted; + + public override void Post(SendOrPostCallback d, object? state) + { + Interlocked.Increment(ref _posted); + d(state); + } } } #endif