ADR 0033 — L1 A4d: a scope child per loop iteration, with its own values
- Status: Accepted by the owner, 2026-11-01 ("A now, B later"); implemented: §3.1 first, then §3.2–3.3 (the owner's "Go with ADR 0033 §3.2–3.3").
- Scope: what a child spawned in an
Async.scopebody may capture (type-system.md §40.5; ADR 0031 §3.3 and §6.6). Found by the reactor benchmark (L1 A4b, ROADMAP §10).
1. Context
A scope's body is a block of the awaiting function (ADR 0031 §6.6). A child captures by reference, and its environment lives in the body's block, so a child that captures one of the body's own locals is N0344: the local ends with the body while the child may still run. That rule is sound, but it leaves a common program with no spelling:
await Async.scope(AsyncError.Cancelled, (s) => {
for (i in 0..workers) {
s.spawn(async () => await work(i)); // N0344: i is the body's
}
for (c in streams.view()) {
s.spawn(async () => await hold(c)); // N0344: c is the body's
}
})?;Checked 2026-11-01 with ceesafec at 844e755: both loops are N0344, even for a u32. The only workaround is recursion through Async.all and Async.boxed, one boxed frame per value (benchmarks/async-reactor/reactor.cee).
What a child sees through a by-reference capture of a Copy local cannot differ from a copy taken at the spawn:
- the body may not use a local after a child captured it (N0241);
- the awaiting function is suspended from the body's end until the scope returns;
- a child that changes a capture captures it mutably, not as a copy.
2. Options
| A. Copies by value, automatically | B. async move children | C. A spawn argument | |
|---|---|---|---|
| Spelling | s.spawn(async () => await work(i)) as written | s.spawn(async move () => await work(i)) | s.spawn(i, async (i) => …) |
Copy values (u32, borrow T) | yes | yes | yes |
Owned values (a Vector built in the body) | no (still N0344) | yes: moved into the child | yes |
| New syntax | none | async move (as move literals, §36.5) | a second spawn form |
| Change to programs that compile today | none (they never capture a body local) | none | none |
3. Proposed decision: A, then B
- A child's
Copycaptures are held by value in its own frame.spawncopies them when it starts the child, whoever owns the captured local, so aCopylocal of the body is no longer N0344. A child keeps capturing by reference what it changes, and what is notCopy. - A copied borrow keeps its loan until the scope returns. A captured
borrow T(or aCopystruct holding one) is a value whose loan the scope-loan rule now follows: its referent must outlive the scope. A borrow of a body local is N0344 still, with the referent named. A borrow of the awaiting function's locals, or of anything they own, is allowed. That is thefor (c in streams.view())loop. async move () => …children (B) move non-Copycaptures into the child's frame, as amoveliteral moves into its environment (§36.5). The body cannot use what it moved. The child drops what it owns when it returns or is cancelled, and a frame that cannot be allocated drops the moved values with the error.- Race and all children (§40.4) follow the same rules. Their frames are the caller's, so a copy lives as long as the race.
- The interpreter already gives each child its own copy of its environment, and keeps doing so.
A first, because it makes the reported program compile as written with no new syntax. B second, as its own step, because it adds syntax and drop obligations to frames.
4. Consequences
- type-system.md §40.5: the scope-loan rule distinguishes copied captures (by value; only their loans are followed) from referenced ones (as today). diagnostics.md: N0344's text names the referent of a copied borrow.
- The C backend: a child closure's environment gains the copied values as fields, which
ceeAI_copies into its frame. The frame-size report (--explain-frames) counts them. - Tests:
- run-pass: a loop of children over indices, and over a vector's elements by borrow;
- check-fail: a copied borrow of a body local (N0344), and a changed capture in a loop (N0344 still);
benchmarks/async-reactor/reactor.ceewritten with the loop, not the recursion.
5. Questions for the owner
- A, copies by value without new syntax? Yes.
- B,
async movefor owned values, as a later step? Yes, after A.
6. Implementation (2026-11-01)
- §3.1, done. While a
spawnchild is checked (Checker.copyCaptures), a capture it only reads whose type isCopyand not scoped becomes a copy (CaptureMode.Copy,src/check/closures.cee).checkSpawnalready exempted copies from N0344 and N0241. Every async closure's frame holdscee_env, its own copy of the environment, whichceeAI_copies from the closure value (src/backend/c_async.cee; the frame report counts it). The closure value is one temporary per literal in the awaiting frame, made again by each iteration of a loop, so without the copy every child of a loop would read the last iteration's environment. N0344's message now says what a child can copy. - §3.2, done (2026-11-01). A copied capture may be scoped (
borrow T). The borrow checker (src/nir/borrow.cee,collectHolders) makes each spawn's environment a holder: the scope's wait uses it, and making it again does not end it (its points-to set already has every iteration's loans), so its loans live from each spawn until the wait. A newBuiltin(AsyncScopeStart, [site])(1007), evaluated where the body starts, ends them there: without it, a scope inside a loop held the previous run's loans across the loop's back edge (run-pass/async-scope'sroundsfound it). It does nothing in C or the interpreter. - §3.3, done (2026-11-01).
async move () => …parses as an async closure withisMove;s.spawnchecks it as a move literal whose result is the scope's (dynFunctionOf, mutable). Its environment is the closure's value, moved into the spawn;ceeAI_copies it into the child's frame, the child thunk drops the copy when the child returns (ownsEnvironment), and a failed spawn drops the original. Anasync movechild may move a borrow in, unlike a boxed move literal (§36.5): its environment is then scoped (capturesLoans), so the holders follow its loans. The interpreter does the same (environmentDrop). Race and all children may not beasync move(N0209). - Tests: run-pass/async-spawn-loop (the loop variable, a body local and a copied borrow), run-pass/async-spawn-move (vectors moved into children, a borrow moved in; live=0 at every injected allocation failure), in C, the interpreter and ASan+UBSan; check-fail/async-spawn-copies (N0344), async-spawn-loans (N0240, N0241, N0243 for copied and moved borrows), async-spawn-moved (N0250), async-spawn-moves (N0209); parse-pass/async-syntax (
async move);benchmarks/async-reactor/reactor.ceespawns its holders in a loop.