2.15. Async runtime
The async runtime that powers script-side Async / await is a small
cooperative scheduler with C-level entry points declared in
include/sqasync.h. Embedders are responsible for:
Calling
sqasync::bind()once per shared state, before anyasyncfunction in script can run.Publishing the
Promiseclass to script (typically throughSqModules::registerAsyncLibor by callingsqasync::sqasync_register()directly).Calling
sqasync::pump()periodically - once per host frame, and in a drain loop on shutdown.
2.15.1. Threading model
The affinity unit is the VM. Each VM owns:
A step queue - single-threaded, no lock. Only the VM thread touches it.
An inbox - protected by a mutex. Any thread may push into it.
Whichever thread calls sqasync::pump(v) drains the inbox into the
step queue and runs both. Embedders may run different VMs on different
threads; the runtime never assumes a single global thread.
Two posting primitives match this split:
sqasync::post_on_vm_thread()- lock-free, must be called on the thread that pumpsv.sqasync::post_from_any_thread()- locks the inbox, callable from any thread.
2.15.2. Lifecycle API
-
SQRESULT sqasync::bind(HSQUIRRELVM v)
Install the async runtime into
v’s shared state and build thePromiseclass. Cache the class internally so further publishing paths see the same object.Call it once per shared state, before any
asyncfunction runs.
-
SQInteger sqasync::pump(HSQUIRRELVM v, SQInteger maxSteps = 1024)
Drain the inbox into the step queue and run up to
maxStepsentries from the queue. Returns the number of entries dispatched. Zero means the queues are empty (or the runtime is not bound).The per-call cap bounds latency under reentrant scheduling: a callback that schedules another callback can extend the same pump up to
maxStepstimes. Anything beyond that waits for the next pump.Typical wiring is one
pump(v)per host frame.
-
SQBool sqasync::has_pending(HSQUIRRELVM v)
SQTrueiff the runtime has work it can drive forward on its own (step queue or inbox non-empty). ReturnsSQFalsewhen the runtime is not bound.A task parked on an unsettled Promise does not count here: the runtime cannot wake it on its own, only the producer that started the work (timer, in-flight HTTP, eventbus, …) can, by calling
resolve_promise/reject_promise. A drain loop that only checkshas_pendingwill therefore exit too soon while such producers still have work to deliver - OR the check with whatever in-flight signals you already track for them:while (sqasync::has_pending(v) || timers.has_due() || http_in_flight) sqasync::pump(v);
2.15.3. Posting work
-
SQRESULT sqasync::post_on_vm_thread(HSQUIRRELVM v, VmCallback fn, void *user)
Schedule
fn(user)to run on the VM thread inside the nextpump(). Lock-free fast path; must be called on the VM thread.Returns
SQ_ERRORif the runtime is not bound or is shutting down. On failure, the caller ownsuserand must free it.
-
SQRESULT sqasync::post_from_any_thread(HSQUIRRELVM v, VmCallback fn, void *user)
Thread-safe variant. Any thread may call; the inbox lock guarantees safe enqueue. The callback still runs on the VM thread inside
pump().Returns
SQ_ERRORif the runtime is not bound or is shutting down. On failure, the caller ownsuserand must free it.Lifetime contract: the embedder must guarantee that
sq_close(v)(which invokes the runtime’s shutdown path) does not happen until all VM-bound worker threads have joined, since the shutdown frees the state non-atomically from the consumer side.
VmCallback is:
typedef void (*VmCallback)(void *user);
The runtime owns the user pointer once a post returns SQ_OK:
when the callback runs (or is drained as part of shutdown), it is the
callback’s responsibility to free or reinterpret user as
appropriate.
2.15.4. Promise class & C API
-
SQRESULT sqasync::push_promise_class(HSQUIRRELVM v)
Push the cached
Promiseclass onto the stack. Useful for embedders that want to instantiate a Promise from C (for example, anasync.delaynative that returns one). ReturnsSQ_ERROR(and raises an error) ifsqasync::bind()has not been called.
-
SQRESULT sqasync::create_promise(HSQUIRRELVM v, HSQOBJECT *out)
Create a fresh
Promiseinstance and write an addref’d handle into*out. Nothing is left on the stack. Caller owns one strong ref and must either:settle the promise via
resolve_promise/reject_promiseand thensq_release(v, out), orsq_release(v, out)directly to discard the promise.
Returns
SQ_ERROR(raised) if the runtime is not bound or instance creation fails;*outis reset to null on error.
-
SQRESULT sqasync::resolve_promise(HSQUIRRELVM v, HSQOBJECT promise, HSQOBJECT value)
-
SQRESULT sqasync::reject_promise(HSQUIRRELVM v, HSQOBJECT promise, HSQOBJECT reason)
Settle a manually-created Promise from C. The payload is kept alive by an internal
sq_addref. Settling an already-settled or already-adopted promise is a silent no-op.resolve_promiseapplies the JS Promise Resolution Procedure: a Promisevalueis adopted (promisemirrors its eventual settlement); a non-Promisevaluefulfills immediately.reject_promisestoresreasonverbatim regardless of type.Both return
SQ_ERRORand throw ifpromiseis not a Promise instance, is a task-promise (returned by anasyncfunction), or the payload ispromiseitself.resolve_promiseadditionally throws on a cycle that lies entirely within the adoption chain (a.resolve(b); b.resolve(a)). Cycles that pass through a rejection (a.resolve(b); b.reject(a)orb.reject(a); a.resolve(b)) are detected during settlement and produce a diagnostic reason rather than throwing.
Typical worker -> VM result delivery looks like:
struct OpCtx {
HSQOBJECT promise; // addref'd via create_promise
HSQOBJECT result; // built on the VM thread
};
// VM thread: kick off the op and hand the promise to script
HSQOBJECT prom;
sqasync::create_promise(v, &prom);
OpCtx *ctx = new OpCtx { prom, /*result=*/{} };
start_worker(ctx);
// push the promise back to script as the call result, etc.
// Worker thread: do the work, then post the completion
sqasync::post_from_any_thread(v, &on_complete, ctx);
// on_complete runs on the VM thread during pump():
static void on_complete(void *user) {
OpCtx *ctx = static_cast<OpCtx *>(user);
// build ctx->result here (we are on the VM thread)
sqasync::resolve_promise(v, ctx->promise, ctx->result);
sq_release(v, &ctx->promise);
delete ctx;
}
2.15.5. Publishing the async module
-
SQInteger sqasync::sqasync_register(HSQUIRRELVM v)
SqModules-shaped registration entry point. On entry the new module table is expected on top of the stack; the function adds
"Promise"-> class into it and leaves the table on top.Suitable for
SqModules::registerStdLibNativeModuleandSqModules::registerAsyncLib.
The simplest wiring for an embedder using SqModules is:
sqasync::bind(v);
module_mgr->registerAsyncLib(); // adds "async" -> { Promise }
After that, script code can write from "async" import Promise.
Embedders that do not use SqModules can publish the class manually
by pushing it via sqasync::push_promise_class() and storing it
wherever they want script to see it.
2.15.6. Async function call integration
When the engine encounters a call to a script-side async function
in SQVM::StartCall, it does not run the body. Instead it builds a
generator from the function’s bytecode and hands it to the runtime
through a hidden entry point (sqasync::wrap_generator). The
runtime builds a task-promise around the generator, schedules the
first step, and returns the task-promise as the call result. Subsequent
pump() calls drive the generator one await at a time.
This means an embedder does not need a per-async-function binding:
any Quirrel function declared async is wired automatically once
sqasync::bind() has installed the runtime.
2.15.7. Shutdown
The runtime is owned by the shared state and freed automatically
during sq_close. Once shutdown begins, post_from_any_thread
returns SQ_ERROR and the caller retains ownership of user;
any queued callbacks are still dispatched so they can release their
refs, and tasks still parked on unsettled promises are reclaimed
without completing their bodies.
To drain pending work cleanly before sq_close, loop on
sqasync::has_pending() calling sqasync::pump() (see
the note under has_pending about pairing it with any in-flight
producers you still own).