Tzopilotl
Docs
GitHub

Tzopilotl — Coroutines

Comprehensive guide to the coroutine system: language usage, runtime architecture, and implementation internals.

1. Overview

Tzopilotl provides coroutines — cooperative generators that yield values one at a time and can be suspended and resumed on demand. A coroutine is declared with coro fn, yields values with yield, and is consumed via the next builtin which returns Option<T>.

-- Declare a coroutine that yields integers
coro fn count(n Int) Int {
    var i = 0;
    while (i < n) {
        yield i;
        i = i + 1;
    }
}

-- Create and consume
let c = 3 count;
c next println;   -- Option<Int>.some(0)
c next println;   -- Option<Int>.some(1)
c next println;   -- Option<Int>.some(2)
c next println;   -- Option<Int>.none

Key properties of Tzopilotl coroutines:

2. Language Guide

2.1 Declaring Coroutines

A coroutine is declared by prefixing a function declaration with coro. The return type annotation specifies the yield type — the type of values that the coroutine produces.

coro fn naturals() Int {
    var i = 0;
    while (true) {
        yield i;
        i = i + 1;
    }
}

The yield statement suspends the coroutine, returns the given value to the caller, and saves all local state. When the coroutine is next resumed, execution continues from the statement immediately after the yield.

Coroutines may take parameters just like regular functions:

coro fn range(start Int, end Int) Int {
    var i = start;
    while (i < end) {
        yield i;
        i = i + 1;
    }
}

2.2 Creating & Resuming

Calling a coro fn does not execute its body. Instead it creates a Coroutine<T> object in the Created state, capturing the arguments for later use:

let c = 5 count;   -- creates Coroutine<Int>, body not yet entered

To advance the coroutine, call next. Each call returns Option<T>:

let c = 3 count;
c next println;   -- Option<Int>.some(0)
c next println;   -- Option<Int>.some(1)
c next println;   -- Option<Int>.some(2)
c next println;   -- Option<Int>.none
c next println;   -- Option<Int>.none  (remains none forever)

Once a coroutine is Done, all subsequent calls to next return none.

2.3 Draining a Coroutine

A common pattern is to drain a coroutine until it is done. Use isSome and unwrap from the standard Option API:

fn drain(c Coroutine<Int>) Void {
    var result = c next;
    while (result isSome) {
        result println;
        result = c next;
    }
}

let c = 4 count;
c drain;

Output:

Option<Int>.some(0)
Option<Int>.some(1)
Option<Int>.some(2)
Option<Int>.some(3)

2.4 Yield Types

Coroutines can yield any type — value types (Int, Float, Bool) and object types (String, Array, Tuple) alike:

-- Yielding strings
coro fn greetings() String {
    yield "hello";
    yield "world";
    yield "goodbye";
}

-- Yielding arrays
coro fn make_arrays() [Int] {
    yield [1, 2, 3];
    yield [4, 5];
}

-- Yielding tuples
coro fn pairs(n Int) (Int, Int) {
    var i = 0;
    while (i < n) {
        yield (i, i * i);
        i = i + 1;
    }
}

2.5 Yield & Control Flow

yield can appear inside any control flow structure: if/else, match, while loops, and sequences thereof. The coroutine correctly remembers where it was suspended and resumes from that exact point.

Yield in if/else

coro fn conditional_yield(flag Bool) Int {
    if (flag) {
        yield 1;
        yield 2;
    } else {
        yield 100;
        yield 200;
    }
    yield 999;
}

Yield in match

enum Shape {
    circle Float,
    rect Float
}

coro fn describe_shapes() String {
    let shapes = [Shape.circle(1.0), Shape.rect(4.0)];
    var i = 0;
    while (i < shapes length) {
        match (shapes[i]) {
            Shape.circle(r): yield "circle";
            Shape.rect(w): yield "rect";
        }
        i = i + 1;
    }
}

Sequential loops

coro fn two_loops() Int {
    var i = 0;
    while (i < 3) {
        yield i;
        i = i + 1;
    }
    i = 10;
    while (i < 13) {
        yield i;
        i = i + 1;
    }
}

-- Yields: 0, 1, 2, 10, 11, 12, then none

2.6 Nesting Coroutines

A coroutine can call next on another coroutine, forwarding its values with yield. This allows coroutine composition:

coro fn inner() Int {
    yield 1;
    yield 2;
}

coro fn outer() Int {
    let ic = inner();
    var r = ic next;
    while (r isSome) {
        yield r unwrap;
        r = ic next;
    }
    yield 99;
}

let o = outer();
o next println;   -- Option<Int>.some(1)
o next println;   -- Option<Int>.some(2)
o next println;   -- Option<Int>.some(99)
o next println;   -- Option<Int>.none

Coroutines can also call ordinary (non-coroutine) functions. Those function calls use the normal flat register file — no heap-allocated frames are needed because normal functions always complete before the coroutine can yield:

fn double(x Int) Int = x * 2;

coro fn doubled_range(n Int) Int {
    var i = 0;
    while (i < n) {
        yield i double;   -- calls double(), yields result
        i = i + 1;
    }
}

2.7 Passing Coroutines as Values

Coroutine<T> is a first-class type. Coroutines can be stored in variables, passed to functions, and selected conditionally:

-- Coroutine as function parameter
fn drain(c Coroutine<Int>) Void {
    var result = c next;
    while (result isSome) {
        result println;
        result = c next;
    }
}

-- Conditional selection (both must yield same type)
coro fn evens() Int { var i = 0; while (i < 6) { yield i; i = i + 2; } }
coro fn odds()  Int { var i = 1; while (i < 6) { yield i; i = i + 2; } }

let selected = true ? evens() : odds();
selected drain;

Multiple coroutines can be interleaved — each maintains its own independent state:

let a = 3 count;
let b = 3 count;
a next println;   -- Option<Int>.some(0)
b next println;   -- Option<Int>.some(0)
a next println;   -- Option<Int>.some(1)
b next println;   -- Option<Int>.some(1)

2.8 Infinite Generators

Coroutines with while (true) produce infinite sequences. Consume only as many values as needed:

coro fn fibs() Int {
    var a = 0;
    var b = 1;
    while (true) {
        yield a;
        let tmp = a + b;
        a = b;
        b = tmp;
    }
}

let f = fibs();
f next println;   -- Option<Int>.some(0)
f next println;   -- Option<Int>.some(1)
f next println;   -- Option<Int>.some(1)
f next println;   -- Option<Int>.some(2)
f next println;   -- Option<Int>.some(3)
f next println;   -- Option<Int>.some(5)
f next println;   -- Option<Int>.some(8)
f next println;   -- Option<Int>.some(13)

An empty coroutine (no yield statements) immediately returns none:

coro fn empty() Int {}

let e = empty();
e next println;   -- Option<Int>.none

2.9 Delegation with yieldAll

The yieldAll builtin drains an inner coroutine, yielding each of its values on behalf of the outer coroutine. This replaces the manual while/next/unwrap loop shown in section 2.6:

coro fn inner() Int {
    yield 10;
    yield 20;
    yield 30;
}

coro fn outer() Int {
    yield 1;
    yieldAll(inner());
    yield 99;
}

let o = outer();
o next println;   -- Option<Int>.some(1)
o next println;   -- Option<Int>.some(10)
o next println;   -- Option<Int>.some(20)
o next println;   -- Option<Int>.some(30)
o next println;   -- Option<Int>.some(99)
o next println;   -- Option<Int>.none

yieldAll also works in pipeline form:

coro fn outer_pipe() Int {
    yield 1;
    inner() yieldAll;
    yield 99;
}

The inner coroutine must yield the same type as the outer coroutine. yieldAll can only be used inside a coro fn body, just like yield.

Multiple yieldAll calls can be combined with regular yield statements to compose sequences from several sources:

coro fn combined() Int {
    var i = 0;
    while (i < 2) {
        yield i;
        i = i + 1;
    }
    inner() yieldAll;
    yield 999;
}

-- Yields: 0, 1, 10, 20, 30, 999, then none

2.10 For Loops over Coroutines

Coroutines are iterable — a for loop can iterate directly over a Coroutine<T>, extracting each yielded value until the coroutine is exhausted:

coro fn count_to(n Int) Int {
    var i = 1;
    while (i <= n) {
        yield i;
        i = i + 1;
    }
}

for (x : count_to(5)) {
    x println;
}
-- Prints: 1, 2, 3, 4, 5

The loop variable x has the coroutine's yield type (Int in this case) — it receives the unwrapped value directly, not an Option. The loop terminates automatically when the coroutine returns none.

This replaces the verbose manual drain pattern:

-- Before: manual drain
var result = c next;
while (result isSome) {
    let x = result unwrap;
    x println;
    result = c next;
}

-- After: for loop
for (x : c) {
    x println;
}

Break and continue

break and continue work as expected. This is especially useful with infinite coroutines:

for (x : fibs()) {
    if (x > 10) { break; }
    x println;
}
-- Prints: 0, 1, 1, 2, 3, 5, 8

Inline coroutine creation

Coroutines can be created inline in the for expression:

for (s : greetings()) {
    s println;
}
-- Prints: hello, world, goodbye

Any yield type

for loops over coroutines work with any yield type — strings, arrays, tuples, and other object types:

coro fn colors() String {
    yield "red";
    yield "green";
    yield "blue";
}

for (c : colors()) {
    c println;
}
-- Prints: red, green, blue
Coroutines join Array, List, and Range as iterable types in for loops. The loop variable type is inferred from the coroutine's yield type, just as it is inferred from the element type of an Array or List.

2.11 Converting to Lists

The toList function converts a Coroutine<T> into a List<T>. The resulting list is lazy — the coroutine is resumed on demand as list elements are accessed:

coro fn count(n Int) Int {
    var i = 0;
    while (i < n) {
        yield i;
        i = i + 1;
    }
}

count(5) toList println;       -- List(0, 1, 2, 3, 4)
count(0) toList println;       -- nil

Because the list is lazy, toList works with infinite coroutines when combined with lazy list operations like take, drop, or collect:

coro fn fibs() Int {
    var a = 0;
    var b = 1;
    while (true) {
        yield a;
        let tmp = a + b;
        a = b;
        b = tmp;
    }
}

fibs() toList take(8) println;       -- List(0, 1, 1, 2, 3, 5, 8, 13)
fibs() toList collect(6) println;    -- [0, 1, 1, 2, 3, 5]

Once converted, the full set of list operations is available — head, tail, map, drop, zip, and so on:

count(5) toList head println;          -- 0
count(5) toList drop(3) println;      -- List(3, 4)
count(3) toList map(fn(x Int) Int = x * 10) println;
                                       -- List(0, 10, 20)
toList vs for loops: Use a for loop when you want to process each value imperatively (with side effects). Use toList when you want to treat the coroutine's output as a data structure and apply functional list operations.

3. Design Rationale

3.1 Why Save-Slot Frames

Tzopilotl is event-driven: the call stack collapses between events, so the garbage collector never needs to scan the stack. This is fundamental to the VM's real-time guarantees. Traditional stackful coroutines would violate this invariant because they keep live stack frames around across event boundaries.

Each coroutine has a single heap-allocated CoroutineFrame that acts as a save slot. During execution, the coroutine body and all functions it calls use the shared flat register file — the same fast path as non-coroutine code. When the coroutine yields, its body registers are copied from the flat register file into the save slot. When resumed, they are copied back. The save slot is an ordinary GC-managed heap object that the incremental collector scans like any other Obj. This gives us:

3.2 Single-Level Yield

yield is restricted to the lexical body of a coro fn. A regular function called from within a coroutine cannot yield on the coroutine's behalf. This simplification:

Despite this restriction, coroutines can still call next on other coroutines. The outer coroutine's context is saved while the inner coroutine runs, and restored when the inner coroutine yields or finishes. This enables powerful composition patterns.

3.3 Real-Time Safety

All coroutine operations are real-time safe:

OperationCostAllocator
Create coroutine (op_coro_create)O(args) copyTLSF
Resume (op_coro_resume)O(regs) copy + O(1) write barrierTLSF (first resume only)
Yield (op_yield)O(regs) copy + O(1) context switchTLSF (Option allocation)
Function call inside coroutineSame as normal callNone (uses flat register file)
Function return inside coroutineSame as normal returnNone

No system allocator calls. No blocking operations. All TLSF allocations are O(1) worst-case.

4. Type System

4.1 Coroutine<T>

When a function is declared with coro fn, its effective return type is Coroutine<T>, where T is the declared yield type. The type checker wraps the declared return type automatically:

-- Declared as yielding Int, actual return type is Coroutine<Int>
coro fn count(n Int) Int { ... }

CoroutineType is an object type (ObjType) parameterized by its yield type. Coroutine types are interned by the TypeUniverse: two calls to coroutineType(intType) return the same CoroutineType*.

Coroutine<T> can be used in type annotations for function parameters:

fn drain(c Coroutine<Int>) Void { ... }

4.2 Option<T> Return

The next builtin returns Option<T>, the language's standard sum type for nullable values:

The next function is resolved as a template builtin that pattern-matches on its argument type:

-- Pseudo-signature (resolved by template resolver)
next(Coroutine<T>) → Option<T>

4.3 Type Checking Rules

RuleDescription
Yield type matchThe expression in yield expr must match the declared yield type T. Int-to-Float promotion is allowed.
Yield locationyield is only allowed inside a coro fn body. Using it elsewhere is a compile-time error.
Body return typeThe body of a coro fn has return type Void. The coroutine finishes by falling off the end (triggering op_coro_done), not by returning a value.
Call site markingCalls to coroutine functions are marked isCoroCall = true. Calls to next() on a coroutine are marked isCoroResume = true. These flags direct the code generator to emit the correct opcodes.
Conditional selectionflag ? coroA() : coroB() type-checks if both branches yield the same type, producing the same Coroutine<T> type.

5. Runtime Architecture

5.1 State Machine

Each CoroutineObj progresses through four states:

  Created ──(first next)──> Running ──(yield)──> Suspended
                               │                     │
                               │                 (next)
                               │                     │
                               │                     v
                               │                  Running ──(yield)──> Suspended
                               │                     │
                               └──(body ends)──>  Done
                                                     │
                                               (all subsequent
                                                next → none)
StateDescription
CreatedCoroutine object exists but body has not been entered. Arguments are stored in the args_[] flexible array.
RunningCoroutine body is actively executing. Set when op_coro_resume enters the coroutine.
SuspendedCoroutine has yielded a value. Frame chain is saved in topFrame_, resume PC in resumePC_.
DoneCoroutine body has finished. topFrame_ is null. All future next calls return none.

5.2 CoroutineObj

CoroutineObj is the main heap object representing a coroutine instance. It uses a flexible array member to store the initial arguments inline:

class CoroutineObj : public Obj {
    CodeBlock*       entryBlock_;       // compiled function body
    State            state_;            // Created | Running | Suspended | Done
    CoroutineFrame*  topFrame_;         // saved frame chain (null when Created/Done)
    Code*            resumePC_;         // instruction to resume from

    // Caller context (saved on resume, restored on yield/done)
    Code*            callerReturnPC_;
    u16              callerResultReg_;
    u32              callerBaseReg_;
    u32              callerFrameCount_;
    CoroutineFrame*  callerCoroFrame_;  // if caller is also a coroutine
    CoroutineObj*    callerCoroutine_;  // if caller is also a coroutine

    // Initial arguments
    FunctionType*    funcType_;         // for GC scanning of Obj* args
    u16              numArgs_;
    Word             args_[];           // flexible array
};

The caller context fields allow op_yield and op_coro_done to restore the caller's execution state without any stack unwinding.

5.3 CoroutineFrame (Save Slot)

CoroutineFrame serves as a save slot for a coroutine's body registers. Each coroutine has exactly one CoroutineFrame, allocated on first resume. During execution, the coroutine body uses the flat register file; the save slot is only populated when the coroutine yields (registers are copied in) and read when the coroutine resumes (registers are copied out).

class CoroutineFrame : public Obj {
    CodeBlock*       codeBlock_;   // which function this frame belongs to
    Code*            returnPC_;    // (legacy, unused in save-slot model)
    CoroutineFrame*  caller_;      // (legacy, always nullptr in save-slot model)
    u16              resultReg_;   // (legacy, unused in save-slot model)
    u16              numRegs_;     // number of registers in this frame
    u16              gcMapIndex_;  // index into codeBlock_->coroGCMaps_
    Word             regs_[];      // flexible array of saved register values
};

Normal functions called from within a coroutine use the flat register file and flat CallFrame stack, identical to non-coroutine execution. Since yield can only appear in the coroutine body itself (not in called functions), all normal function calls have returned by the time a yield occurs, so their registers do not need to be preserved.

5.4 Context Switching

Context switching between the caller and coroutine involves saving and restoring a small set of pointers:

Resume (caller → coroutine)

  1. Save caller context into CoroutineObj: return PC, result register, base register, frame count, and (if the caller is itself a coroutine) the caller's coroutine frame pointer and coroutine object.
  2. Set vm.currentCoroutine_ and vm.currentCoroFrame_.
  3. Compute newBase — place the coroutine body's registers after the caller's register window in the flat register file.
  4. First resume: copy arguments from CoroutineObj::args_[] into the flat register file at newBase.
  5. Subsequent resume: copy saved registers from CoroutineFrame::regs_[] into the flat register file at newBase. Apply write barrier to the save slot.
  6. Push a flat CallFrame for the coroutine body (this updates baseReg_ and currentRegs_ to point into the flat register file).
  7. Jump to entry point (first resume) or saved resumePC_ (subsequent resumes).

Yield (coroutine → caller)

  1. Copy the coroutine body's registers from the flat register file into the CoroutineFrame save slot.
  2. Save resume PC and GC map index into CoroutineObj and CoroutineFrame.
  3. Mark coroutine as Suspended.
  4. Wrap yielded value in Option<T>.some.
  5. Restore caller context from CoroutineObj fields (baseReg, frameCount, currentCoroutine, currentCoroFrame).
  6. Point vm.currentRegs_ at the flat register file at the restored baseReg_.
  7. Write Option<T>.some(value) to the caller's result register.
  8. Clear stale caller references in the CoroutineObj (prevents false GC retention).
  9. Tail-call to caller's return PC.

Done (coroutine → caller)

Same as yield, but without copying registers to the save slot. Sets state to Done, clears topFrame_, and writes Option<T>.none.

5.5 Register Model

The VM's reg(i) accessor uses an indirection pointer currentRegs_:

inline Word& reg(u16 i) { return currentRegs_[i]; }

currentRegs_ always points to regs_ + baseReg_ (the flat register file). This is true for both normal execution and coroutine execution. The pushFrame and popFrame functions unconditionally update currentRegs_ when they change baseReg_.

This means all opcodes — including op_call, op_return, and all others — work identically whether inside a coroutine or not. No coroutine-aware branching is needed in the call/return path. The only coroutine-specific opcodes are op_coro_create, op_coro_resume, op_yield, and op_coro_done.

6. Garbage Collection

The incremental tri-color GC must correctly track object references inside suspended coroutines. This requires three mechanisms: GC maps, frame scanning, and write barriers.

6.1 GC Maps

Each CodeBlock stores a vector of GC maps — one per yield point in the coroutine function:

std::vector<std::vector<u16>> coroGCMaps_;

Each inner vector lists the register indices that hold Obj* values at that particular yield point. These maps are built at compile time by inspecting the local variable scopes at each yield statement.

For example, if a coroutine has two local variables — an Int in register 0 and a String in register 1 — the GC map at a yield point would be [1] (only register 1 holds an object pointer).

6.2 Frame Scanning

CoroutineFrame::gcScan() uses the GC map to mark only the registers that hold live object pointers:

void gcScan(GC* gc, i32& ioWordsToScan) {
    Obj::gcScan(gc, ioWordsToScan);
    if (caller_) { gc->mark(caller_); --ioWordsToScan; }
    // Scan Obj* registers using GC map
    if (codeBlock_ && gcMapIndex_ < codeBlock_->coroGCMaps_.size()) {
        auto& map = codeBlock_->coroGCMaps_[gcMapIndex_];
        for (u16 idx : map) {
            if (idx < numRegs_ && regs_[idx].o) {
                gc->mark(regs_[idx].o);
                --ioWordsToScan;
            }
        }
    }
}

CoroutineObj::gcScan() marks the top frame, any caller coroutine/frame references, and scans Obj* arguments using the function's type information:

void gcScan(GC* gc, i32& ioWordsToScan) {
    Obj::gcScan(gc, ioWordsToScan);
    if (topFrame_) { gc->mark(topFrame_); }
    if (callerCoroFrame_) { gc->mark(callerCoroFrame_); }
    if (callerCoroutine_) { gc->mark(callerCoroutine_); }
    // Scan Obj* args using funcType_
    for (u16 i = 0; i < numArgs_; ++i) {
        if (funcType_->argTypes_[i]->isObjType() && args_[i].o)
            gc->mark(args_[i].o);
    }
}

6.3 Write Barriers on Resume

The incremental GC uses tri-color marking. Between a yield and the next resume, GC heartbeats may have scanned the suspended frames to black (fully scanned). When the coroutine resumes and writes new object pointers into those black frames, the tri-color invariant would be violated.

To prevent this, op_coro_resume applies a write barrier to the save-slot CoroutineFrame before resuming:

// Write barrier on CoroutineFrame (safety for incremental GC)
vm.gc().writeBarrier(frame);

This re-greys the save slot, ensuring it will be re-scanned by the GC and any new object references will be discovered. Since each coroutine has exactly one save slot, this is always O(1).

Why this is correct: After write-barriering, the save slot is at most grey, so the next time the marker visits it (either via an in-event op_safepoint poll or via a between-event nrtTick) it will re-scan whatever object pointers the resumed body has written. Active call frames — including the coroutine's resumed frame — are scanned precisely via stack maps, so any new references created in the body are visible to the collector even before the next yield.

6.4 Root Marking

The tracing collector's root set has four substates, each with its own cursor so the scan can pause and resume across op_safepoint polls under a deadline budget:

Suspended coroutines are reachable either through a global slot that holds them or through the active-frame walk if the running mutator is mid-resume. Once the CoroutineObj is gray, gcScanByTag (tag-dispatched, non-virtual) transitively marks the topFrame_, the caller chain, and every Obj* argument and saved register, using the function's type info and the per-yield stack map.

7. Opcode Reference

7.1 op_coro_create

Creates a new CoroutineObj in the Created state.

WordFieldDescription
0opOpcode handler pointer
1regs[0..2]dst, argBase, argc
2iGlobal index containing the CodeBlock*
3pPointer to CoroutineType*

Behavior: Allocates a CoroutineObj with space for argc arguments. Copies argument values from registers argBase..argBase+argc-1 into the coroutine's args_[] array. Stores the coroutine object in register dst. Advances PC by 4.

Emitted for: calls to coro fn functions (when isCoroCall is true).

7.2 op_coro_resume

Resumes a suspended coroutine, or returns none if it is done.

WordFieldDescription
0opOpcode handler pointer
1regs[0..1]dst, coroReg

Behavior:

Emitted for: calls to next() on a coroutine (when isCoroResume is true).

7.3 op_yield

Suspends the current coroutine and returns a value to the caller.

WordFieldDescription
0opOpcode handler pointer
1regs[0..1]src (value register), gcMapIndex
2pPointer to Option<T> type (EnumType*)

Behavior: Copies the coroutine body's registers from the flat register file into the CoroutineFrame save slot. Saves resumePC_ = pc + 3 and gcMapIndex_ on the save slot. Sets state to Suspended. Creates Option<T>.some(value). Restores caller context (baseReg, frameCount, currentRegs). Writes the some to the caller's result register. Clears stale caller refs. Tail-calls to the caller's return PC.

Emitted for: yield expr; statements inside a coro fn body.

7.4 op_coro_done

Marks the current coroutine as finished and returns none to the caller.

WordFieldDescription
0opOpcode handler pointer
1pPointer to Option<T> type (EnumType*)

Behavior: Sets topFrame_ = nullptr and state_ = Done. Creates Option<T>.none. Restores caller context. Writes none to caller's result register. Tail-calls to caller's return PC.

Emitted for: the implicit end of a coro fn body (replaces op_return_void).

7.5 Unpatched Opcodes

With the save-slot model, no existing opcodes need coroutine-specific branching. op_call, op_return, op_return_void, op_call_lambda, op_tail_call, and op_tail_call_lambda all use the flat register file unconditionally. Since pushFrame and popFrame always update currentRegs_ = regs_ + baseReg_, these opcodes work identically inside and outside of coroutines.

This is possible because yield is restricted to the coroutine body (single-level yield). Normal functions called from within a coroutine always complete before a yield can occur, so their registers do not need to survive across suspension points.

8. Compilation Pipeline

This section traces a coro fn from source text to executable opcodes.

8.1 Lexer

Two keywords are added to the TokenKind enum:

TokenKeywordFile
TokenKind::Corocorolexer.hpp:34, lexer.cpp:170
TokenKind::Yieldyieldlexer.hpp:34, lexer.cpp:171

8.2 Parser

The parser handles two new syntactic forms:

coro fn declaration

When the parser encounters TokenKind::Coro, it consumes the token, expects fn to follow, parses the function declaration normally, and sets isCoroutine = true on the resulting FnDeclNode.

yield statement

When the parser encounters TokenKind::Yield in statement position, it calls parseYieldStmt(), which follows the same pattern as parseReturnStmt(): consumes the keyword, optionally parses an expression, and expects a terminator.

The AST node YieldStmtNode contains a single field value (the expression to yield).

8.3 Type Checker

The type checker performs several coroutine-specific tasks:

  1. Return type wrapping. For a coro fn with declared return type T, the type checker wraps it to Coroutine<T> and stores this as the function's effective return type.
  2. Body return type. Inside a coro fn body, the current return type is set to Void (the body doesn't return values, it yields them).
  3. Yield checking. checkYieldStmt() verifies that:
    • The yield is inside a coro fn body (otherwise: compile error).
    • The yielded expression's type matches the declared yield type.
  4. Call site marking. When resolving a function call:
    • If the target function returns CoroutineType*, the call is marked isCoroCall = true.
    • If the function is next and the argument is a CoroutineType*, the call is marked isCoroResume = true.
  5. Type annotation resolution. Coroutine<T> in type annotations (e.g., function parameters) is resolved by matching the template name "Coroutine" and delegating to coroutineType().

8.4 Code Generator

The code generator tracks three pieces of coroutine state:

FieldPurpose
inCoroutineFn_Whether we're currently generating code for a coro fn body.
currentYieldCount_Counter for GC map indices, incremented at each yield.
currentCoroOptionType_The Option<T> type for the current coroutine's yield type.

Coroutine function body

A coro fn body is compiled identically to a normal function body, with two differences:

  1. The CodeBlock's funcType field is set (used by op_coro_create for GC scanning of arguments).
  2. The implicit terminator at the end of the body is op_coro_done instead of op_return_void.

Yield statement

genYieldStmt():

  1. Collects a GC map by scanning all local variable scopes for registers holding Obj* types.
  2. Appends the map to currentBlock_->coroGCMaps_ and records the index.
  3. Generates the value expression.
  4. Emits op_yield [srcReg, gcMapIndex] [optionType*].

Call site emission

In genCall(), two special cases are handled:

8.5 Builtin Resolution

The next function is registered as a template builtin with a custom resolver:

static bool resolve_next(Compiler& compiler, const std::vector<Type*>& args,
    std::vector<Type*>& pt, Type*& rt, CFun& cf) {
    if (args.size() != 1) return false;
    auto* ct = dynamic_cast<CoroutineType*>(args[0]);
    if (!ct) return false;
    pt = {ct};
    rt = compiler.optionType(ct->yieldType_);
    cf = nullptr;  // codegen handles via op_coro_resume
    return true;
}

Key detail: cf = nullptr signals to the code generator that this builtin has no C function implementation — the codegen must emit op_coro_resume directly. The type checker uses the resolver to determine the return type (Option<T>) and to mark the call as isCoroResume.

9. Source File Map

Summary of all files modified or created for the coroutine implementation:

FileChanges
src/lexer.hppAdded Coro, Yield to TokenKind enum.
src/lexer.cppAdded keyword recognition for "coro" and "yield".
src/ast.hppAdded YieldStmt to Kind enum. Added YieldStmtNode. Added isCoroutine flag on FnDeclNode. Added isCoroCall, isCoroResume flags on CallExpr_.
src/parser.hppDeclared parseYieldStmt().
src/parser.cppAdded Coro case in parseDeclaration(). Added Yield case in parseStatement(). Implemented parseYieldStmt().
src/type_system.hppAdded CoroutineType class (inherits ObjType).
src/type_universe.hppAdded coroutineType() factory declaration and coroutineTypeCache_.
src/type_universe.cppImplemented coroutineType() with interning.
src/value.hppAdded coroGCMaps_ to CodeBlock. Added CoroutineFrame and CoroutineObj classes.
src/value.cppImplemented CoroutineFrame::create() and CoroutineObj::create() factories.
src/vm.hppAdded currentCoroutine_, currentCoroFrame_, currentRegs_. Changed reg(i) to use currentRegs_. Added coroutine accessors and currentFrameNumRegs().
src/vm.cppInitialized currentRegs_. pushFrame/popFrame unconditionally update currentRegs_. Extended markRoots(). Added currentFrameNumRegs() accessor.
src/opcodes.hppDeclared op_coro_create, op_coro_resume, op_yield, op_coro_done.
src/opcodes.cppImplemented four new opcodes (op_coro_create, op_coro_resume, op_yield, op_coro_done). Existing call/return opcodes are unmodified — they use the flat register file for all execution, including within coroutines.
src/type_checker.hppAdded inCoroutineBody_, currentYieldType_, checkYieldStmt().
src/type_checker.cppCoroutine return type wrapping. Yield statement checking. Call site marking. Coroutine<T> type annotation resolution.
src/codegen.hppAdded inCoroutineFn_, currentYieldCount_, currentCoroOptionType_, genYieldStmt().
src/codegen.cppCoroutine function codegen. Yield statement codegen. op_coro_create/op_coro_resume emission at call sites.
src/builtins.cppAdded resolve_next template resolver. Registered "next" builtin template.
src/compiler.hppAdded coroutineType() convenience accessor.
tests/coro_test.xComprehensive coroutine test (16 test sections).
tests/coro_test.expectedExpected output for the coroutine test.