std/task
std/task
Section titled “std/task”std/task — cooperative single-threaded task runtime (Phase-2 core). Backend-independent core of Fern’s concurrency model (docs/ASYNC-IMPLEMENTATION-PLAN.md, docs/ASYNC-IMPLEMENTATION-RESEARCH.md, docs/CONCURRENCY-RESEARCH.md). COLORLESS structured concurrency as STACKLESS continuations driven by a single-threaded readiness reactor — never stackful green threads (which need per-arch assembly and have no WASM story), never a general algebraic-effect system (deferred; effects are the eventual colorless substrate, not the cheapest first step). A task is a STATE MACHINE. Stepping it once either completes it with a result (Done) or suspends it on a reactor token until that token’s I/O is ready, carrying a continuation to resume with the woken value (Wait). The continuation captures the task’s live locals by value — its “stack frame” across the suspension point — AND threads the Reactor, so a resumed task may register a FURTHER wait (multi-await: a sequence of dependent I/Os). Every task — regardless of internal structure — is uniformly a Step, so the runtime needs no generics, no dyn, and no per-backend support. The recursive Step/continuation type compiles + runs unchanged on interp / x86-64 / arm64 and compiles on wasm32 (verified). Phase 2 (this module): multi-await tasks + a real multi-round scheduler, still over an in-memory reactor so the suspend / resume / overlap / re-await model is testable in pure Fern on every backend. Phase 1 (next) swaps the reactor internals for poll(2) / epoll / kqueue (native) and wasi:io/poll (wasm) over real fds — the lang-side API below is the frozen interface that work targets. Phase 3 adds the concurrent { … } / spawn / await surface syntax as a parser-time desugar that EMITS exactly this shape, so user code never writes a state machine by hand. Phase-2 scope: results are i32 (or pointer-sized) — generic Task[T] is gated on self-host generic monomorphization (docs/SELF-HOST-AUDIT.md). The Go compiler already monomorphizes; keeping the runtime concrete keeps it green on the self-hosted path.
enum Step
Section titled “enum Step”pub enum Step { Done(i32), Wait(i32, (i32, Reactor) => (Step, Reactor)),}The uniform task step.
Done(result) — the task finished with this i32 result.
Wait(token, resume) — the task is suspended on reactor token;
call resume(value, rx) once the token’s
I/O completes (with the i32 the I/O yielded
— a byte count, an fd, a status, …) to get
(nextStep, advancedReactor). resume may
register a further wait on the reactor, so
a task can await any number of times.
struct Reactor
Section titled “struct Reactor”pub struct Reactor { next_token: i32, tokens: i32[], values: i32[] }Reactor — the readiness registry that multiplexes pending I/O.
Phase-2 in-memory model: register(value) allocates a token and
queues the value its completion will deliver (simulating a known
I/O result); poll() reports the completed (token, value) pairs.
Phase 1 replaces these fields with a poll(2)/epoll fd-set (native)
or a list<pollable> (wasm) and poll() blocks in the OS until at
least one fd is ready — the public method shapes stay identical.
reactor_new
Section titled “reactor_new”pub function reactor_new(): Reactorreactor_new() — a fresh, empty reactor. Tokens start at 1 so 0 can serve as a “no token” sentinel in later phases.
register
Section titled “register”pub function (rx: Reactor) register(value: i32): (i32, Reactor)(rx).register(value) — allocate a fresh token and queue its
completion value, returning (token, advancedReactor) per the
cursor idiom (docs/CURSOR-IDIOM.md). In Phase 1 value is supplied
by the syscall layer when the fd becomes ready rather than up front.
pub function (rx: Reactor) poll(): (i32[], i32[], Reactor)(rx).poll() — drain the completed (token, value) pairs, returning
(tokens, values, drainedReactor). Phase 2 reports every queued
completion in one round; Phase 1 blocks in the OS poll and reports
only the fds that are actually ready. The scheduler re-polls until
every task finishes, so waits registered mid-run are picked up on a
later round.
pending
Section titled “pending”pub function (rx: Reactor) pending(): i32(rx).pending() — how many registered tokens have not yet been
drained by poll(). Zero means no outstanding I/O.
pub function run(states_in: Step[], rx_in: Reactor): i32[]run(states, rx) — drive a set of already-started tasks to completion on a single thread, multiplexing their waits through the reactor, and return their i32 results in task order.
states[i] is the current Step of task i (what its start function
returned). Every task has already registered its first wait BEFORE
this call, so all that I/O is in flight simultaneously — that
overlap is the fan-out the edge-handler use case needs (issue two
upstream fetches, await both). Each round polls the reactor for
completed tokens, resumes the matching tasks (which may register a
further wait), and repeats until every task is Done.
A task that never completes (its token is never delivered) lands as -1 in the results; with the in-memory reactor that only happens if the deadlock guard trips (no completions but tasks remain). The Phase-1 reactor’s poll blocks until a fd is ready, so a live task always makes progress.
select
Section titled “select”pub function select(states_in: Step[], rx_in: Reactor): (i32, i32)select(states, rx) — run tasks until the FIRST one completes, then
return (winnerIndex, winnerResult) and abandon the rest. This is
the happy-eyeballs / race primitive (docs/CONCURRENCY-RESEARCH.md
Rec §5): fan out to a cache and a primary store, take whichever
answers first.
CANCELLATION is structural: a losing task is simply never resumed
again — its parked continuation (and the live frame it captured) is
dropped when states goes out of scope, and reference counting
reclaims it. There is no separate cancel signal to thread; not
resuming IS the cancel. (The Phase-1 reactor will additionally
close the loser’s fd so the OS stops the in-flight I/O.)
“First” = earliest to reach Done across scheduler rounds, so a task that awaits fewer times beats a deeper one. An already-Done task at entry wins immediately (lowest index). Returns (-1, -1) only if the reactor runs dry with no task completing (in-memory deadlock guard; the real reactor’s poll blocks instead).