Awaitable.GetAwaiter().OnCompleted

Awaitable.GetAwaiter().OnCompleted seems undocumented? GetAwaiter() is specifically decorated with [ExcludeFromDocs]. Is this something I should not be touching in my gameplay code?

This is in context of trying to get a viable WhenAll alternative up and running:

using System;
using System.Threading;
using UnityEngine;

namespace Redacted.Core.Utilities
{
    public static class AwaitableUtilities
    {
        public static Awaitable WhenAll(params Awaitable[] awaitables)
        {
            if (awaitables == null) throw new ArgumentNullException(nameof(awaitables));
            if (awaitables.Length == 0)
            {
                return CompletedAwaitable();
            }

            var completionSource = new AwaitableCompletionSource();
            int remaining = awaitables.Length;

            foreach (Awaitable awaitable in awaitables)
            {
                awaitable.GetAwaiter().OnCompleted(() =>
                {
                    try
                    {
                        awaitable.GetAwaiter().GetResult();
                    }
                    catch
                    {
                        completionSource.TrySetCanceled();
                    }
                    finally
                    {
                        remaining--;
                        if (remaining == 0)
                        {
                            completionSource.SetResult();
                        }
                    }
                });
            }
            
            return completionSource.Awaitable;
        }

        public static async Awaitable WaitUntil(Func<bool> condition, CancellationToken cancellationToken)
        {
            while (!condition())
            {
                if (cancellationToken.IsCancellationRequested)
                {
                    break;
                }
                await Awaitable.NextFrameAsync(cancellationToken);
            }
        }

        public static Awaitable CompletedAwaitable() => CompletedAwaitableCache.Get();

        private static class CompletedAwaitableCache
        {
            private static readonly AwaitableCompletionSource s_completionSource = new();

            public static Awaitable Get()
            {
                s_completionSource.SetResult();
                Awaitable awaitable = s_completionSource.Awaitable;
                s_completionSource.Reset();
                return awaitable;
            }
        }
    }
}

The GetAwaiter exists to be used by the async state machine. The OnCompleted of the GetAwaiter is called when the awaitable object is asynchronously completed. When we use the await keyword, the state machine checks if the awaitable is completed with the help of the IsCompleted property and if it is, executes the rest of the code synchronously.

If it isn’t, the state machine suspends the execution and when the awaitable is completed the delegate on the OnCompleted runs. This delegate contains all the code after the await expression. For this reason, using the OnCompleted is meaningless and sometimes dangerous. Everything you add to the OnCompleted delegate parameter is the same as adding it after the await expression.

The main advantage of the WhenAll method of the Task, over manually awaiting all the tasks is that it will catch all the exceptions that will happen in the tasks that has as parameters and that it will also allow all the tasks to finish before throwing one or more of the exceptions. If exceptions were not a concern then manually starting all the tasks and then manually awaiting all of them would be the same as using WhenAll, only less readable.

I write this about the WhenAll of task, to explain why your code won’t work, if you write:

async void Start()
   {
      AwaitableCompletionSource acs = new AwaitableCompletionSource();

      AwaitableCompletionSource acs2 = new AwaitableCompletionSource();

      acs.TrySetException(new ArgumentException("Argument exception"));
      acs2.TrySetException(new ArgumentException("Argument exception2"));

      var aw = acs.Awaitable;
      var aw2 = acs2.Awaitable;
      
      var bar = AwaitableUtilities.WhenAll(aw, aw2);
      
      try
      {
         await bar;
      }
      catch (Exception e)
      {
         Console.WriteLine(e);
         throw;
      }
   }

You won’t get any of the exceptions here, but instead you will get an exception from your implementation: InvalidOperationException: Can't raise completion of the same Awaitable twice, because your code will try to use the completionSource.SetResult() more than once, because of the var bar ... line.

This happens because of the way the Awaitable is implemented.

In short:

  1. Don’t use the OnCompleted, at best it will be the same as adding the code of the delegate as code sfter the await.
  2. Don’t try to create an implementation similar to task’s WhenAll unless you want to try and fight the Awaitable implementation to somehow catch the possible exceptions in an aggregateexception and set this in its SetResult, instead of manually awaiting all you awaitables.

As a final note, the Awaitable is more comperable to the ValueTask than the Task, because of performance reasons, it cannot have its completion raised more than once, cannot be awaited more than once and so on. If you want Task’s specific functionality, try to use Task and manually create implementations to cancel it when the game stops, the gameobject is destroyed etc. I think it is less complicated than trying to work around the Awaitable’s limitations.

Thank you for the input, I will scrap my attempts at Awaitable WhenAll and do as you suggested.