1.10. Async / await
Quirrel ships a cooperative async/await runtime built on top of
generators. It lets a function suspend at an await point, return a
Future to its caller right away, and resume later on the same VM
thread when the awaited value is ready.
Note
The runtime must be initialized by the host before any async
function runs (see Async runtime). The standalone Quirrel
interpreter (sq) does this automatically; embedders must call
sqasync::bind and arrange for sqasync::pump to be called.
1.10.1. Background and design notes
Modelled on JavaScript Promise; named Future because the script
surface has no .reject() - faulted state comes only from a throw
inside an async.
await - only valid inside an async function - is how a
caller consumes a Future’s value. A consequence is that any caller
that needs to await a Future (rather than just pass it
through) must itself be async - the requirement propagates outward.
This is the pattern Bob Nystrom describes as “function coloring” in What Color is Your Function?, recommended reading for the analytical framing and the trade-offs the JS-shaped model carries.
1.10.2. Declaring async functions
Prefix async in front of any function declaration form to make it
async-capable. Calling such a function never runs the body synchronously:
it immediately returns a Future (a “task-future”) and the body’s
first step is queued for the next pump().
Statement form:
async function loadStuff() {
let data = await fetch()
return data
}
Expression form (anywhere a regular function expression is legal):
let f = async function() {
return await pending()
}
Lambda form:
let f = async @() "direct"
let g = async @(p) await p
Async methods on a class:
class Counter {
count = 0
async function bump(n) {
let delta = await someSource(n)
this.count = this.count + delta
return this.count
}
}
The body of an async function may contain await (the whole point) and
may return a value or throw: the returned task-future settles
fulfilled with the returned value or faulted with the thrown error.
Returning a Future from an async function does NOT yield a
Future<Future<value>> to the caller: the runtime adopts the
returned future’s settlement (the JS-style “Promise Resolution
Procedure”). return p is therefore equivalent to return await p
from the caller’s perspective, with one fewer suspension:
async function inner() {
let p = Future()
p.resolve(42)
return p // outer task-future adopts p
}
async function outer() {
let v = await inner() // v is 42, not a Future
print(v)
}
If the returned future is still pending, the task-future stays pending
until the inner settles, then mirrors its fulfilment or fault. The
fault path is not unwrapped: throw someFuture faults the
task-future with the Future object as the value (matching JS).
Because await accepts any Future regardless of who returned
it, the natural shape for a callback-to-Future adapter is a sync
function:
function timeoutFuture(sec, value): instance {
let p = Future()
gui_scene.setTimeout(sec, @() p.resolve(value))
return p
}
async function f() {
let v = await timeoutFuture(0.1, "x") // v is "x"
}
The : instance return annotation tells the static analyzer that the
function may return a Future, suppressing the w328 redundant-await
diagnostic at callers. Without an annotation the analyzer still warns
because it cannot infer the body’s return type; annotate, or mark the
helper async function if you prefer the analyzer to know
automatically.
1.10.2.1. Where async is not allowed
On constructors:
async constructoris rejected because a constructor must return the new instance, not a Future.On metamethods:
_add,_unm,_cmp,_tostringand the rest of the metamethod set must return their expected value synchronously, soasyncis rejected on them.
1.10.3. await
await <expr> evaluates <expr> and suspends the async function
until the result is available.
If the result is a
Futurein thependingstate, the current async function parks on it. When the future is later settled, the scheduler queues a resume step:Fulfilled -> the await expression yields the resolved value.
Faulted -> the thrown value is re-raised at the await site (a surrounding
try/catchwill catch it).
If the result is a
Futurealready in thefulfilledorfaultedstate, no parking happens: the resume step is queued and runs at the next scheduler step, typically within the samepump()call.If the result is not a Future, the value is delivered to the await site as-is at the next scheduler step (no transient Future is allocated). This is what makes
await scalara valid no-op.
await is only valid inside an async function. Using it in a
plain function is a compile-time error.
await and partial expressions:
async function compute() {
let r = await a + await b // two suspensions
let r2 = await f(await g()) // nested call
}
Each await is a real suspension point; in-flight expression
operands are preserved across the suspension on the VM stack.
await inside loops works as expected:
async function loop() {
for (local i = 0; i < 3; i++) {
let v = await source(i)
print(v)
}
foreach (idx, item in array) {
let v = await source(item)
...
}
}
1.10.4. The async module
The async built-in module exposes the Future class:
from "async" import Future
1.10.4.1. Future class
A Future is in one of three states: "pending", "fulfilled",
"faulted". State transitions are monotonic: once settled
(fulfilled or faulted), a Future stays in that state and further
resolve() calls are silent no-ops.
- Future()
Construct a fresh pending future. No arguments - there is no JS-style executor callback. Settle it later with
.resolve. A future built this way can only be fulfilled directly; there is noreject. It becomes faulted only second-hand, by adopting a faulted future - for example when you.resolveit with the result of anasynccall whose body threw.
- Future.getState()
Returns the current state as one of the strings
"pending"/"fulfilled"/"faulted".
- Future.resolve(value)
Settle a pending future as fulfilled with
value. Idempotent on an already-settled future: a Future transitions from pending to settled exactly once; second and later calls to.resolveare silently ignored and leave the original state and value unchanged.If
valueis itself aFuture, the receiver adopts its settlement instead of storing the Future as the value (chain-unwrap, matching JS). If the inner future is already fulfilled the receiver settles immediately with the inner’s value; if faulted, the receiver settles faulted with the same value; if pending, the receiver stays pending until the inner settles and then mirrors it. Recursive: an inner Future that itself wraps another Future unwraps all the way to a non-Future value.Adoption locks the receiver: once
p.resolve(otherFuture)has been accepted, subsequentp.resolve(...)calls are silent no-ops, exactly as on an already-settled future. OnlyotherFuture’s eventual settlement can completep.Two errors throw:
Resolving a future with itself (
p.resolve(p)) or with a Future that chains back topvia any depth of adoption (a.resolve(b); b.resolve(a)or longer chains): would form an uncollectable cycle, detected by walking the adoption chain.Resolving the future returned by an
asyncfunction: a task-future is owned by its generator and must settle through the body’s return / throw, not externally.
Example:
let p = Future()
p.resolve(42)
async function f() {
let x = await p
print(x) // 42
}
f()
Note
A pending p.resolve(q) makes p and q reference each
other so settlement can propagate. If nothing settles q and the
script drops all external references, the pair leaks until the VM
is closed; refcounting cannot break the cycle, and the
unhandled-error diagnostic does not surface it.
1.10.4.2. Task-futures vs. manually constructed futures
Two flavours of Future exist at runtime:
Manual / bare future - created by
Future()from script (orsqasync::future_createfrom C++). Settle it explicitly through.resolve(or, from native code,sqasync::future_throwfor a worker-thread bug). This is what bindings hand to script when they begin an async operation.Task-future - created implicitly when an
asyncfunction is called. It is owned by the function’s generator frame; calling.resolveon it from outside throws an error. The function’s ownreturn/throwis the only way to settle it.
1.10.4.3. Subclassing Future
Subclasses of Future work as long as the subclass constructor
chains to super.constructor(). Skipping it leaves the instance
without its internal state, in which case getState /
resolve throw "invalid 'this'".
1.10.5. Scheduling model and execution order
Calling an async function is non-blocking. The body starts running only
when the host calls sqasync::pump. As a result, code that comes
after the call in the same script-frame runs first:
async function main() {
print("body start\n")
let x = await fulfilled
print("body got: ")
print(x)
}
print("before call\n")
main()
print("script done\n")
Output:
before call
script done
body start
body got: 42
The scheduler is a FIFO queue. Long chains of immediately-resolved
awaits are drained inside one pump call (up to its per-call cap);
other tasks interleave in queue order. The implementation does not
recurse on the C stack across await: it is safe to await tens of
thousands of times inside a single async function.
Multiple async tasks may park on the same pending future; when the future settles, every waiter is resumed in FIFO order with the same settled value (or with the same fault value).
1.10.6. Errors and faulted futures
Inside an async function, a throw (or any unhandled runtime error)
faults the task-future with the thrown value. await re-raises the
fault at the await site:
async function faulter() {
throw "boom"
}
async function consumer() {
try {
await faulter()
} catch (e) {
print("caught: " + e) // "caught: boom"
}
}