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
Promise 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. 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 Promise (a “task-promise”) 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-promise resolves
with the returned value or rejects with the thrown error.
Returning a Promise from an async function does NOT yield a
Promise<Promise<value>> to the caller: the runtime adopts the
returned promise’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 = Promise()
p.resolve(42)
return p // outer task-promise adopts p
}
async function outer() {
let v = await inner() // v is 42, not a Promise
print(v)
}
If the returned promise is still pending, the task-promise stays pending
until the inner settles, then mirrors its fulfilment or rejection. The
reject path is not unwrapped: throw somePromise rejects the
task-promise with the Promise object as the reason (matching JS).
Because chain-unwrap also applies to await on a plain function that
returns a Promise, the natural shape for a callback-to-Promise
adapter is a sync function:
function timeoutPromise(sec, value): instance {
let p = Promise()
gui_scene.setTimeout(sec, @() p.resolve(value))
return p
}
async function f() {
let v = await timeoutPromise(0.1, "x") // v is "x"
}
The : instance return annotation tells the static analyzer that the
function may return a Promise, 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.1.1. Where async is not allowed
On table members:
asyncis only legal on free functions and class methods, never inside a table literal.On constructors:
async constructoris rejected because a constructor must return the new instance, not a Promise.On metamethods:
_add,_unm,_cmp,_tostringand the rest of the metamethod set must return their expected value synchronously, soasyncis rejected on them.
1.10.2. await
await <expr> evaluates <expr> and suspends the async function
until the result is available.
If the result is a
Promisein thependingstate, the current async function parks on it. When the promise is later settled, the scheduler queues a resume step:Fulfilled -> the await expression yields the resolved value.
Rejected -> the rejection reason is re-raised at the await site (a surrounding
try/catchwill catch it).
If the result is a
Promisealready in thefulfilledorrejectedstate, no parking happens: the resume step is queued immediately and the body wakes up on the next pump iteration.If the result is not a Promise, the value is delivered to the await site as-is on the next pump iteration (no transient Promise 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.3. The async module
The async built-in module exposes the Promise class:
from "async" import Promise
1.10.3.1. Promise class
A Promise is in one of three states: "pending", "fulfilled",
"rejected". State transitions are monotonic: once settled
(fulfilled or rejected), a Promise stays in that state and further
resolve() / reject() calls are silent no-ops.
- Promise()
Construct a fresh pending promise. No arguments - there is no JS-style executor callback. Settle it later through
.resolve/.reject.
- Promise.getState()
Returns the current state as one of the strings
"pending"/"fulfilled"/"rejected".
- Promise.resolve(value)
Settle a pending promise as fulfilled with
value. Idempotent on an already-settled promise: a Promise transitions from pending to settled exactly once; second and later calls to.resolve/.rejectare silently ignored and leave the original state, value and reason unchanged.If
valueis itself aPromise, the receiver adopts its settlement instead of storing the Promise as the value (chain-unwrap, matching JS). If the inner promise is already fulfilled the receiver settles immediately with the inner’s value; if rejected, the receiver settles rejected with the same reason; if pending, the receiver stays pending until the inner settles and then mirrors it. Recursive: an inner Promise that itself wraps another Promise unwraps all the way to a non-Promise value.Adoption locks the receiver: once
p.resolve(otherPromise)has been accepted, subsequentp.resolve(...)/p.reject(...)calls are silent no-ops, exactly as on an already-settled promise. OnlyotherPromise’s eventual settlement can completep.Two errors throw:
Resolving a promise with itself (
p.resolve(p)) or with a Promise 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 promise returned by an
asyncfunction: a task-promise is owned by its generator and must settle through the body’s return / throw, not externally.
- Promise.reject(reason)
Settle a pending promise as rejected with
reason. Same restrictions asresolve: self-reject throws, and rejecting a task-promise from outside throws.The reject path does not chain-unwrap:
p.reject(somePromise)storessomePromiseas the rejection reason verbatim. Mirrors the JS rule that only the resolve path adopts thenables.
Example:
let p = Promise()
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-rejection diagnostic does not surface it.
1.10.3.2. Task-promises vs. manually constructed promises
Two flavours of Promise exist at runtime:
Manual / bare promise - created by
Promise()from script (orsqasync::create_promisefrom C++). Settle it explicitly through.resolve/.reject. This is what bindings hand to script when they begin an async operation.Task-promise - created implicitly when an
asyncfunction is called. It is owned by the function’s generator frame; calling.resolveor.rejecton it from outside throws an error. The function’s ownreturn/throwis the only way to settle it.
1.10.3.3. Subclassing Promise
Subclasses of Promise 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 / reject throw "invalid 'this'".
1.10.4. 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 promise; when the promise settles, every waiter is resumed in FIFO order with the same settled value (or with the same rejection reason).
1.10.5. Errors, exceptions, rejections
Inside an async function, a throw (or any unhandled runtime error)
rejects the task-promise with the thrown value as the rejection
reason. await re-raises a rejection at the await site:
async function rejecter() {
throw "boom"
}
async function consumer() {
try {
await rejecter()
} catch (e) {
print("caught: " + e) // "caught: boom"
}
}