Asynchronous code
Use asynchronous functions when work may suspend while waiting for a platform operation or another computation. Noodle keeps success, business failure, and cancellation as distinct typed outcomes.
Async function types
Section titled “Async function types”datatype LoadError LoadFailed(String)end
async func load_count() throws LoadError -> Int do 42endThe declared body result is Int. Callers receive Promise[Int, LoadError].
An async function without throws uses the empty standard-library error set
NoError.
Promise[U, E] is eager and freely shareable: calling an async function starts
its body immediately, every holder observes the same outcome, and the promise
itself carries no cancellation capability. Promise always takes both type
arguments; there is no one-argument spelling.
A function type may carry the same modifier, written
async (Int) -> Promise[Int, LoadError]. It describes a callee that requires
async context and returns exactly the promise written in its result position;
the modifier adds no second promise layer. async () -> Int and
async () -> Async.Scope are invalid, and a synchronous function that returns a
promise has an unmarked type and is a different type from the async function
type with the same parameters and result.
Await a promise
Section titled “Await a promise”.await() is available only inside an async context. It waits for the
promise’s real outcome and yields it; it never substitutes an early
Cancelled for an operation that is still running:
datatype LoadError LoadFailed(String)end
async func load_count() throws LoadError -> Int do 42end
async func doubled_count() throws LoadError -> Int do outcome = load_count().await(); switch outcome case AsyncResult::Ok(value) then value * 2 case AsyncResult::Err(error) then throw error case AsyncResult::Cancelled(_) then 0 endendWaiting produces AsyncResult[T, E] with three constructors:
Ok(T)for success;Err(E)for the promise’s declared business error;Cancelled(CancelReason)when the producer reported cancellation.
Cancellation is not merged into the error set. Waiting creates no authority over the producer: a borrowed promise is neither cancelled nor reparented by a consumer, so a wait can outlive the consumer’s own cancellation request.
Propagate an async outcome
Section titled “Propagate an async outcome”Postfix ! on AsyncResult extracts Ok and propagates both Err and
Cancelled through the enclosing async function:
datatype LoadError LoadFailed(String)end
async func load_count() throws LoadError -> Int do 42end
async func doubled_count() throws LoadError -> Int do value = load_count().await()!; value * 2endUse this form when the current function does not own recovery. Use an explicit
switch when each outcome needs local behavior.
Spawn cancellable work
Section titled “Spawn cancellable work”An ordinary async call has no cancellation capability: it is owned work, but
nothing in the language can request that its scope stop. Async.spawn starts a
callback as the owned body of a new child scope and returns an Async.Task, the
handle that carries the right to cancel that scope:
async func with_deadline() -> Int do task = Async.spawn(|| slow_lookup(7)); _ = task.cancel(); switch task.promise.await() case AsyncResult::Ok(value) then value case AsyncResult::Err(_) then 0 case AsyncResult::Cancelled(_) then 0 endend
async func slow_lookup(key : Int) -> Int do keyendAsync.Task[T, E] is a datatype with a private constructor and read-only
promise : Promise[T, E] and scope : Async.Scope fields. It is deliberately
not awaitable: observe the result through task.promise, and cancel through
task.cancel(). The private constructor is the capability boundary — user code
cannot combine an arbitrary promise with a scope to build a handle — and reading
a handle’s scope grants spawn authority without granting cancel power over
anything else.
cancel() is synchronous and idempotent, requests cancellation of the task’s
scope and its descendants, never its ancestors or siblings, and returns without
waiting. It is a no-op once the task’s promise has fulfilled. Cancellation is a
request, not a forced outcome: the producer’s own Ok, Err, or Cancelled
result is what the promise fulfils with.
Scopes and ownership
Section titled “Scopes and ownership”Async.Scope is an opaque, freely shareable value that grants the right to
start work under one scope. A body obtains its own scope with
Async.current_scope(), and a call that takes a scope answer can receive one as
an ordinary parameter:
async func start_report(scope : Async.Scope) -> Async.Task[Unit, NoError] do Async.spawn(|| write_report(), ?{scope: scope})end
async func write_report() -> Unit do () endAsync.spawn(f) parents the new scope onto the caller’s current scope, so it
requires async context. Async.spawn(f, ?{scope: s}) parents it onto s and is
legal from any body, including a synchronous one. A target that is cancelled,
still closing, or exited never runs the callback: the returned handle’s promise
is already Cancelled carrying that scope’s first reason.
An ordinary async call runs under the scope of its call site, and its return
does not end that scope or cancel work started under it. Only Async.spawn,
async bindings, and the runner’s roots create scopes.
When an owning body exits — normally or with Err or Cancelled — its scope
stops admitting new work, requests cancellation of the work it owns, and joins
that work before publishing the owner’s own result. The join covers unawaited
spawned tasks, ordinary async calls made under that scope, and owned host
operations, and it waits for real termination rather than for a cancellation
notification. Cancelling leftover work during the exit never rewrites the
owner’s Ok or Err. Work that does not cooperate can therefore delay scope
completion indefinitely.
Because the join is unconditional, there is no shielding or privileged cleanup scope. To start cleanup work after cancellation, spawn it onto an explicitly supplied scope that is still active; the cleanup belongs to that target scope and the original scope waits for it only if the program awaits its promise.
Check cancellation
Section titled “Check cancellation”Async.checkpoint() reports the calling scope’s cancellation state
synchronously as an ordinary value:
async func step() -> Unit do switch Async.checkpoint() case AsyncResult::Ok(_) then () case AsyncResult::Err(_) then () case AsyncResult::Cancelled(_) then () end;endWhen the scope has a cancellation request, checkpoint() returns
AsyncResult::Cancelled(reason) with the scope’s recorded first reason;
otherwise it returns AsyncResult::Ok(()). It is a synchronous, context-gated
operation, not an async function: it does not suspend, does not yield to the
event loop, and never forces a return. Async.checkpoint()! propagates
cancellation under the ordinary postfix ! rules, and handling a cancelled
result neither clears the request nor reopens the scope for new work.
A scope that no longer admits ordinary work starts no new ordinary async call:
the call returns an already-fulfilled Cancelled with the scope’s first reason
without running the invoked body, not even its synchronous prefix. The callee
and every explicit argument are still evaluated exactly once and in source
order. Already-running work remains cooperatively cancellable rather than
preempted, and awaiting an existing promise, matching an AsyncResult, and
holding an existing promise are not new production, so they stay legal.
Async program entry points
Section titled “Async program entry points”An entry file may declare async func main() -> Unit. The runner gives it a
fresh root scope and waits for that root to cancel and join everything the body
owned before it publishes the program outcome:
datatype LoadError LoadFailed(String)end
async func load_count() throws LoadError -> Int do 42end
async func doubled_count() throws LoadError -> Int do value = load_count().await()!; value * 2end
async func main() -> Unit do result = doubled_count().await(); switch result case AsyncResult::Ok(value) then Debug.trace(value) case AsyncResult::Err(LoadError::LoadFailed(message)) then Console.println(message) case AsyncResult::Cancelled(_) then Console.println("cancelled") end;endAt the outer entry boundary, success exits normally, an unhandled business
error exits unsuccessfully, and cancellation uses the cancellation exit path.
An async test body receives the same fresh-root treatment. Only runners create
roots: a host callback that starts async work receives or captures a scope and
spawns onto it rather than getting an implicit root.
Async bindings
Section titled “Async bindings”An async binding starts its initializer immediately as the owned body of a
fresh child scope. Its type is AsyncResult[T, E]; a reference to the binding is
legal only inside an async context and waits for that background computation,
and postfix ! extracts its Ok value:
async func load_name() -> String do "Ada"end
async func greeting() -> String do async name = load_name().await()!; name!endThe initializer is checked as an anonymous async body, so it may use .await()
and postfix !. A plain value is lifted to AsyncResult::Ok, and errors
propagated inside the initializer become that computation’s error outcome. An
initializer that already produces an AsyncResult — the bare
promise.await(), or any value of that type — is that computation’s own
outcome, so the binding takes the awaited call’s AsyncResult[T, E] and
promise.await()! is its explicit-propagation spelling.
Multiple bindings start before the rest of the block runs, so they can overlap:
async func load_user() -> String do "Ada" endasync func load_plan() -> String do "Pro" end
async func describe() -> String do async user = load_user().await()!; async plan = load_plan().await()!; user_name = user!; plan_name = plan!; user_name ++ " / " ++ plan_nameendEvery exit from the binding’s lexical block — the normal end, an explicit
return, ! propagation, a labelled exit, or a loop break or continue that
leaves the block — requests cancellation of that scope and joins it before
control leaves. The barrier is unconditional: it does not depend on whether the
binding was ever referenced, and there is no awaited or consumed flag.
Use an async binding when the lexical scope clearly owns the concurrent work. Call an async function directly when explicit task ownership and waiting make the control flow easier to see.
Host operations
Section titled “Host operations”A promise-returning extern func declaration forms the platform boundary
between the language and a host operation. Its companion settles the promise
only after the operation really finished its termination and cleanup work: a
cancellation request delivered to a host hook is a request, not an
acknowledgement. An owned host operation registers with the scope that owns it,
which is what lets an owner’s exit cancel and join it, and it reports
Cancelled instead of starting when its scope admits no new work. A host
rejection, a fulfilment that is not a valid AsyncResult, and a throwing
cancellation hook are runtime failures, never a fabricated business Err or
Cancelled.
Next: Tests, including asynchronous tests.