Asynchronous Programming (async / await / futures)
The rontolisp package provides a small asynchronous surface modeled on
JavaScript promises and async/await, expressed in Lisp. None of it is part
of Common Lisp; reference every operator with the rontolisp: qualifier (see
Packages). The unit of the model is a future: a
value standing in for a computation that may not have finished yet. Calling an
rontolisp:async-defun
returns one, rontolisp:await
resolves it, and a handful of combinators build on top.
| Operator | Purpose |
|---|---|
rontolisp:async-defun | Define an asynchronous function (returns a future) |
rontolisp:async-lambda | The anonymous counterpart |
rontolisp:async | (async (defun ...)) / (async (lambda ...)) — a JavaScript-style spelling of the two above |
rontolisp:await | Suspend until a future settles and return its value |
rontolisp:futurep | t if a value is a future |
rontolisp:wait-for | A future that settles to nil after N milliseconds (the async counterpart of cl:sleep) |
rontolisp:then / then* | Attach a transform to a future as a value |
rontolisp:catch | Attach an error fallback to a future as a value |
rontolisp:finally | Run a cleanup thunk on both the success and error channels |
rontolisp:make-stream / stream-read / stream-write / stream-close / read-all | Asynchronous byte/string streams |
Backend support. The whole surface works on the interpreter, the JVM backend and the WASM
--componentbackend, but the machinery under it differs. On the interpreter and JVM an async body runs on a virtual thread — after its first suspension it runs in real parallelism with the caller. On--componentthe body compiles into a cooperative, single-threaded state machine over the WASI 0.3 component-model async ABI (see below); such a component must run withwasmtime -W exceptions=y. Preview 1 WASM has no asynchronous host I/O, so async bodies run to completion eagerly (a degenerate-but-observably-consistent synchronous mode), andwait-for/ the guest stream operations are compile errors there.--no-gcrejects the whole async surface at compile time.
Futures and eager start
rontolisp:async-defun defines a function whose call starts the body
immediately and hands back a future rather than a value. The body runs until
its first await of an unsettled future (or until it finishes) — "eager
start" — then the caller resumes:
The call itself is an opaque future — rontolisp:futurep recognizes it, and
it prints as #<FUTURE>:
The future settles with the value of the last body form, or with the error the
body signaled (re-signaled when the future is awaited — see
Errors). The anonymous counterpart is
rontolisp:async-lambda,
and (rontolisp:async (defun ...)) / (rontolisp:async (lambda ...)) is an
equivalent JavaScript-flavored spelling of the two.
Awaiting
rontolisp:await suspends the current asynchronous function until a future
settles and returns its settled value. It is generic: a settled future never
suspends, nested futures flatten, and a value that is not a future passes
through unchanged — so await can be applied uniformly to a value that may or
may not be a future.
await placement is lexical: it is legal only inside an
async-defun/async-lambda body, or at top level (which is implicitly
asynchronous). In any plain defun/lambda — even one nested inside an
asynchronous body — it is an error at definition time:
> (defun bad () (rontolisp:await 1))
rontolisp:await is only allowed inside rontolisp:async-defun/async-lambda or at top level
Errors across the await barrier
An error signaled by an async body does not escape at call time; it settles the
future and re-signals the condition at the await. Catch it with a
handler-case around the await — condition-type dispatch works across the
barrier:
Overlapping work
Because a call is already running when it returns its future, several
asynchronous operations overlap — start them all, then await each (in any
order). The clearest illustration is rontolisp:wait-for, which returns a
future settling after a delay: it is the asynchronous counterpart of cl:sleep
(which blocks the whole program and takes seconds). Timers run concurrently,
so two started together settle in delay order, not start order, and awaiting
both takes about the longer delay, not the sum:
The same overlap is what makes several rontolisp:fetch
requests run in parallel: start them, then await the responses.
Composing futures as values (then / then* / catch / finally)
await is the right tool when the future is right there in your async body. But
a future is also a first-class value that can cross a boundary — be returned,
stored, passed around — and the caller on the other side need not itself be an
async-defun just because its callee is one. The combinator quartet transforms
a future as a value, each returning a fresh future:
rontolisp:thenattaches a success transform. On the input's successful settlement it invokes the function with the settled value and settles to the result; on an upstream error the callback is skipped and the condition propagates unchanged. If the function itself returns a future,awaitflattens it (nofuture<future<T>>):
rontolisp:then*is variadic chain sugar — thread a value through several stages without the nesting a manual chain would need:
rontolisp:catchattaches an error fallback (JavaScript.catch); a successful value passes through unchanged:
rontolisp:finallyruns a zero-argument cleanup thunk on both the success and error channels; the original outcome carries through (likeunwind-protect):
A non-future first argument to any of the four is a type-error — there is no
JavaScript-style auto-coercion to a resolved promise. And note that
rontolisp:catch is not Common Lisp's
catch/throw
tag-based special form: they live in different packages and qualified names
never collide (see the
catch reference page for the
naming details).
Asynchronous streams
Where a future settles once, a stream delivers a sequence of chunks over
time. A guest-created stream is one value owning both ends: producers append
with rontolisp:stream-write
and finish with
rontolisp:stream-close;
consumers take chunks with
rontolisp:stream-read (each
read yields a future) or drain the chunks into one string in one await with
rontolisp:read-all:
stream-read returns a future settling to the next chunk, or to nil once the
stream is closed and drained — chunks are never nil, so a nil result always
means end of stream. A read on an open, empty stream stays pending until a write
arrives; that pending read is the suspension an awaiting async function parks
on.
Guest-created streams (make-stream / stream-write) exist on the interpreter
and the JVM backend. On --component the stream operations work too, but the
streams themselves arrive from the host: a rontolisp:fetch
response :body and a rontolisp:http-handler request
:raw-body (in its default :stream mode) are asynchronous streams on every
backend. Those HTTP body streams are byte streams: each chunk is an
(unsigned-byte 8) vector holding the octets as they came off the wire, so a
handler that answers a fetched :body as its own response body relays it
byte-exact, and read-all is where the bytes become text (one UTF-8 decode of
the whole body, so a chunk boundary inside a code point costs nothing).
Under the hood: WASI Preview 3 futures & streams
The --component backend is the one place where the async model maps onto a
platform primitive rather than onto host threads. A WASI 0.3 (Preview 3)
component builds on the component-model async canonical ABI, whose two
built-in parametric types are future<T> (a one-shot asynchronous result) and
stream<T> (a sequence of chunks). rontolisp's futures and asynchronous
streams lower directly onto them:
- An
async-defun/async-lambdabody (and a top level containingawait) compiles into an entry + resume state machine over first-class component-model futures. Anawaitof a value that is already settled just continues; anawaitof a pending host operation genuinely suspends the task, and the component's event loop resumes it when the awaited event arrives. Tasks are cooperative and single-threaded — two in-flight operations of one component instance interleave, but never preempt each other. This is the deliberate divergence from the interpreter/JVM's virtual threads, where a body runs in real parallelism after its first suspension and racing on shared global state is the program's own responsibility. rontolisp:wait-forlowers to the host timer,wasi:clocks/monotonic-clock@0.3.0'swait-for, returned as a pending future the event loop settles — which is why timers genuinely overlap in a component too.- A fetch response's
:body/ a served request's:raw-bodyis a component-modelstream<u8>wrapped as a rontolisp stream;stream-readof a chunk the host still has in flight is a pending future, so a slow body read parks only its own task while another task's timer or fetch keeps running.
Because the async ABI uses the component-model exception mechanism, any async
component must run with wasmtime -W exceptions=y on top of -W gc=y. All of
this rides the base component-model async support enabled by default in
wasmtime 46+ — no experimental feature flags remain. See the
WASI 0.3 Component guide for the component runtime as a
whole.
Preview 1 WASM has none of this — there is no asynchronous host I/O in a
Preview 1 core module — so an async body simply runs to completion the moment
it is called, and its future is born already settled. The observable behavior
matches the other backends whenever an await is adjacent to the call that
produced the future (the common shape); it diverges only in that an error
signals at the call rather than at the await, and wait-for / the guest
stream operations are rejected at compile time. --no-gc rejects the entire
async surface by name.
A --no-wasi reactor is a Preview 1 module, so those degenerate futures are
what it has — and yet it is the one Preview 1 build that does real asynchronous
host work, because the host does the waiting rather than the guest.
--host-fetch
routes rontolisp:fetch at a pair of host imports (the head, then the body a
chunk at a time), and a JavaScript host implements them with
WebAssembly.Suspending (JSPI): the whole wasm stack parks until the promise
settles, so (await (fetch ...)) reads exactly as it does everywhere else, and
by the time fetch returns its future — the reply's HEAD — is already
settled. The price
is paid on the host side, not in the Lisp — every export must be entered through
WebAssembly.promising and calls must be serialised (a re-entered export
refuses with a trap), and nothing on the load path may fetch, because
_initialize is the one stack a suspending host cannot park.
Where async shows up
The async surface is small on purpose; most programs meet it through one of the I/O features built on it:
- HTTP Requests (fetch) —
fetchreturns a future; a request body is drained withread-all. - Serving HTTP (http-handler) — a handler that awaits (for
example, one that fetches) must itself be an
async-defun. - TCP Sockets — a pending
tcp-acceptor socket read suspends only its own task inside a component. - Host-driven reactors — a synchronous
handler cannot
await, so it returns the FUTURE anasync-defunproduced and the reactor transport resolves it at the boundary.