Coroutines​

A coroutine is a function that pauses at each yield, so multi-frame behavior (AI, cutscenes, staged animations) is straight-line code.

  • The coroutine prefix declares one. A call runs none of the body and returns a handle. Each call is an independent instance.
  • A step runs the body to its next yield. A body with N yields takes N + 1 steps: the last runs from the final yield to the end, and only then is done() true.
  • A handle is a reference. A copy (into a field, an array or map element, a by-value parameter, another variable, a deep copy of an instance that holds one) drives the one live coroutine.
FormA step that reaches a yieldThe step that finishes the bodyAfter that
for (auto v : h)v is the yielded valuethe loop ends; the return value is never iteratedno iterations
h.advance(v)true, the value stored into vfalse, v unchangedfalse, v unchanged
h.next()the yielded valuethe return value (null when there is none)raises Cannot resume a completed coroutine
h.next(n)n calls to next(), returning the last value; n is an int of at least 1. When the body ends early, the steps before the end run, then it raises the same text.
h.done()falsetrue. It only asks: it never starts or advances the body.
h.result()raises Coroutine has not finishedthe return value, a copy each time, whichever form drove the body; null when the body returned nothing or failed

Range-for drives a coroutine​

A range-based for is the way to consume a coroutine. It takes a handle or a call that makes one, and a handle already advanced continues from where it stands. When the steps cannot sit in one loop (one step per frame), h.advance(v) takes one, shaped like a C++ bool advance(T& v); a yielded null is a step like any other. A loop on done() and next() hands out the final value too:

jaiscript
coroutine int waves() { yield 1; yield 2; yield 3; return 9; }
for (auto w : waves()) { print("for got {}", w); }

auto a = waves();
int v = 0;
while (a.advance(v)) { print("advance got {}", v); }
print("again: {}, v: {}, result: {}", a.advance(v), v, a.result());

auto b = waves();
print("skip ahead: {}", b.next(2));
while (!b.done()) { print("next gave {}", b.next()); }
output
for got 1
for got 2
for got 3
advance got 1
advance got 2
advance got 3
again: false, v: 3, result: 9
skip ahead: 2
next gave 3
next gave 9

v binds as a T& parameter does (a variable, a field or an element), at the call, before the body runs. A const or a temporary fails the load; a temporary the checker cannot see raises Cannot pass non-lvalue to reference parameter without resuming. The yielded value converts to v's declared type as an assignment would, and a pair of known types no assignment accepts fails the load.

A value cannot be sent into a running coroutine. Pass inputs as parameters, or share state with the caller (a field of its instance, a program-scope variable). advance() without its variable, a non-integer count and a count below 1 each raise (Run-time errors). Any other member is a load error: h.resume() reports no member 'resume' on 'coroutine_handle' (did you mean 'next'?).

Spellings​

coroutine goes in front of every function spelling from the Functions chapter, for methods (static ones included) and functions in a namespace body too:

jaiscript
coroutine int a() { yield 1; }                 // return-type-first
coroutine b() -> int { yield 2; }              // trailing arrow
coroutine function c() -> auto { yield 3; }    // function keyword + arrow
coroutine function d() { yield 4; }            // function keyword, inferred
coroutine e() { yield 5; }                     // bare: no type, no keyword
print("{} {} {} {} {}", a().next(), b().next(), c().next(), d().next(), e().next());

class Spawner {
    int base = 10;
    coroutine w() -> int { yield base * 3; }
    static coroutine int ticks() { yield 1; yield 2; }
}
namespace ns { coroutine int g() { yield 7; } }
auto t = Spawner::ticks();
print("{} {} {} {}", Spawner().w().next(), t.next(), t.next(), ns::g().next());
output
1 2 3 4 5
30 1 2 7

The declared type describes what the body yields and returns, never the call, which is always a handle. A handle has no type name of its own: hold it in auto or var. type_of names it "coroutine_handle".

Each yield e and the final return convert to the declared type as a return does. A float the checker sees yielded from an int coroutine fails the load; any other value the type cannot take raises at the yield, catchable around the step, and the failed step finishes the coroutine. A bare yield; hands out nothing and is not converted:

jaiscript
coroutine float g() { yield 2; yield "three"; return 4; }
auto h = g();
print(h.next());
try { h.next(); } catch (e) { print("caught: ", e); }
print(h.done());
output
2.0
caught: Cannot yield string from 'g' (declared -> float)
true

A coroutine cannot be a lambda, return a reference, or take a parameter by reference (T&, const T&, auto&, var&). All are parse errors: coroutine int count_up(int& n) reports coroutine 'count_up' cannot take 'n' by reference: it runs on after the call returns, past the argument's lifetime; take it by value (int n), or pass a shared_ptr to share an object.

Method coroutines​

A method coroutine sees this, and fields read and write normally across suspensions. Derived classes inherit method coroutines. The handle pins the receiver alive, here past the frame that made it:

jaiscript
class Gen {
    int id = 0;
    Gen(int i) { id = i; }
    coroutine int gen() { yield id; id *= 10; yield id; }
}
function make(int i) {
    auto g = Gen(i);
    return g.gen();
}
auto ga = make(1);
auto gb = make(2);
print("{} {} {} {}", ga.next(), gb.next(), ga.next(), gb.next());
output
1 2 10 20

So a field holding a handle of its own instance's method is a strong cycle, broken by reassigning or nulling the field. A static coroutine's handle carries no receiver.

A behaviour in a field​

A game object keeps its behaviour as a handle in a field and advances it once a frame. The field is auto from the call that makes the handle (a field initializer can make it), or var when the behaviour starts later. There is no yield from: one coroutine runs another (a free function, a method of the same or another object) with a range-for that yields what it yields:

jaiscript
coroutine string wait(int frames) {
    for (int i = 0; i < frames; ++i) { yield "waiting"; }
}

class Boss {
    int hp = 30;
    string phase = "";
    coroutine string charge() { phase = "charge"; yield "charging"; yield "slam"; }
    coroutine string fight() {
        while (hp > 20) { phase = "volley"; yield "fires a volley"; hp -= 5; }
        for (auto step : wait(2)) { yield step; }
        for (auto step : charge()) { yield step; }
    }
    auto brain = fight();
    void think() {
        if (brain.done()) { return; }
        var said = brain.next();
        if (!brain.done()) { print(`${phase}: ${said}`); }
    }
}

Boss boss;
for (int frame = 0; frame < 10; ++frame) { boss.think(); }
output
volley: fires a volley
volley: fires a volley
volley: waiting
volley: waiting
charge: charging
charge: slam

A copy of the object shares the handle, and the shared coroutine keeps changing the original's fields (Value Semantics). A copy that needs a behaviour of its own assigns its field a new call.

Rules and edges​

  • Parameters. Each argument takes its value at the call, and the first step moves it into its parameter: a by-value parameter is one object that lives as long as the body's frame. A shared_ptr<T> parameter is the spelling for an object the coroutine and its caller both change.
  • Yielded values are copies. yield e hands out a value as return e would: an existing object (a global, a local, an element) is copied, never shared. Side effects in a yield expression run exactly once.
  • A failed step marks only that handle done, and its throw reaches the code that took the step.
  • Handles keep working across separate execute() calls and when passed by reference.
  • Hot reload. A free coroutine can be redefined while one of its handles is suspended (the old handle keeps the old body), and method coroutines survive field-changing reloads of their class.
  • The execution budget applies during a step, and budget errors skip catches inside coroutines too.
  • The checker does not check yield and return values statically, and treats what next() returns as unknown. result() has the declared type, leniently, since a body that falls off its end gives null.

sleep: behavior that waits​

sleep(seconds) waits that long on the engine's time source (Time). It is a builtin like print: every engine has it, and a program-scope function named sleep replaces it.

  • The argument is a float or an int; anything else raises.
  • Zero or less returns at once and never suspends. sleep(0) is the debugger's sync point, so a loop the host drives calls it once per frame (Debugging).
  • Under a run (engine::start, Suspendable runs) the whole program suspends at the sleep, every frame and local in place, and the host has control until the time has passed. The standalone runner runs every file this way.
  • Under a plain execute() nothing suspends: sleep blocks the thread.
jaiscript
coroutine int countdown(int from) {
    for (int n = from; n > 0; --n) { sleep(0.01); yield n; }
}
float start = time::now();
for (int n : countdown(3)) { print("{}...", n); }
print("liftoff after at least 30 ms: {}", time::now() - start >= 0.03);
output
3...
2...
1...
liftoff after at least 30 ms: true
  • A sleep in a coroutine body is not a yield. The whole run waits, the caller of next() included, and the coroutine counts as running meanwhile: another next() on the same handle raises Coroutine is already running.
  • A sleep under native code blocks, as under execute(), and the run warns once: sleep blocks here: a run suspends only where no host callback, destructor, signal receiver, include, import or hot_reload_migrate is running. A builtin's callback (a sort comparator, a filter predicate) counts as a host callback.
  • The checker warns of a sleep written in a destructor ('sleep' in a destructor blocks: a run never suspends inside a destructor) or in hot_reload_migrate, and refuses one in a parallel_for body or a lambda handed to parallel_transform or parallel_reduce.
  • Hot reload reaches a waiting run. A waiting frame finishes the body it started, and every call made after a reload runs the new definitions.
  • The budget covers each stretch. Every resume is a host entry with a fresh execution budget, and time asleep never counts against it. time::execute_elapsed() counts from the start of the run, time asleep included.