Goose

Coroutines: stackless, value-typed, scheduled by libraries

Research note, October 2026. Nothing here is implemented, and every piece of syntax is a placeholder. The question was whether Goose should have a coroutine or async feature, what the smallest useful form of one is that fits the language's stack model, and what the languages that already have one learned. The answer proposed here is a stackless coroutine whose frame is an ordinary fixed-size Goose value: started by a struct literal, driven by one builtin, and scheduled by whatever library the program chooses. Section 4 is the design, section 6 the alternatives considered for each part, section 8 the order to build it in.

0. Summary

1. What the feature is for

1.1 The server workload

The feature is expected to be used first for server infrastructure, and that workload fixes what matters:

The same mechanism serves game and UI logic that runs across frames (the patrol example of section 7), and generators.

1.2 The constraints

The brief for this note, restated as tests that the design has to pass:

  1. Useful: writing the state machine by hand must be clearly worse. A hand-written machine (samples/31_mini_doom.goose's Actor keeps timer, pain, death and attacking for this) spreads one sequential procedure over an enum and a match; the compiler can keep the procedure and build the enum.
  2. Thin: the fewest new rules, each one derivable from a rule the language has. Nothing opaque: a reader of the generated C should recognize the struct and the switch.
  3. Cheapest representation: a frame is parameters plus the locals that cross a yield plus a tag, no more, with nothing per resume beyond a call and a switch.
  4. Zero cost to code that does not use it.
  5. Libraries build the rest. Scheduling, I/O integration, timers, cancellation policy and structured concurrency are all choices that a server, a game and an embedded program make differently; the language fixes none of them.
  6. It uses what already makes Goose fast: pools, relative references, flat values through queues, share-nothing workers, in-place construction, whole-program specialization.

2. What Goose already decides

Most of the design follows from the spec; this section collects the rules that force it, with the sections they come from.

2.1 A suspended function can keep neither of its stacks

A Goose activation keeps its fixed-size locals on the native stack or in registers, and its nonfixed locals on data stacks, each on the stack index the compiler assigned it (§1.2, §10.3). A coroutine suspends in the middle of its body and returns control to whoever resumed it; that resumer goes on to run other code, and later resumes the coroutine again, possibly from a different place in a different function.

The only thing a suspended coroutine can own is therefore a fixed-size value: the saved locals. This is the same conclusion the spec draws for recursion, and the same one threads reach from the other direction (a thread_fn gets its own block of stacks because it is a separate program, §11.2).

2.2 The recursion rule is the coroutine rule

§7.8: "no function in the cycle may call into the cycle while one of its nonfixed locals is in scope", with the temporaries rule (a) to (d) for the unnamed nonfixed values of the calling statement. The reason is that each activation would otherwise need a data stack of its own. Replace "call into the cycle" with "suspension point" and the reason holds word for word: each suspended frame would otherwise need a data stack of its own.

The checker already implements this rule (CycleCallTemps, CheckCycleInit, Node::nftemp, implementation.md §3.11), and codegen asserts it (NoStackAcrossCycleCall). A coroutine needs the same checks at a yield, and the same answers for the programmer: declare the scratch buffer in a block that ends before the yield, or own the growable data outside and pass it in by reference or as a pool index. §7.8's advice for cycles ("Growable data that must outlive a recursive call is owned outside the cycle and passed in: references, slices, reusable pools, or one struct of references to several tables, A.6") is the advice for coroutines.

2.3 Stackful coroutines are out

A stackful coroutine (a fiber, a goroutine, a Lua coroutine, Boost Context) keeps a native stack of its own and switches to it; suspension is a register save and a stack-pointer swap, and the body needs no transform. It is the more convenient model: anything can suspend, at any call depth, with no coloring. For Goose it is ruled out, not just unattractive:

Rust reached the same place: its 2014 runtime had green threads and RFC 230 removed them, for the cost of a stack per task and the mismatch with native code; async/await came five years later as a stackless design. The convenient model stays reachable within a bounded budget, as an opt-in "task with its own stacks" (§11.3's dynamic stacks), which is listed as future work in section 9, not as the feature.

2.4 Function values are inlined, so HOFs cost nothing to suspend through

A function value is a compile-time entity, spliced inline into the function it is passed to, which is specialized per function value (§7.6, implementation.md §6.6). So a fn each<F>(xs: i64[:]) { for x in xs { F(x) } } called with a block that yields is, in that specialization, code that yields: its locals (here, the loop index) cross the yield like the coroutine's own, and nothing about each needs to be declared for it. Kotlin needs inline fun to get this; Goose has it already, and it is what keeps the standard library's HOFs usable from coroutines without a second, colored copy (3.4, 3.10).

2.5 Threads share nothing, so thread-per-core is the shape

Goose workers never share mutable memory, each thread program is an instance with its own globals, and only flat values cross through typed queues (§11.2). This decides how coroutines and threads combine:

So the answer to "do Goose's threads make one way of combining the two a shoe-in" is yes: one loop per worker, frames and buffers in that worker's pools, queues for anything that crosses. The language should enable it and fix nothing more; the loop is library code (section 5).

2.6 Pools own what a coroutine cannot

A coroutine cannot own a u8[>..] across a yield (2.1), and a server has a buffer per connection. In Goose this is already answered for every other "one growable thing per item" problem: reusable pools of fixed-size elements, reusable[] slice pools, and relative references (§5.4, §3.9; design/owning_dynamic_data.md). A connection is an element of a pool, its receive buffer a u8[..4096] limited array inside that element or a run in a slice pool, and its frame holds a 4-byte in pool link to it. The frame stays flat and small, the buffer stays where the kernel can write into it while the coroutine is suspended (which is what completion-based I/O such as io_uring and IOCP needs, and what Rust's borrowed-buffer futures make hard, 3.2), and freeing a connection is free on two pools.

2.7 No function pointers, so the scheduler protocol is data

Rust's Future::poll receives a Waker, a (data pointer, vtable) pair that the leaf future stores and calls when the task should be polled again. C++ hands await_suspend a coroutine_handle, resumed through a function pointer in the frame. Kotlin's Continuation has a virtual resumeWith. Goose has no runtime function values (§7.6), so a coroutine cannot hand anyone a callback. It does not need to: the entity that resumed it is the one that will resume it again, and it already knows which frame it holds. What the coroutine must communicate is what it is waiting for, and that is a value: yield Wait.Readable { fd: fd }. The loop that resumed it stores the frame's index against that fd and resumes the frame when the fd is ready, passing back what happened as the resume value. This is cheaper than a waker (no clone, no refcount, no indirect call), it is flat, and it is why the yield carries a typed value in this design (4.3).

3. What other languages did

3.1 The taxonomy

De Moura and Ierusalimschy's "Revisiting Coroutines" (TOPLAS 2009) is the standard reference. A coroutine design chooses on three axes: stackful or stackless (can it suspend from inside a nested ordinary call); symmetric (any coroutine transfers to any other) or asymmetric (a coroutine yields to whatever resumed it); and whether coroutines are first-class values. The paper shows that asymmetric stackful first-class coroutines have the expressive power of one-shot continuations, which is what Lua ships. For Goose, 2.3 fixes stackless; 2.7 and simplicity fix asymmetric; and the frame being an ordinary value gives first-class without any further mechanism.

3.2 Rust

async/await (stable since 1.39, 2019) compiles an async fn into an anonymous state machine implementing Future, polled with a Context carrying a Waker. The parts that are universally judged right and that this design keeps:

What caused the famous disagreements, and what this design does about each:

3.3 C++

C++20 coroutines (cppreference) are a deliberately low-level building block: a function containing co_await, co_yield or co_return is transformed, and everything about it (the frame's allocation, when it first suspends, what co_await does, what the caller gets back) is customized through a promise_type and awaiter objects. The lesson is mostly negative, as the brief anticipated:

3.4 Kotlin

Kotlin's language feature is tiny: the suspend modifier, the Continuation interface, and one intrinsic (KEEP: Coroutines). The compiler turns a suspend function into a state machine with a label and fields for the locals live across suspension points, and a suspend call passes a continuation and returns either a value or COROUTINE_SUSPENDED. Everything else (launch, async, Deferred, dispatchers, channels, Flow, cancellation, structured concurrency) is the kotlinx.coroutines library, and the language has stayed stable since 1.3 (2018) while the library evolved. That split is what the brief asks for, and it worked.

What does not carry over: the state machine object is heap-allocated per suspending invocation (the KEEP says it is created "when suspending function is invoked"), resumption is a virtual resumeWith, and suspendCoroutine allocates once more per suspension, which is why generators needed intrinsics to be affordable. These are acceptable on the JVM and impossible here. What does carry over: cooperative cancellation delivered at suspension points, structured concurrency as a library discipline (Elizarov, "Structured concurrency"; Smith, "Notes on structured concurrency"), and inline HOFs that suspend transparently (2.4 gives Goose this for every HOF).

3.5 Go

Goroutines are stackful (a few kilobytes of stack, grown by copying, contiguous stacks), scheduled M:N by a work-stealing runtime (Vyukov's scheduler design) whose network poller intercepts blocking calls so that ordinary code never notices it is asynchronous. People like it for exactly that: no coloring, no executor to choose, a server is go handle(conn). The costs are the runtime (a GC with precise stack maps to move stacks, a scheduler, a few hundred nanoseconds per switch), shared-memory races, goroutine leaks and the absence of structured concurrency (errgroup and context are libraries). None of the mechanism is available to Goose (2.3), and the architecture it gives (migration between threads) is not the fastest one for servers (3.9). What Goose can offer in its place is that a per-worker loop needs no scheduler at all.

3.6 Zig, twice

Zig's first design (0.10 docs: async functions) is the closest prior art to section 4: @Frame(f) was a type, the frame of f, sized by the compiler; the caller placed it (on its stack, in an array, in an allocator) with async f(args); suspend/resume/await drove it; and whether a function was async was inferred from whether it suspended. The inference was its problem: async-ness was viral and invisible, frames' sizes depended on whole call trees, and the feature did not survive the move to the self-hosted compiler in 0.11 (2023; release notes). Lesson: mark it explicitly (coro fn), and make a coroutine's frame depend on its own body, with called coroutines inlined by a rule rather than by inference (4.4).

Zig's second design (2025; Cro, "Zig's new async I/O"; Kelley's devlog) passes an std.Io interface value into every function that does I/O, as Zig already passes allocators. io.async(f) and .await are methods on it, and the chosen implementation decides whether they block, use a thread pool, or run green threads on an event loop; a stackless implementation is planned. It is a vtable, with the cost admitted ("only guaranteed to be removed when a single Io implementation is used"), and the green-thread implementation needs stack switching, which 2.3 rules out here. The idea that does transfer is the scheduler as an explicit parameter: in Goose that is a static function value or a module, resolved at compile time, so the vtable cost never arises.

3.7 C#, Swift, Python, Lua, Java, OCaml

3.8 Coroutines in C without a compiler

Duff's device (1983), Tatham's "Coroutines in C" (2000) and Dunkels' Protothreads (2006) implement stackless coroutines in plain C: a switch on a saved line number jumps into the middle of the function, and any state that must survive is kept in a struct the programmer declares. Protothreads run sensor-network nodes in two bytes of state per thread. They show what the lowering of 4.12 is, and exactly what a compiler adds over them: it hoists the locals that cross a yield into the struct and reloads them, and it checks that nothing else was needed. That is the entire "convenience" of the feature, and it is enough to make the hand-written version clearly worse (1.2).

3.9 The fastest servers

Seastar (C++, ScyllaDB), glommio (Rust, io_uring), monoio (Rust, io_uring) and embassy (Rust, embedded) share the shape of 2.5: one event loop per core, no sharing between cores, explicit message passing, tasks that are plain values, and completion- or readiness-based I/O driven directly by the loop. io_uring is the Linux API they target; its completion model wants buffers that outlive the submitting task's suspension, which pools give (2.6). Goose's thread_fn plus typed queues is this architecture's substrate already; the coroutine is the missing per-connection piece.

3.10 What to take

Lesson From In this design
State machine, lazy, inline-nested, statically sized Rust, C#, C++ 4.1, 4.4, 4.12
Overlap non-simultaneous locals in the frame Rust 4.12
Frame placed by the caller, type named by the function Zig 0.10 4.1, 4.2
Mark coroutines explicitly, never infer Zig 0.11's removal 4.1
Keep the language surface tiny, the library rich Kotlin 4.13
Cancellation delivered at suspension points, as a value Kotlin, Python 4.10
No self-borrows across suspension, hence no Pin Rust's pain 4.5 R4
No callback-shaped waker; the resumer knows the frame Rust's Waker, C++ handles 2.7, 4.3
One yield, one resume, one value each way C++'s protocol soup 4.3
Protocol logic sans-I/O; only waiting is colored Python, Rust ecosystems 4.13, 5
Thread-per-core, share-nothing, pools own buffers Seastar, glommio, monoio 2.5, 2.6, 5
HOFs suspend transparently Kotlin inline 2.4, 4.6
Frames as values can be copied, sent, stored Protothreads, embassy 4.9

4. The design

Syntax is a placeholder throughout; section 6.9 lists the alternatives.

4.1 Declaration: a function that is also a type

coro fn serve(c: Conn&<u32 in conns>) -> bool resume Event {
    …                         // a body that may yield
}

coro fn declares two things under one name:

Two more clauses can follow the result types:

Y, X and the results are fixed-size. A coroutine that produces growable data appends it to a buffer it is given, as a cycle function does (§7.8); it cannot be a [>..] result built at the resumer's destination, since the resume that completes it is not statically known from the ones that do not (6.8). A coro fn is not extern, export, thread_fn or recursive; checkfree and simd apply as to any function; inline means nothing, since what gets inlined is the resume.

4.2 Starting: a literal, and nothing runs

var task = serve { c };                   // a frame, suspended before its first statement
tasks.push(serve { c: conns.alloc_ref(Conn { fd: fd, .. }) });

A frame is constructed by the struct literal of its type: the parameters positionally or by name, defaults as field defaults, built in place like any struct (§4.2, §4.3). Nothing of the body runs. This is Rust's and Python's laziness, chosen because it is also the thinnest: starting is a store of the arguments and a zero tag, and no code can run that the owner of the frame did not ask for. The parameter fields are readable from outside (task.c), and writable only if declared var, as let fields are (§3.2).

4.3 yield and resume

let w, suspended = resume(task, Event.Ready);   // runs to the next yield or to the end

That is the whole runtime surface: one way in, one way out, a value each way. Every protocol of section 5 is spelled with these. The names are builtins so that resume can be specialized per frame type without a declaration; by UFCS, task.resume(ev) works too (§7.1).

4.4 Calling a coroutine from a coroutine

coro fn read_line(c: Conn&, out: u8[..4096]&) -> bool resume Event { … }

coro fn serve(c: Conn&) resume Event {
    while read_line(c, c.line) { handle(c.line[..]); }
}

A call of a coro fn from a coroutine body runs the callee to completion inside the caller's state machine: each of the callee's yields is a yield of the caller, each resume of the caller continues the callee, and the call's value is the callee's result. This is Rust's .await and Python's yield from without a keyword, and Kotlin's suspend call. The callee's yield type must be the caller's (or the caller's is inferred from it), and its resume type must be the caller's or absent (it ignores the value).

The callee's locals that cross its yields are part of the caller's frame, so the frame of a coroutine is sized by its whole call tree, statically. That is why a coroutine cannot be recursive: there is no bound. A coroutine can call ordinary recursive functions between yields, and an ordinary function can call a coroutine only by starting it as a value and resuming it (4.3). Whether the callee's code is spliced into the caller's segments (one tag, one switch) or kept as a separate resume function with a tag of its own (one extra switch per level per resume) is an implementation choice per call site, like inlining; the community sketch measured the difference at 45% on a patrol benchmark (section 7), so the spliced form is the default and the nested form is for large callees with many callers.

4.5 Rules at a suspension point

A suspension point is a yield, and every call of a coro fn from a coroutine, in the body or in a block it passes to a HOF (4.6).

Everything else a function may do, a coroutine may do between two yields: grow pools and buffers through references, declare and use scratch buffers in inner blocks, call anything, return … from within the coroutine (4.7), spawn workers, block on qget (a logic hazard, not an error).

4.6 Blocks, HOFs and nested functions

A block written in a coroutine body may yield. The function it is passed to is specialized for that block (§7.6), and that specialization becomes part of the coroutine: R1 to R4 apply to the HOF's own locals at the block's yields, as they would to the coroutine's, and its locals that cross join the frame. So each_rev(xs) { yield Wait.Any; } works with std's ordinary each_rev, whose only local across the yield is its loop index. The HOF itself stays an ordinary fn: it contains no yield, it only runs a block that does. A function that yields in its own text is a coro fn, so the sketch's wait_until is written coro fn wait_until<F>() { while !F() { yield Wait.Any; } } and called as wait_until { ready(c) }; a coro fn with a function-value parameter can be called but not started, since a literal has nothing to bind F to. A block that yields can only be passed to a function whose own locals and results obey the rules at that point (R1, R2: fixed results, no nonfixed local in scope), which the specialization checks; a block passed into a recursive cycle cannot yield (R7). return inside such a block returns from the lexically enclosing coroutine, as it returns from the enclosing named function today (§7.6).

A nested fn declared in a coroutine body may not yield; a nested coro fn may, and is called as any coroutine is (4.4). Function values that are named coroutines (each_rev(xs, step) where step is a coro fn) follow the same rule as blocks.

4.7 return … from, defer and the other exits

4.8 Lifetimes: the frame is a holder

A frame type is a struct with reference fields wherever its parameters or hoisted locals are references, slices, or values holding them. So it is "implicitly generic over those fields' roots" (§9.2) and checked as any such struct: storing a frame into a pool, a local or a global is a store of what it holds, under the store rule; a frame in a global pool holds only references that outlive globals (pools, other globals, static data), which is the server's shape (5); a frame built from a reference to a local of main's loop can live in that loop's locals and nowhere deeper. The read-back rules (§9.5) give a frame read out of a pool the roots of the pool's candidates, and in pool links stay exact wherever the frame is (§3.9), which is why the server example links its connection that way. A frame rooted at a grow-shrink array (its c points into a [>..<]) is held by variables only (§5.2), like any such value.

Nothing here is new: it is what a struct { c: Conn&, n: i64, state: u8 } would already be subject to. The one coroutine-specific addition is R4, which keeps the frame's own storage out of the roots of what it holds.

4.9 Copying, flatness and threads

Because of R4 a frame holds no reference into itself, so it is a plain fixed-size value:

4.10 Finishing, re-entry, abandonment

4.11 Generics and checking order

A generic coro fn step<T>(…) is monomorphized like any function, and its frame type is per instantiation like a generic struct's. The frame's layout depends only on the parameter types and type arguments: the hoisted locals' types follow from the body with concrete parameter types, whatever the roots. So the layout is fixed when the type is first used (a literal, a field, an array element), by checking the body once standalone, as CheckUnreached checks a function with no caller (implementation.md §3.1). Each resume site then specializes the body for real, as a call of fn serve_resume(f: serve&, x: Event) -> Wait, bool with the frame's roots, writability and in pool provenance, so that the body's stores and shrinks are checked for the storage that frame actually names. Two frame instances with different root bindings are distinct for checking and identical in layout, as reference-holding struct instances are (§9.2). A frame type with a function-value parameter has no literal (4.6).

4.12 Lowering to C, and what it costs

The frame is a packed C struct; the body is one C function with a switch over the tag that jumps to the label after the yield it was suspended at (the Protothreads lowering, 3.8, which is legal C: a goto may enter a loop body, and the generated C already declares every aggregate local at the top of the function, implementation.md §6.7). Sketch for a body with one yield that keeps n live:

typedef struct serve_g { uint32_t c_g; uint8_t gs_tag; int64_t n_g; } serve_g;

static bool serve_resume(serve_g *f, Event_g ev, Wait_g *out, int64_t gs_sp) {
    int64_t n;
    switch (f->gs_tag) {
        case 0: goto L0;
        case 1: n = f->n_g; goto L1;             // reload what is live after yield 1
        default: gs_fail_finished();
    }
L0: for (;;) {
        n = net_recv(…);
        while (n == AGAIN) {
            f->n_g = n; f->gs_tag = 1;           // save what is live after the yield
            *out = (Wait_g){ .tag = READABLE, .fd = … };
            return true;                         // yield
        L1:
            if (ev.tag != READY) { f->gs_tag = DONE; return false; }
            n = net_recv(…);
        }
        …
    }
}

What it costs, per operation:

Operation Cost
start (serve { c }) the parameters stored, a zero tag
resume one direct call (often inlined), one switch, the loads of the locals live at that point
yield the stores of the locals live after it, a tag store, the yield value in registers, a return
finish a tag store, the result store, a return
a called coroutine nothing per call when spliced; one extra switch per level per resume when nested
frame size parameters + the largest set of locals live across one yield (+ result) + 1 tag byte
non-users nothing: no runtime state, no hidden parameters, no change to any other function

This is the cost of the hand-written state machine, and the community sketch's measurement (section 7) found a hand-lowered coroutine within 10% of one when the call tree was spliced into a single tag.

4.13 What stays out of the language

Each of these is a library, and section 5 shows the first of them:

5. A server on top

A sketch of the layering, with a hypothetical net module of extern fn sockets (there is none yet; section 8 lists it as a prerequisite track). Names are placeholders.

import std;
import net;        // hypothetical: nonblocking sockets and a readiness poll as externs

// Connections live in a pool. The buffer is a limited array inside the element,
// so the kernel can write into it while the coroutine is suspended (§5.3).
struct Conn { fd: i64, inbuf: u8[..4096], outbuf: u8[..4096] }
reusable var conns: Conn[>..] = [];

// The protocol between coroutines and the loop is two flat ADTs the library
// defines. A different loop (io_uring, IOCP, a game's frame tick) defines its own.
enum Wait  { Readable { fd: i64 }, Writable { fd: i64 }, Timer { deadline_ns: i64 }, Any }
enum Event { Ready, Timeout, Hangup, Cancel }

// Waiting is the only colored code. recv appends into the limited array and
// returns the byte count, 0 at end of stream, or net::AGAIN.
coro fn fill(c: Conn&<u32 in conns>) -> bool resume Event {
    loop {
        let n = net::recv(c.fd, c.inbuf);
        if n != net::AGAIN { return n > 0; }
        guard (yield Wait.Readable { fd: c.fd }) == Event.Ready else { return false; }
    }
}

coro fn flush(c: Conn&<u32 in conns>) -> bool resume Event { … }   // the same for Writable

// The handler is straight-line code; the parser is an ordinary function over
// slices (sans-I/O), usable from blocking code just as well.
coro fn serve(c: Conn&<u32 in conns>) resume Event {
    loop {
        guard fill(c) else { return; }
        let consumed = http::respond(c.inbuf[..], c.outbuf);   // parses, writes a reply
        guard consumed > 0 else { continue; }                   // need more bytes
        drop_front(c.inbuf, consumed);                          // keeps the unparsed tail
        guard flush(c) else { return; }
    }
}

// The loop. Frames live in a pool beside the connections; the loop keeps a
// table from fd to frame index for the events the kernel hands back.
reusable var tasks: serve[>..] = [];
var by_fd: i64[>..] = [];                                 // fd -> index into tasks, or -1
var timers: Deadline[>..] = [];                           // a heap of (deadline, task)
var ready: i64[>..<] = [];                                // tasks to resume next round

fn run(listen_fd: i64) {
    loop {
        for ev in net::poll_wait(…) {                      // (fd, readable/writable/hangup)
            let i = by_fd[ev.fd];
            let w, suspended = resume(tasks[i], if ev.hangup { Event.Hangup } else { Event.Ready });
            guard suspended else {
                conns.free(conns.index_of(tasks[i].c)); tasks.free(i); net::close(ev.fd);
                continue;
            }
            match w {
                Readable r => net::watch(r.fd, net::READ, i),
                Writable r => net::watch(r.fd, net::WRITE, i),
                Timer t    => timers.push(Deadline { at: t.deadline_ns, task: i }),
                Any        => ready.push(i),
            }
        }
        … accept new connections into conns and tasks; fire due timers …
    }
}

// Thread-per-core: each worker is a program instance with its own copies of
// conns, tasks and by_fd (§11.2), accepting on its own SO_REUSEPORT socket.
thread_fn worker(listen_fd: i64) { run(listen_fd); }

fn main() {
    for i in hardware_threads() { thread_spawn(worker, net::listen(8080, true)); }   // SO_REUSEPORT
    …
}

What this shows about the fit:

6. Options, with pros and cons

Each part of section 4 had alternatives. The recommendation is marked.

6.1 Stackless (recommended) / stackful / dynamic stacks. Stackless is forced (2.3). Stackful is ruled out by the data-stack model. Dynamic stacks (§11.3, a task owning a block of data stacks, switched by setting gs_stks) would lift R1 for a bounded number of heavyweight tasks and is the community sketch's suggestion for later; it costs address space per task and a stack switch per resume, and it should be an opt-in on the declaration (coro fn … with stacks), never the default. Not in v1.

6.2 The frame type: named by the function (recommended) / co<f> / a user struct. Naming the frame after the function (Zig's @Frame(f) in spirit, 3.6) gives the type a name to put in pools and fields, a literal to start it with, and fields for the parameters, at no new type-system concept. co<f> (the sketch) needs a type expression that names a function, which nothing else in Goose has. The Protothreads form, where the user declares the state struct and the compiler only checks that nothing else crosses a yield, is the thinnest of all and keeps the layout entirely in the user's hands (packed fields, varints), but it gives up the one convenience the feature exists for (3.8); it remains available as a style, since a coroutine whose locals never cross a yield has a frame of just its parameters. A --frames listing (section 8) tells the programmer what was hoisted.

6.3 Typed yield and resume values (recommended) / bare suspend() with fields. With bare suspension, a coroutine communicates with its resumer through the frame's parameter fields, which is enough for a game tick that polls every frame but not for a server, where the loop must learn what each of 10⁵ tasks waits for without asking each one (2.7). A typed yield costs nothing (the value is returned in registers) and gives generators for free. The resume value can be emulated through a var field, so it is the more optional half; it is kept because cancellation and completion results (a byte count from io_uring) are naturally resume values, and because a value in registers beats a store and a reload through the frame.

6.4 No self-references across a yield (recommended) / self-referential, non-copyable frames. The sketch chose the second: build in place, never copy, and the outlives rule keeps references to the frame's own locals valid. Goose can express "fixed but not copyable" already (a value holding self-relative references, §3.9), and a reference into the frame's own locals is self-relative in nature. That form allows let s = local[..n]; yield; use(s), and it costs: the frame cannot be copied, popped, sent or serialized, the locals must live in the frame in place (addressed through f-> in the hot path), and the layout cannot overlap slots whose addresses were taken. R4 keeps the frame a plain value and the hot path in registers, at the price of one error message that points at the fix. A later relaxation can keep both: a local whose address crosses a yield lives in the frame in place, and references to it are rooted at the frame's storage (exact when the frame is a variable, the pool's when it is an element), while the frame stays copyable because TODO 16's "whole-region copy" is exactly what copying a frame is.

6.5 Lazy start (recommended) / run to the first yield at construction. Kotlin and C++ (by default) run eagerly to the first suspension. Lazy is thinner (a literal), gives the owner control over when work happens, and is what Rust's experience endorses; the cost is that a coroutine that wants to do something immediately needs one resume, which a library start does.

6.6 Spliced call trees (recommended by default) / nested frames. 4.4 and 4.12. Splicing duplicates a callee's code per call site, like inlining, and gives one switch; nesting shares code and costs a switch per level. Let the optimizer decide by size, as it does for calls.

6.7 A std Wait/Event convention / fully generic coroutines. Rust's Waker lets any future run on any executor; a typed yield ties a library coroutine to the Wait type it constructs. The recommendation is a small conventional io::Wait/io::Event pair in the standard library that its reference loop consumes, so that library coroutines and loops interoperate, with the language itself indifferent. A loop with other needs defines its own types and adapts. This is the explicit trade of dynamic dispatch for flat data, and it is the same trade Goose makes everywhere else.

6.8 Fixed-size results and yields only (recommended) / nonfixed results. A [>..] result is built at its destination by the call that produces it (§7.3); for a coroutine, that is one of many resume calls, and the destination would have to be reserved across all of them. The spec's idiom for recursion (build into a buffer passed by reference, §7.8) covers it. Revisit if real code wants coro fn lines() -> u8[>..].

6.9 Syntax. coro fn beside thread_fn and recursive fn; the sketch's suspend_fn is the other obvious spelling. yield is universal. resume/result as builtins (like thread_spawn/thread_wait), reached by UFCS as members. The resume X header clause is the least settled part: alternatives are a marked parameter (ev: resume Event) or an attribute list. await as a keyword is deliberately absent: a call is a call.

7. The community suspend_fn sketch

bitspittle's suspend_fn proposal (October 2026, read after the design above was drafted, as the brief asked) arrives at the same core from the Kotlin side: stackless; §7.8's rule at suspend(); a called suspend function's state stored inside the caller's variant, "a tree of enums with a size that is known at compile time", as Rust lays futures out; co_start that runs nothing; co_resume -> bool; everything else ( await, await_all, run_blocking, a scheduler) as library code. Its patrol example, a guard that walks, rests and walks back while the game keeps drawing, is a good second use case beside the server, and its before and after (four match arms and a flag, against four straight lines) makes the case of 1.2(1) better than prose does.

Its measurement is the most valuable part: 10,000 hand-lowered patrols for 10,000 frames on an Apple M3 Pro cost 0.88 ns per guard per frame as a hand-written machine, 1.28 ns as a nested lowering (a tag per level), and 0.97 ns with the levels merged into one program counter; 22, 48 and 24 bytes of state. So a coroutine costs what the machine it replaces costs, if the call tree is spliced (6.6), and the nested form is 45% slower. That number is what made 4.4 and 4.12 default to splicing. (The benchmark lives in that fork, bench/suspend_fn, not in this repository.)

Where this note differs, and why:

suspend_fn sketch This note
suspension carries nothing; the resumer polls a predicate each tick a typed value each way (4.3), so a loop with 10⁵ tasks learns what each waits for (2.7, 6.3)
frame co<f>, built in place, never copied, not flat a struct named after the function, copyable and flat when its fields are (4.9, 6.4)
references to own locals across a suspension allowed, hence non-copyable forbidden (R4), hence a plain value and a register-resident hot path
threads a coroutine cannot cross a flat frame can be queued to another worker (4.9)
blocks in HOFs that suspend open question allowed; the HOF's specialization joins the coroutine (4.6)
return … from open question within the coroutine's call tree, as today; never past resume (4.7)
generators open question the same machinery (4.3)
game-frame use the example works unchanged: resume(p, Event.Tick) per frame, with yield Wait.Any for "next frame"

Its open question "is a fixed but not copyable kind of value new to the type system" is answered by R4 (nothing non-copyable is needed) and, for the relaxation of 6.4, by §3.9's values with self-relative references.

8. Steps

  1. Settle the spec text. A new §7.15 (or a section of its own) with 4.1 to 4.11 as rules, R1 to R8 numbered, placeholder syntax decided, and the Wait/Event convention noted as a library matter. Add TODO items for 6.1, 6.4's relaxation and 6.8.
  2. Measure the server shape by hand, as the sketch measured the patrol: a hand-lowered C echo loop over 10⁵ pooled frames with a poll-based reactor, in bench/coro/, against the same program in Rust/tokio, Go and plain C with epoll. This fixes the target before anything is built and tests the claim of 1.1 that the per-event cost is a switch and a few loads. Replicate the patrol numbers on this machine too.
  3. Checker. Parse coro fn, the yield/resume clauses and the yield expression; the frame type as a struct instance built from the parameters plus the hoisted locals (R3's syntactic liveness, reusing the shrink rules' blockpos scan); R1 and R2 by reusing CycleCallTemps, CheckCycleInit and Node::nftemp with yields as the marked calls; R4 as the return check on roots at each yield; R5 to R8 as the simple rejections they are; the resume/result builtins specialized per frame type and roots (4.11); error messages that name the local and the yield.
  4. Codegen. The frame struct beside the other generated types; one resume function per frame instantiation with the switch and labels, copy-in/copy-out at the segment boundaries; splicing of called coroutines first (it needs no second tag), the nested form after, chosen by the optimizer's inlining policy; gs_sp and the watermark discipline unchanged. --frames listing each frame's size and hoisted locals, and the LSP's hover showing the same.
  5. Tests. A corpus entry per rule (each error message), the patrol and the echo loop as samples, a generator, a yielding block through std's HOFs, the R2 rejection of exception::try around a yield, a frame through a queue, a from_bytes of a frame pool, re-entry and finished-resume aborts in debug.
  6. Networking track (independent of 1 to 5, and a prerequisite for the server): sockets, nonblocking I/O and a readiness poll in os or a new net module as extern fns over the runtime, portable (poll) first, epoll/kqueue/IOCP next, io_uring when the completion-shaped protocol of 5 is wanted.
  7. The reference loop in std: io::Wait, io::Event, a reactor with timers, run_blocking, and the thread-per-core skeleton of section 5 as a sample. Then the server benchmark of step 2 in Goose, which is the measurement the brief's "absolute fastest" claim stands or falls on.
  8. Later: overlapped frame slots (4.12), the 6.4 relaxation, for x in gen, dynamic-stack tasks (6.1), nonfixed results (6.8).

9. Open questions

References

Spec and implementation sections referred to above: §1.2 memory model, §1.3 the stack invariant, §4.3 in-place construction, §5.4 reusable arrays, §7.6 static function values, §7.8 recursion, §7.9 return … from, §9.2 roots, §10.3 stack assignment, §11.2 concurrency, §11.3 dynamic stacks; implementation.md §3.11 recursion, §6.2 stack assignment, §6.6 calls and return from, §7 the runtime; owning_dynamic_data.md, scope_exit_cleanup.md.