ADR 0053 — Task sets: spawning while a task runs
- Status: Accepted by the owner, 2026-11-02 ("Yes, I accept all five ADRs with your recommendations"): every question of §5 as recommended. Asked for by the owner for CeeSafe.Web (
~/dev/CeeSafe.Web/docs/ceesafe-upstream-work.md, item U11). A server starts a task per accepted connection, and an HTTP/2 connection starts a task per stream as streams open. - Scope: a new
Asyncoperation and a lib/std type, the checker and the borrow checker's view of it, both backends, the runtime's scope record, the interpreter.
1. Context
A scope's children are spawned only in its synchronous body (type-system.md §40.5: "s is not a local, and names the scope only in s.spawn(…)"). Everything is built on that:
- The checker resolves
sonly while the body is checked, in the same function (src/check/async.cee,scopeNamed). A child closure is another function, sosis unknown there. - The borrow checker keeps each child's captured loans until the scope's wait, in the same NIR function (
src/nir/borrow.cee, the spawn holders). - The C names the scope as a field of the awaiting frame (
cee_frame->cee_s<site>), which only that function's code can reach. - The runtime sets the scope's owner at each spawn (
cee_spawn:scope->owner = cee_exec.current). A child spawning into its parent's scope would make the owner chain, which cancellation walks, wrong.
So a server today runs a fixed set of worker children, each looping accept-then-serve on a shared listener (CeeSafe.Web's packages/web_transport/tests/workers.cee). The worker count is the connection limit, and every worker's frame exists from the start.
There are no detached tasks, and this ADR keeps it so (§40.5: "There is no detached task"). A task set is a scope whose children are started while it runs, and it waits for all of them before it returns.
2. Options
What dynamically started children may capture.
| A. Owned values only (proposed) | B. Loans, as a scope's children | |
|---|---|---|
| What | async move closures whose captures are owned or Copy and hold no loan; shared state through Arc | children borrow from the task that started them, as a scope's do |
| Cost | a shared listener or configuration goes in an Arc, or is passed down by value | a child may outlive the task that started it, so its loans would have to reach past that task's frame: a borrow-checker rule across functions and tasks that does not exist |
A is proposed. It is sound with the rules that exist: a child holds nothing that can end before it does. A connection's task owns its stream, and what it shares it shares through Arc.
3. Proposed decision
3.1 The operation
await Async.tasks(limit, oom, async (set) => {
loop {
let stream = await listener.accept()?;
await set.spawn(async move () => await serve(stream))?; // waits while `limit` children run
}
})(Note, ADR 0074 §2.3: until 0.18 a child could not move a capture out, and this example was N0251. A child's body runs once, so it now may.)
- The operation.
await Async.tasks(limit, oom, body)isResult<void, E>:limit: u32is the most children live at once;oom: Eis the result if a child's frame cannot be allocated;bodyis an async closure takingset: borrow TaskSet<E>and returningResult<void, E>.
- The body runs as the set's owner, like any awaited call. When it returns, the set waits for every child.
Async.tasksIn(alloc, limit, oom, body)takes the children's frames fromalloc, asAsync.scopeIndoes.- The handle.
setis an ordinaryborrowparameter. The body may pass it to the functions it calls and awaits, so an accept loop can live in its own function. A spawned child cannot capture it: by §3.2, it holds no loan.
3.2 Spawning
await set.spawn(child)startschild, anasync moveclosure literal of resultResult<void, E>, and isResult<void, E>:- while
limitchildren are live, the spawning task waits for one to return (admission, as backpressure); - once the set is cancelled, it returns the cancellation's error and starts nothing;
- if the frame cannot be allocated, it returns
Err(oom), and the set goes on.
- while
set.trySpawn(child)isbool: false whenlimitchildren are live or the set is cancelled, without waiting. It is not a task, so it needs noawait.- Captures (option A): each capture of
childis moved or copied into it, and its type holds no loan: no reference, slice orStringView, and no struct with region parameters. Otherwise N0349, naming the capture.spawnSend's thread rule (§40.5, ADR 0038) applies to aset.spawnSend, which may run on another worker.
3.3 Errors and cancellation
- A child's
Errdoes not cancel its siblings. One failed connection must not end the others. The set keeps the first child error. - The set's result, once the body and every child have returned:
- the body's
Errif it returned one; - else the first child error, or
oomif a frame could not be allocated; - else
Ok.
- the body's
- The body's
Err, or a cancellation of the set's owner, cancels every child (§40.7's flag), and the set waits for them. - The owner is fixed when the set starts. A child spawned by any task of the set has the set's owner in its chain, so cancellation reaches it.
3.4 Lowering and the runtime
- The set's record is a
ceesafe_scopewith a limit and a count, in the frame of the task that awaitsAsync.tasks, as a scope's is.TaskSet<E>is lib/std's handle to it: a reference to the record, which the body takes byborrowand so cannot outlive. The C reaches it through the handle, from any function. ceesafe_async_spawnlearns about sets:- the owner is set once, at the set's start;
- a full set parks the spawning task until a child returns (a wait list on the record);
- a returning child wakes one waiting spawner.
- The interpreter looks a set up by its handle, not by the current task and site as it does a scope today. So a spawn from any task of the set reaches the set.
4. Consequences
- CeeSafe.Web's server starts a task per connection, up to a limit, with no worker frames allocated in advance, and HTTP/2 a task per stream.
- Sharing goes through
Arc(or values passed down). That is the price of children that may outlive the task that started them. - A scope's
s.spawnis unchanged. Children that borrow from their caller stay the static form. - New diagnostic: N0349, a set child's capture that holds a loan.
5. Questions for the owner
await Async.tasks(limit, oom, async (set) => …)andAsync.tasksIn(alloc, …), the handle aborrowparameter the body may pass down but no child may capture? Recommended: yes.- Children capture owned or
Copyvalues only, with no loans, sharing throughArc(option A)? Recommended: yes. await set.spawnwaits whilelimitchildren are live;set.trySpawnreturns false instead? Recommended: yes.- A child's
Errdoes not cancel its siblings; the set reports the body's error, else the first child's? Recommended: yes.
6. Implementation plan (once accepted)
- Spec:
- §40.5 (task sets beside scopes, the capture rule and N0349);
- §40.7 (cancellation through the set's owner);
- c-backend.md §3a;
- intermediate-representation.md;
- diagnostics.md;
- the guide's async page.
src/:- the checker (the operation, the handle's type,
spawn/trySpawnon it, N0349); - HIR and NIR forms;
- the C backend (the record in the frame, spawn through the handle);
- the interpreter's sets.
- the checker (the operation, the handle's type,
- Runtime: the record's limit, count, waiting spawners and fixed owner;
tools/gen_runtime.py. - lib/std:
TaskSet<E>;tools/gen_library.py. - Tests:
- corpus run-pass: an accept loop starting a task per connection, with more clients than the limit, all served; a full set making the spawner wait;
trySpawnrefused when full; a child's error not cancelling siblings; the body's error and an outer cancellation cancelling every child;oomfromtasksInwith a failing allocator; the handle passed to a function that spawns; - check-fail: N0349 for a borrowed capture, a slice, a
StringView; the handle captured by a child; tools/cancel_sweep.py, the allocation-failure sweep with--check-leaks, TSan forspawnSend(ADR 0038);- every reactor and Windows.
- corpus run-pass: an accept loop starting a task per connection, with more clients than the limit, all served; a full set making the spawner wait;
- ROADMAP §10.
7. Implementation notes (2026-11-02)
- The body through ADR 0037.
Async.tasks(limit, oom, body)is checked as a call of lib/core'sAsyncTasks<E>.run, an@effectsOf(body)async fn whose async instance awaits the body in the awaiting task: the set's owner, as §3.1 says. Its frame holds the record (TaskSetRecord<E>: the runtime'sceesafe_task_set, then the first child error), and after the body it waits for the children withceesafe_async_set_doneandAsync.park. So the compiler needs no form for the body or the wait, and the borrow checker no rule: only the start is the compiler's. - The start.
await set.spawn(child)is checked asawait TaskSet.spawned(set, SetSpawn(set.recordOf(), await set.room(), child)), andtrySpawnwithtryRoomandstarted.SetSpawnis a HIR form and the NIR builtinAsyncSetSpawn, emitted in any function (a sync one may calltrySpawn).setmust be a local or parameter of typeTaskSet<E>or a reference to one, since it is read once per step. - Readings of §3.2 and §3.4.
spawn'sErr(oom)may come many times, soEmust beCopy(N0268 atAsync.tasks).- A spawn on a cancelled set has no error of
Ethat means cancellation, so it starts nothing and isErr(oom); its task is being cancelled, and so is its set's result. - Only the owner can spawn (a child cannot capture the set), so the runtime keeps one task waiting for room, and the owner never changes.
- A
limitof 0 is 1.
Async.tasksIn(the second commit). A spawn in another function does not know the allocator's type, so the set is given its adapters when it starts: lib/core'sAsyncTasksIn<E, A>.runcallsAsync.setAllocator(record, alloc), a library-only primitive (N0345), whose builtinAsyncSetAllocatorcarriesAand emitsceesafe_async_set_allocator(record, ceeAA_<A>, ceeAD_<A>); the runtime's set spawns take their blocks from them.Async.tasksisAsyncTasksIn<E, SystemHeap>(its frames are the system heap's either way).tasksInworks in the freestanding profile;tasksis hosted only.set.spawnSendchecks ADR 0038's rule on the child's captures (each moved in, so each must beSend) and onE, and callsceesafe_async_set_spawn_sendandceesafe_async_publish. A set withspawnSendchildren is shared: admission and its wait for room take the run's lock, asscope_donedoes. With a user allocator (tasksIn) the child runs on this worker, since a frame from it is freed where the child returns.- The interpreter takes a user allocator's blocks for a set's children as for
scopeIn, and models FailingAllocator's attempts (failAfter). - The interpreter models the record (its InterpScope, flagged as a set), the room and done waits (parked until a child returns, through
asyncWoken), the body's cancellation, and the start. - Tests: run-pass/async-task-set (ten children under a limit of three, started from a function the set is passed to; a child's error that cancels no sibling; the body's error cancelling every child;
trySpawnrefused while full; an outer timeout reaching every child), natively, in the interpreter, under the cancellation sweep; check-fail/ async-task-set (N0349 for a reference, a slice, aStringViewand the set itself; N0209; N0342; N0268); the guide's example.