ADR 0031 — L1: async/await (stackless frames, structured scopes, cancellation as a flag)
- Status: Accepted by the owner, 2026-10-31 ("I accept it as written."): every question as recommended (§5). Amended the same day while writing
type-system.md§40 (§6): a scope's allocation failure is an error the caller names ("Caller names the OOM error", the owner's choice), and the changes that follow from?needing one error type (§17.4). - Scope:
async fn, async closures,await, thesuspendeffect,Async.run, structured scopes, cancellation, and the frame ABI between ceesafec, the runtime andlib/std. The first CeeSafe language decision after the fork (Decisions 0006, 0007). The design and its rationale aredocs/async-await-proposal.md(revised after a cross-check against the tree, 2026-10-31); this record fixes the choices the implementation depends on.
1. Context
CeeSafe has no concurrency other than threads (§38: Thread.spawn, Thread.both, Arc, Mutex, the atomics). async, await and yield are reserved (language-spec.md §1) and lexed as keywords (src/syntax/token.cee) but used nowhere.
Rust's async shows ten failure modes (the proposal, "What goes wrong in Rust and others"). Most come from three choices: futures that move (hence Pin), tasks that outlive their spawner (hence 'static + Send), and cancellation by dropping a suspended future. CeeSafe can avoid all three because of rules it already has:
- a panic aborts (OD-5), so a suspended frame is never unwound;
- context effects exist (§32), so blocking in async and a spin guard across
awaitcan be refused; - fork-join borrowing exists (§38.6, ADR 0018);
- scoped collections of closures exist (§25, §36.2);
- allocators are explicit (§18, §30).
Facts from the tree that constrain the design:
- NIR has no call terminator: a call is an rvalue (
intermediate-representation.md). - The C structurer falls back to flat form on an irreducible graph (
c-backend.md§3). - The interpreter runs NIR on an explicit activation stack.
- Every console, file and socket operation has
sleep(§32.1). - There are no channels yet (ADR 0016 left them for a later increment).
2. Options
| Model | Verdict |
|---|---|
| A. Stackless frames that never move, structured scopes, cancellation as a flag | Proposed |
| B. Stackful coroutines or green threads | Needs stack switching per architecture, which C11 cannot express; kernel stacks are small |
| C. Effect handlers (OCaml 5) | The same stack switching, and a large new language feature |
D. Rust's model (poll, Pin, cancel by drop, detached tasks) | The source of the problems A avoids |
3. Decision: A
3.1 The language
async fn f(…) -> T. A call to it is a task. In v1 a task must be awaited at once (await f(x)), passed toAsync.run, or spawned into a scope (§3.3). Anything else is N0342. There is no first-class task value.await eis a prefix operator. It is allowed only in an async fn or async closure body (N0341: a sync function, adropblock or a sync closure). Its operand is a primary followed by calls, member accesses and indexes; the first?or!ends the operand and applies to the awaited value, soawait f(x)?is(await f(x))?andawait f(x)?.lenis((await f(x))?).len(a task is never aResult). Its type is the task's result. (Wording corrected 2026-10-31 while writinggrammar.md: the accepted text said "binds looser than postfix operators", which would groupawait (f(x)?), contrary to its own example; the example is the decision.)- Async closures,
async (…) => …, are closure literals whose call is a task (§36.2's capture rules apply unchanged). They are accepted where a task-producing closure is expected:spawn,race,all,shield. - Recursion through
awaitwould make a frame infinitely large, so it is N0343. Its sure fix,await Async.boxed(alloc, f(…)), comes with L1 A3.
3.2 Effects (amending §32 and §36.3)
A third effect,
suspend: the code may await.@effects(…)and effect lists on function types accept it (N0290 as before for other names).suspendis not in the default effect list of a function type written without one, because sync code cannot await.New contexts and restrictions:
Context Forbids Diagnostic An async fn or async closure body sleepN0295, with the call chain; Async.blockingis the bridgeA live @context(atomic)value (§32.3's liveness rule)suspendN0295 @context(irq)suspend, in addition tosleepandallocN0295 Calls through closures.
Async.scope,spawn,race,allandshieldare@effectsOftheir closure arguments (§32.5).
3.3 Structured concurrency
- Scopes.
Async.scopeIn(alloc, oom, (s) => …)runs its body, which may calls.spawn(async () => …). It returns only after every child has returned. Child frames are placed withalloc.Async.scope(oom, body)uses the system heap and hasalloc.spawnreturnsvoid; a child whose frame cannot be allocated is not started, the others are cancelled, and the scope returnsErr(oom)(amended, §6). - The scope-loan rule.
sis typed as a scoped collection of closures whose region is the body's environment, ands.spawn(c)checks like apush(c). Hence:- captures that conflict across spawns are N0241, by the existing borrow rules;
- nothing a child captures can be changed, moved or ended by the body before the scope ends;
- a child that captures one of the body's own locals, which end before the children do, is N0344.
- No
SendorSharecheck on children's captures, because children run on the scope's own thread.Async.blocking(closure)runs its closure on a worker thread, so its captures follow §38.6's rule (N0334). - The result. The scope's result is the first error a child returns. The other children are then cancelled (§3.4), and the scope still waits for them.
- Combinators (amended, §6).
Async.race(a, b)returns the winner and hands back the loser's value if it also finished (Raced<T>).Async.all(a, b)runs both and returns the first error, with results through captures.- Both take two async closures and embed their frames in the caller's, so they never allocate.
- There is no detached spawn in v1.
3.4 Cancellation
- Cancelling a task sets a flag. It never destroys a frame. A frame is freed only after its task returns.
- Who sees the flag. Only the library's operations that would block observe it: timers, I/O, channel receives, plus
Async.checkpoint(cancelled)andAsync.isCancelled(). The library's operations returnErr(AsyncError.Cancelled), whereAsyncError(lib/std) hasCancelled,TimedOutandIo(IoError).checkpoint(cancelled: E)returnsResult<void, E>(amended, §6). - Cleanup is ordinary code on the
?path, and destructors run in the normal order. - Shields.
Async.shield(deadline, body)makes those operations ignore the flag. Once the deadline passes they returnErr(TimedOut), neverCancelled. dropblocks stay synchronous (§3.1, N0341).
3.5 Threads, executors and the library
One executor per thread; a task never moves between threads. Tasks need not be
Send. Several cores are used by several threads, each withAsync.run, connected by channels (ADR 0032, L1 A2).Built in and library. The compiler builds in only what needs frame layout or control flow:
async fn, async closures,await;Async.run,scope/scopeIn,spawn,race,all,shield,checkpoint,blocking;- two primitives,
Async.park()(suspend the current task until woken) andAsync.waker()(its wake handle). They are accepted only in library files (N0345).
Timers, console, files, sockets,
Async.MutexandChannelare library code inlib/stdover runtime functions with honest@effects(Decision 0008).Console output from async code waits for the descriptor to be writable (
poll) and writes at mostPIPE_BUFbytes per step. POSIX guarantees such a write does not block, so the runtime function declares@effects().Profiles. Async is in the hosted profile until L1 A4. The frame ABI is in every profile.
3.6 Lowering (amending intermediate-representation.md and c-backend.md)
NIR.
- A
StartTask(callee, args)rvalue initialises a frame local, which holds the loans of its arguments, as a closure environment does. - An
Await(frame, dest, resume)terminator ends a block. - A
Park(resume)terminator implementsAsync.park(). - The borrow, move and drop analyses treat both terminators as a call.
- A coroutine pass after the effect checker numbers the states and builds the await graph (N0343).
- A
Frames. Async fn
Fbecomes three pieces of C:struct ceeF_Fholds the parameters, every local, one child-frame field per await site, the result and the state;F_initfills in the arguments;F_resume(frame, task)returns false while the task is pending.
A frame is placed once (on
Async.run's caller's stack, in its parent's frame, or from a scope's allocator) and never moves, so a borrow of a local acrossawaitstays valid. In v1 every local lives in the frame; L1 A3 shrinks frames by liveness and adds--explain-frames.Resume functions are always emitted in flat form. The state
switchmakes the graph irreducible for anawaitinside a loop.The runtime.
ceesafe_rt_async.cholds the executor, the timers and the reactor (pollin A1, kqueue and epoll in A2), plus wakers by id.ceesafe/rt.hdeclares the task header: the resume function, root frame, cancel flag, shield depth, scheduler links and waker.Determinism. The interpreter runs async code on a virtual clock with a seeded run order. A
CEESAFE_CANCEL_AT=ksweep cancels at every suspension point, modelled onCEESAFE_FAIL_ALLOC_AT.
3.7 Diagnostics
| Code | Meaning |
|---|---|
| N0341 | await outside an async fn or async closure |
| N0342 | A call of an async fn that is not awaited, run or spawned |
| N0343 | Recursion through await (a frame that contains itself) |
| N0344 | A spawned child that captures the scope body's own locals |
| N0345 | Async.park or Async.waker outside the library |
N0290 and N0295 extend to suspend.
4. Consequences
- Eight of the ten Rust failure modes become compile errors or cannot arise. Colouring is kept on purpose, and the debugger's task tree is a tooling task (L1 A3).
- The compiler itself does not use async, so the fixed point is unaffected until a later decision makes it do so.
lib/stdgainsasync.cee(A1) andchannel.cee(A2, with ADR 0032).- The interpreter gains a task scheduler (A2). Scopes and their bodies must run as activations, never through the interpreter's nested calls.
5. Questions for the owner
Each has a recommended answer, which §3 assumes.
- First-class
Task<T>values in v1? No (§3.1.1). - How
Cancelledis typed: a case of every async I/O error enum, withcheckpoint()returningResult<void, Cancelled>. Amended (§6): one library enum,AsyncError, andcheckpoint(cancelled: E)(§3.4.2). raceand a loser that also finished: hand back its value (§3.3.5).awaitand?:await f(x)?is(await f(x))?(§3.1.2).- Awaiting in a
deferordropinside an implicit shield: not in v1 (§3.4.5). - Resume functions: flat C in v1 (§3.6.3).
- Scope storage: an explicit allocator,
scopeIn, withscopeon the system heap (§3.3.1).
6. Amendments (2026-10-31)
? propagates only an error of the same type (type-system.md §17.4: there are no From conversions). Three accepted choices did not respect that, and were changed while §40 was written:
- A scope's allocation failure. As accepted,
spawnreturnedResult<void, AllocError>, which cannot become the scope'sResult<void, E>. Asked, the owner chose the caller-named error:Async.scope(oom, body)andscopeIn(alloc, oom, body), withspawnreturningvoid(§3.3.1). checkpoint. For the same reason,checkpoint()returningResult<void, Cancelled>could not be propagated with?from a function with its own error type. It takes the error to return:checkpoint(cancelled: E).isCancelled()is added for code that decides for itself (§3.4.2).- The I/O error type. Adding
Cancelledto the built-inIoErrorwould break every exhaustivematchon it. The library's async operations returnAsyncErrorinstead, which wrapsIoError(§3.4.2). raceandallareAsync.race(a, b)andAsync.all(a, b)rather than scope methods, with two children whose frames are embedded. They then need no allocator and no out-of-memory error (§3.3.5).
7. Implementation
None yet. ROADMAP.md (language track L1) records each phase as it is run.