zena:async

zena
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/tryFail report 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

zena
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.

Constructors
zena
new()
#

A child of the current scope, sharing its cancelled level.

Properties
zena
isCancelled: boolean { get; }
#
Methods
zena
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.

zena
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.

zena
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

zena
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.

Constructors
zena
new()
#
Properties
zena
isCancelled: boolean { get; }
#
Methods
zena
cancel(): void
#

Cancels every child (and work they spawned): marks the group's scope, so each child raises at its next checkpoint.

zena
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.

zena
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.

zena
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

zena
class FutureClaim<T> implements Disposable
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.

Constructors
zena
new(interest: Interest)
#
Properties
zena
future: Future<T>
#
zena
isReleased: boolean { get; }
#

Whether this handle still holds its claim.

Methods
zena
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().

zena
split(): FutureClaim<T>
#

A second claim on the same work. Throws if this handle was already released — a released handle has nothing to share.

zena
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

zena
class Future<T>
Re-exported from zena:async/future.zena
Constructors
zena
new()
#
Properties
zena
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.

Methods
zena
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).

zena
static of<A>(value: A): Future<A>
#

An already-resolved future.

zena
static failed<A>(error: Error): Future<A>
#

An already-failed future.

zena
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.

zena
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.

zena
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).

zena
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

zena
class AnyCompleter
Re-exported from zena:async/future.zena

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.

Constructors
zena
new()
#
Methods
zena
fail(_error: Error): void
#

Rejects the underlying future. Overridden by Completer<T>.

Completer

zena
class Completer<T> extends AnyCompleter
Extends AnyCompleter
Re-exported from zena:async/future.zena

The boundary object: everything external — host I/O, timers, test harnesses — completes futures through a Completer.

Constructors
zena
new()
#
Properties
zena
future
#
Methods
zena
complete(value: T): void
#
zena
fail(error: Error): void
#
zena
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.

zena
tryFail(error: Error): boolean
#

Settles with a failure, reporting whether it won.

AggregateError

zena
class AggregateError extends Error
Extends Error
Re-exported from zena:async/future.zena

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.

Constructors
zena
new()
#
Properties
zena
failures: Array<Error>
#
2 inherited members
From Error
Properties
zena
message: String
#
Methods
zena
getStackTrace(): String | null
#

Interfaces

Waiter

zena
interface Waiter
Re-exported from zena:async/executor.zena

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.

Methods
zena
run(): void
#

Parker

zena
interface Parker
Re-exported from zena:async/executor.zena

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.

Methods
zena
park(): boolean
#

Type aliases

Awaited

zena
type Awaited<T>
Re-exported from zena:async/future.zena

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

zena
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

zena
function currentScope(): CancelScope

The scope new async work would bind to; sync code polls this to stop early without any signature change.

checkCancellation

zena
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

zena
function setParker(p: Parker): void
Re-exported from zena:async/executor.zena

Register the drain loop's park source, replacing any previous one.

scheduleTask

zena
function scheduleTask(task: Waiter): void
Re-exported from zena:async/executor.zena

Enqueue a task on the microtask queue.

scheduleMicrotask

zena
function scheduleMicrotask(cb: () => void): void
Re-exported from zena:async/executor.zena

Enqueue a callback on the microtask queue.

drainMicrotasks

zena
function drainMicrotasks(): void
Re-exported from zena:async/executor.zena

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

zena
function runFuture<T>(f: Future<T>): T
Re-exported from zena:async/future.zena

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.