zena:async
import {…} from 'zena:async';
zena:async — futures, the executor, cancellation scopes, and the task/claim layers over them (docs/design/async.md, docs/design/cancellation.md).
The module is a facade: Future/Completer and the executor live
in ./async/ files and are re-exported here by name. The settle
symbols those files share are deliberately NOT re-exported, which is
what keeps a future unreadable except by subscribing (the files
themselves are not importable as modules, so the symbols stay
unnameable outside zena:async).
Deliberately ordinary Zena: no async/gen syntax appears here, so
this module compiles with no bootstrapping problem. The
async/await surface lowers onto these types; from non-async code
they are used directly via callbacks.
Semantics pinned here:
- A future settles exactly once; settling twice throws
(
tryComplete/tryFailreport it instead, for callers racing on purpose). - Callbacks always run from the FIFO microtask queue, never synchronously inside complete()/fail()/then() — the "always async" rule, so completion order is deterministic and re-entrancy surprises are impossible.
Implementation note (compiler limitation worked around, loud when hit): no closures are created inside generic code here — the bootstrap compiler predates per-instantiation closure specialization — which is why the combinators and waiters are written as classes rather than callbacks.
Classes
CancelScope
class CancelScope
A cancellation scope: a level-triggered cancelled flag and a parent
link. Cancelling a scope marks it and every descendant, and every
checkpoint in a cancelled scope raises — however many already have.
Async frames bind to the current scope at creation and test it at
each checkpoint; sync code that wants to stop early polls
currentScope() explicitly.
new()
A child of the current scope, sharing its cancelled level.
isCancelled: boolean { get; }
static detached(): CancelScope
A scope with no parent, wherever it is created: cancelling any
other scope never reaches it, and only its holder can cancel it.
This is the scope for work that must outlive its callers — a
cache's shared fills, for example — where new CancelScope()
would quietly parent to whichever scope happens to be current at
construction.
cancel(): void
Marks this scope and every descendant cancelled. Code between checkpoints is never interrupted: each frame bound to a marked scope raises at its next checkpoint. The root scope cannot be cancelled — code that started before any scope existed belongs to it, and cancelling everything is not a meaningful request.
run<R>(body: () => R): R
Runs body with this scope as the current scope, so async
frames created inside bind to it. Eager start does the
inheritance: a frame's ramp runs synchronously here, reads the
current scope once, and stores it — after creation the frame
never consults it again.
TaskGroup
class TaskGroup
A group of async calls that are cancelled and awaited together.
spawn starts a call and makes it a member: the group holds one
CancelScope, each member's frame binds to that scope, and the
group watches each member's future. cancel cancels every member;
one member's failure cancels the rest; and join gives a future
that completes once every member has settled — failing with the
first member failure, if there was one. A member that ends
cancelled is a recorded outcome, not an error.
new()
isCancelled: boolean { get; }
cancel(): void
Cancels every child (and work they spawned): marks the group's scope, so each child raises at its next checkpoint.
spawn<T>(body: () => Future<T>): Future<T>
Starts body as a member of the group. The call begins here,
synchronously, with the group's scope installed as current,
so the new frame binds to it. The returned future is an ordinary
future — anyone may await it — and the group watches it either
way.
join(): Future<void>
A future that completes once every member has settled — failing with the first member failure, if there was one. Joins while members are outstanding share one future. A join with nothing outstanding gets a fresh, already-settled future: settling and keeping the shared completer instead would break a group that spawns again after going idle, whose next drain would settle it a second time.
static race<A>(bodies: Array<() => Future<A>>): Future<A>
Runs every body as a member of a fresh group and gives the first
settled outcome — value or failure — cancelling the rest. This is
the loser-cancelling form of Future.race: race over
already-started futures ignores its losers, because it does not
own them, while this form starts the candidates itself and so may
cancel the ones that lost. If cancellation instead arrives from
outside — an ancestor scope cancels the group before any
candidate settles — the returned future completes cancelled.
An empty array is refused: its future could never settle.
FutureClaim
class FutureClaim<T> implements Disposable
A future plus a counted claim on the work producing it. Work
spawned on behalf of several consumers should keep running while
any of them still wants the result and stop when none does; holding
an FutureClaim is declaring that want. Releasing the handle —
using, :dispose, or an explicit release() — gives the claim
up, and when the last claim is gone the work's scope is cancelled,
one queue turn later.
Sharing is an explicit split(): the count goes up before the
second handle exists, so claims never pass through zero during a
handoff. Releasing is per-handle and idempotent.
The bare future stays an ordinary value. Holding it confers
awaiting and nothing else: if every claim is released, a bystander
mid-await gets the cancellation raise at its checkpoint. Awaiting
without holding a claim is awaiting at the claim holders' pleasure
— which is the honest version of the contract, and why nothing on
Future itself can cancel.
new(interest: Interest)
static spawn<A>(body: () => Future<A>): FutureClaim<A>
Starts body as owned work: the call runs under a new scope — a
child of the current scope, so enclosing cancellation still
reaches it — and the returned handle carries the one initial
claim. Work that must not inherit the caller's cancellation runs
the spawn under CancelScope.detached().
split(): FutureClaim<T>
A second claim on the same work. Throws if this handle was already released — a released handle has nothing to share.
release(): void
Gives this handle's claim up. Releasing the last claim cancels the work's scope one queue turn later. Idempotent, per handle.
Future
class Future<T>
new()
isCompleted: boolean { get; }
Whether this future has settled. The one synchronous observation
kept public: runtime infrastructure needs it (a Parker deciding
whether a wake is already armed — zena:time/p3.zena), and unlike
the settled STATE it does not let a result channel branch on an
outcome it should only learn by subscribing.
async then<R>(onValue: (value: T) => R, onError: (error: Error) => R | null = null): Future<Awaited<R>>
A future of this future's value, transformed: once this settles
with a value, the derived future settles with onValue(value) —
and a callback result that is itself a future CHAINS, the derived
future settling the way the inner one does (Awaited<R> is the
type of that flattening, one level). A failure propagates
untransformed unless onError is given, whose result then
RECOVERS the chain; a callback that throws fails the derived
future with what it threw, and is NOT caught by its own
onError, which handles only this future's failure. Either
callback runs from the microtask queue, never synchronously
here.
Cancellation is not an error and has no callback: if this future
completes cancelled, the derived future completes cancelled too,
and neither callback runs. Reacting to a cancellation belongs to
the scope that caused it, or to a finalizer (cancel, using) in
the work being unwound — never to a result channel, where a
handler could swallow the unwind (docs/design/cancellation.md).
static of<A>(value: A): Future<A>
An already-resolved future.
static failed<A>(error: Error): Future<A>
An already-failed future.
static all<A>(futures: Array<Future<A>>): Future<Array<A>>
A future of every input's value, in input order regardless of the order they settle in — or of the first failure to arrive.
The failure is reported as soon as it happens, without waiting for the other inputs: once one has failed no combined value can exist, so waiting could only delay the answer, and would hang outright if another input never settles. Inputs that settle after that are ignored — they lost a race that was already decided, which is not an error.
With no inputs the result is an empty array. It still settles
through the microtask queue like everything else (the always-async
rule), so all never runs a continuation before its caller
returns.
Futures are eager, so the inputs are already running when they get here: this waits for them, it does not start them.
static race<A>(futures: Array<Future<A>>): Future<A>
A future settling as the first input to settle does — with its value, or with its failure. A failure wins a race exactly the way a value does; use it when the first answer is the answer, and note that the losers keep running (nothing here cancels them; that waits on cancellation).
Throws on an empty array rather than returning a future. A race with nothing in it can never settle, and a future that never settles surfaces much later as a deadlock report naming the awaiting code, so the mistake is refused where it was made.
static async allSettled<A>(futures: Array<Future<A>>): Future<Array<Outcome<A, Error>>>
A future of every input's outcome, value or failure alike, in
input order. It settles only once every input has — there is no
early exit to take, which is what lets this be ordinary async
code: awaiting the inputs one at a time still finishes when the
slowest input does, because the inputs are already running and a
later input settles whether or not anything is awaiting it yet.
(all and race answer BEFORE some inputs settle, so they need
concurrent observation and stay subscription-shaped.)
Cancellation is not an outcome (no Err is minted for it, and
Outcome has no third arm): catch cannot observe the
cancellation channel, so awaiting a cancelled input unwinds this
frame and the combined future completes cancelled — being
downstream of cancelled work is inside the blast radius
(docs/design/cancellation.md).
static any<A>(futures: Array<Future<A>>): Future<A>
A future of the first input to settle with a value. Failures
do not win: a failed input just leaves the race, and only when
every input has failed does the result fail — with an
AggregateError carrying each input's failure in input order.
The mirror image of all: all needs every value so one failure
decides it, any needs one value so only total failure does.
Throws on an empty array for race's reason — a future that can
never settle surfaces much later, as a deadlock report far from
the mistake.
AnyCompleter
class AnyCompleter
A completer with its payload type erased.
This exists so a collection can hold completers for different T —
which is what zena:js's handle registry is. A base class
rather than an interface, deliberately and not by preference: a value
held as an interface could not be tested or downcast back to its
concrete type when this was written (x is Plain answered false
through an interface reference, for generic and non-generic classes
alike). That was fixed on 2026-08-13, so the base class is now a
workaround that could be revisited.
Through a base class it works, and it distinguishes specializations
exactly: a Completer<String> held as AnyCompleter tests true for
Completer<String> and false for Completer<i32>, Completer<f64>,
and Completer<SomeOtherClass> alike.
That is what makes the registry need no type tag of its own. The
completer's own specialized type is the tag, checked by the same
ref.cast the language already emits.
fail is declared here because failing needs no payload: a caller
that only knows the erased completer can still reject it.
Completer
class Completer<T> extends AnyCompleter
The boundary object: everything external — host I/O, timers, test harnesses — completes futures through a Completer.
new()
future
complete(value: T): void
fail(error: Error): void
tryComplete(value: T): boolean
Settles with a value, reporting whether it won. False means someone else settled first — not an error for a caller racing on purpose.
tryFail(error: Error): boolean
Settles with a failure, reporting whether it won.
AggregateError
class AggregateError extends Error
The failure of every input at once: how Future.any reports that no
input produced a value, with each input's failure kept in input
order.
Interfaces
Waiter
interface Waiter
One unit of queued work.
The same type serves every role: a suspended async frame (the split pass synthesizes an implementation per async function), a callback bridge, a combinator's subscription, and a bare closure. Because a waiter reads the settled value out of the future rather than being handed it, this needs no type parameter, so one implementation per frame serves every await site in a function whatever it awaits.
run(): void
Parker
interface Parker
A source of completions that arrive from outside the queue, and that the drain loop can wait for (async.md §4, Level 1).
The microtask queue alone can only run work that already exists. A
timer, or host I/O, settles a Completer at a moment nothing in the
queue can cause — so when the queue empties, the drain loop asks the
parker to block until the next such completion is ready.
park() returns true if it delivered a completion (the drain loop
then runs the microtasks that produced and asks again) and false if
there is nothing outstanding to wait for. Returning false is what
ends the drain; a parker that always returns true never terminates.
park(): boolean
Type aliases
Awaited
type Awaited<T>
The type await x has when x: T (docs/design/type-operators.md):
Future<U> becomes U, a union's future arms unwrap, anything
else passes through — one level, matching what one await does.
Functions
raiseCancellation
declare function raiseCancellation(): never
Raises on the cancellation channel: a second exception tag that
catch (e) cannot observe, so cancellation cannot be swallowed —
finally, using, and scope-drop regions still run their cleanup,
and the unwind then continues (docs/design/cancellation.md). This is
the raw raise the checkpoint machinery will emit; until CancelScope
lands there is no reason to call it directly.
currentScope
function currentScope(): CancelScope
The scope new async work would bind to; sync code polls this to stop early without any signature change.
checkCancellation
function checkCancellation(): void
The opt-in checkpoint for CPU-bound work with no natural suspension
point — a parser's token loop, a long numeric pass. Raises on the
cancellation channel if the current scope has been cancelled,
exactly as a real suspension point would, so cleanup (finally,
using, cancel clauses) and propagation work unchanged.
currentScope().isCancelled remains the poll-only form, for code
that stops early without unwinding.
An observe site, not a cause: calling this never opens the whole-program cancellation gate, and under a closed gate the check is a branch never taken.
setParker
function setParker(p: Parker): void
Register the drain loop's park source, replacing any previous one.
scheduleTask
function scheduleTask(task: Waiter): void
Enqueue a task on the microtask queue.
scheduleMicrotask
function scheduleMicrotask(cb: () => void): void
Enqueue a callback on the microtask queue.
drainMicrotasks
function drainMicrotasks(): void
Run microtasks until the queue is empty and nothing external is left to wait for.
With no parker registered this is exactly "drain the queue". With one, an empty queue is not the end: the loop parks, and whatever that delivers is drained in turn, until the parker reports nothing outstanding.
runFuture
function runFuture<T>(f: Future<T>): T
Runs the executor to a standstill and returns f's value, throwing
its failure.
The public way to get a value out of a future from synchronous code,
and deliberately the only one: it cannot answer before the queue has
run, so there is no way to observe whether something settled "early".
If f is still pending once nothing more can happen, that is a
deadlock and it throws saying so.
Top-level only — a driver, a test, main. Calling it from inside a
task would re-enter the drain and break run-to-completion.