ProtoPromise 2.0.0

There is a newer version of this package available.
See the version list below for details.

Requires NuGet 2.5 or higher.

dotnet add package ProtoPromise --version 2.0.0
NuGet\Install-Package ProtoPromise -Version 2.0.0
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ProtoPromise" Version="2.0.0" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add ProtoPromise --version 2.0.0
#r "nuget: ProtoPromise, 2.0.0"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install ProtoPromise as a Cake Addin
#addin nuget:?package=ProtoPromise&version=2.0.0

// Install ProtoPromise as a Cake Tool
#tool nuget:?package=ProtoPromise&version=2.0.0

ProtoPromise

<a href="https://promisesaplus.com/"> <img src="https://promisesaplus.com/assets/logo-small.png" alt="Promises/A+ logo" title="Promises/A+ 1.1 compliant" align="right" /> </a>

Robust and efficient library for management of asynchronous operations.

  • Allocation-free async operations
  • Cancelable operations with custom allocation-free CancelationToken/Source
  • Progress with universal automatic or manual normalization
  • Full causality traces
  • Interoperable with Tasks and Unity's Coroutines
  • Thread safe
  • .Then API and async/await
  • Easily switch to foreground or background context
  • Combine async operations
  • CLS compliant

This library was built to work in all C#/.Net ecosystems, including Unity, Mono, .Net Framework, .Net Core and UI frameworks. It is CLS compliant, so it is not restricted to only C#, and will work with any .Net language.

ProtoPromise conforms to the Promises/A+ Spec as far as is possible with C# (using static typing instead of dynamic), and further extends it to support Cancelations and Progress.

This library took inspiration from ES6 Promises (javascript), RSG Promises (C#), uPromise (C#/Unity), TPL, and UniTask (C#/Unity).

Latest Updates

v 2.0.0 - March 7, 2022

  • Full library thread-safety with minimal locks.

  • Added Deferred.Cancel.

  • Added Deferred and CancelationSource and CancelationToken and CancelationRegistration Try... methods.

  • Added CancelationRegistration methods to unregister/check registration and if the token is requesting cancelation atomically.

  • Added static CancelationToken.Canceled() to get a token already in the canceled state without allocating.

  • Added Promise(<T>).WaitAsync(SynchronizationOption) and Promise(<T>).WaitAsync(SynchronizationContext) to schedule the next callback/await on the desired context.

  • Added Promise.Run static functions.

  • Added Promise.SwitchToForeground(), Promise.SwitchToBackground(), and Promise.SwitchToContext(SynchronizationContext) static functions.

  • Added Promise.AwaitWithProgress(float minProgress, float maxProgress) API to propagate the progress to an async Promise(<T>) function.

  • Promises are now structs, making already resolved promises live only on the stack, increasing performance.

  • Eliminated potential StackOverflowExceptions from async/await continuations when both the async function and the awaited object are Promise(<T>).

  • Changed behavior of Promise.CatchCancelation to return a new promise and behave more like Promise.Catch, where onCanceled resolves the returned promise when it returns, or adopts the state of the returned promise.

  • Removed cancelation reasons.

  • A rejected promise awaited in an async function now throws the original exception, if the promise was rejected with an exception.

  • Deprecations and other breaking changes (see full release notes).

See Release Notes for the full changelog.

Contents

Package Installation

Unity

  • Install via package manager (recommended)

In the Package Manager, open the dropdown and click on Add Package from git url and enter https://github.com/TimCassell/ProtoPromise.git?path=ProtoPromise_Unity/Assets/Plugins/ProtoPromise. Or add "com.timcassell.protopromise": "https://github.com/TimCassell/ProtoPromise.git?path=ProtoPromise_Unity/Assets/Plugins/ProtoPromise" to Packages/manifest.json. You may append #vX.X.X to use a specific version, for exampe #v2.0.0.

  • Install via Unity's Asset Store

Add to your assets from the Asset Store at https://assetstore.unity.com/packages/tools/integration/protopromise-181997.

  • Download unitypackage from GitHub

Go to the latest release and download the unitypackage. Import the unitypackage into your Unity project.

Nuget

  1. Go to the latest release and download the source code.

  2. Extract the source code from the zip.

  3. Now you have 2 options:

    1. Use dll
    1. Open the ProtoPromise.sln in Visual Studio 2019 or later.
    2. Select the configuration you want (Release/Debug With(out) Progress).
    3. Build the solution.
    4. Select the dll you want to use under Runtime/bin and use that in your own project.
    1. Use source code
    1. (optional) Place the entire Runtime folder in your project directory.
    2. Add the ProtoPromise.csproj project reference to your solution (Visual Studio).
    3. Set the compiler symbol PROTO_PROMISE_PROGRESS_DISABLE if you want to disable progress.
  4. When you have the ProtoPromise assembly referenced in your project, once per frame (or application update loop), call Proto.Promises.Promise.Manager.HandleCompletesAndProgress(), ideally as the last thing before the frame is rendered (or the update loop starts over). This should only be done on the UI/main thread.

  5. Continue to Creating a Promise for an Async Operation.

Creating a Promise for an Async Operation

Import the namespace:

using Proto.Promises;

Create a deferred before you start the async operation:

var deferred = Promise.NewDeferred<string>();

The type of the deferred should reflect the result of the asynchronous operation.

Then initiate your async operation and return the promise to the caller.

return deferred.Promise;

Upon completion of the async op the promise is resolved via the deferred:

deferred.Resolve(value);

The promise is rejected on error/exception:

deferred.Reject(error);

To see it in context, here is an example function that downloads text from a URL. The promise is resolved when the download completes. If there is an error during download, say unresolved domain name, then the promise is rejected:

public Promise<string> Download(string url)
{
    var deferred = Promise.NewDeferred<string>();    // Create deferred.
    using (var client = new WebClient())
    {
        client.DownloadStringCompleted += (s, ev) =>  // Monitor event for download completed.
        {
            if (ev.Error != null)
            {
                deferred.Reject(ev.Error);   // Error during download, reject the promise.
            }
            else
            {
                deferred.Resolve(ev.Result); // Downloaded completed successfully, resolve the promise.
            }
        };

        client.DownloadStringAsync(new Uri(url), null); // Initiate async op.
    }

    return deferred.Promise; // Return the promise so the caller can await resolution (or error).
}

Creating a Promise, Alternate Method

There is another way to create a promise that replicates the JavaScript convention of passing a resolver function into the constructor. The only difference is a deferred is passed to the function instead of another function. The deferred is what controls the resolution or rejection of the promise, just as it was above. This allows you to express the previous example like this:

public Promise<string> Download(string url)
{
  return Promise.New<string>(deferred =>
  {
      using (var client = new WebClient())
      {
          client.DownloadStringCompleted += (s, ev) =>  // Monitor event for download completed.
          {
              if (ev.Error != null)
              {
                  deferred.Reject(ev.Error);   // Error during download, reject the promise.
              }
              else
              {
                  deferred.Resolve(ev.Result); // Downloaded completed successfully, resolve the promise.
              }
          };

          client.DownloadStringAsync(new Uri(url), null); // Initiate async op.
      }
  });
}

With this method, if the function throws an exception before the deferred is settled, the deferred/promise will be rejected with that exception.

Waiting for an Async Operation to Complete

The simplest and most common usage is to use the await keyword in an async function. Code that comes after the await will not be ran until the promise has completed. await promise will also return its result when it completes so that it can be assigned to a variable.

async void Func()
{
    string html = await Download("http://www.google.com");
    Console.WriteLine(html);
}

This snippet downloads the front page from Google and prints it to the console.

If you are using a language that does not support async/await (or an older version of C#), or if you prefer the javascript "thenable" convention, you can use the .Then API.

void Func()
{
    Download("http://www.google.com")
        .Then(html => Console.WriteLine(html))
        .Forget();
}

In this case, because the operation can fail, you will also want to handle the error:

async void Func()
{
    try
    {
        string html = await Download("http://www.google.com");
        Console.WriteLine(html);
    }
    catch (Exception error)
    {
        Console.WriteLine("An error occured while downloading: " + error);
    }
}
void Func()
{
    Download("http://www.google.com")
        .Then(html => Console.WriteLine(html))
        .Catch((Exception error) => Console.WriteLine("An error occured while downloading: " + error));
        .Forget();
}

The chain of processing for a promise ends as soon as an error/exception occurs. In this case when an error occurs the Reject handler would be called, but not the Resolve handler. If there is no error, then only the Resolve handler is called.

Chaining Async Operations

Multiple async operations can be chained one after the other using await or .Then.

void Func()
{
    try
    {
        string html = await Download("http://www.google.com");
        // Extract the first link and download it and wait for the download to complete.
        string firstLink = ExtractFirstLink(html)
        string firstLinkHtml = await Download(firstLink);
        Console.WriteLine(firstLinkHtml);
    }
    catch (Exception error)
    {
        Console.WriteLine("An error occured while downloading: " + error);
    }
}
void Func()
{
    Download("http://www.google.com")
        .Then(html =>
        {
            // Extract the first link and download it and wait for the download to complete.
            string firstLink = ExtractFirstLink(html)
            return Download(firstLink);
        })
        .Then(firstLinkHtml => Console.WriteLine(firstLinkHtml))
        .Catch((Exception error) => Console.WriteLine("An error occured while downloading: " + error))
        .Forget();
}

Here we are chaining another download onto the end of the first download. The first link in the html is extracted and we then download that. .Then expects the return value to be another promise. The chained promise can have a different result type.

Transforming the Results

Sometimes you will want to simply transform or modify the resulting value without chaining another async operation.

async Promise<string[]> GetAllLinks(string url)
{
    string html = await Download(url);
    return ExtractAllLinks(html);   // Extract all links and return an array of strings.
}
Promise<string[]> GetAllLinks(string url)
{
    return Download(url)
        .Then(html => ExtractAllLinks(html)))   // Extract all links and return an array of strings.
}

As demonstrated, the type of the value can also be changed during transformation. In the previous snippet a Promise<string> is transformed to a Promise<string[]>.

Promise Types

Promises may either be a value promise (Promise<T>), or a non-value promise (Promise). A value promise represents an asynchronous operation that will result in a value, and a non-value promise represents an asynchronous operation that simply does something without returning anything. In games, this is useful for composing and linking animations and other effects.

RunAnimation("Foo")                         // RunAnimation returns a promise that
    .Then(() => RunAnimation("Bar"))        // is resolved when the animation is complete.
    .Then(() => PlaySound("AnimComplete"))
    .Forget();

Promise<T> contains much of the same methods as Promise, so any value promise can be used like it is a non-value promise (the onResolved delegate can ignore its value), and can be implicitly casted and passed around as such. Besides casting, you can convert any value promise to a non-value promise and vice-versa via the Then method. The type of delegate you pass in will determine the type of the promise that is returned.

public Promise<TimeSpan> DownloadTest(string url) // Returns a promise that yields how long it took to download the url
{
    Stopwatch watch = Stopwatch.StartNew();
    return Download(url)                        // <---- string promise
        .Then(html =>                           // <---- non-value promise
        {
            watch.Stop(); // Download is done, stop the timer.
            Console.Log("Cool, Google works!");
            // Don't return anything
        })
        .Then(() =>                             // <---- TimeSpan promise
        {
            return watch.Elapsed;               // Return how much time elapsed since the download started.
        });
}

If the delegate returns nothing/void, it will become a non-value promise. If it returns an object or value, it will become a value promise of that object/value's type. Likewise, if the delegate returns another promise, the promise returned from .Then will adopt the type and state of that promise.

Error Handling

An error raised in a callback aborts the function and rejects the promise, and all subsequent onResolved callbacks in the chain are ignored, until an onRejected callback is encountered that matches the rejection.

void Func()
{
    Download("http://www.google.com")
        .Then(v => { throw new Exception(); return v; })    // <--- An error here aborts all subsequent callbacks...
        .Then(v => DoSomething())
        .Then(() => DoSomethingElse())
        .Catch((Exception e) => HandleError(e));            // <--- Until the error handler is invoked here.
}

When a promise is awaited in an async function and is rejected, the exception behaves similar to a non-async function, skipping the rest of the code up to a catch clause that matches the exception.

void Func()
{
    try
    {
        string html = await Download("http://www.google.com");  // <--- An error from Download will be thrown here, and aborts all subsequent code...
        await DoSomething();
        DoSomethingElse();
    }
    catch (Exception e)                                         // <--- Until the catch clause is matched to the exception here.
    {
        HandleError(e);
    }
}

Type Matching Error Handling

Promises can be rejected with any type of object or value, so you may decide to filter the type you want to handle:

rejectedPromise
    .Catch((ArgumentException e) => HandleArgumentError(e))
    .Catch((string e) => HandleStringError(e))
    .Catch((object e) => HandleAnyError(e))
    .Catch(() => HandleError());

A rejected reason will propagate down the chain of catches until it encounters a type that it can be assigned to. The very last Catch that does not accept a type or value will catch everything no matter what it is. If an earlier reject handler catches, later catches will not be ran unless another error occurs.

Note: the rejected value passed into the onRejected callback will never be null. If a null value is passed into Deferred.Reject, it will be transformed into a NullReferenceException.

Caught Error Continuation

When a promise is rejected and that rejection is caught, the next promise is resolved if the rejection handler returns without throwing any exceptions:

rejectedPromise
    .Catch(() => GetFallbackString())
    .Then((string s) => Console.WriteLine(s));

Unlike resolve handlers, which can transform the promise into any other type of promise, reject handlers can only keep the same promise type, or transform it to a non-value promise. You can also have the reject handler run another async operation and adopt its state the same way we did in Chaining Async Operations, as long as the other promise's type is the same or non-value.

rejectedPromise
    .Catch(() => DownloadSomethingElse(fallbackUrl));

Unhandled Rejections

When Catch is omitted, or none of the filters apply, an UnhandledException is sent to the Promise.Config.UncaughtRejectionHandler if it exists. If it does not exist, a System.AggregateException (which contains UnhandledExceptions) is thrown on the Promise.Config.ForegroundContext if it exists, or Promise.Config.BackgroundContext if it does not.

UnhandledExceptions wrap the rejections and contain the full causality trace so you can more easily debug what caused an error to occur in your async functions. Causality traces for .Then API is only available in DEBUG mode, for performance reasons (See Compiler Options). Exceptions in async functions contain the async stack traces natively.

Promises that are already settled

For convenience and optimizations, there are methods to get a promise that is already resolved, rejected, or canceled:

var resolvedNonValuePromise = Promise.Resolved();

var resolvedStringPromise = Promise.Resolved("That was fast");

var rejectedNonValuePromise = Promise.Rejected("Something went wrong!");

var rejectedIntPromise = Promise<int>.Rejected("Something went wrong!");

var canceledNonValuePromise = Promise.Canceled();

var canceledIntPromise = Promise<int>.Canceled();

This is useful if the operation actually completes synchronously but you still need to return a promise.

Progress reporting

Promises can additionally report their progress towards completion, allowing the implementor to give the user feedback on the asynchronous operation.

Promises report their progress as a value from 0 to 1. You can register a progress listener like so:

promise
    .Progress(progress =>
    {
        progressBar.SetProgress(progress);
        progressText.SetText( ((int) (progress * 100f)).ToString() + "%" );
    }

Progress can be reported through the deferred, and if it is reported, progress must be reported between 0 and 1 inclusive:

Promise WaitForSeconds(float seconds)
{
    var deferred = Promise.NewDeferred();
    StartCoroutine(_Countup());
    return deferred.Promise;
    
    IEnumerator _Countup()
    {
        for (float current = 0f; current < seconds; current += Time.deltaTime)
        {
            yield return null;
            deferred.ReportProgress(current / seconds); // Report the progress, normalized between 0 and 1.
        }
        deferred.Resolve();
    }
}

Reporting progress to a deferred is entirely optional, but even if progress is never reported through the deferred, it will always be reported as 1 after the promise is resolved.

Progress will always be automatically normalized from the .Then API, no matter how long the promise chain is.

Download("google.com")                      // <---- This will report 0.0f - 0.25f
    .Then(() => WaitForSeconds(1f))         // <---- This will report 0.25f - 0.5f
    .Then(() => Download("bing.com"))       // <---- This will report 0.5f - 0.75f
    .Then(() => WaitForSeconds(1f))         // <---- This will report 0.75f - 1.0f
    .Progress(progressBar.SetProgress)
    .Then(() => Console.Log("Downloads and extra waits complete."));

Progress must be manually normalized in an async Promise function via the .AwaitWithProgress API.

async Promise Func()
{
    await Download("google.com").AwaitWithProgress(0f, 0.25f);      // <---- This will report 0.0f - 0.25f
    await WaitForSeconds(1f).AwaitWithProgress(0.25f, 0.5f);        // <---- This will report 0.25f - 0.5f
    await Download("bing.com").AwaitWithProgress(0.5f, 0.75f);      // <---- This will report 0.5f - 0.75f
    await WaitForSeconds(1f).AwaitWithProgress(0.75f, 1f);          // <---- This will report 0.75f - 1.0f
}

Combining Multiple Async Operations

All Parallel

The All function combines multiple async operations to run in parallel. It converts a collection of promises or a variable length parameter list of promises into a single promise that yields a collection.

Say that each promise yields a value of type T, the resulting promise then yields a collection with values of type T.

Here is an example that extracts links from multiple pages and merges the results:

Promise.All(Download("http://www.google.com"), Download("http://www.bing.com"))  // Download each URL.
    .Then(pages =>                          // Receives collection of downloaded pages.
        pages.SelectMany(
            page => ExtractAllLinks(page)   // Extract links from all pages then flatten to single collection of links.
        )
    )
    .Then(links =>                          // Receives the flattened collection of links from all pages at once.
    {
        foreach (var link in links)
        {
            Console.WriteLine(link);
        }
    });

Progress from an All promise will be normalized from all of the input promises.

Merge Parallel

The Merge function behaves just like the All function, except that it can be used to combine multiple types, and instead of yielding an IList<T>, it yields a ValueTuple<> that contains the types of the promises provided to the function.

Promise.Merge(Download("http://www.google.com"), DownloadImage("http://www.example.com/image.jpg"))  // Download HTML and image.
    .Then(values =>                         // Receives ValueTuple<string, Texture>.
    {
        Console.WriteLine(values.Item1);    // Print the HTML.
        image.SetTexture(values.Item2);     // Assign the texture to an image object.
    });

Race Parallel

The Race function is similar to the All function, but it is the first async operation that settles that wins the race and the promise adopts its state.

Promise.Race(Download("http://www.google.com"), Download("http://www.bing.com"))  // Download each URL.
    .Then(html => Console.Log(html));                       // Both pages are downloaded, but only
                                                            // log the first one downloaded.

Progress from a Race promise will be the maximum of those reported by all the input promises.

First Parallel

The First function is almost idential to Race except that if a promise is rejected or canceled, the First promise will remain pending until one of the input promises is resolved or they are all rejected/canceled.

Sequence

The Sequence function builds a single promise that wraps multiple sequential operations that will be invoked one after the other.

Multiple promise-returning functions are provided as input, these are chained one after the other and wrapped in a single promise that is resolved once the sequence has completed.

var sequencePromise = Promise.Sequence(
    () => RunAnimation("Foo"),
    () => RunAnimation("Bar"),
    () => PlaySound("AnimComplete")
);

Configuration

You can change whether or not objects will be pooled via Promise.Config.ObjectPoolingEnabled. Enabling pooling reduces GC pressure, and it is enabled by default.

If you are in DEBUG mode, you can configure when additional stacktraces will be generated via Promise.Config.DebugCausalityTracer.

Promise.Config.UncaughtRejectionHandler allows you to route unhandled rejections through a delegate instead of being thrown.

Promise.Config.ForegroundContext is the context to which foreground operations are posted, typically used to marshal work to the UI thread. This is automatically set in Unity, but in other UI frameworks it should be set at application startup (usually Promise.Config.ForegroundContext = SynchronizationContext.Current is enough). Note: if your application uses multiple SynchronizationContexts, you should instead pass the context directly to the WaitAsync and other APIs instead of using SynchronizationOption.Foreground. See Switching Execution Context.

Promise.Config.BackgroundContext can be set to override how background operations are executed. If this is null, ThreadPool.QueueUserWorkItem(callback, state) is used.

Compiler Options

If you're compiling from source (not from dll), you can configure some compilation options.

Progress can be disabled if you don't intend to use it and want to save a little memory/cpu cycles. You can disable progress by adding PROTO_PROMISE_PROGRESS_DISABLE to your compiler symbols.

By default, debug options are tied to the DEBUG compiler symbol, which is defined by default in the Unity Editor and not defined in release builds. You can override that by defining PROTO_PROMISE_DEBUG_ENABLE to force debugging on in release builds, or PROTO_PROMISE_DEBUG_DISABLE to force debugging off in debug builds (or in the Unity Editor). If both symbols are defined, ENABLE takes precedence.

Advanced

Cancelations

Cancelation tokens are primarily used to cancel promises, but can be used to cancel anything. They come in 3 parts: CancelationSource, CancelationToken, and CancelationRegistration.

Cancelation Source

A CancelationSource is what is used to actually cancel a token. When a consumer wants to cancel a producer's operation, it creates a CancelationSource via CancelationSource.New() and caches it somewhere (usually in a private field). When it determines it no longer needs the result of the operation, it calls CancelationSource.Cancel().

When you are sure that the operation has been fully cleaned up, you must dispose of the source: CancelationSource.Dispose(). This usually makes most sense to do it in a promise's Finally callback (or try/finally clause in an async function, or you can use the using keyword).

You can get the token to pass to the producer from the CancelationSource.Token property.

Cancelation Token

A CancelationToken is what is passed around to listen for a cancelation event. Tokens are read-only, meaning it cannot be canceled without the source. You can use the token to pass into functions (like Promise.Then) without worrying about it being canceled from within those functions.

You can register a callback to the token that will be invoked when the source is canceled:

public void Func(CancelationToken token)
{
    token.Register(() => Console.Log("token was canceled"));
}

If the source is disposed without being canceled, the callback will not be invoked.

You can check whether the token is already canceled:

public IEnumerator FuncEnumerator(CancelationToken token)
{
    bool retained = token.TryRetain();
    while (!token.IsCancelationRequested)
    {
        Console.Log("Doing something");
        if (DoSomething())
        {
            yield break;
        }
        yield return null;
    }
    Console.Log("token was canceled");
    if (retained)
    {
        token.Release();
    }
}

Note: If you are checking the IsCancelationRequested property instead of registering a callback, you should retain the token for the duration of the operation, then release it when the operation is complete. If you do not retain it, it could be disposed before the next time the IsCancelationRequested is accessed.

Cancelation Registration

When you register a callback to a token, it returns a CancelationRegistration which can be used to unregister the callback.

CancelationRegistration registration = token.Register(() => Console.Log("This won't get called."));

// ... later, before the source is canceled
registration.Unregister();

If the registration is unregistered before the source is canceled, the callback will not be invoked. Once a registration has been unregistered, it cannot be re-registered. You must register a new callback to the token if you wish to do so.

Canceling Promises

Promise implementations usually do not allow cancelations, but it has proven to be invaluable to asynchronous libraries, and ProtoPromise is no exception.

Promises can be canceled 2 ways: passing a CancelationToken into Promise.{Then, Catch, ContinueWith} or Promise.NewDeferred, or by throwing a Cancelation Exception. When a promise is canceled, all promises that have been chained from it will be canceled, until a CatchCancelation.

CancelationSource cancelationSource = CancelationSource.New();

Download("http://www.google.com");                                  // <---- This will run to completion if no errors occur.
    .Then(html => Console.Log(html), cancelationSource.Token).      // <---- This will be canceled before the download completes and will not run.
    .Then(() => Download("http://www.bing.com"))                    // <---- This will also be canceled and will not run.
    .Then(html => Console.Log(html))                                // <---- This will also be canceled and will not run.
    .Finally(cancelationSource.Dispose);                            // Remember to always dispose of the cancelation source when it's no longer needed.
    
// ... later, before the first download is complete
cancelationSource.Cancel();                 // <---- This will stop the callbacks from being ran, but will not stop the google download.

Cancelations can be caught, similar to how rejections are caught, except there is no value.

cancelablePromise
    .CatchCancelation(() =>
    {
        Console.Log("Download was canceled!");
    });

Just like .Catch, .CatchCancelation can return a value of the same type, or no value to transform the promise to a non-value promise, or it can return another promise of the same type or non-value to have its state adopted.

cancelablePromise
    .CatchCancelation(() =>
    {
        Console.Log("Download was canceled! Getting fallback string.");
        return GetFallbackString();
    })
    .Then(result => Console.Log(result))                                    // <--- This will log the fallback string

Cancelations always propagate downwards, and never upwards:

CancelationSource cancelationSource = CancelationSource.New();

Download("http://www.google.com")                                           // <---- This will *not* be canceled and will run to completion
    .Then(html => Console.Log(html))                                        // <---- This will *not* be canceled and will run
    .Then(() => Download("http://www.bing.com"), cancelationSource.Token)   // <---- This will be canceled before the download starts and will not run.
    .Then(html => Console.Log(html))                                        // <---- This will be canceled and will not run.
    .Finally(cancelationSource.Dispose);                                    // Remember to always dispose of the cancelation source when it's no longer needed.
    
// ... later, before the first download is complete
cancelationSource.Cancel();

In an async function, you can use the token.ThrowIfCancelationRequested API.

async Promise Func(CancelationToken token)
{
    string html = await Download("http://www.google.com");      // <---- This will *not* be canceled and will run to completion
    Console.Log(html);                                          // <---- This will *not* be canceled and will run
    token.ThrowIfCancelationRequested();                        // <---- This will throw a CanceledException and cancel the `async Promise`
    html = await Download("http://www.bing.com");               // <---- This will not run
    Console.Log(html);                                          // <---- This will not run
}
Unhandled Cancelations

Unlike rejections, cancelations are considered part of normal program flow, and will not be thrown outside of async functions. Therefore, catching cancelations is entirely optional.

Special Exceptions

Normally, an Exception thrown in an onResolved or onRejected callback will reject the promise with that exception. There are, however, a few special exceptions that can be thrown to produce different behaviour:

Rethrow

throw Promise.Rethrow can be used if you want to do something if a rejection occurs, but not suppress that rejection. Throwing Promise.Rethrow will rethrow that rejection, preserving its stacktrace (if applicable). This works just like throw; in synchronous catch clauses. This is only valid when used in onRejected callbacks. If accessed in other contexts, it will throw an InvalidOperationException.

RejectException

throw Promise.RejectException(reason) can be used to reject the promise with a reason that is not an Exception. If reason is an Exception, you may want to just throw it directly, unless you want to preserve its stacktrace.

CancelException

throw Promise.CancelException() can be used to cancel the promise. You can also throw an OperationCanceledException, which is equivalent.

Error Retries and Async Recursion

What I especially love above this system is you can implement retries through a technique I call "Asynchronous Recursion".

public Promise<string> Download(string url, int maxRetries = 0)
{
    return Download(url)
        .Catch(() =>
        {
            if (maxRetries <= 0)
            {
                throw Promise.Rethrow; // Rethrow the rejection without processing it so that the caller can catch it.
            }
            Console.Log($"There was an error downloading {url}, retrying..."); 
            return Download(url, maxRetries - 1);
        };
}

Even though the recursion can go extremely deep or shallow, the promise's progress will still be normalized between 0 and 1. Though, a caveat to this is if the first attempt succeeds, the progress will go up to 0.5, then immediately jump to 1. Otherwise you might notice it behave like 0.5, 0.75, 0.875, 0.9375, ...

This can also be done with async functions.

public async Promise<string> Download(string url, int maxRetries = 0)
{
    try
    {
        return await Download(url).AwaitWithProgress(0f, 1f);
    }
    catch
    {
        if (maxRetries <= 0)
        {
            throw Promise.Rethrow; // Rethrow the rejection without processing it so that the caller can catch it.
        }
        Console.Log($"There was an error downloading {url}, retrying..."); 
        return await Download(url, maxRetries - 1).AwaitWithProgress(0f, 1f);
    }
}

Async recursion is just as powerful as regular recursion, but it is also just as dangerous, if not more. If you mess up on regular recursion, your program will immediately crash from a StackOverflowException. Async recursion with this library will never crash from a stack overflow due to the iterative implementation, however if you don't do it right, it will eventually crash from an OutOfMemoryException due to each call waiting for the next and creating a new promise each time, consuming your heap space. Because promises can remain pending for an indeterminate amount of time, this error can potentially take a long time to show itself and be difficult to track down. So be very careful when implementing async recursion, and remember to always have a base case!

Of course, async functions are powerful enough where this retry behavior can be done in a loop without blowing up the heap.

public async Promise<string> Download(string url, int maxRetries = 0)
{
Retry:
    try
    {
        return await Download(url).AwaitWithProgress(0f, 1f);
    }
    catch
    {
        if (--maxRetries < 0)
        {
            throw; // Rethrow the rejection without processing it so that the caller can catch it.
        }
        Console.Log($"There was an error downloading {url}, retrying..."); 
        goto Retry;
    }
}

Multiple-Consumer

Most promises can only be awaited once, and if they are not awaited, they must be returned or forgotten. You can preserve a promise so that it can be awaited multiple times via the promise.Preserve() API. When you are finished with the promise, you must call promise.Forget(). Callbacks added to a preserved promise which will be invoked in the order that they are added.

Capture Values

The C# compiler allows capturing variables inside delegates, known as closures. This involves creating a new object and a new delegate for every closure. These objects will eventually need to be garbage collected when the delegate is no longer reachable.

To solve this issue, capture values was added to the library. Every method that accepts a delegate can optionally take any value as a parameter, and pass that value as the first argument to the delegate. To capture multiple values, you should pass a System.ValueTuple<> that contains the values you wish to capture. The error retry example can be rewritten to reduce allocations:

public Promise<string> Download(string url, int maxRetries = 0)
{
    return Download(url)
        .Catch((url, maxRetries), cv => // Capture url and maxRetries in a System.ValueTuple<string, int>
        {
            var (_url, retryCount) = cv; // Deconstruct the value tuple (C# 7 feature)
            if (retryCount <= 0)
            {
                throw Promise.Rethrow; // Rethrow the rejection without processing it so that the caller can catch it.
            }
            Console.Log($"There was an error downloading {_url}, retrying..."); 
            return Download(_url, retryCount - 1);
        });
}

When the C# compiler sees a lamda expression that does not capture/close any variables, it will cache the delegate statically, so there is only one instance in the program. If the lambda only captures this, it's not quite as bad as capturing local variables, as the compiler will generate a cached delegate in the class. This means there is one delegate per instance. We can reduce that to one delegate in the program by passing this as the capture value.

Note: Visual Studio will tell you what variables are captured/closed if you hover the =>. You can use that information to optimize your delegates. In C# 9 and later, you can use the static modifier on your lambdas so that the compiler will not let you accidentally capture variables the expensive way.

See Understanding Then for information on all the different ways you can capture values with the Then overloads.

Switching Execution Context

Context switching in this case refers to switching execution between the main/UI thread and background threads. Executing code on a background thread frees up the UI thread to draw the application at a higher frame-rate and not freeze the application when executing an expensive computation.

Promise continuations (.Then or await) normally execute synchronously, not caring what thread they are executing on. However, you can force continuations to execute either on the foreground context for UI code, or background context for expensive non-UI code. You can use the promise.WaitAsync(SynchronizationOption) to force the next continuation to execute on the given context (Synchronous (default), Foreground, or Background).

async void Func()
{
    // Not sure what thread we're on here...
    await DoSomethingAsync()
        .WaitAsync(SynchronizationOption.Background);
    // Now we're executing in the background.
    Console.Log("Thread is background: " + Thread.CurrentThread.IsBackground); // true
    await DoSomethingElseAsync()
        .WaitAsync(SynchronizationOption.Foreground);
    // Now we're executing in the foreground (UI thread).
    Console.Log("Thread is background: " + Thread.CurrentThread.IsBackground); // false
}
void Func()
{
    // Not sure what thread we're on here...
    DoSomethingAsync()
        .WaitAsync(SynchronizationOption.Background)
        .Then(() =>
        {
            // Now we're executing in the background.
            Console.Log("Thread is background: " + Thread.CurrentThread.IsBackground); // true
            return DoSomethingElseAsync();
        }
        .WaitAsync(SynchronizationOption.Foreground)
        .Then(() =>
        {
            // Now we're executing in the foreground (UI thread).
            Console.Log("Thread is background: " + Thread.CurrentThread.IsBackground); // false
        })
        .Forget();
}

To make things a little easier, there are shortcut functions to simply hop to the foreground or background context: Promise.SwitchToForeground() and Promise.SwitchToBackground().

The Foreground option posts the continuation to Promise.Config.ForegroundContext. This property is set automatically in Unity, but in other UI frameworks it should be set at application startup (usually Promise.Config.ForegroundContext = SynchronizationContext.Current is enough).

If your application uses multiple SynchronizationContexts, instead of using SynchronizationOption.Foreground, you should pass the proper SynchronizationContext directly to WaitAsync.

Other APIs that allow you to pass SynchronizationOption or SynchronizationContext to configure the context that the callback executes on are Promise.Progress (default Foreground), Promise.New (default Synchronous), and Promise.Run (default Background).

Additional Information

Understanding Then

There are 144 overloads for the Then method (72 for Promise and another 72 for Promise<T>). Rather than trying to remember all 144 overloads, it's easier to remember these rules:

  • Then must always be given at least 1 delegate.

  • The first delegate is onResolved.

  • onResolved will be invoked if the promise is resolved.

  • If the promise provides a value (Promise<T>), onResolved may take that value as an argument.

  • If a capture value is provided to onResolved, the capture value must be the first argument to Then and the first argument to onResolved

  • A second delegate is optional. If it is provided, it is onRejected.

  • If onRejected does not accept any arguments, it will be invoked if the promise is rejected for any reason.

  • If onRejected accepts an argument without a capture value, it will be invoked if the promise is rejected with a reason that is convertible to that argument's type.

  • If a capture value is provided to onRejected, it must come after onResolved and before onRejected in the Then arguments, and it must be the first argument to onRejected.

  • If a capture value is provided to onRejected and that is the only argument onRejected accepts, it will be invoked if the promise is rejected for any reason.

  • If a capture value is provided to onRejected and onRejected accepts another argument, it will be invoked if the promise is rejected with a reason that is convertible to the second argument's type.

  • If onResolved does not return a value, or it returns a non-value Promise:

    • the returned promise will be a non-value Promise.
    • onRejected must not return a value, or it must return a non-value Promise.
  • If onResolved returns a value, or it returns a Promise<T>:

    • the returned promise will be a Promise<T> of the type of that value (or the same type of promise).
    • onRejected must return a value of the same type, or a Promise<T> of the same type.
  • If either onResolved or onRejected return a promise, the promise returned from Then will adopt the state of that promise (waits until it completes).

  • If either onResolved or onRejected throws an Exception, the returned promise will be rejected with that exception, unless that exception is one of the Special Exceptions.

  • You may optionally provide a CancelationToken as the last parameter.

    • If the token is canceled while the promise is pending, the callback(s) will not be invoked, and the returned promise will be canceled.

You may realize that Catch(onRejected) also works just like onRejected in Then. There is, however, one key difference: with Then(onResolved, onRejected), only one of the callbacks will ever be invoked. With Then(onResolved).Catch(onRejected), both callbacks can be invoked if onResolved throws an exception.

Finally

Finally adds an onFinally delegate that will be invoked when the promise is resolved, rejected, or canceled. If the promise is rejected, that rejection will not be handled by the finally callback. That way it works just like finally clauses in normal synchronous code. Finally, therefore, should be used to clean up resources, like IDisposables.

ContinueWith

ContinueWith adds an onContinue delegate that will be invoked when the promise is resolved, rejected, or canceled. A Promise.ResultContainer or Promise<T>.ResultContainer will be passed into the delegate that can be used to check the promise's state and result or reject reason. The promise returned from ContinueWith will be resolved/rejected/canceled with the same rules as Then in Understanding Then. Promise.Rethrow is an invalid operation during an onContinue invocation, instead you can use resultContainer.RethrowIfRejected() and resultContainer.RethrowIfCanceled()

Task Interoperability

Promises can easily interoperate with Tasks simply by calling the Promise.ToTask() or Task.ToPromise() extension methods.

Unity Yield Instructions and Coroutines Interoperability

If you are using coroutines, you can easily convert a promise to a yield instruction via promise.ToYieldInstruction() which you can yield return to wait until the promise has settled. You can also convert any yield instruction (including coroutines themselves) to a promise via PromiseYielder.WaitFor(yieldInstruction). This will wait until the yieldInstruction has completed before resolving the promise.

public async Promise<Texture2D> DownloadTexture(string url)
{
    using (var www = UnityWebRequestTexture.GetTexture(url))
    {
        await PromiseYielder.WaitFor(www.SendWebRequest());
        if (www.isHttpError || www.isNetworkError)
        {
            throw Promise.RejectException(www.error);
        }
        return ((DownloadHandlerTexture) www.downloadHandler).texture;
    }
}
IEnumerator GetAndAssignTexture(Image image, string url)
{
    using (var textureYieldInstruction = DownloadTexture(url).ToYieldInstruction())
    {
        yield return textureYieldInstruction;
        Texture2D texture = textureYieldInstruction.GetResult();
        Sprite sprite = Sprite.Create(texture, new Rect(0, 0, texture.width, texture.height), new Vector2(0.5f, 0.5f));
        image.sprite = sprite;
    }
}
Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net35 is compatible.  net40 is compatible.  net403 was computed.  net45 is compatible.  net451 was computed.  net452 was computed.  net46 was computed.  net461 was computed.  net462 was computed.  net463 was computed.  net47 is compatible.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETFramework 3.5

    • No dependencies.
  • .NETFramework 4.0

    • No dependencies.
  • .NETFramework 4.5

    • No dependencies.
  • .NETFramework 4.7

    • No dependencies.
  • .NETStandard 2.0

    • No dependencies.

NuGet packages (2)

Showing the top 2 NuGet packages that depend on ProtoPromise:

Package Downloads
ProtoPromise.UnityHelpers.2018.3

ProtoPromise helpers for UnityEngine.

ProtoPromise.UnityHelpers.2023.1

ProtoPromise helpers for UnityEngine.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
3.0.2 156 4/12/2024
3.0.1 161 4/6/2024
3.0.0 239 3/18/2024
2.7.0 626 2/11/2024
2.6.1 1,037 10/22/2023
2.6.0 347 10/2/2023
2.5.4 583 6/17/2023
2.5.3 578 6/9/2023
2.5.2 539 6/5/2023
2.5.1 518 5/27/2023
2.5.0 552 5/15/2023
2.4.1 751 2/6/2023
2.4.0 807 12/23/2022
2.3.0 951 9/25/2022
2.2.0 726 8/6/2022
2.1.0 707 6/20/2022
2.0.2 740 4/25/2022
2.0.1 755 4/7/2022
2.0.0 747 3/7/2022

First nuget version.