Skip to content

Register the Option awaiter continuations without a synchronization context - #1014

Merged
FreeApophis merged 4 commits into
mainfrom
fix-flaky-option-awaiter-test
Oct 29, 2026
Merged

FreeApophis merged 4 commits into
mainfrom
fix-flaky-option-awaiter-test

Conversation

@FreeApophis

@FreeApophis FreeApophis commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Closes #1004

Root cause

A continuation registered via OnCompleted/UnsafeOnCompleted on the awaiter of an already completed task is never invoked inline: Task.SetContinuationForAwait hands 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 the MaxConcurrencySyncContext with one worker thread per core, and on the Windows runner dotnet test executes 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:

  • The original test (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 without ConfigureAwait(false)), the failing Funcky.Async.Test run took 16 s instead of the usual 2–5 s.
  • The first attempt in this PR cleared the context, which only moved the continuation to the thread pool; the CI run of that commit then timed out on Option<Task>.None.GetAwaiter().OnCompleted(...).

A synchronization context whose Post never 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*Awaiter types forward OnCompleted and UnsafeOnCompleted 1:1 to the BCL awaiters. One asymmetry worth knowing: the generic awaiters (Option<Task<T>>, Option<ValueTask<T>>) hold an Option of the inner awaiter and invoke the continuation for None inline, whereas the non-generic ones (Option<Task>, Option<ValueTask>) replace None by Task.CompletedTask/default(ValueTask) and therefore schedule it like any completed task. The tests now document this.

Changes (Funcky.Test/Monads/OptionAwaiterTest.cs only)

No test waits on any scheduler any more; there are no timed waits left.

  • None and completed Some (Task, Task, ValueTask, ValueTask, with and without ConfigureAwait(false)) assert IsCompleted and GetResult(). await only calls OnCompleted when IsCompleted is false, so registering on a completed awaiter is an artificial scenario.
  • OnCompleted/UnsafeOnCompleted forwarding is tested on an incomplete task (TaskCompletionSource<T>, wrapped in a ValueTask/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 within SetResult, and the invocation is asserted right after without waiting.
  • A RecordingSynchronizationContext (runs posts inline, counts them) pins the context flow: 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 (the context is installed only while registering; the continuation then runs inline within SetResult).
  • OptionOfIncompleteTaskIsAwaitable covers the await-based happy path for a task that completes after the await started.

Verification

dotnet build Funcky.slnx -warnaserror clean; Funcky.Test green on net10.0 and net4.8; Funcky.Async.Test green on net9.0 and net8.0.

🤖 Generated with Claude Code

…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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, and None continuations.
  • 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

FreeApophis and others added 3 commits October 27, 2026 18:11
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>
@FreeApophis
FreeApophis merged commit 26811b9 into main Oct 29, 2026
10 checks passed
@FreeApophis
FreeApophis deleted the fix-flaky-option-awaiter-test branch October 29, 2026 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flaky test: OptionAwaiterTest.UnsafeOnCompletedInvokesTheContinuationForSome times out on Windows CI

3 participants