async/await subtleties

Jan 3, 2019   #async/await  #Promise  #Exceptions  #try/catch/finally 

ECMAScript’s new async functions and generators simplify working with concurrent I/O code. While they work hand-in-hand with Promises, there are some subtleties that either seem to be underspecified in the language or not talked about much.

For the purpose of this post, I assume you’re already familiar with the async keyword and its usage in Javascript as well as the await keyword for awaiting the result of an async function. Also, beware that though here you may find ways to use them that you didn’t know about earlier, but that doesn’t mean you should use them.

Contents

edit:2019-01-08: This post got longer than I expected. So adding a ToC.

await works with Promise-like objects too

This may come as a surprise to some, but it looks like the spec is pretty lenient about what objects can be awaited using the await keyword.

To start with, the expression await fn() does not on the surface tell you anything about whether fn() is about calling an async function or a normal one that returns a Promise. Both are permissible and work in the same way - i.e. the await will result in a wait for the Promise to be fulfilled or rejected to continue. This interoperability with Promise-returning code is great since you can reuse a lot of existing code that’s been written to work with Promises within async functions.

Digging a little deeper, fn() only needs to return a “promise-like” object and doesn’t need to exactly return a native Promise. This is also convenient since it permits any widely used Promise spec compliant library to be used instead of native Promise objects.

The killer is that it takes very little for an object to qualify as “promise-like”. All it really takes is for fn() to return an object that has a single then method that accepts two one-shot continuations as arguments … like this -

function fn() {
    // ...
    return {
        then: function (onsuccess, onfailure) {
            // ...
            onsuccess(result); // or onfailure(error)
        }
    };
}

You can start off any process that will move on by calling onsuccess or onfailure when it is done, right within the then method.

You can use this for some subtle effects like promises that don’t start their async activity unless they are awaited upon, which can be a useful optimization in some situations. You can, for example, use this to implement channels.

Another neat hack possible with this is to bring in compatibility for all the NodeJS APIs which accept a callback argument at the end, by combining this behaviour of await with the argument binding behaviour of Function.prototype.bind.1

Say, you want to use fs.access to check for existence of a file, you can set things up such that you only need to write -

async function someTask() {
    // ... some code ...
    await fs.access.bind(fs, "/tmp/some-file.txt");
    // ... more code ...
}

The expression fs.access.bind(fs, "/tmp/some-file.txt") produces a function, which when called with a single callback argument will perform the asynchronous exists check equivalent to fs.access("/tmp/some-file.txt", callback).2

So to make this one-argument function look like a promise to await, we can just add a .then method to all Function objects like this -

Function.prototype.then = function (onsuccesss, onfailure) {
    this(function (err, result) {
        if (err) { return onfailure(err); }
        onsuccess(result);
    });
}

That’s very convenient as it makes interop with NodeJS-style APIs very simple, albeit with the overhead of creating a bound function and a callback closure on every such call. Note, however, that the activity of the async function implemented like above will not get triggered unless it is awaited .. which is when the bound function actually ends up being called.

You can also add a few more utilities to Function around this idea -

Function.prototype.mapErr = function (errTx) {
    let theFn = this;
    return function (callback) {
        theFn(function (...args) {
            if (args[0]) { return callback(errTx(args[0])); }
            callback.apply(null, args);
        });
    };
};

Function.prototype.failWith = function (errVal) {
    return this.mapErr(e => errVal);
};

Function.prototype.catch = function (handler) {
    let theFn = this;
    return function (callback) {
        theFn(async function (...args) {
            if (args[0]) { return callback(null, await handler(args[0])); }
            callback.apply(null, args);
        });
    };
};

finally (edit:2019-01-07)

Cleanup, finally style needs some extra machinery since in Javascript, as with many GC’d languages, there is no mechanism to tap into scope entry and exit. We need to be explicit about that. So we can make a “scope” object for that purpose.

function beginScope() {
    let cleanupActions = [];
    return {
        "finally": function (cleanup) {
            cleanupActions.push(cleanup);
            return cleanup;
        },
        end: async function () {
            try {
                while (cleanupActions.length > 0) {
                    await cleanupActions.pop()();
                }
            } catch (e) {
                console.error('FATAL ERROR: Cleanup actions are not expected to throw up.');
                require('process').exit(-1);
            }
        }
    };
}

You can use scope like this -

let scope = beginScope();
try {
    let f = await fs.open.bind(fs, '/tmp/file.txt');
    scope.finally(async () => { await fs.close.bind(fs,f); });
    // ...
} finally {
    await scope.end();
}

The advantage of doing it this way is that the code inside the try block can have the cleanup code remain close to it as opposed to being located far down south. Also, variables local to the try block cannot be accessed in the finally block and this helps deal with that too.

await can be used with non-promise values as well

An expression like await 42 is equivalent to 42. Also, if f() is a synchronous function call returning a normal value (i.e. not a Promise), then the expression await f() within an async function is equivalent to f() within that function.

await turns promise rejections into exceptions

Within the body of an async function, if you use an await on promise-returning function or an async function, the rejection will be turned into an “exception” that can be caught and processed using the normal try-catch mechanism in the language. Here is what I mean -

async function long_drawn_task() {
    try {
        // funky code
        await funkyNormalFunction();
        // more funky code
    } catch (e) {
        // Rejections of the promise returned by funkyNormalFunction()
        // will arrive here with the rejection value bound to "e".
    }
}

async functions can be used like normal functions that return Promise objects

This is kind of the dual of the previous point - where we stated that you can use normal functions that return Promise objects as the target of an await expression. Similarly, normal functions can call async functions as though they are functions that return Promise objects. This is, again, great for interoperability with a lot of existing code and you should be happy about it.

This is particularly useful when porting callback based code to use async/await. You can mark callback functions as async and walk the call tree step by step.

async functions can return a Promise too

So, why should only normal functions be permitted to return Promise objects in order to be used with await? async functions can also do that. When an async function returns a Promise, then the code that awaits the result of the async function call will receive the resolved value of the returned Promise … recursively.

What this means is that in the normal (i.e. non-exceptional) control flow of an async function, the task1 and task2 functions below are equivalent and indistinguishable from the caller’s perspective.

async function task1() {
    // some code
    return anotherTask();
}

async function task2() {
    // some code
    return await anotherTask()
}

Beware of try-catch blocks in async functions

Don’t be tempted to think that return await is equivalent to return within an async function’s body based on the previous section. That holds only for non-exceptional control flow and doesn’t hold within try-catch blocks.

The following function will cause rejections in anotherTask() to be caught within task1() itself.

async function task1() {
    try {
        // some code
        return await anotherTask();
    } catch (e) {
        // Swallow it
        return null;
    }

    // dead code.
}

.. however, the following task2 will propagate the rejection of the promise returned by anotherTask() to the caller of task2!!

async function task2() {
    try {
        // some code
        return anotherTask();
    } catch (e) {
        // Swallow it
        return null;
    }

    // dead code.
}

In other words, though return anotherTask() syntactically occurs within the try block, its errors will not be caught in the catch block. More craziness follows in that in the following task3 function, the code in the finally block will run before the Promise returned by anotherTask() finishes.

async function task3() {
    try {
        // some code
        return anotherTask();
    } catch (e) {
        // Swallow it
        return null;
    } finally {
        doSomeCleanup();
    }

    // dead code
}

This is the current behaviour in Firefox and NodeJS.

Opinion

I think this behaviour is madness and is a gap in the ECMAScript spec that needs to be plugged. Here are the reasons -

  1. If you stick to async functions and await … which is what we expect code to look like as it evolves to use this otherwise simplifying feature … you won’t have any language mechanism to control what happens between a return and the result of the await since the semantics are expected to be the same as passing around values.

  2. A return that syntactically occurs within a try block like it does in task2 and task3 above behaves as though it is, at least partially, operating outside it … especially with the finally behaviour discussed above. I think the syntactic and semantic expectations should match .. at least when a new (and very useful) language feature like this is being offered.

  3. There is a kind of “limbo zone” between a return statement and the await expression that it connects to that programmers should not have access to.

  4. There is no extra expressive power gained by having the current behaviour. At least, I can’t think of any. Only confusion seems to be the possible outcome.

I also think try-catch-finally in garbage collected languages is broken and have some thoughts on what they should actually be like.

Interim suggestion

My suggestion when you’re faced with returning the result of a promise-returning function in an async function’s body -

Always use return await anotherTask().

There is little downside to it. The semantics of await is such that it will wait for the whole promise chain to finish - with either a resolution or a rejection.

Your expectations of catch and finally based on their syntactic position will always be valid.

There is a tiny bit of overhead as the resolved value will be wrapped into another Promise, but this is something that VMs can optimize for in the future without non-promise-using code being aware of it. In a world with only async and await and no Promise objects, there need be no such distinction.

Recommendation

While await’s semantics have been specified to be promise-resolving, I think that specification must extend to the return statement occurring within async functions as well. At the very least, the spec must require implementations to preserve the equivalence between return await fn() and return fn() when used within an async function, irrespective of where they occur.

Footnotes


  1. Promise libraries usually support a “promisify” operation that turns NodeJS style API exports into functions that return Promises instead. That’s an extra step to do though, and often leaving you not knowing whether you’ve covered all the APIs you’re using or not.

    [return]
  2. edit:2019-01-13: I had used fs.exists as an example that was incorrect and deprecated. [return]