Repository navigation
Register the Option awaiter continuations without a synchronization context - #1014
Merged
Merged
Conversation
…ontext 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 <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🟢 Approval recommended
The focused test-only changes restore synchronization contexts reliably and have no identified blocking issues.
No open issues 🎉
What changed in this PR
Addresses the flaky Option awaiter tests reported in #1004 by isolating continuation registration from xUnit’s synchronization context, without changing library behavior.
Changes:
- Registers continuations without a synchronization context and waits using
ManualResetEventSlim. - Adds tests for context capture,
ConfigureAwait(false)bypass, andNonecontinuations. - Adds an await-based test for an initially incomplete task.
| File | Description |
|---|---|
Funcky.Test/Monads/OptionAwaiterTest.cs |
Updates continuation test helpers and adds synchronization-context and incomplete-task coverage. |
Review effort: Balanced
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<Task>.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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
JudoJosef
approved these changes
Oct 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1004
Root cause
A continuation registered via
OnCompleted/UnsafeOnCompletedon the awaiter of an already completed task is never invoked inline:Task.SetContinuationForAwaithands it to a scheduler. With a synchronization context installed that is the context (Post); without one it is the thread pool. Under xunit v2 the context is theMaxConcurrencySyncContextwith one worker thread per core, and on the Windows runnerdotnet testexecutes seven test hosts concurrently, so both schedulers are shared with every other test in the process and both were starved for more than the 5 s timeout:await Task.WhenAny(invoked.Task, Task.Delay(5 s))) waited for a continuation posted to xunit's context; the failures were always at the first line of each fact (the path withoutConfigureAwait(false)), the failing Funcky.Async.Test run took 16 s instead of the usual 2–5 s.Option<Task>.None.GetAwaiter().OnCompleted(...).A synchronization context whose
Postnever runs its callbacks reproduces the original failure deterministically with the exact message; three full solution runs pinned to two cores did not trigger it naturally.The library is correct and unchanged: all
Option*Awaitertypes forwardOnCompletedandUnsafeOnCompleted1:1 to the BCL awaiters. One asymmetry worth knowing: the generic awaiters (Option<Task<T>>,Option<ValueTask<T>>) hold anOptionof the inner awaiter and invoke the continuation forNoneinline, whereas the non-generic ones (Option<Task>,Option<ValueTask>) replaceNonebyTask.CompletedTask/default(ValueTask)and therefore schedule it like any completed task. The tests now document this.Changes (
Funcky.Test/Monads/OptionAwaiterTest.csonly)No test waits on any scheduler any more; there are no timed waits left.
Noneand completedSome(Task, Task, ValueTask, ValueTask, with and withoutConfigureAwait(false)) assertIsCompletedandGetResult().awaitonly callsOnCompletedwhenIsCompletedis false, so registering on a completed awaiter is an artificial scenario.OnCompleted/UnsafeOnCompletedforwarding is tested on an incomplete task (TaskCompletionSource<T>, wrapped in aValueTask/ValueTask<T>for those variants): the continuation is asserted not to have run, the source is completed on the test thread with the synchronization context cleared, where the BCL runs the await continuation inline withinSetResult, and the invocation is asserted right after without waiting.RecordingSynchronizationContext(runs posts inline, counts them) pins the context flow: completedSomeand the non-genericNonepost exactly once, the genericNoneand everyConfigureAwait(false)path run inline, andConfigureAwait(false)does not capture the context of the registration (the context is installed only while registering; the continuation then runs inline withinSetResult).OptionOfIncompleteTaskIsAwaitablecovers the await-based happy path for a task that completes after the await started.Verification
dotnet build Funcky.slnx -warnaserrorclean; Funcky.Test green on net10.0 and net4.8; Funcky.Async.Test green on net9.0 and net8.0.🤖 Generated with Claude Code