Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save misterdjules/30db8fc651746c6f917a to your computer and use it in GitHub Desktop.
Save misterdjules/30db8fc651746c6f917a to your computer and use it in GitHub Desktop.

Challenges in using post-mortem debugging with async/await

Introduction

async/await is a new feature of the EcmaScript programming language that combines generators and promises to improve the language-level model for writing asynchronous code.

It is currently in "stage 3" of the TC39 process, which means it's a "candidate" that needs more implementation experience.

It is currently implemented only by transpilers such as Babel, Traceur and TypeScript, and by only one VM: Microsoft Edge 13, behind a flag.

The async keyword is used to declare a function as being able to use the await keyword to "wait" on asynchronous operations:

// necessary since some transpilers like babel will generate code
// that uses block-scoped declarations
'use strict';

function someAsyncOperation() {
	return new Promise(function (resolve, reject) {
		setImmediate(function _asyncDone() {
			return resolve(42);
		});
	});
}

function someOtherAsyncOperation() {
	return new Promise(function (resolve, reject) {
		setImmediate(function _asyncDone() {
			return resolve(43);
		});
	});
}

async function someAsyncWorkflow() {
	var foo = await someAsyncOperation();
	var bar = await someOtherAsyncOperation();

	console.log('foo:', foo);
	console.log('bar:', bar);
}

someAsyncWorkflow();

Note that in the code above, the someAsyncWorkflow function outputs the results from the two asynchronous operations that it awaits on the console, when a more idiomatic way to write this code would be to simply return foo and bar and use another async function to await on someAsyncWorkflow, but that would have made the generated code that the rest of this document uses as a reference non-minimal.

Current async/await implementations

Most current async/await implementations implement a variant of the spawnfunction mentioned in the async/await specification:

function spawn(genF, self) {
    return new Promise(function(resolve, reject) {
        var gen = genF.call(self);
        function step(nextF) {
            var next;
            try {
                next = nextF();
            } catch(e) {
                // finished with failure, reject the promise
                reject(e);
                return;
            }
            if(next.done) {
                // finished with success, resolve the promise
                resolve(next.value);
                return;
            }
            // not finished, chain off the yielded promise and `step` again
            Promise.resolve(next.value).then(function(v) {
                step(function() { return gen.next(v); });
            }, function(e) {
                step(function() { return gen.throw(e); });
            });
        }
        step(function() { return gen.next(undefined); });
    });
}

Basically, async functions are turned into a generator that yields values that are (potentially) asynchronously generated by promises and that returns a promise.

Internally, here's how a typical JavaScript runtime runs the function someAsyncWorkflow:

  1. It turns the function into a generator.

  2. It returns a new promise that represents its eventual result.

  3. That new promise's resolver starts the generator.

  4. await expressions are implemented by yield statements that are executed within the generated generator.

  5. When an "awaited" promise resolves, next is called on the generator by passing the value produced by the awaited promise. When it rejects, it calls throw on the generator.

  6. If the generator throws, the promise that was returned as a result of the async function rejects. If the generator completes, that promise resolves.

More specifically, here is the code that Babel, a JavaScript transpiler, generates for the above-mentioned sample code:

'use strict';

let someAsyncWorkflow = function () {
    var ref = _asyncToGenerator(function* () {
        var foo = yield someAsyncOperation();
        var bar = yield someOtherAsyncOperation();

        console.log('foo:', foo);
        console.log('bar:', bar);
    });

    return function someAsyncWorkflow() {
        return ref.apply(this, arguments);
    };
}();

function _asyncToGenerator(fn) {
 	return function () {
	    var gen = fn.apply(this, arguments);
	    return new Promise(function (resolve, reject) {
	     	function step(key, arg) { 
	        	try {
	          		var info = gen[key](arg);
	          		var value = info.value;
	         	} catch (error) {
	           		reject(error);
	           		return;
	         	}

	        	if (info.done) {
	          		resolve(value);
	        	} else {
	          		return Promise.resolve(value).then(function (value) {
	          			return step("next", value);
	          		}, function (err) {
	          			return step("throw", err);
	          		});
	        	}
	      	}

	      	return step("next");
	    });
  	};
}

function someAsyncOperation() {
    return new Promise(function (resolve, reject) {
        setImmediate(function _asyncDone() {
            return resolve(42);
        });
    });
}

function someOtherAsyncOperation() {
    return new Promise(function (resolve, reject) {
        setImmediate(function _asyncDone() {
            return resolve(43);
        });
    });
}

someAsyncWorkflow();

The _asyncToGenerator generator function is the equivalent of the spawn function described in the async/await specification. ChakraCore emits similar code.

Issues with regards to post-mortem debugging

The rest of this document uses the code generated by Babel as reference, since it's much easier to reason about than ChakraCore's implementation.

Symptoms

From the perspective of a user of post-mortem debugging tools, the main issue is that all errors thrown synchronously and not explicitly caught do not make the process exit, or abort when the --abort-on-uncaught-exception command line option is passed to node.

For instance, any throw statement that doesn't have an explicit corresponding catch clause added to the sample code mentioned in the introduction will not make node abort when running with --abort-on- uncaught-exception, as a user not familiar with async/await's error handling might expect.

Let's consider the following instance of this problem:

// necessary since some transpilers like babel will generate code
// that uses block-scoped declarations
'use strict';

function someAsyncOperation(someParam) {	
	return new Promise(function (resolve, reject) {
		if (typeof someParam !== 'string') {
			throw new Error('someParam must be a string');
		}

		setImmediate(function _asyncDone() {
			return resolve(42);
		});
	});
}

function someOtherAsyncOperation() {
	return new Promise(function (resolve, reject) {
		setImmediate(function _asyncDone() {
			return resolve(43);
		});
	});
}

async function someAsyncWorkflow() {
	var foo = await someAsyncOperation();
	var bar = await someOtherAsyncOperation();

	console.log('foo:', foo);
	console.log('bar:', bar);
}

someAsyncWorkflow();

When running this code with --abort-on-uncaught-exception, node doesn't abort, and exits successfully, even though someAsyncOperation throws an error synchronously, and that error is not explicitly caught in the rest of the code:

$ node --version
v4.3.0
$ node --abort-on-uncaught-exception test-async-throw-param-out.js 
$ echo $?
0
$ 

Causes

Async/await builds on top of promises. The challenges in using promises with post-mortem debugging and differences with the pre-promises error handling model are presented in details in a separate document and boils down to the fact that promises implicitly turn uncaught errors thrown synchronously into rejections, which don't automatically make a node process exit (or abort when using --abort-on-uncaught-exception).

Furthermore, from looking at Babel's generated code for the _asyncToGenerator function, the reasons why uncaught errors do not result in the process exiting or aborting can be summarized as follows:

  1. The generator is run by a promise's resolver, which turn errors thrown synchronously and not caught into rejections with implicit try/catch blocks.

  2. Awaited promises, by definition, have any of their uncaught synchronous exceptions turned into a rejection, that itself triggers a call to Generator.prototype.throw.

  3. Moreover, calls to Generator.prototype.next and Generator.prototype.throw in the step function are implicitly wrapped in a try/catch block. Thus, all errors thrown from any async function are implicitly caught.

Exploring solutions

Because async/await builds on top of two different abstractions, namely generators and promises, both of which have various imcompatibilities with post-mortem debugging, this section first explores if some of the potential solutions to fix their respective issues can improve async/await's support for post-mortem debugging.

Making promises not implicitly catch all uncaught errors

In the document entitled "Challenges in using promises with post-mortem debugging", a few potential solutions are explored to:

  1. Prevent promises from implicitly catching uncaught errors thrown synchronously.

  2. Making node abort at the exact time an uncaught error is thrown when --abort- on-uncaught-exception is used.

The most promissing solution for these two problems was suggested by @vkurchatkin and works by adding a special case in v8::internal::Isolate::Throw to check if the exception is thrown from within a promise that doesn't have a catch handler.

First use case: throwing synchronously from an awaited promise's resolver

So what happens for async/await when this potential solution is used to run the same sample code used to describe the original problem:

// necessary since some transpilers like babel will generate code
// that uses block-scoped declarations
'use strict';

function someAsyncOperation(someParam) {	
	return new Promise(function (resolve, reject) {
		if (typeof someParam !== 'string') {
			throw new Error('someParam must be a string');
		}

		setImmediate(function _asyncDone() {
			return resolve(42);
		});
	});
}

function someOtherAsyncOperation() {
	return new Promise(function (resolve, reject) {
		setImmediate(function _asyncDone() {
			return resolve(43);
		});
	});
}

async function someAsyncWorkflow() {
	var foo = await someAsyncOperation();
	var bar = await someOtherAsyncOperation();

	console.log('foo:', foo);
	console.log('bar:', bar);
}

someAsyncWorkflow();

The source code is first transpiled with Babel:

[jgilli@dev ~/node]$ ./node_modules/.bin/babel test-async-throw-param.js > test-async-throw-param-out.js

and then run with --abort-on-uncaught-exceptin:

[jgilli@dev ~/node]$ ./node --abort-on-uncaught-exception ~/generators-js/test-async-throw-param-out.js 
Uncaught Error: someParam must be a string

FROM
/home/jgilli/generators-js/test-async-throw-param-out.js:22:4
someAsyncOperation (/home/jgilli/generators-js/test-async-throw-param-out.js:20:9)
/home/jgilli/generators-js/test-async-throw-param-out.js:5:19
step (/home/jgilli/generators-js/test-async-throw-param-out.js:17:191)
/home/jgilli/generators-js/test-async-throw-param-out.js:17:451
/home/jgilli/generators-js/test-async-throw-param-out.js:17:99
someAsyncWorkflow (/home/jgilli/generators-js/test-async-throw-param-out.js:13:14)
Object.<anonymous> (/home/jgilli/generators-js/test-async-throw-param-out.js:38:1)
Module._compile (module.js:417:34)
Object.Module._extensions..js (module.js:426:10)
Module.load (module.js:357:32)
Function.Module._load (module.js:314:12)
Function.Module.runMain (module.js:451:10)
startup (node.js:149:18)
node.js:1013:3
Illegal Instruction (core dumped)

The node process aborted, which is the expected behavior when throwing synchronously and using --abort-on-uncaught-exception. Moreover, the process aborted at the exact time the error threw:

[root@dev ~]# mdb /home/jgilli/cores/core.node.34907
Loading modules: [ libumem.so.1 libc.so.1 ld.so.1 ]
> ::load /home/jgilli/mdb_v8/build/amd64/mdb_v8.so
mdb_v8 version: 1.1.2 (dev)
V8 version: 4.8.271.17
Autoconfigured V8 support from target
C++ symbol demangling enabled
> ::jsstack
native: v8::base::OS::Abort+9
native: v8::internal::Runtime_Throw+0x30
        (1 internal frame elided)
js:     <anonymous> (as <anon>)
js:     Promise
        (1 internal frame elided)
js:     someAsyncOperation
        (1 internal frame elided)
js:     <anonymous> (as <anon>)
js:     next
js:     step
        (1 internal frame elided)
js:     <anonymous> (as <anon>)
js:     Promise
        (1 internal frame elided)
js:     <anonymous> (as <anon>)
        (1 internal frame elided)
js:     someAsyncWorkflow
js:     <anonymous> (as <anon>)
        (1 internal frame elided)
js:     <anonymous> (as Module._compile)
js:     <anonymous> (as Module._extensions..js)
js:     <anonymous> (as Module.load)
js:     <anonymous> (as Module._load)
js:     <anonymous> (as Module.runMain)
js:     startup
js:     <anonymous> (as <anon>)
        (1 internal frame elided)
        (1 internal frame elided)
native: v8::internal::_GLOBAL__N_1::Invoke+0xb3
native: v8::internal::Execution::Call+0x62
native: v8::Function::Call+0xf6
native: v8::Function::Call+0x41
native: node::LoadEnvironment+0x1e8
native: node::Start+0x508
native: _start+0x6c
>

So it seems that this use case is fixed by @vkurchatkin proposed change, with the caveats mentioned in the document that describes it in further details: it might not be an acceptable changefor V8 due to its differences with the default behavior of rejected promises in node.

For the sake of exploring the solutions space, let's consider it's an acceptable solution, and let's move on to testing if this change fixes other use cases.

Second use case: throwing from the yielded functions creating the awaited promises

Now let's consider the following slightly different sample code:

// necessary since some transpilers like babel will generate code
// that uses block-scoped declarations
'use strict';

function someAsyncOperation(someParam) {	
	if (typeof someParam !== 'string') {
		throw new Error('someParam must be a string');
	}

	return new Promise(function (resolve, reject) {
		setImmediate(function _asyncDone() {
			return resolve(42);
		});
	});
}

function someOtherAsyncOperation() {
	return new Promise(function (resolve, reject) {
		setImmediate(function _asyncDone() {
			return resolve(43);
		});
	});
}

async function someAsyncWorkflow() {
	var foo = await someAsyncOperation();
	var bar = await someOtherAsyncOperation();

	console.log('foo:', foo);
	console.log('bar:', bar);
}

someAsyncWorkflow();

Note how the error is now thrown from someAsyncOperation, and not from the resolver of the promise it creates.

When running this code with --abort-on-uncaught-exception after having transpiled it with Babel, it outputs the following:

$ ./node_modules/.bin/babel test-async-throw-param.js > test-async-throw-param-out.js
$ ./node --abort-on-uncaught-exception ~/generators-js/test-async-throw-param-out.js 
$ echo $?
0
$

The process doesn't abort and it exits sucessfully. The reason is that _asyncToGenerator by default wraps calls to the generator's next and throw calls in a try/catch block, thus catching any error thrown as part of the evaluation of the await expressions, such as when someAsyncOperation is called.

So it seems the _asyncGenerator code itself would need to not add that implicit try/catch block when using --abort-on-uncaught-exception. Let's explore that solution in the next section.

Removing implicit try/catch blocks from _asyncToGenerator

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment