From 6d586d7c8ec34f963d6a2762cdc7ce04a4064eae Mon Sep 17 00:00:00 2001 From: Thomas Bruderer Date: Tue, 27 Oct 2026 17:22:35 +0100 Subject: [PATCH 1/3] Register the Option awaiter continuations without a synchronization context OnCompleted and UnsafeOnCompleted on the awaiter of a completed Task post the continuation to the current SynchronizationContext instead of running it inline. Under xunit that is the MaxConcurrencySyncContext with one worker thread per core. When the Windows runner executes seven test hosts at once and both workers of Funcky.Async.Test are busy with synchronous FsCheck properties, the posted continuation waits in the queue longer than the 5 s timeout and the test reports "the continuation was not invoked" (#1004). Only the first line of each affected fact ever failed, the one without ConfigureAwait(false); the None facts never did. A synchronization context whose Post never runs its callbacks reproduces the failure deterministically; three full solution runs pinned to two cores did not trigger it naturally. The library forwards OnCompleted and UnsafeOnCompleted 1:1 to the BCL awaiters and is unchanged. The facts for the explicit registration now run without a synchronization context, so the continuation goes to the thread pool which other tests cannot starve, and wait synchronously on a ManualResetEventSlim. New facts pin the context flow down with a recording context: Some posts to the current context exactly once, ConfigureAwait(false) bypasses it, and None never posts. An await-based fact covers a task that completes after the await started. Closes #1004 Co-Authored-By: Claude Fable 5.1 --- Funcky.Test/Monads/OptionAwaiterTest.cs | 199 ++++++++++++++++++------ 1 file changed, 155 insertions(+), 44 deletions(-) diff --git a/Funcky.Test/Monads/OptionAwaiterTest.cs b/Funcky.Test/Monads/OptionAwaiterTest.cs index 1805fa74..66353efc 100644 --- a/Funcky.Test/Monads/OptionAwaiterTest.cs +++ b/Funcky.Test/Monads/OptionAwaiterTest.cs @@ -3,6 +3,8 @@ namespace Funcky.Async.Test.Monads; public sealed class OptionAwaiterTest { + private static readonly TimeSpan ContinuationTimeout = TimeSpan.FromSeconds(5); + [Fact] public async Task OptionOfVoidTaskIsAwaitable() { @@ -39,66 +41,175 @@ public async Task OptionOfValueTaskIsAwaitable() } [Fact] - public async Task OnCompletedInvokesTheContinuationForNone() + public async Task OptionOfIncompleteTaskIsAwaitable() + { + var source = new TaskCompletionSource(); + var awaiting = AwaitAsync(Option.Some(source.Task)); + source.SetResult(10); + FunctionalAssert.Some(10, await awaiting); + + static async Task> AwaitAsync(Option> option) => await option; + } + + [Fact] + public void OnCompletedInvokesTheContinuationForNone() { - 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)); + AssertContinuationIsInvoked(continuation => Option>.None.GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option>.None.GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.None.GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.None.GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); } [Fact] - public async Task UnsafeOnCompletedInvokesTheContinuationForNone() + public void UnsafeOnCompletedInvokesTheContinuationForNone() { - 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)); + AssertContinuationIsInvoked(continuation => Option>.None.GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option>.None.GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.None.GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.None.GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); } [Fact] - public async Task OnCompletedInvokesTheContinuationForSome() + public void OnCompletedInvokesTheContinuationForSome() { - 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)); + AssertContinuationIsInvoked(continuation => Option.Some(Task.FromResult(10)).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.FromResult(10)).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(Task.CompletedTask).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(Task.CompletedTask).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.CompletedTask).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.CompletedTask).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); } [Fact] - public async Task UnsafeOnCompletedInvokesTheContinuationForSome() + public void 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)); + AssertContinuationIsInvoked(continuation => Option.Some(Task.FromResult(10)).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.FromResult(10)).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(Task.CompletedTask).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(Task.CompletedTask).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.CompletedTask).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.CompletedTask).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); } - private static async Task AssertContinuationIsInvoked(Action registerContinuation) + [Fact] + public void OnCompletedForSomePostsTheContinuationToTheCurrentSynchronizationContext() + { + 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)); + } + + [Fact] + public void ConfigureAwaitFalseForSomeBypassesTheCurrentSynchronizationContext() + { + AssertContinuationIsNotPosted(continuation => Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsNotPosted(continuation => Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsNotPosted(continuation => Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsNotPosted(continuation => Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsNotPosted(continuation => Option.Some(Task.CompletedTask).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsNotPosted(continuation => Option.Some(Task.CompletedTask).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsNotPosted(continuation => Option.Some(ValueTask.CompletedTask).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsNotPosted(continuation => Option.Some(ValueTask.CompletedTask).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + } + + [Fact] + public void ContinuationForNoneIsNeverPosted() { - // 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"); + AssertContinuationIsNotPosted(continuation => Option>.None.GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsNotPosted(continuation => Option>.None.GetAwaiter().UnsafeOnCompleted(continuation)); + AssertContinuationIsNotPosted(continuation => Option>.None.GetAwaiter().OnCompleted(continuation)); + AssertContinuationIsNotPosted(continuation => Option>.None.GetAwaiter().UnsafeOnCompleted(continuation)); + } + + // The awaiter of a completed Task posts its continuation to the current SynchronizationContext instead of + // invoking it inline. Under xunit that is the MaxConcurrencySyncContext with one worker thread per core, + // which other tests can occupy for seconds, so a test waiting for such a continuation starves on small + // runners (#1004). Without a context the continuation goes to the thread pool, which cannot be starved by + // other tests, so the wait below is both safe and short. + private static void AssertContinuationIsInvoked(Action registerContinuation) + => WithoutSynchronizationContext(() => + { + using var invoked = new ManualResetEventSlim(); + registerContinuation(invoked.Set); + Assert.True(invoked.Wait(ContinuationTimeout), "the continuation was not invoked"); + }); + + // A recording context runs posted callbacks inline, so a posted continuation has run when the registration returns. + 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"); + }); + + private static void AssertContinuationIsNotPosted(Action registerContinuation) + => WithSynchronizationContext(new RecordingSynchronizationContext(), context => + { + using var invoked = new ManualResetEventSlim(); + registerContinuation(invoked.Set); + Assert.True(invoked.Wait(ContinuationTimeout), "the continuation was not invoked"); + 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 From f6a2ba4a8928e65527e3d0028aeb128ec7f8ff2c Mon Sep 17 00:00:00 2001 From: Thomas Bruderer Date: Tue, 27 Oct 2026 18:11:35 +0100 Subject: [PATCH 2/3] Remove every scheduler dependency from the Option awaiter tests The first fix moved the continuation of a completed task from xunit's synchronization context to the thread pool, which the loaded two-core runner starved as well: OnCompletedInvokesTheContinuationForNone timed out on Option.None, whose non-generic awaiter wraps the awaiter of Task.CompletedTask and therefore schedules the continuation like any completed task instead of invoking it inline as the generic awaiters do for None. A continuation registered on an already completed task always goes through a scheduler shared with every other test in the process, so no test waits for one any more: - None and completed Some assert IsCompleted and GetResult; await never calls OnCompleted on a completed awaiter anyway. - OnCompleted and UnsafeOnCompleted register on an incomplete task and complete the TaskCompletionSource on the test thread with the synchronization context cleared, where the BCL runs the continuation inline within SetResult, so the assertion follows without any wait. - The recording synchronization context runs posted callbacks inline: completed Some and the non-generic None post exactly once, the generic None and every ConfigureAwait(false) path run inline, and ConfigureAwait(false) does not capture the context of the registration. Co-Authored-By: Claude Fable 5.1 --- Funcky.Test/Monads/OptionAwaiterTest.cs | 227 +++++++++++++++++------- 1 file changed, 159 insertions(+), 68 deletions(-) diff --git a/Funcky.Test/Monads/OptionAwaiterTest.cs b/Funcky.Test/Monads/OptionAwaiterTest.cs index 66353efc..af201f67 100644 --- a/Funcky.Test/Monads/OptionAwaiterTest.cs +++ b/Funcky.Test/Monads/OptionAwaiterTest.cs @@ -3,8 +3,6 @@ namespace Funcky.Async.Test.Monads; public sealed class OptionAwaiterTest { - private static readonly TimeSpan ContinuationTimeout = TimeSpan.FromSeconds(5); - [Fact] public async Task OptionOfVoidTaskIsAwaitable() { @@ -51,60 +49,116 @@ public async Task OptionOfIncompleteTaskIsAwaitable() 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 OnCompletedInvokesTheContinuationForNone() + public void AwaiterOfNoneIsCompletedWithNone() { - AssertContinuationIsInvoked(continuation => Option>.None.GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option>.None.GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.None.GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.None.GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.None.ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + var taskAwaiter = Option>.None.GetAwaiter(); + Assert.True(taskAwaiter.IsCompleted); + FunctionalAssert.None(taskAwaiter.GetResult()); + + var configuredTaskAwaiter = Option>.None.ConfigureAwait(false).GetAwaiter(); + Assert.True(configuredTaskAwaiter.IsCompleted); + FunctionalAssert.None(configuredTaskAwaiter.GetResult()); + + var valueTaskAwaiter = Option>.None.GetAwaiter(); + Assert.True(valueTaskAwaiter.IsCompleted); + FunctionalAssert.None(valueTaskAwaiter.GetResult()); + + var configuredValueTaskAwaiter = Option>.None.ConfigureAwait(false).GetAwaiter(); + Assert.True(configuredValueTaskAwaiter.IsCompleted); + FunctionalAssert.None(configuredValueTaskAwaiter.GetResult()); } [Fact] - public void UnsafeOnCompletedInvokesTheContinuationForNone() + public void AwaiterOfVoidNoneIsCompleted() { - AssertContinuationIsInvoked(continuation => Option>.None.GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option>.None.GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option>.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.None.GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.None.GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.None.ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + 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 OnCompletedInvokesTheContinuationForSome() + public void AwaiterOfCompletedSomeIsCompletedWithTheResult() { - AssertContinuationIsInvoked(continuation => Option.Some(Task.FromResult(10)).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.FromResult(10)).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(Task.CompletedTask).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(Task.CompletedTask).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.CompletedTask).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.CompletedTask).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); + var taskAwaiter = Option.Some(Task.FromResult(10)).GetAwaiter(); + Assert.True(taskAwaiter.IsCompleted); + FunctionalAssert.Some(10, taskAwaiter.GetResult()); + + var configuredTaskAwaiter = Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter(); + Assert.True(configuredTaskAwaiter.IsCompleted); + FunctionalAssert.Some(10, configuredTaskAwaiter.GetResult()); + + var valueTaskAwaiter = Option.Some(ValueTask.FromResult(10)).GetAwaiter(); + Assert.True(valueTaskAwaiter.IsCompleted); + FunctionalAssert.Some(10, valueTaskAwaiter.GetResult()); + + var configuredValueTaskAwaiter = Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter(); + Assert.True(configuredValueTaskAwaiter.IsCompleted); + FunctionalAssert.Some(10, configuredValueTaskAwaiter.GetResult()); } [Fact] - public void UnsafeOnCompletedInvokesTheContinuationForSome() + public void AwaiterOfCompletedVoidSomeIsCompleted() { - AssertContinuationIsInvoked(continuation => Option.Some(Task.FromResult(10)).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.FromResult(10)).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(Task.CompletedTask).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(Task.CompletedTask).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.CompletedTask).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsInvoked(continuation => Option.Some(ValueTask.CompletedTask).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); + 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 void OnCompletedForSomePostsTheContinuationToTheCurrentSynchronizationContext() + public void OnCompletedInvokesTheContinuationWhenTheTaskCompletes() + { + 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 void UnsafeOnCompletedInvokesTheContinuationWhenTheTaskCompletes() + { + 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 void ContinuationForCompletedSomeIsPostedToTheCurrentSynchronizationContext() { AssertContinuationIsPosted(continuation => Option.Some(Task.FromResult(10)).GetAwaiter().OnCompleted(continuation)); AssertContinuationIsPosted(continuation => Option.Some(Task.FromResult(10)).GetAwaiter().UnsafeOnCompleted(continuation)); @@ -116,42 +170,74 @@ public void OnCompletedForSomePostsTheContinuationToTheCurrentSynchronizationCon 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 ConfigureAwaitFalseForSomeBypassesTheCurrentSynchronizationContext() + public void ContinuationForGenericNoneIsInvokedInline() { - AssertContinuationIsNotPosted(continuation => Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsNotPosted(continuation => Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsNotPosted(continuation => Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsNotPosted(continuation => Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsNotPosted(continuation => Option.Some(Task.CompletedTask).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsNotPosted(continuation => Option.Some(Task.CompletedTask).ConfigureAwait(false).GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsNotPosted(continuation => Option.Some(ValueTask.CompletedTask).ConfigureAwait(false).GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsNotPosted(continuation => Option.Some(ValueTask.CompletedTask).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.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 ContinuationForNoneIsNeverPosted() + public void ContinuationForVoidNoneIsPostedToTheCurrentSynchronizationContext() { - AssertContinuationIsNotPosted(continuation => Option>.None.GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsNotPosted(continuation => Option>.None.GetAwaiter().UnsafeOnCompleted(continuation)); - AssertContinuationIsNotPosted(continuation => Option>.None.GetAwaiter().OnCompleted(continuation)); - AssertContinuationIsNotPosted(continuation => Option>.None.GetAwaiter().UnsafeOnCompleted(continuation)); + 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)); } - // The awaiter of a completed Task posts its continuation to the current SynchronizationContext instead of - // invoking it inline. Under xunit that is the MaxConcurrencySyncContext with one worker thread per core, - // which other tests can occupy for seconds, so a test waiting for such a continuation starves on small - // runners (#1004). Without a context the continuation goes to the thread pool, which cannot be starved by - // other tests, so the wait below is both safe and short. - private static void AssertContinuationIsInvoked(Action registerContinuation) + [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(() => { - using var invoked = new ManualResetEventSlim(); - registerContinuation(invoked.Set); - Assert.True(invoked.Wait(ContinuationTimeout), "the continuation was not invoked"); + 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); }); - // A recording context runs posted callbacks inline, so a posted continuation has run when the registration returns. private static void AssertContinuationIsPosted(Action registerContinuation) => WithSynchronizationContext(new RecordingSynchronizationContext(), context => { @@ -161,12 +247,17 @@ private static void AssertContinuationIsPosted(Action registerContinuati Assert.True(invoked, "the continuation was not invoked"); }); - private static void AssertContinuationIsNotPosted(Action registerContinuation) - => WithSynchronizationContext(new RecordingSynchronizationContext(), context => + // 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(() => { - using var invoked = new ManualResetEventSlim(); - registerContinuation(invoked.Set); - Assert.True(invoked.Wait(ContinuationTimeout), "the continuation was not invoked"); + 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); }); From 3b888fe9ba89178ed1dafe58b0a6c6fc45c406a8 Mon Sep 17 00:00:00 2001 From: Thomas Bruderer Date: Thu, 29 Oct 2026 14:32:30 +0100 Subject: [PATCH 3/3] Use the Assert members in the Option awaiter tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #1011 made λ1203 an error for the test projects after this test was rewritten, so the merge from main failed the build on every leg with FunctionalAssert.Some and FunctionalAssert.None. Co-Authored-By: Claude Fable 5.1 --- Funcky.Test/Monads/OptionAwaiterTest.cs | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Funcky.Test/Monads/OptionAwaiterTest.cs b/Funcky.Test/Monads/OptionAwaiterTest.cs index e7a65004..af6644c6 100644 --- a/Funcky.Test/Monads/OptionAwaiterTest.cs +++ b/Funcky.Test/Monads/OptionAwaiterTest.cs @@ -44,7 +44,7 @@ public async Task OptionOfIncompleteTaskIsAwaitable() var source = new TaskCompletionSource(); var awaiting = AwaitAsync(Option.Some(source.Task)); source.SetResult(10); - FunctionalAssert.Some(10, await awaiting); + Assert.Some(10, await awaiting); static async Task> AwaitAsync(Option> option) => await option; } @@ -55,19 +55,19 @@ public void AwaiterOfNoneIsCompletedWithNone() { var taskAwaiter = Option>.None.GetAwaiter(); Assert.True(taskAwaiter.IsCompleted); - FunctionalAssert.None(taskAwaiter.GetResult()); + Assert.None(taskAwaiter.GetResult()); var configuredTaskAwaiter = Option>.None.ConfigureAwait(false).GetAwaiter(); Assert.True(configuredTaskAwaiter.IsCompleted); - FunctionalAssert.None(configuredTaskAwaiter.GetResult()); + Assert.None(configuredTaskAwaiter.GetResult()); var valueTaskAwaiter = Option>.None.GetAwaiter(); Assert.True(valueTaskAwaiter.IsCompleted); - FunctionalAssert.None(valueTaskAwaiter.GetResult()); + Assert.None(valueTaskAwaiter.GetResult()); var configuredValueTaskAwaiter = Option>.None.ConfigureAwait(false).GetAwaiter(); Assert.True(configuredValueTaskAwaiter.IsCompleted); - FunctionalAssert.None(configuredValueTaskAwaiter.GetResult()); + Assert.None(configuredValueTaskAwaiter.GetResult()); } [Fact] @@ -95,19 +95,19 @@ public void AwaiterOfCompletedSomeIsCompletedWithTheResult() { var taskAwaiter = Option.Some(Task.FromResult(10)).GetAwaiter(); Assert.True(taskAwaiter.IsCompleted); - FunctionalAssert.Some(10, taskAwaiter.GetResult()); + Assert.Some(10, taskAwaiter.GetResult()); var configuredTaskAwaiter = Option.Some(Task.FromResult(10)).ConfigureAwait(false).GetAwaiter(); Assert.True(configuredTaskAwaiter.IsCompleted); - FunctionalAssert.Some(10, configuredTaskAwaiter.GetResult()); + Assert.Some(10, configuredTaskAwaiter.GetResult()); var valueTaskAwaiter = Option.Some(ValueTask.FromResult(10)).GetAwaiter(); Assert.True(valueTaskAwaiter.IsCompleted); - FunctionalAssert.Some(10, valueTaskAwaiter.GetResult()); + Assert.Some(10, valueTaskAwaiter.GetResult()); var configuredValueTaskAwaiter = Option.Some(ValueTask.FromResult(10)).ConfigureAwait(false).GetAwaiter(); Assert.True(configuredValueTaskAwaiter.IsCompleted); - FunctionalAssert.Some(10, configuredValueTaskAwaiter.GetResult()); + Assert.Some(10, configuredValueTaskAwaiter.GetResult()); } [Fact]