Async / await
FluffOS supports native coroutine-style asynchronous programming
(issue #1319, phase 1): a first-class promise value type, an
async function modifier, an await expression that suspends the
function without blocking the driver, and acatch, the async-aware
form of catch. This page is the execution-model specification.
Promises
A promise is a first-class LPC value holding the eventual result of an
asynchronous operation. It is created pending and settles exactly once —
fulfilled with a value, rejected with a reason, or cancelled.
Cancelled is reported by promise_status() as 3: a negative settlement
that is not a fault. See
promise_create(), promise_resolve(), promise_reject(),
promise_then(), promise_catch(), promise_status(),
promise_result(), and async_info().
promise is a declared type, like mapping or buffer — usable for
variables, parameters, return types and arrays, and enforced by the
compiler's type checking. It is parameterized by the type it will
eventually deliver, written promise<T>:
promise<mapping> fetch(string uid); // return type
void handle(promise<int> p); // parameter
promise<int> *pending = ({ }); // array of promise<int>
promise<string *> names; // promise of an array of strings
promise anything; // bare `promise` == promise<mixed>
promise<T> * and promise<T *> are different types and compose
independently: the first is an array of promises, the second is one
promise that delivers an array. Any type may be a payload — including
class types — except void and promise itself: resolving a promise
with a promise adopts it, so a promise value is never itself a promise, and
a nested payload is rejected at compile time. (Spelled promise<promise<int> >
you get the explicit diagnostic; spelled promise<promise<int>> you get a
plain syntax error instead, because >> lexes as the shift operator.)
Assignment and argument passing compare payloads, so promise<int> and
promise<string> are incompatible while promise (i.e. promise<mixed>)
accepts either. await yields the payload type, so int n = await fetch_count(); type-checks and string s = await fetch_count(); does not.
typeof() returns "promise" — it reports the value's kind, not its
payload. An async function's promise does carry the payload its function
declared, as a plain runtime type tag, and sprintf("%O", p) names it
the way the driver names any runtime type: PROMISE<int>( fulfilled: 42 ),
PROMISE<array> for a promise<string *>, PROMISE<class> for a
promise<class point>. A promise from promise_create() or
promise_then() declares nothing and renders bare, PROMISE( pending ).
Two things that tag is not. It is not finer-grained than the runtime's own
types: the compiler tells promise<string *> from promise<int *>, the
value only says "array". And it is a declaration, not a measurement —
exactly as trustworthy as any other declared LPC type, which is to say an
async int f() whose body returns a mixed that happens to hold a string
still produces a promise tagged int. Read it as documentation of intent,
the way you would read the function's signature, not as a checked property
of the settled value.
Promises compare by identity, work as mapping keys, deep-copy shallowly
(identity-preserving, like objects), and are not serialized by
save_object().
Settlement delivery is never synchronous: handlers attached with
promise_then() and suspended await expressions always run from the
microtask drain, in attachment order, each with a fresh evaluation-cost
budget (the same model as call_out(0) callbacks). The drain is a
zero-delay gametick event, so it runs after the current execution finishes
but still within the same gametick -- not on a later one. A drain with more
work than fits in one turn re-posts itself to the event loop and continues
there, so later deliveries of a large batch can land after that gametick (see
"Interaction with the rest of the driver").
Fulfilling a promise with another promise adopts it (flattening): the outer promise stays pending until the inner one settles, then settles the same way. A rejection that is never observed — no rejection handler, the result never read — is reported to the debug log when the promise is deallocated, naming where it was rejected:
Unhandled promise rejection (rejected by /obj/thing at /obj/thing.lpc:42): no such file
Deallocation can be arbitrarily far from the rejection, and the reason on
its own is often not enough to identify which promise it was — (int) 0 is
an ordinary rejection value for the promise forms of async_read() and
friends — so the locus is the part that makes the line actionable.
async functions
async int transfer(string from, string to, int amount) {
mapping acc = await fetch_account(from);
if (acc["balance"] < amount) return 0;
await update_account(from, -amount);
await update_account(to, amount);
return 1;
}
A function declared async always returns a promise for its result:
an async T f() is typed promise<T> at every call site, while T stays
what return statements inside the body are checked against. Calling one
runs its body
synchronously until the first await of a promise — any promise:
awaiting a promise always parks, even one that has already settled, and
only awaiting a non-promise value continues synchronously (see await
below). The caller receives the promise immediately — already fulfilled
only if the body completed without awaiting any promise, pending
otherwise.
The one exception to async T f() being typed promise<T> is T being an
array of promises. A type word carries a single promise bit, so
promise<promise<int> *> is unspellable; the call site is typed
promise<mixed *> instead — sound (it really is a promise of an array) but
weaker, so promise<int> *a = f(); does not compile. Only the payload
loses its element type: async int *g() is still typed promise<int *>.
The promise belongs to the body: it is the body's result channel, and the
body is the only thing that settles it. promise_resolve() and
promise_reject() refuse a promise that came from an async function.
Allowing it would discard whatever the body goes on to return — first
settle wins — and would not stop the body, which keeps running, keeps its
object alive and keeps its suspension slot. Note that JS cannot reach this
state at all, since an async function's resolver is never handed out; here
promises are first-class, so the refusal has to be explicit.
To ask a body to stop, use promise_cancel(), which is the real primitive
the refusal above pointed at: it makes the body's next await raise a
catchable "*async function cancelled", unwinding through acatch and
running defer handlers on the way out. It is cooperative — a body part-way
through straight-line code finishes that stretch, and one that never awaits
again runs to completion — and the request is consumed by the raise, so
a body that catches its cancellation can still await cleanup and return
normally. It does not propagate into promises the body is awaiting.
Two patterns remain useful alongside it, depending on which side you are on.
A body that wants a cheaper, purely voluntary check awaits a gate the
caller holds:
async int worker(promise cancel) {
foo();
if (promise_status(cancel)) return 0; // caller gave up
return await slow();
}
A caller that wants to bound a wait it does not own wraps the promise instead of settling it:
promise with_timeout(promise p, int secs) {
promise gate = promise_create();
promise_then(p, function(mixed v) {
if (!promise_status(gate)) promise_resolve(gate, v);
});
call_out(function() {
if (!promise_status(gate)) promise_reject(gate, "*timeout");
}, secs);
return gate;
}
The promise_status() guards are not optional, and this is where LPC
differs from JS: there, settling an already-settled promise is a silent
no-op, which is what lets Promise.race be written in userland. Here it is
an error, so the unguarded version throws on whichever of the two paths
finishes second — the common case, not a rare one.
In practice promise_race() does this for you, and is what you should reach
for first:
promise with_timeout(promise p, int secs) {
return promise_race(({ p, timer_that_rejects(secs) }));
}
Neither shape stops the underlying work; only your wait ends. To stop the
work as well, the operation has to be an async function you can
promise_cancel(). The exception is promise_reject of an
external_start / external_run promise, which kills the child;
external_kill(handle) is the handle form.
return value fulfills the promise (a returned promise is adopted). An
uncaught error inside the body rejects it — an async body behaves as if
wrapped in an implicit catch, so the error is reported like a caught
error and becomes the rejection reason. throw(value) inside an async
body rejects with value.
The modifier propagates through inheritance and applies on every call
path: direct local calls, ::-qualified calls, call_other(), and
function pointers.
An override must agree with the inherited function about async, in
both directions, and the compiler rejects a mismatch. The inherited program
is already compiled, and every call to the function inside it was typed
against its own declaration: making an override async when the base is not
would hand those calls a promise where they expect the declared type (if (f()) becomes unconditionally true, and the first arithmetic on the result
errors at runtime far from the cause), and dropping async in an override
does the reverse. Changing any other part of a return type across an
override merely warns; this one is an error because nothing checks it at
runtime.
await
await expr is a unary prefix expression (await a + b parses as
(await a) + b):
- a non-promise operand passes through unchanged — awaiting a plain value is a no-op, not a scheduling point;
- a promise always suspends, even one that has already settled. The driver keeps serving everything else; the function resumes from the microtask drain with a fresh evaluation-cost budget, receiving the value, or with the rejection raised at the await point.
That last rule is what makes await a scheduling primitive: because every
awaited promise costs a suspension and buys a fresh budget, a loop of
awaits breaks long work into separately-metered pieces instead of burning
one budget until "too long evaluation". Resuming is a microtask, not a
timer: a sequential loop of awaits runs at full speed, all of it inside the
drain, with no wall-clock delay per iteration. Only when a drain exceeds
the turn's async drain eval budget is spent does it yield to the event loop
and pick up again a moment later — the price of the driver staying responsive under
unbounded async work, paid once per batch rather than once per await.
The flip side is that a plain await is not a way to hand the event
loop a turn: the resume is re-queued into the same drain turn, so the loop
does not run in between. When that is what you want — a long computation
that should let players be served around it — use
async_yield(), whose promise settles
from the event loop's own post-poll queue:
async void reindex(mixed *rows) {
int i;
foreach (mixed row in rows) {
index(row);
if (++i % 500 == 0) {
await async_yield(); // the loop runs here
}
}
}
await call_out(0) is not a substitute — a call_out(0) runs on the same
gametick, and call_out(0) nest level refuses one used as a yield inside a
loop. await call_out(1) does reach the loop, but costs a whole gametick.
The yield is to the event loop's own post-I/O queue, not a timer, so there is no fixed delay between batches: the loop polls sockets, fires due timers and comes straight back. That keeps latency at one batch — measured worst timer jitter is a few milliseconds even while the drain is saturated — but it also means sustained async work runs the driver at 100% of one core rather than leaving it idle between batches. A mud with a genuinely unbounded promise chain will use every cycle it is given; that is the intended behaviour, and it is a change worth knowing about on a co-tenanted host.
While suspended, the object remains fully live: incoming calls run
normally (there are no re-entrancy locks), and each async call has its
own suspension state, so concurrent invocations don't interfere. The
suspension holds real references on the object and its program.
this_player() across a suspension is governed by the same driver option
as call_out() callbacks, this_player in call_out: with the option
enabled (the default), this_player() at suspension time is restored on
resume; with it disabled, this_player() is 0 after resuming.
acatch
await is not allowed inside catch (see restrictions below).
acatch(expr) / acatch { ... } is the async-aware replacement: the same
value convention as catch — 0 on success, the error value on failure —
but await may suspend inside the protected region.
async void pay(object who, int amount) {
mixed err = acatch(await bank_transfer(who, amount));
if (err) write("Transfer failed: " + err + "\n");
}
An error raised inside the region — synchronously, or as the rejection of
an awaited promise, even one that settles long after the function
suspended — resumes execution right after the acatch with the error as
its value. acatch is only legal directly inside an async function
body, and not inside a plain catch.
One rejection does not reach an acatch region: if the awaited promise is
garbage-collected while still pending (its last reference dropped, so it can
never settle), the parked frame is abandoned rather than resumed, and the
async function's own promise is rejected with *awaited promise was collected before settling. There is no resume, so no acatch inside the
function runs -- observe that case on the returned promise instead.
Restrictions
These rules keep suspension sound (the VM parks exactly one frame; no C++ stack may be pinned across a suspension). Each violation is a clean compile-time or runtime error, never silent misbehavior:
-
awaitandacatchare only legal directly inside anasyncfunction body — not in(: :)functionals or anonymous functions (those run in their own frames), and not at top level. -
awaitis not allowed insidecatch(...)ortime_expression(...)(their implementation recurses the C++ stack). Useacatch. -
An
awaitcannot suspend while a transient reference sits on the value stack that suspension cannot relocate. Aforeachover a local loop variable is fine — arrays, mappings, strings, buffers, nested loops included — because that variable's lvalue addresses a slot inside the frame, and the frame is exactly what parking copies and rebuilds, so it is carried across as an offset. What is still refused is aforeachover a global loop variable (its lvalue points into the object's variable block, a second relocation base), arefloop variable orrefargument, and a string-char or buffer-byte lvalue (s[i],b[i]— those are backed by shared VM scratch state, one instance at a time by construction, so they can never be per-frame). It is a runtime error rather than a compile error because it depends on what the awaited promise turns out to be; use a local loop variable, or an indexedfor. Compound assignment is not affected —arr[i] += await p,s += await pand friends evaluate the right-hand side before pinning the target, so they park and resume normally. -
If the object is destructed, recompiled by
recompile_object(), or has its program swapped byreplace_program()while a function is suspended, the resume is abandoned and the function's promise rejects with a descriptive error. -
break/continuemay not jump out of anacatchregion (same rule ascatch);returnworks normally. -
An apply should not be
async. The driver is the caller and reads the return value the moment the apply returns, which for an async function is a promise handed back the instant the body parks — before it has decided anything. Consumers that treat an unrecognised value as permissive then read that as "yes". Applies whose value is ignored (create(),init()) fail more quietly but no more usefully: the driver treats the object as ready while the body is still parked. An apply that wants async work calls an async function:void create() { start_loading(); } // apply, ordinaryasync void start_loading() { config = await load(); }The compiler enforces this as far as a name can:
asyncon an object apply (create,init,id,heart_beat, …) is an error, since the driver calls those on any object;asyncon a master-only apply (valid_read,error_handler,compile_object, …) is a warning, because on any object other than the master the name is the author's to use.That check is a lint, not a boundary, and it matters not to mistake it for one. It keys on the declaration, so it does not catch an ordinary apply that returns the result of an async call —
mixed id(string s) { return slow(); }— and it cannot coveradd_action()verb functions at all, whose names are arbitrary. So the consumers refuse a promise themselves — a function that has not answered has not said yes:check_valid_path()denies avalid_read()/valid_write()that returns one, and logs why.- every master approval gate —
valid_seteuid,valid_bind,valid_shadow,valid_socket,valid_object,valid_link,valid_hide,valid_override— denies and names itself in the log. - the command parser treats a verb function that returns one as having
declined the command: it goes on to the next sentence on that verb
and, if none takes it, calls
notify_no_command(). present()does not match an object whoseid()returns one, and the parser does not set an object'sliving/inventory_accessible/inventory_visible/livings_are_remoteflags from one.
-
An eval-cost ("too long evaluation") error can never be swallowed by an async body or
acatch, matchingcatch.
Resource limits
Each suspended function holds a heap copy of its frame, so the number of
concurrently suspended async functions is bounded by the runtime config
option max suspended async functions (default 10000; 0 disables the
limit). An await that would exceed it raises a clean error at the await
point instead of suspending — catchable with acatch, and otherwise
simply rejecting that function's promise. This is a runaway-exhaustion
guard: a loop spawning async calls that never settle hits a bounded error
rather than consuming memory without limit.
await async_yield() holds one of those slots while it is parked, like any
other await. It is a scheduling primitive rather than a wait on real work,
so a function that yields periodically occupies a slot only between the yield
and the loop's next pass.
call_out(delay) — a delay with no callback — returns a promise
fulfilled (with 0) when the delay elapses, and rejected if the call_out
is removed (remove_call_out() with no argument sweeps it with the rest)
or its object destructed first. await call_out(2) is the non-blocking
pause idiom. The classic call_out(fn, delay, ...) form is unchanged and
still returns the handle; note the promise form returns no handle, so a
timer you may need to cancel individually should use the classic form.
The worker-thread I/O efuns of the async package follow the same
pattern: async_read(path), async_write(path, str, flag) and
async_getdir(path) with the trailing callback omitted return a
promise fulfilled with the value the callback would have received, or
rejected with the failure value (e.g. async_read's negative int) —
string s = await async_read(path);.
external_start(index, args) — the same efun with the socket callbacks
omitted — returns a promise fulfilled with
({ stdout, stderr, exit_code }) when the process exits (a non-zero
exit still fulfills), or rejected with a socket error /
"*external process aborted". The child is started with
posix_spawn() (Win32: CreateProcess). promise_reject of that
promise kills the child. A handle is stopped with external_kill(h)
(keep the result) or external_close(h) (discard). The classic
external_start(index, args, read, write, close) form is unchanged and
still returns the socket fd.
external_create(index, args) allocates a handle. external_run(handle)
starts the process the same way and fulfills with the same
({ stdout, stderr, exit_code }) tuple. After it fulfills,
external_stdout(), external_stderr() and external_exit_code()
also read the results from the handle. external_write() /
external_close_stdin() drive the child's stdin.
async_yield() returns a promise fulfilled with 0 on the event loop's
next pass — await async_yield(); is the cooperative preemption point for a
long computation, letting the driver serve players between chunks. It is the
one await that reaches the loop; see "Interaction with the rest of the
driver" for why a plain await does not. Pending yields are bounded by the
same max pending promise deliveries ceiling as queued reactions,
because a yield the caller has dropped stays allocated until the loop runs.
async_info() lists every currently suspended frame — what it is, where
it is parked, and what it awaits — the async counterpart of
call_out_info(). A frame stops being listed the moment its object is
destructed, even in the case where the driver cannot free it yet (its
delivery was already queued, so the queue owns it until it arrives and is
abandoned there). Such a frame can never resume, so it is not reported as
suspended and does not hold one of the max suspended async functions
slots against live frames.
async, await, acatch and promise are reserved words. Existing
mudlib code using them as identifiers must be renamed.
Interaction with the rest of the driver
-
Delivery rides the gametick event queue, and a drain that cannot finish in one go re-posts itself to the event loop rather than running on. Each turn spends at most
async drain eval budgetof eval cost — counting work created during the turn, so a sequential chain of awaits runs at full speed rather than paying a loop turn per link. Between turns the driver reads pending network input, schedules commands and fires timers.The budget governs how many deliveries a turn starts; it never interrupts one it has started. Each delivery is armed with a whole
maximum evaluation costof its own, so a resumed async function or a settlement handler always runs to its own natural end, however much time the deliveries ahead of it in the same turn used. Running out is a scheduling decision, not an error: the remaining deliveries are pushed past the next I/O poll and nothing in flight is aborted or dropped.A turn also ends immediately when the delivery that just ran consumed its entire budget and took "too long evaluation". That handler alone has already held the loop for longer than any turn budget, so starting another full-budget delivery behind it is the last thing the driver wants; the queue is deferred instead.
The effect is that async work proceeds continuously, using time the driver would otherwise spend idle between gameticks, without holding the event loop for more than one batch. A large backlog is delivered across as many turns as it needs instead of in one stall, and a self-feeding
promise_then()chain yields every batch rather than wedging the driver.Because the batch is the only bound, its size is a responsiveness setting. The default keeps a turn to roughly a millisecond of trivial deliveries; raising it far above that lets a self-feeding chain hold the driver for correspondingly longer, and a large enough value will stall network I/O outright.
The re-post is a true yield to I/O, not a timer: it rides libevent's
active_laterqueue, which the loop promotes at the top of its next iteration, before polling — and keeps the loop from blocking while one is pending. So the loop polls sockets between every turn, with no fixed delay to pay. A zero-delay timer would not do this (libevent dispatches a zero timeout immediately, from the same pass, so a self-re-posting drain never lets the loop poll at all — measured: acall_outsix seconds out did not run in ninety seconds), and a one-millisecond timer does yield but caps delivery at one turn per millisecond. Measured against that 1 ms predecessor: 9.0M deliveries in 5s versus 635k, i.e. 14×, with login latency under sustained promise load still around a millisecond.async_info(1)reports the scheduler:pending_deliveries, the monotonicdrain_yields, and the effectivedrain_eval_budget. A risingdrain_yieldswith a non-zeropending_deliveriesis backpressure — async work arriving faster than it is delivered.A settle that happens outside gametick dispatch — which is how every
package/asyncI/O completion arrives (async_read,async_write,async_getdir,async_db_exec) — arms the drain through the event loop rather than the tick queue, so it is delivered on the loop's next pass instead of waiting for the next gametick.A consequence worth knowing when writing tests: a batch of settlements larger than one turn is not all delivered in the same gametick, even though each individual settle queues immediately.
-
On the WebAssembly build there is no evaluation limit at all — the driver's eval timer is built only for Linux (
#ifdef __linux__ineval_limit.cc), and emscripten — like macOS and Windows — gets none, so the boot log says so. Deliveries are still armed, but the arming does nothing: a runaway promise handler blocks the page exactly as awhile (1);in any other function would. What does still work is the turn budget, which is measured on the monotonic clock — so a large backlog is still split across turns and the page still gets its turns back between them. Only the per-delivery bound is missing. -
On a Debug build, suspended coroutines and promise reactions are fully visible to the
check_memory()ref-count checker. -
defer()handlers registered before anawaitsurvive the suspension and run when the function finally finishes, including when it is abandoned because its object was recompiled. Two cases where they do not run:- The object was destructed. Frames parked inside an object are
abandoned by
destruct_object()itself, and that happens partway through tearing the object down, where running mudlib code is not safe — so theirdefer()handlers are discarded rather than run. Even reached by the other route (a delivery arriving for a destructed owner, which runs at a safe point), most handlers still could not run: adefer()handler is a function pointer, and the driver refuses to call one whose owner is destructed ("Owner of function pointer is destructed"), which is every handler written the natural way —defer((: release_lock, key :))inside the object's own method. Do not rely ondefer()for cleanup that must survive the owner being destructed mid-await; put that cleanup in an object that outlives the operation. - The awaited promise was garbage collected. That happens on a deallocation path where running mudlib code is unsafe, so those handlers are dropped.
- The object was destructed. Frames parked inside an object are
abandoned by
-
Driver shutdown discards queued deliveries without running them, like pending
call_out()s.