MozPromise: C++ promises in Gecko

MozPromise is a powerful and flexible promise implementation in C++ designed to manage asynchronous operations within Gecko. Its set of feature mirror the spirit of JavaScript promises, including the ability to attach resolve and reject callbacks, chain promises, and handle asynchronous tasks across different threads. MozPromise supports both exclusive and non-exclusive promises. Exclusive promises enforce that there is at most one call to either Then(...), ensuring that the promise is used in a predictable and controlled manner. Non-exclusive promises, on the other hand, allow multiple resolve or reject operations, providing flexibility for more complex use cases.

Additionally, MozPromise offers mechanisms for disconnecting promises, allowing consumers to cancel the delivery of resolve or reject values when they are no longer needed. These features make MozPromise a versatile tool for managing asynchronous workflows spanning multiple threads, ensuring thread safety, and maintaining the correct sequence of operations in complex applications, in contrast to the usual pattern seen in the Gecko code base, which is dispatching Runnable manually.

Another option for syncing state across threads is to use State Mirroring.

MozPromise aren’t really related to the DOM Promise class, but can be often used in conjunction. This is done manually by for example calling dom::Promise::MaybeResolve from a lambda passed to the Then(...) of a MozPromise, on the main thread.

Throughout this document, the producer is the piece of code that creates, then resolves or rejects a promise, and typically starts or does the work that needs to happen as part of the asynchronous operation. The consumer is the piece of code that will react to the promise being resolved or rejected, and therefore gets to know about the completion of the work.

Guarantees of MozPromise

MozPromise provides several guarantees to ensure predictable and reliable behavior. These include:

  • Ordering: MozPromise ensures that the resolve or reject callbacks are dispatched in the order they are attached, maintaining the correct sequence of operations. Attaching callbacks after a promise has completed will call them.

  • Thread Safety: They are designed to be thread-safe, allowing promises to be created, chained, resolved, and rejected on different threads without causing race conditions. The resolve/reject handlers are always destroyed on their target threads. MozPromiseHolder and MozPromiseRequestHolder themselves need to be synchronized however.

  • Completion: Once a promise is resolved or rejected, it cannot be changed, ensuring that the state of the promise is consistent and immutable. *Holder instances can be reused.

Typical uses

If the workload is synchronous:

From the producer side:
  • Do the work

  • Return a resolved or rejected promise via MozPromise::CreateAndResolve or MozPromise::CreateAndReject

From the consumer side:
  • Call the method returning a promise

  • Call Then() on the promise to set the actions to run on a given thread once the promise has settled.

If the workload is asynchronous:

From the producer side: - Allocate a MozPromise (probably via a MozPromiseHolder) and return it to the consumer has a RefPtr<MozPromise> - Dispatch the async work to any thread with InvokeAsync forward its returned promise to the caller. - Once the work has been completed, return its result directly in the async task using MozPromise::CreateAndResolve or MozPromise::CreateAndReject.

From the consumer side: - Call the method returning a MozPromise - Call Then() on the promise to set the actions to run on a given thread once the promise has settled.

In both case, it is possible to Track() the promise, to cancel the delivery of the resolve/reject result and prevent callbacks to be run.

Concepts

Threads on which Promises Run

Promises in Mozilla’s MozPromise framework can run on various threads, depending on the context in which they are created and resolved. The Then method allows specifying the target thread on which the resolve or reject callbacks should be executed. InvokeAsync allows selecting the thread the work will happen on, which commonly also is where the promise gets rejected or resolved.

The Then(...) method

This method is called to set reject/resolve callbacks to be called when a promise has settled. There are two ways of specifying those. The first style is to pass a single callback, using the type MyPromise::ResolveOrRejectValue, if MyPromise has been aliased to a particular type, and to handle rejection / resolution by checking the value itself:

RefPtr<MyPromise> promise = /* from somehere */

promise->Then(mainThread, __func__,
    [this, self = RefPtr{this}]
    (const MyPromise::ResolveOrRejectValue& aValue) {
    if (aValue.IsResolve())) {
        /* HandleResolvedValue(value); */
        return;
    }
    /* HandleRejectedValue(); */
    });

The other style is by passing two callbacks, one for resolution (first parameter), and one for rejection (second parameter):

RefPtr<MyPromise> promise = /* from somewhere */

// Granted those functions have the correct parameters types.
promise->Then(
    mainThread, __func__, &HandleResolvedValue, &HandleRejectedValue);

The exact argument type to use in those callback depends on the exclusivity of the promise, see section below.

When method pointers are passed in, a refcounted instance pointers is necessary in the capture list. Both function pointers and lambda can be used.

The Then(…) method returns an object that can be used to do two things:

  • Convert it back to a MozPromise, that will be resolved once the resolve/reject of the first promise are called. This allow chaining multiple promises by in turn calling Then(...) on that converted promise.

  • Track the first promise, to be able to cancel the delivery of the callbacks, if they haven’t already been called. This is done by disconnecting a MozPromiseRequestHolder.

Reference counting

Since promises are by essence asynchronous, and can run on various threads, it is important to ensure that the objects that are going to be used in the callback functions are still alive when the promise is rejected or resolved. This is typically done by adding reference counting to a class, and by passing an addrefed copy of the this pointer into the lambdas, like so:

class SomeClass {
public:
    // Adding refcounting to a class:
    NS_INLINE_DECL_THREADSAFE_REFCOUNTING(SomeClass)

    RefPtr<MyPromise> DoIt() {
        RefPtr<MyPromise> promise = mHolder.Ensure(__func__);

        promise->Then(
            backgroundThread, __func__,
            // Make the lambda has keeping a reference to the class via
            // the capture list, by creating a new RefPtr in the lambda's
            // scope
            [this, self = RefPtr{this}](int value) {
                /* handle resolution */
            },
            // Reject and resolve have the same lifetime, no need
            // to do anything here if we don't need a reference to this
            // (e.g. we're just logging, etc.)
            [](nsresult error) { /* HandleRejectedValue(error); */ });

        return promise.forget();
    }
private:
    MozPromiseHolder<MyPromise> mHolder;
};

Exclusivity

Exclusivity in MozPromise refers to the ability to enforce that a promise resolves or rejects to a single set of callbacks. When the IsExclusive template argument is set to true, the promise prevents multiple resolution or rejection callbacks on a single promise when it is not a feature that is desirable for a particular use, for instance when that could lead to unexpected behavior.

This invariant is checked with an assertion, that is enabled in release builds, and will fail when attempting to install the second set of callbacks on an exclusive promise.

When a promise is exclusive, the result value is moved into the resolve callback using an rvalue reference. A typical signature is therefore:

using MyPromise = MozPromise<int, nsresult, true>;
void Callback(MyPromise::ResolveOrRejectValue&& aResult);

This means that the callback is the sole owner of the value. The callback’s closure will be deleted on the thread they are called on. It follows that the types used for rejection/resolution values need to be movable or copiable.

If however the promise isn’t exclusive, the result is passed using a const lvalue reference:

void Callback(const MyPromise::ResolveOrRejectValue& aResult);

This allows multiple callbacks to have a reference to the value.

Main classes

MozPromise

MozPromise is a template class that represents a promise in C++, similar to JavaScript promises. It manages an asynchronous request that may or may not be able to be fulfilled immediately. The template arguments for MozPromise are:

  • ResolveValueT: The type of the value that the promise resolves to.

  • RejectValueT: The type of the value that the promise rejects with.

  • IsExclusive: A boolean flag indicating whether the promise is exclusive,

    meaning it can only be resolved or rejected once.

Aliasing the promises types using typedef or using is a common practice to simplify the usage of MozPromise with specific resolve and reject types. For example:

using CustomBoolPromise = MozPromise<bool, nsresult, true>;

defines a generic exclusive promise type that resolves to a boolean and rejects with an nsresult.

This makes the code more readable and easier to maintain, as the specific types of the promises are clearly defined and can be reused throughout the codebase.

MozPromiseHolder

MozPromiseHolder is a template class designed to encapsulate a MozPromise. It is useful for classes whose methods return promises, i.e., the “inside” of the asynchronous request: the part that will eventually resolve or reject. It is suitable for advanced cases where InvokeAsync is not enough.

Typically, you store a MozPromiseHolder in a class that will return promises to callers and internally resolve those promises. For good measure a MozPromiseHolder shouldn’t be leaked outside its owner class or into nested classes, much like JS promise resolve/reject functions shouldn’t leak outside of the constructor scope.

MozPromiseHolder provides methods to ensure a promise is created, check if it is empty, steal the private promise, resolve or reject the promise, and set task dispatching and priority. It allows managing promises within a class, ensuring that the promise is properly handled and can be resolved or rejected as needed. Note that MozPromiseHolder is not thread-safe in itself, although the promise it encapsulates is.

class SomeClass {
public:
    RefPtr<MyPromise> DoIt() {
        RefPtr<MyPromise> promise = mHolder.Ensure(__func__);
        MOZ_ASSERT(!mHolder.IsEmpty());

        // ... deep inside some async code, potentially on a different thread,
        // resolve the promise via the holder:
        // mHolder.Resolve(42, __func__);
        // It is empty after resolving
        // MOZ_ASSERT(mHolder.IsEmpty());

        return promise.forget();
    }
private:
    MozPromiseHolder<MyPromise> mHolder;
};

MozPromise::Request / MozPromiseRequestHolder

MozPromiseRequestHolder is a template class that encapsulates a MozPromise::Request reference, that is rarely use directly. It is used by classes which may want to disconnect from waiting on a MozPromise, i.e. the “outside” of the asynchronous request. This class provides methods to track a request, complete it, disconnect it, and check if it exists. It is useful for managing the lifecycle of a promise request, ensuring that the request can be properly tracked, completed, or disconnected as needed.

In essence, this is a handle on a particular request made with within the MozPromise framework.

Disconnecting a request must happen on the target thread of the resolve/reject handler it is tracking. This handler is released when Disconnect() is called.

When dealing with MozPromise close to the WebIDL binding layer, another option is DOMMozPromiseRequestHolder, that will disconnect promises appropriately when the global goes away. It works in the same way otherwise.

To associate a MozPromiseRequestHolder with a MozPromise, the Track(...) method is used:

class SomeClass {
public:
    // refcounting is mandatory
    NS_INLINE_DECL_THREADSAFE_REFCOUNTING(SomeClass)
    RefPtr<MyPromise> DoIt() {
        RefPtr<MyPromise> promise = mHolder.Ensure(__func__);
        MOZ_ASSERT(!mHolder.IsEmpty());

        promise->Then(
            backgroundThread, __func__,
            [this, self = RefPtr{this}](int value) {
              // Resolved: mark as complete
              mRequestHandle.Complete();
              /* do something with value */
            },
            [](nsresult error) {
              // Rejected: also mark as complete
              mRequestHandle.Complete();
              /* HandleRejectedValue(error); */
        }).Track(mRequestHandle);

        // ... deep inside some async code, potentially on a different thread,
        // resolve the promise:
        // promise.Resolve(42, __func__);

        return promise.forget();
    }
    void CancelIt() {
        // Functions passed to Then() won't be called. This must
        // be called on `backgroundThread`
        mRequestHandle.DisconnectIfExists();
    }
private:
    MozPromiseHolder<MyPromise> mHolder;
    MozPromiseRequestHolder<MyPromise> mRequestHandle;
};

The InvokeAsync Function

The InvokeAsync function is used to invoke a promise-returning function asynchronously on a given thread. It dispatches a task to invoke the function on the proper thread and also chains the resulting promise to the one that the caller received, so that resolve/reject values are forwarded through. This function is useful for scheduling asynchronous tasks that return promises, ensuring that the tasks are executed on the correct thread and that the promises are properly chained.

class SomeClass {
    public:
    NS_INLINE_DECL_THREADSAFE_REFCOUNTING(SomeClass)
    RefPtr<MyPromise> AsyncFunction(nsISerialEventTarget* target) {
        return InvokeAsync(target, __func__, []() -> RefPtr<MyPromise> {
            // ... some expensive async work is happening
            int result = 42;
            return MyPromise::CreateAndResolve(result, __func__);
        });
    }

    RefPtr<MyPromise> DoItAsync() {
        nsCOMPtr<nsISerialEventTarget> backgroundThread = /* from somewhere */;
        nsCOMPtr<nsISerialEventTarget> mainThread = do_GetMainThread();

        // Call the async function on the background task queue
        RefPtr<MyPromise> promise = AsyncFunction(backgroundThread);

        // But get the completion callbacks on the main thread
        promise->Then(
            mainThread, __func__,
            [this, self = RefPtr{this}](int value) {
              /* HandleResolvedValue(value); */
            },
            [](nsresult error) {
              /* HandleRejectedValue(error); */
        });

        return promise.forget());
    }
};

Advanced features

Direct Task Dispatch

Direct task dispatch is a feature in MozPromise that allows the resolve or reject callbacks to be executed on the direct task queue instead of the normal event loop. This is particularly useful for scenarios where multiple asynchronous steps are involved, as it avoids a full trip to the back of the event queue for each additional asynchronous step. By using direct task dispatch, the callbacks are executed more promptly, reducing latency and improving the overall responsiveness of the application.

This is only available when the callbacks are set to run on the same thread the caller is on.

In Web land, this would be akin to executing something in a microtask checkpoint, and not a regular event loop task. While it is the default for Web Promises, it is opt-in in MozPromise.

To enable direct task dispatch, the UseDirectTaskDispatch method is called on the MozPromiseHolder instance. This method sets the promise to use the direct event queue for dispatching the resolve or reject callbacks.

A related concept is “tail dispatching” of Runnable.

Synchronous Dispatch

Synchronous dispatch is another feature in MozPromise that allows the resolve or reject callbacks to be executed synchronously on the same thread, rather than being dispatched asynchronously. This is useful in scenarios where the callbacks need to be executed immediately, without waiting for the event loop to process them. Synchronous dispatch ensures that the callbacks are executed in a predictable and timely manner, which can be crucial for certain types of operations.

This is only available when the callbacks are set to run on the same thread the caller is on.

To enable synchronous dispatch, the UseSynchronousTaskDispatch method is called on the MozPromiseHolder instance. This method sets the promise to execute the resolve or reject callbacks synchronously on the same thread. When the promise is resolved or rejected, the callbacks are executed immediately, without being dispatched to the event loop.

However, synchronous dispatch can introduce potential issues, such as deadlocks. A deadlock occurs when two or more threads are waiting for each other to release resources, resulting in a situation where neither thread can proceed. In the context of MozPromise, a deadlock can occur if the resolve or reject callbacks are waiting for a resource that is held by the same thread, causing the thread to block indefinitely.

To mitigate the risk of deadlocks, it is important to use synchronous dispatch judiciously and ensure that the callbacks do not depend on resources that are held by the same thread.

Caveats

It is an error to destroy a promise that hasn’t been resolved or rejected. Teardown of an object owning a MozPromiseHolder is therefore going to assert in this case.

When dealing with MozPromise (like most asynchronous constructs), the shutdown phase can be a problem. Since there’s no way to handle the failure to dispatch to a thread, it’s an error to have a promise chain set to run some handler on a thread that may have shut down. One way to fix this is to provide threading guarantees, by blocking shutdown, or to disconnect the promise via a MozPromiseRequestHolder when shutting down. Both can possibly be needed.

When using MozPromiseHolder::Ensure, a new MozPromise will be created even if the previous one was already settled. Sometimes external bookkeeping (for example keeping the MozPromise around to check if it’s the same) is necessary to ensure that the handlers are set on the correct MozPromise, and not potentially another one.