/Coro

Async/await, generators, and arbitrary coroutines for Haxe

Primary LanguageOCamlMIT LicenseMIT

Coro

This library is the Haxe compiler plugin which provides generic coroutines implementation.

The library also includes async/await and generators implementations on top of the plugin.

Installation

To use this plugin you will need to setup Haxe for development (see Building Haxe from source) And then:

$ git clone https://github.com/RealyUniqueName/coro.git
$ cd coro
$ haxelib dev coro .
$ cd path/to/dev/haxe
$ make PLUGIN=path/to/coro/src/ml/coro_plugin plugin

Now to use Coro all you need to do is to add -lib coro to your compilation flags.

Generator

import coro.Generator;

class Test {
	static public function main() {
		for(n in fibonacci(5)) {
			trace(n); // 1, 1, 2, 3, 5
		}
	}

	static function fibonacci(iterations:Int) return new Generator<Int>(yield -> {
		var current = 1;
		var previous = 0;
		while(iterations-- > 0) {
			yield(current);
			var next = current + previous;
			previous = current;
			current = next;
		}
	});
}

Async/await

coro.Async mimics the js.Promise api.

Additionally it has Async.wrap() and Async.wrapVoid methods which can be used to transition from the 3rd-party asynchronous API to the coro.Async

import coro.Async;

class Test {
	static public function main() {
		md5WebPage('http://example.com').start()
			.then(md5 -> trace(md5)) 		//print the calculated hash in case of success
			.catchError(msg -> trace(msg)); //print error message in case of failure
	}

	static function md5WebPage(url:String) return new AsyncValue(() -> {
		//Wait a second. Just because I can.
		Async.delay(1000).await();
		try {
			var contents = request(url).await();
			return haxe.crypto.Md5.encode(contents);
		} catch(error:String) {
			trace(error); //Log error message
			throw error; //rethrow
		}
	});

	static function request(url:String) return Async.wrap((resolve, reject) -> {
		var http = new haxe.Http(url);
		http.onData = resolve;
		http.onError = error -> reject(error);
		http.request();
	});
}

Awaiting ES6 promises

using coro.Async;

function greet(promise:js.Promise<String>) return new Async(() -> {
	var name = promise.await();
	trace('Hello, $name!');
});

Converting Async to ES6 promise

using coro.Async;

function delayGreet(name:String):Promise<String> {
	var async = new AsyncValue<String>(() -> {
		Async.delay(1000).await();
		return name;
	});

	//convert coroutine to promise
	return async.promise();
}

Pipe

Pipe is the coroutine, which allows two-way communication between the coroutine caller and the coroutine itself.

import coro.Pipe;

class Test {
	static public function main() {
		var repl = getRepl();
		while(true) {
			Sys.println('Type a command: ');
			var cmd = input();
			try {
				//send the command to REPL and get the result back
				var result = repl.send(cmd);
				Sys.println('Result: $result');
			} catch(e:ClosedPipeException) {
				//Pipe closed with the "exit" command
				break;
			} catch(e:Dynamic) {
				Sys.println('Error: $e');
				return;
			}
		}
		//Get the value returned with `return value` expression in the Pipe
		var cmdCount = repl.getResult();
		Sys.println('Total commands executed: $cmdCount');
	}

	static function getRepl() return new Pipe<Int,String,Int>(yield -> {
		var parser = ~/^(\d+)\s*\+\s*(\d+)$/; //Allows "123 + 456"
		var counter = 0;
		var command = yield(0);
		while(command != 'exit') {
			++counter;
			if(parser.match(command)) {
				var eval = Std.parseInt(parser.matched(1)) + Std.parseInt(parser.matched(2));
				//Send the evaluated value to the caller and wait for the next command
				command = yield(eval);
			} else {
				throw 'Invalid command: $command';
			}
		}
		return counter; //this value can be accessed with the `Pipe.getResult()` method
	});

	static function input():String {
		var result = '';
		var c = Sys.getChar(true);
		while(c != 13) {
			result += String.fromCharCode(c);
			c = Sys.getChar(true);
		}
		Sys.print('\n');
		return result;
	}
}

Arbitrary coroutine example

Every call in this example suspends execution of the greet():

function greet() return new Dialog(() -> {
	speak("What's your name?");
	var name = listen();
	speak('Nice to meet you, $name!');
	wait(1000);
	explode();
});

See https://github.com/RealyUniqueName/Coro/blob/master/tests/cases/TestDialogExample.hx#L10

Implementation details

The plugin operates on the Typed Syntax Tree. It is executed after typing step of the compiler and before the optimization step.

The plugin transforms local function declarations to state machines if passed to the argument of coro.Coroutine type followed by the optional argument of coro.Coroutine.Generated type. Transformed function is then passed to the argument of Generated type while the original function is replaced with null. Function arguments list gets appended with the one or two new arguments: sm or sm, resumeValue

E.g. if the signature of generator function is

	function generator(
		userFunction:Coroutine<yield:YieldType->ReturnType>,
		?genFunction:Generated<(yield:YieldType, sm:StateMachineType, resumeValue:ResumeType)->Void>
	)

then this expression

	generator(yield -> {/* body */});

is transformed to

	generator(null, (yield, sm, resumeValue) -> {/* transformed body */});

Such approach was chosen to stay in the boundaries of the Haxe type system.

The benefits are

  • Full compiler-based completion support;
  • No macros;
  • No auto generated types or fields;

The downside is impossibility to invoke super methods in a coroutine.

The coroutine body is split into states by suspending calls. A function is considered suspending if the signature of a callee is coro.Suspend<T:Function> (e.g. coro.Suspend<()->Void>) or if the callee is a method with @:suspend meta applied to it.

Now for example the fibonacci generator mentioned above is transformed to the following state machine:

static function fibonacci(iterations:Int) {
	var previous;
	var current;
	return new coro.Generator(null, function(yield:coro.Suspend<(Int)->Void>, sm:coro.Generator<Int>) {
		var state = sm.state;
		//if an exception will raise, the state machine will be left in `Interrupted` state
		//which is `-2`. See `coro.Coroutine.StateExitCode` enum.
		sm.state = -2;
		while (true)
			if (state == 0) {
				current = 1;
				previous = 0;
				state = 1;
			} else if (state == 1) {
				if (iterations-- > 0) {
					sm.nextState = 2;
					yield(current);
					//"yield" is the suspending function, so the `return` is generated to suspend execution
					return sm.state = sm.nextState;
				} else
					state = 3;
			} else if (state == 2) {
				var next = current + previous;
				previous = current;
				current = next;
				state = 1;
			} else if (state == 3)
				return sm.state = sm.nextState = -1
			else
				throw new coro.CoroutineStateException(state);
	});
}

Tests

To run test:

For eval: $ haxe tests.hxml --interp

For js: $ haxe tests.hxml -js bin/test.js && node bin/test.js

For java: $ haxe tests.hxml -java bin/java && java -jar bin/java/Tests-Debug.jar

etc.