From 16ab004c7466f3e13347c6cbfa9d3946a835bde9 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 10:54:17 +0200 Subject: [PATCH 01/15] docs(brief): add M1.0.11 milestone brief --- briefs/M1.0.11-async-core.md | 177 +++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) create mode 100644 briefs/M1.0.11-async-core.md diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md new file mode 100644 index 0000000..1bbf047 --- /dev/null +++ b/briefs/M1.0.11-async-core.md @@ -0,0 +1,177 @@ +# M1.0.11 — Async suspension core (await family + async fn execution) + +> **Status:** PLANNED +> **Phase:** 1 +> **Branch:** `phase-1/etch/async-core` +> **Planned tag:** `v0.10.11-async-core` +> **Dependencies:** M1.0.10 (base tag `v0.10.10-structural-mutation`) +> **Opened:** 2026-07-01 +> **Closed:** — + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +Phase 1, Etch tree-walking interpreter. M1.0 closes the EBNF v0.6 execution gaps (criterion C1.6) across M1.0.0–M1.0.20. The async algebra is only partially executed today: `async rule` runs with a *top-level* `await wait` / `await global_event`; `async fn` and `async method` calls return `RuntimeFailure`; `await wait_unscaled` / `entity_event` / `future` return `RuntimeFailure`; `race` / `sync` / `branch` / `spawn { }` do not execute. This milestone delivers the async **suspension core**: a general suspendable-task substrate (replacing the per-rule special case), `async fn` / `async method` execution, the `await` targets this milestone owns (`wait`, `global_event`, and `future` for a direct async call), and function coloring. It does **not** build the concurrency algebra (M1.0.12), the time subsystem (M1.0.13), or entity-scoped events (M1.0.14). + +## Scope + +The work is sequenced as five gated sub-steps E1–E5 (see the gate protocol in the Claude Code prompt). Each is a deliverable group with its own acceptance. + +- **E1 — Task substrate + resume frame-stack.** Replace the per-rule `async_slots` (one `AsyncSlot` parallel to `rule_descs`) with a dynamic, heap-allocated `AsyncTask` pool. Each task carries a **resume frame-stack**: a stack of `(block, statement cursor, scope)` frames. Re-host the existing `async rule` (`wait` / `global_event`, top-level) on the new substrate. No new constructs in this step. +- **E2 — `async fn` / `async method` execution.** Remove the two `RuntimeFailure` early-returns that gate async fn/method interpretation. A direct `await f()` where `f` is async runs `f`'s body as additional frames on the **caller's** task; `f`'s own `await` suspends the whole task; `f`'s `return v` resolves at the caller's await site. Value-await binding `let x = await f()` works. +- **E3 — Owned `await` targets + Duration `wait` + placement rule.** (a) `wait` accepts a `Duration` as its final API, evaluated via a minimal Duration-literal→seconds path and converted to the logical tick clock via a Phase-1 **fixed-timestep constant** (the 1/60 frame convention). (b) `global_event` re-hosted on the new substrate. (c) `future` for a direct async call (delivered by E2). (d) New diagnostic **E0904** (`await_not_statement_head`): an `await` that is not the full right-hand expression of a statement is rejected — a Phase-1 tree-walker restriction (the spec assigns no code for it, since §9.5 shows a sub-expression `await` as valid). +- **E4 — Function coloring.** Enforce the §9.3 coloring matrix with the spec-assigned code **E0901** (`AsyncCallInNonAsyncContext`, `etch-resolver-types.md §9.2`): an `async fn` / `async method` called from a non-async context, **or** an `await` used in a non-async scope — both are the same async-effect-in-non-async-context violation. A legal async→async call via `await` passes. +- **E5 — CLAUDE.md + in-repo docs.** Update `CLAUDE.md` per `engine-development-workflow.md §3.4` (current-state table, +1 Tags-table line, open-decisions / scope-boundary note for the await-family partition, "Last updated" date). Add thorough doc-comments documenting the Phase-1 async model (task pool, frame-stack, statement-head placement, fixed-timestep `wait`, coloring) on the touched interpreter and type-checker code. + +## Out of scope + +The following could look in-scope but are **not** in this milestone. Several remain `RuntimeFailure` or a fail-loud parse error; that is correct — the milestone that owns each one closes it. + +- `race` / `sync` / `branch` / `spawn { }` — **M1.0.12**. The `spawn {` parse seam stays fail-loud; `race` / `sync` stay reserved in `non_s3_keywords`; the `branch` async-statement form stays fail-loud. No keyword graduation here. +- `await` on a stored `TaskHandle` / `Future` (handle-await) — **M1.0.12** (arrives with `spawn`). Only the **direct-call** `future` form is delivered here. +- `await wait_unscaled` — **M1.0.13** (needs the scaled/unscaled time subsystem). Stays `RuntimeFailure`. +- Timers `after` / `every` / `after_unscaled` and `quantize` — **M1.0.13**. A distinct mechanism (scheduled callbacks, not suspension). +- `await entity_event` — **M1.0.14** (needs entity-scoped events). Stays `RuntimeFailure`. +- Entity-bound `async rule` (one task per matching entity). Stays `RuntimeFailure` (the M0.8 boundary). Later milestone. +- **Cancellation** of any kind. It arrives with `race` (M1.0.12), Phase-1 non-transitive. +- The bytecode async lowering (poll fn / `ASYNC_YIELD` / state struct) — Phase 2. +- The `etch-reference-part1.md §9` KB update recording the tree-walker model and the §9.4 drift fixes — a **Claude.ai** deliverable produced after merge as a complete re-uploadable file, not a repo change in this milestone. + +## Specs to read first + +Mandatory before writing any code; checked off in the LIVING SECTION. + +1. `etch-reference-part1.md` — §9 (§9.1 execution model, §9.2 `async fn`/`async rule`, §9.3 coloring matrix, §9.4 await targets, §9.9 sequential vs parallel). The reference semantics this milestone must reproduce. +2. `etch-resolver-types.md` — §9 (§9.1 EffectSet, §9.2 propagation + the async/effect diagnostic codes **E0901–E0903**, §9.4 builtin effects). The authority for coloring rules and codes. +3. `etch-grammar.md` — §4.2 (`await_target`). +4. `etch-memory-model.md` — §4 (persistent heap + deterministic refcount), §5.7 and §7.1 (async state-struct layout + lifetime), §10.2 (async fn end-to-end). +5. `etch-ast-ir.md` — §3.4.2 (`StmtKind`), §5.3 (await desugaring — the Phase-2 shape the tree-walker stays behaviorally identical to). +6. `etch-bytecode.md` — §9 (the Phase-2 async lowering — context only, **not built here**; §9.5 the Phase-1 non-transitive cancellation boundary, for context). +7. `engine-development-workflow.md` — §3.4 (CLAUDE.md update), §4.3 (commits / language), §4.6 (squash format), §4.7 (merge + tag procedure). +8. `engine-zig-conventions.md` — Zig 0.16.x conventions. + +## Files to create or modify + +Files outside this list must not be touched without a justification logged in "Execution log". + +- `src/etch/interp.zig` — modify — async task pool replacing the per-rule `async_slots`; resume frame-stack `(block, cursor, scope)`; lift the `async fn` / `async method` `RuntimeFailure` gates (frame inlining); `wait` Duration handling + the fixed-timestep constant + minimal Duration-literal→seconds eval; re-host `global_event`. Inline `test "…"` blocks (the existing async-rule tests live here). +- `src/etch/types.zig` — modify — function coloring (E1301 / E1302) and await placement (E1300); await-target recognition. Inline `test "…"` blocks (the existing type-check tests live here). +- `src/etch/diagnostics.zig` — modify — add two diagnostic kinds in the effect/async block E09xx: `async_call_in_non_async_context` (**E0901**, spec-assigned by `etch-resolver-types.md §9.2`) and `await_not_statement_head` (**E0904**, the Phase-1 placement restriction), with their short-code and name mappings (the E09xx block is free in the code map). +- `tests/etch_interp/programs/_async_core.etch` — create — integration Etch program (next available ordinal) exercising an `async rule` calling an `async fn` with sequential value-awaits and the owned targets. +- `CLAUDE.md` — modify — §3.4 patch (current-state table → Next M1.0.12; +1 Tags-table line on close; open-decisions note; "Last updated"). +- `briefs/M1.0.11-async-core.md` — create — this brief, committed verbatim as the branch's first commit. + +`src/etch/parser.zig`, `src/etch/token.zig`, and `src/etch/ast.zig` are **not** expected to change: the five `await` targets already parse, `await` / `async` are already graduated keywords, `AwaitExpr` already exists, and the task pool / frame-stack are interpreter-internal state (not AST). Any change to them is a deviation to log. + +## Acceptance criteria + +### Tests + +Inline `test` blocks, in the file under test (Zig convention), plus the integration program. + +- `src/etch/interp.zig` — the existing async-rule behaviors remain green on the new substrate: `test "async rule suspends at await wait(...) and resumes …"` (migrated to the Duration form `wait(s)`), `test "async rule resumes on await global_event(T) …"`, and `test "async runtime failure surfaces typed last_error …"`. +- `src/etch/interp.zig` — `test` — an `async fn` called from an `async rule` with an internal `await wait` / `await global_event` runs to completion across ticks; the return value flows into a `let x = await f()` binding. +- `src/etch/interp.zig` — `test` — an `async method` called via `await` behaves the same. +- `src/etch/interp.zig` — `test` — a statement-head `await` inside a nested block (`if` / `loop` body) suspends and resumes at the correct cursor without re-running prefix statements (no double `emit`). +- `src/etch/interp.zig` — `test` — `await wait(s)` resumes at the fixed-timestep-equivalent tick count. +- `src/etch/interp.zig` — `test` — `await wait_unscaled(...)`, `await entity_event(...)`, and a handle-await still fail loud (surface a typed `RuntimeFailure`) — confirms the partition boundary is intact. +- `src/etch/types.zig` — `test` — E0904 fires on a sub-expression `await` (`return some(await f())`) and does **not** fire on the statement-head forms (`let x = await f()`, `x = await f()`, `return await f()`, bare `await f()`). +- `src/etch/types.zig` — `test` — E0901 fires on an `async fn` called from a sync `fn` / sync `rule`, and on an `await` used in a sync context; a legal async→async call via `await` passes. + +### Benchmarks + +None. A sync-only program must remain byte-identical to the pre-milestone runtime (no async task pool allocation, no clock churn) — assert this property by construction, not by a benchmark. + +### Observable behavior + +- Running the integration program `tests/etch_interp/programs/_async_core.etch`: an `async rule` calls an `async fn` performing `let a = await ; let b = await wait(1.0s); …`, completes across several ticks, and emits an event the harness observes at the expected tick. + +### CI + +- `zig build` clean, zero warnings, on the configured matrix (`{ubuntu-24.04, windows-2025} × {Debug, ReleaseSafe}`) +- `zig build test` green (Debug + ReleaseSafe) +- `zig fmt --check` green +- `zig build lint` green (once the custom linter exists) +- `commit-msg` hook green on every commit of the branch + +## Conventions + +- **Branch:** `phase-1/etch/async-core` +- **Final tag:** `v0.10.11-async-core` (posted by Guy after merge; tag-name format follows the real M1.0.x tags — no `M` segment in the tag name) +- **PR title:** `Phase 1 / Etch / Async suspension core` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +**The tree-walker async model (design contract for this milestone).** + +The spec's async implementation is compiled state machines (`etch-bytecode.md §9`, Phase 2). Phase 1 is the tree-walker; this milestone **defines** its async mechanism, behaviorally identical to the spec's observable semantics (`etch-reference-part1.md §9`) but **not** the bytecode lowering. This is the same pattern as M1.0.9 (text re-parse, not bytecode). + +- **No fibers, no per-task OS thread** (`etch-reference-part1.md §9.1`). A task is a heap record holding a **resume frame-stack**: a stack of `(block, statement cursor, scope)` frames. An `await` at a statement-head position suspends by recording the frame-stack plus the wake condition; resume re-enters at `cursor + 1` in the innermost frame and **never re-runs prefix statements**, so `emit` and structural mutations never double-fire. This generalizes the M0.8 single-top-level-cursor `AsyncSlot` to nested blocks and a dynamic task pool. The substrate must support a frame-stack of depth greater than one. + +- **Await placement (Phase-1 restriction, E0904).** `await` must be the **full right-hand expression** of an expr-statement, a `let` initializer, an assignment RHS, or a `return` operand, in any statement block (including `if` / `else` / `loop` / `while` / match-arm bodies). A sub-expression `await` — `some(await f())`, `f(await g())`, `a + await b` — is rejected; the author hoists it into a `let` (`let t = await f(); return some(t)`). This is semantics-preserving (`await` has no effect beyond suspend + resolve). The Phase-2 bytecode VM removes this restriction. + +- **`async fn` / `async method` via frame inlining.** A direct `await f()` (f async) pushes `f`'s body frames onto the caller's task; `f`'s own `await` suspends the whole task; `f`'s `return v` resolves at the caller's await site. This is the `future` target for **direct** calls; `await` on a stored `TaskHandle` is M1.0.12 (arrives with `spawn`). + +- **`wait` final API = `Duration`** (`etch-reference-part1.md §9.4`). The Phase-1 implementation converts the Duration to the logical tick clock (`async_tick`) via a fixed-timestep constant (the 1/60 frame convention). M1.0.13 swaps this implementation for scaled game time **without changing the signature** — at `time_scale = 1` and fixed `dt = 1/60` the behavior is identical. The interim int-tick form is replaced; existing `wait(int)` call sites and tests migrate to `wait(s)`. Only the minimal Duration-literal→seconds eval needed for the `wait` argument is in scope; general Duration arithmetic stays fail-loud until a milestone needs it. + +- **Function coloring** (`etch-reference-part1.md §9.3`; codes in `etch-resolver-types.md §9.2`) is enforced here because async fn execution makes it load-bearing; the violation code is the spec-assigned **E0901** (`AsyncCallInNonAsyncContext`). It was unnecessary while `async rule` was the only async-bearing construct. + +- **Cancellation: none in this milestone.** It arrives with `race` (M1.0.12), Phase-1 non-transitive (`etch-bytecode.md §9.5`). + +**Drifts to record in the KB later (Claude.ai deliverable, not this milestone's repo scope):** `etch-reference-part1.md §9.4` header says "Quatre formes" while it enumerates five targets (`wait`, `wait_unscaled`, `entity_event`, `global_event`, `future`) — confirm five; the internal `WakeCond` name `wait_until` vs the construct `await wait`; `wait` is a `Duration`, not an int tick count. + +**Gate protocol.** This milestone runs gate-by-gate (one E-step per review cycle). See the Claude Code prompt: branch pushed at creation (`git push -u`), one E-step at a time, STOP at each E-step boundary emitting `étape E terminée, prête pour review`, GO delivered on the real pushed diff (never on a recap). The brief is complete on entry — no avoidable STOP. + +--- + +# LIVING SECTION + +*Maintained by Claude Code during the milestone. The log is not a marketing report: it serves review and post-mortem debugging.* + +## Specs read + +*Check before writing any production code.* + +- [ ] `etch-reference-part1.md` (§9) — read +- [ ] `etch-resolver-types.md` (§9) — read +- [ ] `etch-grammar.md` (§4.2) — read +- [ ] `etch-memory-model.md` (§4, §5.7, §7.1, §10.2) — read +- [ ] `etch-ast-ir.md` (§3.4.2, §5.3) — read +- [ ] `etch-bytecode.md` (§9) — read +- [ ] `engine-development-workflow.md` (§3.4, §4.3, §4.6, §4.7) — read +- [ ] `engine-zig-conventions.md` — read + +## Execution log + +*One entry per logical unit of work. Chronological. Short.* + +- + +## Recorded deviations + +*Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each references the commit that records it. Empty at milestone end = nominal case.* + +- + +## Blockers encountered + +*Blocking points that required a return to Claude.ai. 2+ distinct blockers = re-scope signal.* + +- + +## Closing notes + +*Fill in at Status → CLOSED, just before opening the PR.* + +- **What worked:** +- **What deviated from the original spec:** +- **What to flag explicitly in review:** +- **Final measurements:** +- **Residual risks / tech debt left intentionally:** From 8263354de76eab1e02279ddcba90f364528667f3 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 10:55:17 +0200 Subject: [PATCH 02/15] docs(brief): confirm specs read for M1.0.11 --- briefs/M1.0.11-async-core.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md index 1bbf047..3ab8c65 100644 --- a/briefs/M1.0.11-async-core.md +++ b/briefs/M1.0.11-async-core.md @@ -139,14 +139,14 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § *Check before writing any production code.* -- [ ] `etch-reference-part1.md` (§9) — read -- [ ] `etch-resolver-types.md` (§9) — read -- [ ] `etch-grammar.md` (§4.2) — read -- [ ] `etch-memory-model.md` (§4, §5.7, §7.1, §10.2) — read -- [ ] `etch-ast-ir.md` (§3.4.2, §5.3) — read -- [ ] `etch-bytecode.md` (§9) — read -- [ ] `engine-development-workflow.md` (§3.4, §4.3, §4.6, §4.7) — read -- [ ] `engine-zig-conventions.md` — read +- [x] `etch-reference-part1.md` (§9) — read 2026-07-01 10:52 +- [x] `etch-resolver-types.md` (§9) — read 2026-07-01 10:52 +- [x] `etch-grammar.md` (§4.2) — read 2026-07-01 10:52 +- [x] `etch-memory-model.md` (§4, §5.7, §7.1, §10.2) — read 2026-07-01 10:52 +- [x] `etch-ast-ir.md` (§3.4.2, §5.3) — read 2026-07-01 10:52 +- [x] `etch-bytecode.md` (§9) — read 2026-07-01 10:52 +- [x] `engine-development-workflow.md` (§3.4, §4.3, §4.6, §4.7) — read 2026-07-01 10:52 +- [x] `engine-zig-conventions.md` — read 2026-07-01 10:52 ## Execution log From 667116acad1c416798f1a51d58a584d7956549a5 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 10:56:00 +0200 Subject: [PATCH 03/15] docs(brief): activate M1.0.11 --- briefs/M1.0.11-async-core.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md index 3ab8c65..58cc67d 100644 --- a/briefs/M1.0.11-async-core.md +++ b/briefs/M1.0.11-async-core.md @@ -1,6 +1,6 @@ # M1.0.11 — Async suspension core (await family + async fn execution) -> **Status:** PLANNED +> **Status:** ACTIVE > **Phase:** 1 > **Branch:** `phase-1/etch/async-core` > **Planned tag:** `v0.10.11-async-core` From cefce298453652e958c2e41d8c6417dc661db299 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 11:30:52 +0200 Subject: [PATCH 04/15] feat(etch): async task pool + resume frame-stack (M1.0.11 E1) --- briefs/M1.0.11-async-core.md | 3 +- src/etch/interp.zig | 705 ++++++++++++++++++++++++++++++----- 2 files changed, 608 insertions(+), 100 deletions(-) diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md index 58cc67d..b7d56b4 100644 --- a/briefs/M1.0.11-async-core.md +++ b/briefs/M1.0.11-async-core.md @@ -152,7 +152,8 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § *One entry per logical unit of work. Chronological. Short.* -- +- 2026-07-01 — E1 design (interp.zig). Replace the per-rule `AsyncSlot` (single top-level cursor, `[]AsyncSlot` parallel to `rule_descs`) with a dynamic heap-allocated `AsyncTask` pool (`async_tasks: ArrayListUnmanaged`) + a per-rule `rule_tasks: []?u32` handle map. Each task carries an EXPLICIT resume frame-stack (`frames: ArrayListUnmanaged(AsyncFrame)`, innermost last) of `run`/`loop_`/`while_` frames. `driveTask` is an iterative stack machine: statement-head `await` suspends at ANY depth (top-level and inside `if`/`else`(-if chain)/`loop`/`while`/`match`-arm/`block`), and resume re-drives the persisted stack without re-running prefix statements (no double `emit`) — the explicit-stack model makes resume trivial and side-effect-free (`etch-reference-part1.md §9.12`). Sync control-flow (`if`/`match`/`loop`/`block`/`while`) is mirrored (not delegated) in the driver so a nested body can suspend; all other statements delegate to the existing sync `execStmt`. WakeCond (int-tick `wait` + `global_event`) is unchanged in E1 — the Duration `wait` and E0904/E0901 land in E3/E4. Residual (documented, not in the §9.12 placement list): `await` inside a `for` body and inside a `try` body fall through to sync `execStmt` and fail loud (needs iteration-state / try-frame serialization) — deferred. +- 2026-07-01 — E1 done. `interp.zig`: new async substrate wired (`compile` allocs `rule_tasks`, `deinit` tears down the pool). The 3 existing async-rule tests pass UNCHANGED on the new substrate (re-host is behavior-preserving; int-tick `wait` stays until E3). Added 2 inline tests: `await` inside an `if` body (depth-2 stack + no double `emit`, counted via an `@on_event` observer) and inside a `loop` body (per-iteration resume + `break` unwind). Stale comment fixed (the runtime-failure test cited the removed `driveAsyncBody`/`finishAsync`). Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), and the etch test target `367 pass (367 total)` in BOTH Debug and ReleaseSafe. Sync-only programs keep an empty pool + `null` handle map + no `async_tick` churn (byte-identical by construction). `parser.zig`/`token.zig`/`ast.zig` untouched. ## Recorded deviations diff --git a/src/etch/interp.zig b/src/etch/interp.zig index f58ad8e..23bc386 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -527,27 +527,96 @@ const WakeCond = union(enum) { global_event: StringId, }; -/// Per-`async rule` suspend/resume state (M0.8 E3 sub-slice B — the Option-A -/// task-record, validated by Guy). Held in a slice parallel to `rule_descs`; -/// only `is_async` rules use their slot. This is the interpreter-level analogue -/// of the async state struct (`etch-memory-model.md §5.7`) — at the tree-walk -/// level, no compiled state machine (that is Phase-2 codegen). -const AsyncSlot = struct { - state: enum { unspawned, suspended, done } = .unspawned, - /// Next top-level body-statement index to run on resume. `await` is bounded - /// to a top-level statement (the M0.8 cursor model); nested/value await - /// fails loud (the Phase-2 state machine covers the general case). +/// One frame of an `AsyncTask`'s resume stack (M1.0.11 E1). The tree-walker is +/// its own runtime (`etch-reference-part1.md §9.12`): rather than a compiled +/// state machine (Phase-2 bytecode), a suspended task is a heap record holding a +/// STACK of frames — each a `(block, statement cursor)` position plus the control +/// shape needed to resume it. On `await` the whole stack is retained; `driveTask` +/// resumes by re-entering the innermost frame at its cursor and NEVER re-running +/// an already-executed statement (no double `emit`). The frame kinds mirror the +/// sync executor's suspendable control flow: a linear `run` (rule body / `if` +/// branch / `match` arm / plain `block` / — E2 — an inlined `async fn` body), a +/// `loop`, and a `while`. This generalizes the M0.8 single-top-level-cursor +/// `AsyncSlot` to nested blocks (the stack reaches depth > 1). `for`-body and +/// `try`-body awaits are NOT frame-driven (they fall through to sync `execStmt` +/// and fail loud) — outside the §9.12 placement list, deferred. +const AsyncFrame = union(enum) { + run: RunFrame, + loop_: LoopFrame, + while_: WhileFrame, +}; + +/// A linear statement run: execute `block[cursor .. block_len]`, then (if any) +/// evaluate the trailing `block_expr` value for effect, then pop. Backs the rule +/// body, an `if` branch, a `match` arm block, and a plain `{ }` block. +const RunFrame = struct { + block_start: u32, + block_len: u32, + cursor: u32 = 0, + /// Trailing `block_expr` value expression, evaluated for effect on pop + /// (`null` for a rule body or a value-less block). Statement-position blocks + /// discard the value, but a side-effecting trailing expr must still run. + value_expr: ?NodeId = null, +}; + +/// `loop { body }`: run the body, resetting the cursor to 0 at the end so it +/// repeats; a `break`/`continue` targeting `label` (or unlabeled) is consumed by +/// `unwindControl`. +const LoopFrame = struct { + block_start: u32, + block_len: u32, + cursor: u32 = 0, + label: StringId = 0, +}; + +/// `while [let x =] cond { body }`: at the top of each iteration (`in_iter = +/// false`) re-evaluate the condition; enter the body (`in_iter = true`) while it +/// holds, and drop back to the condition when the body run completes. `while` +/// carries no loop label (mirrors the sync executor's `handleLoopControl(0)`). +const WhileFrame = struct { + while_id: NodeId, cursor: u32 = 0, + in_iter: bool = false, +}; + +/// A suspendable task (M1.0.11 E1) — the dynamic-pool replacement for the M0.8 +/// per-rule `AsyncSlot`. Holds the resume frame-stack (`frames`, innermost last), +/// the wake condition it is blocked on, and the locals retained across +/// suspension. Allocated in `Interpreter.async_tasks` on first spawn; one task +/// per `async rule` in E1 (E2 inlines `async fn` bodies as extra frames on the +/// SAME task; M1.0.12 `spawn` will create sibling tasks in the pool). This is the +/// tree-walk analogue of the async state struct (`etch-memory-model.md §5.7`) — +/// no compiled state machine (that is Phase-2 codegen). +const AsyncTask = struct { + state: enum { suspended, done } = .suspended, wake: WakeCond = .{ .wait_until = 0 }, - /// The task's locals, retained across suspension. POD-only in M0.8 (heap - /// locals surviving a suspend are out of scope — flagged for Review E3). + frames: std.ArrayListUnmanaged(AsyncFrame) = .empty, + /// The task's locals, retained across suspension. POD-only in M0.8/M1.0.11 + /// (heap locals surviving a suspend are out of scope — flagged for Review). locals: Locals = .{}, - fn deinit(self: *AsyncSlot, gpa: std.mem.Allocator) void { + fn deinit(self: *AsyncTask, gpa: std.mem.Allocator) void { + self.frames.deinit(gpa); self.locals.deinit(gpa); } }; +/// Outcome of one `driveTask` pass over a task's frame-stack (M1.0.11 E1). +const AsyncOutcome = enum { suspended, completed }; + +/// Result of stepping one body statement in `stepBodyStmt` (M1.0.11 E1). +const StepAction = enum { + /// A plain statement ran and the cursor advanced. + advanced, + /// A child frame (nested block / loop / while) was pushed; the parent cursor + /// was already advanced past the control-flow statement. + pushed, + /// A statement-head `await` suspended the task (stack retained). + suspended, + /// `break`/`continue`/`return`/`throw` fired — `unwindControl` handles it. + signaled, +}; + /// Per-observer-rule context handed to the Tier-0 `ObserverRegistry` as the /// opaque `ctx` pointer (M1.0.2 E3). Points back at the interpreter + the /// descriptor index so the trampoline can run the right rule body. Allocated @@ -664,9 +733,17 @@ pub const Interpreter = struct { /// Logical async clock — incremented once per `stepOnce` when `has_async`. /// `await wait(N)` resolves against it (N is a tick count, not seconds). async_tick: u64 = 0, - /// Suspend/resume state, one slot per rule (parallel to `rule_descs`). Empty - /// when `!has_async`; a non-async rule never touches its slot. - async_slots: []AsyncSlot = &.{}, + /// Dynamic pool of suspendable tasks (M1.0.11 E1) — the growable replacement + /// for the M0.8 per-rule `AsyncSlot` slice. A task is appended on first spawn + /// of an `async rule` and lives (state `.done` once finished) until `deinit`. + /// Empty when `!has_async`; grows as async rules spawn (E2/M1.0.12 add fn + /// frames / sibling tasks). Indices into it are stable within a tick (no task + /// is created mid-drive in E1). + async_tasks: std.ArrayListUnmanaged(AsyncTask) = .empty, + /// Per-rule handle into `async_tasks` (parallel to `rule_descs`, allocated iff + /// `has_async`): `null` until the async rule first spawns, then the pool index + /// of its task. A non-async rule's entry stays `null`. + rule_tasks: []?u32 = &.{}, /// Reusable cursor buffer for the multi-term (`or`) archetype-union merge /// (M1.0.0). Resized to the term count of the rule being iterated; capacity /// is retained across rules/ticks so the union path allocates at most once. @@ -741,8 +818,9 @@ pub const Interpreter = struct { self.pending_tags.deinit(self.gpa); for (self.pending_extensions.items) |pe| self.gpa.free(pe.name); self.pending_extensions.deinit(self.gpa); - for (self.async_slots) |*slot| slot.deinit(self.gpa); - self.gpa.free(self.async_slots); + for (self.async_tasks.items) |*task| task.deinit(self.gpa); + self.async_tasks.deinit(self.gpa); + self.gpa.free(self.rule_tasks); self.descriptors.deinit(self.gpa); self.merge_cursors.deinit(self.gpa); self.gpa.free(self.observer_ctxs); @@ -962,9 +1040,10 @@ pub const Interpreter = struct { break; } } - // Allocate one suspend/resume slot per rule iff any rule is `async` - // (M0.8 E3 sub-slice B). A sync-only program keeps an empty slice and - // never advances `async_tick` — byte-identical to the pre-B runtime. + // Allocate the per-rule task-handle map iff any rule is `async` (M1.0.11 + // E1). The task pool itself starts empty and grows on first spawn; a + // sync-only program keeps an empty map + pool and never advances + // `async_tick` — byte-identical to the pre-async runtime. var any_async = false; for (slice) |rd| { if (rd.is_async) { @@ -972,10 +1051,10 @@ pub const Interpreter = struct { break; } } - const async_slots: []AsyncSlot = if (any_async) blk: { - const slots = try gpa.alloc(AsyncSlot, slice.len); - for (slots) |*slot| slot.* = .{}; - break :blk slots; + const rule_tasks: []?u32 = if (any_async) blk: { + const map = try gpa.alloc(?u32, slice.len); + @memset(map, null); + break :blk map; } else &.{}; // Pass E — build the Level-B descriptors (M0.8 E4, build-structure @@ -998,7 +1077,7 @@ pub const Interpreter = struct { .tagset_id = tagset_id, .has_changed = any_changed, .has_async = any_async, - .async_slots = async_slots, + .rule_tasks = rule_tasks, .descriptors = descriptors, .world = world, .persistent_literals = persistent_literals, @@ -1608,96 +1687,398 @@ pub const Interpreter = struct { if (matched) report.rules_matched += 1; } - /// Drive an `async rule`'s suspend/resume task at its position in the rule - /// order (M0.8 E3 sub-slice B, Option-A task-record). Spawns the task on - /// first reach, resumes a suspended one whose wake has fired, skips one - /// still waiting, and never re-runs a completed one. + /// Drive an `async rule`'s task at its position in the rule order (M1.0.11 + /// E1). Spawns the task on first reach, resumes a suspended one whose wake + /// has fired, skips one still waiting, and never re-runs a completed one. One + /// task per async rule (the §9.2 parameterless, non-entity-bound shape); an + /// entity-bound async rule (one task per matching entity) is deferred and + /// fails loud (counted once, then parked `.done`). fn runAsyncRule(self: *Interpreter, world: *World, idx: usize, report: *RuntimeReport) !void { const rd = self.rule_descs[idx]; - const slot = &self.async_slots[idx]; - switch (slot.state) { - .done => return, - .suspended => { - if (!self.asyncWakeFired(slot.wake)) return; - // wake fired → resume from `slot.cursor` below - }, - .unspawned => { - // M0.8 bounds async rules to the §9.2 shape: a single - // (non-entity-bound) task, parameterless. A resource-only - // `when` is fine; an entity-bound async rule (entity param + - // component `when`) would need one task per matching entity — - // deferred, fail-loud, flagged for Review E3. - const rule = self.ast.rule_decls.items[rd.rule_idx]; - if (rule.params_len > 0 or rd.is_entity_bound) { - report.runtime_errors += 1; - slot.state = .done; - return; - } - slot.locals = .{}; - slot.cursor = 0; - // run from the start below - }, + if (self.rule_tasks[idx]) |ti| { + // Already spawned: resume only if still suspended and its wake fired. + if (self.async_tasks.items[ti].state == .done) return; + if (!self.asyncWakeFired(self.async_tasks.items[ti].wake)) return; + report.rules_matched += 1; + try self.driveTask(world, &self.async_tasks.items[ti], report); + return; } + // First reach → spawn a task in the pool. + const rule = self.ast.rule_decls.items[rd.rule_idx]; + if (rule.params_len > 0 or rd.is_entity_bound) { + report.runtime_errors += 1; + try self.async_tasks.append(self.gpa, .{ .state = .done }); + self.rule_tasks[idx] = @intCast(self.async_tasks.items.len - 1); + return; + } + try self.async_tasks.append(self.gpa, .{}); + const ti: u32 = @intCast(self.async_tasks.items.len - 1); + self.rule_tasks[idx] = ti; + // The initial frame is the rule body as a linear run. + try self.async_tasks.items[ti].frames.append(self.gpa, .{ .run = .{ + .block_start = rule.body_start, + .block_len = rule.body_len, + } }); report.rules_matched += 1; - try self.driveAsyncBody(world, rd, slot, report); + try self.driveTask(world, &self.async_tasks.items[ti], report); } - /// Run an async rule body from `slot.cursor`, suspending at the next - /// top-level `await` statement (recording its wake + the resume cursor) or - /// running to completion. `await` is bounded to a bare top-level statement - /// (the M0.8 cursor model); a nested / value `await` reaches `evalExpr`'s - /// `await_expr` arm and fails loud. - fn driveAsyncBody(self: *Interpreter, world: *World, rd: RuleDesc, slot: *AsyncSlot, report: *RuntimeReport) !void { - const rule = self.ast.rule_decls.items[rd.rule_idx]; + /// Drive `task` over its resume frame-stack until it suspends at the next + /// `await` or runs to completion (M1.0.11 E1). Resets the per-body signal + /// state, runs `driveLoop`, and routes a fail-loud into `finishTaskFailed`. + fn driveTask(self: *Interpreter, world: *World, task: *AsyncTask, report: *RuntimeReport) !void { self.control = .none; + self.control_label = 0; self.thrown = false; self.returning = false; self.pending_error = null; - var s: u32 = slot.cursor; - while (s < rule.body_len) : (s += 1) { - const stmt_id: NodeId = @bitCast(self.ast.extra.items[rule.body_start + s]); - if (self.bareAwaitExpr(stmt_id)) |aw_id| { - const wake = self.evalAwaitTarget(world, &slot.locals, aw_id) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - error.RuntimeFailure => return self.finishAsync(slot, report, true), - }; - slot.cursor = s + 1; - slot.wake = wake; - slot.state = .suspended; + const outcome = self.driveLoop(world, task) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.RuntimeFailure => { + self.finishTaskFailed(task, report); return; + }, + }; + switch (outcome) { + .suspended => task.state = .suspended, + .completed => self.finishTaskDone(task, report), + } + } + + /// The iterative frame-stack machine (M1.0.11 E1, `etch-reference-part1.md + /// §9.12`). Processes the innermost frame's next statement; a statement-head + /// `await` suspends the whole task (stack retained, no re-run on resume); a + /// nested `if`/`match`/`loop`/`while`/`block` pushes a child frame; everything + /// else runs through the sync `execStmt`. Returns `.suspended` (stack persists + /// for the next tick) or `.completed` (stack drained / a `return` / an + /// uncaught `throw` / a top-level `break`). + fn driveLoop(self: *Interpreter, world: *World, task: *AsyncTask) StmtError!AsyncOutcome { + const locals = &task.locals; + drive: while (task.frames.items.len > 0) { + const ti = task.frames.items.len - 1; + switch (task.frames.items[ti]) { + .run => { + const rf = &task.frames.items[ti].run; + if (rf.cursor >= rf.block_len) { + const val = rf.value_expr; + _ = task.frames.pop(); + // A block's trailing value runs for effect (rare at stmt + // position); it cannot suspend (an `await` there is a + // sub-expression, rejected E0904 / fails loud). + if (val) |v| _ = try self.evalExpr(world, locals, v); + continue :drive; + } + const stmt: NodeId = @bitCast(self.ast.extra.items[rf.block_start + rf.cursor]); + switch (try self.stepBodyStmt(world, task, &task.frames.items[ti].run.cursor, stmt)) { + .suspended => return .suspended, + .advanced, .pushed => continue :drive, + .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, + } + }, + .loop_ => { + const lf = &task.frames.items[ti].loop_; + if (lf.cursor >= lf.block_len) { + task.frames.items[ti].loop_.cursor = 0; // `loop` repeats + continue :drive; + } + const stmt: NodeId = @bitCast(self.ast.extra.items[lf.block_start + lf.cursor]); + switch (try self.stepBodyStmt(world, task, &task.frames.items[ti].loop_.cursor, stmt)) { + .suspended => return .suspended, + .advanced, .pushed => continue :drive, + .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, + } + }, + .while_ => { + const wid = task.frames.items[ti].while_.while_id; + const wh = self.ast.while_stmts.items[self.ast.stmtData(wid)]; + if (!task.frames.items[ti].while_.in_iter) { + if (!(try self.whileCondEnter(world, locals, wid))) { + _ = task.frames.pop(); // condition false → `while` ends + continue :drive; + } + task.frames.items[ti].while_.in_iter = true; + task.frames.items[ti].while_.cursor = 0; + continue :drive; + } + if (task.frames.items[ti].while_.cursor >= wh.body_len) { + task.frames.items[ti].while_.in_iter = false; // re-check cond + continue :drive; + } + const stmt: NodeId = @bitCast(self.ast.extra.items[wh.body_start + task.frames.items[ti].while_.cursor]); + switch (try self.stepBodyStmt(world, task, &task.frames.items[ti].while_.cursor, stmt)) { + .suspended => return .suspended, + .advanced, .pushed => continue :drive, + .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, + } + }, } - self.execStmt(world, &slot.locals, stmt_id) catch |err| switch (err) { - error.OutOfMemory => return error.OutOfMemory, - error.RuntimeFailure => return self.finishAsync(slot, report, true), - }; - if (self.thrown) { - self.thrown = false; - self.pending_error = .{ .kind = .UncaughtThrow, .span = self.thrown_span }; - return self.finishAsync(slot, report, true); + } + return .completed; + } + + /// Step one statement of the current frame's body (M1.0.11 E1). `cursor` + /// points at the active frame's statement cursor; this advances it (for a + /// plain run / a pushed child / a resumed-after `await`) BEFORE any frame push + /// (a push reallocates `task.frames`, so the pointer is used first). Returns + /// the action for `driveLoop` to route. + fn stepBodyStmt(self: *Interpreter, world: *World, task: *AsyncTask, cursor: *u32, stmt: NodeId) StmtError!StepAction { + const locals = &task.locals; + // (1) statement-head `await` → suspend the whole task, resuming AFTER it. + if (self.bareAwaitExpr(stmt)) |aw| { + task.wake = try self.evalAwaitTarget(world, locals, aw); + cursor.* += 1; + return .suspended; + } + const sk = self.ast.stmtKind(stmt); + // (2a) `while` statement → push a while frame (it re-checks its own cond). + if (sk == .while_stmt) { + cursor.* += 1; + try task.frames.append(self.gpa, .{ .while_ = .{ .while_id = stmt } }); + return .pushed; + } + // (2b) `if` / `match` / `loop` / `block` expression-statements → push a + // child frame so a body `await` can suspend. `for` / `try` are NOT + // frame-driven (fall through to sync `execStmt`; a body `await` there + // fails loud — outside the §9.12 placement list, deferred). + if (sk == .expr_stmt) { + const e: NodeId = @bitCast(self.ast.stmtData(stmt)); + switch (self.ast.exprKind(e)) { + .loop_expr => { + const lp = self.ast.loop_exprs.items[self.ast.exprData(e)]; + cursor.* += 1; + try task.frames.append(self.gpa, .{ .loop_ = .{ + .block_start = lp.body_start, + .block_len = lp.body_len, + .label = lp.label, + } }); + return .pushed; + }, + .block_expr => { + cursor.* += 1; + try self.pushBlockRun(task, e); + return .pushed; + }, + .if_expr => { + // Select the branch synchronously (a condition cannot suspend); + // push its block as a run frame (or nothing if no branch taken). + const branch = try self.asyncIfBranch(world, locals, e); + cursor.* += 1; + if (branch) |b| try self.pushBlockRun(task, b); + return .pushed; + }, + .match_expr => { + // Select the arm synchronously; a block arm becomes a run + // frame (so its body can suspend); a bare-expr arm runs for + // effect (a sub-expression `await` there fails loud). + const body = try self.matchArmBody(world, locals, e); + cursor.* += 1; + if (self.ast.exprKind(body) == .block_expr) { + try self.pushBlockRun(task, body); + } else { + _ = try self.evalExpr(world, locals, body); + } + return .pushed; + }, + else => {}, } - // A `break`/`continue`/`return` reaching the async rule top level - // ends the task (rules have no return value), like `execBody`. - if (self.control != .none) { - self.control = .none; - self.control_label = 0; - break; + } + // (3) ordinary statement → the shared sync executor. + try self.execStmt(world, locals, stmt); + if (self.control != .none or self.thrown or self.returning) return .signaled; + cursor.* += 1; + return .advanced; + } + + /// Push a `block_expr`'s body as a `.run` frame (M1.0.11 E1), carrying its + /// trailing value expression (evaluated for effect on pop). + fn pushBlockRun(self: *Interpreter, task: *AsyncTask, block_expr_id: NodeId) StmtError!void { + const blk = self.ast.block_exprs.items[self.ast.exprData(block_expr_id)]; + try task.frames.append(self.gpa, .{ .run = .{ + .block_start = blk.body_start, + .block_len = blk.body_len, + .value_expr = if (blk.value.isNone()) null else blk.value, + } }); + } + + /// Unwind the frame-stack for a pending control signal (M1.0.11 E1). A + /// `return` or an uncaught `throw` ends the task (`false`, stack cleared — + /// rules have no return value). A `break`/`continue` pops intervening block + /// frames to the nearest matching `loop`/`while` and consumes it there + /// (`true`, keep driving); an unmatched signal at the task top ends the task + /// (`false`). Mirrors the sync `loop_expr` / `handleLoopControl` semantics. + fn unwindControl(self: *Interpreter, task: *AsyncTask) StmtError!bool { + if (self.returning) { + self.returning = false; + self.return_value = .{ .unit = {} }; + task.frames.clearRetainingCapacity(); + return false; + } + if (self.thrown) { + // Left set for `finishTaskDone` to surface as an UncaughtThrow. + task.frames.clearRetainingCapacity(); + return false; + } + while (task.frames.items.len > 0) { + const ti = task.frames.items.len - 1; + switch (task.frames.items[ti]) { + .run => { + _ = task.frames.pop(); // abandon the intervening block + }, + .loop_ => { + const label = task.frames.items[ti].loop_.label; + if (self.control_label == 0 or self.control_label == label) { + const was_break = self.control == .break_; + self.control = .none; + self.control_label = 0; + if (was_break) { + _ = task.frames.pop(); // the loop exits + } else { + task.frames.items[ti].loop_.cursor = 0; // continue → loop again + } + return true; + } + _ = task.frames.pop(); // labeled signal for an outer loop → propagate + }, + .while_ => { + // `while` carries no label → matches only an unlabeled signal. + if (self.control_label == 0) { + const was_break = self.control == .break_; + self.control = .none; + if (was_break) { + _ = task.frames.pop(); + } else { + task.frames.items[ti].while_.in_iter = false; // continue → re-check cond + } + return true; + } + _ = task.frames.pop(); // labeled → propagate (while has no label) + }, } - if (self.returning) { - self.returning = false; - self.return_value = .{ .unit = {} }; - break; + } + // No enclosing loop matched: a `break`/`continue` at the task top has + // nowhere to go — consume it and end the task (mirrors `execBody`). + self.control = .none; + self.control_label = 0; + return false; + } + + /// Complete a task normally (M1.0.11 E1): surface an uncaught `throw` as a + /// counted runtime error, clear the residual signal state, free the frames + + /// retained locals, and park the task `.done`. + fn finishTaskDone(self: *Interpreter, task: *AsyncTask, report: *RuntimeReport) void { + if (self.thrown) { + self.thrown = false; + self.pending_error = .{ .kind = .UncaughtThrow, .span = self.thrown_span }; + self.harvestError(report); + } + self.control = .none; + self.control_label = 0; + self.returning = false; + self.return_value = .{ .unit = {} }; + task.state = .done; + task.frames.clearAndFree(self.gpa); + task.locals.deinit(self.gpa); + task.locals = .{}; + } + + /// Complete a fail-loud task (M1.0.11 E1): harvest the typed error into the + /// report and park the task `.done`, freeing its frames + retained locals. + fn finishTaskFailed(self: *Interpreter, task: *AsyncTask, report: *RuntimeReport) void { + self.harvestError(report); + self.control = .none; + self.control_label = 0; + self.thrown = false; + self.returning = false; + task.state = .done; + task.frames.clearAndFree(self.gpa); + task.locals.deinit(self.gpa); + task.locals = .{}; + } + + /// Resolve an `if`/`else if`/`else` chain to the `block_expr` of the taken + /// branch (or `null` when none is), binding an `if let` payload (M1.0.11 E1). + /// Conditions are evaluated synchronously — an `await` in a condition is a + /// sub-expression and fails loud. Mirrors the sync `if_expr` arm of `evalExpr`. + fn asyncIfBranch(self: *Interpreter, world: *World, locals: *Locals, ife_id: NodeId) StmtError!?NodeId { + const ife = self.ast.if_exprs.items[self.ast.exprData(ife_id)]; + if (ife.let_binding != 0) { + const opt = try self.evalExpr(world, locals, ife.cond); + if (opt != .optional) return error.RuntimeFailure; + if (self.optionals.items[opt.optional]) |payload| { + try locals.put(self.gpa, ife.let_binding, payload, false); + return ife.then_block; + } + if (ife.else_branch.isNone()) return null; + if (self.ast.exprKind(ife.else_branch) == .if_expr) return try self.asyncIfBranch(world, locals, ife.else_branch); + return ife.else_branch; + } + const cond = try self.evalExpr(world, locals, ife.cond); + if (cond != .bool_) return error.RuntimeFailure; + if (cond.bool_) return ife.then_block; + if (ife.else_branch.isNone()) return null; + if (self.ast.exprKind(ife.else_branch) == .if_expr) return try self.asyncIfBranch(world, locals, ife.else_branch); + return ife.else_branch; + } + + /// Select a `match`'s winning arm and return its body expression, binding a + /// pattern capture (M1.0.11 E1). The scrutinee + patterns are evaluated + /// synchronously. Mirrors the sync `match_expr` arm of `evalExpr`; a + /// fall-through is a runtime failure (exhaustiveness proven at type-check). + fn matchArmBody(self: *Interpreter, world: *World, locals: *Locals, m_id: NodeId) StmtError!NodeId { + const m = self.ast.match_exprs.items[self.ast.exprData(m_id)]; + const scrut = try self.evalExpr(world, locals, m.scrutinee); + var i: u32 = 0; + while (i < m.arms_len) : (i += 1) { + const arm = self.ast.match_arms.items[m.arms_start + i]; + switch (arm.pattern_kind) { + .wildcard => return arm.body, + .binding => { + try locals.put(self.gpa, arm.pattern_payload, scrut, false); + return arm.body; + }, + .literal => { + const lit: NodeId = @bitCast(arm.pattern_payload); + const lit_v = try self.evalExpr(world, locals, lit); + if (scrut.eql(lit_v)) return arm.body; + }, + .enum_variant => { + if (scrut != .enum_value) return error.RuntimeFailure; + const pat = self.ast.enum_pattern_payloads.items[arm.pattern_payload]; + const edecl = self.enum_decls.get(scrut.enum_value.type_name) orelse return error.RuntimeFailure; + const vidx = self.enumVariantIndexOf(edecl, pat.variant) orelse return error.RuntimeFailure; + if (scrut.enum_value.variant == vidx) return arm.body; + }, + .optional_some => { + if (scrut != .optional) return error.RuntimeFailure; + if (self.optionals.items[scrut.optional]) |payload| { + try locals.put(self.gpa, arm.pattern_payload, payload, false); + return arm.body; + } + }, + .optional_none => { + if (scrut != .optional) return error.RuntimeFailure; + if (self.optionals.items[scrut.optional] == null) return arm.body; + }, } } - self.finishAsync(slot, report, false); + return error.RuntimeFailure; } - /// Complete a task (success or fail-loud), freeing its retained locals so - /// the final slot deinit is a no-op. - fn finishAsync(self: *Interpreter, slot: *AsyncSlot, report: *RuntimeReport, failed: bool) void { - if (failed) self.harvestError(report); - slot.state = .done; - slot.locals.deinit(self.gpa); - slot.locals = .{}; + /// Evaluate a `while [let x =] cond` at the top of an iteration (M1.0.11 E1): + /// `true` = enter the body, `false` = stop the loop. Mirrors the sync + /// `while_stmt` arm of `execStmt`. + fn whileCondEnter(self: *Interpreter, world: *World, locals: *Locals, wid: NodeId) StmtError!bool { + const wh = self.ast.while_stmts.items[self.ast.stmtData(wid)]; + if (wh.let_binding != 0) { + const opt = try self.evalExpr(world, locals, wh.cond); + if (opt != .optional) return error.RuntimeFailure; + const payload = self.optionals.items[opt.optional] orelse return false; + try locals.put(self.gpa, wh.let_binding, payload, false); + return true; + } + const cond = try self.evalExpr(world, locals, wh.cond); + if (cond != .bool_) return error.RuntimeFailure; + return cond.bool_; } /// The `await_expr` of a bare top-level `await ` statement (an @@ -7346,6 +7727,132 @@ test "async rule resumes on await global_event(T) (M0.8 E3 sub-slice B)" { try std.testing.expectEqual(@as(i64, 7), readResourceInt(&world, out_id)); } +test "async rule suspends at a statement-head await inside an if body and resumes without re-running the prefix (M1.0.11 E1)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // The frame-stack substrate suspends at an `await` NESTED in an `if` body + // (a depth-2 resume stack: rule body + if-branch) and resumes at the correct + // cursor. The prefix `emit Beat` inside the if runs EXACTLY ONCE — an + // `@on_event` observer counts it into `Log.n`; a double-fire on resume would + // make it 2. `Out.n` traces the sequence 1 → (suspend) → 2 → 3. + const source = + \\event Beat { } + \\resource Out { n: int = 0 } + \\resource Log { n: int = 0 } + \\async rule seq() + \\ when resource Out + \\{ + \\ let a = get_mut(Out) + \\ a.n = 1 + \\ if a.n == 1 { + \\ emit Beat { } + \\ await wait(2) + \\ let b = get_mut(Out) + \\ b.n = 2 + \\ } + \\ let c = get_mut(Out) + \\ c.n = 3 + \\} + \\@on_event(Beat) + \\rule count_beat() + \\ when resource Log + \\{ + \\ let l = get_mut(Log) + \\ l.n += 1 + \\} + ; + + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + const log_id = world.registry.idOf("Log").?; + + // tick 1: n=1, enter the if, emit Beat (counted → Log.n=1), suspend at the + // await (wake at async_tick 3). + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, log_id)); + // tick 2: still suspended — no re-run, Log.n stays 1. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, log_id)); + // tick 3: wake fires → resume AFTER the await (b.n=2), leave the if, c.n=3. + // The `emit Beat` prefix is NOT rerun (Log.n still 1) — no double emit. + const r3 = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r3.runtime_errors); + try std.testing.expectEqual(@as(i64, 3), readResourceInt(&world, out_id)); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, log_id)); +} + +test "async rule suspends at a statement-head await inside a loop body and resumes each iteration (M1.0.11 E1)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // A `loop` body that awaits every iteration and breaks on the third: the + // loop frame persists across suspension and resumes mid-iteration. `Out.n` + // counts iterations (1, 2, 3) and settles at 3 after the break — proving the + // loop frame repeats correctly and `break` unwinds the frame-stack. + const source = + \\resource Out { n: int = 0 } + \\async rule ticker() + \\ when resource Out + \\{ + \\ loop { + \\ let o = get_mut(Out) + \\ o.n += 1 + \\ if o.n == 3 { + \\ break + \\ } + \\ await wait(1) + \\ } + \\} + ; + + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + + // tick 1: iteration 1 (n=1), suspend at await (wake at async_tick 2). + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + // tick 2: resume → iteration 2 (n=2), suspend again (wake at async_tick 3). + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); + // tick 3: resume → iteration 3 (n=3), the if fires `break`, the loop exits, + // the task completes. + const r3 = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r3.runtime_errors); + try std.testing.expectEqual(@as(i64, 3), readResourceInt(&world, out_id)); + // tick 4: task done — n stays 3. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 3), readResourceInt(&world, out_id)); +} + test "runProgram Optional ops: ??, !, ?., patterns, pop, m[k] (M0.8 E3-C tranche 4)" { const gpa = std.testing.allocator; var world = World.init(); @@ -7514,7 +8021,7 @@ test "async runtime failure surfaces typed last_error (D-S4-runtime-report)" { var world = World.init(); defer world.deinit(gpa); - // The async choke point (`driveAsyncBody` → `finishAsync`) harvests the + // The async choke point (`driveTask` → `finishTaskFailed`) harvests the // same typed payload as `execBody` — the task fails before its first // suspension and the report carries the kind. const source = From e0adcce514e0b2a42c8de89ac96ca47efc683738 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 14:20:26 +0200 Subject: [PATCH 05/15] fix(etch): complete AsyncFrame set with for and try frames (M1.0.11 E1) --- briefs/M1.0.11-async-core.md | 3 +- src/etch/interp.zig | 338 +++++++++++++++++++++++++++++++++-- 2 files changed, 323 insertions(+), 18 deletions(-) diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md index b7d56b4..71c4365 100644 --- a/briefs/M1.0.11-async-core.md +++ b/briefs/M1.0.11-async-core.md @@ -115,7 +115,7 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § - **No fibers, no per-task OS thread** (`etch-reference-part1.md §9.1`). A task is a heap record holding a **resume frame-stack**: a stack of `(block, statement cursor, scope)` frames. An `await` at a statement-head position suspends by recording the frame-stack plus the wake condition; resume re-enters at `cursor + 1` in the innermost frame and **never re-runs prefix statements**, so `emit` and structural mutations never double-fire. This generalizes the M0.8 single-top-level-cursor `AsyncSlot` to nested blocks and a dynamic task pool. The substrate must support a frame-stack of depth greater than one. -- **Await placement (Phase-1 restriction, E0904).** `await` must be the **full right-hand expression** of an expr-statement, a `let` initializer, an assignment RHS, or a `return` operand, in any statement block (including `if` / `else` / `loop` / `while` / match-arm bodies). A sub-expression `await` — `some(await f())`, `f(await g())`, `a + await b` — is rejected; the author hoists it into a `let` (`let t = await f(); return some(t)`). This is semantics-preserving (`await` has no effect beyond suspend + resolve). The Phase-2 bytecode VM removes this restriction. +- **Await placement (Phase-1 restriction, E0904).** `await` must be the **full right-hand expression** of an expr-statement, a `let` initializer, an assignment RHS, or a `return` operand, in any statement block that can hold statements — rule/`fn` body, `if`/`else`, `loop`/`while`/`for`, match-arm, and `try`/`catch` bodies. A sub-expression `await` — `some(await f())`, `f(await g())`, `a + await b` — is rejected; the author hoists it into a `let` (`let t = await f(); return some(t)`). This is semantics-preserving (`await` has no effect beyond suspend + resolve). The Phase-2 bytecode VM removes this restriction. - **`async fn` / `async method` via frame inlining.** A direct `await f()` (f async) pushes `f`'s body frames onto the caller's task; `f`'s own `await` suspends the whole task; `f`'s `return v` resolves at the caller's await site. This is the `future` target for **direct** calls; `await` on a stored `TaskHandle` is M1.0.12 (arrives with `spawn`). @@ -154,6 +154,7 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § - 2026-07-01 — E1 design (interp.zig). Replace the per-rule `AsyncSlot` (single top-level cursor, `[]AsyncSlot` parallel to `rule_descs`) with a dynamic heap-allocated `AsyncTask` pool (`async_tasks: ArrayListUnmanaged`) + a per-rule `rule_tasks: []?u32` handle map. Each task carries an EXPLICIT resume frame-stack (`frames: ArrayListUnmanaged(AsyncFrame)`, innermost last) of `run`/`loop_`/`while_` frames. `driveTask` is an iterative stack machine: statement-head `await` suspends at ANY depth (top-level and inside `if`/`else`(-if chain)/`loop`/`while`/`match`-arm/`block`), and resume re-drives the persisted stack without re-running prefix statements (no double `emit`) — the explicit-stack model makes resume trivial and side-effect-free (`etch-reference-part1.md §9.12`). Sync control-flow (`if`/`match`/`loop`/`block`/`while`) is mirrored (not delegated) in the driver so a nested body can suspend; all other statements delegate to the existing sync `execStmt`. WakeCond (int-tick `wait` + `global_event`) is unchanged in E1 — the Duration `wait` and E0904/E0901 land in E3/E4. Residual (documented, not in the §9.12 placement list): `await` inside a `for` body and inside a `try` body fall through to sync `execStmt` and fail loud (needs iteration-state / try-frame serialization) — deferred. - 2026-07-01 — E1 done. `interp.zig`: new async substrate wired (`compile` allocs `rule_tasks`, `deinit` tears down the pool). The 3 existing async-rule tests pass UNCHANGED on the new substrate (re-host is behavior-preserving; int-tick `wait` stays until E3). Added 2 inline tests: `await` inside an `if` body (depth-2 stack + no double `emit`, counted via an `@on_event` observer) and inside a `loop` body (per-iteration resume + `break` unwind). Stale comment fixed (the runtime-failure test cited the removed `driveAsyncBody`/`finishAsync`). Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), and the etch test target `367 pass (367 total)` in BOTH Debug and ReleaseSafe. Sync-only programs keep an empty pool + `null` handle map + no `async_tick` churn (byte-identical by construction). `parser.zig`/`token.zig`/`ast.zig` untouched. +- 2026-07-01 — E1 fix (Guy STOP on review): the earlier residual was wrong to defer — `for_stmt` and `try_catch_stmt` are real EBNF v0.6 statement blocks whose statement-head `await` must execute (C1.6), and deferring them would force a future `AsyncFrame` variant (re-opening the frozen substrate, forbidden). Added `for_` and `try_` to `AsyncFrame`, completing the frame set BEFORE E2. `for_` (`ForIter` = range/array/map + cursor, persisted at suspension; mirrors the sync `for`) — a `range` is fully sound; an array/map iterable across a suspend inherits the M0.8 heap-across-suspend caveat and `forAdvance` bounds-checks the handle → typed `RuntimeFailure`, never an OOB crash. `try_` (try/catch ranges + `in_catch` phase) — `unwindControl` routes a `throw` (even one that runs only AFTER a resume) to the nearest enclosing `try`, re-establishing the handler across the suspension; a `catch`'s own throw re-propagates. `break`/`continue` unwinding extended to `for_` (unlabeled) and `try_` (transparent). Removed the "deferred" comment. No frame owns heap → teardown unchanged. Added 2 inline tests: `await` in a `for` body (range, per-iteration resume, iterator state preserved: n = 1 → 3 → 6) and in a `try` body (post-resume `throw` routes to `catch`, caught → `runtime_errors == 0`). Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), etch target `369 pass (369 total)` in Debug AND ReleaseSafe. FROZEN Notes "Await placement" bullet broadened per Guy's ruling (Recorded deviation below). ## Recorded deviations diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 23bc386..6bc9d12 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -534,16 +534,20 @@ const WakeCond = union(enum) { /// shape needed to resume it. On `await` the whole stack is retained; `driveTask` /// resumes by re-entering the innermost frame at its cursor and NEVER re-running /// an already-executed statement (no double `emit`). The frame kinds mirror the -/// sync executor's suspendable control flow: a linear `run` (rule body / `if` -/// branch / `match` arm / plain `block` / — E2 — an inlined `async fn` body), a -/// `loop`, and a `while`. This generalizes the M0.8 single-top-level-cursor -/// `AsyncSlot` to nested blocks (the stack reaches depth > 1). `for`-body and -/// `try`-body awaits are NOT frame-driven (they fall through to sync `execStmt` -/// and fail loud) — outside the §9.12 placement list, deferred. +/// sync executor's control flow, ONE per statement block that can hold statements +/// — a linear `run` (rule body / `if` branch / `match` arm / plain `block` / — E2 +/// — an inlined `async fn` body), a `loop`, a `while`, a `for`, and a `try`/`catch` +/// — so a statement-head `await` can suspend inside ANY of them. This generalizes +/// the M0.8 single-top-level-cursor `AsyncSlot` to nested blocks (the stack reaches +/// depth > 1). The frame set is COMPLETE for EBNF v0.6's statement blocks (C1.6): +/// no body kind falls through to a fail-loud await. No frame owns heap (frames are +/// pure indices), so teardown is a plain `frames.deinit`. const AsyncFrame = union(enum) { run: RunFrame, loop_: LoopFrame, while_: WhileFrame, + for_: ForFrame, + try_: TryFrame, }; /// A linear statement run: execute `block[cursor .. block_len]`, then (if any) @@ -579,6 +583,49 @@ const WhileFrame = struct { in_iter: bool = false, }; +/// The iterator state of a `for` frame, persisted across a suspension. A `range` +/// is fully self-contained (no heap) → sound across any suspend. An `array`/`map` +/// holds a collection-store handle + the once-snapshotted length + the current +/// index; the referenced collection lives in the rule-arena store, so a heap +/// iterable surviving a suspend shares the M0.8 "POD-only across a suspend" caveat +/// (a store reset by an intervening rule frees it). `forAdvance` bounds-checks the +/// handle and fails loud (a typed `RuntimeFailure`, never an OOB crash) rather than +/// dereference a reset store. The common `for i in 0..N` (range) is unconditionally +/// sound. +const ForIter = union(enum) { + range: struct { next: i64, end: i64, inclusive: bool }, + array: struct { handle: u32, len: usize, idx: usize }, + map: struct { handle: u32, len: usize, idx: usize }, +}; + +/// `for v [, k] in iter { body }`: at the top of each iteration (`in_iter = +/// false`) advance the iterator and bind the loop variable(s); run the body while +/// elements remain, dropping back to the iterator when the body run completes. The +/// iterator position lives in `iter`, persisted across suspension so a body +/// `await` resumes at the same element. `for` carries no loop label (mirrors the +/// sync executor's `handleLoopControl(0)`). +const ForFrame = struct { + for_id: NodeId, + iter: ForIter, + cursor: u32 = 0, + in_iter: bool = false, +}; + +/// `try { body } catch e { handler }`: drives the `try` body (`in_catch = false`); +/// a `throw` reaching this frame (possibly after a suspension inside the body — +/// the handler is re-established across the suspend) switches it to the `catch` +/// body (`in_catch = true`, the caught value bound to `catch_name`). Mirrors the +/// sync `try_catch_stmt` arm of `execStmt`. +const TryFrame = struct { + try_start: u32, + try_len: u32, + catch_start: u32, + catch_len: u32, + catch_name: StringId, + cursor: u32 = 0, + in_catch: bool = false, +}; + /// A suspendable task (M1.0.11 E1) — the dynamic-pool replacement for the M0.8 /// per-rule `AsyncSlot`. Holds the resume frame-stack (`frames`, innermost last), /// the wake condition it is blocked on, and the locals retained across @@ -1811,6 +1858,43 @@ pub const Interpreter = struct { .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, } }, + .for_ => { + const f = self.ast.for_stmts.items[self.ast.stmtData(task.frames.items[ti].for_.for_id)]; + if (!task.frames.items[ti].for_.in_iter) { + if (!(try self.forAdvance(&task.frames.items[ti].for_, locals))) { + _ = task.frames.pop(); // iterator exhausted → `for` ends + continue :drive; + } + task.frames.items[ti].for_.in_iter = true; + task.frames.items[ti].for_.cursor = 0; + continue :drive; + } + if (task.frames.items[ti].for_.cursor >= f.body_len) { + task.frames.items[ti].for_.in_iter = false; // advance to next element + continue :drive; + } + const stmt: NodeId = @bitCast(self.ast.extra.items[f.body_start + task.frames.items[ti].for_.cursor]); + switch (try self.stepBodyStmt(world, task, &task.frames.items[ti].for_.cursor, stmt)) { + .suspended => return .suspended, + .advanced, .pushed => continue :drive, + .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, + } + }, + .try_ => { + const tf = &task.frames.items[ti].try_; + const start = if (tf.in_catch) tf.catch_start else tf.try_start; + const len = if (tf.in_catch) tf.catch_len else tf.try_len; + if (tf.cursor >= len) { + _ = task.frames.pop(); // `try` (or `catch`) body done + continue :drive; + } + const stmt: NodeId = @bitCast(self.ast.extra.items[start + tf.cursor]); + switch (try self.stepBodyStmt(world, task, &task.frames.items[ti].try_.cursor, stmt)) { + .suspended => return .suspended, + .advanced, .pushed => continue :drive, + .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, + } + }, } } return .completed; @@ -1836,10 +1920,39 @@ pub const Interpreter = struct { try task.frames.append(self.gpa, .{ .while_ = .{ .while_id = stmt } }); return .pushed; } - // (2b) `if` / `match` / `loop` / `block` expression-statements → push a - // child frame so a body `await` can suspend. `for` / `try` are NOT - // frame-driven (fall through to sync `execStmt`; a body `await` there - // fails loud — outside the §9.12 placement list, deferred). + // (2b) `for` statement → evaluate the iterable ONCE (its side effects run + // now, exactly like the sync `for`) and push a for frame carrying the + // iterator state (so a body `await` resumes at the same element). + if (sk == .for_stmt) { + const f = self.ast.for_stmts.items[self.ast.stmtData(stmt)]; + const iter = try self.evalExpr(world, locals, f.iterable); + const for_iter: ForIter = switch (iter) { + .range => |r| .{ .range = .{ .next = r.start, .end = r.end, .inclusive = r.inclusive } }, + .array_ref => |h| .{ .array = .{ .handle = h, .len = self.collections.arrays.items[h].items.len, .idx = 0 } }, + .map_ref => |h| .{ .map = .{ .handle = h, .len = self.collections.maps.items[h].items.len, .idx = 0 } }, + else => return error.RuntimeFailure, + }; + cursor.* += 1; + try task.frames.append(self.gpa, .{ .for_ = .{ .for_id = stmt, .iter = for_iter } }); + return .pushed; + } + // (2c) `try { } catch e { }` → push a try frame driving the `try` body; a + // `throw` (even after a suspension inside the body) routes to the `catch` + // via `unwindControl`, re-establishing the handler across the suspend. + if (sk == .try_catch_stmt) { + const tc = self.ast.try_catch_stmts.items[self.ast.stmtData(stmt)]; + cursor.* += 1; + try task.frames.append(self.gpa, .{ .try_ = .{ + .try_start = tc.try_start, + .try_len = tc.try_len, + .catch_start = tc.catch_start, + .catch_len = tc.catch_len, + .catch_name = tc.catch_name, + } }); + return .pushed; + } + // (2d) `if` / `match` / `loop` / `block` expression-statements → push a + // child frame so a body `await` can suspend. if (sk == .expr_stmt) { const e: NodeId = @bitCast(self.ast.stmtData(stmt)); switch (self.ast.exprKind(e)) { @@ -1901,11 +2014,15 @@ pub const Interpreter = struct { } /// Unwind the frame-stack for a pending control signal (M1.0.11 E1). A - /// `return` or an uncaught `throw` ends the task (`false`, stack cleared — - /// rules have no return value). A `break`/`continue` pops intervening block - /// frames to the nearest matching `loop`/`while` and consumes it there - /// (`true`, keep driving); an unmatched signal at the task top ends the task - /// (`false`). Mirrors the sync `loop_expr` / `handleLoopControl` semantics. + /// `return` ends the task (`false`, stack cleared — rules have no return + /// value). A `throw` routes to the nearest enclosing `try` still in its try + /// phase — binding the caught value and switching that frame to its `catch` + /// (`true`, keep driving) — or, with no such `try`, ends the task as an + /// uncaught throw (`false`). A `break`/`continue` pops intervening block + /// frames to the nearest matching `loop`/`while`/`for` and consumes it there + /// (`true`); an unmatched signal at the task top ends the task (`false`). + /// Mirrors the sync `loop_expr` / `handleLoopControl` / `try_catch_stmt` + /// semantics — and re-establishes a `try`'s handler across a suspension. fn unwindControl(self: *Interpreter, task: *AsyncTask) StmtError!bool { if (self.returning) { self.returning = false; @@ -1914,8 +2031,27 @@ pub const Interpreter = struct { return false; } if (self.thrown) { - // Left set for `finishTaskDone` to surface as an UncaughtThrow. - task.frames.clearRetainingCapacity(); + // Route the throw to the nearest `try` in its try phase (a `catch`'s + // own throw re-propagates past it). Popping intervening loop/run/for + // frames mirrors the sync unwinding of a throw out of nested control + // flow. If none catches it, the residual `self.thrown` is left set for + // `finishTaskDone` to surface as an UncaughtThrow. + while (task.frames.items.len > 0) { + const ti = task.frames.items.len - 1; + switch (task.frames.items[ti]) { + .try_ => |tfv| { + if (!tfv.in_catch) { + self.thrown = false; + try task.locals.put(self.gpa, tfv.catch_name, self.thrown_value, false); + task.frames.items[ti].try_.in_catch = true; + task.frames.items[ti].try_.cursor = 0; + return true; + } + _ = task.frames.pop(); // throw inside this `catch` → propagate past it + }, + else => _ = task.frames.pop(), + } + } return false; } while (task.frames.items.len > 0) { @@ -1953,6 +2089,23 @@ pub const Interpreter = struct { } _ = task.frames.pop(); // labeled → propagate (while has no label) }, + .for_ => { + // `for` carries no label → matches only an unlabeled signal. + if (self.control_label == 0) { + const was_break = self.control == .break_; + self.control = .none; + if (was_break) { + _ = task.frames.pop(); + } else { + task.frames.items[ti].for_.in_iter = false; // continue → next element + } + return true; + } + _ = task.frames.pop(); // labeled → propagate (for has no label) + }, + .try_ => { + _ = task.frames.pop(); // `break`/`continue` unwinds past a `try` + }, } } // No enclosing loop matched: a `break`/`continue` at the task top has @@ -2081,6 +2234,48 @@ pub const Interpreter = struct { return cond.bool_; } + /// Advance a `for` frame's iterator by one element, binding the loop + /// variable(s) (M1.0.11 E1): `true` = a body iteration was entered, `false` = + /// the iterator is exhausted (the `for` ends). Mirrors the sync `for_stmt` + /// arms of `execStmt`. A `range` is self-contained; an `array`/`map` handle is + /// bounds-checked against the (possibly reset) collection store and fails loud + /// rather than dereference out of bounds (the heap-across-suspend caveat). + fn forAdvance(self: *Interpreter, ff: *ForFrame, locals: *Locals) StmtError!bool { + const f = self.ast.for_stmts.items[self.ast.stmtData(ff.for_id)]; + switch (ff.iter) { + .range => { + const r = &ff.iter.range; + const more = if (r.inclusive) r.next <= r.end else r.next < r.end; + if (!more) return false; + try locals.put(self.gpa, f.var_name, .{ .int_ = r.next }, false); + r.next += 1; + return true; + }, + .array => { + const a = &ff.iter.array; + if (a.idx >= a.len) return false; + if (a.handle >= self.collections.arrays.items.len) return error.RuntimeFailure; + const col = self.collections.arrays.items[a.handle]; + if (a.idx >= col.items.len) return error.RuntimeFailure; + try locals.put(self.gpa, f.var_name, col.items[a.idx], false); + a.idx += 1; + return true; + }, + .map => { + const m = &ff.iter.map; + if (m.idx >= m.len) return false; + if (m.handle >= self.collections.maps.items.len) return error.RuntimeFailure; + const col = self.collections.maps.items[m.handle]; + if (m.idx >= col.items.len) return error.RuntimeFailure; + const pair = col.items[m.idx]; + try locals.put(self.gpa, f.var_name, pair.key, false); + if (f.index_name != 0) try locals.put(self.gpa, f.index_name, pair.value, false); + m.idx += 1; + return true; + }, + } + } + /// The `await_expr` of a bare top-level `await ` statement (an /// expr-stmt wrapping an `.await_expr`), or null. Only a bare top-level /// await is a suspension point — the cursor-based resume re-enters at the @@ -7853,6 +8048,115 @@ test "async rule suspends at a statement-head await inside a loop body and resum try std.testing.expectEqual(@as(i64, 3), readResourceInt(&world, out_id)); } +test "async rule suspends at a statement-head await inside a for body and resumes per iteration with iterator state preserved (M1.0.11 E1)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // A `for i in 0..3` body that awaits every iteration: the for frame persists + // its range iterator (`next`) across suspension and resumes at the correct + // element. `Out.n` accumulates `i + 1` per iteration → 1, 3, 6 — the running + // total proves `i` took 0, 1, 2 in order across the suspends. + const source = + \\resource Out { n: int = 0 } + \\async rule ranger() + \\ when resource Out + \\{ + \\ for i in 0..3 { + \\ let o = get_mut(Out) + \\ o.n += i + 1 + \\ await wait(1) + \\ } + \\} + ; + + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + + // tick 1: i=0 → n += 1 = 1, suspend (wake at async_tick 2). + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + // tick 2: resume → i=1 → n += 2 = 3, suspend. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 3), readResourceInt(&world, out_id)); + // tick 3: resume → i=2 → n += 3 = 6, suspend. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 6), readResourceInt(&world, out_id)); + // tick 4: resume → iterator exhausted → the `for` ends, the task completes. + const r4 = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r4.runtime_errors); + try std.testing.expectEqual(@as(i64, 6), readResourceInt(&world, out_id)); + // tick 5: task done — n stays 6. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 6), readResourceInt(&world, out_id)); +} + +test "async rule suspends inside a try body and a post-resume throw routes to the catch (M1.0.11 E1)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // The `try` frame carries its catch handler across a suspension: the body + // awaits, and the `throw` runs only AFTER the resume — yet it still routes to + // the `catch` (n goes 1 → 2, and the throw is CAUGHT so runtime_errors == 0). + const source = + \\resource Out { n: int = 0 } + \\async rule guarded() + \\ when resource Out + \\{ + \\ try { + \\ let o = get_mut(Out) + \\ o.n = 1 + \\ await wait(1) + \\ throw Error { message: "boom", code: ErrorCode.io_fail } + \\ } catch e { + \\ let o2 = get_mut(Out) + \\ o2.n = 2 + \\ } + \\} + ; + + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + + // tick 1: enter the try, o.n=1, suspend at the await (the try frame — with its + // catch handler — persists across the suspend). + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + // tick 2: resume → the throw fires and routes to the catch → o.n=2. The throw + // was caught, so it is NOT counted as a runtime error. + const r2 = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r2.runtime_errors); + try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); + // tick 3: task done — n stays 2. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); +} + test "runProgram Optional ops: ??, !, ?., patterns, pop, m[k] (M0.8 E3-C tranche 4)" { const gpa = std.testing.allocator; var world = World.init(); From 0f56b511de6358f25bca4423dff66aae51597642 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 14:20:44 +0200 Subject: [PATCH 06/15] =?UTF-8?q?docs(brief):=20record=20=C2=A79.12=20plac?= =?UTF-8?q?ement=20broadening=20to=20for=20and=20try?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- briefs/M1.0.11-async-core.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md index 71c4365..fab68b7 100644 --- a/briefs/M1.0.11-async-core.md +++ b/briefs/M1.0.11-async-core.md @@ -160,7 +160,7 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § *Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each references the commit that records it. Empty at milestone end = nominal case.* -- +- §9.12 placement broadened to `for` and `try`/`catch` — C1.6; AsyncFrame set completed before E2. Commit e0adcce. ## Blockers encountered From 7d830642b968245293c6094ca971efc29d2577e0 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 15:10:11 +0200 Subject: [PATCH 07/15] feat(etch): execute async fn/method via await inlining (M1.0.11 E2) --- briefs/M1.0.11-async-core.md | 1 + src/etch/interp.zig | 542 ++++++++++++++++++++++++++++++----- src/etch/types.zig | 11 + 3 files changed, 490 insertions(+), 64 deletions(-) diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md index fab68b7..fb5182c 100644 --- a/briefs/M1.0.11-async-core.md +++ b/briefs/M1.0.11-async-core.md @@ -155,6 +155,7 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § - 2026-07-01 — E1 design (interp.zig). Replace the per-rule `AsyncSlot` (single top-level cursor, `[]AsyncSlot` parallel to `rule_descs`) with a dynamic heap-allocated `AsyncTask` pool (`async_tasks: ArrayListUnmanaged`) + a per-rule `rule_tasks: []?u32` handle map. Each task carries an EXPLICIT resume frame-stack (`frames: ArrayListUnmanaged(AsyncFrame)`, innermost last) of `run`/`loop_`/`while_` frames. `driveTask` is an iterative stack machine: statement-head `await` suspends at ANY depth (top-level and inside `if`/`else`(-if chain)/`loop`/`while`/`match`-arm/`block`), and resume re-drives the persisted stack without re-running prefix statements (no double `emit`) — the explicit-stack model makes resume trivial and side-effect-free (`etch-reference-part1.md §9.12`). Sync control-flow (`if`/`match`/`loop`/`block`/`while`) is mirrored (not delegated) in the driver so a nested body can suspend; all other statements delegate to the existing sync `execStmt`. WakeCond (int-tick `wait` + `global_event`) is unchanged in E1 — the Duration `wait` and E0904/E0901 land in E3/E4. Residual (documented, not in the §9.12 placement list): `await` inside a `for` body and inside a `try` body fall through to sync `execStmt` and fail loud (needs iteration-state / try-frame serialization) — deferred. - 2026-07-01 — E1 done. `interp.zig`: new async substrate wired (`compile` allocs `rule_tasks`, `deinit` tears down the pool). The 3 existing async-rule tests pass UNCHANGED on the new substrate (re-host is behavior-preserving; int-tick `wait` stays until E3). Added 2 inline tests: `await` inside an `if` body (depth-2 stack + no double `emit`, counted via an `@on_event` observer) and inside a `loop` body (per-iteration resume + `break` unwind). Stale comment fixed (the runtime-failure test cited the removed `driveAsyncBody`/`finishAsync`). Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), and the etch test target `367 pass (367 total)` in BOTH Debug and ReleaseSafe. Sync-only programs keep an empty pool + `null` handle map + no `async_tick` churn (byte-identical by construction). `parser.zig`/`token.zig`/`ast.zig` untouched. - 2026-07-01 — E1 fix (Guy STOP on review): the earlier residual was wrong to defer — `for_stmt` and `try_catch_stmt` are real EBNF v0.6 statement blocks whose statement-head `await` must execute (C1.6), and deferring them would force a future `AsyncFrame` variant (re-opening the frozen substrate, forbidden). Added `for_` and `try_` to `AsyncFrame`, completing the frame set BEFORE E2. `for_` (`ForIter` = range/array/map + cursor, persisted at suspension; mirrors the sync `for`) — a `range` is fully sound; an array/map iterable across a suspend inherits the M0.8 heap-across-suspend caveat and `forAdvance` bounds-checks the handle → typed `RuntimeFailure`, never an OOB crash. `try_` (try/catch ranges + `in_catch` phase) — `unwindControl` routes a `throw` (even one that runs only AFTER a resume) to the nearest enclosing `try`, re-establishing the handler across the suspension; a `catch`'s own throw re-propagates. `break`/`continue` unwinding extended to `for_` (unlabeled) and `try_` (transparent). Removed the "deferred" comment. No frame owns heap → teardown unchanged. Added 2 inline tests: `await` in a `for` body (range, per-iteration resume, iterator state preserved: n = 1 → 3 → 6) and in a `try` body (post-resume `throw` routes to `catch`, caught → `runtime_errors == 0`). Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), etch target `369 pass (369 total)` in Debug AND ReleaseSafe. FROZEN Notes "Await placement" bullet broadened per Guy's ruling (Recorded deviation below). +- 2026-07-01 — E2 (async fn / async method execution). `interp.zig`: removed the two `is_async` `RuntimeFailure` gates in `callFn`/`callMethod` (a direct sync call to an async callee stays fail-loud pending E4 coloring; async execution runs via the await path). New `call` `AsyncFrame` variant (`CallFrame`) — a direct `await f()`/`await obj.m()` (the `future` target) inlines the callee's body as a frame on the CALLER's task, so the callee's own `await` suspends the whole task and its `return` resolves at the caller's await site. Each call frame OWNS a heap-boxed `scope` (params + `self`, freed on pop via `deinitFrame`/`popFrame`); `currentScope` walks the stack for the nearest enclosing call scope (else task root), so a callee's locals never collide with the caller's and survive suspension. `RetTarget` classifies the await site (`stmtHeadAwait`): `discard` (expr-stmt), `bind` (`let x = await f()`), `assign_local` (`x = await f()`, simple lvalue), `return_` (`return await f()`). `deliverAwaitValue` binds/assigns f's return at completion; `return_` (and an explicit `return v` inside a callee) routes through `unwindControl`'s returning-loop (find nearest call frame → deliver / chain `return await`). A wake-condition `await` in a value position (`let x = await wait(…)`) delivers its unit value on resume via `task.pending_bind`. `types.zig`: `synthExprE` gains an `await_expr` arm — `future` → the awaited call's return type (so `let x = await f()` binds the right type + the inner call is type-checked), else `unknown`; no coloring/placement yet (E4/E3). `callFn`/`callMethod` sync arg-binding factored into the shared `bindAsyncParams`. Added 2 inline tests: an `async fn` (`let x = await compute()`, return flows to the binding, n → 42) and an `async method` (`let x = await c.bumped()`, own scope + a local surviving the suspend, n → 11). Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), etch target `371 pass (371 total)` in Debug AND ReleaseSafe, full suite exit 0, no leaks (testing.allocator). `parser.zig`/`token.zig`/`ast.zig` untouched. ## Recorded deviations diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 6bc9d12..be8a9b6 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -540,14 +540,17 @@ const WakeCond = union(enum) { /// — so a statement-head `await` can suspend inside ANY of them. This generalizes /// the M0.8 single-top-level-cursor `AsyncSlot` to nested blocks (the stack reaches /// depth > 1). The frame set is COMPLETE for EBNF v0.6's statement blocks (C1.6): -/// no body kind falls through to a fail-loud await. No frame owns heap (frames are -/// pure indices), so teardown is a plain `frames.deinit`. +/// no body kind falls through to a fail-loud await. `call` (M1.0.11 E2) is the +/// inlined body of an `async fn`/`async method` reached by a direct `await f()`; it +/// OWNS a heap-boxed scope (freed on pop — see `deinitFrame`), so teardown is not a +/// bare `frames.deinit`. The other frame kinds are pure indices. const AsyncFrame = union(enum) { run: RunFrame, loop_: LoopFrame, while_: WhileFrame, for_: ForFrame, try_: TryFrame, + call: CallFrame, }; /// A linear statement run: execute `block[cursor .. block_len]`, then (if any) @@ -626,6 +629,33 @@ const TryFrame = struct { in_catch: bool = false, }; +/// Where an `await`'s resolved value is delivered at the caller's await site +/// (M1.0.11 E2). Set from the statement that carries the `await`: a bare +/// expr-statement discards it; a `let x = await …` binds a fresh local; an +/// `x = await …` assigns an existing local; a `return await …` returns it from +/// the enclosing `async fn` / rule. +const RetTarget = union(enum) { + discard, + bind: struct { name: StringId, is_mut: bool }, + assign_local: StringId, + return_, +}; + +/// The inlined body of an `async fn` / `async method` reached by a direct +/// `await f()` (M1.0.11 E2, `etch-reference-part1.md §9.12`): `f`'s body runs as +/// frames on the CALLER's task, so `f`'s own `await` suspends the whole task and +/// `f`'s `return` resolves at the caller's await site. Owns a heap-boxed `scope` +/// (f's params/locals — freed when the frame pops); `value_expr` is f's trailing +/// block value (implicit return); `ret` is where f's return value is delivered. +const CallFrame = struct { + block_start: u32, + block_len: u32, + cursor: u32 = 0, + scope: *Locals, + value_expr: ?NodeId = null, + ret: RetTarget, +}; + /// A suspendable task (M1.0.11 E1) — the dynamic-pool replacement for the M0.8 /// per-rule `AsyncSlot`. Holds the resume frame-stack (`frames`, innermost last), /// the wake condition it is blocked on, and the locals retained across @@ -638,11 +668,24 @@ const AsyncTask = struct { state: enum { suspended, done } = .suspended, wake: WakeCond = .{ .wait_until = 0 }, frames: std.ArrayListUnmanaged(AsyncFrame) = .empty, - /// The task's locals, retained across suspension. POD-only in M0.8/M1.0.11 - /// (heap locals surviving a suspend are out of scope — flagged for Review). + /// The task's ROOT locals (the rule body's scope), retained across suspension. + /// An `async fn` call frame carries its OWN scope (`CallFrame.scope`); the + /// active scope for a statement is `currentScope` (nearest enclosing call + /// frame, else this). POD-only across a suspend (the M0.8 caveat). locals: Locals = .{}, + /// The delivery target for a wake-condition `await` used in a value position + /// (`let x = await wait(…)`): the (unit) value is bound on resume (M1.0.11 + /// E2). `.discard` for a bare `await wait/global_event` (the common form). + pending_bind: RetTarget = .discard, fn deinit(self: *AsyncTask, gpa: std.mem.Allocator) void { + for (self.frames.items) |*f| switch (f.*) { + .call => |cf| { + cf.scope.deinit(gpa); + gpa.destroy(cf.scope); + }, + else => {}, + }; self.frames.deinit(gpa); self.locals.deinit(gpa); } @@ -1770,15 +1813,205 @@ pub const Interpreter = struct { try self.driveTask(world, &self.async_tasks.items[ti], report); } + /// The active scope for the top frame (M1.0.11 E2): the nearest enclosing + /// `async fn` call frame's own scope, or the task's root locals if none. The + /// `&task.locals` fallback is stable within a drive (no task is created + /// mid-drive; `async_tasks` never reallocates while `driveLoop` runs). + fn currentScope(task: *AsyncTask) *Locals { + var i = task.frames.items.len; + while (i > 0) { + i -= 1; + switch (task.frames.items[i]) { + .call => |*cf| return cf.scope, + else => {}, + } + } + return &task.locals; + } + + /// Free a frame's owned resources (M1.0.11 E2): only a `call` frame owns heap + /// (its `async fn` scope). Called for every frame removal. + fn deinitFrame(self: *Interpreter, frame: *AsyncFrame) void { + switch (frame.*) { + .call => |cf| { + cf.scope.deinit(self.gpa); + self.gpa.destroy(cf.scope); + }, + else => {}, + } + } + + /// Pop the top frame, freeing its owned resources (M1.0.11 E2). + fn popFrame(self: *Interpreter, task: *AsyncTask) void { + if (task.frames.pop()) |f| { + var fr = f; + self.deinitFrame(&fr); + } + } + + /// Pop every frame, freeing owned resources (M1.0.11 E2) — the task-teardown + /// path (completion / fail-loud), replacing a bare `clearRetainingCapacity`. + fn clearFrames(self: *Interpreter, task: *AsyncTask) void { + while (task.frames.items.len > 0) self.popFrame(task); + } + + /// Deliver a resolved `await` value to its site (M1.0.11 E2). `return_` is + /// NOT handled here (it routes through the returning-unwind); this covers the + /// value-delivering targets. + fn deliverAwaitValue(self: *Interpreter, task: *AsyncTask, ret: RetTarget, v: Value) StmtError!void { + switch (ret) { + .discard, .return_ => {}, + .bind => |b| try currentScope(task).put(self.gpa, b.name, v, b.is_mut), + .assign_local => |name| { + const ptr = currentScope(task).getPtr(name) orelse return error.RuntimeFailure; + ptr.* = v; + }, + } + } + + /// Classify a statement as a statement-head `await` and its delivery target + /// (M1.0.11 E2): a bare expr-stmt (discard), a `let x = await …` (bind), a + /// simple `x = await …` (assign a local), or a `return await …`. A destructuring + /// `let`, a compound/complex-lvalue assignment, or any non-`await` RHS returns + /// `null` (handled elsewhere / by the sync executor). Sub-expression `await` + /// is not statement-head — it reaches `evalExpr` and fails loud (E0904, E3). + const AwaitSite = struct { await_id: NodeId, ret: RetTarget }; + fn stmtHeadAwait(self: *Interpreter, stmt: NodeId) ?AwaitSite { + switch (self.ast.stmtKind(stmt)) { + .expr_stmt => { + const e: NodeId = @bitCast(self.ast.stmtData(stmt)); + if (self.ast.exprKind(e) == .await_expr) return .{ .await_id = e, .ret = .discard }; + }, + .let_stmt => { + const let = self.ast.let_stmts.items[self.ast.stmtData(stmt)]; + if (let.name != 0 and self.ast.exprKind(let.value) == .await_expr) + return .{ .await_id = let.value, .ret = .{ .bind = .{ .name = let.name, .is_mut = let.is_mut } } }; + }, + .assign_stmt => { + const a = self.ast.assign_stmts.items[self.ast.stmtData(stmt)]; + if (a.op == .assign and self.ast.exprKind(a.value) == .await_expr and self.ast.exprKind(a.target) == .ident) + return .{ .await_id = a.value, .ret = .{ .assign_local = self.ast.exprData(a.target) } }; + }, + .return_stmt => { + const operand: NodeId = @bitCast(self.ast.stmtData(stmt)); + if (!operand.isNone() and self.ast.exprKind(operand) == .await_expr) + return .{ .await_id = operand, .ret = .return_ }; + }, + else => {}, + } + return null; + } + + /// Evaluate a call's arguments in the caller's scope and bind them (parameter + /// order, named-arg aware) into `dest` (M1.0.11 E2). Shared by the fn and + /// method call-frame setup; mirrors the arg handling of `callFn`/`callMethod`. + fn bindAsyncParams(self: *Interpreter, world: *World, caller: *Locals, dest: *Locals, fndecl: ast_mod.FnDecl, args_start: u32, args_len: u32, names_start: u32) StmtError!void { + if (fndecl.params_len != args_len) return error.RuntimeFailure; + if (args_len > max_call_args) return error.RuntimeFailure; + var values: [max_call_args]Value = undefined; + var j: u32 = 0; + while (j < args_len) : (j += 1) { + const arg: NodeId = @bitCast(self.ast.extra.items[args_start + j]); + values[j] = try self.evalExpr(world, caller, arg); + } + var i: u32 = 0; + while (i < fndecl.params_len) : (i += 1) { + const p = self.ast.fn_params.items[fndecl.params_start + i]; + const idx = self.ast.callArgIndexForParam(args_start, args_len, names_start, i, p.name) orelse return error.RuntimeFailure; + try dest.put(self.gpa, p.name, values[idx], false); + } + } + + /// Begin a direct `await f()` on an `async fn` / `async method` (M1.0.11 E2): + /// resolve the callee, evaluate its args in the caller's scope, create a fresh + /// heap-boxed scope (params + `self`), advance the caller cursor PAST the await, + /// and push a `call` frame carrying `ret` (where `f`'s return value lands). `f` + /// then runs as frames on this task; its own `await` suspends the whole task. + /// A direct call to a SYNC fn/method, or an unresolved callee, fails loud. + fn beginAsyncCall(self: *Interpreter, world: *World, task: *AsyncTask, scope: *Locals, cursor: *u32, call_expr: NodeId, ret: RetTarget) StmtError!StepAction { + const new_scope = try self.gpa.create(Locals); + new_scope.* = .{}; + errdefer { + new_scope.deinit(self.gpa); + self.gpa.destroy(new_scope); + } + var fndecl: ast_mod.FnDecl = undefined; + switch (self.ast.exprKind(call_expr)) { + .fn_call => { + const call = self.ast.call_exprs.items[self.ast.exprData(call_expr)]; + if (self.ast.exprKind(call.callee) != .ident) return error.RuntimeFailure; + const callee_name = self.ast.exprData(call.callee); + if (scope.get(callee_name) != null) return error.RuntimeFailure; // a local (closure), not an async fn + fndecl = self.fns.get(callee_name) orelse return error.RuntimeFailure; + if (!fndecl.is_async) return error.RuntimeFailure; + try self.bindAsyncParams(world, scope, new_scope, fndecl, call.args_start, call.args_len, call.names_start); + }, + .method_call => { + const mc = self.ast.method_calls.items[self.ast.exprData(call_expr)]; + var self_value: ?Value = null; + if (self.ast.exprKind(mc.receiver) == .path) { + // `Type.assoc()` — associated fn (no `self`). + const type_name = self.ast.exprData(mc.receiver); + fndecl = self.methods.get(methodKey(type_name, mc.method_name)) orelse return error.RuntimeFailure; + } else { + const recv = try self.evalExpr(world, scope, mc.receiver); + self_value = recv; + switch (recv) { + .struct_ref => |h| { + const tn = self.structs.list.items[h].type_name; + fndecl = self.methods.get(methodKey(tn, mc.method_name)) orelse + self.trait_methods.get(methodKey(tn, mc.method_name)) orelse + return error.RuntimeFailure; + }, + .entity_id => { + const en = self.ast.strings.find("Entity") orelse return error.RuntimeFailure; + fndecl = self.trait_methods.get(methodKey(en, mc.method_name)) orelse return error.RuntimeFailure; + }, + else => return error.RuntimeFailure, + } + } + if (!fndecl.is_async) return error.RuntimeFailure; + if (self_value) |sv| { + if (self.ast.strings.find("self")) |sid| try new_scope.put(self.gpa, sid, sv, fndecl.self_kind == .by_mut); + } + try self.bindAsyncParams(world, scope, new_scope, fndecl, mc.args_start, mc.args_len, mc.names_start); + }, + else => return error.RuntimeFailure, + } + cursor.* += 1; // advance the caller PAST the await before descending into `f` + try task.frames.append(self.gpa, .{ .call = .{ + .block_start = fndecl.body_start, + .block_len = fndecl.body_len, + .scope = new_scope, + .value_expr = if (fndecl.value.isNone()) null else fndecl.value, + .ret = ret, + } }); + return .pushed; + } + /// Drive `task` over its resume frame-stack until it suspends at the next /// `await` or runs to completion (M1.0.11 E1). Resets the per-body signal - /// state, runs `driveLoop`, and routes a fail-loud into `finishTaskFailed`. + /// state, delivers a pending value-await binding on resume (E2), runs + /// `driveLoop`, and routes a fail-loud into `finishTaskFailed`. fn driveTask(self: *Interpreter, world: *World, task: *AsyncTask, report: *RuntimeReport) !void { self.control = .none; self.control_label = 0; self.thrown = false; self.returning = false; self.pending_error = null; + // A wake-condition `await` used in a value position (`let x = await wait(…)`) + // resolves to `unit`; deliver it into the resuming scope now (M1.0.11 E2). + if (@as(std.meta.Tag(RetTarget), task.pending_bind) != .discard) { + self.deliverAwaitValue(task, task.pending_bind, .{ .unit = {} }) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.RuntimeFailure => { + task.pending_bind = .discard; + self.finishTaskFailed(task, report); + return; + }, + }; + task.pending_bind = .discard; + } const outcome = self.driveLoop(world, task) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.RuntimeFailure => { @@ -1800,23 +2033,26 @@ pub const Interpreter = struct { /// for the next tick) or `.completed` (stack drained / a `return` / an /// uncaught `throw` / a top-level `break`). fn driveLoop(self: *Interpreter, world: *World, task: *AsyncTask) StmtError!AsyncOutcome { - const locals = &task.locals; drive: while (task.frames.items.len > 0) { const ti = task.frames.items.len - 1; + // The active scope is the nearest enclosing `async fn` call frame's + // own scope (M1.0.11 E2), else the task root — recomputed each pass so + // a pushed/popped call frame flips it for the next statement. + const scope = currentScope(task); switch (task.frames.items[ti]) { .run => { const rf = &task.frames.items[ti].run; if (rf.cursor >= rf.block_len) { const val = rf.value_expr; - _ = task.frames.pop(); + self.popFrame(task); // A block's trailing value runs for effect (rare at stmt // position); it cannot suspend (an `await` there is a // sub-expression, rejected E0904 / fails loud). - if (val) |v| _ = try self.evalExpr(world, locals, v); + if (val) |v| _ = try self.evalExpr(world, scope, v); continue :drive; } const stmt: NodeId = @bitCast(self.ast.extra.items[rf.block_start + rf.cursor]); - switch (try self.stepBodyStmt(world, task, &task.frames.items[ti].run.cursor, stmt)) { + switch (try self.stepBodyStmt(world, task, scope, &task.frames.items[ti].run.cursor, stmt)) { .suspended => return .suspended, .advanced, .pushed => continue :drive, .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, @@ -1829,7 +2065,7 @@ pub const Interpreter = struct { continue :drive; } const stmt: NodeId = @bitCast(self.ast.extra.items[lf.block_start + lf.cursor]); - switch (try self.stepBodyStmt(world, task, &task.frames.items[ti].loop_.cursor, stmt)) { + switch (try self.stepBodyStmt(world, task, scope, &task.frames.items[ti].loop_.cursor, stmt)) { .suspended => return .suspended, .advanced, .pushed => continue :drive, .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, @@ -1839,8 +2075,8 @@ pub const Interpreter = struct { const wid = task.frames.items[ti].while_.while_id; const wh = self.ast.while_stmts.items[self.ast.stmtData(wid)]; if (!task.frames.items[ti].while_.in_iter) { - if (!(try self.whileCondEnter(world, locals, wid))) { - _ = task.frames.pop(); // condition false → `while` ends + if (!(try self.whileCondEnter(world, scope, wid))) { + self.popFrame(task); // condition false → `while` ends continue :drive; } task.frames.items[ti].while_.in_iter = true; @@ -1852,7 +2088,7 @@ pub const Interpreter = struct { continue :drive; } const stmt: NodeId = @bitCast(self.ast.extra.items[wh.body_start + task.frames.items[ti].while_.cursor]); - switch (try self.stepBodyStmt(world, task, &task.frames.items[ti].while_.cursor, stmt)) { + switch (try self.stepBodyStmt(world, task, scope, &task.frames.items[ti].while_.cursor, stmt)) { .suspended => return .suspended, .advanced, .pushed => continue :drive, .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, @@ -1861,8 +2097,8 @@ pub const Interpreter = struct { .for_ => { const f = self.ast.for_stmts.items[self.ast.stmtData(task.frames.items[ti].for_.for_id)]; if (!task.frames.items[ti].for_.in_iter) { - if (!(try self.forAdvance(&task.frames.items[ti].for_, locals))) { - _ = task.frames.pop(); // iterator exhausted → `for` ends + if (!(try self.forAdvance(&task.frames.items[ti].for_, scope))) { + self.popFrame(task); // iterator exhausted → `for` ends continue :drive; } task.frames.items[ti].for_.in_iter = true; @@ -1874,7 +2110,7 @@ pub const Interpreter = struct { continue :drive; } const stmt: NodeId = @bitCast(self.ast.extra.items[f.body_start + task.frames.items[ti].for_.cursor]); - switch (try self.stepBodyStmt(world, task, &task.frames.items[ti].for_.cursor, stmt)) { + switch (try self.stepBodyStmt(world, task, scope, &task.frames.items[ti].for_.cursor, stmt)) { .suspended => return .suspended, .advanced, .pushed => continue :drive, .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, @@ -1885,11 +2121,42 @@ pub const Interpreter = struct { const start = if (tf.in_catch) tf.catch_start else tf.try_start; const len = if (tf.in_catch) tf.catch_len else tf.try_len; if (tf.cursor >= len) { - _ = task.frames.pop(); // `try` (or `catch`) body done + self.popFrame(task); // `try` (or `catch`) body done continue :drive; } const stmt: NodeId = @bitCast(self.ast.extra.items[start + tf.cursor]); - switch (try self.stepBodyStmt(world, task, &task.frames.items[ti].try_.cursor, stmt)) { + switch (try self.stepBodyStmt(world, task, scope, &task.frames.items[ti].try_.cursor, stmt)) { + .suspended => return .suspended, + .advanced, .pushed => continue :drive, + .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, + } + }, + .call => { + const cf = &task.frames.items[ti].call; + if (cf.cursor >= cf.block_len) { + // `f` fell off the end → implicit return (trailing block + // value, or unit). `scope` is f's own scope (the call frame + // is the top). Pop f (frees its scope), then resolve at the + // caller's await site. + const val = cf.value_expr; + const ret = cf.ret; + var rv: Value = .{ .unit = {} }; + if (val) |v| rv = try self.evalExpr(world, scope, v); + self.popFrame(task); + switch (ret) { + .return_ => { + self.return_value = rv; + self.returning = true; + if (try self.unwindControl(task)) continue :drive else return .completed; + }, + else => { + try self.deliverAwaitValue(task, ret, rv); + continue :drive; + }, + } + } + const stmt: NodeId = @bitCast(self.ast.extra.items[cf.block_start + cf.cursor]); + switch (try self.stepBodyStmt(world, task, scope, &task.frames.items[ti].call.cursor, stmt)) { .suspended => return .suspended, .advanced, .pushed => continue :drive, .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, @@ -1905,13 +2172,24 @@ pub const Interpreter = struct { /// plain run / a pushed child / a resumed-after `await`) BEFORE any frame push /// (a push reallocates `task.frames`, so the pointer is used first). Returns /// the action for `driveLoop` to route. - fn stepBodyStmt(self: *Interpreter, world: *World, task: *AsyncTask, cursor: *u32, stmt: NodeId) StmtError!StepAction { - const locals = &task.locals; - // (1) statement-head `await` → suspend the whole task, resuming AFTER it. - if (self.bareAwaitExpr(stmt)) |aw| { - task.wake = try self.evalAwaitTarget(world, locals, aw); - cursor.* += 1; - return .suspended; + fn stepBodyStmt(self: *Interpreter, world: *World, task: *AsyncTask, scope: *Locals, cursor: *u32, stmt: NodeId) StmtError!StepAction { + // (1) statement-head `await` (M1.0.11 E1/E2) — in an expr-stmt (discard), + // a `let` initializer (bind), an assignment RHS (assign), or a `return` + // operand. A `future` (`await f()`) inlines `f`'s body as a call frame + // (E2); a wake-condition target (`wait` / `global_event`) suspends the + // task, delivering its (unit) value to the site on resume via `pending_bind`. + if (self.stmtHeadAwait(stmt)) |site| { + const aw = self.ast.awaitExpr(site.await_id); + switch (aw.target_kind) { + .future => return try self.beginAsyncCall(world, task, scope, cursor, aw.arg_expr, site.ret), + .wait, .global_event => { + task.wake = try self.evalAwaitTarget(world, scope, site.await_id); + cursor.* += 1; + task.pending_bind = site.ret; + return .suspended; + }, + .wait_unscaled, .entity_event => return error.RuntimeFailure, + } } const sk = self.ast.stmtKind(stmt); // (2a) `while` statement → push a while frame (it re-checks its own cond). @@ -1925,7 +2203,7 @@ pub const Interpreter = struct { // iterator state (so a body `await` resumes at the same element). if (sk == .for_stmt) { const f = self.ast.for_stmts.items[self.ast.stmtData(stmt)]; - const iter = try self.evalExpr(world, locals, f.iterable); + const iter = try self.evalExpr(world, scope, f.iterable); const for_iter: ForIter = switch (iter) { .range => |r| .{ .range = .{ .next = r.start, .end = r.end, .inclusive = r.inclusive } }, .array_ref => |h| .{ .array = .{ .handle = h, .len = self.collections.arrays.items[h].items.len, .idx = 0 } }, @@ -1974,7 +2252,7 @@ pub const Interpreter = struct { .if_expr => { // Select the branch synchronously (a condition cannot suspend); // push its block as a run frame (or nothing if no branch taken). - const branch = try self.asyncIfBranch(world, locals, e); + const branch = try self.asyncIfBranch(world, scope, e); cursor.* += 1; if (branch) |b| try self.pushBlockRun(task, b); return .pushed; @@ -1983,12 +2261,12 @@ pub const Interpreter = struct { // Select the arm synchronously; a block arm becomes a run // frame (so its body can suspend); a bare-expr arm runs for // effect (a sub-expression `await` there fails loud). - const body = try self.matchArmBody(world, locals, e); + const body = try self.matchArmBody(world, scope, e); cursor.* += 1; if (self.ast.exprKind(body) == .block_expr) { try self.pushBlockRun(task, body); } else { - _ = try self.evalExpr(world, locals, body); + _ = try self.evalExpr(world, scope, body); } return .pushed; }, @@ -1996,7 +2274,7 @@ pub const Interpreter = struct { } } // (3) ordinary statement → the shared sync executor. - try self.execStmt(world, locals, stmt); + try self.execStmt(world, scope, stmt); if (self.control != .none or self.thrown or self.returning) return .signaled; cursor.* += 1; return .advanced; @@ -2025,16 +2303,43 @@ pub const Interpreter = struct { /// semantics — and re-establishes a `try`'s handler across a suspension. fn unwindControl(self: *Interpreter, task: *AsyncTask) StmtError!bool { if (self.returning) { - self.returning = false; - self.return_value = .{ .unit = {} }; - task.frames.clearRetainingCapacity(); - return false; + // `return v` unwinds to the nearest enclosing `async fn` call frame, + // which returns `v` to ITS await site (M1.0.11 E2). `return await g()` + // chains: that call frame's own `ret` is `return_`, so we loop and the + // enclosing fn returns `v` too. With no enclosing call frame, the + // `return` is in the rule body — end the task (rules have no value). + while (true) { + find: while (task.frames.items.len > 0) { + switch (task.frames.items[task.frames.items.len - 1]) { + .call => break :find, + else => self.popFrame(task), + } + } + if (task.frames.items.len == 0) { + self.returning = false; + self.return_value = .{ .unit = {} }; + return false; + } + const ret = task.frames.items[task.frames.items.len - 1].call.ret; + const v = self.return_value; + self.popFrame(task); + switch (ret) { + .return_ => self.return_value = v, // enclosing fn returns `v` too → loop + else => { + self.returning = false; + self.return_value = .{ .unit = {} }; + try self.deliverAwaitValue(task, ret, v); + return true; + }, + } + } } if (self.thrown) { // Route the throw to the nearest `try` in its try phase (a `catch`'s - // own throw re-propagates past it). Popping intervening loop/run/for - // frames mirrors the sync unwinding of a throw out of nested control - // flow. If none catches it, the residual `self.thrown` is left set for + // own throw re-propagates past it). Popping intervening loop/run/for/ + // call frames mirrors the sync unwinding of a throw out of nested + // control flow (a call frame's scope is freed by `popFrame`). If none + // catches it, the residual `self.thrown` is left set for // `finishTaskDone` to surface as an UncaughtThrow. while (task.frames.items.len > 0) { const ti = task.frames.items.len - 1; @@ -2042,14 +2347,14 @@ pub const Interpreter = struct { .try_ => |tfv| { if (!tfv.in_catch) { self.thrown = false; - try task.locals.put(self.gpa, tfv.catch_name, self.thrown_value, false); + try currentScope(task).put(self.gpa, tfv.catch_name, self.thrown_value, false); task.frames.items[ti].try_.in_catch = true; task.frames.items[ti].try_.cursor = 0; return true; } - _ = task.frames.pop(); // throw inside this `catch` → propagate past it + self.popFrame(task); // throw inside this `catch` → propagate past it }, - else => _ = task.frames.pop(), + else => self.popFrame(task), } } return false; @@ -2057,8 +2362,10 @@ pub const Interpreter = struct { while (task.frames.items.len > 0) { const ti = task.frames.items.len - 1; switch (task.frames.items[ti]) { - .run => { - _ = task.frames.pop(); // abandon the intervening block + .run, .call, .try_ => { + // A block / call / try frame is transparent to `break`/ + // `continue`: abandon it and keep unwinding to the loop. + self.popFrame(task); }, .loop_ => { const label = task.frames.items[ti].loop_.label; @@ -2067,13 +2374,13 @@ pub const Interpreter = struct { self.control = .none; self.control_label = 0; if (was_break) { - _ = task.frames.pop(); // the loop exits + self.popFrame(task); // the loop exits } else { task.frames.items[ti].loop_.cursor = 0; // continue → loop again } return true; } - _ = task.frames.pop(); // labeled signal for an outer loop → propagate + self.popFrame(task); // labeled signal for an outer loop → propagate }, .while_ => { // `while` carries no label → matches only an unlabeled signal. @@ -2081,13 +2388,13 @@ pub const Interpreter = struct { const was_break = self.control == .break_; self.control = .none; if (was_break) { - _ = task.frames.pop(); + self.popFrame(task); } else { task.frames.items[ti].while_.in_iter = false; // continue → re-check cond } return true; } - _ = task.frames.pop(); // labeled → propagate (while has no label) + self.popFrame(task); // labeled → propagate (while has no label) }, .for_ => { // `for` carries no label → matches only an unlabeled signal. @@ -2095,16 +2402,13 @@ pub const Interpreter = struct { const was_break = self.control == .break_; self.control = .none; if (was_break) { - _ = task.frames.pop(); + self.popFrame(task); } else { task.frames.items[ti].for_.in_iter = false; // continue → next element } return true; } - _ = task.frames.pop(); // labeled → propagate (for has no label) - }, - .try_ => { - _ = task.frames.pop(); // `break`/`continue` unwinds past a `try` + self.popFrame(task); // labeled → propagate (for has no label) }, } } @@ -2128,8 +2432,10 @@ pub const Interpreter = struct { self.control_label = 0; self.returning = false; self.return_value = .{ .unit = {} }; + self.clearFrames(task); // frees any residual call-frame scopes task.state = .done; task.frames.clearAndFree(self.gpa); + task.pending_bind = .discard; task.locals.deinit(self.gpa); task.locals = .{}; } @@ -2142,8 +2448,10 @@ pub const Interpreter = struct { self.control_label = 0; self.thrown = false; self.returning = false; + self.clearFrames(task); // frees any residual call-frame scopes task.state = .done; task.frames.clearAndFree(self.gpa); + task.pending_bind = .discard; task.locals.deinit(self.gpa); task.locals = .{}; } @@ -2276,17 +2584,6 @@ pub const Interpreter = struct { } } - /// The `await_expr` of a bare top-level `await ` statement (an - /// expr-stmt wrapping an `.await_expr`), or null. Only a bare top-level - /// await is a suspension point — the cursor-based resume re-enters at the - /// statement after it. - fn bareAwaitExpr(self: *const Interpreter, stmt_id: NodeId) ?NodeId { - if (self.ast.stmtKind(stmt_id) != .expr_stmt) return null; - const e: NodeId = @bitCast(self.ast.stmtData(stmt_id)); - if (self.ast.exprKind(e) != .await_expr) return null; - return e; - } - /// Resolve an `await` target to a `WakeCond` (M0.8 E3 sub-slice B). `wait(N)` /// counts N TICKS from now; `global_event(T)` waits for an event of type T. /// `wait_unscaled` (needs a real clock), `entity_event` (no entity- @@ -2869,7 +3166,11 @@ pub const Interpreter = struct { /// consumed at this boundary; with no explicit return the trailing block /// value is the implicit return. `async fn` interpretation is E3 (fail loud). fn callFn(self: *Interpreter, world: *World, caller_locals: *Locals, fndecl: ast_mod.FnDecl, call: ast_mod.CallExpr) StmtError!Value { - if (fndecl.is_async) return error.RuntimeFailure; // async interp lands in E3 + // An `async fn` executes via the await call-frame path (`beginAsyncCall`), + // not this synchronous path (M1.0.11 E2). Reaching here for an async fn is + // a direct (non-`await`) call — a function-coloring violation the E4 + // type-checker rejects (E0901); until then it degrades to a fail-loud. + if (fndecl.is_async) return error.RuntimeFailure; if (fndecl.params_len != call.args_len) return error.RuntimeFailure; var frame: Locals = .{}; defer frame.deinit(self.gpa); @@ -3364,7 +3665,9 @@ pub const Interpreter = struct { } fn callMethod(self: *Interpreter, world: *World, caller_locals: *Locals, method: ast_mod.FnDecl, mc: ast_mod.MethodCall, self_value: ?Value) StmtError!Value { - if (method.is_async) return error.RuntimeFailure; // async interp lands in E3 + // As in `callFn`: an `async method` runs via the await call-frame path + // (M1.0.11 E2); a direct sync call is a coloring violation (E0901, E4). + if (method.is_async) return error.RuntimeFailure; if (method.params_len != mc.args_len) return error.RuntimeFailure; var frame: Locals = .{}; defer frame.deinit(self.gpa); @@ -8157,6 +8460,117 @@ test "async rule suspends inside a try body and a post-resume throw routes to th try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); } +test "async fn called via await runs to completion across ticks and its return value flows into a let binding (M1.0.11 E2)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // `compute` is an `async fn` with an internal `await wait` and a return value. + // The caller `let x = await compute()` inlines `compute`'s body onto the + // caller's task (its await suspends the WHOLE task); `compute`'s `return` + // resolves at the caller's await site, binding `x`. `n` goes 0 → 42. + const source = + \\resource Out { n: int = 0 } + \\async fn compute() -> int { + \\ await wait(1) + \\ return 42 + \\} + \\async rule caller() + \\ when resource Out + \\{ + \\ let x = await compute() + \\ let o = get_mut(Out) + \\ o.n = x + \\} + ; + + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + + // tick 1: caller enters `compute`, which suspends at its `await wait(1)` + // (the whole task suspends; the caller has not bound x yet). + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); + // tick 2: `compute` resumes, `return 42` resolves at the caller's await site + // (x = 42), then `o.n = x` → n = 42. + const r2 = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r2.runtime_errors); + try std.testing.expectEqual(@as(i64, 42), readResourceInt(&world, out_id)); + // tick 3: task done — n stays 42. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 42), readResourceInt(&world, out_id)); +} + +test "async method called via await inlines with its own scope and locals survive the suspension (M1.0.11 E2)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // `bumped` is an `async method`: it reads `self.base` into a local, suspends + // at `await wait(1)`, and returns the local + 1 on resume. The call frame + // carries `self` + the local in its OWN heap-boxed scope, retained across the + // suspension (no collision with the caller's scope). `n` goes 0 → 11. + const source = + \\struct Counter { base: int = 0 } + \\impl Counter { + \\ async fn bumped(self) -> int { + \\ let b = self.base + \\ await wait(1) + \\ return b + 1 + \\ } + \\} + \\resource Out { n: int = 0 } + \\async rule caller() + \\ when resource Out + \\{ + \\ let c = Counter { base: 10 } + \\ let x = await c.bumped() + \\ let o = get_mut(Out) + \\ o.n = x + \\} + ; + + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + + // tick 1: caller builds `c`, enters `c.bumped()`, which reads self.base into a + // local and suspends at its `await wait(1)`. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); + // tick 2: `bumped` resumes (its local `b = 10` survived), returns 11, which + // binds `x`; `o.n = x` → n = 11. + const r2 = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r2.runtime_errors); + try std.testing.expectEqual(@as(i64, 11), readResourceInt(&world, out_id)); + // tick 3: task done — n stays 11. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 11), readResourceInt(&world, out_id)); +} + test "runProgram Optional ops: ??, !, ?., patterns, pop, m[k] (M0.8 E3-C tranche 4)" { const gpa = std.testing.allocator; var world = World.init(); diff --git a/src/etch/types.zig b/src/etch/types.zig index c325518..bde5f15 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -4552,6 +4552,17 @@ pub const TypeChecker = struct { // no body handle (§4.5). `checkStmt` handles the legal statement // position before this arm is reached. .spawn_struct => return try self.checkSpawnStruct(id, data, ctx_opt, true), + // M1.0.11 E2 — type an `await`. The `future` form (`await f()`) carries + // the awaited call's declared return type, so `let x = await f()` binds + // the right type (and the inner call is type-checked here — arg count, + // etc.). The wake-condition forms (`wait` / `wait_unscaled` / + // `entity_event` / `global_event`) produce no value. Function coloring + // (E0901) and placement (E0904) are added in E4 / E3. + .await_expr => { + const aw = self.arena.awaitExpr(id); + if (aw.target_kind == .future) return try self.synthExprE(aw.arg_expr, ctx_opt); + return ResolvedType.unknown; + }, .paren => unreachable, // parser doesn't emit a paren node — it returns the inner expr else => return ResolvedType.unknown, } From 123ff9a6928e6b39cd57b403befd87e9f4518d1b Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 15:50:52 +0200 Subject: [PATCH 08/15] docs(brief): correct E2 log on callFn/callMethod async gates --- briefs/M1.0.11-async-core.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md index fb5182c..31eec10 100644 --- a/briefs/M1.0.11-async-core.md +++ b/briefs/M1.0.11-async-core.md @@ -155,7 +155,7 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § - 2026-07-01 — E1 design (interp.zig). Replace the per-rule `AsyncSlot` (single top-level cursor, `[]AsyncSlot` parallel to `rule_descs`) with a dynamic heap-allocated `AsyncTask` pool (`async_tasks: ArrayListUnmanaged`) + a per-rule `rule_tasks: []?u32` handle map. Each task carries an EXPLICIT resume frame-stack (`frames: ArrayListUnmanaged(AsyncFrame)`, innermost last) of `run`/`loop_`/`while_` frames. `driveTask` is an iterative stack machine: statement-head `await` suspends at ANY depth (top-level and inside `if`/`else`(-if chain)/`loop`/`while`/`match`-arm/`block`), and resume re-drives the persisted stack without re-running prefix statements (no double `emit`) — the explicit-stack model makes resume trivial and side-effect-free (`etch-reference-part1.md §9.12`). Sync control-flow (`if`/`match`/`loop`/`block`/`while`) is mirrored (not delegated) in the driver so a nested body can suspend; all other statements delegate to the existing sync `execStmt`. WakeCond (int-tick `wait` + `global_event`) is unchanged in E1 — the Duration `wait` and E0904/E0901 land in E3/E4. Residual (documented, not in the §9.12 placement list): `await` inside a `for` body and inside a `try` body fall through to sync `execStmt` and fail loud (needs iteration-state / try-frame serialization) — deferred. - 2026-07-01 — E1 done. `interp.zig`: new async substrate wired (`compile` allocs `rule_tasks`, `deinit` tears down the pool). The 3 existing async-rule tests pass UNCHANGED on the new substrate (re-host is behavior-preserving; int-tick `wait` stays until E3). Added 2 inline tests: `await` inside an `if` body (depth-2 stack + no double `emit`, counted via an `@on_event` observer) and inside a `loop` body (per-iteration resume + `break` unwind). Stale comment fixed (the runtime-failure test cited the removed `driveAsyncBody`/`finishAsync`). Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), and the etch test target `367 pass (367 total)` in BOTH Debug and ReleaseSafe. Sync-only programs keep an empty pool + `null` handle map + no `async_tick` churn (byte-identical by construction). `parser.zig`/`token.zig`/`ast.zig` untouched. - 2026-07-01 — E1 fix (Guy STOP on review): the earlier residual was wrong to defer — `for_stmt` and `try_catch_stmt` are real EBNF v0.6 statement blocks whose statement-head `await` must execute (C1.6), and deferring them would force a future `AsyncFrame` variant (re-opening the frozen substrate, forbidden). Added `for_` and `try_` to `AsyncFrame`, completing the frame set BEFORE E2. `for_` (`ForIter` = range/array/map + cursor, persisted at suspension; mirrors the sync `for`) — a `range` is fully sound; an array/map iterable across a suspend inherits the M0.8 heap-across-suspend caveat and `forAdvance` bounds-checks the handle → typed `RuntimeFailure`, never an OOB crash. `try_` (try/catch ranges + `in_catch` phase) — `unwindControl` routes a `throw` (even one that runs only AFTER a resume) to the nearest enclosing `try`, re-establishing the handler across the suspension; a `catch`'s own throw re-propagates. `break`/`continue` unwinding extended to `for_` (unlabeled) and `try_` (transparent). Removed the "deferred" comment. No frame owns heap → teardown unchanged. Added 2 inline tests: `await` in a `for` body (range, per-iteration resume, iterator state preserved: n = 1 → 3 → 6) and in a `try` body (post-resume `throw` routes to `catch`, caught → `runtime_errors == 0`). Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), etch target `369 pass (369 total)` in Debug AND ReleaseSafe. FROZEN Notes "Await placement" bullet broadened per Guy's ruling (Recorded deviation below). -- 2026-07-01 — E2 (async fn / async method execution). `interp.zig`: removed the two `is_async` `RuntimeFailure` gates in `callFn`/`callMethod` (a direct sync call to an async callee stays fail-loud pending E4 coloring; async execution runs via the await path). New `call` `AsyncFrame` variant (`CallFrame`) — a direct `await f()`/`await obj.m()` (the `future` target) inlines the callee's body as a frame on the CALLER's task, so the callee's own `await` suspends the whole task and its `return` resolves at the caller's await site. Each call frame OWNS a heap-boxed `scope` (params + `self`, freed on pop via `deinitFrame`/`popFrame`); `currentScope` walks the stack for the nearest enclosing call scope (else task root), so a callee's locals never collide with the caller's and survive suspension. `RetTarget` classifies the await site (`stmtHeadAwait`): `discard` (expr-stmt), `bind` (`let x = await f()`), `assign_local` (`x = await f()`, simple lvalue), `return_` (`return await f()`). `deliverAwaitValue` binds/assigns f's return at completion; `return_` (and an explicit `return v` inside a callee) routes through `unwindControl`'s returning-loop (find nearest call frame → deliver / chain `return await`). A wake-condition `await` in a value position (`let x = await wait(…)`) delivers its unit value on resume via `task.pending_bind`. `types.zig`: `synthExprE` gains an `await_expr` arm — `future` → the awaited call's return type (so `let x = await f()` binds the right type + the inner call is type-checked), else `unknown`; no coloring/placement yet (E4/E3). `callFn`/`callMethod` sync arg-binding factored into the shared `bindAsyncParams`. Added 2 inline tests: an `async fn` (`let x = await compute()`, return flows to the binding, n → 42) and an `async method` (`let x = await c.bumped()`, own scope + a local surviving the suspend, n → 11). Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), etch target `371 pass (371 total)` in Debug AND ReleaseSafe, full suite exit 0, no leaks (testing.allocator). `parser.zig`/`token.zig`/`ast.zig` untouched. +- 2026-07-01 — E2 (async fn / async method execution). `interp.zig`: the two `is_async` `RuntimeFailure` gates in `callFn`/`callMethod` (interp.zig:3173 / :3670) are KEPT — they are the sync-call path's fail-loud on a DIRECT (non-`await`) call to an async callee (E4 coloring will promote it to a compile error). Async execution never reaches them: the await path (`beginAsyncCall`) short-circuits `callFn`/`callMethod`. (Only the obsolete "async interp lands in E3" comments changed.) New `call` `AsyncFrame` variant (`CallFrame`) — a direct `await f()`/`await obj.m()` (the `future` target) inlines the callee's body as a frame on the CALLER's task, so the callee's own `await` suspends the whole task and its `return` resolves at the caller's await site. Each call frame OWNS a heap-boxed `scope` (params + `self`, freed on pop via `deinitFrame`/`popFrame`); `currentScope` walks the stack for the nearest enclosing call scope (else task root), so a callee's locals never collide with the caller's and survive suspension. `RetTarget` classifies the await site (`stmtHeadAwait`): `discard` (expr-stmt), `bind` (`let x = await f()`), `assign_local` (`x = await f()`, simple lvalue), `return_` (`return await f()`). `deliverAwaitValue` binds/assigns f's return at completion; `return_` (and an explicit `return v` inside a callee) routes through `unwindControl`'s returning-loop (find nearest call frame → deliver / chain `return await`). A wake-condition `await` in a value position (`let x = await wait(…)`) delivers its unit value on resume via `task.pending_bind`. `types.zig`: `synthExprE` gains an `await_expr` arm — `future` → the awaited call's return type (so `let x = await f()` binds the right type + the inner call is type-checked), else `unknown`; no coloring/placement yet (E4/E3). `callFn`/`callMethod` sync arg-binding factored into the shared `bindAsyncParams`. Added 2 inline tests: an `async fn` (`let x = await compute()`, return flows to the binding, n → 42) and an `async method` (`let x = await c.bumped()`, own scope + a local surviving the suspend, n → 11). Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), etch target `371 pass (371 total)` in Debug AND ReleaseSafe, full suite exit 0, no leaks (testing.allocator). `parser.zig`/`token.zig`/`ast.zig` untouched. ## Recorded deviations @@ -169,6 +169,10 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § - +## Notes + +- 2026-07-01 (Guy, E2 review) — clarification, no code change: the `callFn`/`callMethod` `is_async` gates (interp.zig:3173 / :3670) are NOT removed and must NOT be — they fail-loud a DIRECT sync call to an async fn. `beginAsyncCall` short-circuits `callFn`, so the await path never hits them. E4 coloring will turn this runtime fail-loud into a compile error. (Corrects an inaccurate word — "removed the gates" — in the E2 execution-log entry; the code was always correct.) + ## Closing notes *Fill in at Status → CLOSED, just before opening the PR.* From 0883d1f3b698df29a7c9f630cb7912a12747c51d Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 16:19:26 +0200 Subject: [PATCH 09/15] feat(etch): wait Duration + E0904 await placement (M1.0.11 E3) --- briefs/M1.0.11-async-core.md | 2 + src/etch/diagnostics.zig | 5 + src/etch/interp.zig | 193 ++++++++++++++++++++++++++++------- src/etch/types.zig | 91 +++++++++++++++-- 4 files changed, 250 insertions(+), 41 deletions(-) diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md index 31eec10..6ba10fb 100644 --- a/briefs/M1.0.11-async-core.md +++ b/briefs/M1.0.11-async-core.md @@ -157,6 +157,8 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § - 2026-07-01 — E1 fix (Guy STOP on review): the earlier residual was wrong to defer — `for_stmt` and `try_catch_stmt` are real EBNF v0.6 statement blocks whose statement-head `await` must execute (C1.6), and deferring them would force a future `AsyncFrame` variant (re-opening the frozen substrate, forbidden). Added `for_` and `try_` to `AsyncFrame`, completing the frame set BEFORE E2. `for_` (`ForIter` = range/array/map + cursor, persisted at suspension; mirrors the sync `for`) — a `range` is fully sound; an array/map iterable across a suspend inherits the M0.8 heap-across-suspend caveat and `forAdvance` bounds-checks the handle → typed `RuntimeFailure`, never an OOB crash. `try_` (try/catch ranges + `in_catch` phase) — `unwindControl` routes a `throw` (even one that runs only AFTER a resume) to the nearest enclosing `try`, re-establishing the handler across the suspension; a `catch`'s own throw re-propagates. `break`/`continue` unwinding extended to `for_` (unlabeled) and `try_` (transparent). Removed the "deferred" comment. No frame owns heap → teardown unchanged. Added 2 inline tests: `await` in a `for` body (range, per-iteration resume, iterator state preserved: n = 1 → 3 → 6) and in a `try` body (post-resume `throw` routes to `catch`, caught → `runtime_errors == 0`). Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), etch target `369 pass (369 total)` in Debug AND ReleaseSafe. FROZEN Notes "Await placement" bullet broadened per Guy's ruling (Recorded deviation below). - 2026-07-01 — E2 (async fn / async method execution). `interp.zig`: the two `is_async` `RuntimeFailure` gates in `callFn`/`callMethod` (interp.zig:3173 / :3670) are KEPT — they are the sync-call path's fail-loud on a DIRECT (non-`await`) call to an async callee (E4 coloring will promote it to a compile error). Async execution never reaches them: the await path (`beginAsyncCall`) short-circuits `callFn`/`callMethod`. (Only the obsolete "async interp lands in E3" comments changed.) New `call` `AsyncFrame` variant (`CallFrame`) — a direct `await f()`/`await obj.m()` (the `future` target) inlines the callee's body as a frame on the CALLER's task, so the callee's own `await` suspends the whole task and its `return` resolves at the caller's await site. Each call frame OWNS a heap-boxed `scope` (params + `self`, freed on pop via `deinitFrame`/`popFrame`); `currentScope` walks the stack for the nearest enclosing call scope (else task root), so a callee's locals never collide with the caller's and survive suspension. `RetTarget` classifies the await site (`stmtHeadAwait`): `discard` (expr-stmt), `bind` (`let x = await f()`), `assign_local` (`x = await f()`, simple lvalue), `return_` (`return await f()`). `deliverAwaitValue` binds/assigns f's return at completion; `return_` (and an explicit `return v` inside a callee) routes through `unwindControl`'s returning-loop (find nearest call frame → deliver / chain `return await`). A wake-condition `await` in a value position (`let x = await wait(…)`) delivers its unit value on resume via `task.pending_bind`. `types.zig`: `synthExprE` gains an `await_expr` arm — `future` → the awaited call's return type (so `let x = await f()` binds the right type + the inner call is type-checked), else `unknown`; no coloring/placement yet (E4/E3). `callFn`/`callMethod` sync arg-binding factored into the shared `bindAsyncParams`. Added 2 inline tests: an `async fn` (`let x = await compute()`, return flows to the binding, n → 42) and an `async method` (`let x = await c.bumped()`, own scope + a local surviving the suspend, n → 11). Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), etch target `371 pass (371 total)` in Debug AND ReleaseSafe, full suite exit 0, no leaks (testing.allocator). `parser.zig`/`token.zig`/`ast.zig` untouched. +- 2026-07-01 — E3 (owned await targets + Duration `wait` + placement rule). (a) `wait` now takes a `Duration` (final API, §9.4): `evalAwaitTarget`'s `.wait` arm parses a Duration LITERAL → seconds (`durationLiteralSeconds`, strips the `s`) → tick count via the fixed 1/60 timestep constant `async_fixed_dt_hz = 60` (`ticks = round(secs * 60)`); a non-literal Duration (const/arithmetic) stays fail-loud (out of scope). `wait` no longer takes an int; the existing `await wait(N)` call sites/tests migrated to the Duration form (`wait(1)` → `wait(0.02s)` = 1 tick, `wait(2)` → `wait(0.04s)` = 2 ticks — exact tick counts preserved, no assertion churn). (b)/(c) `global_event` re-host + direct-call `future` were already delivered by E1/E2. (d) `diagnostics.zig`: new `await_not_statement_head` kind → **E0904** / `AwaitNotStatementHead` (the free E09xx block). `types.zig`: a `stmt_head_await` field is set at the top of `checkStmt` to the statement's head `await` (expr-stmt / `let` init / simple assign RHS / `return` operand); `synthExprE`'s `await_expr` arm emits E0904 for any OTHER `await` it visits (a sub-expression). Tests: `await wait(1.0s)` resumes at tick 60 (fixed-timestep pin); `wait_unscaled`/`entity_event`/handle-await still fail loud (partition intact); E0904 fires on `return some(await f())` and NOT on the four head forms. Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), etch target `374 pass (374 total)` in Debug AND ReleaseSafe, full suite exit 0. `parser.zig`/`token.zig`/`ast.zig` untouched. + ## Recorded deviations *Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each references the commit that records it. Empty at milestone end = nominal case.* diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index 4f1d73b..f57d07e 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -360,6 +360,9 @@ pub const DiagnosticCode = enum { prefab_component_redefined, // M0.8 E7 — E1796 PrefabComponentRedefined (RESERVED: variant/base component-shape merge is M0.9 runtime) prefab_remove_base_component, // M0.8 E7 — W1790 PrefabRemoveBaseComponent (RESERVED: no `remove` syntax in the §24.1 grammar) + // ── async / effects (E09xx, M1.0.11 — etch-resolver-types.md §9.2) ── + await_not_statement_head, // M1.0.11 E3 — E0904 AwaitNotStatementHead (Phase-1 tree-walker: `await` must be a statement's full RHS) + /// Canonical short code, e.g. `"E0001"`. pub fn code(self: DiagnosticCode) []const u8 { return switch (self) { @@ -545,6 +548,7 @@ pub const DiagnosticCode = enum { .prefab_component_field_type_invalid => "E1795", .prefab_component_redefined => "E1796", .prefab_remove_base_component => "W1790", + .await_not_statement_head => "E0904", }; } @@ -733,6 +737,7 @@ pub const DiagnosticCode = enum { .prefab_component_field_type_invalid => "PrefabComponentFieldTypeInvalid", .prefab_component_redefined => "PrefabComponentRedefined", .prefab_remove_base_component => "PrefabRemoveBaseComponent", + .await_not_statement_head => "AwaitNotStatementHead", }; } }; diff --git a/src/etch/interp.zig b/src/etch/interp.zig index be8a9b6..6a4019b 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -513,13 +513,29 @@ const Control = enum { none, break_, continue_ }; /// What an enclosing loop should do once a control signal has surfaced. const LoopAction = enum { again, stop, propagate }; +/// Phase-1 fixed-timestep tick rate (`etch-reference-part1.md §9.12`): `await +/// wait(d)` converts a `Duration` to `async_tick` counts as `round(seconds * +/// 60)` — the 1/60 frame convention. M1.0.13 replaces this with scaled game time +/// WITHOUT changing the `wait` signature: at `time_scale = 1` and fixed `dt = +/// 1/60`, the behavior is identical. +const async_fixed_dt_hz: f64 = 60.0; + +/// Parse the seconds of a `Duration` literal lexeme (`"1.5s"` → 1.5) — the +/// minimal Duration→seconds path `await wait` needs (M1.0.11 E3). `null` if the +/// lexeme is malformed. General `Duration` arithmetic stays out of scope. +fn durationLiteralSeconds(text: []const u8) ?f64 { + if (text.len < 2 or text[text.len - 1] != 's') return null; + return std.fmt.parseFloat(f64, text[0 .. text.len - 1]) catch null; +} + /// The condition that resumes a suspended `async rule` (M0.8 E3 sub-slice B). /// The tree-walker is its own runtime (`etch-reference-part1.md §9`): an /// `await` suspends the rule as a task-record, polled each tick in `stepOnce`. const WakeCond = union(enum) { - /// Resume once `async_tick` reaches this value. `await wait(N)` reached at - /// tick T sets it to T + N — M0.8 counts `wait` in TICKS (no wall-clock; - /// `wait_unscaled` / duration waits fail loud, out of E3 — Guy's ruling). + /// Resume once `async_tick` reaches this value. `await wait(d)` reached at + /// tick T sets it to T + `round(seconds(d) * 60)` — the Phase-1 fixed-timestep + /// conversion (M1.0.11 E3, `async_fixed_dt_hz`). `wait_unscaled` (M1.0.13) + /// stays fail-loud. wait_until: u64, /// Resume once an event of this type is present in the per-tick EventStore /// (M0.8 E3 sub-slice B — `await global_event(T)`). The producer must run @@ -821,7 +837,8 @@ pub const Interpreter = struct { /// byte-identical to the pre-B runtime (no `async_tick` churn, no slots). has_async: bool = false, /// Logical async clock — incremented once per `stepOnce` when `has_async`. - /// `await wait(N)` resolves against it (N is a tick count, not seconds). + /// `await wait(d)` resolves against it: a `Duration` is converted to a tick + /// count via the fixed 1/60 timestep (`async_fixed_dt_hz`, M1.0.11 E3). async_tick: u64 = 0, /// Dynamic pool of suspendable tasks (M1.0.11 E1) — the growable replacement /// for the M0.8 per-rule `AsyncSlot` slice. A task is appended on first spawn @@ -1489,7 +1506,7 @@ pub const Interpreter = struct { // advances the tick (byte-identical to the pre-E3 runtime). if (self.has_changed) world.beginFrame(); // Advance the logical async clock once per tick when async rules are - // present (M0.8 E3 sub-slice B). `await wait(N)` resolves against it. + // present (M0.8 E3 sub-slice B). `await wait(d)` resolves against it. if (self.has_async) self.async_tick += 1; // Events have a per-tick lifetime (`Lifetime.tick`): clear the previous // tick's queue before running this tick's rules (M0.8 E3). @@ -2183,7 +2200,7 @@ pub const Interpreter = struct { switch (aw.target_kind) { .future => return try self.beginAsyncCall(world, task, scope, cursor, aw.arg_expr, site.ret), .wait, .global_event => { - task.wake = try self.evalAwaitTarget(world, scope, site.await_id); + task.wake = try self.evalAwaitTarget(site.await_id); cursor.* += 1; task.pending_bind = site.ret; return .suspended; @@ -2584,22 +2601,24 @@ pub const Interpreter = struct { } } - /// Resolve an `await` target to a `WakeCond` (M0.8 E3 sub-slice B). `wait(N)` - /// counts N TICKS from now; `global_event(T)` waits for an event of type T. - /// `wait_unscaled` (needs a real clock), `entity_event` (no entity- - /// association in the global EventStore), and `future` (T2) fail loud — - /// deferred, flagged for Review E3. The interpreter is the reference. - fn evalAwaitTarget(self: *Interpreter, world: *World, locals: *Locals, await_id: NodeId) StmtError!WakeCond { + /// Resolve a wake-condition `await` target to a `WakeCond` (M1.0.11 E3). Only + /// `wait` / `global_event` reach here (`future` is handled by `beginAsyncCall` + /// upstream). `wait` takes a `Duration` (final API, §9.4): a Duration LITERAL + /// → seconds → `async_tick` counts via the fixed 1/60 timestep + /// (`async_fixed_dt_hz`). M1.0.13 swaps this for scaled game time WITHOUT + /// changing the signature (at `time_scale = 1`, fixed `dt = 1/60`, identical). + /// A non-literal Duration (const / arithmetic) is out of scope → fail loud. + /// `global_event(T)` waits for an event of type `T`. `wait_unscaled` (M1.0.13) + /// / `entity_event` (M1.0.14) fail loud (defensive; filtered upstream). + fn evalAwaitTarget(self: *Interpreter, await_id: NodeId) StmtError!WakeCond { const aw = self.ast.awaitExpr(await_id); switch (aw.target_kind) { .wait => { - const v = try self.evalExpr(world, locals, aw.arg_expr); - const n: i64 = switch (v) { - .int_ => |x| x, - else => return error.RuntimeFailure, - }; - if (n < 0) return error.RuntimeFailure; - return .{ .wait_until = self.async_tick + @as(u64, @intCast(n)) }; + if (self.ast.exprKind(aw.arg_expr) != .duration_lit) return error.RuntimeFailure; + const secs = durationLiteralSeconds(self.ast.strings.slice(self.ast.exprData(aw.arg_expr))) orelse return error.RuntimeFailure; + if (secs < 0) return error.RuntimeFailure; + const ticks: u64 = @intFromFloat(@round(secs * async_fixed_dt_hz)); + return .{ .wait_until = self.async_tick + ticks }; }, .global_event => return .{ .global_event = aw.event_type }, .wait_unscaled, .entity_event, .future => return error.RuntimeFailure, @@ -8116,15 +8135,15 @@ test "runProgram changed on a freshly-spawned entity uses the spawn tick (M1.0.1 try std.testing.expectEqual(@as(Tick, 1), readChangedTick(&world, health_id, e)); } -test "async rule suspends at await wait(N) and resumes N ticks later (M0.8 E3 sub-slice B)" { +test "async rule suspends at await wait(s) and resumes at the equivalent tick (M1.0.11 E3)" { const gpa = std.testing.allocator; var world = World.init(); defer world.deinit(gpa); // The §9.2 shape: a parameterless async rule sets a resource field, suspends - // for 2 ticks (`await wait(2)`), then sets it again. The tree-walker is its - // own runtime — it suspends at the await and resumes on wake, no state - // machine (codegen is Phase 2). The interpreter is the reference. + // on a Duration `await wait(0.04s)` — 0.04 s × 60 = 2 ticks at the Phase-1 + // fixed 1/60 timestep — then sets it again. The tree-walker is its own + // runtime; it suspends at the await and resumes on wake (codegen is Phase 2). const source = \\resource Out { n: int = 0 } \\async rule seq() @@ -8132,7 +8151,7 @@ test "async rule suspends at await wait(N) and resumes N ticks later (M0.8 E3 su \\{ \\ let a = get_mut(Out) \\ a.n = 1 - \\ await wait(2) + \\ await wait(0.04s) \\ let b = get_mut(Out) \\ b.n = 2 \\} @@ -8154,7 +8173,7 @@ test "async rule suspends at await wait(N) and resumes N ticks later (M0.8 E3 su defer interp.deinit(); const out_id = world.registry.idOf("Out").?; - // tick 1: spawn → n=1, suspend at `await wait(2)` (wake at async_tick 3). + // tick 1: spawn → n=1, suspend at `await wait(0.04s)` (wake at async_tick 3). _ = try interp.runFor(&world, 1); try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); // tick 2: still suspended (async_tick 2 < 3) — n unchanged. @@ -8246,7 +8265,7 @@ test "async rule suspends at a statement-head await inside an if body and resume \\ a.n = 1 \\ if a.n == 1 { \\ emit Beat { } - \\ await wait(2) + \\ await wait(0.04s) \\ let b = get_mut(Out) \\ b.n = 2 \\ } @@ -8315,7 +8334,7 @@ test "async rule suspends at a statement-head await inside a loop body and resum \\ if o.n == 3 { \\ break \\ } - \\ await wait(1) + \\ await wait(0.02s) \\ } \\} ; @@ -8368,7 +8387,7 @@ test "async rule suspends at a statement-head await inside a for body and resume \\ for i in 0..3 { \\ let o = get_mut(Out) \\ o.n += i + 1 - \\ await wait(1) + \\ await wait(0.02s) \\ } \\} ; @@ -8422,7 +8441,7 @@ test "async rule suspends inside a try body and a post-resume throw routes to th \\ try { \\ let o = get_mut(Out) \\ o.n = 1 - \\ await wait(1) + \\ await wait(0.02s) \\ throw Error { message: "boom", code: ErrorCode.io_fail } \\ } catch e { \\ let o2 = get_mut(Out) @@ -8472,7 +8491,7 @@ test "async fn called via await runs to completion across ticks and its return v const source = \\resource Out { n: int = 0 } \\async fn compute() -> int { - \\ await wait(1) + \\ await wait(0.02s) \\ return 42 \\} \\async rule caller() @@ -8499,7 +8518,7 @@ test "async fn called via await runs to completion across ticks and its return v defer interp.deinit(); const out_id = world.registry.idOf("Out").?; - // tick 1: caller enters `compute`, which suspends at its `await wait(1)` + // tick 1: caller enters `compute`, which suspends at its `await wait(0.02s)` // (the whole task suspends; the caller has not bound x yet). _ = try interp.runFor(&world, 1); try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); @@ -8519,7 +8538,7 @@ test "async method called via await inlines with its own scope and locals surviv defer world.deinit(gpa); // `bumped` is an `async method`: it reads `self.base` into a local, suspends - // at `await wait(1)`, and returns the local + 1 on resume. The call frame + // at `await wait(0.02s)`, and returns the local + 1 on resume. The call frame // carries `self` + the local in its OWN heap-boxed scope, retained across the // suspension (no collision with the caller's scope). `n` goes 0 → 11. const source = @@ -8527,7 +8546,7 @@ test "async method called via await inlines with its own scope and locals surviv \\impl Counter { \\ async fn bumped(self) -> int { \\ let b = self.base - \\ await wait(1) + \\ await wait(0.02s) \\ return b + 1 \\ } \\} @@ -8558,7 +8577,7 @@ test "async method called via await inlines with its own scope and locals surviv const out_id = world.registry.idOf("Out").?; // tick 1: caller builds `c`, enters `c.bumped()`, which reads self.base into a - // local and suspends at its `await wait(1)`. + // local and suspends at its `await wait(0.02s)`. _ = try interp.runFor(&world, 1); try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); // tick 2: `bumped` resumes (its local `b = 10` survived), returns 11, which @@ -8571,6 +8590,110 @@ test "async method called via await inlines with its own scope and locals surviv try std.testing.expectEqual(@as(i64, 11), readResourceInt(&world, out_id)); } +test "await wait(1.0s) resumes at the fixed-timestep-equivalent tick count (60) (M1.0.11 E3)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // 1.0 s at the Phase-1 fixed 1/60 timestep = 60 ticks. Spawned at async_tick + // 1, the task wakes at tick 61 — not before. This pins the Duration→tick + // conversion (M1.0.13 will swap the clock without changing the result at + // time_scale = 1). + const source = + \\resource Out { n: int = 0 } + \\async rule sec() + \\ when resource Out + \\{ + \\ let o = get_mut(Out) + \\ o.n = 1 + \\ await wait(1.0s) + \\ let o2 = get_mut(Out) + \\ o2.n = 2 + \\} + ; + + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const out_id = world.registry.idOf("Out").?; + + // tick 1: n=1, suspend (wake at async_tick 61). + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + // through tick 60: still suspended (60 < 61). + _ = try interp.runFor(&world, 59); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + // tick 61: wake fires (61 >= 61) → n=2. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, out_id)); +} + +/// Compile + run `source` for 2 ticks and return the runtime-error count, after +/// asserting it parses and type-checks clean (M1.0.11 E3 fail-loud partition +/// helper). The async targets NOT owned by this milestone must surface a typed +/// `RuntimeFailure` (counted), never crash or silently no-op. +fn asyncFailLoudCount(gpa: std.mem.Allocator, source: []const u8) !u64 { + var world = World.init(); + defer world.deinit(gpa); + var pr = try parser_mod.parse(gpa, source); + defer pr.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), pr.diagnostics.len); + var diags: std.ArrayListUnmanaged(Diagnostic) = .empty; + defer { + for (diags.items) |*d| d.deinit(gpa); + diags.deinit(gpa); + } + try types_mod.TypeChecker.check(gpa, &pr.ast, &diags); + try std.testing.expectEqual(@as(usize, 0), diags.items.len); + var interp = try Interpreter.compile(gpa, &pr.ast, &world); + defer interp.deinit(); + const report = try interp.runFor(&world, 2); + return report.runtime_errors; +} + +test "await wait_unscaled / entity_event / handle-await still fail loud (partition boundary intact, M1.0.11 E3)" { + const gpa = std.testing.allocator; + // `wait_unscaled` — needs the scaled/unscaled time subsystem (M1.0.13). + try std.testing.expect((try asyncFailLoudCount(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ await wait_unscaled(1.0s) + \\} + )) >= 1); + // `entity_event` — needs entity-scoped events (M1.0.14). + try std.testing.expect((try asyncFailLoudCount(gpa, + \\event Ev { } + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ await entity_event(get(Out), Ev) + \\} + )) >= 1); + // handle-await (`await` on a stored non-call value / TaskHandle) — M1.0.12. + try std.testing.expect((try asyncFailLoudCount(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let h = 5 + \\ await h + \\} + )) >= 1); +} + test "runProgram Optional ops: ??, !, ?., patterns, pop, m[k] (M0.8 E3-C tranche 4)" { const gpa = std.testing.allocator; var world = World.init(); @@ -8751,7 +8874,7 @@ test "async runtime failure surfaces typed last_error (D-S4-runtime-report)" { \\ let x = 1 / d \\ let r = get_mut(Out) \\ r.n = x - \\ await wait(1) + \\ await wait(0.02s) \\} ; diff --git a/src/etch/types.zig b/src/etch/types.zig index bde5f15..e6dedd0 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -307,6 +307,13 @@ pub const TypeChecker = struct { /// E2), used to type a `return expr` body statement against. `null` outside /// a body; `.unit` for a void fn (no `-> type`). current_fn_return: ?ResolvedType = null, + /// The `await_expr` node that is the statement-head `await` of the statement + /// currently being checked (M1.0.11 E3), or `NodeId.none`. Set at the top of + /// `checkStmt` for the allowed positions (expr-stmt / `let` init / simple + /// assignment RHS / `return` operand); `synthExprE` flags any OTHER + /// `await_expr` it visits as E0904 `AwaitNotStatementHead` (a sub-expression + /// `await` — a Phase-1 tree-walker restriction; §9.12). + stmt_head_await: NodeId = NodeId.none, /// Merged global tag table (M0.8 E3, `etch-validation-ecs.md` §5.2), built /// between pass 1 and pass 2 from every `tags { ... }` block. `null` until /// `buildTags` runs. Pass 2 (tag-op when-conditions / `tag_path` operands, @@ -4017,6 +4024,31 @@ pub const TypeChecker = struct { fn checkStmt(self: *TypeChecker, ctx: *RuleCtx, stmt_id: NodeId) !void { const kind = self.arena.stmtKind(stmt_id); const data = self.arena.stmtData(stmt_id); + // M1.0.11 E3 — mark this statement's head `await` (if any) as allowed: + // it is the full RHS of an expr-stmt / `let` init / simple assignment / + // `return`. Any OTHER `await_expr` visited while checking this statement + // is a sub-expression and is flagged E0904 in `synthExprE`. Reset first + // (a non-head statement carries no allowed await). + self.stmt_head_await = NodeId.none; + switch (kind) { + .expr_stmt => { + const e: NodeId = @bitCast(data); + if (self.arena.exprKind(e) == .await_expr) self.stmt_head_await = e; + }, + .let_stmt => { + const v = self.arena.let_stmts.items[data].value; + if (self.arena.exprKind(v) == .await_expr) self.stmt_head_await = v; + }, + .assign_stmt => { + const a = self.arena.assign_stmts.items[data]; + if (a.op == .assign and self.arena.exprKind(a.value) == .await_expr) self.stmt_head_await = a.value; + }, + .return_stmt => { + const v: NodeId = @bitCast(data); + if (!v.isNone() and self.arena.exprKind(v) == .await_expr) self.stmt_head_await = v; + }, + else => {}, + } switch (kind) { .let_stmt => { const let = self.arena.let_stmts.items[data]; @@ -4552,13 +4584,18 @@ pub const TypeChecker = struct { // no body handle (§4.5). `checkStmt` handles the legal statement // position before this arm is reached. .spawn_struct => return try self.checkSpawnStruct(id, data, ctx_opt, true), - // M1.0.11 E2 — type an `await`. The `future` form (`await f()`) carries - // the awaited call's declared return type, so `let x = await f()` binds - // the right type (and the inner call is type-checked here — arg count, - // etc.). The wake-condition forms (`wait` / `wait_unscaled` / - // `entity_event` / `global_event`) produce no value. Function coloring - // (E0901) and placement (E0904) are added in E4 / E3. + // M1.0.11 E2/E3 — type an `await`. The `future` form (`await f()`) + // carries the awaited call's declared return type, so `let x = await f()` + // binds the right type (and the inner call is type-checked here — arg + // count, etc.). The wake-condition forms (`wait` / `wait_unscaled` / + // `entity_event` / `global_event`) produce no value. E3 placement + // (E0904): an `await` that is not this statement's head await is a + // sub-expression (`some(await f())`, `a + await b`) — rejected; hoist + // it into a `let`. Function coloring (E0901) is added in E4. .await_expr => { + if (@as(u32, @bitCast(id)) != @as(u32, @bitCast(self.stmt_head_await))) { + try self.emit(.await_not_statement_head, .error_, self.arena.exprSpan(id), "`await` must be the full right-hand expression of a statement — hoist it into a `let` (Phase-1 restriction)", .{}); + } const aw = self.arena.awaitExpr(id); if (aw.target_kind == .future) return try self.synthExprE(aw.arg_expr, ctx_opt); return ResolvedType.unknown; @@ -9376,3 +9413,45 @@ test "extension methods type-check on an Entity receiver (M1.0.9 B2)" { defer bad.deinit(gpa); try std.testing.expect(bad.diagnostics.items.len > 0); } + +test "E0904 fires on a sub-expression await, not on the statement-head forms (M1.0.11 E3)" { + const gpa = std.testing.allocator; + // The four statement-head positions — expr-stmt, `let` init, simple assign + // RHS, `return` operand — are the allowed placement: no E0904. + var head = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async fn f() -> int { + \\ await wait(0.02s) + \\ return 1 + \\} + \\async rule r() + \\ when resource Out + \\{ + \\ let mut x = await f() + \\ x = await f() + \\ await f() + \\ return await f() + \\} + ); + defer head.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), head.parse_diags.len); + try expectNoCode(head.diagnostics.items, .await_not_statement_head); + + // A sub-expression `await` (here, an argument of `some(...)`) is not the full + // RHS of its statement — E0904 (hoist it into a `let`). + var sub = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async fn f() -> int { + \\ await wait(0.02s) + \\ return 1 + \\} + \\async rule r() + \\ when resource Out + \\{ + \\ return some(await f()) + \\} + ); + defer sub.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), sub.parse_diags.len); + try expectAnyCode(sub.diagnostics.items, .await_not_statement_head); +} From 4ae7f5a7e6d70076d06ba8af35eafc7ad99347b4 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 17:10:15 +0200 Subject: [PATCH 10/15] fix(etch): flag await in value-position blocks E0904 (M1.0.11 E3) --- briefs/M1.0.11-async-core.md | 2 + src/etch/types.zig | 117 ++++++++++++++++++++++++++++++++--- 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md index 6ba10fb..6b2d9eb 100644 --- a/briefs/M1.0.11-async-core.md +++ b/briefs/M1.0.11-async-core.md @@ -159,6 +159,8 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § - 2026-07-01 — E3 (owned await targets + Duration `wait` + placement rule). (a) `wait` now takes a `Duration` (final API, §9.4): `evalAwaitTarget`'s `.wait` arm parses a Duration LITERAL → seconds (`durationLiteralSeconds`, strips the `s`) → tick count via the fixed 1/60 timestep constant `async_fixed_dt_hz = 60` (`ticks = round(secs * 60)`); a non-literal Duration (const/arithmetic) stays fail-loud (out of scope). `wait` no longer takes an int; the existing `await wait(N)` call sites/tests migrated to the Duration form (`wait(1)` → `wait(0.02s)` = 1 tick, `wait(2)` → `wait(0.04s)` = 2 ticks — exact tick counts preserved, no assertion churn). (b)/(c) `global_event` re-host + direct-call `future` were already delivered by E1/E2. (d) `diagnostics.zig`: new `await_not_statement_head` kind → **E0904** / `AwaitNotStatementHead` (the free E09xx block). `types.zig`: a `stmt_head_await` field is set at the top of `checkStmt` to the statement's head `await` (expr-stmt / `let` init / simple assign RHS / `return` operand); `synthExprE`'s `await_expr` arm emits E0904 for any OTHER `await` it visits (a sub-expression). Tests: `await wait(1.0s)` resumes at tick 60 (fixed-timestep pin); `wait_unscaled`/`entity_event`/handle-await still fail loud (partition intact); E0904 fires on `return some(await f())` and NOT on the four head forms. Green: `zig build`, `zig fmt --check`, `zig build lint` (exit 0), etch target `374 pass (374 total)` in Debug AND ReleaseSafe, full suite exit 0. `parser.zig`/`token.zig`/`ast.zig` untouched. +- 2026-07-01 — E3 fix (Guy STOP on review): E0904 missed a statement-head `await` inside a VALUE-position block. `synthBlock`/`synthIf`/`synthMatch` re-mark the head-await via `checkStmt` even when the block is a value (`let x = if c { await f() } else { g() }`, `= match`/`= loop`/`= { … await … }`, a control-flow/block assignment RHS or `return` operand): those type-checked clean but the tree-walker evaluates value-blocks synchronously (`evalExpr`, no await arm) → the `await` fails loud at runtime — an inexecutable placement E0904 must catch at compile time. Fix: a `TypeChecker.await_suspendable` flag (true only on the async driver's frame-driven spine — rule/`fn`/method body + statement-position control-flow bodies; false inside a value block). Set true at the three body-check roots; `synthHeadValue` sets it true ONLY when the `let`/assign/`return` value IS the statement's head `await` (else false), so a control-flow/block value carries `false` into its bodies. The `await_expr` arm now emits E0904 unless the await is BOTH the statement head AND suspendable. The assign head is also tightened to a simple `local =` target (a field/index target leaves the await a sub-expression). Test added: `await` in a value `if`/`{ }` block → E0904; the same in a statement-position `if` body stays clean. Green: etch target `375 pass (375 total)` in Debug AND ReleaseSafe, full suite exit 0, `zig fmt`/`lint` clean. + ## Recorded deviations *Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each references the commit that records it. Empty at milestone end = nominal case.* diff --git a/src/etch/types.zig b/src/etch/types.zig index e6dedd0..fce963d 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -314,6 +314,15 @@ pub const TypeChecker = struct { /// `await_expr` it visits as E0904 `AwaitNotStatementHead` (a sub-expression /// `await` — a Phase-1 tree-walker restriction; §9.12). stmt_head_await: NodeId = NodeId.none, + /// Whether the statements currently being checked sit on the async driver's + /// frame-driven spine (M1.0.11 E3): a rule / `fn` / method body, or the body + /// of a statement-position control-flow (the interpreter pushes those as + /// frames). `false` inside a VALUE block (`let x = if c { … } else { … }`, + /// `= match`/`= loop`/`= { … }`, a control-flow/block assignment RHS or + /// `return` operand) — the tree-walker evaluates those synchronously, so a + /// statement-head `await` there is inexecutable and gets E0904 even though it + /// is syntactically a head. + await_suspendable: bool = false, /// Merged global tag table (M0.8 E3, `etch-validation-ecs.md` §5.2), built /// between pass 1 and pass 2 from every `tags { ... }` block. `null` until /// `buildTags` runs. Pass 2 (tag-op when-conditions / `tag_path` operands, @@ -3572,6 +3581,11 @@ pub const TypeChecker = struct { const saved_ret = self.current_fn_return; self.current_fn_return = ret_t; defer self.current_fn_return = saved_ret; + // The body statements sit on the async driver's frame-driven spine + // (M1.0.11 E3): a statement-head `await` here is executable. + const saved_susp = self.await_suspendable; + self.await_suspendable = true; + defer self.await_suspendable = saved_susp; var s: u32 = 0; while (s < decl.body_len) : (s += 1) { @@ -3767,7 +3781,11 @@ pub const TypeChecker = struct { try self.collectWhen(&ctx, rule.when_root); } - // Walk the body statements. + // Walk the body statements. The rule body sits on the async driver's + // frame-driven spine (M1.0.11 E3): a statement-head `await` is executable. + const saved_susp = self.await_suspendable; + self.await_suspendable = true; + defer self.await_suspendable = saved_susp; var s: u32 = 0; while (s < rule.body_len) : (s += 1) { const stmt_raw = self.arena.extra.items[rule.body_start + s]; @@ -3827,6 +3845,11 @@ pub const TypeChecker = struct { const saved_ret = self.current_fn_return; self.current_fn_return = ret_t; defer self.current_fn_return = saved_ret; + // The body statements sit on the async driver's frame-driven spine + // (M1.0.11 E3): a statement-head `await` here is executable. + const saved_susp = self.await_suspendable; + self.await_suspendable = true; + defer self.await_suspendable = saved_susp; var s: u32 = 0; while (s < decl.body_len) : (s += 1) { @@ -4040,8 +4063,11 @@ pub const TypeChecker = struct { if (self.arena.exprKind(v) == .await_expr) self.stmt_head_await = v; }, .assign_stmt => { + // Only a simple `local = await …` is a frame-driven head (the + // interpreter's `assign_local`); a field/index target or a + // compound op leaves the await as a sub-expression → E0904. const a = self.arena.assign_stmts.items[data]; - if (a.op == .assign and self.arena.exprKind(a.value) == .await_expr) self.stmt_head_await = a.value; + if (a.op == .assign and self.arena.exprKind(a.target) == .ident and self.arena.exprKind(a.value) == .await_expr) self.stmt_head_await = a.value; }, .return_stmt => { const v: NodeId = @bitCast(data); @@ -4073,7 +4099,7 @@ pub const TypeChecker = struct { break :blk self.checkStructLitAgainst(let.value, sl_data, declared.?.struct_t, ctx) catch ResolvedType.unknown; } } - break :blk self.synthExpr(let.value, ctx); + break :blk self.synthHeadValue(let.value, ctx); }; const final = if (declared) |d| blk: { if (d == .builtin and inferred == .builtin and !self.literalTypeFits(d.builtin, let.value, inferred.builtin)) { @@ -4111,7 +4137,7 @@ pub const TypeChecker = struct { const span = self.arena.exprSpan(assign.target); try self.emit(.type_mismatch, .error_, span, "cannot assign to immutable binding (use 'let mut')", .{}); } - const rhs_type = self.synthExpr(assign.value, ctx); + const rhs_type = self.synthHeadValue(assign.value, ctx); if (local.type_ == .builtin and rhs_type == .builtin and !self.literalTypeFits(local.type_.builtin, assign.value, rhs_type.builtin)) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(assign.value), "assignment value type does not match binding type", .{}); } @@ -4276,7 +4302,7 @@ pub const TypeChecker = struct { // enclosing fn (e.g. a rule body) is permissive. const value: NodeId = @bitCast(data); if (!value.isNone()) { - const vt = self.synthExpr(value, ctx); + const vt = self.synthHeadValue(value, ctx); if (self.current_fn_return) |ret| { if (ret == .builtin and vt == .builtin and !self.literalTypeFits(ret.builtin, value, vt.builtin)) { try self.emit(.return_type_mismatch, .error_, self.arena.exprSpan(value), "return value type does not match the declared return type", .{}); @@ -4369,6 +4395,19 @@ pub const TypeChecker = struct { return self.synthExprE(id, ctx_opt) catch ResolvedType.unknown; } + /// Synthesize a statement-head VALUE expression (`let` init / assignment RHS / + /// `return` operand) with the correct suspendable context (M1.0.11 E3): the + /// value is on the frame-driven spine ONLY if it IS this statement's head + /// `await` (`let x = await f()`). Any other value (an `if`/`match`/`loop`/ + /// block, or an expression merely containing an `await`) is synchronous, so a + /// statement-head `await` nested inside it is inexecutable → E0904. + fn synthHeadValue(self: *TypeChecker, id: NodeId, ctx_opt: ?*RuleCtx) ResolvedType { + const saved = self.await_suspendable; + self.await_suspendable = @as(u32, @bitCast(id)) == @as(u32, @bitCast(self.stmt_head_await)); + defer self.await_suspendable = saved; + return self.synthExpr(id, ctx_opt); + } + fn synthExprE(self: *TypeChecker, id: NodeId, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { const kind = self.arena.exprKind(id); const data = self.arena.exprData(id); @@ -4593,8 +4632,13 @@ pub const TypeChecker = struct { // sub-expression (`some(await f())`, `a + await b`) — rejected; hoist // it into a `let`. Function coloring (E0901) is added in E4. .await_expr => { - if (@as(u32, @bitCast(id)) != @as(u32, @bitCast(self.stmt_head_await))) { - try self.emit(.await_not_statement_head, .error_, self.arena.exprSpan(id), "`await` must be the full right-hand expression of a statement — hoist it into a `let` (Phase-1 restriction)", .{}); + // E0904 fires unless this `await` is BOTH the statement head AND + // on the frame-driven spine — a statement-head `await` inside a + // synchronously-evaluated VALUE block (e.g. `let x = if c { await + // f() }`) is inexecutable, so it is rejected too (M1.0.11 E3). + const is_head = @as(u32, @bitCast(id)) == @as(u32, @bitCast(self.stmt_head_await)); + if (!is_head or !self.await_suspendable) { + try self.emit(.await_not_statement_head, .error_, self.arena.exprSpan(id), "`await` must be the full right-hand expression of a statement on the async path — hoist it into a `let` (Phase-1 restriction)", .{}); } const aw = self.arena.awaitExpr(id); if (aw.target_kind == .future) return try self.synthExprE(aw.arg_expr, ctx_opt); @@ -9455,3 +9499,62 @@ test "E0904 fires on a sub-expression await, not on the statement-head forms (M1 try std.testing.expectEqual(@as(usize, 0), sub.parse_diags.len); try expectAnyCode(sub.diagnostics.items, .await_not_statement_head); } + +test "E0904 fires on a statement-head await inside a value-position block, not a statement-position one (M1.0.11 E3)" { + const gpa = std.testing.allocator; + // An `if` used as a VALUE (a `let` initializer) is evaluated synchronously by + // the tree-walker — a statement-head `await` in its branch is inexecutable → E0904. + var vif = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async fn f() -> int { + \\ await wait(0.02s) + \\ return 1 + \\} + \\async rule r() + \\ when resource Out + \\{ + \\ let x = if true { await f() } else { await f() } + \\} + ); + defer vif.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), vif.parse_diags.len); + try expectAnyCode(vif.diagnostics.items, .await_not_statement_head); + + // A value block `{ … await … }` (a `let` initializer) — same synchronous + // evaluation → E0904. + var vblk = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async fn f() -> int { + \\ await wait(0.02s) + \\ return 1 + \\} + \\async rule r() + \\ when resource Out + \\{ + \\ let x = { await f() } + \\} + ); + defer vblk.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), vblk.parse_diags.len); + try expectAnyCode(vblk.diagnostics.items, .await_not_statement_head); + + // But the SAME `if`/`let` in STATEMENT position (a frame-driven body the + // driver pushes as a frame) is fine — no E0904. + var sif = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async fn f() -> int { + \\ await wait(0.02s) + \\ return 1 + \\} + \\async rule r() + \\ when resource Out + \\{ + \\ if true { + \\ let y = await f() + \\ } + \\} + ); + defer sif.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), sif.parse_diags.len); + try expectNoCode(sif.diagnostics.items, .await_not_statement_head); +} From 36fdc142dfd192071bb4cebb142bd515447b193f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 18:46:07 +0200 Subject: [PATCH 11/15] feat(etch): function coloring E0901 async-in-non-async (M1.0.11 E4) --- briefs/M1.0.11-async-core.md | 2 + src/etch/diagnostics.zig | 3 ++ src/etch/types.zig | 100 +++++++++++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md index 6b2d9eb..f21736b 100644 --- a/briefs/M1.0.11-async-core.md +++ b/briefs/M1.0.11-async-core.md @@ -161,6 +161,8 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § - 2026-07-01 — E3 fix (Guy STOP on review): E0904 missed a statement-head `await` inside a VALUE-position block. `synthBlock`/`synthIf`/`synthMatch` re-mark the head-await via `checkStmt` even when the block is a value (`let x = if c { await f() } else { g() }`, `= match`/`= loop`/`= { … await … }`, a control-flow/block assignment RHS or `return` operand): those type-checked clean but the tree-walker evaluates value-blocks synchronously (`evalExpr`, no await arm) → the `await` fails loud at runtime — an inexecutable placement E0904 must catch at compile time. Fix: a `TypeChecker.await_suspendable` flag (true only on the async driver's frame-driven spine — rule/`fn`/method body + statement-position control-flow bodies; false inside a value block). Set true at the three body-check roots; `synthHeadValue` sets it true ONLY when the `let`/assign/`return` value IS the statement's head `await` (else false), so a control-flow/block value carries `false` into its bodies. The `await_expr` arm now emits E0904 unless the await is BOTH the statement head AND suspendable. The assign head is also tightened to a simple `local =` target (a field/index target leaves the await a sub-expression). Test added: `await` in a value `if`/`{ }` block → E0904; the same in a statement-position `if` body stays clean. Green: etch target `375 pass (375 total)` in Debug AND ReleaseSafe, full suite exit 0, `zig fmt`/`lint` clean. +- 2026-07-01 — E4 (function coloring, §9.3). `diagnostics.zig`: new `async_call_in_non_async_context` kind → **E0901** / `AsyncCallInNonAsyncContext`. `types.zig`: a `current_is_async` field tracks the async color of the fn/rule/method whose body is being checked (set at the three body-check roots from `decl.is_async` / `rule.is_async`, save/restored). E0901 fires (a) in `synthExprE`'s `await_expr` arm when `!current_is_async` (an `await` in a non-async context), and (b) in `synthFreeFnCall` + `checkMethodArgs` when the callee is `async` and `!current_is_async` (calling an `async fn`/`async method` from a non-async context). A legal async→async `await f()` reaches both with `current_is_async` true → no E0901. Test: E0901 on an `async fn` called from a sync `fn` AND a sync `rule`, and on an `await` in a sync `rule`; a legal `let x = await af()` in an `async rule` stays clean. Documented residual (out of E0901's spec scope, §9.2): a direct NON-`await` call to an `async fn` in an ASYNC context is not flagged by E4 — it fails loud at runtime (the `callFn`/`callMethod` `is_async` gate) and is M1.0.12's territory (`spawn`/`branch`/handle give non-await async execution a home + its own coloring). Green: etch target `376 pass (376 total)` in Debug AND ReleaseSafe, full suite exit 0, `zig fmt`/`lint` clean. The `_async_core.etch` integration program (Observable behavior) is created in the final-validation step (Étape 4), not E4 — async is interpreter-only (the `programs/` corpus is the interp↔codegen differential, which rejects async). + ## Recorded deviations *Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each references the commit that records it. Empty at milestone end = nominal case.* diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index f57d07e..23109a9 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -361,6 +361,7 @@ pub const DiagnosticCode = enum { prefab_remove_base_component, // M0.8 E7 — W1790 PrefabRemoveBaseComponent (RESERVED: no `remove` syntax in the §24.1 grammar) // ── async / effects (E09xx, M1.0.11 — etch-resolver-types.md §9.2) ── + async_call_in_non_async_context, // M1.0.11 E4 — E0901 AsyncCallInNonAsyncContext (async fn/method call, or `await`, in a non-async fn/rule) await_not_statement_head, // M1.0.11 E3 — E0904 AwaitNotStatementHead (Phase-1 tree-walker: `await` must be a statement's full RHS) /// Canonical short code, e.g. `"E0001"`. @@ -548,6 +549,7 @@ pub const DiagnosticCode = enum { .prefab_component_field_type_invalid => "E1795", .prefab_component_redefined => "E1796", .prefab_remove_base_component => "W1790", + .async_call_in_non_async_context => "E0901", .await_not_statement_head => "E0904", }; } @@ -737,6 +739,7 @@ pub const DiagnosticCode = enum { .prefab_component_field_type_invalid => "PrefabComponentFieldTypeInvalid", .prefab_component_redefined => "PrefabComponentRedefined", .prefab_remove_base_component => "PrefabRemoveBaseComponent", + .async_call_in_non_async_context => "AsyncCallInNonAsyncContext", .await_not_statement_head => "AwaitNotStatementHead", }; } diff --git a/src/etch/types.zig b/src/etch/types.zig index fce963d..a047957 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -323,6 +323,11 @@ pub const TypeChecker = struct { /// statement-head `await` there is inexecutable and gets E0904 even though it /// is syntactically a head. await_suspendable: bool = false, + /// Whether the `fn` / `rule` / method whose body is currently being checked is + /// `async` (M1.0.11 E4, function coloring §9.3). An `await`, or a call to an + /// `async fn`/`async method`, in a NON-async context is E0901 + /// `AsyncCallInNonAsyncContext`. `false` outside any body. + current_is_async: bool = false, /// Merged global tag table (M0.8 E3, `etch-validation-ecs.md` §5.2), built /// between pass 1 and pass 2 from every `tags { ... }` block. `null` until /// `buildTags` runs. Pass 2 (tag-op when-conditions / `tag_path` operands, @@ -3586,6 +3591,11 @@ pub const TypeChecker = struct { const saved_susp = self.await_suspendable; self.await_suspendable = true; defer self.await_suspendable = saved_susp; + // Function coloring context (M1.0.11 E4): `await` / async calls are legal + // only when this body is `async`. + const saved_async = self.current_is_async; + self.current_is_async = decl.is_async; + defer self.current_is_async = saved_async; var s: u32 = 0; while (s < decl.body_len) : (s += 1) { @@ -3786,6 +3796,11 @@ pub const TypeChecker = struct { const saved_susp = self.await_suspendable; self.await_suspendable = true; defer self.await_suspendable = saved_susp; + // Function coloring context (M1.0.11 E4): `await` / async calls are legal + // only in an `async rule`. + const saved_async = self.current_is_async; + self.current_is_async = rule.is_async; + defer self.current_is_async = saved_async; var s: u32 = 0; while (s < rule.body_len) : (s += 1) { const stmt_raw = self.arena.extra.items[rule.body_start + s]; @@ -3850,6 +3865,11 @@ pub const TypeChecker = struct { const saved_susp = self.await_suspendable; self.await_suspendable = true; defer self.await_suspendable = saved_susp; + // Function coloring context (M1.0.11 E4): `await` / async calls are legal + // only when this body is `async`. + const saved_async = self.current_is_async; + self.current_is_async = decl.is_async; + defer self.current_is_async = saved_async; var s: u32 = 0; while (s < decl.body_len) : (s += 1) { @@ -4640,6 +4660,11 @@ pub const TypeChecker = struct { if (!is_head or !self.await_suspendable) { try self.emit(.await_not_statement_head, .error_, self.arena.exprSpan(id), "`await` must be the full right-hand expression of a statement on the async path — hoist it into a `let` (Phase-1 restriction)", .{}); } + // Function coloring (M1.0.11 E4, §9.3): `await` is an async effect + // — only an `async fn`/`async rule` may use it. + if (!self.current_is_async) { + try self.emit(.async_call_in_non_async_context, .error_, self.arena.exprSpan(id), "`await` is only allowed in an `async fn` or `async rule`", .{}); + } const aw = self.arena.awaitExpr(id); if (aw.target_kind == .future) return try self.synthExprE(aw.arg_expr, ctx_opt); return ResolvedType.unknown; @@ -4977,6 +5002,12 @@ pub const TypeChecker = struct { /// is the declared return type (`unknown` for a void fn). fn synthFreeFnCall(self: *TypeChecker, id: NodeId, call: ast_mod.CallExpr, item_id: NodeId, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { const decl = self.arena.fn_decls.items[self.arena.itemData(item_id)]; + // Function coloring (M1.0.11 E4, §9.3): calling an `async fn` from a + // non-async context is E0901. (A legal async→async call is via `await`, + // which reaches here with `current_is_async` true.) + if (decl.is_async and !self.current_is_async) { + try self.emit(.async_call_in_non_async_context, .error_, self.arena.exprSpan(id), "cannot call `async fn` '{s}' from a non-async context (needs an `async fn`/`async rule` + `await`)", .{self.arena.strings.slice(decl.name)}); + } const ret: ResolvedType = if (decl.return_type.isNone()) ResolvedType.unknown else self.namedTypeToResolved(decl.return_type); var pnames: std.ArrayListUnmanaged(StringId) = .empty; defer pnames.deinit(self.gpa); @@ -5804,6 +5835,12 @@ pub const TypeChecker = struct { /// resolved `method` (M0.8 E2 block 3) and return its declared return type. /// `self` is not part of the argument list (it is the receiver). fn checkMethodArgs(self: *TypeChecker, id: NodeId, mc: ast_mod.MethodCall, method: ast_mod.FnDecl, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { + // Function coloring (M1.0.11 E4, §9.3): calling an `async method` from a + // non-async context is E0901 (a legal call is via `await` in an async + // context, which reaches here with `current_is_async` true). + if (method.is_async and !self.current_is_async) { + try self.emit(.async_call_in_non_async_context, .error_, self.arena.exprSpan(id), "cannot call `async` method '{s}' from a non-async context (needs an `async fn`/`async rule` + `await`)", .{self.arena.strings.slice(mc.method_name)}); + } const ret: ResolvedType = if (method.return_type.isNone()) ResolvedType.unknown else self.namedTypeToResolved(method.return_type); var pnames: std.ArrayListUnmanaged(StringId) = .empty; defer pnames.deinit(self.gpa); @@ -9558,3 +9595,66 @@ test "E0904 fires on a statement-head await inside a value-position block, not a try std.testing.expectEqual(@as(usize, 0), sif.parse_diags.len); try expectNoCode(sif.diagnostics.items, .await_not_statement_head); } + +test "E0901 fires on an async call / await in a non-async context, not on a legal async→async await (M1.0.11 E4)" { + const gpa = std.testing.allocator; + const af = + \\resource Out { n: int = 0 } + \\async fn af() -> int { + \\ await wait(0.02s) + \\ return 1 + \\} + \\ + ; + // (a) `async fn` called from a SYNC `fn` → E0901. + var sfn = try parseAndCheck(gpa, af ++ + \\fn caller() -> int { + \\ let x = af() + \\ return x + \\} + ); + defer sfn.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), sfn.parse_diags.len); + try expectAnyCode(sfn.diagnostics.items, .async_call_in_non_async_context); + + // (b) `async fn` called from a SYNC `rule` → E0901. + var srule = try parseAndCheck(gpa, af ++ + \\rule caller() + \\ when resource Out + \\{ + \\ let x = af() + \\ let o = get_mut(Out) + \\ o.n = x + \\} + ); + defer srule.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), srule.parse_diags.len); + try expectAnyCode(srule.diagnostics.items, .async_call_in_non_async_context); + + // (c) `await` used in a SYNC context → E0901. + var sawait = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\rule caller() + \\ when resource Out + \\{ + \\ await wait(0.02s) + \\} + ); + defer sawait.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), sawait.parse_diags.len); + try expectAnyCode(sawait.diagnostics.items, .async_call_in_non_async_context); + + // (d) a legal `async`→`async` call via `await` — no E0901. + var ok = try parseAndCheck(gpa, af ++ + \\async rule caller() + \\ when resource Out + \\{ + \\ let x = await af() + \\ let o = get_mut(Out) + \\ o.n = x + \\} + ); + defer ok.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), ok.parse_diags.len); + try expectNoCode(ok.diagnostics.items, .async_call_in_non_async_context); +} From 4f46b1b372799d82abb09ecce2fabafc8cebde80 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 20:15:43 +0200 Subject: [PATCH 12/15] docs(etch): document the Phase-1 async suspension model (M1.0.11 E5) --- src/etch/interp.zig | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/etch/interp.zig b/src/etch/interp.zig index 6a4019b..c9eaa23 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -528,6 +528,36 @@ fn durationLiteralSeconds(text: []const u8) ?f64 { return std.fmt.parseFloat(f64, text[0 .. text.len - 1]) catch null; } +// ─── Async suspension core (M1.0.11, `etch-reference-part1.md §9.12`) ───────── +// +// Phase 1 is the tree-walker; it reproduces the §9 observable async semantics +// WITHOUT the Phase-2 compiled state machine (`etch-bytecode.md §9`). A suspended +// task is a heap record — an `AsyncTask` in the `Interpreter.async_tasks` pool — +// carrying a RESUME FRAME-STACK: a stack of `AsyncFrame`s (innermost last), one +// per statement block on the call/control-flow path. `driveTask`/`driveLoop` is an +// ITERATIVE machine over that stack (no fibers, no per-task OS thread, §9.1): +// +// - A statement-head `await` suspends the whole task at ANY depth: `driveLoop` +// returns, the frame-stack persists, and resume re-enters the innermost frame +// at its cursor — a prefix statement is NEVER re-run, so `emit` and structural +// mutations don't double-fire. `wait(Duration)` resolves against `async_tick` +// via the fixed 1/60 timestep (`async_fixed_dt_hz`); `global_event` against the +// per-tick event store; the direct-call `future` (`await f()`) is frame +// inlining (below). +// - Frame kinds cover every EBNF v0.6 statement block (C1.6): `run` (rule/`fn` +// body, `if` branch, `match` arm, plain block), `loop_`, `while_`, `for_`, +// `try_` (a `throw` after a resume routes to the enclosing `try_` — the +// handler is re-established across the suspension), and `call` (an inlined +// `async fn`/`async method` body — `await f()` pushes `f`'s body + a heap-boxed +// scope + a `RetTarget`; `f`'s own `await` suspends the whole task; `f`'s +// `return` resolves at the caller's await site). +// - Placement (Phase-1, type-checker `E0904`): `await` must be a statement's +// full RHS on the frame-driven spine; a sub-expression `await`, or one in a +// synchronously-evaluated VALUE block, is rejected. Coloring (§9.3, `E0901`): +// an `await` / async call in a non-async `fn`/`rule` is rejected. +// - A sync-only program allocates no task, keeps `async_tick` at 0, and is +// byte-identical to the pre-async runtime (by construction). + /// The condition that resumes a suspended `async rule` (M0.8 E3 sub-slice B). /// The tree-walker is its own runtime (`etch-reference-part1.md §9`): an /// `await` suspends the rule as a task-record, polled each tick in `stepOnce`. From 6f60cfad182ec3af0fd4b0524982796546e6fd4f Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 20:17:01 +0200 Subject: [PATCH 13/15] docs(claude-md): update for M1.0.11 --- CLAUDE.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index de27450..a861bce 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,9 +11,9 @@ knowledge base — see § Quick links spec. |---|---| | Phase | 1 (Etch ↔ ECS) | | Current milestone | (none — between milestones) | -| Last released tag | `v0.10.10-structural-mutation` | +| Last released tag | `v0.10.11-async-core` | | Active branch | `main` | -| Next planned milestone | M1.0.11 — `async` complete (`race`/`sync`/`branch` + the remaining `await` targets). M1.0 (Etch ↔ ECS interpreter) is **21 sub-milestones** (M1.0.0–M1.0.20), **NOT complete**: M1.0.0–M1.0.10 closed; M1.0.11–M1.0.20 close the remaining EBNF v0.6 execution gaps (criterion C1.6), with the Etch-closure tag at M1.0.20. `override` stays the last reserved `non_s3_keywords` member (waits for a Tier-1 overridable module). | +| Next planned milestone | M1.0.12 — concurrency algebra (`race`/`sync`/`branch`/`spawn { }` + cancellation + `await` on a `TaskHandle`). M1.0 (Etch ↔ ECS interpreter) is **21 sub-milestones** (M1.0.0–M1.0.20), **NOT complete**: M1.0.0–M1.0.11 closed; M1.0.12–M1.0.20 close the remaining EBNF v0.6 execution gaps (criterion C1.6), Etch-closure tag at M1.0.20. Await-family partition: `wait_unscaled` + timers → M1.0.13 (time subsystem); `entity_event` → M1.0.14 (entity-scoped events). `override` stays the last reserved `non_s3_keywords` member. | ## Tags @@ -48,6 +48,7 @@ knowledge base — see § Quick links spec. | `v0.10.8-const-private-test` | 2026-06-29 | M1.0.8 — `const` top-level + `private` + `test` graduation | The last three `non_s3_keywords` graduate parser-up (`override` stays reserved). Lexer: `kw_const`/`kw_private`/`kw_test` added to `s3_keywords`, removed from the reserve list (identifier→keyword logic unchanged). AST: `ConstDecl`/`TestDecl` side-slabs + `Visibility {public, private}` field on the `Item` node (`itemVisibility`/`setItemVisibility`). Parser: `parseConstDecl` (`const ( IDENT \| TYPE_IDENT ) : type = const_expr`, top-level only — `parseStmt` untouched, so `const` in a block is a parse error per part1 §4.5); `parseTestDecl` (`test STRING block`, reuses `parseBlockExpr`, no execution); `private` prefix in `parseOneTopLevel` (after annotations, before dispatch; rejects `private import/const/type`; sets the item `.private`). Lockstep set extended {dispatch, `recoverToTopLevel` stop-set, the single error-message enumeration} — `private` adds no stop-set member. Resolver: `SymbolKind` += `const_`/`test_`; `pass1Collect` registers both, `checkConstValue` reuses the field-default surface (`E1101 NotConstEvaluable` + `E0200 TypeMismatch`); tests registered but not exported. `buildExports` exports `const_decl` and reads `Item.visibility` per decl → **activates the dormant `E0107 ImportPrivateItem`** check. **Cleared M1.0.7 debts**: cross-file `const` resolves; selectively importing a `private` item emits `E0107`. | | `v0.10.9-extension-hooks` | 2026-06-30 | M1.0.9 — Execute extension hooks (`on_attach`/`on_detach`) | Founds the runtime text-execution surface M1.0.6 deferred. Decision: **text re-parse, not bytecode** (the VM is Phase 2, `etch-bytecode.md §17`). `parser.parseStmtBlock` parses a cooked hook statement-run (`"; "`-separated, no braces — the shape `descriptor.renderStmtRunAlloc` emits) into a transient `AstArena` via a new `parseStmtFragment` loop (reuses `parseStmt`, skips one optional `.semicolon` between statements). `interp.execHookText` rebinds `self.ast` to the hook arena (the field is a reassignable `*const AstArena`; the executor resolves identifiers via `self.ast.strings`, so the rebind is required), binds the implicit `entity`, runs via the existing `execStmtRun`, and routes deferred structural changes through the world's shared observer-deferred buffer (mirror of `runObserverBody`). `bindToWorld` registers the real `on_attach`/`on_detach` trampolines (`ctx = *Interpreter`); the loader's `dispatchOnAttach` now reaches execution. `world.zig` gains the `on_detach` seam (`registerOnDetach`/`dispatchOnDetach` + `ExtensionDetachFn`/`detach_hook`, mirror of `on_attach`) + a per-entity active-extension side-table (`entity_extensions`: `addEntityExtension`/`removeEntityExtension`/`hasEntityExtension`/`entityExtensions`, freed in `deinit`). `dispatchMethodOnValue` entity arm gains `activate_extension`/`deactivate_extension`/`has_extension`/`active_extensions`; the `Bridge` holds an optional `ExtensionResolver` (`setExtensionResolver`); `loader.runtimeActivate`/`runtimeDeactivate` are the pub runtime entries (deactivate fires `on_detach` first, then `removeComponentDynamic`). Headline: a cooked scene activating `CombatModule` runs `on_attach` at load (`Health.max` 100→150). Conflict policy stays **reject** (`ExtensionComponentConflict`), not last-wins. **B1+B2 merge-blocker round-trip (same day):** the Etch `activate_extension`/`deactivate_extension` now ENQUEUE a deferred op (interp-side `pending_extensions`, drained at the tick boundary) instead of mutating immediately mid-`iterateArchetype` (the immediate `runtimeActivate`/`runtimeDeactivate` stay for load + direct paths); and the type-checker (`dispatchMethodOnType` entity arm) recognizes all four methods so they pass `weld check`. | | `v0.10.10-structural-mutation` | 2026-06-30 | M1.0.10 — Structural mutation in bodies (`spawn`/`despawn`/`add(T)`/`remove(T)`) | The four structural ops are **executable from `rule`/observer/hook bodies** as DEFERRED changes, and the S4 "no structural mutation" interpreter boundary is **lifted**. Parser: `spawn` graduates `non_s3_keywords`→`kw_spawn` + new `ExprKind.spawn_struct` (`spawn (` structural vs `spawn {` = fail-loud M1.0.11 async seam); `despawn`/`add`/`remove` need no parser change (postfix methods on an `Entity` receiver). Type-checker: the four ops recognized on an `Entity` receiver; component-literal fields validated via `checkComponentInstance` reuse (`E0306` unknown field / `E0307` field-type) — no body handle (`E0304`, statement-position only, v0.6) / prefab-name spawn refused (`E0305`, gated on the prefab runtime). Interp: each op ENQUEUES a Tier-0 `CommandBuffer` command onto `world.observer_registry.deferred` (eager payload resolution), drained at the tick boundary by `flushStructural` via `applyWithObservers` (observers fire per op — `on_spawned`+`on_add` / `on_remove`+`on_despawned` / `on_replaced`), never mid-`iterateArchetype`. No sentinel/planned-pool (reserved post-v0.6); `command_buffer.zig`/`entity.zig` untouched (FROZEN). | +| `v0.10.11-async-core` | 2026-07-01 | M1.0.11 — Async suspension core (`await` family + `async fn`/`async method` execution) | The per-rule `AsyncSlot` becomes a dynamic `AsyncTask` pool with a **resume frame-stack** (`run`/`loop_`/`while_`/`for_`/`try_`/`call` frames, innermost last); a statement-head `await` suspends at any depth (incl. `for`/`try` bodies) and resumes without re-running prefixes (no double `emit`); a `throw` post-resume routes to the enclosing `try_` frame. `async fn`/`async method` **execute via frame inlining** (`await f()` pushes the callee body + heap-boxed scope + `RetTarget` on the caller task; `return` resolves at the await site; a SYNC call to an `async` fn stays fail-loud — `callFn`/`callMethod` `is_async` gate). Owned `await` targets: `wait` as a **`Duration`** (fixed 60 Hz → ticks; non-literal fail-loud), `global_event`, direct-call `future`. Placement (Phase-1): `await` must be a statement's full RHS on the frame-driven spine — a sub-expression `await` or one in a synchronously-evaluated VALUE block → **`E0904 AwaitNotStatementHead`**. Coloring (§9.3): an `async` call / `await` in a non-async `fn`/`rule` → **`E0901 AsyncCallInNonAsyncContext`**. `parser.zig`/`token.zig`/`ast.zig` untouched. | ## Hypotheses validated by spikes @@ -80,7 +81,8 @@ knowledge base — see § Quick links spec. - **M1.0.7 scope boundary (cross-file import)**: `import` graduated parser-up (the only `non_s3_keywords` member to leave; `const`/`private`/`test`/`override` stay reserved for M1.0.8). **Validated approach**: a **per-module** byte-keyed exports index (`{name bytes → {kind, arena_index, item_id}}`) extends the M0.9 byte-keyed `ProjectContext` pattern (StringIds are per-arena) — NOT a flat global index, so two modules exporting the same name never collide; the imported-component cross-arena resolution (decl fetched from its defining arena, field names compared by bytes) **unblocks the E1793 false positive** for `.prefab.etch`. **Deferred-but-pre-wired**: module-alias qualified `m.Type` resolution (D-F — the `imported_alias` binding is recorded at E5, so the descending `Path` walk is purely additive later); `E0107 ImportPrivateItem` (D-G — wired through the exports `visibility` flag, dormant until `private` graduates M1.0.8). **Not debt — moot**: the cross-arena field-TYPE check (E1795) resolves builtin types; this is COMPLETE for components because `validateFieldsInDecl(.component_like)` admits only builtin-POD field types (named struct/enum/string rejected on components) — the named-type branch is unreachable for a valid component (forward-compat headroom only). The cross-file `const`-import acceptance test is deferred to M1.0.8 (`const` is not parseable until it graduates) — cross-file resolution is covered by the imported-component type test + the prefab unblock. - **M1.0.8 scope boundary (`const`/`private`/`test` graduation)**: the last three `non_s3_keywords` graduate parser-up; `override` stays reserved (waits for a Tier-1 overridable module). **Top-level `const` only** — `parseStmt` is deliberately NOT extended, so a block-level `const` is a parse error; the tri-document drift (`const_stmt` under `etch-grammar.md §4.1` statements vs §4.5 "top-level only" vs `local_const` in `etch-resolver-types.md §2.1`) is a PREEXISTING cross-doc inconsistency deferred to a KB-audit conversation (NOT resolved here). **`private` is direct export-visibility + `E0107` only** — visibility inheritance (`etch-resolver-types.md §10.2`) and `W0902 PrivateTypeInPublicImpl` stay additive/deferred; `private` is parsed as a prefix on a `declaration_body` (rejects `private import/const/type`) and adds no `recoverToTopLevel` stop-set member. **`test` is parse + validate + symbol registration only** — no execution surface exists (same blocker family as M1.0.9); tests register a `test_` symbol but are never exported. **Residual**: a string-named `test "X"` registers under the byte sequence `X` via `registerSymbol`, so it shares the name namespace with identifier-named symbols (a `test "Foo"` collides with `component Foo` → `E0101`); acceptable for M1.0.8, revisit when the M1.0.9 test-runner formalizes test identity. **Cross-file tests** live in `tests/etch/import_resolve_test.zig` (the `validateProject` harness), not inline in `types.zig` (which cannot reach `validateProject` — a tier-up dependency). - **M1.0.9 scope boundary (execute extension hooks)** — **decided: TEXT re-parse, not bytecode** (the VM is Phase 2, `etch-bytecode.md §17`; `etch-visual-scripting.md §4`). The Tier-0 seam (`register`/`dispatch` `On{Attach,Detach}`) is backend-agnostic — a Phase-2 migration swaps the bridge callback + the cook side + a format bump. `execHookText` reuses the SAME `execStmtRun` that runs all Phase-1 gameplay (not hook-specific scaffolding). **Surface findings vs the brief's premise** (the brief's NOTE pre-authorizes adapting to the actual surface): (1) the interpreter has **no `entity.add(T)`/`spawn`/`despawn`** structural mutation in bodies (S4 boundary, `interp.zig` header) and the only deferred-structural producer (tag mutation) is **not in the cookable hook subset** `{let,emit,expr,assign,return}` — so a cooked Etch hook **cannot** itself issue a deferred structural change; the "drained before `on_spawned`" test is realized with a Tier-0 stand-in attach callback that enqueues into the same observer-deferred channel (Recorded deviation). (2) **[B1 round-trip 2026-06-30]** the Etch `activate_extension`/`deactivate_extension` ENQUEUE a deferred command (interp-side `pending_extensions`, mirror of `pending_tags`, drained at the tick boundary by `flushPendingExtensions`) instead of an immediate `add`/`removeComponentDynamic` — fixing the archetype-migration-mid-`iterateArchetype` corruption. The immediate `runtimeActivate`/`runtimeDeactivate` (and the new bytes-taking `pub activateExtension`/`deactivateExtension`) stay for the load + direct-programmatic paths (outside iteration). (3) The activate/deactivate/has/active tests live in `tests/scene/extensions_test.zig`, not inline in `interp.zig`, because they need the cook pipeline (`scene_cook` → circular import otherwise) — the M1.0.8 tier-dependency precedent. (4) **[B2 round-trip 2026-06-30]** the four entity methods are recognized by the **type-checker** (`dispatchMethodOnType` entity arm, `types.zig`: `activate`/`deactivate_extension → unit`/unknown, `has_extension → bool`, `active_extensions → [string]`, arg-validated) — a real rule body calling them passes `weld check`; tests no longer skip the checker. **Deferred (not M1.0.9)**: the §30.5 compile-time additive-conflict warning (out of brief scope); a `test`-runner (the `ast.zig` `TestDecl` comment "execution surface is M1.0.9" is a pre-existing M1.0.8 imprecision — M1.0.9 delivers HOOK execution, not `test` execution). -- **M1.0.10 scope boundary (structural mutation in bodies)** — the four ops (`spawn`/`despawn`/`add(T)`/`remove(T)`) are DEFERRED structural changes routed through the Tier-0 `CommandBuffer` (`world.observer_registry.deferred`), drained at the tick boundary via `applyWithObservers` (NOT a parallel `pending_*` queue). **In-body `spawn` is statement-only, NO body handle** (v0.6, `etch-grammar.md §4.5`): binding/using a spawn result is refused (`E0304`); the sentinel/planned-pool that would back a body handle is reserved post-v0.6 (`etch-bytecode.md §11.2`) — NOT built. **Prefab-name `spawn("X")`** parses + is recognized but refused (`E0305`), gating on the prefab runtime (post-Etch). **Recorded deviation (Claude.ai round-trip, VERDICT E2 — STOP):** E2 was completed to statically validate component-literal fields (`E0306`/`E0307`) by reusing the scene/prefab `checkComponentInstance` path — "type the expression" implies field validation for `weld check` / C1.6. **Design note (E3):** the 4 ops route via a `structuralDeferred` helper (prefers `observer_deferred` when bound, else the world's shared buffer) rather than binding `observer_deferred` in `execBody` — equivalent routing, leaves the tag/extension `pending_*` paths untouched. **Out (later milestones):** async `spawn { }` task (M1.0.11); the §30.5 additive-conflict cook warning (later cook milestone). +- **M1.0.10 scope boundary (structural mutation in bodies)** — the four ops (`spawn`/`despawn`/`add(T)`/`remove(T)`) are DEFERRED structural changes routed through the Tier-0 `CommandBuffer` (`world.observer_registry.deferred`), drained at the tick boundary via `applyWithObservers` (NOT a parallel `pending_*` queue). **In-body `spawn` is statement-only, NO body handle** (v0.6, `etch-grammar.md §4.5`): binding/using a spawn result is refused (`E0304`); the sentinel/planned-pool that would back a body handle is reserved post-v0.6 (`etch-bytecode.md §11.2`) — NOT built. **Prefab-name `spawn("X")`** parses + is recognized but refused (`E0305`), gating on the prefab runtime (post-Etch). **Recorded deviation (Claude.ai round-trip, VERDICT E2 — STOP):** E2 was completed to statically validate component-literal fields (`E0306`/`E0307`) by reusing the scene/prefab `checkComponentInstance` path — "type the expression" implies field validation for `weld check` / C1.6. **Design note (E3):** the 4 ops route via a `structuralDeferred` helper (prefers `observer_deferred` when bound, else the world's shared buffer) rather than binding `observer_deferred` in `execBody` — equivalent routing, leaves the tag/extension `pending_*` paths untouched. **Out (later milestones):** async `spawn { }` task (M1.0.12 — M1.0.11 built the async suspension core but the `spawn { }` task form ships with the concurrency algebra); the §30.5 additive-conflict cook warning (later cook milestone). +- **M1.0.11 scope boundary (async suspension core)** — the Phase-1 tree-walker async model (NOT the Phase-2 bytecode state machine, `etch-bytecode.md §9`) is a dynamic `AsyncTask` pool + resume frame-stack reproducing the §9 observable semantics; documented in `etch-reference-part1.md §9.12` (Claude.ai KB re-upload). **Recorded deviations (Claude.ai round-trips):** §9.12 placement broadened to `for`/`try`/`catch` bodies (E1) and to reject `await` in VALUE-position blocks (E3, `E0904`) — both real EBNF v0.6 statements, C1.6; the `programs/` integration file dropped (that corpus is the interp↔codegen differential, rejects async — Observable behavior covered by inline cross-tick tests). **Owned here:** `await` family (`wait` Duration/60 Hz, `global_event`, direct-call `future`) + `async fn`/`method` execution + coloring `E0901` + placement `E0904`. **Out (owned by later milestones, NOT debt):** `race`/`sync`/`branch`/`spawn { }` + cancellation + `await` on a `TaskHandle` → M1.0.12; `await wait_unscaled` + timers (need the scaled/unscaled time subsystem) → M1.0.13; `await entity_event` (needs entity-scoped events) → M1.0.14; entity-bound `async rule` → later; the Phase-2 bytecode async lowering. **Open decision — async effect-consumption completeness:** a BARE `async` call in an `async` context (no `await`, no wrapper) is illegal per `etch-resolver-types.md §9.2` (the `{async}` effect is consumed by `await` OR `spawn`/`branch`/`race`/`sync`) but is **not** `E0901` (the non-async-context case) and currently **fails loud at runtime** (`is_async` gate) — the compile-time check completes with the consumption constructs in **M1.0.12**. ## Non-negotiable rules @@ -214,4 +216,4 @@ The `briefs/` directory is the source of truth for milestone state. The brief's --- -Last updated: 2026-06-30 +Last updated: 2026-07-01 From c73a3531a35b4b6ebc53fd066322b2844470c668 Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 20:22:23 +0200 Subject: [PATCH 14/15] docs(brief): close M1.0.11 --- briefs/M1.0.11-async-core.md | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md index f21736b..fcda66f 100644 --- a/briefs/M1.0.11-async-core.md +++ b/briefs/M1.0.11-async-core.md @@ -1,12 +1,12 @@ # M1.0.11 — Async suspension core (await family + async fn execution) -> **Status:** ACTIVE +> **Status:** CLOSED > **Phase:** 1 > **Branch:** `phase-1/etch/async-core` > **Planned tag:** `v0.10.11-async-core` > **Dependencies:** M1.0.10 (base tag `v0.10.10-structural-mutation`) > **Opened:** 2026-07-01 -> **Closed:** — +> **Closed:** 2026-07-01 --- @@ -62,7 +62,6 @@ Files outside this list must not be touched without a justification logged in "E - `src/etch/interp.zig` — modify — async task pool replacing the per-rule `async_slots`; resume frame-stack `(block, cursor, scope)`; lift the `async fn` / `async method` `RuntimeFailure` gates (frame inlining); `wait` Duration handling + the fixed-timestep constant + minimal Duration-literal→seconds eval; re-host `global_event`. Inline `test "…"` blocks (the existing async-rule tests live here). - `src/etch/types.zig` — modify — function coloring (E1301 / E1302) and await placement (E1300); await-target recognition. Inline `test "…"` blocks (the existing type-check tests live here). - `src/etch/diagnostics.zig` — modify — add two diagnostic kinds in the effect/async block E09xx: `async_call_in_non_async_context` (**E0901**, spec-assigned by `etch-resolver-types.md §9.2`) and `await_not_statement_head` (**E0904**, the Phase-1 placement restriction), with their short-code and name mappings (the E09xx block is free in the code map). -- `tests/etch_interp/programs/_async_core.etch` — create — integration Etch program (next available ordinal) exercising an `async rule` calling an `async fn` with sequential value-awaits and the owned targets. - `CLAUDE.md` — modify — §3.4 patch (current-state table → Next M1.0.12; +1 Tags-table line on close; open-decisions note; "Last updated"). - `briefs/M1.0.11-async-core.md` — create — this brief, committed verbatim as the branch's first commit. @@ -168,6 +167,8 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § *Changes to the FROZEN SECTION made mid-milestone after a Claude.ai round-trip. Each references the commit that records it. Empty at milestone end = nominal case.* - §9.12 placement broadened to `for` and `try`/`catch` — C1.6; AsyncFrame set completed before E2. Commit e0adcce. +- §9.12 placement broadened again (E3): a statement-head `await` in a VALUE-position block (`let x = if c { await f() }`, `= match`/`= loop`/`= { … }`, control-flow/block assignment RHS or `return` operand) is E0904 — those blocks are evaluated synchronously by the tree-walker, so the await is inexecutable; C1.6. Commit 4ae7f5a. +- Files list: dropped the `tests/etch_interp/programs/_async_core.etch` integration program (FROZEN "Files to create or modify"). That directory is the interp↔codegen differential corpus (`codegen_corpus_build.zig`), and codegen rejects `async` (`UnsupportedConstruct`, Phase 2) — an async program cannot join it. The "Observable behavior" acceptance is instead covered by the inline cross-tick tests in `interp.zig`: `async fn` value-await (n → 42), `async method` (n → 11), `await` in a `for` body (n → 1 → 3 → 6), `await` in a `try` body (post-resume `throw` → `catch`), `await wait(1.0s)` resuming at tick 60, and the nested `if`-body `emit` counted by an `@on_event` observer. Recorded at milestone close. ## Blockers encountered @@ -181,10 +182,8 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § ## Closing notes -*Fill in at Status → CLOSED, just before opening the PR.* - -- **What worked:** -- **What deviated from the original spec:** -- **What to flag explicitly in review:** -- **Final measurements:** -- **Residual risks / tech debt left intentionally:** +- **What worked:** The explicit iterative frame-stack (`AsyncTask.frames`) made resume trivial and side-effect-free — the stack persists across a suspension and re-driving it never re-runs a prefix (no double `emit`), which is the property §9.12 demands. Building the frame set COMPLETE up front (E1 for/try after Guy's STOP, E2 call frames) meant E2/E3/E4 added no new `AsyncFrame` variants — the substrate stayed closed. Mirroring the sync executor's control-flow semantics (loop labels, break/continue, match arms, try/catch) in the driver, and reusing the sync `execStmt` for leaf statements, kept the two executors behaviorally aligned. The E0904 placement check reused the same `stmt_head_await` NodeId that the interpreter's `stmtHeadAwait` keys on — one notion of "statement-head", two consumers. +- **What deviated from the original spec:** Three FROZEN-section deviations (all Claude.ai round-trips, in Recorded deviations): §9.12 placement broadened to `for`/`try`/`catch` bodies (E1) and to reject `await` in value-position blocks (E3) — both real EBNF v0.6 statement blocks, C1.6; and the `tests/etch_interp/programs/_async_core.etch` integration file dropped (that corpus is the interp↔codegen differential and codegen rejects `async`). +- **What to flag explicitly in review:** (1) The async task pool reserves NO capacity but never reallocates mid-drive (no task is created during a drive in E1–E4), and call-frame scopes are heap-boxed (`*Locals`), so scope pointers survive `frames` reallocation and suspension; M1.0.12 `spawn` (which creates sibling tasks) must re-verify this. (2) Function coloring is complete for the non-async-context case (E0901); a BARE async call in an async context (no `await`/wrapper) is NOT E0901 and currently fails loud at runtime — the compile-time check completes in M1.0.12 with the effect-consumption constructs (see the CLAUDE.md M1.0.11 open decision). (3) `for`-over-array/map with a body `await` across a suspension inherits the M0.8 "heap-across-suspend, POD-only" caveat — `forAdvance` bounds-checks the handle and fails loud (typed `RuntimeFailure`, never an OOB crash); the `range` form is unconditionally sound and is what the test uses. +- **Final measurements:** No benchmark (per the brief). A sync-only program allocates no task pool, keeps `async_tick` at 0, and takes no async path — byte-identical to the pre-async runtime by construction. Test count: the etch target went 367 → 376 (nine added: 2 nested-block E1, 2 for/try E1-fix, 2 async fn/method E2, wait(1.0s)/fail-loud-partition E3, E0904 sub-expr + E0904 value-position + E0901 coloring in types.zig), green in Debug AND ReleaseSafe. +- **Residual risks / tech debt left intentionally:** (a) The bare-async-call-in-async-context compile check (M1.0.12, above). (b) `for`/`try` heap-iterable-across-suspend caveat (fail-loud, above). (c) Minor doc imprecision NOT patched: the M1.0.10 tag entry (CLAUDE.md tags table) calls `spawn {` the "M1.0.11 async seam" — `spawn {` stays fail-loud and moved to M1.0.12; left as a historical tag description (the M1.0.10 "Out" pointer WAS patched to M1.0.12 in this milestone's drift audit). (d) `etch-reference-part1.md §9` KB update recording the tree-walker model + the §9.4 "Quatre/Cinq formes" and `wait_until` drift fixes — a Claude.ai deliverable after merge (out of repo scope, per Out-of-scope). From 52ca5d38f72c623b2918bb4eb7832ec3f04923ca Mon Sep 17 00:00:00 2001 From: Guy Senpai Date: Wed, 1 Jul 2026 21:18:16 +0200 Subject: [PATCH 15/15] docs(brief): align frozen prose with recorded deviations (M1.0.11) --- briefs/M1.0.11-async-core.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/briefs/M1.0.11-async-core.md b/briefs/M1.0.11-async-core.md index fcda66f..be9b08f 100644 --- a/briefs/M1.0.11-async-core.md +++ b/briefs/M1.0.11-async-core.md @@ -88,7 +88,7 @@ None. A sync-only program must remain byte-identical to the pre-milestone runtim ### Observable behavior -- Running the integration program `tests/etch_interp/programs/_async_core.etch`: an `async rule` calls an `async fn` performing `let a = await ; let b = await wait(1.0s); …`, completes across several ticks, and emits an event the harness observes at the expected tick. +- The observable async behavior is exercised by the inline cross-tick tests in `interp.zig`: `async fn` value-await (n → 42), `async method` (n → 11), `await` in a `for` body (n → 1 → 3 → 6), `await` in a `try` body (post-resume `throw` → `catch`), `await wait(1.0s)` resuming at tick 60, and a nested `if`-body `emit` counted once by an `@on_event` observer. No `programs/` integration file — that corpus is the interp↔codegen differential and codegen rejects `async` (Phase 2), cf. Recorded deviations. ### CI @@ -114,7 +114,7 @@ The spec's async implementation is compiled state machines (`etch-bytecode.md § - **No fibers, no per-task OS thread** (`etch-reference-part1.md §9.1`). A task is a heap record holding a **resume frame-stack**: a stack of `(block, statement cursor, scope)` frames. An `await` at a statement-head position suspends by recording the frame-stack plus the wake condition; resume re-enters at `cursor + 1` in the innermost frame and **never re-runs prefix statements**, so `emit` and structural mutations never double-fire. This generalizes the M0.8 single-top-level-cursor `AsyncSlot` to nested blocks and a dynamic task pool. The substrate must support a frame-stack of depth greater than one. -- **Await placement (Phase-1 restriction, E0904).** `await` must be the **full right-hand expression** of an expr-statement, a `let` initializer, an assignment RHS, or a `return` operand, in any statement block that can hold statements — rule/`fn` body, `if`/`else`, `loop`/`while`/`for`, match-arm, and `try`/`catch` bodies. A sub-expression `await` — `some(await f())`, `f(await g())`, `a + await b` — is rejected; the author hoists it into a `let` (`let t = await f(); return some(t)`). This is semantics-preserving (`await` has no effect beyond suspend + resolve). The Phase-2 bytecode VM removes this restriction. +- **Await placement (Phase-1 restriction, E0904).** `await` must be the **full right-hand expression** of an expr-statement, a `let` initializer, an assignment RHS, or a `return` operand, in any statement block that can hold statements — rule/`fn` body, `if`/`else`, `loop`/`while`/`for`, match-arm, and `try`/`catch` bodies. A sub-expression `await` — `some(await f())`, `f(await g())`, `a + await b` — is rejected; the author hoists it into a `let` (`let t = await f(); return some(t)`). Likewise, an `await` inside a block used as a VALUE (`let x = if c { await f() }`, `= match` / `= loop` / `= { … }`, or a block/control-flow assignment RHS / `return` operand) is rejected (`E0904`) — such blocks are evaluated synchronously by the tree-walker, so the `await` is inexecutable. This is semantics-preserving (`await` has no effect beyond suspend + resolve). The Phase-2 bytecode VM removes this restriction. - **`async fn` / `async method` via frame inlining.** A direct `await f()` (f async) pushes `f`'s body frames onto the caller's task; `f`'s own `await` suspends the whole task; `f`'s `return v` resolves at the caller's await site. This is the `future` target for **direct** calls; `await` on a stored `TaskHandle` is M1.0.12 (arrives with `spawn`).