Deep dive into Promises - Part II


In the previous post, we learnt the following things about the implementation of a Promise

  1. A Promise is a JSObject that takes an Executor fn object as argument.
  2. The Executor fn object takes 2 fns : resolve and reject of class JS_CLASS_PROMISE_RESOLVE_FUNCTION and JS_CLASS_PROMISE_REJECT_FUNCTION respectively as arguments.
  3. The resolve and reject fns have a payload of type JSPromiseFunctionData that have pointers to the Promise so that the fns can resolve/reject the promise when called.
  4. When a promise is fulfilled or rejected, all the callbacks associated with promise are enqueued as microtasks.
  5. When the execution context is empty and there are pending callbacks on promises, the runtime dequeues the callbacks from the microtask queue and executes the callbacks.

The Promise API provides static methods, such as resolve and reject that immediately returns a fulfilled or a rejected promise. These static methods do not take an executor fn as argument, but a value (or thenable object that we will ignore for now, for more details, check out the MDN docs) and returns a Promise that resolves to the value, thus essentially being a shorthand for new Promise((resolve) => resolve(value)).

As we saw in the last post, the C function that implements the constructs the Promise object, expects an executor to be passed as the first argument:

static JSValue js_promise_constructor(JSContext *ctx, JSValueConst new_target,
                                      int argc, JSValueConst *argv)
{
    JSValueConst executor;
    JSValue obj;
    JSPromiseData *s;
    JSValue args[2], ret;
    int i;

    executor = argv[0];
    if (check_function(ctx, executor))
        return JS_EXCEPTION;

However, the static Promise.resolve and reject methods take only a value as argument, so how does this work ?

Turns out, there are separate C fns in the interpreter’s code that implement the static resolve and reject methods. These fns create a dummy or a sort of proxy Executor fn that when called with resolve, reject args, just captures the reference to the Promise from the resolve and reject args. The captured reference to the Promise is then used by the C fns to resolve the Promise. Lets see how that works.

In quickjs both Promise.resolve and Promise.reject static methods are handled by the same C function: js_promise_resolve. A fulfilled vs rejected Promise is distinguised using the the 4th magic argument

static const JSCFunctionListEntry js_promise_funcs[] = {
    JS_CFUNC_MAGIC_DEF("resolve", 1, js_promise_resolve, 0 ),
    JS_CFUNC_MAGIC_DEF("reject", 1, js_promise_resolve, 1 ), // magic == 1

static JSValue js_promise_resolve(JSContext *ctx, JSValueConst this_val,
                                  int argc, JSValueConst *argv, int magic)
{
    JSValue result_promise, resolving_funcs[2], ret;
    BOOL is_reject = magic;
    // if passsed value is another Promise simply return a reference to it
    
    result_promise = js_new_promise_capability(ctx, resolving_funcs, this_val);
    ...
    ret = JS_Call(ctx, resolving_funcs[is_reject], JS_UNDEFINED, 1, argv);
    ...
    return result_promise;

js_new_promise_capability is the fn that creates a constructs a Promise with a dummy executor. It takes as arguments 2 resolving fns that capture a reference to the result_promise. When the correct resolving_funcs is called with the value to be resolved, it sets the result_promise’s JSPromiseData->promise_result to the argv JSValue that is passed, thus resolving the Promise and returning it.

Diving a bit into js_new_promise_capability fn we see how the dummy executor is created and the reference to the Promise’s resolving fns are captured and returned

static JSValue js_new_promise_capability(JSContext *ctx,
                                         JSValue *resolving_funcs,
                                         JSValueConst ctor)
{
    JSValue executor, result_promise;
    JSCFunctionDataRecord *s;
    int i;

    executor = js_promise_executor_new(ctx);
    result_promise = js_promise_constructor(ctx, ctor, 1,
                                                (JSValueConst *)&executor);
    ...
    s = JS_GetOpaque(executor, JS_CLASS_C_FUNCTION_DATA);
    
    for(i = 0; i < 2; i++)
        resolving_funcs[i] = JS_DupValue(ctx, s->data[i]);
    JS_FreeValue(ctx, executor);
    return result_promise;

The dummy executor is a JSObject that has a class_id of JS_CLASS_C_FUNCTION_DATA. JSObjects of this class_id have a variable length data payload that can be used to store references to other objects , which is our case are the Promise’s resolving functions

JSCFunctionDataRecord {
    JSCFunctionData *func;
    uint8_t length;
    uint8_t data_len;
    uint16_t magic;
    JSValue data[0];
} JSCFunctionDataRecord;

The dummy executor captures the resolve and reject into the data field

This is how a JSObject of class_id JS_CLASS_C_FUNCTION_DATA is created:

JSValue JS_NewCFunctionData(JSContext *ctx, JSCFunctionData *func,
                            int length, int magic, int data_len,
                            JSValueConst *data)
{
    JSCFunctionDataRecord *s;
    JSValue func_obj;
    int i;

    func_obj = JS_NewObjectProtoClass(ctx, ctx->function_proto,
                                      JS_CLASS_C_FUNCTION_DATA);
    
    // we create space for capturing the resolving fns
    s = js_malloc(ctx, sizeof(*s) + data_len * sizeof(JSValue));
    
    s->func = func;
    s->length = length;
    s->data_len = data_len;
    s->magic = magic;
    for(i = 0; i < data_len; i++)
        s->data[i] = JS_DupValue(ctx, data[i]);
    JS_SetOpaque(func_obj, s);
    js_function_set_properties(ctx, func_obj,
                               JS_ATOM_empty_string, length);
    return func_obj;
}
// Fn that creates dummy executor
// js_promise_executor is the executor fn that is 
// passed to the Promise constructor
static JSValue js_promise_executor_new(JSContext *ctx)
{
    JSValueConst func_data[2];

    func_data[0] = JS_UNDEFINED;
    func_data[1] = JS_UNDEFINED;
    return JS_NewCFunctionData(ctx, js_promise_executor, 2,
                               0, 2, func_data);
}
// the executor that is called by the Promise constructor
// func_data is the data variable length array in the 
// JSCFunctionDataRecord payload returned by js_promise_executor_new
// and argv are the Promise's resolve and reject fns
// we copy the resolve and reject fns into data[0] and data[1]
static JSValue js_promise_executor(JSContext *ctx,
                                   JSValueConst this_val,
                                   int argc, JSValueConst *argv,
                                   int magic, JSValue *func_data)
{
    int i;
    for(i = 0; i < 2; i++) {
        if (!JS_IsUndefined(func_data[i]))
            return JS_ThrowTypeError(ctx, "resolving function already set");
        func_data[i] = JS_DupValue(ctx, argv[i]);
    }
    return JS_UNDEFINED;
}

Congrats on making it so far, the last section can be quite confusing to keep track of in your head, so let me try to summarize what happened.

  1. Static methods like Promise.resolve and Promise.reject take a value and not an executor fn as argument. They return a Promise that resolves to the value (or rejects with the value)
  2. These JS Promise static methods are implemented in the interpreter via the js_promise_resolve C function.
  3. In js_promise_resolve, a dummy executor of class JS_CLASS_C_FUNCTION_DATA is created. This executor class has a data slot in its definition that can be used to store references to JSValues(s).
  4. The dummy executor is passed as the executor argument when creating a promise and this dummy executor captures the resolve and reject fns passed to it into its data slots.
  5. js_promise_resolve extracts the resolve and the reject fns from the data slot of the dummy executor and then calls them with the value passed to resolve/reject thus fulfilling the promise and returns the promise.

The last function we will see in this post is the static method Promise.race. Knowing how resolve, reject and Promise handlers are implemented will help us understand how race works, thus laying the foundation for understanding why our original “bug” happened.

static JSValue js_promise_race(JSContext *ctx, JSValueConst this_val,
                               int argc, JSValueConst *argv)
{
    JSValue result_promise, resolving_funcs[2], item, next_promise, ret;
    JSValue next_method = JS_UNDEFINED, iter = JS_UNDEFINED;
    JSValue promise_resolve = JS_UNDEFINED;
    BOOL done;

    // create a promise and extract its resolve and reject 
    // similar to the mechanism used for `resolve` and `reject`
    result_promise = js_new_promise_capability(ctx, resolving_funcs, this_val);
    promise_resolve = JS_GetProperty(ctx, this_val, JS_ATOM_resolve);
    iter = JS_GetIterator(ctx, argv[0], FALSE);
    next_method = JS_GetProperty(ctx, iter, JS_ATOM_next);
    for(;;) {
            /* XXX: conformance: should close the iterator if error on 'done'
               access, but not on 'value' access */
            item = JS_IteratorNext(ctx, iter, next_method, 0, NULL, &done);
            if (done)
                break;
            next_promise = JS_Call(ctx, promise_resolve,
                                   this_val, 1, (JSValueConst *)&item);
            
            ret = JS_InvokeFree(ctx, next_promise, JS_ATOM_then, 2,
                                (JSValueConst *)resolving_funcs);
            if (check_exception_free(ctx, ret))
                goto fail_reject1;
        }
  return result_promise;
}

race iterates through the array passed as an argument. In each iterator, it first turns the array item into a Promise if it is not one (via next_promise = JS_Call(ctx, promise_resolve, this_val, 1, (JSValueConst *)&item);)) and then calls js_promise_then on next_promise. As we saw from our previous post, this is nothing but calling the .then handler on a promise, which if the promise is already fulfilled , schedules a microtask. What is the scheduled microtask in the case of race ? It simply calls the resolving function of the Promise (result_promise) that is returned by race

    //resolving_funcs point to result_promise so that when they are called they resolve result_promise
    // if it isn't already resolved
    result_promise = js_new_promise_capability(ctx, resolving_funcs, this_val);
    ...
    for(;;) {
            ...
            // if next_promise is already fulfilled, a microtasks is enqueued that calls the appropriate resolving_funcs
            // if not, the resolving_funcs are changed to the existing promise_reaction handles on `next_promise`
            ret = JS_InvokeFree(ctx, next_promise, JS_ATOM_then, 2,
                                (JSValueConst *)resolving_funcs);
            if (check_exception_free(ctx, ret))

If none of the promises in the race array argument .race([a, b, ...]) are fulfilled during the time of the call, the resolving funcs are simply chained to the promise_reaction handlers of each of the promise in the array and the promise that fulfills first calls the resolving func, thus setting the result of our result_promise to the value of the first promise in the arguments that get fulfilled.

To recap what we have learnt so far in this edition of Promises:

  1. static methods like Promise.reject and Promise.resolve create a dummy executor of type JSCFunctionData that capture a refernce to a Promise and resolve it with the value passed to them as arguments
  2. Promise.race creates a return_promise and iterates through an array of promises passed as argument. It builds on top of the .then handler of a Promise that when called, enqueues a microtask if the promise is already resolved or sets up the promises’ reaction handlers to call a handler func. To each of the promise in the argument array, it calls the then handler of that promise with the resolving funcs of result_promise as args, thus chaining the fulfillment of result_promise to the first promise that resolves.

In part 3, the final chapter, we will take a look at why initial bug occured because we annotated our function as async and how returning a promise directly from a non-async function fixed the problem.