Deep dive into Promises - Part I


Prologue

It all started with the following piece of code in Javascript and its unit test (I wrote this in Typescript, but for all intents and purposes, this snippet is equivalent)

Context

At a work project, we had to implement a counter that keeps track of the number of active jobs. When a shutdown signal (via SIGTERM) is sent to the program, it needs to keep the program alive for as long as possible so that all the active jobs are completed and shutdown once the last active job finishes. The shutdown method thus returns a Promise that is resolved once the last operation is completed and the shutdown code can be chained to the Promise using the .then handler.

import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';

function createCounter() {
  const operationsMap = new Map();
  let shutdownSignalReceived = false;

  let { promise, resolve, reject } = Promise.withResolvers();

  function maybeResolveShutdown() {
    if (shutdownSignalReceived && operationsMap.size === 0) {
      resolve();
    }
  }

  const counter = {
    get(operationId) { operationsMap.get(operationId) },

    set(operationId, value)  {
      operationsMap.set(operationId, value);
    },

    remove(operationId) {
      const value = operationsMap.get(operationId);
      operationsMap.delete(operationId);
      maybeResolveShutdown();
      return value;
    },

    size () { return operationsMap.size},
    async shutdown()  {
      shutdownSignalReceived = true;
      maybeResolveShutdown();
      return promise;
    },
  };

  return counter;
}

async function getPromiseState(p)  {
  const sentinelValue = { v: 'sentinel' };
  const sentinelPromise = Promise.resolve(sentinelValue);
  const result = await Promise.race([p, sentinelPromise]);
  return result === sentinelValue ? 'pending' : 'resolved';
}
describe(' Promise counter tests ', () => {
it('checks that counter shutdown promise behaves as expected', async () => {
    const counter = createCounter();
    const value = 'MyValue';
    counter.set('key', value);
    const shutDownPromise = counter.shutdown();
    assert.equal('pending', await getPromiseState(shutDownPromise));
    counter.remove('key');
    assert.equal('resolved', await getPromiseState(shutDownPromise));
  });

});

I wrote a simple unit-test to test this implementation. And lo and behold, it failed !

 failing tests:

test at code.js:49:1
 checks that counter shutdown promise behaves as expected (0.691625ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
  + actual - expected

  + 'resolved'
  - 'pending'

      at TestContext.<anonymous> (file:///Users/govind/personal/quickjs/code.js:56:12)
      at async Test.run (node:internal/test_runner/test:932:9)
      at async Promise.all (index 0)
      at async Suite.run (node:internal/test_runner/test:1310:7)
      at async startSubtestAfterBootstrap (node:internal/test_runner/harness:296:3) {
    generatedMessage: true,
    code: 'ERR_ASSERTION',
    actual: 'resolved',
    expected: 'pending',
    operator: 'strictEqual'
  }

The test creates a Promise that is already resolved and then races it against the shutdown promise. Ideally by the time Promise.race is called, the shutdown promise must be resolved as we removed the last key from the counter after calling shutdown, but that doesn’t seem to be the case ! The Promise.race call with [shutdownPromise, sentinel] resolves with the sentinel value, which I didn’t expect.

After a couple of hours of breaking my head over this “simple” piece of code and being hit by a wave of imposter syndrome, I finally figured out what was the problem in code and turns out, it was just a one-line fix: Changing the shutdown function from async to sync

// async shutdown()  {
shutdown() {
    shutdownSignalReceived = true;
    maybeResolveShutdown();
    return promise;
  },

Dont async functions return promises ? Therefore this shouldn’t have been an issue. I mean, I am returning a Promise already so what could go wrong ? Functions marked async wrap their return arguments in a new Promise and if I return an existing Promise a that resolves to a value b then the returned promise also resolves to b.

However, WHEN the new promise resolves is an interesting (or weird, if you do not like Javascript) detail and the initial bug in the implementation was due to the mismatch in the timing of the Promise resolution.

To understand this and more, lets take a deep-dive into how Promises work and how they are implemented by a Javascript engine.

The basics, before we start

For exploring the implementation of Promises, I decided to look into the source code of QuickJs, the JS engine written in C by the legendary Fabrice Bellard. I chose QuickJs (henceforth abbreviated to qjs) because it is written in C and is relatively small (50k LOC in quickjs.c as opposed to the 1.3M lines or so of code in V8) and thus easier to understand. Initially I was worried if there would be an enormous difference in the implementations of qjs and v8, but turns out the Javascript language specification specifies exactly how a Promise must be created, fulfilled, the callback handlers called etc and qjs implements the spec faithfully. So it looks like all the extra code in V8 is mostly about how to make JS faster using a JIT and/or better garbage collectors (and to V8’s credit it seems to be an order of magnitude or more faster in benchmarks when the JIT kicks in). For the purpose of our education though, qjs should be sufficient.

Let’s start with some basics A JSValue data structure represents a value in Javascript. A value can be one of bool | string | number | object | null etc

typedef union JSValueUnion {
    int32_t int32;
    double float64;
    void *ptr;
#if JS_SHORT_BIG_INT_BITS == 32
    int32_t short_big_int;
#else
    int64_t short_big_int;
#endif
} JSValueUnion;

typedef struct JSValue {
    JSValueUnion u;
    int64_t tag;
} JSValue;

The tag defines what the value in the union is. Depending on the tag, the right kind of value is extracted by the interpreter using macros

#define JS_VALUE_GET_TAG(v) ((int32_t)(v).tag)
/* same as JS_VALUE_GET_TAG, but return JS_TAG_FLOAT64 with NaN boxing */
#define JS_VALUE_GET_NORM_TAG(v) JS_VALUE_GET_TAG(v)
#define JS_VALUE_GET_INT(v) ((v).u.int32)
#define JS_VALUE_GET_BOOL(v) ((v).u.int32)
#define JS_VALUE_GET_FLOAT64(v) ((v).u.float64)
#define JS_VALUE_GET_SHORT_BIG_INT(v) ((v).u.short_big_int)
#define JS_VALUE_GET_PTR(v) ((v).u.ptr)

Number types like bool , number etc can fit in < 64-bits. For anything larger, such as Strings, Objects etc, the JSValue stores a pointer to the object and depending on the tag, the void *ptr in the tag is type cast as needed. For example, here is the implementation of the String type in qjs

struct JSString {
    JSRefCountHeader header; /* must come first, 32-bit */
    uint32_t len : 31;
    uint8_t is_wide_char : 1; /* 0 = 8 bits, 1 = 16 bits characters */
    uint32_t hash : 30;
    uint8_t atom_type : 2; /* != 0 if atom, JS_ATOM_TYPE_x */
    uint32_t hash_next; /* atom_index for JS_ATOM_TYPE_SYMBOL */
    union {
        uint8_t str8[0]; /* 8 bit strings will get an extra null terminator */
        uint16_t str16[0];
    } u;
};

A JSValue that represents a String will have a tag = JS_TAG_STRING and the pointer in value.u is obtained and type cast correctly

static inline BOOL JS_IsEmptyString(JSValueConst v)
{
    return JS_VALUE_GET_TAG(v) == JS_TAG_STRING && JS_VALUE_GET_STRING(v)->len == 0;
}

A Promise is a JS_VALUE with tag = JS_TAG_OBJECT. In JS as you are well aware, objects are quite generic. Arrays are objects, functions are objects, and then you have your typical objects a = {'1': 2} and Maps, Sets etc and so on. To know excatly what kind of an object a Value is , the ptr of a value is type cast to a JSObject * , The JSObject struct has a field class_id that denotes the exact sub-type of object (int array, float array, map, set, promise, function etc).

struct JSObject {
    union {
        ...
        struct {
            // other fields elided for easier understanding
            uint8_t is_constructor : 1; /* TRUE if object is a constructor function */
            uint8_t has_immutable_prototype : 1; /* cannot modify the prototype */
            uint8_t tmp_mark : 1; /* used in JS_WriteObjectRec() */
            uint16_t class_id; /* see JS_CLASS_x */
        };
    };
    // other fields elided for easier understanding
}

For a Promise, the JSObject with void *ptr points to a struct of type JSPromiseData

typedef struct JSPromiseData {
    JSPromiseStateEnum promise_state;
    /* 0=fulfill, 1=reject, list of JSPromiseReactionData.link */
    struct list_head promise_reactions[2];
    BOOL is_handled; /* Note: only useful to debug */
    JSValue promise_result;
} JSPromiseData;

The enum describes the state of the Promise: if it is pending, fulfilled or rejected. The promise_reactions is a list of callbacks chained to one another, each chain for the case when a promise is fulfilled or rejected respectively (for example, when you add a callback to the Promise using .then or .catch or .finally). The promise_result is the value that the Promise is resolved with (for example if we call Promise.resolve(5), the promise_result is a JSValue of type = JS_TAG_INT and value 5)

When we create a Promise in Javascript using new Promise((resolve, reject) {...}), the fn that is passed as an argument to the Promise constructor is called as an executor. The executor fn takes 2 arguments as input: resolve and reject that are fns and fulfill and reject the Promise respectively when called with args. resolve and reject are a bit magical in the sense that they have some sort of a reference to the Promise so that when called, they know how to set the state of the Promise and its value.

A Promise once fulfilled or rejected cannot be changed (that is a fulfilled promise cannot be re-fulfilled or rejected and vice-versa). await-ing a Promise or chaining new callbacks to it yields the same fulfilled value .

How does it work at the interpreter level ?

Lets look at the implementation of the js_promise_constructor function which is the C function that gets called when you run the js new Promise(executor)

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;
    obj = js_create_from_ctor(ctx, new_target, JS_CLASS_PROMISE);
    if (JS_IsException(obj))
        return JS_EXCEPTION;
    s = js_mallocz(ctx, sizeof(*s));
    if (!s)
        goto fail;
    s->promise_state = JS_PROMISE_PENDING;
    s->is_handled = FALSE;
    for(i = 0; i < 2; i++)
        init_list_head(&s->promise_reactions[i]);
    s->promise_result = JS_UNDEFINED;
    JS_SetOpaque(obj, s);
    if (js_create_resolving_functions(ctx, args, obj))
        goto fail;
    ret = JS_Call(ctx, executor, JS_UNDEFINED, 2, (JSValueConst *)args);
    if (JS_IsException(ret)) {
        JSValue ret2, error;
        error = JS_GetException(ctx);
        ret2 = JS_Call(ctx, args[1], JS_UNDEFINED, 1, (JSValueConst *)&error);
        JS_FreeValue(ctx, error);
        if (JS_IsException(ret2))
            goto fail1;
        JS_FreeValue(ctx, ret2);
    }
    JS_FreeValue(ctx, ret);
    JS_FreeValue(ctx, args[0]);
    JS_FreeValue(ctx, args[1]);
    return obj;
 fail1:
    JS_FreeValue(ctx, args[0]);
    JS_FreeValue(ctx, args[1]);
 fail:
    JS_FreeValue(ctx, obj);
    return JS_EXCEPTION;
}

The executor is the argument we pass to the Promise constructor call as the first argument. The lines to watch are where the JSPromiseData object is created and set. The state of the promise is JS_PROMISE_PENDING and the result is undefined.

s = js_mallocz(ctx, sizeof(*s));
...
s->promise_state = JS_PROMISE_PENDING;
s->is_handled = FALSE;
for(i = 0; i < 2; i++)
    init_list_head(&s->promise_reactions[i]);
s->promise_result = JS_UNDEFINED;
JS_SetOpaque(obj, s);

Before we call the executor with resolve and reject as arguments, we need to set them up with a reference to the Promise object we just created so that when resolve or reject is called, they can set the state of the Promise appropriately. This is done by the js_create_resolving_functions fn just before the JS_Call(.. executor) to call the executor

static int js_create_resolving_functions(JSContext *ctx,
                                         JSValue *resolving_funcs,
                                         JSValueConst promise)

{
    JSValue obj;
    JSPromiseFunctionData *s;
    JSPromiseFunctionDataResolved *sr;
    int i, ret;

    sr = js_malloc(ctx, sizeof(*sr));
    if (!sr)
        return -1;
    sr->ref_count = 1;
    sr->already_resolved = FALSE; /* must be shared between the two functions */
    ret = 0;
    for(i = 0; i < 2; i++) {
        obj = JS_NewObjectProtoClass(ctx, ctx->function_proto,
                                     JS_CLASS_PROMISE_RESOLVE_FUNCTION + i);
        if (JS_IsException(obj))
            goto fail;
        s = js_malloc(ctx, sizeof(*s));
        if (!s) {
            JS_FreeValue(ctx, obj);
        fail:

            if (i != 0)
                JS_FreeValue(ctx, resolving_funcs[0]);
            ret = -1;
            break;
        }
        sr->ref_count++;
        s->presolved = sr;
        s->promise = JS_DupValue(ctx, promise);
        JS_SetOpaque(obj, s);
        js_function_set_properties(ctx, obj, JS_ATOM_empty_string, 1);
        resolving_funcs[i] = obj;
    }
    js_promise_resolve_function_free_resolved(ctx->rt, sr);
    return ret;
}

resolve and reject are sort of special objects with class_ids JS_CLASS_PROMISE_RESOLVE_FUNCTION, JS_CLASS_PROMISE_REJECT_FUNCTION. The payload of these objects are of type JSPromiseFunctionData

typedef struct JSPromiseFunctionData {
    JSValue promise;
    JSPromiseFunctionDataResolved *presolved;
} JSPromiseFunctionData;

Notice how the resolving functions’ payload tracks a promise and pointer a JSPromiseFunctionDataResolved structure

typedef struct JSPromiseFunctionDataResolved {
    int ref_count;
    BOOL already_resolved;
} JSPromiseFunctionDataResolved;

The DataResolved object is shared by both the resolve/reject fns so that if you call one after another, they know if the promise has already been resolved or rejected

 JSPromiseFunctionDataResolved *sr = js_malloc(ctx, sizeof(*sr));
 sr->ref_count = 1;
 sr->already_resolved = FALSE; /* must be shared between the two functions */

When creating each of the resolve/reject fns we intialize the fns each with the shared DataResolved structure and the Promise we created

for(i = 0; i < 2; i++) {
    obj = JS_NewObjectProtoClass(ctx, ctx->function_proto,
                                 JS_CLASS_PROMISE_RESOLVE_FUNCTION + i);
    s = js_malloc(ctx, sizeof(*s));
    sr->ref_count++;
    s->presolved = sr;
    s->promise = JS_DupValue(ctx, promise);
    JS_SetOpaque(obj, s);

So to recap, we have

  1. A Promise that will be resolved/rejected by the executor
  2. A resolve and a reject fn with references to the Promise so that when they are called with a value they know how to resolve the Promise

We need to return these resolving functions to the caller of js_create_resolving_functions. C functions cannot return multiple values as return values, so the caller creates space in its stack to store references to the resolving functions and passes them as arguments to js_create_resolving_functions which sets these slots to each of the resolving function

// js_promise_constructor(...) {
JSValue args[2], ret;
executor = argv[0];
obj = js_create_from_ctor(ctx, new_target, JS_CLASS_PROMISE);
//obj.s is the promise that is initialized  as above
// the resolve/reject fns created by js_create_... are captured in args
if (js_create_resolving_functions(ctx, args, obj))
    goto fail;
// args now has resolve and reject fns, call the executor fn with resolve and reject as args
ret = JS_Call(ctx, executor, JS_UNDEFINED, 2, (JSValueConst *)args);

Assume that our executor a JS function, finishes successfully and wishes to resolve the Promise with a value of 5. It calls the resolve fn that is passed to it as argument, with an argument of JSValue {.tag = JS_TAG_INT, .u.int32 = 5}; Recall that our resolve fn had a class_id of JS_CLASS_PROMISE_RESOLVE_FUNCTION. Qjs interpreter when it evaluates a func object of that class_id, dispatches to the function js_promise_resolve_function_call, setting the argument func_obj as the resolve fn

The logic is straight forward

  1. Get the promise associated the resolve function using func_obj->u.promise_function_data
  2. mark the structure shared by the resolved and the reject function as already solved, so that if we call resolve or reject again, we can ignore the changes. 3.Set the promise as fulfilled using fulfill_or_reject_promise. This fn sets the Promise’s state to Fulfilled or Rejected. If a then callback was previously added to the Promise, (either as .then((v) => {}) or as .then((onFulFilled, on Rejected))) , it runs the associated reaction handlers.
static JSValue js_promise_resolve_function_call(JSContext *ctx,
                                                JSValueConst func_obj,
                                                JSValueConst this_val,
                                                int argc, JSValueConst *argv,
                                                int flags)
{
    JSObject *p = JS_VALUE_GET_OBJ(func_obj);
    JSPromiseFunctionData *s;
    JSValueConst resolution, args[3];
    JSValue then;
    BOOL is_reject;

    s = p->u.promise_function_data;
    if (!s || s->presolved->already_resolved)
        return JS_UNDEFINED;
    s->presolved->already_resolved = TRUE;
    is_reject = p->class_id - JS_CLASS_PROMISE_RESOLVE_FUNCTION;
    if (argc > 0)
        resolution = argv[0];
    else
        resolution = JS_UNDEFINED;
    if (is_reject || !JS_IsObject(resolution)) {
        goto done;
    } else if (js_same_value(ctx, resolution, s->promise)) {
        JS_ThrowTypeError(ctx, "promise self resolution");
        goto fail_reject;
    }
    then = JS_GetProperty(ctx, resolution, JS_ATOM_then);
    if (JS_IsException(then)) {
        JSValue error;
    fail_reject:
        error = JS_GetException(ctx);
        reject_promise(ctx, s->promise, error);
        JS_FreeValue(ctx, error);
    } else if (!JS_IsFunction(ctx, then)) {
        JS_FreeValue(ctx, then);
        done:
        fulfill_or_reject_promise(ctx, s->promise, resolution, is_reject);
    } else {
        args[0] = s->promise;
        args[1] = resolution;
        args[2] = then;
        JS_EnqueueJob(ctx, js_promise_resolve_thenable_job, 3, args);
        JS_FreeValue(ctx, then);
    }
    return JS_UNDEFINED;
}

```c
static void fulfill_or_reject_promise(JSContext *ctx, JSValueConst promise,
                                      JSValueConst value, BOOL is_reject)
{
    JSPromiseData *s = JS_GetOpaque(promise, JS_CLASS_PROMISE);
    struct list_head *el, *el1;
    JSPromiseReactionData *rd;
    JSValueConst args[5];

    set_value(ctx, &s->promise_result, JS_DupValue(ctx, value));
    s->promise_state = JS_PROMISE_FULFILLED + is_reject;
    ...
    if (s->promise_state == JS_PROMISE_REJECTED && !s->is_handled) {
        JSRuntime *rt = ctx->rt;
        if (rt->host_promise_rejection_tracker) {
            rt->host_promise_rejection_tracker(ctx, promise, value, FALSE,
                                               rt->host_promise_rejection_tracker_opaque);
        }
    }
    // Loop through the linked Callbacks and enqueue them as a microTask
    list_for_each_safe(el, el1, &s->promise_reactions[is_reject]) {
        rd = list_entry(el, JSPromiseReactionData, link);
        args[0] = rd->resolving_funcs[0];
        args[1] = rd->resolving_funcs[1];
        args[2] = rd->handler;
        args[3] = JS_NewBool(ctx, is_reject);
        args[4] = value;
        JS_EnqueueJob(ctx, promise_reaction_job, 5, args);
        list_del(&rd->link);
        promise_reaction_data_free(ctx->rt, rd);
    }

    list_for_each_safe(el, el1, &s->promise_reactions[1 - is_reject]) {
        rd = list_entry(el, JSPromiseReactionData, link);
        list_del(&rd->link);
        promise_reaction_data_free(ctx->rt, rd);
    }
}

There are 2 more items we will explore in this blog post before taking a break JS_EnqueueJob and js_promise_then - the function that handles when a callback is set on a Promise using the .then method on a Promise

Microtask queues and Promises

Most of you might be familiar (if not used) the Microtask Queue API in Javascript. The microtasks are functions/tasks that are run when the execution stack is empty: that is your JS runtime has finished loading a modules and all the function calls starting from some main and is waiting for the event loop to terminate. While the runtime polls the event loop, if there are any callbacks associated with timers or promises are ready to be executed, the runtime dequeues these microtasks and execute them. NodeJS for example exposes this queueMicrotask as an API to the programmers so that programmers can schedule microtasks as needed. This however, seems to be a custom API that is not defined in the specification. QuickJs does not expose a queueMicrotask API, but the JS_EnqueueJob calls in the code we see above basically enqueues promise reactions as microtasks.

// module a.js

function somePromise() {
  let p = Promise.withResolvers();
  setTimeout(() => { p.resolve(4000)}, 5000); // resolve promise after 5 secs
  return p.promise;
}

let k = somePromise();
k.then((v) => console.log("resolved"));

The execution stack will be something like:

  1. Module load fn
  2. somePromise()
  3. then() callback enqueues a callback on the promise

once the module is loaded, the execution stack is empty and the event loop keeps running until the timer set by setTimeout expires and the promise is resolved. There is a then callback associated with the promise and since the execution stack is empty, the callback is dequeued and executed by the JS engine.

If my explanation of the Execution stack isn’t clear do not hesitate to checkout the docs on MDN that explains it with better examples.

The last qjs function we will look at is the js_promise_then function. This fn handles the promise.then method that sets a callback handler on a Promise. Recall that Promise.then() returns another Promise to supplant/replace our original promise with some extra user-defined processing (such as processing the body of a fetch call etc). js_promise_then creates a new Promise object, chains the promises in a such a way so that when the original promise is resolved, our callback is called and then the new Promise is also resolved

static JSValue js_promise_then(JSContext *ctx, JSValueConst this_val,
                               int argc, JSValueConst *argv)
{
    JSValue ctor, result_promise, resolving_funcs[2];
    JSPromiseData *s;
    int i, ret;

    s = JS_GetOpaque2(ctx, this_val, JS_CLASS_PROMISE);
    if (!s)
        return JS_EXCEPTION;

    ctor = JS_SpeciesConstructor(ctx, this_val, JS_UNDEFINED);
    if (JS_IsException(ctor))
        return ctor;
    result_promise = js_new_promise_capability(ctx, resolving_funcs, ctor);
    JS_FreeValue(ctx, ctor);
    if (JS_IsException(result_promise))
        return result_promise;
    ret = perform_promise_then(ctx, this_val, argv,
                               (JSValueConst *)resolving_funcs);
    for(i = 0; i < 2; i++)
        JS_FreeValue(ctx, resolving_funcs[i]);
    if (ret) {
        JS_FreeValue(ctx, result_promise);
        return JS_EXCEPTION;
    }
    return result_promise;
}

The actual logic of updating the state of the Promise and enqueuing the callback is done in the perform_promise_then function

static __exception int perform_promise_then(JSContext *ctx,
                                            JSValueConst promise,
                                            JSValueConst *resolve_reject,
                                            JSValueConst *cap_resolving_funcs)
{
    JSPromiseData *s = JS_GetOpaque(promise, JS_CLASS_PROMISE);
    JSPromiseReactionData *rd_array[2], *rd;
    int i, j;

    rd_array[0] = NULL;
    rd_array[1] = NULL;
    for(i = 0; i < 2; i++) {
        JSValueConst handler;
        rd = js_mallocz(ctx, sizeof(*rd));
        if (!rd) {
            if (i == 1)
                promise_reaction_data_free(ctx->rt, rd_array[0]);
            return -1;
        }
        for(j = 0; j < 2; j++)
            rd->resolving_funcs[j] = JS_DupValue(ctx, cap_resolving_funcs[j]);
        handler = resolve_reject[i];
        if (!JS_IsFunction(ctx, handler))
            handler = JS_UNDEFINED;
        rd->handler = JS_DupValue(ctx, handler);
        rd_array[i] = rd;
    }

    if (s->promise_state == JS_PROMISE_PENDING) {
        for(i = 0; i < 2; i++)
            list_add_tail(&rd_array[i]->link, &s->promise_reactions[i]);
    } else {
        JSValueConst args[5];
        if (s->promise_state == JS_PROMISE_REJECTED && !s->is_handled) {
            JSRuntime *rt = ctx->rt;
            if (rt->host_promise_rejection_tracker) {
                rt->host_promise_rejection_tracker(ctx, promise, s->promise_result,
                                                   TRUE, rt->host_promise_rejection_tracker_opaque);
            }
        }
        i = s->promise_state - JS_PROMISE_FULFILLED;
        rd = rd_array[i];
        args[0] = rd->resolving_funcs[0];
        args[1] = rd->resolving_funcs[1];
        args[2] = rd->handler;
        args[3] = JS_NewBool(ctx, i);
        args[4] = s->promise_result;
        JS_EnqueueJob(ctx, promise_reaction_job, 5, args);
        for(i = 0; i < 2; i++)
            promise_reaction_data_free(ctx->rt, rd_array[i]);
    }
    s->is_handled = TRUE;
    return 0;
}

If the promise we set a callback on is yet to fulfilled (state == PENDING), perform_promise_then simply chains the resolve and reject callbacks of our new Promise to that of the previous one. If the promise is already resolved (or rejected), perform_promise_then simply enqueues a microtask with the fulfilled (or rejected) handler to be run when the runtime is running tasks from the microtasks queue.

This is a lot of information to take in a single blog post, so I shall leave it hanging at this point for now. So far we have understood the following concepts

  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
  3. The resolve and reject fns have a payload 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.

In the next few blog posts, we will take a look at how other Promise methods like resolve , reject , race are built on top of the existing primitives we have discussed so far and then finally build up to the chain of events as to why the initial “bug” occured.