Skip to content

Design decisions

Simon Krajewski edited this page Jan 1, 2026 · 1 revision

Coroutines and asynchronous programming in general is a vast topic on which many smart people have spent a lot of thinking time. During the development of hxcoro, we researched various approaches and concepts from various languages such as Kotlin, C#, Rust and python. It was clear early on that just blindly copying any of these approaches wouldn't work well for Haxe, so we ended up with somewhat of an amalgam. While there's a large overlap with the Kotlin approach, there are also some differences and we would like to justify some decisions we've made.

Haxe std vs. hxcoro

We're following Kotlin here insofar as that only the coroutine parts that are needed by the compiler itself are in the Haxe standard library, with everything else living in the hxcoro library. This allows independent updates and forced a clean separation of concerns between the low-level state machine handling of coroutines in the compiler and the higher-level concepts in hxcoro.

Implicit suspension

The perhaps biggest point of contention in the coroutine community is implicit suspension, which basically means that a coroutine can be called from another coroutine without a special marker such as await:

import hxcoro.Coro.*;
import hxcoro.CoroRun;

@:coroutine function f() {
	delay(15);
	return "ok";
}

function main() {
	CoroRun.run(() -> {
		trace("running");
		trace(f()); // implicit suspension
		trace("done");
	});
}

This tends to make Rust programmers in particular cringe and bring up some valid counterarguments, mostly related to readability of the ease of debugging. On the other hand, Kotlin programmers will argue that this can be alleviated at IDE-level by detecting and marking such calls, giving the same visual information without the need to manually sprinkle await markers everywhere.

We decided to allow implicit suspension because:

  • It didn't require us to come up with any additional syntax and was the natural first step anyway.
  • It is a better fit for a language where any syntactic call could already invoke a macro.
  • It can indeed be detected and marked at IDE-level in the future.

Coroutine return values

The nature of coroutines requires communicating whether or not execution was suspended. Kotlin uses a special COROUTINE_SUSPENDED value which is returned from the coroutine in such cases. This works very well in the very object-oriented JVM-world, but did not seem like the perfect fit for some of our other targets.

We decided to be more explicit about this and make all coroutines return an instance of haxe.coro.SuspensionResult whose state field signifies how our execution went (or is going). Any actual invoker of the coroutine code must then explicitly check that state and react accordingly.

Since allocating such objects is not free and can negatively affect performance, we decided to combine this with our continuation classes: haxe.coro.BaseContinuation extends SuspensionResult and is then the value we return from our coroutine code. Since we need to allocate a continuation anyway, this comes at (almost) no additional cost.

Exception handling

After introducing SuspensionResult we decided to utilize it for the exceptional state as well by catching exceptions early and not letting them naturally bubble up the call stack. This is why SuspensionResult.state is one of Pending, Returned or Thrown. We can expose this by manually invoking a coroutine and checking its return value:

import haxe.coro.context.Context;
import hxcoro.task.CoroTask;

@:coroutine function f() {
	throw "throw";
}

function main() {
	final task = new CoroTask(Context.create(), CoroTask.CoroScopeStrategy);
	final result = f(task);
	trace(result.state.toString(), result.error); // Thrown, throw
}

Even though f clearly throws, control flow terminates normally because the exception is caught early (though in this particular case it is never actually thrown in the first place, but that's just internals). Typically, this would then be processed like so:

	switch (result.state) {
		case Pending:
			// bad luck because we don't have a scheduler
		case Returned:
			trace(result.result);
		case Thrown:
			throw result.error;
	}

The advantage of this approach is that we don't need to worry about these exceptions in the internal execution logic. It also allows us to provide better stack traces without leaking too many internals like continuation calls and scheduling events.

Clone this wiki locally