diff --git a/CLAUDE.md b/CLAUDE.md index a861bce..3749698 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.11-async-core` | +| Last released tag | `v0.10.12-concurrency-algebra` | | Active branch | `main` | -| 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. | +| Next planned milestone | M1.0.13 — time subsystem (scaled/unscaled game time, `await wait_unscaled`, timers `after`/`every`/`after_unscaled`/`quantize`; replaces the fixed-1/60 `wait` conversion without changing its signature). M1.0 (Etch ↔ ECS interpreter) is **21 sub-milestones** (M1.0.0–M1.0.20), **NOT complete**: M1.0.0–M1.0.12 closed; M1.0.13–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 top-level construct keyword; the timer family (`every`/`after_unscaled`/`quantize`) also waits in `non_s3_keywords` for M1.0.13. | ## Tags @@ -49,6 +49,7 @@ knowledge base — see § Quick links spec. | `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. | +| `v0.10.12-concurrency-algebra` | 2026-07-03 | M1.0.12 — Concurrency algebra (`race`/`sync`/`branch`/`spawn { }` + cancellation + `TaskHandle` await) | Closes the concurrency chapter of EBNF v0.6 (C1.6). `race`/`sync` graduate (`kw_race`/`kw_sync`); the four statements parse (`ConcurrencyBranch` shared slab + `RaceStmt`/`SyncStmt`/`BranchStmt`/`SpawnStmt`; `spawn (`/`spawn {` token disambiguation kept local; `let h = spawn { }` IS the SpawnStmt binding form, not a let-stmt). Type-check: E0901 on the four forms outside async; **`E0905 UnconsumedAsyncEffect`** — `await` is the SOLE call-grain consumer (§9.2 **revision 2**, STOP round-trip: the constructs relocate the suspension, their bodies are ordinary async contexts); **`E0906`** `return` race-branch-only (winner-return propagation); **`E0907`** break/continue must not cross the task boundary (per-branch loop depth + label window); `TaskHandle` builtin (non-POD, field-rejected), `h.cancel()`, `await h` typed (unit Phase 1). Execution: heap-record pointer-stable monotonic task pool (husk parking — no reuse, index = Phase-1 handle identity, no generations); drive-by-origin (children interleave at the origin rule's position, creation order, mid-pass pickup); `race`/`sync` = child task per admitted branch (guards evaluated first in the live parent scope; per-branch scope SNAPSHOT copy) with parent suspended on `children_any`/`children_all` (zero admitted → same-tick passthrough; one-tick construct latency otherwise — parent precedes children in pool order, so losers are canceled before their wakes can fire); race = first-`.done`-in-declaration-order winner, losers `cancelTask`'d (NON-transitive), winner-`return` re-raised at the race site; failed tasks park `.canceled` (never a winner, never block a join, `await` on them fails loud). `branch`/`spawn` = detached tasks (`Value.task_handle` = pool index); `await h` on done = immediate resume delivering the parked result, on canceled(-while-awaited) = fail-loud. Fix-as-you-go: `return await ` no longer drops its return at resume (M1.0.11 gap). | ## Hypotheses validated by spikes @@ -83,6 +84,7 @@ knowledge base — see § Quick links spec. - **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.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**. +- **M1.0.12 scope boundary (concurrency algebra)** — the four constructs (`race`/`sync`/`branch`/`spawn { }`) parse, type-check, and execute as CHILD TASKS in the interpreter's pointer-stable monotonic pool (heap records, husk parking, no slot reuse — a pool index IS the Phase-1 `TaskHandle`, no generations), driven by-origin at the creating rule's position in creation order. **Recorded deviation (STOP round-trip 2026-07-02, `etch-resolver-types.md` §9.2 revision 2):** the brief's "calls inside the four construct bodies count as consumed launch sites" is SUPERSEDED — `await` is the SOLE call-grain consumer of the `{async}` effect (`E0905` applies recursively inside the construct bodies; the constructs relocate the suspension into a child task, they do not replace the `await`). Return asymmetry (Guy's ruling 2026-07-02): `return` legal only in a `race` branch (winner-return re-raised at the race site); `sync`/`branch`/`spawn` bodies reject it (`E0906`). Documented one-tick construct latency: a race/sync parent precedes its children in pool-creation order, so it resumes the tick AFTER its wake fires — which guarantees losers are canceled before their own wakes can fire (zero-admitted constructs have no latency). Cancellation is NON-transitive (Phase 1, `etch-bytecode.md` §9.5). Failed tasks (uncaught `throw` / runtime failure) park `.canceled` — never a race winner, never block a `sync` join, `await`ing them fails loud (§9.8 amended). Fix-as-you-go: `return await ` dropped its return at resume (M1.0.11 gap) — fixed. **Out (later):** `wait_unscaled` + timers (M1.0.13), `entity_event` (M1.0.14), entity-bound `async rule`, Phase-2 bytecode lowering (`etch-bytecode.md` §9.4), transitive cancellation (Phase 2+), task-pool slot reuse / generations (Phase-2 refcounted model), value-producing spawn bodies (no EBNF v0.6 value channel). ## Non-negotiable rules @@ -216,4 +218,4 @@ The `briefs/` directory is the source of truth for milestone state. The brief's --- -Last updated: 2026-07-01 +Last updated: 2026-07-03 diff --git a/briefs/M1.0.12-concurrency-algebra.md b/briefs/M1.0.12-concurrency-algebra.md new file mode 100644 index 0000000..5c8a188 --- /dev/null +++ b/briefs/M1.0.12-concurrency-algebra.md @@ -0,0 +1,205 @@ +# M1.0.12 — Concurrency algebra (race / sync / branch / spawn + cancellation + TaskHandle await) + +> **Status:** CLOSED +> **Phase:** 1 +> **Branch:** `phase-1/etch/concurrency-algebra` +> **Planned tag:** `v0.10.12-concurrency-algebra` +> **Dependencies:** M1.0.11 (base tag `v0.10.11-async-core`) +> **Opened:** 2026-07-02 +> **Closed:** 2026-07-03 + +--- + +# FROZEN SECTION + +*Produced by Claude.ai. Not modifiable by Claude Code outside a Claude.ai round-trip (cf. § Recorded deviations).* + +## Context + +M1.0.11 delivered the async suspension substrate: a dynamic `AsyncTask` pool, resume frame-stacks, statement-head `await` at any depth, and `async fn` execution by frame inlining on the caller's task. It deliberately did NOT build the concurrency constructs: `race`/`sync` are still reserved keywords, the async `spawn { }` and statement-position `branch` forms fail loud at parse, and `await` on a stored `TaskHandle` fails loud at runtime. M1.0.12 closes the concurrency chapter of EBNF v0.6 (criterion C1.6): all four constructs parse, type-check, and execute in the tree-walking interpreter, with Phase-1 non-transitive cancellation and a runtime `TaskHandle`. It also completes the async effect-consumption compile check (`E0905`) left open by M1.0.11. + +**Execution model (decided, extends `etch-reference-part1.md` §9.12).** Unlike `async fn` (frame INLINING on the same task), every `race`/`sync` branch and every `branch`/`spawn` body is a CHILD TASK in the interpreter's task pool, interleaved cooperatively on the single thread (each task advances to its next `await`, then yields). The parent of a `race`/`sync` suspends on a child-set wake condition; `branch`/`spawn` create a detached task and the parent continues. Semantics are behaviorally identical to `etch-reference-part1.md` §9.5–9.9 as amended 2026-07-02 (winner-return propagation, scope snapshot, control-flow boundary, TaskHandle semantics). The Phase-2 bytecode lowering (`etch-bytecode.md` §9.4) is NOT built. + +## Scope + +Five gated sub-steps. Fix-as-you-go applies: any gap found at any point is fixed in this milestone, not deferred. + +**E1 — Multi-task scheduler substrate (no observable behavior change)** + +- Convert `Interpreter.async_tasks` from `ArrayListUnmanaged(AsyncTask)` to a pointer-stable pool (`ArrayListUnmanaged(*AsyncTask)`, records heap-allocated with `gpa`). Rationale: `spawn`/`race`/`sync` create sibling tasks MID-DRIVE; the current invariant "async_tasks never reallocates while driveLoop runs" (relied on by `currentScope`'s `&task.locals` and every `*AsyncTask` threaded through `driveTask`/`driveLoop`/`stepBodyStmt`) would break on append-reallocation. The pool is monotonic in Phase 1: no slot reuse; a completed task is parked as a small husk (frames + locals freed, result retained) — this is what makes `await` on an already-done handle trivially correct without generations or refcounting. +- Add to `AsyncTask`: `origin_rule: u32` (the async rule descriptor index that transitively created this task), `parent: ?u32` (pool index, null for rule roots and detached tasks after creation bookkeeping), task state `canceled` (alongside `suspended`/`done`), and `result: Value` (parked completion value for handle-await delivery; `.unit` default). +- Child-set storage: a shared `ArrayListUnmanaged(u32)` of pool indices; `WakeCond` variants reference ranges into it (keeps `WakeCond` small). +- Three new `WakeCond` variants: `children_any { start, len }` (race parent: fires when at least one child is `done`), `children_all { start, len }` (sync parent: fires when no child remains `suspended`), `task_done: u32` (handle-await: fires when the target task is not `suspended`). `asyncWakeFired` polls pool states; no notification machinery. +- Drive-by-origin: `runAsyncRule(idx)` drives, in task-CREATION order, every ready task in the pool with `origin_rule == idx` (not just `rule_tasks[idx]`). This preserves the M0.8 producer-before-consumer ruling: events emitted by child tasks interleave at the origin rule's position in the rule order, deterministically, including for detached tasks that outlive a parent iteration. +- Cancellation primitive: `cancelTask` marks `canceled`, frees frames + locals (same teardown as `finishTaskDone`), stops scheduling. Non-transitive: tasks the canceled task had itself launched are independent pool entries and keep running. +- Gate E1: all existing M1.0.11 tests green unchanged (rule root tasks are the only pool occupants; behavior byte-identical). + +**E2 — Keyword graduation, parse, AST** + +- `token.zig`: graduate `race`/`sync` out of `non_s3_keywords` into `s3_keywords` as `kw_race`/`kw_sync`. Update the graduation test. +- `ast.zig`: payload structs + arena constructors for the four `StmtKind` placeholders: `RaceStmt`/`SyncStmt` (contiguous branch runs; each branch = optional condition `NodeId` + one statement `NodeId` + span, per `race_branch = [ "if" expression "=>" ] statement`, `etch-grammar.md` §4.2), `BranchStmt` (block), `SpawnStmt` (block + optional `let` binding name — the binding is PART of the statement: `spawn_stmt = [ "let" IDENT "=" ] "spawn" block`). +- `parser.zig`: parse the four statement forms. Replace the two fail-louds: the `spawn {` seam in `parseStructuralSpawn` (M1.0.11 comment marks it) and the `kw_branch` statement-head fail-loud in `parseStmt`. `spawn` disambiguation stays token-based: `spawn (` → structural expr (M1.0.10, untouched), `spawn {` → async task statement; `let h = spawn { }` is parsed as a `SpawnStmt` with binding (NOT a let-stmt whose initializer is a spawn). Quest/dialogue `branch` parsing lives inside `parseQuestBranch`/`parseDialogueElems` — disjoint parse contexts, no conflict; add a test proving coexistence. +- The parser accepts the four forms in any statement position; async-context enforcement is the type-checker's job (E3). +- Gate E2: parse tests for all forms incl. conditional branches, nested constructs, empty bodies, `spawn {` vs `spawn (` disambiguation, quest/dialogue coexistence. + +**E3 — Type-check: coloring, effect-consumption completeness, control-flow boundary, TaskHandle** + +- Async-context requirement: the four constructs are legal only where `current_is_async` (else `E0901`, matching "Tous les constructs async ne sont disponibles que dans un context async", `etch-grammar.md` §4.2). +- Effect-consumption completeness (open decision inherited from M1.0.11, settled here): a bare async call in an ASYNC context — not wrapped in `await`, not launched inside `spawn`/`branch`/`race`/`sync` — is rejected at compile time with `E0905 UnconsumedAsyncEffect` (`etch-resolver-types.md` §9.2). Calls inside the four construct bodies count as consumed launch sites. +- Control-flow boundary (`etch-resolver-types.md` §9.2, amended): `return` inside a `sync` branch or a `branch`/`spawn` body → `E0906 IllegalReturnInConcurrencyBranch`; `return` inside a `race` branch is LEGAL (winner-return propagation, E4). `break`/`continue` targeting a loop outside the concurrency construct → `E0907 ControlFlowEscapesTaskBranch` (all four forms). Loops fully inside a branch body keep working. +- `TaskHandle`: type the `spawn` binding as the builtin `TaskHandle` (`etch-grammar.md` §2.2, non-POD — reject as component/resource field like other non-POD builtins). `h.cancel()` recognized as a builtin method call (no args, unit). `await h` where `h: TaskHandle` types as the handle-await form (result typed `unit` in Phase 1 — spawn bodies are blocks, not value-producing; see Notes). `await` on a non-TaskHandle non-call expression stays rejected. +- Conditional branch guards (`if cond =>`) type-check as `bool`, evaluated in the parent scope. +- Diagnostic codes come from the spec — E0905/E0906/E0907 are assigned in `etch-resolver-types.md` §9.2 and cataloged in `etch-diagnostics.md` §12 (2026-07-02 revision). Do NOT invent codes. +- Gate E3: positive + negative type-check tests for every rule above. + +**E4 — Execute `race` and `sync` (child tasks, winner selection, cancellation)** + +- Construct entry (during a drive): evaluate each branch's `if cond =>` guard synchronously in the parent's current scope; for each admitted branch, create a child task (`origin_rule` inherited, initial run frame over the branch statement — a block branch frames its statement run; a single non-block statement frames a length-1 run) with root locals = SNAPSHOT COPY of the parent's current scope (normative: branch writes to inherited locals are invisible to the parent and to sibling branches; cross-branch communication goes through ECS state/events — `etch-reference-part1.md` §9.8 amended block). Then suspend the parent on `children_any` (race) / `children_all` (sync). A race/sync with ZERO admitted branches does not suspend: the parent continues immediately. +- Child tasks are driven by the same `driveTask` machinery at their origin rule's position, in creation order. Determinism: within one `runAsyncRule` pass, tasks are driven in creation order; a child that never suspends completes within the pass. +- Race wake: scan children in DECLARATION order; the first with state `done` is the winner (deterministic tie-break when several complete in the same tick). Cancel every other non-done child via `cancelTask` (non-transitive). Winner-return propagation: if the winner finished with a pending `return`, re-raise that return at the race statement site in the parent (the parent unwinds as if the race statement itself returned the value — `etch-reference-part1.md` §9.5 amended). A losing branch's pending return is discarded with the cancellation. +- Sync wake: when no child remains `suspended`, the parent resumes at the statement after the `sync`. Failed children (uncaught `throw` → fail-loud harvested error) do not block the join; the errors are already reported. +- Failed branch in a race is not a winner; if ALL branches fail, the race completes with the parent resuming after the statement (errors already harvested fail-loud). +- Gate E4: interleaving tests (emit ordering across parent/children over multiple ticks), race timeout pattern from §9.5 (winner return propagates), conditional branch admission, race determinism tie-break, sync join with a failing branch, cancellation of losers verified (loser's subsequent statements never run, no double emit), zero-admitted-branch passthrough. + +**E5 — Execute `branch`/`spawn`, runtime TaskHandle, KB closure** + +- `branch { }`: create a detached child task (snapshot scope, same origin_rule), parent continues immediately at the next statement. No handle anywhere. +- `spawn { }`: same, plus a `TaskHandle` value. New `Value` variant `task_handle: u32` (pool index; safe because the pool is monotonic — no reuse, no generation needed in Phase 1). With binding: bound in the parent scope. Without binding: discarded. +- `h.cancel()`: idempotent — cancels a suspended task, no-op on `done`/`canceled`. +- `await h`: parent suspends on `task_done`; if the task is already `done`, resume immediately delivering the parked `result` (unit in Phase 1); if the task is `canceled` (or is canceled while awaited), FAIL-LOUD runtime error (no silent unit) — `etch-reference-part1.md` §9.8 amended. Replace the M1.0.11 handle-await fail-loud test with the real behavior. +- Detached-task lifecycle tests: task outliving the parent's completion keeps running at the origin rule's position; canceled parent does NOT cancel its detached children (non-transitive boundary verified). +- KB closure (in-PR, per §3.4 discipline): extend `etch-reference-part1.md` §9.12 with the multi-task scheduler realization (child tasks, drive-by-origin, WakeCond set, monotonic pool, cancellation teardown) — Claude.ai produces the swap content at gate E5; CLAUDE.md update per workflow §3.4. +- Gate E5: full construct matrix green; differential harness unaffected for sync-only programs. + +## Out of scope + +- Phase-2 bytecode lowering (`RACE_BEGIN`/`SYNC_BEGIN`/`ASYNC_BRANCH`/`ASYNC_SPAWN`, poll fns, refcounted state structs — `etch-bytecode.md` §9.4). The tree-walker realizes the same observable semantics by child tasks; no opcode work. +- Transitive cancellation propagation (explicitly Phase 2+ reconsideration, `etch-bytecode.md` §9.5). +- `wait_unscaled` / timers (`after`/`every`/`after_unscaled`/`quantize`) — M1.0.13. +- `entity_event` await target — M1.0.14. +- Value-producing spawn bodies / typed `await h` results beyond unit (requires block-value task results; not in EBNF v0.6's `spawn_stmt` contract — see Notes). +- Task pool slot reuse / free-list / handle generations (Phase-2 VM has the refcounted model; the Phase-1 husk-parking growth characteristic is accepted and documented). +- Structural `spawn(...)` expression changes (M1.0.10 surface untouched except the shared keyword dispatch). + +## Specs to read first + +1. `etch-reference-part1.md` — §9.5–9.9 (construct semantics, INCLUDING the 2026-07-02 normative amendments: winner-return propagation, TaskHandle semantics, shared branch semantics block), §9.12 (tree-walker suspension core to be extended) +2. `etch-grammar.md` — §4.2 (race_stmt/sync_stmt/branch_stmt/spawn_stmt EBNF, conditional branches, spawn binding form), §2.2 (TaskHandle builtin type), §3.2 (structural_spawn — the form NOT touched) +3. `etch-resolver-types.md` — §9.2 (effect consumption, E0905/E0906/E0907 — 2026-07-02 revision), §9.3 (rule error fatality) +4. `etch-ast-ir.md` — §3.4.2 (StmtKind Race/Sync/Branch/Spawn) +5. `etch-diagnostics.md` — §12 (E09XX catalog, 2026-07-02 revision incl. E0904 backfill) +6. `etch-bytecode.md` — §9.4–9.5 (Phase-2 target form NOT built; cancellation boundary) + +## Files to create or modify + +- `src/etch/token.zig` — modify — graduate `race`/`sync` to `kw_race`/`kw_sync`; update graduation test +- `src/etch/ast.zig` — modify — RaceStmt/SyncStmt/BranchStmt/SpawnStmt payloads, branch storage, arena constructors +- `src/etch/parser.zig` — modify — parse the four forms; remove the two fail-louds; keep structural `spawn (` path intact +- `src/etch/types.zig` — modify — async-context enforcement, E0905/E0906/E0907, TaskHandle typing, `h.cancel()` builtin, handle-await typing +- `src/etch/diagnostics.zig` — modify — add E0905/E0906/E0907 entries +- `src/etch/value.zig` — modify — `task_handle: u32` variant +- `src/etch/interp.zig` — modify — pointer-stable pool, origin_rule drive, WakeCond variants, child-task creation, race/sync/branch/spawn execution, cancellation, handle-await, winner-return propagation +- Tests live in-file per the established `src/etch/*.zig` test convention (no separate tests/ tree for Etch) + +## Acceptance criteria + +### Tests + +All in-file (`zig build test`), following the M1.0.11 naming convention (`test "… (M1.0.12 E)"`). + +- `token.zig` — graduation test updated: `race`/`sync` map to keywords, no longer reserved +- `parser.zig` — the four forms parse (incl. conditional branches, `let h = spawn { }`, bare `spawn { }`, nested constructs); `spawn (` vs `spawn {` disambiguation; quest/dialogue `branch` coexistence +- `types.zig` — E0901 on the four forms outside async context; E0905 bare async call in async context (and NOT fired when consumed by any of the five forms); E0906 return in sync/branch/spawn branches, accepted in race branches; E0907 break/continue crossing the boundary, accepted fully inside; TaskHandle binding typed, non-POD rejection, `cancel()` and `await h` typed +- `interp.zig` — E1: M1.0.11 suite green unchanged. E4: race first-wins + declaration-order tie-break; loser canceled (no statement after its suspension point runs, no double emit); winner `return` propagates to the enclosing fn/rule at the race site; conditional admission; zero-branch passthrough; sync joins all incl. a failing branch; emit interleaving order across ticks is deterministic and documented in the test. E5: branch detached (parent continues same tick); spawn handle cancel/await; await on done handle immediate; await on canceled handle fail-loud; detached task survives parent completion; non-transitive cancellation verified +- Differential harness — sync-only programs byte-identical (no task allocated) + +### Benchmarks + +- None (interpreter milestone; no perf target). + +### Observable behavior + +- An `.etch` program exercising the §9.5 timeout pattern (race between a slow await and a `wait` timeout, winner returns) and a §9.6 parallel-preload pattern (sync over three awaits) runs under the interpreter test harness and produces the documented emit sequence across ticks. + +### CI + +- `zig build` clean, zero warnings, on the configured matrix +- `zig build test` green (debug + ReleaseSafe) +- `zig fmt --check` green +- `commit-msg` hook green on every commit of the branch (≤72-char subject) + +## Conventions + +- **Branch:** `phase-1/etch/concurrency-algebra` +- **Final tag:** `v0.10.12-concurrency-algebra` +- **PR title:** `Phase 1 / Etch / Concurrency algebra (race, sync, branch, spawn)` +- **Commit convention:** Conventional Commits (cf. `engine-development-workflow.md §4.3`) +- **Merge strategy:** squash-and-merge (cf. `engine-development-workflow.md §4.6`) + +## Notes + +- **Pointer-stability is the E1 crux.** The M1.0.11 code comments state the reallocation invariant explicitly (`currentScope` fallback stability, `driveLoop` task pointers). Creating a child task mid-drive with the current flat ArrayList invalidates live pointers — heap-allocated task records are the minimal fix. Do not attempt an index-based rewrite of the drive path instead; it touches far more surface for no Phase-1 benefit. +- **`return` semantics are asymmetric by design** (Guy's ruling 2026-07-02): race branches may return (winner propagates at the race site — the parent is provably suspended there for the whole course); sync/branch/spawn branches may not (`E0906`). Do not "generalize" return propagation to the other forms. +- **Race is a statement, not an expression.** It produces no value itself; values exit via the winner's `return` or via ECS state. Do not add an expression form. +- **Handle-await result is unit in Phase 1.** `spawn` bodies are blocks in statement position; EBNF v0.6 gives them no value channel. `AsyncTask.result` exists for structure (and delivers unit); typed results are a bytecode-era concern. Do not invent a value channel. +- **Scope snapshot is a full copy at construct entry**, taken AFTER guard evaluation. Guards see the live parent scope; bodies see the snapshot. Snapshot copies are per-branch (sibling isolation). +- **Uncaught `throw` in any child task** = fail-loud harvested runtime error at the task's drive position (consistent with rule-error fatality). It must not tear down siblings or the parent beyond the documented race/sync semantics. +- **Keyword coexistence trap:** `kw_branch` serves quest/dialogue branches (M0.8 E4) inside their construct parsers AND the new async statement at general statement head. `kw_spawn` serves the structural expression (`spawn (`) and the async task (`spawn {`). Both disambiguations are purely local (parse context / next token); keep them that way. +- **Growth characteristic (accepted):** the monotonic pool retains one small husk per completed task for the program run. Acceptable for Phase-1 test/demo workloads; the Phase-2 VM has the refcounted lifecycle. Document it in the §9.12 extension. + +--- + +# 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 + +- [x] `etch-reference-part1.md` — read in full 2026-07-02 02:29 (incl. the 2026-07-02 normative amendments: §9.5 winner-return propagation, §9.8 TaskHandle semantics + shared branch semantics block, §9.12 tree-walker suspension core) +- [x] `etch-grammar.md` — read in full 2026-07-02 02:30 (§4.2 race/sync/branch/spawn EBNF + conditional branches + spawn binding, §2.2 TaskHandle non-POD, §3.2 structural_spawn untouched) +- [x] `etch-resolver-types.md` — read in full 2026-07-02 02:31 (§9.2 effect consumption + E0905/E0906/E0907, §9.3 rule error fatality); §9.2 revision 2 (re-attached at the E3 STOP round-trip) re-read 2026-07-02 11:20 — `await` sole call-grain consumer +- [x] `etch-ast-ir.md` — read in full 2026-07-02 02:32 (§3.4.2 StmtKind Race/Sync/Branch/Spawn placeholders, §5.3 Phase-2 lowering forms NOT built) +- [x] `etch-diagnostics.md` — read in full 2026-07-02 02:28 (§12 E09XX catalog, 2026-07-02 revision incl. E0904 backfill and E0905/E0906/E0907) +- [x] `etch-bytecode.md` — read in full 2026-07-02 02:33 (§9.4 RACE_BEGIN/SYNC_BEGIN/ASYNC_BRANCH/ASYNC_SPAWN target form NOT built, §9.5 non-transitive cancellation boundary) + +## Execution log + +- E1 2026-07-02: `async_tasks` converted to a pointer-stable pool (`ArrayListUnmanaged(*AsyncTask)`, heap records via `gpa.create`, monotonic — husk-parked, no reuse). `AsyncTask` gains `origin_rule`/`parent`/`result` + state `.canceled`; `WakeCond` gains `children_any`/`children_all`/`task_done` (poll-based, ranges into the new shared `task_children` list); `runAsyncRule` rewritten drive-by-origin (index-based scan, so mid-drive children are picked up in creation order within the same pass); `cancelTask` primitive (idempotent, non-transitive, same teardown as completion). +- E1 note (flag for review): `children_any` fires on "any child `.done`" OR "no child `.suspended`" — the second clause realizes E4's all-branches-failed passthrough (a race whose every branch fails must complete); the brief's parenthetical describes only the winner case. Dead code until E4 (no children exist yet). +- E1 note: `finishTaskDone`/`finishTaskFailed` still park `.done` (M1.0.11 behavior byte-preserved); the failed-task → `.canceled` repark lands in E4 where it first becomes observable (failed racer must not be a winner). +- E1 validation: full suite green debug + ReleaseSafe, `zig fmt --check` + `zig build lint` green; 918/935 tests passed = base 934 + the new E1 substrate test (pool identity + cancelTask park/idempotence/never-rescheduled). Two transient full-suite exit-1s were observed under cold-cache load (0 failed assertions, both also reproducible-then-gone on unmodified base) — the known macOS full-suite flake family, not attributable to this diff (4/4 dedicated runs green on both trees). +- E1 GO received 2026-07-02 (both journal notes validated as-is). +- E2 2026-07-02: `race`/`sync` graduated (`kw_race`/`kw_sync`; reserve list keeps `override` + the M1.0.13 timer family); AST payloads `ConcurrencyBranch` (shared slab, match-arms bulk-append pattern) + `RaceStmt`/`SyncStmt`/`BranchStmt`/`SpawnStmt` with arena constructors; parser: the two fail-louds replaced (`kw_branch` statement-head → `parseBranchStmt`; `spawn {` seam → task statement at statement head / `let h = spawn { }` inside `parseLetStmt`, and a precise sub-expression-position error in `parseStructuralSpawn`); `parseIf` split into head + `finishIf` so a race/sync branch resolves `if expr =>` (guard) vs `if expr { }` (if-statement branch) after the expression; `startsKeywordStmt` extended (race/sync/branch, spawn-with-lbrace) so the forms parse inside value blocks. +- E2 note: the bound form rejects `mut` / a type annotation at parse (`spawn_stmt` EBNF admits neither); `if let` at a race/sync branch head is always an if-statement branch (the guard grammar has no `let`). Quest-branch coexistence test initially placed the quest `branch` at quest top level — corrected to a stage element (its EBNF §8.3 position). +- E2 validation: full suite green debug + ReleaseSafe (943 tests = 935 + 8 new: 1 token graduation + 7 parser incl. conditional branches, nesting, empty bodies, spawn disambiguation, quest/dialogue coexistence, binding-form rejections); fmt + lint green. +- E2 GO received 2026-07-02. +- E3 2026-07-02: diagnostics E0905/E0906/E0907 registered (codes + PascalCase names per `etch-diagnostics.md` §12). `types.zig`: `BuiltinType.task_handle` (non-POD §2.2 — NOT in `fromName`, dedicated field-rejection message on component/resource); `checkStmt` arms for the four constructs — E0901 async-context gate on each, `if cond =>` guards typed bool in the PARENT scope, each branch/body checked in an innermost-branch context (`conc_branch` + per-branch loop depth + label window `conc_labels_base`, saved/restored); E0906 on `return` (race-only legal, asymmetric per brief Notes); E0907 on `break`/`continue` whose target (by depth or by label) lies outside the branch; E0905 on a bare async call in async context via awaited-node identity (`awaited_call`) + the four-construct launch-site exemption (`in_conc_construct`); `await` future form: a direct call keeps its declared type, a non-call target must type `TaskHandle` (handle-await, result `unknown` ≈ unit Phase 1), any other known type → E0200; `h.cancel()` builtin on a TaskHandle receiver (no args, statement-effect); spawn binding bound AFTER the body (the §9.8 snapshot excludes the handle). +- E3 note: the M1.0.11 fail-loud partition test in `interp.zig` dropped its third case (`await` on a stored int) — that shape is now rejected at TYPE-CHECK (E0200), so it can no longer reach the runtime in a checked program; the checker-side rejection is covered by the new E3 tests, and E5 replaces the runtime path with real handle-await behavior (per the brief). +- E3 note (placement interaction, inherited M1.0.11 — flag for review): a branch BLOCK whose last item is a bare `await` parses that await as the block's trailing VALUE (synchronously evaluated, off the frame spine) → E0904, unchanged. The §9.5 timeout pattern is unaffected (its block ends on `return`). Documented in the guard test. +- E3 validation: full suite green debug + ReleaseSafe (+7 types.zig tests: E0901×4-forms positive/negative, E0905 bare/consumed-by-five-forms, E0906 3-forms/race-legal, E0907 crossing/in-branch incl. labels, TaskHandle ops + misuse + field rejection, bool guards); fmt + lint green. +- E3 STOP fix 2026-07-02 (see Recorded deviations): `in_conc_construct` exemption removed — `awaited_call` identity is the sole E0905 consumption criterion; both E0905 messages reworded per the fix list; E0905 test inverted (bare call in each of the four bodies → 4 × E0905; awaited call in each body + rule top → clean). E0906/E0907/TaskHandle/guards untouched per the verdict. Suite re-validated debug + ReleaseSafe. +- E3 GO received 2026-07-02. +- E4 2026-07-02: `race`/`sync` execute. `beginRaceSync` (construct entry from `stepBodyStmt`): guards ALL evaluated first in the parent's live scope (a failing guard leaves no orphan child), then one child task per admitted branch — `origin_rule` inherited, `parent` linked (lineage only), root locals = per-branch snapshot copy (`cloneLocalsInto`, value-level; heap handles share the M0.8 rule-arena caveat), block branch → `run` frame via `pushBlockRun`, single-statement branch → new `AsyncFrame.single` length-1 run (a branch stmt is a bare NodeId in `concurrency_branches`, unaddressable by `RunFrame`'s `extra` range); parent suspends on `children_any`/`children_all`, cursor already past the construct; ZERO admitted → `.advanced` (same-tick passthrough). `resolveChildWake` (at parent resume in `driveTask`, before any statement): race = first-`.done`-in-declaration-order winner, `cancelTask` every other non-done child (non-transitive), winner-return re-raised at the race site via the returning-unwind (delivers at an enclosing `async fn`'s await site through its call frame, or ends the rule task); sync = join is complete by wake construction. Task-level `return` parked in the husk (`returned` + `result`, set in `unwindControl`). E1-deferred repark lands: `finishTaskFailed` → `.canceled`; `finishTaskDone` with uncaught throw → `.canceled` (failed child ≠ winner, never blocks a join). +- E4 note (documented determinism): a parent always precedes its children in pool-creation order, so within a tick the parent is visited BEFORE children complete — a race/sync parent resumes the tick AFTER its wake condition becomes true (one-tick construct latency), and losers are canceled before their own wakes can fire (the pass visits the resolved parent first next tick). Zero-admitted constructs have NO latency (no suspension). The interleaving test documents the full emit timeline. +- E4 validation: full suite green debug + ReleaseSafe; +7 interp tests (§9.5 timeout pattern with winner-return propagation + loser cancellation; deterministic emit interleaving across ticks with canceled-loser-never-emits; conditional admission + zero-admitted passthrough; declaration-order tie-break; sync join with failing branch; all-branches-failed race passthrough; §9.8 snapshot-scope isolation); M1.0.11 suite green unchanged; fmt + lint green. +- E4 GO received 2026-07-03. +- E5 2026-07-03: `branch`/`spawn` execute as DETACHED tasks (snapshot scope, same `origin_rule`, no parent link; parent continues immediately — its statements land the same tick). `Value.task_handle: u32` (pool index — monotonic pool, no generation; handle equality deliberately absent, not a v0.6 operation). Bound form: handle bound in the PARENT scope after the snapshot. `h.cancel()` = `dispatchMethodOnValue` task_handle arm → the E1 `cancelTask` (idempotent, non-transitive). `await h`: non-call future target evaluated to a TaskHandle — already-`.done` → IMMEDIATE resume delivering the parked `result` (unit Phase 1, no suspension); `.suspended` → parent suspends on `task_done`; `.canceled` (before or WHILE awaited) → fail-loud runtime error, no silent unit (§9.8 amended) — checked at both the await site and the `driveTask` resume. +- E5 fix-as-you-go (flag for review): `return await ` (wait/global_event/handle) silently DROPPED its return at resume in M1.0.11 (`deliverAwaitValue` no-ops on `.return_`, execution fell through past the statement) — fixed: a `.return_` pending bind now re-raises the return through the unwind at resume; same fix at the immediate-done handle-await site. Pre-existing gap exposed by the handle-await path. +- E5 STOP fix 2026-07-03: regression test added for the `return await` fix — three forms: (a) `return await wait(…)` in an async fn (resume `.return_` on a wake target: the unit value resolves at the caller's await site, no fall-through), (b) `return await h` on an already-`.done` handle (immediate `.signaled` path), (c) `return await h` on a `.suspended` handle (`.return_` pending bind at the `task_done` resume). Each form carries a poison `throw` AFTER the return (a fall-through would count a runtime error) + a caller continuation asserted to run exactly once. Detail: the first draft's poison used `get_mut(Out)` inside the fn bodies — E1213 (resource access is when-gated, fns have no `when`); reworked to poison-`throw` + event-emitting spawn bodies folded into `Out` by an `@on_event` observer. Nothing else touched. +- E5 validation: full suite green debug + ReleaseSafe; +8 interp tests (branch detached + outlives parent; cancel-before-first-drive; await-join across ticks; await-on-done immediate same-drive resume; await-on-canceled fail-loud; canceled-WHILE-awaited fail-loud at resume (harness-driven); non-transitive parent-cancel with surviving detached child; construct-matrix smoke: race nested in a spawn body joined via handle). The M1.0.11 handle-await fail-loud test was already retired at E3 (checker-rejected shape) — real behavior now tested. Differential harness unaffected (sync-only byte-identical). `CLAUDE.md` updated per §3.4 (current-state table, +1 Tags row, M1.0.12 open-decisions entry incl. the §9.2-rev-2 deviation, date). KB §9.12 extension: swap content to be produced by Claude.ai at this gate (per brief E5) — flagged in the gate signal. + +## Recorded deviations + +- E3 STOP round-trip (Claude.ai, 2026-07-02) — **E0905 launch-site exemption removed** (`etch-resolver-types.md` §9.2 revision 2, re-attached): the brief's E3 wording "Calls inside the four construct bodies count as consumed launch sites" is superseded. `await` is the SOLE call-grain consumer of the `{async}` effect; the four constructs relocate the suspension into a child task and their bodies are ordinary async contexts where E0905 applies recursively. Rationale: the blanket exemption blessed a bare async call inside a spawn/branch/race/sync body that is GUARANTEED to fail loud at execution (sync call path, M1.0.11 `is_async` gate) — the exact soundness hole E0905 exists to close. Implementation: `in_conc_construct` removed, `awaited_call` node identity is the only criterion; E0905 message updated; tests inverted (bare call in each of the four bodies → 4 × E0905; awaited call in each body → clean). + +## Blockers encountered + +- + +## Closing notes + +- **What worked:** The E1 pointer-stable pool conversion carried the whole milestone — no drive-path pointer ever needed revisiting after it (the brief's "do not attempt an index-based rewrite" note held). Drive-by-origin's index-based pass gave mid-drive child pickup and the deterministic creation-order guarantee for free, and the parent-precedes-children pool-order invariant turned out to be the load-bearing determinism argument (losers are provably canceled before their wakes can fire). The E4/E5 test batteries passed first-run once written against the documented tick timelines. +- **What deviated from the original spec:** One recorded deviation (STOP round-trip 2026-07-02): the E3 "calls inside the four construct bodies count as consumed launch sites" wording was superseded by `etch-resolver-types.md` §9.2 revision 2 — `await` is the SOLE call-grain consumer of the `{async}` effect; the constructs relocate the suspension, their bodies are ordinary async contexts (E0905 applies recursively). See Recorded deviations. +- **What to flag explicitly in review:** (1) `children_any` fires on "any child done OR none suspended" — the second clause realizes the all-branches-failed race passthrough (validated at the E1 GO). (2) Fix-as-you-go: `return await ` silently dropped its return at resume since M1.0.11 — fixed at both resume sites, regression-tested across the three forms (E5 STOP). (3) One-tick construct latency for race/sync with ≥1 admitted branch (parent precedes children in pool order); zero-admitted constructs pass through same-tick — documented in the interleaving tests. (4) Failed tasks park `.canceled` (shared with explicit cancellation): not a winner, never block a join, `await` on them fails loud — one state, three semantics, documented on `AsyncTask.state`. (5) Language-audit exception: one French grammar citation lives in the brief's FROZEN SECTION (upstream-authored, not modifiable here); the two code-comment copies of it were paraphrased to English. (6) KB §9.12 swap content is Claude.ai-produced at the E5 gate (flagged in the gate signal); CLAUDE.md was updated on-branch per §3.4. +- **Final measurements:** No benchmarks (interpreter milestone, per brief). Test deltas: 934 (base) → 967 tests — +1 E1 substrate, +8 E2 (1 token + 7 parser), +7 E3 types, +7 E4 interp, +9 E5 interp (incl. the 3-form return-await regression) + the §9.6 observable; full suite green debug + ReleaseSafe throughout; `zig fmt --check` + `zig build lint` green. +- **Residual risks / tech debt left intentionally:** (1) Monotonic pool growth — one husk per completed task per program run (brief-accepted; Phase-2 VM has the refcounted lifecycle). (2) Heap-backed values (collections/struct handles) in child-task snapshots and in a race winner's `return` value share the M0.8 POD-across-suspend rule-arena caveat. (3) A branch block whose LAST item is a bare `await` parses that await as the block's trailing VALUE → E0904 (M1.0.11 placement, unchanged; the §9.5 pattern ends on `return` and is unaffected). (4) `rules_matched` counts once per tick per rule when ANY of its tasks drove — a coarse per-rule notion, informational only. (5) The `parent` link on race/sync children is lineage bookkeeping only (cancellation stays non-transitive by spec). diff --git a/src/etch/ast.zig b/src/etch/ast.zig index 8bbd7cc..89a16c6 100644 --- a/src/etch/ast.zig +++ b/src/etch/ast.zig @@ -262,8 +262,9 @@ pub const ExprKind = enum { /// `structural_spawn`, M1.0.10). A statement-position expression: the v0.6 /// no-body-handle decision (§4.5) means the type-checker rejects binding or /// using its result (M1.0.10 E2). Data indexes `spawn_structs`. Distinct - /// from the async `spawn { }` task form (§4.2 `spawn_stmt`, M1.0.11) which - /// is fail-loud at parse — it is NOT this node. + /// from the async `spawn { }` task STATEMENT (§4.2 `spawn_stmt`, + /// `StmtKind.spawn_stmt` since M1.0.12) — the keyword is shared, the token + /// after `spawn` disambiguates; this node is only ever the `(` form. spawn_struct, }; @@ -997,6 +998,60 @@ pub const AwaitExpr = struct { event_type: StringId, }; +/// One branch of a `race` / `sync` statement (M1.0.12 E2, `etch-grammar.md` +/// §4.2 `race_branch = [ "if" expression "=>" ] statement`). `cond` is +/// `NodeId.none` for an unconditional branch — a conditional branch's guard is +/// evaluated synchronously in the parent scope at construct entry (§9.5) and +/// decides whether the branch starts. `stmt` is the single branch statement +/// (commonly a block-expression statement `{ … }`). +pub const ConcurrencyBranch = struct { + cond: NodeId, + stmt: NodeId, + span: SourceSpan, +}; + +/// `race "{" { race_branch } "}"` statement (M1.0.12 E2, `etch-grammar.md` +/// §4.2). Branches are a contiguous `(start, len)` run of +/// `arena.concurrency_branches`. First branch to complete wins; the losers are +/// canceled (§9.5 — execution M1.0.12 E4). +pub const RaceStmt = struct { + branches_start: u32, + branches_len: u32, +}; + +/// `sync "{" { sync_branch } "}"` statement (M1.0.12 E2, `etch-grammar.md` +/// §4.2). Same branch storage as `race`; the parent joins when ALL branches +/// complete (§9.6 — execution M1.0.12 E4). +pub const SyncStmt = struct { + branches_start: u32, + branches_len: u32, +}; + +/// `branch block` statement (M1.0.12 E2, `etch-grammar.md` §4.2 +/// `branch_stmt`) — a fire-and-forget detached task; no handle, the parent can +/// neither await nor cancel it (§9.7 — execution M1.0.12 E5). The body is a +/// statement run in `arena.extra` (same layout as a rule body). Distinct from +/// the quest/dialogue `branch` sub-constructs, which are parsed inside their +/// own construct parsers and never reach statement position. +pub const BranchStmt = struct { + body_start: u32, + body_len: u32, +}; + +/// `[ "let" IDENT "=" ] "spawn" block` statement (M1.0.12 E2, +/// `etch-grammar.md` §4.2 `spawn_stmt`) — a detached task with a `TaskHandle` +/// (§9.8). `binding` is the `let` name (`0` when absent — the handle is +/// discarded); the binding is PART of the statement, not a `let_stmt` whose +/// initializer is a spawn. The body is a statement run in `arena.extra`. +/// Distinct from the structural `spawn ( … )` expression (§3.2 +/// `structural_spawn` — `ExprKind.spawn_struct`); the two share the keyword, +/// disambiguated by the token after `spawn`. +pub const SpawnStmt = struct { + binding: StringId, + body_start: u32, + body_len: u32, +}; + /// `|a, b| expr` closure (M0.8 closures, `etch-grammar.md` §524). Params are a /// flat `(start, len)` range of `arena.closure_params`; the body is an /// expression node. E1 closures take an expression body — a `{ block }` body @@ -2499,6 +2554,15 @@ pub const AstArena = struct { try_catch_stmts: std.ArrayListUnmanaged(TryCatchStmt) = .empty, emit_stmts: std.ArrayListUnmanaged(EmitStmt) = .empty, await_exprs: std.ArrayListUnmanaged(AwaitExpr) = .empty, + /// Branch runs shared by `race_stmts` / `sync_stmts` (M1.0.12 E2). Each + /// statement's branches are a contiguous `(start, len)` run — nesting is + /// safe because a nested construct finishes (and appends its run) before + /// the enclosing one appends its own (the `match_arms` precedent). + concurrency_branches: std.ArrayListUnmanaged(ConcurrencyBranch) = .empty, + race_stmts: std.ArrayListUnmanaged(RaceStmt) = .empty, + sync_stmts: std.ArrayListUnmanaged(SyncStmt) = .empty, + branch_stmts: std.ArrayListUnmanaged(BranchStmt) = .empty, + spawn_stmts: std.ArrayListUnmanaged(SpawnStmt) = .empty, named_types: std.ArrayListUnmanaged(NamedTypeNode) = .empty, array_types: std.ArrayListUnmanaged(ArrayTypeNode) = .empty, map_types: std.ArrayListUnmanaged(MapTypeNode) = .empty, @@ -2712,6 +2776,11 @@ pub const AstArena = struct { self.try_catch_stmts.deinit(gpa); self.emit_stmts.deinit(gpa); self.await_exprs.deinit(gpa); + self.concurrency_branches.deinit(gpa); + self.race_stmts.deinit(gpa); + self.sync_stmts.deinit(gpa); + self.branch_stmts.deinit(gpa); + self.spawn_stmts.deinit(gpa); self.named_types.deinit(gpa); self.array_types.deinit(gpa); self.map_types.deinit(gpa); @@ -3423,6 +3492,38 @@ pub const AstArena = struct { return try self.addStmt(gpa, .try_catch_stmt, idx, span); } + /// `branches` is the statement's complete branch list, bulk-appended to + /// `arena.concurrency_branches` as a contiguous run (M1.0.12 E2 — the + /// `addMatch` arms pattern, safe under nesting). + pub fn addRaceStmt(self: *AstArena, gpa: std.mem.Allocator, branches: []const ConcurrencyBranch, span: SourceSpan) !NodeId { + const start: u32 = @intCast(self.concurrency_branches.items.len); + try self.concurrency_branches.appendSlice(gpa, branches); + const idx: u32 = @intCast(self.race_stmts.items.len); + try self.race_stmts.append(gpa, .{ .branches_start = start, .branches_len = @intCast(branches.len) }); + return try self.addStmt(gpa, .race_stmt, idx, span); + } + + /// Same storage discipline as `addRaceStmt` (M1.0.12 E2). + pub fn addSyncStmt(self: *AstArena, gpa: std.mem.Allocator, branches: []const ConcurrencyBranch, span: SourceSpan) !NodeId { + const start: u32 = @intCast(self.concurrency_branches.items.len); + try self.concurrency_branches.appendSlice(gpa, branches); + const idx: u32 = @intCast(self.sync_stmts.items.len); + try self.sync_stmts.append(gpa, .{ .branches_start = start, .branches_len = @intCast(branches.len) }); + return try self.addStmt(gpa, .sync_stmt, idx, span); + } + + pub fn addBranchStmt(self: *AstArena, gpa: std.mem.Allocator, bs: BranchStmt, span: SourceSpan) !NodeId { + const idx: u32 = @intCast(self.branch_stmts.items.len); + try self.branch_stmts.append(gpa, bs); + return try self.addStmt(gpa, .branch_stmt, idx, span); + } + + pub fn addSpawnStmt(self: *AstArena, gpa: std.mem.Allocator, ss: SpawnStmt, span: SourceSpan) !NodeId { + const idx: u32 = @intCast(self.spawn_stmts.items.len); + try self.spawn_stmts.append(gpa, ss); + return try self.addStmt(gpa, .spawn_stmt, idx, span); + } + pub fn addLetStmt(self: *AstArena, gpa: std.mem.Allocator, let: LetStmt, span: SourceSpan) !NodeId { const idx: u32 = @intCast(self.let_stmts.items.len); try self.let_stmts.append(gpa, let); diff --git a/src/etch/diagnostics.zig b/src/etch/diagnostics.zig index 23109a9..92486d0 100644 --- a/src/etch/diagnostics.zig +++ b/src/etch/diagnostics.zig @@ -363,6 +363,9 @@ pub const DiagnosticCode = enum { // ── 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) + unconsumed_async_effect, // M1.0.12 E3 — E0905 UnconsumedAsyncEffect (bare async call in an async context: neither awaited nor launched via spawn/branch/race/sync) + illegal_return_in_concurrency_branch, // M1.0.12 E3 — E0906 IllegalReturnInConcurrencyBranch (return in a sync branch or a branch/spawn body; legal only in a race branch) + control_flow_escapes_task_branch, // M1.0.12 E3 — E0907 ControlFlowEscapesTaskBranch (break/continue targeting a loop outside the concurrency construct) /// Canonical short code, e.g. `"E0001"`. pub fn code(self: DiagnosticCode) []const u8 { @@ -551,6 +554,9 @@ pub const DiagnosticCode = enum { .prefab_remove_base_component => "W1790", .async_call_in_non_async_context => "E0901", .await_not_statement_head => "E0904", + .unconsumed_async_effect => "E0905", + .illegal_return_in_concurrency_branch => "E0906", + .control_flow_escapes_task_branch => "E0907", }; } @@ -741,6 +747,9 @@ pub const DiagnosticCode = enum { .prefab_remove_base_component => "PrefabRemoveBaseComponent", .async_call_in_non_async_context => "AsyncCallInNonAsyncContext", .await_not_statement_head => "AwaitNotStatementHead", + .unconsumed_async_effect => "UnconsumedAsyncEffect", + .illegal_return_in_concurrency_branch => "IllegalReturnInConcurrencyBranch", + .control_flow_escapes_task_branch => "ControlFlowEscapesTaskBranch", }; } }; diff --git a/src/etch/interp.zig b/src/etch/interp.zig index c9eaa23..609f0f2 100644 --- a/src/etch/interp.zig +++ b/src/etch/interp.zig @@ -257,8 +257,9 @@ const RuleDesc = struct { /// `initial_tick` (0). last_run_tick: Tick, /// `async rule` (M0.8 E3 sub-slice B): the rule suspends at `await` and - /// resumes a later tick via its `AsyncSlot`, instead of running to - /// completion every tick. Dispatched by `runAsyncRule` in `stepOnce`. + /// resumes a later tick via its task in the `async_tasks` pool (the M0.8 + /// `AsyncSlot` lineage), instead of running to completion every tick. + /// Dispatched by `runAsyncRule` in `stepOnce`. is_async: bool, /// Entities this rule matched in the most recent tick it ran (M1.0.0 /// observable). Reset at the top of `runRule`'s entity-bound path, @@ -571,6 +572,19 @@ const WakeCond = union(enum) { /// (M0.8 E3 sub-slice B — `await global_event(T)`). The producer must run /// before the awaiter in the rule order, same as the observer drain. global_event: StringId, + /// A `race` parent (M1.0.12 E1): fires when at least one child in + /// `task_children[start .. start+len]` is `.done` (a winner exists) — OR when + /// no child remains `.suspended` (every branch failed/canceled: no winner, + /// the race completes and the parent resumes after the statement, E4). + /// A range into the shared `Interpreter.task_children` list, kept small. + children_any: struct { start: u32, len: u32 }, + /// A `sync` parent (M1.0.12 E1): fires when no child in the range remains + /// `.suspended` — failed (canceled) children do not block the join. + children_all: struct { start: u32, len: u32 }, + /// A handle-await parent (M1.0.12 E1, `await h`): fires when the target task + /// is no longer `.suspended`. The pool is monotonic (no slot reuse), so a + /// bare pool index is a stable identity — no generation needed in Phase 1. + task_done: u32, }; /// One frame of an `AsyncTask`'s resume stack (M1.0.11 E1). The tree-walker is @@ -597,6 +611,18 @@ const AsyncFrame = union(enum) { for_: ForFrame, try_: TryFrame, call: CallFrame, + single: SingleFrame, +}; + +/// A length-1 statement run (M1.0.12 E4): the root frame of a `race`/`sync` +/// child task whose branch is a single NON-block statement (`await wait(1.0s)` +/// or a guarded `if cond => stmt` — the branch statement is a bare `NodeId` in +/// `arena.concurrency_branches`, not an `arena.extra` run, so `RunFrame`'s +/// range encoding cannot address it). `cursor` 0 = not yet run, 1 = done → +/// pop. Transparent to `unwindControl` like a `run` frame. +const SingleFrame = struct { + stmt: NodeId, + cursor: u32 = 0, }; /// A linear statement run: execute `block[cursor .. block_len]`, then (if any) @@ -705,13 +731,23 @@ const CallFrame = struct { /// 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). +/// suspension. Since M1.0.12 E1 each task is a HEAP record (`gpa.create`) in the +/// `Interpreter.async_tasks` pointer pool: `race`/`sync`/`branch`/`spawn` create +/// sibling tasks MID-DRIVE, so pool growth must not invalidate the live +/// `*AsyncTask` threaded through `driveTask`/`driveLoop`/`stepBodyStmt` (nor +/// `currentScope`'s `&task.locals`). The pool is MONOTONIC in Phase 1: no slot +/// reuse — a completed task parks as a small husk (frames + locals freed, +/// `result` retained), which makes `await` on an already-done handle trivially +/// correct without generations or refcounting. 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, + /// `.suspended` = live (schedulable when `wake` fires); `.done` = completed + /// normally (`result` parked); `.canceled` = terminated WITHOUT a result — + /// explicitly canceled (`cancelTask`) or, from E4, failed loud (uncaught + /// `throw` / runtime failure). A `.canceled` task is never a race winner, + /// never blocks a `sync` join, and `await`ing it fails loud (§9.8 amended). + state: enum { suspended, done, canceled } = .suspended, wake: WakeCond = .{ .wait_until = 0 }, frames: std.ArrayListUnmanaged(AsyncFrame) = .empty, /// The task's ROOT locals (the rule body's scope), retained across suspension. @@ -723,6 +759,28 @@ const AsyncTask = struct { /// (`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, + /// The async rule descriptor index that transitively created this task + /// (M1.0.12 E1). Drive-by-origin schedules every task at ITS RULE's position + /// in the rule order — events emitted by child tasks interleave there, + /// deterministically, including for detached tasks outliving their parent. + origin_rule: u32 = 0, + /// Pool index of the task that created this one (M1.0.12 E1); `null` for + /// rule roots — and for detached (`branch`/`spawn`) tasks after creation + /// bookkeeping. Cancellation is NON-transitive (Phase 1): this link is + /// lineage bookkeeping, not a cancellation channel. + parent: ?u32 = null, + /// Parked completion value (M1.0.12 E1): the husk keeps it after frames + + /// locals are freed. For a `spawn` task it is the handle-await delivery + /// value — always `.unit` in Phase 1 (`spawn` bodies are blocks — no value + /// channel, brief Notes). For a `race` child it carries the branch's + /// pending `return` value (with `returned` set, E4) — re-raised at the + /// race site if this child wins; discarded otherwise. + result: Value = .{ .unit = {} }, + /// True when the task completed via a task-level `return` (M1.0.12 E4): + /// `result` then holds the returned value. Only a `race` branch may + /// return (E0906), so this drives winner-return propagation (§9.5); + /// meaningless-but-harmless on other tasks. + returned: bool = false, fn deinit(self: *AsyncTask, gpa: std.mem.Allocator) void { for (self.frames.items) |*f| switch (f.*) { @@ -871,12 +929,20 @@ pub const Interpreter = struct { /// 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 - /// 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, + /// for the M0.8 per-rule `AsyncSlot` slice. POINTER-STABLE since M1.0.12 E1: + /// each element is a heap record (`gpa.create`), because `race`/`sync`/ + /// `branch`/`spawn` create sibling tasks MID-DRIVE and an append must not + /// invalidate the live `*AsyncTask` (or `&task.locals`) threaded through the + /// drive path. MONOTONIC: no slot reuse — a finished task parks as a husk + /// (state `.done`/`.canceled`) until `deinit`, so a pool index is a stable + /// task identity (Phase-1 `TaskHandle`, no generations). Empty when + /// `!has_async`. + async_tasks: std.ArrayListUnmanaged(*AsyncTask) = .empty, + /// Shared child-set storage (M1.0.12 E1): a `race`/`sync` parent appends its + /// admitted children's pool indices here and suspends on a `WakeCond` range + /// `{start, len}` into it — keeps `WakeCond` small. Append-only within a + /// program run (ranges stay valid for the parent's whole suspension). + task_children: std.ArrayListUnmanaged(u32) = .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`. @@ -955,8 +1021,12 @@ 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_tasks.items) |*task| task.deinit(self.gpa); + for (self.async_tasks.items) |task| { + task.deinit(self.gpa); + self.gpa.destroy(task); + } self.async_tasks.deinit(self.gpa); + self.task_children.deinit(self.gpa); self.gpa.free(self.rule_tasks); self.descriptors.deinit(self.gpa); self.merge_cursors.deinit(self.gpa); @@ -1824,46 +1894,88 @@ pub const Interpreter = struct { if (matched) report.rules_matched += 1; } - /// 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`). + /// Create a task record in the pool (M1.0.12 E1). Heap-allocated so live + /// `*AsyncTask` pointers survive pool growth (children are created + /// mid-drive); monotonic — the returned index is a stable task identity. + fn newTask(self: *Interpreter, origin_rule: u32, parent: ?u32) !u32 { + const task = try self.gpa.create(AsyncTask); + errdefer self.gpa.destroy(task); + task.* = .{ .origin_rule = origin_rule, .parent = parent }; + try self.async_tasks.append(self.gpa, task); + return @intCast(self.async_tasks.items.len - 1); + } + + /// Cancel a task (M1.0.12 E1): free its frames + locals — the same teardown + /// as `finishTaskDone` — and park it `.canceled` so it is never scheduled + /// again. Idempotent: a `.done`/`.canceled` task is left untouched (§9.8 + /// amended, `h.cancel()`). NON-transitive (Phase 1, `etch-bytecode.md §9.5`): + /// tasks the canceled task had itself launched are independent pool entries + /// and keep running. + fn cancelTask(self: *Interpreter, ti: u32) void { + const task = self.async_tasks.items[ti]; + if (task.state != .suspended) return; + self.clearFrames(task); + task.frames.clearAndFree(self.gpa); + task.pending_bind = .discard; + task.locals.deinit(self.gpa); + task.locals = .{}; + task.state = .canceled; + } + + /// Drive an `async rule`'s tasks at its position in the rule order. Spawns + /// the rule-root task on first reach; then (M1.0.12 E1, drive-by-origin) + /// drives, in task-CREATION order, every ready task in the pool whose + /// `origin_rule` is this rule — not just the root. This preserves the M0.8 + /// producer-before-consumer ruling: events emitted by child tasks interleave + /// at the origin rule's position, deterministically, including for detached + /// tasks that outlive a parent iteration. The scan is index-based so a child + /// created mid-drive (appended at the tail) is picked up by the SAME pass — + /// a child that never suspends completes within the pass. A parent is always + /// created before its children (lower index), so a suspended `race` parent + /// resumes — and cancels its losers — before any loser could resume. + /// + /// One root 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]; - 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; + if (self.rule_tasks[idx] == null) { + // First reach → spawn the rule-root 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; + const ti = try self.newTask(@intCast(idx), null); + self.async_tasks.items[ti].state = .done; + self.rule_tasks[idx] = ti; + return; + } + const ti = try self.newTask(@intCast(idx), null); + self.rule_tasks[idx] = ti; + // The initial frame is the rule body as a linear run. The fresh + // task's default wake (`wait_until = 0`) fires immediately, so the + // drive-by-origin pass below runs it this tick. + try self.async_tasks.items[ti].frames.append(self.gpa, .{ .run = .{ + .block_start = rule.body_start, + .block_len = rule.body_len, + } }); } - // 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; + var drove_any = false; + var ti: usize = 0; + while (ti < self.async_tasks.items.len) : (ti += 1) { + const task = self.async_tasks.items[ti]; + if (task.origin_rule != idx) continue; + if (task.state != .suspended) continue; + if (!self.asyncWakeFired(task.wake)) continue; + drove_any = true; + try self.driveTask(world, task, report); } - 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.driveTask(world, &self.async_tasks.items[ti], report); + if (drove_any) report.rules_matched += 1; } /// 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). + /// `&task.locals` fallback is stable across pool growth (M1.0.12 E1): the + /// task is a heap record, so sibling tasks created MID-DRIVE never move it. fn currentScope(task: *AsyncTask) *Locals { var i = task.frames.items.len; while (i > 0) { @@ -2046,10 +2158,46 @@ pub const Interpreter = struct { self.thrown = false; self.returning = false; self.pending_error = null; + // A handle-await resume (M1.0.12 E5, §9.8 amended) first checks its + // target: canceled WHILE awaited → fail-loud runtime error (no silent + // unit); done → its parked result (unit in Phase 1) is the value + // delivered at the await site below. + var resume_value: Value = .{ .unit = {} }; + switch (task.wake) { + .task_done => |ti| { + const target = self.async_tasks.items[ti]; + if (target.state == .canceled) { + task.pending_bind = .discard; + self.finishTaskFailed(task, report); + return; + } + resume_value = target.result; + }, + else => {}, + } // 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) { + // resolves to `unit` — or, for a handle-await, to the parked result; + // deliver it into the resuming scope now (M1.0.11 E2). A `return await + // ` re-raises the RETURN at resume instead (M1.0.12 E5 + // fix-as-you-go: `deliverAwaitValue` no-ops on `.return_`, so M1.0.11 + // silently dropped the return and fell through past the statement). + if (@as(std.meta.Tag(RetTarget), task.pending_bind) == .return_) { + task.pending_bind = .discard; + self.returning = true; + self.return_value = resume_value; + const cont = self.unwindControl(task) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.RuntimeFailure => { + self.finishTaskFailed(task, report); + return; + }, + }; + if (!cont) { + self.finishTaskDone(task, report); + return; + } + } else if (@as(std.meta.Tag(RetTarget), task.pending_bind) != .discard) { + self.deliverAwaitValue(task, task.pending_bind, resume_value) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.RuntimeFailure => { task.pending_bind = .discard; @@ -2059,6 +2207,27 @@ pub const Interpreter = struct { }; task.pending_bind = .discard; } + // A parent suspended on a race/sync child set resolves the construct + // FIRST (M1.0.12 E4): winner selection + loser cancellation, and — on + // a returning winner — the §9.5 winner-return re-raise at the race + // site: the parent unwinds as if the race statement itself returned + // the value (to the enclosing `async fn`'s await site via its call + // frame, or ending the task at rule level). + if (self.resolveChildWake(task)) |winner_return| { + self.returning = true; + self.return_value = winner_return; + const cont = self.unwindControl(task) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + error.RuntimeFailure => { + self.finishTaskFailed(task, report); + return; + }, + }; + if (!cont) { + self.finishTaskDone(task, report); + return; + } + } const outcome = self.driveLoop(world, task) catch |err| switch (err) { error.OutOfMemory => return error.OutOfMemory, error.RuntimeFailure => { @@ -2178,6 +2347,20 @@ pub const Interpreter = struct { .signaled => if (try self.unwindControl(task)) continue :drive else return .completed, } }, + .single => { + // Length-1 run (M1.0.12 E4) — a race/sync child whose + // branch is a single non-block statement. + if (task.frames.items[ti].single.cursor >= 1) { + self.popFrame(task); + continue :drive; + } + const stmt = task.frames.items[ti].single.stmt; + switch (try self.stepBodyStmt(world, task, scope, &task.frames.items[ti].single.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) { @@ -2228,7 +2411,43 @@ pub const Interpreter = struct { 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), + .future => { + // A direct call inlines the callee's body (M1.0.11 E2); + // any other expression is the HANDLE-AWAIT form (M1.0.12 + // E5, §9.8): evaluate it to a `TaskHandle` and join. + const ak = self.ast.exprKind(aw.arg_expr); + if (ak == .fn_call or ak == .method_call) { + return try self.beginAsyncCall(world, task, scope, cursor, aw.arg_expr, site.ret); + } + const hv = try self.evalExpr(world, scope, aw.arg_expr); + if (hv != .task_handle) return error.RuntimeFailure; + const target = self.async_tasks.items[hv.task_handle]; + switch (target.state) { + // Already done: resume IMMEDIATELY (no suspension), + // delivering the parked result (unit in Phase 1). A + // `return await h` raises the return signal instead + // (`deliverAwaitValue` no-ops on `.return_`). + .done => { + if (@as(std.meta.Tag(RetTarget), site.ret) == .return_) { + self.returning = true; + self.return_value = target.result; + return .signaled; + } + try self.deliverAwaitValue(task, site.ret, target.result); + cursor.* += 1; + return .advanced; + }, + // Awaiting a canceled task is a fail-loud runtime + // error — no silent unit (§9.8 amended). + .canceled => return error.RuntimeFailure, + .suspended => { + cursor.* += 1; + task.pending_bind = site.ret; + task.wake = .{ .task_done = hv.task_handle }; + return .suspended; + }, + } + }, .wait, .global_event => { task.wake = try self.evalAwaitTarget(site.await_id); cursor.* += 1; @@ -2239,6 +2458,42 @@ pub const Interpreter = struct { } } const sk = self.ast.stmtKind(stmt); + // (1b) `race` / `sync` statement (M1.0.12 E4) → admit branches (guards + // in the live parent scope), create one child task per admitted branch, + // and suspend the parent on the child set. + if (sk == .race_stmt or sk == .sync_stmt) { + return try self.beginRaceSync(world, task, scope, cursor, stmt, sk == .race_stmt); + } + // (1c) `branch { }` / `[let h =] spawn { }` (M1.0.12 E5, §9.7-§9.8) → + // create a DETACHED child task (snapshot scope, same origin_rule, + // no parent link) and continue immediately — the parent never waits + // on it. `spawn` additionally yields a `TaskHandle` (the child's pool + // index — monotonic pool, no generation), bound in the PARENT scope + // AFTER the snapshot (the handle does not exist inside the body). + if (sk == .branch_stmt or sk == .spawn_stmt) { + const data = self.ast.stmtData(stmt); + const body: ast_mod.BranchStmt = if (sk == .branch_stmt) + self.ast.branch_stmts.items[data] + else blk: { + const ss = self.ast.spawn_stmts.items[data]; + break :blk .{ .body_start = ss.body_start, .body_len = ss.body_len }; + }; + const ti = try self.newTask(task.origin_rule, null); + const child = self.async_tasks.items[ti]; + try cloneLocalsInto(self.gpa, scope, &child.locals); + try child.frames.append(self.gpa, .{ .run = .{ + .block_start = body.body_start, + .block_len = body.body_len, + } }); + if (sk == .spawn_stmt) { + const ss = self.ast.spawn_stmts.items[data]; + if (ss.binding != 0) { + try scope.put(self.gpa, ss.binding, .{ .task_handle = ti }, false); + } + } + cursor.* += 1; + return .advanced; + } // (2a) `while` statement → push a while frame (it re-checks its own cond). if (sk == .while_stmt) { cursor.* += 1; @@ -2327,6 +2582,129 @@ pub const Interpreter = struct { return .advanced; } + /// Enter a `race`/`sync` statement during a drive (M1.0.12 E4, §9.5-§9.6). + /// Two passes: (1) evaluate every `if cond =>` guard SYNCHRONOUSLY in the + /// parent's live current scope — guards decide admission, and evaluating + /// them all before creating any child means a failing guard leaves no + /// orphan child behind; (2) create one child task per admitted branch — + /// `origin_rule` inherited (drive-by-origin schedules it at this rule's + /// position), `parent` linked (lineage only, cancellation is + /// non-transitive), root locals = a per-branch SNAPSHOT COPY of the + /// parent's current scope (§9.8 normative: branch writes to inherited + /// locals are invisible to the parent and to siblings; cross-branch + /// communication goes through ECS state/events) — then suspend the parent + /// on the child set (`children_any` for race, `children_all` for sync). + /// A block branch frames its statement run; any other single statement + /// frames a length-1 `single` run. ZERO admitted branches → no suspension, + /// the parent continues immediately (E4). Resolution at the parent's + /// resume is `resolveChildWake`. + fn beginRaceSync(self: *Interpreter, world: *World, task: *AsyncTask, scope: *Locals, cursor: *u32, stmt: NodeId, is_race: bool) StmtError!StepAction { + const data = self.ast.stmtData(stmt); + const range: ast_mod.RaceStmt = if (is_race) + self.ast.race_stmts.items[data] + else blk: { + const ss = self.ast.sync_stmts.items[data]; + break :blk .{ .branches_start = ss.branches_start, .branches_len = ss.branches_len }; + }; + // Pass 1 — guard admission, all guards before any child creation. + var admitted_buf: std.ArrayListUnmanaged(u32) = .empty; + defer admitted_buf.deinit(self.gpa); + var i: u32 = 0; + while (i < range.branches_len) : (i += 1) { + const br = self.ast.concurrency_branches.items[range.branches_start + i]; + if (!br.cond.isNone()) { + const cond = try self.evalExpr(world, scope, br.cond); + if (cond != .bool_) return error.RuntimeFailure; + if (!cond.bool_) continue; + } + try admitted_buf.append(self.gpa, range.branches_start + i); + } + cursor.* += 1; // the parent resumes AFTER the construct + if (admitted_buf.items.len == 0) return .advanced; + // Pass 2 — child creation, in declaration order (= creation order = + // deterministic drive + winner tie-break order). + const parent_idx: ?u32 = blk: { + for (self.async_tasks.items, 0..) |t, pi| { + if (t == task) break :blk @intCast(pi); + } + break :blk null; + }; + const set_start: u32 = @intCast(self.task_children.items.len); + for (admitted_buf.items) |bi| { + const br = self.ast.concurrency_branches.items[bi]; + const ti = try self.newTask(task.origin_rule, parent_idx); + const child = self.async_tasks.items[ti]; + try cloneLocalsInto(self.gpa, scope, &child.locals); + var framed = false; + if (self.ast.stmtKind(br.stmt) == .expr_stmt) { + const e: NodeId = @bitCast(self.ast.stmtData(br.stmt)); + if (self.ast.exprKind(e) == .block_expr) { + try self.pushBlockRun(child, e); + framed = true; + } + } + if (!framed) try child.frames.append(self.gpa, .{ .single = .{ .stmt = br.stmt } }); + try self.task_children.append(self.gpa, ti); + } + const len: u32 = @intCast(admitted_buf.items.len); + task.pending_bind = .discard; + task.wake = if (is_race) + .{ .children_any = .{ .start = set_start, .len = len } } + else + .{ .children_all = .{ .start = set_start, .len = len } }; + return .suspended; + } + + /// Resolve a parent's child-set wake at resume (M1.0.12 E4), BEFORE any + /// statement steps. Race (`children_any`): scan the children in + /// DECLARATION order — the first `.done` is the winner (deterministic + /// tie-break when several complete in the same tick); cancel every other + /// non-done child (non-transitive — tasks a loser launched keep running); + /// return the winner's pending `return` value for re-raising at the race + /// site (`null` when the winner did not return — or when EVERY branch + /// failed: no winner, nothing to cancel that is not already terminal, the + /// parent just resumes after the statement). Sync (`children_all`): the + /// join is complete by wake construction (no child still `.suspended`; + /// failed children never block it) — nothing to do. Any other wake: not a + /// child set — no-op. + fn resolveChildWake(self: *Interpreter, task: *AsyncTask) ?Value { + switch (task.wake) { + .children_any => |r| { + var winner: ?u32 = null; + for (self.task_children.items[r.start .. r.start + r.len]) |ci| { + if (self.async_tasks.items[ci].state == .done) { + winner = ci; + break; + } + } + for (self.task_children.items[r.start .. r.start + r.len]) |ci| { + if (winner != null and ci == winner.?) continue; + self.cancelTask(ci); + } + if (winner) |wi| { + const wtask = self.async_tasks.items[wi]; + if (wtask.returned) return wtask.result; + } + return null; + }, + .children_all => return null, + else => return null, + } + } + + /// Snapshot-copy `src` locals into `dest` (M1.0.12 E4, §9.8 normative): a + /// child task's root scope is a per-branch COPY of the parent's current + /// scope, taken at construct entry after guard evaluation. Value-level + /// copy: rebinding a local inside the branch is invisible outside; a + /// heap-BACKED value (collection/struct handle) shares its rule-arena + /// referent — the M0.8 POD-across-suspend caveat family. + fn cloneLocalsInto(gpa: std.mem.Allocator, src: *const Locals, dest: *Locals) error{OutOfMemory}!void { + var it = src.map.iterator(); + while (it.next()) |entry| { + try dest.map.put(gpa, entry.key_ptr.*, entry.value_ptr.*); + } + } + /// 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 { @@ -2363,6 +2741,14 @@ pub const Interpreter = struct { } } if (task.frames.items.len == 0) { + // Task-level `return` (M1.0.12 E4): park it in the husk — + // a race parent re-raises the WINNER's return at the race + // site (§9.5); unused for any other task (a rule has no + // return value; sync/branch/spawn bodies reject `return`, + // E0906). Heap-backed values share the rule-arena + // POD-across-suspend caveat. + task.returned = true; + task.result = self.return_value; self.returning = false; self.return_value = .{ .unit = {} }; return false; @@ -2409,9 +2795,9 @@ pub const Interpreter = struct { while (task.frames.items.len > 0) { const ti = task.frames.items.len - 1; switch (task.frames.items[ti]) { - .run, .call, .try_ => { - // A block / call / try frame is transparent to `break`/ - // `continue`: abandon it and keep unwinding to the loop. + .run, .call, .try_, .single => { + // A block / call / try / single frame is transparent to + // `break`/`continue`: abandon it and keep unwinding. self.popFrame(task); }, .loop_ => { @@ -2468,19 +2854,24 @@ pub const Interpreter = struct { /// 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`. + /// retained locals, and park the task — `.done` on a clean completion, + /// `.canceled` when it ended on an uncaught `throw` (M1.0.12 E4: a FAILED + /// task terminated without a result — never a race winner, never blocks a + /// `sync` join, `await`ing it fails loud; §9.8 amended). fn finishTaskDone(self: *Interpreter, task: *AsyncTask, report: *RuntimeReport) void { + var failed = false; if (self.thrown) { self.thrown = false; self.pending_error = .{ .kind = .UncaughtThrow, .span = self.thrown_span }; self.harvestError(report); + failed = true; } self.control = .none; self.control_label = 0; self.returning = false; self.return_value = .{ .unit = {} }; self.clearFrames(task); // frees any residual call-frame scopes - task.state = .done; + task.state = if (failed) .canceled else .done; task.frames.clearAndFree(self.gpa); task.pending_bind = .discard; task.locals.deinit(self.gpa); @@ -2488,7 +2879,9 @@ pub const Interpreter = struct { } /// 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. + /// report and park the task `.canceled` (M1.0.12 E4 — failed, no result; + /// was `.done` before the child-task distinction became observable), + /// freeing its frames + retained locals. fn finishTaskFailed(self: *Interpreter, task: *AsyncTask, report: *RuntimeReport) void { self.harvestError(report); self.control = .none; @@ -2496,7 +2889,7 @@ pub const Interpreter = struct { self.thrown = false; self.returning = false; self.clearFrames(task); // frees any residual call-frame scopes - task.state = .done; + task.state = .canceled; task.frames.clearAndFree(self.gpa); task.pending_bind = .discard; task.locals.deinit(self.gpa); @@ -2655,11 +3048,39 @@ pub const Interpreter = struct { } } - /// True iff a suspended task's wake condition is satisfied this tick. + /// True iff a suspended task's wake condition is satisfied this tick. The + /// child-set variants (M1.0.12 E1) POLL the pool states — no notification + /// machinery; the pool is small and the poll runs at the rule's position. fn asyncWakeFired(self: *const Interpreter, wake: WakeCond) bool { return switch (wake) { .wait_until => |t| self.async_tick >= t, .global_event => |type_name| self.events.count(type_name) > 0, + // Race parent: a winner exists (some child `.done`) — or no child + // remains `.suspended` (every branch failed/canceled → no winner; + // the race completes and the parent resumes after the statement, E4). + .children_any => |r| blk: { + var any_done = false; + var any_suspended = false; + for (self.task_children.items[r.start .. r.start + r.len]) |ci| { + switch (self.async_tasks.items[ci].state) { + .done => any_done = true, + .suspended => any_suspended = true, + .canceled => {}, + } + } + break :blk any_done or !any_suspended; + }, + // Sync parent: join when no child remains `.suspended` — failed + // (canceled) children do not block the join (E4). + .children_all => |r| blk: { + for (self.task_children.items[r.start .. r.start + r.len]) |ci| { + if (self.async_tasks.items[ci].state == .suspended) break :blk false; + } + break :blk true; + }, + // Handle-await: the target task reached a terminal state (`.done` + // delivers its parked result; `.canceled` fails loud at resume, E5). + .task_done => |ti| self.async_tasks.items[ti].state != .suspended, }; } @@ -3471,6 +3892,19 @@ pub const Interpreter = struct { /// resolver's `dispatchMethodOnType` split. fn dispatchMethodOnValue(self: *Interpreter, world: *World, locals: *Locals, mc: ast_mod.MethodCall, recv: Value) StmtError!Value { switch (recv) { + .task_handle => |ti| { + // M1.0.12 E5 — the TaskHandle's single method (§9.8): + // `h.cancel()` is IDEMPOTENT — cancels a suspended task, a + // no-op on `.done`/`.canceled` (`cancelTask` gates on state). + // Non-transitive: tasks the target launched keep running. + const mname = self.ast.strings.slice(mc.method_name); + if (std.mem.eql(u8, mname, "cancel")) { + if (mc.args_len != 0) return error.RuntimeFailure; + self.cancelTask(ti); + return Value{ .unit = {} }; + } + return error.RuntimeFailure; + }, .struct_ref => |handle| { const type_name = self.structs.list.items[handle].type_name; const key = methodKey(type_name, mc.method_name); @@ -8691,7 +9125,7 @@ fn asyncFailLoudCount(gpa: std.mem.Allocator, source: []const u8) !u64 { return report.runtime_errors; } -test "await wait_unscaled / entity_event / handle-await still fail loud (partition boundary intact, M1.0.11 E3)" { +test "await wait_unscaled / entity_event 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, @@ -8712,16 +9146,1058 @@ test "await wait_unscaled / entity_event / handle-await still fail loud (partiti \\ 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, + // The third M1.0.11 case — `await` on a stored non-TaskHandle value — is + // rejected at TYPE-CHECK since M1.0.12 E3 (E0200, "await target must be a + // direct async call or a TaskHandle"), so it never reaches the runtime: + // covered by the types.zig E3 tests. Real handle-await execution is E5. +} + +test "task pool is pointer-stable and cancelTask parks a suspended task for good (M1.0.12 E1)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // Substrate check for the multi-task scheduler (no construct surface yet): + // the rule-root task is a heap record in the pointer pool, carries its + // `origin_rule`, and `cancelTask` frees its frames + locals, parks it + // `.canceled`, and the drive-by-origin pass never schedules it again — + // `Out.n` stays at the pre-suspension value forever. Idempotent re-cancel. + const source = + \\resource Out { n: int = 0 } + \\async rule seq() + \\ when resource Out + \\{ + \\ let a = get_mut(Out) + \\ a.n = 1 + \\ await wait(0.04s) + \\ let b = get_mut(Out) + \\ b.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: spawn → n=1, suspend at `await wait(0.04s)`. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + const ti = interp.rule_tasks[0].?; + const task = interp.async_tasks.items[ti]; + try std.testing.expectEqual(@as(u32, 0), task.origin_rule); + try std.testing.expect(task.state == .suspended); + try std.testing.expect(task.frames.items.len > 0); + + // Cancel: frames freed, parked `.canceled`; the heap record's address is + // the pool entry itself (pointer-stable identity). + interp.cancelTask(ti); + try std.testing.expect(task.state == .canceled); + try std.testing.expectEqual(@as(usize, 0), task.frames.items.len); + interp.cancelTask(ti); // idempotent on a non-suspended task + try std.testing.expect(task.state == .canceled); + + // ticks 2..5: the canceled task is never scheduled again — n stays 1 even + // past the original wake tick (0.04s × 60 = tick 3). + const r = try interp.runFor(&world, 4); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); +} + +test "race timeout pattern: winner return propagates, loser canceled (M1.0.12 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // The Sec. 9.5 canonical pattern: a slow fetch races a timeout. The timeout + // branch (3 ticks) beats the slow branch (6 ticks); its `return 99` + // propagates to the race site — `with_timeout` returns 99 at the caller's + // await — and the loser is canceled: `slow`'s `return 1` never lands. + const source = \\resource Out { n: int = 0 } + \\async fn slow() -> int { + \\ await wait(0.1s) + \\ return 1 + \\} + \\async fn with_timeout() -> int { + \\ race { + \\ return await slow() + \\ { await wait(0.05s) + \\ return 99 } + \\ } + \\ return 0 + \\} \\async rule r() \\ when resource Out \\{ - \\ let h = 5 - \\ await h + \\ let x = await with_timeout() + \\ let o = get_mut(Out) + \\ o.n = x \\} - )) >= 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: spawn -> race entry -> 2 children (slow: wake 7; timeout: wake 4); + // parent suspended on children_any. ticks 2-3: everyone waits. + _ = try interp.runFor(&world, 4); + // tick 4: the timeout child resumed, returned 99, parked done. The parent + // (lower pool index, already visited this tick) resumes NEXT tick. + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); + // tick 5: parent resumes -> winner = timeout branch, `slow` canceled -> + // 99 re-raised at the race site -> with_timeout returns 99 -> n = 99. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 99), readResourceInt(&world, out_id)); + // ticks 6-10: past the slow branch's original wake (7) — canceled, its + // `return 1` never lands; n stays 99, no runtime errors. + const tail = try interp.runFor(&world, 5); + try std.testing.expectEqual(@as(u64, 0), tail.runtime_errors); + try std.testing.expectEqual(@as(i64, 99), readResourceInt(&world, out_id)); +} + +test "race emit interleaving is deterministic; canceled loser never emits (M1.0.12 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // Emit ordering across parent/children over multiple ticks, encoded as + // decimal digits into Out.n by an @on_event observer. Documented order: + // tick 1: parent emits 1, race entry (children suspend) -> n = 1 + // tick 3: fast child resumes, emits 2, completes -> n = 12 + // tick 4: parent resumes (winner = fast), cancels slow, + // emits 4 -> n = 124 + // tick 7+ (slow child's original wake): canceled, never + // emits 3 — and the fast child never re-runs (no + // double emit) -> n = 124 + const source = + \\event Mark { k: int = 0 } + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ emit Mark { k: 1 } + \\ race { + \\ { await wait(0.04s) + \\ emit Mark { k: 2 } } + \\ { await wait(0.1s) + \\ emit Mark { k: 3 } } + \\ } + \\ emit Mark { k: 4 } + \\} + \\@on_event(Mark) + \\rule collect() + \\ when resource Out + \\{ + \\ let o = get_mut(Out) + \\ o.n = o.n * 10 + event.k + \\} + ; + 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").?; + + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 2); // tick 3: fast child emits 2 + try std.testing.expectEqual(@as(i64, 12), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 1); // tick 4: parent resumes, emits 4 + try std.testing.expectEqual(@as(i64, 124), readResourceInt(&world, out_id)); + const tail = try interp.runFor(&world, 6); // through tick 10: loser stays canceled + try std.testing.expectEqual(@as(u64, 0), tail.runtime_errors); + try std.testing.expectEqual(@as(i64, 124), readResourceInt(&world, out_id)); +} + +test "conditional admission + zero-admitted passthrough (M1.0.12 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // Guards are evaluated in the parent's LIVE scope at construct entry: the + // false guard's branch is never admitted (its write never happens); the + // true guard's branch runs. A construct whose every branch is refused + // (and an empty one) does not suspend — the parent continues immediately. + const source = + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let yes = true + \\ let no = false + \\ race { + \\ if no => { let a = get_mut(Out) + \\ a.n = 111 } + \\ if yes => { let b = get_mut(Out) + \\ b.n = 5 } + \\ } + \\ sync { + \\ if no => await wait(0.04s) + \\ } + \\ race { } + \\ let o = get_mut(Out) + \\ o.n = o.n + 100 + \\} + ; + 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: race admits ONLY the `yes` branch (child completes in-pass, + // n=5); the parent is suspended on it (resumes next tick). + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 5), readResourceInt(&world, out_id)); + // tick 2: parent resumes; the zero-admitted `sync` and the empty `race` + // pass through WITHOUT suspending -> +100 lands the same tick. + const r2 = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r2.runtime_errors); + try std.testing.expectEqual(@as(i64, 105), readResourceInt(&world, out_id)); +} + +test "race tie-break: same-tick completions resolve in declaration order (M1.0.12 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // Both branches complete in the same tick (same wait): the winner is the + // FIRST in declaration order -> pick() returns 10, never 20. The loser's + // pending return is discarded. + const source = + \\resource Out { n: int = 0 } + \\async fn pick() -> int { + \\ race { + \\ { await wait(0.04s) + \\ return 10 } + \\ { await wait(0.04s) + \\ return 20 } + \\ } + \\ return 0 + \\} + \\async rule r() + \\ when resource Out + \\{ + \\ let x = await pick() + \\ 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 3: both children resume and complete; tick 4: the parent picks the + // declaration-order winner. + const r = try interp.runFor(&world, 4); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 10), readResourceInt(&world, out_id)); +} + +test "sync joins all branches; a failing branch does not block the join (M1.0.12 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // Three branches: two parallel awaited writes (2 and 5 ticks) and one that + // throws uncaught (fails loud in-pass, harvested, parked canceled). The + // join completes when the two live branches are done — the failed one + // neither blocks nor re-reports. + const source = + \\resource A { n: int = 0 } + \\resource B { n: int = 0 } + \\async rule r() + \\ when resource A and resource B + \\{ + \\ sync { + \\ { await wait(0.04s) + \\ let a = get_mut(A) + \\ a.n = 1 } + \\ { await wait(0.08s) + \\ let b = get_mut(B) + \\ b.n = 2 } + \\ { throw Error { message: "boom", code: .io_fail } } + \\ } + \\ let a2 = get_mut(A) + \\ a2.n = a2.n + 10 + \\} + ; + 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 a_id = world.registry.idOf("A").?; + const b_id = world.registry.idOf("B").?; + + // tick 1: children created; the throwing branch fails loud in-pass + // (1 runtime error), the two awaiters suspend (wakes 3 and 6). + const r1 = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 1), r1.runtime_errors); + // tick 3: A=1. tick 6: B=2 (join condition now holds). + _ = try interp.runFor(&world, 5); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, a_id)); + try std.testing.expectEqual(@as(i64, 2), readResourceInt(&world, b_id)); + // tick 7: the parent joins (failed branch did not block it) -> A=11. + const r7 = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r7.runtime_errors); + try std.testing.expectEqual(@as(i64, 11), readResourceInt(&world, a_id)); +} + +test "race with every branch failing completes; parent resumes after it (M1.0.12 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // Both branches throw uncaught -> both park canceled (2 harvested errors), + // no winner exists — the race still completes and the parent resumes at + // the statement after it. + const source = + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ race { + \\ { throw Error { message: "a", code: .io_fail } } + \\ { throw Error { message: "b", code: .io_fail } } + \\ } + \\ let o = get_mut(Out) + \\ o.n = 7 + \\} + ; + 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: both children fail loud in-pass (2 errors), parent suspended. + const r1 = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 2), r1.runtime_errors); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); + // tick 2: children_any fires with NO winner (none done, none suspended) + // -> the parent resumes after the race. + const r2 = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r2.runtime_errors); + try std.testing.expectEqual(@as(i64, 7), readResourceInt(&world, out_id)); +} + +test "branch scope is a snapshot copy: writes are invisible to the parent (M1.0.12 E4)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // Sec. 9.8 normative: the child starts on a COPY of the parent's scope — + // its rebinds of an inherited local (before and after its own suspension) + // never reach the parent, whose `v` still reads 1 after the join. + const source = + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let mut v = 1 + \\ sync { + \\ { v = 99 + \\ await wait(0.04s) + \\ v = 100 } + \\ } + \\ let o = get_mut(Out) + \\ o.n = v + \\} + ; + 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: entry (child rebinds ITS v to 99, suspends). tick 3: child + // resumes (v -> 100), completes. tick 4: parent joins -> n = parent's v = 1. + const r = try interp.runFor(&world, 4); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); +} + +test "branch is detached: parent continues same tick, task outlives it (M1.0.12 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // Sec. 9.7: fire-and-forget. The parent continues immediately (its write + // lands tick 1) and COMPLETES; the detached child keeps running at the + // origin rule's position and lands its write at its own wake, ticks after + // the parent is gone. + const source = + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ branch { + \\ await wait(0.1s) + \\ let b = get_mut(Out) + \\ b.n = b.n + 50 + \\ } + \\ let o = get_mut(Out) + \\ o.n = 3 + \\} + ; + 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: child created (suspends, wake 7); parent continues -> n=3, done. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 3), readResourceInt(&world, out_id)); + // ticks 2-6: the detached child waits (its parent is long done). + _ = try interp.runFor(&world, 5); + try std.testing.expectEqual(@as(i64, 3), readResourceInt(&world, out_id)); + // tick 7: the detached child resumes at the origin rule's position -> +50. + const r = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 53), readResourceInt(&world, out_id)); +} + +test "spawn handle: cancel() prevents the task from ever running (M1.0.12 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // `h.cancel()` on a just-spawned (suspended) task parks it canceled before + // its first drive — its body never runs. Idempotence is the E1 primitive. + const source = + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let h = spawn { + \\ await wait(0.04s) + \\ let s = get_mut(Out) + \\ s.n = 111 + \\ } + \\ h.cancel() + \\ let o = get_mut(Out) + \\ o.n = 5 + \\} + ; + 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 r = try interp.runFor(&world, 6); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 5), readResourceInt(&world, out_id)); +} + +test "await h joins a running task and resumes after its completion (M1.0.12 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + const source = + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let h = spawn { + \\ await wait(0.04s) + \\ let s = get_mut(Out) + \\ s.n = 7 + \\ } + \\ await h + \\ let o = get_mut(Out) + \\ o.n = o.n + 100 + \\} + ; + 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 3: the task resumes and completes (n=7); the awaiting parent + // (lower index, already visited) joins the NEXT tick. + _ = try interp.runFor(&world, 3); + try std.testing.expectEqual(@as(i64, 7), readResourceInt(&world, out_id)); + // tick 4: parent resumes after the join -> n=107. + const r = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 107), readResourceInt(&world, out_id)); +} + +test "await on an already-done handle resumes immediately, same drive (M1.0.12 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // The monotonic-pool payoff: the husk keeps its state, so `await h` on a + // task done ticks ago delivers the parked (unit) result WITHOUT + // suspending — the +10 lands in the same drive as the resume from `wait`. + const source = + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let h = spawn { + \\ let s = get_mut(Out) + \\ s.n = 1 + \\ } + \\ await wait(0.04s) + \\ await h + \\ let o = get_mut(Out) + \\ o.n = o.n + 10 + \\} + ; + 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: spawn -> child completes in-pass (n=1); parent waits (wake 3). + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + // tick 3: parent resumes from wait; `await h` (done) does NOT suspend -> + // +10 lands the same tick. + const r = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 11), readResourceInt(&world, out_id)); +} + +test "await on a canceled handle fails loud, no silent unit (M1.0.12 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // Sec. 9.8 amended: awaiting a task canceled BEFORE the await is a + // runtime error — the statements after the await never run. + const source = + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let h = spawn { + \\ await wait(0.1s) + \\ } + \\ h.cancel() + \\ await h + \\ let o = get_mut(Out) + \\ o.n = 9 + \\} + ; + 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 r = try interp.runFor(&world, 3); + try std.testing.expectEqual(@as(u64, 1), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); +} + +test "a task canceled WHILE awaited fails the awaiter loud at resume (M1.0.12 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // The awaiter is suspended on task_done; the target is then canceled from + // outside (harness-driven — the Phase-1 surface has no cross-task cancel + // path other than a handle, but the runtime boundary must hold): the + // awaiter fails loud at its resume, never reaching the next statement. + const source = + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let h = spawn { + \\ await wait(0.1s) + \\ } + \\ await h + \\ let o = get_mut(Out) + \\ o.n = 9 + \\} + ; + 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: parent (pool 0) suspends on task_done(1); the spawned task + // (pool 1) suspends on its wait. + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(usize, 2), interp.async_tasks.items.len); + // Cancel the awaited task out from under the awaiter. + interp.cancelTask(1); + // tick 2: the awaiter's wake fires (target no longer suspended) -> its + // resume sees `.canceled` -> fail-loud; n never becomes 9. + const r = try interp.runFor(&world, 2); + try std.testing.expectEqual(@as(u64, 1), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); +} + +test "canceling the parent does not cancel its detached children (M1.0.12 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // The non-transitive boundary (Phase 1, etch-bytecode.md par. 9.5): the + // parent is canceled while its spawned task still waits — the detached + // task is an independent pool entry and completes on schedule; the + // parent's own tail statement never runs. + const source = + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let h = spawn { + \\ await wait(0.08s) + \\ let s = get_mut(Out) + \\ s.n = s.n + 7 + \\ } + \\ await wait(0.2s) + \\ let o = get_mut(Out) + \\ o.n = 999 + \\} + ; + 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: parent (pool 0) suspends on its long wait; spawned task (pool 1) + // suspends on its short one. Cancel the PARENT. + _ = try interp.runFor(&world, 1); + interp.cancelTask(interp.rule_tasks[0].?); + // tick 6: the detached task still completes -> n = 7. + _ = try interp.runFor(&world, 5); + try std.testing.expectEqual(@as(i64, 7), readResourceInt(&world, out_id)); + // Through tick 14 (past the parent's original wake 13): the canceled + // parent never resumes -> n stays 7, no errors. + const r = try interp.runFor(&world, 8); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 7), readResourceInt(&world, out_id)); +} + +test "construct matrix: race nested in a spawn body, joined via handle (M1.0.12 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // spawn { race { fast, slow } } + await h: the spawned task races its own + // grandchildren (fast wins at tick 3, slow canceled at the spawn task's + // resume, tick 4), completes, and the rule-root awaiter joins at tick 5. + const source = + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let h = spawn { + \\ race { + \\ { await wait(0.04s) + \\ let a = get_mut(Out) + \\ a.n = a.n + 1 } + \\ { await wait(0.1s) + \\ let b = get_mut(Out) + \\ b.n = b.n + 500 } + \\ } + \\ } + \\ await h + \\ let o = get_mut(Out) + \\ o.n = o.n + 20 + \\} + ; + 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 3: the fast grandchild lands +1. + _ = try interp.runFor(&world, 3); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + // tick 4: the spawn task resolves its race (slow canceled) and completes; + // tick 5: the rule root joins -> +20. + _ = try interp.runFor(&world, 2); + try std.testing.expectEqual(@as(i64, 21), readResourceInt(&world, out_id)); + // Past the slow branch's original wake: canceled, +500 never lands. + const r = try interp.runFor(&world, 5); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 21), readResourceInt(&world, out_id)); +} + +test "return await regression: the return is never dropped at resume (M1.0.12 E5)" { + const gpa = std.testing.allocator; + + // Regression for the fix-as-you-go E5 fix: a `return await ` + // used to DROP its return at resume (`deliverAwaitValue` no-ops on + // `.return_`) and fall through past the statement. Three forms, each with + // a poison statement AFTER the `return` that must never run, and a caller + // continuation that must run exactly once. + + // (a) `return await wait(...)` in an async fn — resume path, wake-target + // form: the (unit) value resolves at the caller's await site (bind), the + // fn's trailing statements are dead. + { + var world = World.init(); + defer world.deinit(gpa); + const source = + \\resource Out { n: int = 0 } + \\async fn fa() { + \\ return await wait(0.04s) + \\ throw Error { message: "fallthrough", code: ErrorCode.io_fail } + \\} + \\async rule r() + \\ when resource Out + \\{ + \\ let x = await fa() + \\ let o = get_mut(Out) + \\ o.n = o.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").?; + // ticks 1-2: suspended at the return's await (wake 3). + _ = try interp.runFor(&world, 2); + try std.testing.expectEqual(@as(i64, 0), readResourceInt(&world, out_id)); + // tick 3: resume -> the RETURN fires (not a fall-through): fa's + // poison write (111) never runs, the caller continues once -> n = 1. + const r = try interp.runFor(&world, 2); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 1), readResourceInt(&world, out_id)); + } + + // (b) `return await h` on an already-`.done` handle — the immediate path + // (`.signaled` at the await site, no suspension). + { + var world = World.init(); + defer world.deinit(gpa); + const source = + \\event Tick { } + \\resource Out { n: int = 0 } + \\async fn fb() { + \\ let h = spawn { + \\ emit Tick { } + \\ } + \\ await wait(0.04s) + \\ return await h + \\ throw Error { message: "fallthrough", code: ErrorCode.io_fail } + \\} + \\async rule r() + \\ when resource Out + \\{ + \\ await fb() + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\} + \\@on_event(Tick) + \\rule count() + \\ when resource Out + \\{ + \\ let l = get_mut(Out) + \\ l.n = l.n + 10 + \\} + ; + 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: the spawned task completes in-pass (emits Tick -> n=10); + // fb waits (wake 3). + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 10), readResourceInt(&world, out_id)); + // tick 3: fb resumes; `return await h` on the DONE handle returns + // immediately (same drive): the poison throw is dead (0 errors), + // the caller continues once -> 11. + const r = try interp.runFor(&world, 2); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 11), readResourceInt(&world, out_id)); + } + + // (c) `return await h` on a `.suspended` handle — the `.return_` pending + // bind at the task_done resume. + { + var world = World.init(); + defer world.deinit(gpa); + const source = + \\event Tock { } + \\resource Out { n: int = 0 } + \\async fn fc() { + \\ let h = spawn { + \\ await wait(0.04s) + \\ emit Tock { } + \\ } + \\ return await h + \\ throw Error { message: "fallthrough", code: ErrorCode.io_fail } + \\} + \\async rule r() + \\ when resource Out + \\{ + \\ await fc() + \\ let o = get_mut(Out) + \\ o.n = o.n + 1 + \\} + \\@on_event(Tock) + \\rule count() + \\ when resource Out + \\{ + \\ let l = get_mut(Out) + \\ l.n = l.n + 100 + \\} + ; + 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 3: the spawned task completes (emits Tock -> n=100); the rule + // task (awaiting the handle via fc's return) joins the NEXT tick. + _ = try interp.runFor(&world, 3); + try std.testing.expectEqual(@as(i64, 100), readResourceInt(&world, out_id)); + // tick 4: the `.return_` pending bind re-raises the return at resume: + // the poison throw is dead (0 errors), the caller continues once -> 101. + const r = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 101), readResourceInt(&world, out_id)); + } +} + +test "observable: sync parallel-preload over three awaits, documented emit sequence (M1.0.12 E5)" { + const gpa = std.testing.allocator; + var world = World.init(); + defer world.deinit(gpa); + + // The Sec. 9.6 parallel-preload pattern (brief Observable behavior): three + // branches "load" in parallel — all start at tick 1, each emits its Ready + // mark at its own wake — and the parent continues only after ALL of them + // (never between). Documented sequence, digits folded into Out.n: + // tick 1: sync entry — parent emits 9 (start), branches suspend -> 9 + // tick 3 (0.04s = 2 ticks): branch 1 emits 1 -> 91 + // tick 6 (0.08s = round(4.8) = 5): branch 2 emits 2 -> 912 + // tick 7 (0.1s = 6 ticks): branch 3 emits 3 -> 9123 + // tick 8: the parent joins and emits 4 (all-loaded) -> 91234 + const source = + \\event Mark { k: int = 0 } + \\resource Out { n: int = 0 } + \\async rule preload() + \\ when resource Out + \\{ + \\ emit Mark { k: 9 } + \\ sync { + \\ { await wait(0.04s) + \\ emit Mark { k: 1 } } + \\ { await wait(0.08s) + \\ emit Mark { k: 2 } } + \\ { await wait(0.1s) + \\ emit Mark { k: 3 } } + \\ } + \\ emit Mark { k: 4 } + \\} + \\@on_event(Mark) + \\rule collect() + \\ when resource Out + \\{ + \\ let o = get_mut(Out) + \\ o.n = o.n * 10 + event.k + \\} + ; + 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").?; + + _ = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(i64, 9), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 2); // tick 3 + try std.testing.expectEqual(@as(i64, 91), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 3); // tick 6 + try std.testing.expectEqual(@as(i64, 912), readResourceInt(&world, out_id)); + _ = try interp.runFor(&world, 1); // tick 7 + try std.testing.expectEqual(@as(i64, 9123), readResourceInt(&world, out_id)); + // tick 8: the join — the "all loaded" mark lands strictly after every + // branch's own mark. + const r = try interp.runFor(&world, 1); + try std.testing.expectEqual(@as(u64, 0), r.runtime_errors); + try std.testing.expectEqual(@as(i64, 91234), readResourceInt(&world, out_id)); } test "runProgram Optional ops: ??, !, ?., patterns, pop, m[k] (M0.8 E3-C tranche 4)" { diff --git a/src/etch/parser.zig b/src/etch/parser.zig index 410625b..9bf1640 100644 --- a/src/etch/parser.zig +++ b/src/etch/parser.zig @@ -5269,6 +5269,11 @@ pub const Parser = struct { fn startsKeywordStmt(self: *const Parser) bool { return switch (self.peek()) { .kw_let, .kw_assert, .kw_for, .kw_while, .kw_break, .kw_continue, .kw_throw, .kw_try, .kw_return, .kw_emit => true, + // The concurrency statements (M1.0.12 E2, §4.2) — never a block's + // trailing value. `spawn` only in its `spawn {` task form; the + // structural `spawn (` stays on the expression path. + .kw_race, .kw_sync, .kw_branch => true, + .kw_spawn => self.peekNext() == .lbrace, .ident => self.peekNext() == .colon, // labeled loop `outer:` else => false, }; @@ -5356,6 +5361,15 @@ pub const Parser = struct { _ = try self.expect(.eq, "expected '=' in 'if let' binding"); } const cond = try self.parseExprNoStruct(0); + return try self.finishIf(kw_span, let_binding, cond); + } + + /// Finish an `if` whose condition is already parsed (M1.0.12 E2 split): the + /// then-block, the else-if chain, and the node. Lets the `race`/`sync` + /// branch parser resolve `if expr =>` (conditional branch guard) vs + /// `if expr { … }` (an if-statement branch) AFTER the expression — the two + /// only diverge at the token following the condition. + fn finishIf(self: *Parser, kw_span: SourceSpan, let_binding: StringId, cond: NodeId) ParseError!NodeId { const then_block = try self.parseBlockExpr(); var else_branch: NodeId = NodeId.none; if (self.peek() == .kw_else) { @@ -5581,13 +5595,132 @@ pub const Parser = struct { }, .{ .byte_start = kw.span.byte_start, .byte_end = closing.span.byte_end }); } + /// Parse the branch list of a `race` / `sync` body (M1.0.12 E2, + /// `etch-grammar.md` §4.2 `race_branch = [ "if" expression "=>" ] + /// statement`). A branch starting with `if` is ambiguous until after the + /// expression: a following `=>` makes the expression a conditional-branch + /// GUARD (evaluated in the parent scope at construct entry, §9.5) and the + /// branch is the statement after `=>`; anything else means the `if` was + /// itself the branch statement (an if-statement) — finished via `finishIf` + /// with the already-parsed condition. `if let` is always an if-statement + /// branch (the guard grammar has no `let`). Collected into `branches` + /// (caller-owned) and bulk-appended by `addRaceStmt` / `addSyncStmt`. + fn parseConcurrencyBranches(self: *Parser, branches: *std.ArrayListUnmanaged(ast_mod.ConcurrencyBranch)) ParseError!void { + while (self.peek() != .rbrace and self.peek() != .eof) { + try self.surfaceTokenErrors(); + const branch_start = self.current.span; + if (self.peek() == .kw_if and self.peekNext() != .kw_let) { + const kw_span = (try self.advance()).span; // 'if' + const cond = try self.parseExprNoStruct(0); + if (try self.match(.fat_arrow)) { + const stmt = try self.parseStmt(); + try branches.append(self.gpa, .{ + .cond = cond, + .stmt = stmt, + .span = .{ .byte_start = branch_start.byte_start, .byte_end = self.arena.stmtSpan(stmt).byte_end }, + }); + continue; + } + // No `=>` — the `if` is the branch statement itself. + const ife = try self.finishIf(kw_span, 0, cond); + const span = self.arena.exprSpan(ife); + const stmt = try self.arena.addExprStmt(self.gpa, ife, span); + try branches.append(self.gpa, .{ .cond = NodeId.none, .stmt = stmt, .span = span }); + continue; + } + const stmt = try self.parseStmt(); + try branches.append(self.gpa, .{ + .cond = NodeId.none, + .stmt = stmt, + .span = self.arena.stmtSpan(stmt), + }); + } + } + + /// Parse `race "{" { race_branch } "}"` (M1.0.12 E2, `etch-grammar.md` + /// §4.2 `race_stmt`). An empty body parses (zero admitted branches — the + /// parent continues immediately, E4); async-context legality is the + /// type-checker's job (E3). + fn parseRaceStmt(self: *Parser) ParseError!NodeId { + const kw = try self.advance(); // 'race' + _ = try self.expect(.lbrace, "expected '{' to open the race body"); + var branches: std.ArrayListUnmanaged(ast_mod.ConcurrencyBranch) = .empty; + defer branches.deinit(self.gpa); + try self.parseConcurrencyBranches(&branches); + const closing = try self.expect(.rbrace, "expected '}' to close the race body"); + return try self.arena.addRaceStmt(self.gpa, branches.items, .{ + .byte_start = kw.span.byte_start, + .byte_end = closing.span.byte_end, + }); + } + + /// Parse `sync "{" { sync_branch } "}"` (M1.0.12 E2, `etch-grammar.md` + /// §4.2 `sync_stmt`). Same branch grammar as `race`. + fn parseSyncStmt(self: *Parser) ParseError!NodeId { + const kw = try self.advance(); // 'sync' + _ = try self.expect(.lbrace, "expected '{' to open the sync body"); + var branches: std.ArrayListUnmanaged(ast_mod.ConcurrencyBranch) = .empty; + defer branches.deinit(self.gpa); + try self.parseConcurrencyBranches(&branches); + const closing = try self.expect(.rbrace, "expected '}' to close the sync body"); + return try self.arena.addSyncStmt(self.gpa, branches.items, .{ + .byte_start = kw.span.byte_start, + .byte_end = closing.span.byte_end, + }); + } + + /// Parse `branch block` (M1.0.12 E2, `etch-grammar.md` §4.2 `branch_stmt`) + /// — the fire-and-forget task statement. The body is a statement run (the + /// rule-body layout). Only reached from statement position; the + /// quest/dialogue `branch` sub-constructs are parsed inside their own + /// construct parsers (disjoint contexts). + fn parseBranchStmt(self: *Parser) ParseError!NodeId { + const kw = try self.advance(); // 'branch' + _ = try self.expect(.lbrace, "expected '{' to open the branch body"); + const body = try self.parseStmtRun(); + const closing = try self.expect(.rbrace, "expected '}' to close the branch body"); + return try self.arena.addBranchStmt(self.gpa, .{ + .body_start = body.start, + .body_len = body.len, + }, .{ .byte_start = kw.span.byte_start, .byte_end = closing.span.byte_end }); + } + + /// Parse `spawn block` (M1.0.12 E2, `etch-grammar.md` §4.2 `spawn_stmt`), + /// the async task statement. `binding` is the `let` name for the bound form + /// `let h = spawn { }` (dispatched from `parseLetStmt`; `0` = discarded + /// handle); `start_span` is the statement's first token (`let` or `spawn`). + /// The caller has already checked the next tokens are `spawn {`. + fn parseSpawnStmt(self: *Parser, binding: StringId, start_span: SourceSpan) ParseError!NodeId { + _ = try self.advance(); // 'spawn' + _ = try self.expect(.lbrace, "expected '{' to open the spawn task body"); + const body = try self.parseStmtRun(); + const closing = try self.expect(.rbrace, "expected '}' to close the spawn task body"); + return try self.arena.addSpawnStmt(self.gpa, .{ + .binding = binding, + .body_start = body.start, + .body_len = body.len, + }, .{ .byte_start = start_span.byte_start, .byte_end = closing.span.byte_end }); + } + fn parseStmt(self: *Parser) ParseError!NodeId { if (self.peek() == .kw_branch) { - // `branch` graduated to a keyword for quest/dialogue branches - // (M0.8 E4); its async-algebra statement form (T2/T3) stays out - // of M0.8 — keep the fail-loud explicit (it lexed - // `error_unknown_keyword` before the graduation). - return self.parseErr(self.peekSpan(), "the async 'branch' statement is not in M0.8 scope (T2/T3, Phase 2)"); + // The async `branch { }` statement (M1.0.12 E2, §4.2 branch_stmt). + // The quest/dialogue `branch` sub-constructs never reach here — + // they are parsed inside their own construct parsers. + return try self.parseBranchStmt(); + } + if (self.peek() == .kw_race) { + return try self.parseRaceStmt(); + } + if (self.peek() == .kw_sync) { + return try self.parseSyncStmt(); + } + // `spawn {` at statement head is the async task statement (M1.0.12 E2, + // §4.2 spawn_stmt, binding-less form). `spawn (` falls through to the + // expression path (structural spawn, §3.2); the bound form + // `let h = spawn { }` is dispatched inside `parseLetStmt`. + if (self.peek() == .kw_spawn and self.peekNext() == .lbrace) { + return try self.parseSpawnStmt(0, self.peekSpan()); } if (self.peek() == .kw_after) { // `after` graduated to a keyword for routine triggers (M0.8 E4); @@ -5670,6 +5803,20 @@ pub const Parser = struct { type_annotation = try self.parseType(); } _ = try self.expect(.eq, "expected '=' in let binding"); + // `let h = spawn { }` — the bound spawn task statement (M1.0.12 E2, + // §4.2 `spawn_stmt = [ "let" IDENT "=" ] "spawn" block`): the binding + // is PART of the spawn statement, not a let-stmt whose initializer is + // a spawn. The grammar admits neither `mut` nor a type annotation on + // this form (the handle types as the builtin `TaskHandle`, E3). + if (self.peek() == .kw_spawn and self.peekNext() == .lbrace) { + if (is_mut) { + return self.parseErr(self.peekSpan(), "a spawn task binding takes the form 'let IDENT = spawn { ... }' — 'mut' is not part of the spawn_stmt grammar (§4.2)"); + } + if (!type_annotation.isNone()) { + return self.parseErr(self.peekSpan(), "a spawn task binding takes the form 'let IDENT = spawn { ... }' — a type annotation is not part of the spawn_stmt grammar (§4.2)"); + } + return try self.parseSpawnStmt(name_id, let_span); + } const value = try self.parseExpr(0); const span: SourceSpan = .{ .byte_start = let_span.byte_start, @@ -6391,9 +6538,13 @@ pub const Parser = struct { /// `structural_spawn`, M1.0.10). The token after `spawn` disambiguates: /// `spawn (` → STRUCTURAL spawn — `spawn(C1 {…}, …)` component-literal /// varargs, or `spawn("Prefab")` prefab name. - /// `spawn {` → the async task form (§4.2 `spawn_stmt`), owned by M1.0.11 — - /// emit a clear fail-loud diagnostic rather than mis-parsing. - /// This is the seam M1.0.11 fills. + /// `spawn {` → the async task STATEMENT (§4.2 `spawn_stmt`, M1.0.12 E2) + /// — dispatched at statement head (`parseStmt`) and in the + /// `let h = spawn { }` binding form (`parseLetStmt`); it + /// never reaches this expression parser from those sites. + /// Reaching the `{` HERE means `spawn { }` sits in a genuine + /// sub-expression position, which the grammar does not admit + /// (spawn_stmt is a statement) → precise parse error. /// Statement-position only (no body handle, §4.5) is enforced by the /// type-checker (M1.0.10 E2); the parser produces the node in any expression /// position. The prefab form parses + is recognized but is refused at @@ -6401,9 +6552,7 @@ pub const Parser = struct { fn parseStructuralSpawn(self: *Parser) ParseError!NodeId { const kw_span = (try self.advance()).span; // 'spawn' if (self.peek() == .lbrace) { - // The `{` (async) branch — owned by M1.0.11. Fail loud with a precise - // message instead of mis-parsing it as a structural spawn. - return self.parseErr(self.peekSpan(), "the async 'spawn { ... }' task is not yet executable (M1.0.11); only structural 'spawn(...)' is supported in M1.0.10"); + return self.parseErr(self.peekSpan(), "the async 'spawn { ... }' task is a statement (optionally bound: 'let h = spawn { ... }'), not an expression (etch-grammar.md par. 4.2 spawn_stmt)"); } if (self.peek() != .lparen) { return self.parseErrFmt(self.peekSpan(), "expected '(' to open a structural spawn (or '{{' for an async task), got '{s}'", .{self.sliceOf(self.peekSpan())}); @@ -7182,13 +7331,14 @@ test "structural spawn parses a prefab name (M1.0.10)" { try std.testing.expectEqual(@as(u32, 0), ss.args_len); } -test "spawn brace form is the async seam (M1.0.11 diagnostic, M1.0.10)" { +test "spawn { } in a sub-expression position is a parse error (M1.0.12 E2)" { const gpa = std.testing.allocator; - // The async `spawn { }` task form (§4.2) is owned by M1.0.11 — the parser - // emits a clear fail-loud diagnostic rather than mis-parsing it. + // `spawn_stmt` is a STATEMENT (§4.2): statement head and the + // `let h = spawn { }` binding form are its only sites. A `spawn { }` + // nested inside an expression is not in the grammar — precise error. var result = try parse(gpa, \\rule r() { - \\ spawn { } + \\ let x = 1 + spawn { } \\} ); defer result.deinit(gpa); @@ -7202,6 +7352,277 @@ test "spawn brace form is the async seam (M1.0.11 diagnostic, M1.0.10)" { try std.testing.expectEqual(@as(usize, 0), spawns); } +/// Collect the statement kinds of the first rule's body run (M1.0.12 E2 test +/// helper) — the rule body is a `(start, len)` statement run in `arena.extra`. +fn firstRuleBodyKinds(result: *const ParseResult, buf: []ast_mod.StmtKind) []ast_mod.StmtKind { + var rule: ast_mod.RuleDecl = undefined; + var found = false; + for (result.ast.items.items(.kind), 0..) |k, i| { + if (k == .rule_decl) { + rule = result.ast.rule_decls.items[result.ast.items.items(.data)[i]]; + found = true; + break; + } + } + std.debug.assert(found); + var n: usize = 0; + while (n < rule.body_len and n < buf.len) : (n += 1) { + const sid: NodeId = @bitCast(result.ast.extra.items[rule.body_start + n]); + buf[n] = result.ast.stmtKind(sid); + } + return buf[0..n]; +} + +test "race/sync parse: branches, conditional branches, empty body (M1.0.12 E2)" { + const gpa = std.testing.allocator; + var result = try parse(gpa, + \\rule r() { + \\ race { + \\ await wait(1.0s) + \\ if hard => { await wait(2.0s) } + \\ { await wait(3.0s) + \\ return } + \\ } + \\ sync { + \\ await wait(1.0s) + \\ if extra => await wait(4.0s) + \\ } + \\ race { } + \\ sync { } + \\} + ); + defer result.deinit(gpa); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + var buf: [8]ast_mod.StmtKind = undefined; + const kinds = firstRuleBodyKinds(&result, &buf); + try std.testing.expectEqualSlices(ast_mod.StmtKind, &.{ .race_stmt, .sync_stmt, .race_stmt, .sync_stmt }, kinds); + // First race: 3 branches — unconditional await, guarded block, plain block. + const race0 = result.ast.race_stmts.items[0]; + try std.testing.expectEqual(@as(u32, 3), race0.branches_len); + const b = result.ast.concurrency_branches.items; + try std.testing.expect(b[race0.branches_start].cond.isNone()); + try std.testing.expectEqual(ast_mod.StmtKind.expr_stmt, result.ast.stmtKind(b[race0.branches_start].stmt)); + try std.testing.expect(!b[race0.branches_start + 1].cond.isNone()); // `if hard =>` + try std.testing.expect(b[race0.branches_start + 2].cond.isNone()); + // First sync: 2 branches, second guarded with a non-block statement. + const sync0 = result.ast.sync_stmts.items[0]; + try std.testing.expectEqual(@as(u32, 2), sync0.branches_len); + try std.testing.expect(!b[sync0.branches_start + 1].cond.isNone()); + // Empty bodies: zero branches, no error. + try std.testing.expectEqual(@as(u32, 0), result.ast.race_stmts.items[1].branches_len); + try std.testing.expectEqual(@as(u32, 0), result.ast.sync_stmts.items[1].branches_len); +} + +test "race branch starting with an if-statement is unconditional (M1.0.12 E2)" { + const gpa = std.testing.allocator; + // `if cond { … }` at branch head is the branch STATEMENT (an if-statement), + // not a conditional-branch guard — the token after the expression (`{` vs + // `=>`) disambiguates. `if let` likewise. + var result = try parse(gpa, + \\rule r() { + \\ race { + \\ if ready { await wait(1.0s) } + \\ if let v = opt { await wait(2.0s) } + \\ } + \\} + ); + defer result.deinit(gpa); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + const race0 = result.ast.race_stmts.items[0]; + try std.testing.expectEqual(@as(u32, 2), race0.branches_len); + const b = result.ast.concurrency_branches.items; + // Both branches are UNCONDITIONAL if-statement branches. + try std.testing.expect(b[race0.branches_start].cond.isNone()); + try std.testing.expect(b[race0.branches_start + 1].cond.isNone()); + for (0..2) |i| { + const stmt = b[race0.branches_start + i].stmt; + try std.testing.expectEqual(ast_mod.StmtKind.expr_stmt, result.ast.stmtKind(stmt)); + const e: NodeId = @bitCast(result.ast.stmtData(stmt)); + try std.testing.expectEqual(ast_mod.ExprKind.if_expr, result.ast.exprKind(e)); + } + // The `if let` branch kept its binding. + const e1: NodeId = @bitCast(result.ast.stmtData(b[race0.branches_start + 1].stmt)); + const ife = result.ast.if_exprs.items[result.ast.exprData(e1)]; + try std.testing.expect(ife.let_binding != 0); +} + +test "branch/spawn statements parse incl. binding + empty bodies (M1.0.12 E2)" { + const gpa = std.testing.allocator; + var result = try parse(gpa, + \\rule r() { + \\ branch { + \\ await wait(30.0s) + \\ emit Reminder { } + \\ } + \\ spawn { } + \\ let h = spawn { + \\ await wait(1.0s) + \\ } + \\ branch { } + \\} + ); + defer result.deinit(gpa); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + var buf: [8]ast_mod.StmtKind = undefined; + const kinds = firstRuleBodyKinds(&result, &buf); + try std.testing.expectEqualSlices(ast_mod.StmtKind, &.{ .branch_stmt, .spawn_stmt, .spawn_stmt, .branch_stmt }, kinds); + // First branch: two body statements. Last branch: empty body. + try std.testing.expectEqual(@as(u32, 2), result.ast.branch_stmts.items[0].body_len); + try std.testing.expectEqual(@as(u32, 0), result.ast.branch_stmts.items[1].body_len); + // Bare spawn: no binding, empty body. Bound spawn: binding `h`, one stmt. + try std.testing.expectEqual(@as(u32, 0), result.ast.spawn_stmts.items[0].binding); + try std.testing.expectEqual(@as(u32, 0), result.ast.spawn_stmts.items[0].body_len); + try std.testing.expectEqualStrings("h", result.ast.strings.slice(result.ast.spawn_stmts.items[1].binding)); + try std.testing.expectEqual(@as(u32, 1), result.ast.spawn_stmts.items[1].body_len); + // No let-stmt was produced for the bound form (the binding is part of the + // spawn statement), and no structural spawn node either. + try std.testing.expectEqual(@as(usize, 0), result.ast.let_stmts.items.len); + var spawns: usize = 0; + for (result.ast.exprs.items(.kind)) |k| { + if (k == .spawn_struct) spawns += 1; + } + try std.testing.expectEqual(@as(usize, 0), spawns); +} + +test "spawn ( stays structural next to spawn { task form (M1.0.12 E2)" { + const gpa = std.testing.allocator; + // Token-based disambiguation (§3.2 note): `spawn (` → structural expr + // (M1.0.10 surface untouched), `spawn {` → async task statement. + var result = try parse(gpa, + \\component Pos { x: int = 0 } + \\rule r() { + \\ spawn(Pos { x: 1 }) + \\ spawn { } + \\} + ); + defer result.deinit(gpa); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + var spawns: usize = 0; + for (result.ast.exprs.items(.kind)) |k| { + if (k == .spawn_struct) spawns += 1; + } + try std.testing.expectEqual(@as(usize, 1), spawns); + try std.testing.expectEqual(@as(usize, 1), result.ast.spawn_stmts.items.len); +} + +test "nested concurrency constructs parse (M1.0.12 E2)" { + const gpa = std.testing.allocator; + // A `race` inside a `sync` branch block, a `spawn` inside a `branch` body, + // and a `branch` inside a race branch — the shared branch slab stays + // contiguous per statement (the `match_arms` precedent). + var result = try parse(gpa, + \\rule r() { + \\ sync { + \\ { race { + \\ await wait(1.0s) + \\ await wait(2.0s) + \\ } } + \\ await wait(3.0s) + \\ } + \\ branch { + \\ spawn { + \\ branch { } + \\ } + \\ } + \\} + ); + defer result.deinit(gpa); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + // Inner race: 2 branches; outer sync: 2 branches; each run contiguous. + try std.testing.expectEqual(@as(usize, 1), result.ast.race_stmts.items.len); + try std.testing.expectEqual(@as(usize, 1), result.ast.sync_stmts.items.len); + try std.testing.expectEqual(@as(u32, 2), result.ast.race_stmts.items[0].branches_len); + try std.testing.expectEqual(@as(u32, 2), result.ast.sync_stmts.items[0].branches_len); + try std.testing.expectEqual(@as(usize, 4), result.ast.concurrency_branches.items.len); + // The nested race (parsed first) owns the first run. + try std.testing.expectEqual(@as(u32, 0), result.ast.race_stmts.items[0].branches_start); + try std.testing.expectEqual(@as(u32, 2), result.ast.sync_stmts.items[0].branches_start); + // branch { spawn { branch { } } } — two branch stmts, one spawn stmt. + try std.testing.expectEqual(@as(usize, 2), result.ast.branch_stmts.items.len); + try std.testing.expectEqual(@as(usize, 1), result.ast.spawn_stmts.items.len); +} + +test "async branch statement coexists with quest/dialogue branches (M1.0.12 E2)" { + const gpa = std.testing.allocator; + // `kw_branch` serves three disjoint parse contexts: the quest branch + // (inside `parseQuestBranch`), the dialogue branch (inside + // `parseDialogueElems`), and the async statement at general statement + // head. One program using all three parses clean. + var result = try parse(gpa, + \\quest Hunt { + \\ stage Track { + \\ objective main find: player_found_tracks() + \\ branch Peaceful { + \\ stage Talk { + \\ objective main talk: npc_talked() + \\ } + \\ } + \\ } + \\} + \\dialogue Meeting { + \\ speaker "Guide" { + \\ line: "Welcome." + \\ } + \\ branch small_talk { + \\ speaker "Guide" { + \\ line: "Nice weather." + \\ } + \\ } + \\} + \\rule r() { + \\ branch { + \\ await wait(1.0s) + \\ } + \\} + ); + defer result.deinit(gpa); + if (result.diagnostics.len > 0) { + std.debug.print("unexpected parse diagnostic: {s}\n", .{result.diagnostics[0].primary_message}); + try std.testing.expect(false); + } + // The async statement produced exactly one BranchStmt; the quest/dialogue + // branches live in their construct sub-ASTs, not in `branch_stmts`. + try std.testing.expectEqual(@as(usize, 1), result.ast.branch_stmts.items.len); + try std.testing.expectEqual(@as(u32, 1), result.ast.branch_stmts.items[0].body_len); +} + +test "spawn binding form rejects mut and type annotation (M1.0.12 E2)" { + const gpa = std.testing.allocator; + // §4.2: `spawn_stmt = [ "let" IDENT "=" ] "spawn" block` — no `mut`, no + // type annotation on the binding. + var r1 = try parse(gpa, + \\rule r() { + \\ let mut h = spawn { } + \\} + ); + defer r1.deinit(gpa); + try std.testing.expect(r1.diagnostics.len > 0); + try std.testing.expectEqual(diag_mod.DiagnosticCode.parse_error, r1.diagnostics[0].code); + var r2 = try parse(gpa, + \\rule r() { + \\ let h: int = spawn { } + \\} + ); + defer r2.deinit(gpa); + try std.testing.expect(r2.diagnostics.len > 0); + try std.testing.expectEqual(diag_mod.DiagnosticCode.parse_error, r2.diagnostics[0].code); +} + test "parser builds loops, labels, break value, and continue (M0.8 loop/break)" { const gpa = std.testing.allocator; var result = try parse(gpa, diff --git a/src/etch/token.zig b/src/etch/token.zig index 5a99b48..b281b8c 100644 --- a/src/etch/token.zig +++ b/src/etch/token.zig @@ -110,7 +110,9 @@ pub const TokenKind = enum { kw_const, // top-level `const` declaration (M1.0.8 — graduated from non_s3_keywords; top-level only per part1 §4.5) kw_private, // `private` visibility modifier prefix on a declaration_body (M1.0.8 — graduated from non_s3_keywords; grammar §5.1) kw_test, // top-level `test "name" { ... }` block (M1.0.8 — graduated from non_s3_keywords; parse + validate only, no execution) - kw_spawn, // structural spawn expr `spawn(C{…})` (M1.0.10 — graduated from non_s3_keywords; §3.2 structural_spawn. The async `spawn { }` task form §4.2 stays M1.0.11, fail-loud at parse) + kw_spawn, // structural spawn expr `spawn(C{…})` (M1.0.10, §3.2 structural_spawn) + the async task statement `[let IDENT =] spawn { }` (M1.0.12, §4.2 spawn_stmt) — disambiguated by the next token + kw_race, // race statement `race { race_branch* }` (M1.0.12 — graduated from non_s3_keywords; §4.2 race_stmt) + kw_sync, // sync statement `sync { sync_branch* }` (M1.0.12 — graduated from non_s3_keywords; §4.2 sync_stmt) // ── Primitive type keywords (lexed as kw_type_*) ── kw_int, @@ -279,6 +281,8 @@ pub const s3_keywords = [_]KeywordEntry{ .{ .lexeme = "private", .kind = .kw_private }, .{ .lexeme = "test", .kind = .kw_test }, .{ .lexeme = "spawn", .kind = .kw_spawn }, + .{ .lexeme = "race", .kind = .kw_race }, + .{ .lexeme = "sync", .kind = .kw_sync }, .{ .lexeme = "true", .kind = .bool_literal }, .{ .lexeme = "false", .kind = .bool_literal }, .{ .lexeme = "int", .kind = .kw_int }, @@ -315,17 +319,11 @@ pub const non_s3_keywords = [_][]const u8{ // overridable module (cf. `engine-phase-1-plan.md`) ── "override", - // ── Async machinery: `async` graduated with M0.8 E2 (`async fn` parsed; - // interp E3, codegen Phase 2); `await` graduated with M0.8 E3 sub-slice B - // (`async rule`/`async fn` + `await` interpreted, codegen Phase 2); - // `spawn` graduated with M1.0.10 — the STRUCTURAL `spawn(C{…})` expr - // (§3.2 structural_spawn) now lexes as `kw_spawn` (the async `spawn { }` - // task form §4.2 stays M1.0.11, fail-loud at parse). The remaining - // concurrency algebra (`race`/`sync`) stays reserved (T2/T3, flagged - // for Review E3); `branch` graduated with the E4 quest slice (its async - // statement form keeps an explicit fail-loud parse error) ── - "race", - "sync", + // ── Async machinery: fully graduated. `async` with M0.8 E2, `await` with + // M0.8 E3 sub-slice B, `spawn` with M1.0.10 (structural expr) then + // M1.0.12 (async task statement — disambiguated by the next token), + // `branch` with the M0.8 E4 quest slice (async statement form M1.0.12), + // `race` / `sync` with M1.0.12 (concurrency algebra, §4.2) ── // ── Timers / lifecycle (out of S3; `emit` graduated with E3 ECS layer; // `after` graduated with E4 routine triggers — the §4.3 timer @@ -386,11 +384,11 @@ test "const/private/test graduate to s3 keywords" { try std.testing.expect(isKeywordToken(.kw_test)); } -test "spawn graduates to s3 keyword; race/sync stay reserved" { +test "spawn graduates to s3 keyword (M1.0.10)" { // M1.0.10: `spawn` moves from the reserve list into `s3_keywords`, mapped // to `kw_spawn`. The structural `spawn(C{…})` expr now lexes to a real - // keyword so the parser can dispatch it (the async `spawn { }` task form - // is fail-loud at parse, M1.0.11). `race` / `sync` stay reserved. + // keyword so the parser can dispatch it. (The async `spawn { }` task form + // shares the keyword since M1.0.12 — next-token disambiguation.) const T = struct { fn s3Kind(lexeme: []const u8) ?TokenKind { for (s3_keywords) |kw| { @@ -407,12 +405,42 @@ test "spawn graduates to s3 keyword; race/sync stay reserved" { }; try std.testing.expectEqual(TokenKind.kw_spawn, T.s3Kind("spawn").?); try std.testing.expect(!T.reserved("spawn")); - // The rest of the concurrency algebra stays reserved. - try std.testing.expect(T.s3Kind("race") == null); - try std.testing.expect(T.reserved("race")); - try std.testing.expect(T.s3Kind("sync") == null); - try std.testing.expect(T.reserved("sync")); // `kw_spawn` sits inside the contiguous keyword range (tag-path contextual // acceptance via `isKeywordToken`). try std.testing.expect(isKeywordToken(.kw_spawn)); } + +test "race/sync graduate to s3 keywords (M1.0.12 E2)" { + // M1.0.12: `race` / `sync` move from the reserve list into `s3_keywords`, + // mapped to `kw_race` / `kw_sync` — the concurrency-algebra statements + // (§4.2) become parseable. `override` remains the last reserved top-level + // construct keyword (waits for a Tier-1 overridable module); the timer + // family (`every` / `after_unscaled` / `quantize`) waits for M1.0.13. + const T = struct { + fn s3Kind(lexeme: []const u8) ?TokenKind { + for (s3_keywords) |kw| { + if (std.mem.eql(u8, kw.lexeme, lexeme)) return kw.kind; + } + return null; + } + fn reserved(lexeme: []const u8) bool { + for (non_s3_keywords) |kw| { + if (std.mem.eql(u8, kw, lexeme)) return true; + } + return false; + } + }; + try std.testing.expectEqual(TokenKind.kw_race, T.s3Kind("race").?); + try std.testing.expectEqual(TokenKind.kw_sync, T.s3Kind("sync").?); + try std.testing.expect(!T.reserved("race")); + try std.testing.expect(!T.reserved("sync")); + // `override` stays reserved; the timers stay reserved until M1.0.13. + try std.testing.expect(T.reserved("override")); + try std.testing.expect(T.reserved("every")); + try std.testing.expect(T.reserved("after_unscaled")); + try std.testing.expect(T.reserved("quantize")); + // Graduated keywords sit inside the contiguous keyword range (tag-path + // contextual acceptance via `isKeywordToken`). + try std.testing.expect(isKeywordToken(.kw_race)); + try std.testing.expect(isKeywordToken(.kw_sync)); +} diff --git a/src/etch/types.zig b/src/etch/types.zig index a047957..4043c6a 100644 --- a/src/etch/types.zig +++ b/src/etch/types.zig @@ -48,6 +48,12 @@ pub const BuiltinType = enum { /// (the Error layer) `string` is accepted on `struct` fields and resolves /// as a declared type through `namedTypeToResolved`'s special case. string_, + /// `TaskHandle` (§2.2 builtin, M1.0.12 E3) — the value of a bound spawn + /// task statement (`let h = spawn { }`). Non-POD (`etch-grammar.md` §2.2): + /// like `string_`/`time` it is deliberately NOT in `fromName`, so it stays + /// rejected as a component/resource field type. `h.cancel()` and `await h` + /// are its only operations (§9.8). + task_handle, pub fn isNumeric(self: BuiltinType) bool { return switch (self) { @@ -328,6 +334,36 @@ pub const TypeChecker = struct { /// `async fn`/`async method`, in a NON-async context is E0901 /// `AsyncCallInNonAsyncContext`. `false` outside any body. current_is_async: bool = false, + /// The kind of the INNERMOST `race`/`sync` branch or `branch`/`spawn` body + /// enclosing the statements being checked (M1.0.12 E3), `null` outside any. + /// Drives E0906 (a `return` is legal only in a `race` branch — + /// winner-return propagation §9.5; `sync`/`branch`/`spawn` reject it) and + /// E0907 (a `break`/`continue` must not cross the task boundary §9.2). + /// Saved/restored at each branch entry (innermost wins). + conc_branch: ?ConcBranchKind = null, + /// Loop-nesting depth accumulated INSIDE the innermost concurrency branch + /// (M1.0.12 E3): >0 means an unlabeled `break`/`continue` targets an + /// in-branch loop (legal); 0 means it would escape the task → E0907. + /// Reset to 0 at branch entry (saved/restored); incremented by the + /// `for`/`while` statement arms and `synthLoop`. + conc_loop_depth: u32 = 0, + /// Stack of the labels of every labeled loop currently open (M1.0.12 E3), + /// pushed/popped by `synthLoop`. Only the window past `conc_labels_base` + /// belongs to the innermost concurrency branch — a labeled + /// `break`/`continue` whose label is outside that window targets a loop + /// beyond the task boundary → E0907. + conc_labels: std.ArrayListUnmanaged(StringId) = .empty, + /// Start of the innermost branch's label window in `conc_labels` + /// (M1.0.12 E3). Saved/restored at branch entry. + conc_labels_base: usize = 0, + /// The direct-call node consumed by the `await` currently being typed + /// (M1.0.12 E3): the free-fn/method call sites skip E0905 for it. Set + /// around the future-form arg synthesis; `NodeId.none` otherwise. The + /// `await` is the SOLE call-grain consumer of the `{async}` effect + /// (`etch-resolver-types.md` §9.2, revision 2): the four concurrency + /// constructs relocate the suspension into a child task — their bodies + /// are ordinary async contexts where E0905 applies recursively. + awaited_call: 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, @@ -365,6 +401,9 @@ pub const TypeChecker = struct { /// second occurrence of a UUID is E1782. Both sets' keys reference the /// arenas' string pools, which `root.validateProject` keeps alive for the /// duration of the checks. + /// The four concurrency-branch contexts (M1.0.12 E3) — see `conc_branch`. + pub const ConcBranchKind = enum { race, sync, branch, spawn }; + /// Visibility of an exported symbol (M1.0.7 E5, D-G). Since `private` /// graduated (M1.0.8) the exports builder sets `.private` from the decl's /// `Item.visibility`, making the binding path's `E0107` check reachable. @@ -416,6 +455,7 @@ pub const TypeChecker = struct { self.symbols.deinit(self.gpa); self.methods.deinit(self.gpa); self.trait_impls.deinit(self.gpa); + self.conc_labels.deinit(self.gpa); self.generic_scope.deinit(self.gpa); self.imported_symbols.deinit(self.gpa); self.imported_aliases.deinit(self.gpa); @@ -3277,6 +3317,12 @@ pub const TypeChecker = struct { } else { try self.emit(.undefined_symbol, .error_, tspan, "type 'string' is not in the S3 builtin set", .{}); } + } else if (std.mem.eql(u8, tname, "TaskHandle")) { + // M1.0.12 E3 — `TaskHandle` is a non-POD builtin + // (`etch-grammar.md` §2.2): like `string` on components, + // it is rejected as a field type everywhere (a task + // handle is a live runtime identity, never stored state). + try self.emit(.undefined_symbol, .error_, tspan, "type 'TaskHandle' is non-POD (etch-grammar.md par. 2.2) and cannot be a field type", .{}); } else { try self.emit(.undefined_symbol, .error_, tspan, "unknown type '{s}'", .{tname}); } @@ -4251,6 +4297,11 @@ pub const TypeChecker = struct { } try ctx.locals.put(self.gpa, f.var_name, .{ .type_ = elem_t, .is_mut = false }); } + // M1.0.12 E3 — a loop opened here is INSIDE any enclosing + // concurrency branch: its `break`/`continue` are legal (E0907 + // fires only on the boundary-crossing ones). + self.conc_loop_depth += 1; + defer self.conc_loop_depth -= 1; var i: u32 = 0; while (i < f.body_len) : (i += 1) { const body_stmt: NodeId = @bitCast(self.arena.extra.items[f.body_start + i]); @@ -4269,6 +4320,9 @@ pub const TypeChecker = struct { } else if (cond_t == .builtin and cond_t.builtin != .bool_) { try self.emit(.type_mismatch, .error_, self.arena.exprSpan(wh.cond), "while condition must be a bool expression", .{}); } + // M1.0.12 E3 — in-branch loop, mirror of the `for` arm. + self.conc_loop_depth += 1; + defer self.conc_loop_depth -= 1; var i: u32 = 0; while (i < wh.body_len) : (i += 1) { try self.checkStmt(ctx, @bitCast(self.arena.extra.items[wh.body_start + i])); @@ -4280,8 +4334,15 @@ pub const TypeChecker = struct { // present; loop-membership / label validity is permissive in E1. const b = self.arena.break_stmts.items[data]; if (!b.value.isNone()) _ = self.synthExpr(b.value, ctx); + // M1.0.12 E3 — E0907: the break must not cross the enclosing + // concurrency-branch task boundary (§9.2). + try self.checkConcControlFlow(stmt_id, b.label, "break"); + }, + .continue_stmt => { + // M1.0.12 E3 — E0907, mirror of `break` (the label rides in + // the statement's `data`; `0` = unlabeled). + try self.checkConcControlFlow(stmt_id, data, "continue"); }, - .continue_stmt => {}, .throw_stmt => { // `throw expression` (M0.8 error handling, E3-C tranche 2). // The thrown value must be the builtin `Error` struct — part1 @@ -4320,6 +4381,19 @@ pub const TypeChecker = struct { // consistent with the closure-call / trailing-value checks). A // bare `return` is valid in a void fn; a `return` with no // enclosing fn (e.g. a rule body) is permissive. + // + // M1.0.12 E3 — E0906: inside a concurrency branch, `return` is + // legal ONLY in a `race` branch (winner-return propagation at + // the race site, §9.5); in a `sync` branch an early return + // contradicts the join-all, and in a `branch`/`spawn` body the + // parent has potentially already advanced — no propagation + // site (`etch-resolver-types.md` §9.2). Asymmetric by design + // (brief Notes) — do NOT generalize. + if (self.conc_branch) |ck| { + if (ck != .race) { + try self.emit(.illegal_return_in_concurrency_branch, .error_, self.arena.stmtSpan(stmt_id), "'return' is illegal in a '{s}' {s} (only a 'race' branch may return — the winner's return propagates at the race site)", .{ @tagName(ck), if (ck == .sync) "branch" else "body" }); + } + } const value: NodeId = @bitCast(data); if (!value.isNone()) { const vt = self.synthHeadValue(value, ctx); @@ -4383,10 +4457,138 @@ pub const TypeChecker = struct { } try self.validateTagMutationPath(tm.path); }, + .race_stmt, .sync_stmt => { + // `race { race_branch* }` / `sync { sync_branch* }` (M1.0.12 + // E3, §4.2). Async-context requirement first (E0901 — §4.2: + // the async constructs are only available in an async + // context). Each conditional guard (`if cond =>`) types as bool + // in the PARENT scope (it is evaluated there, synchronously, + // at construct entry — §9.5); each branch statement is then + // checked inside its branch context (E0906/E0907; E0905 keeps + // applying recursively — §9.2 revision 2). + if (!self.current_is_async) { + try self.emit(.async_call_in_non_async_context, .error_, self.arena.stmtSpan(stmt_id), "'{s}' is only allowed in an 'async fn' or 'async rule'", .{if (kind == .race_stmt) "race" else "sync"}); + } + const range: ast_mod.RaceStmt = if (kind == .race_stmt) + self.arena.race_stmts.items[data] + else blk: { + const ss = self.arena.sync_stmts.items[data]; + break :blk .{ .branches_start = ss.branches_start, .branches_len = ss.branches_len }; + }; + const branch_kind: ConcBranchKind = if (kind == .race_stmt) .race else .sync; + var i: u32 = 0; + while (i < range.branches_len) : (i += 1) { + const br = self.arena.concurrency_branches.items[range.branches_start + i]; + if (!br.cond.isNone()) { + const cond_t = self.synthExpr(br.cond, ctx); + if (cond_t == .builtin and cond_t.builtin != .bool_) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(br.cond), "conditional branch guard must be a bool expression", .{}); + } + } + try self.checkConcBranchStmt(ctx, br.stmt, branch_kind); + } + }, + .branch_stmt => { + // `branch { }` (M1.0.12 E3, §4.2) — fire-and-forget detached + // task. Async context required (E0901); the body is checked + // inside a `.branch` context (return → E0906, escaping + // break/continue → E0907; E0905 keeps applying recursively). + if (!self.current_is_async) { + try self.emit(.async_call_in_non_async_context, .error_, self.arena.stmtSpan(stmt_id), "'branch' is only allowed in an 'async fn' or 'async rule'", .{}); + } + const bs = self.arena.branch_stmts.items[data]; + try self.checkConcBodyRun(ctx, bs.body_start, bs.body_len, .branch); + }, + .spawn_stmt => { + // `[let h =] spawn { }` (M1.0.12 E3, §4.2) — detached task + // with a handle. Async context required (E0901); body checked + // inside a `.spawn` context. The binding types as the builtin + // `TaskHandle` (§2.2, non-POD) and is bound AFTER the body: + // the task starts on a snapshot of the parent scope taken + // before the parent binds the handle (§9.8) — the handle does + // not exist inside the body. + if (!self.current_is_async) { + try self.emit(.async_call_in_non_async_context, .error_, self.arena.stmtSpan(stmt_id), "'spawn {{ ... }}' is only allowed in an 'async fn' or 'async rule'", .{}); + } + const ss = self.arena.spawn_stmts.items[data]; + try self.checkConcBodyRun(ctx, ss.body_start, ss.body_len, .spawn); + if (ss.binding != 0) { + try ctx.locals.put(self.gpa, ss.binding, .{ .type_ = .{ .builtin = .task_handle }, .is_mut = false }); + } + }, else => {}, } } + /// Enter a concurrency-branch context around one `race`/`sync` branch + /// statement (M1.0.12 E3): E0906/E0907 anchor to the INNERMOST branch and + /// the in-branch loop depth and label window restart at the boundary. The + /// branch body stays an ORDINARY async context otherwise — E0905 applies + /// recursively inside it (§9.2 revision 2: the constructs relocate the + /// `await`, they do not replace it). + fn checkConcBranchStmt(self: *TypeChecker, ctx: *RuleCtx, stmt: NodeId, kind: ConcBranchKind) TypeError!void { + const saved_branch = self.conc_branch; + const saved_depth = self.conc_loop_depth; + const saved_base = self.conc_labels_base; + self.conc_branch = kind; + self.conc_loop_depth = 0; + self.conc_labels_base = self.conc_labels.items.len; + defer { + self.conc_branch = saved_branch; + self.conc_loop_depth = saved_depth; + self.conc_labels_base = saved_base; + } + try self.checkStmt(ctx, stmt); + } + + /// `checkConcBranchStmt` for a `branch`/`spawn` BODY (a statement run in + /// `arena.extra`) — same context discipline, applied around the whole run. + fn checkConcBodyRun(self: *TypeChecker, ctx: *RuleCtx, start: u32, len: u32, kind: ConcBranchKind) TypeError!void { + const saved_branch = self.conc_branch; + const saved_depth = self.conc_loop_depth; + const saved_base = self.conc_labels_base; + self.conc_branch = kind; + self.conc_loop_depth = 0; + self.conc_labels_base = self.conc_labels.items.len; + defer { + self.conc_branch = saved_branch; + self.conc_loop_depth = saved_depth; + self.conc_labels_base = saved_base; + } + var i: u32 = 0; + while (i < len) : (i += 1) { + try self.checkStmt(ctx, @bitCast(self.arena.extra.items[start + i])); + } + } + + /// True when an async call at `id` has its `{async}` effect consumed + /// (M1.0.12 E3, `etch-resolver-types.md` §9.2 revision 2): it is the + /// direct target of the `await` currently being typed — the SOLE + /// call-grain consumer. The four concurrency constructs do NOT consume + /// it: they relocate the suspension into a child task, so a bare async + /// call inside their bodies is E0905 like anywhere else (at execution it + /// would hit the sync call path's `is_async` fail-loud gate). + fn consumesAsyncEffect(self: *const TypeChecker, id: NodeId) bool { + return @as(u32, @bitCast(id)) == @as(u32, @bitCast(self.awaited_call)); + } + + /// E0907 (M1.0.12 E3, `etch-resolver-types.md` §9.2): a `break`/`continue` + /// inside a concurrency branch must target a loop INSIDE the branch — an + /// unlabeled one needs an in-branch loop (depth > 0); a labeled one needs + /// its label among the loops opened inside the branch (the label window + /// past `conc_labels_base`). Loops fully inside the branch keep working. + fn checkConcControlFlow(self: *TypeChecker, stmt_id: NodeId, label: StringId, comptime what: []const u8) !void { + if (self.conc_branch == null) return; + if (label == 0) { + if (self.conc_loop_depth > 0) return; + } else { + for (self.conc_labels.items[self.conc_labels_base..]) |l| { + if (l == label) return; + } + } + try self.emit(.control_flow_escapes_task_branch, .error_, self.arena.stmtSpan(stmt_id), "'" ++ what ++ "' targets a loop outside the enclosing concurrency branch — control flow cannot cross the task boundary", .{}); + } + /// Validate a tag-mutation operand path against the global tag table (M0.8 /// E3, `etch-grammar.md` §4.4). `add_tag` / `remove_tag` set or clear a /// single bit, so the path must resolve to a declared **leaf**; an unknown @@ -4666,7 +4868,27 @@ pub const TypeChecker = struct { 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); + if (aw.target_kind == .future) { + // The direct call is CONSUMED by this await (M1.0.12 E3, + // §9.2) — exempt from E0905 while it is synthesized. Only + // the target itself is exempt: an async call among its + // ARGUMENTS is still bare. + const saved_awaited = self.awaited_call; + self.awaited_call = aw.arg_expr; + defer self.awaited_call = saved_awaited; + const t = try self.synthExprE(aw.arg_expr, ctx_opt); + const ak = self.arena.exprKind(aw.arg_expr); + if (ak == .fn_call or ak == .method_call) return t; + // Non-call target: the handle-await form (M1.0.12 E3, + // §9.8) — the target must be a TaskHandle. The result is + // unit in Phase 1 (spawn bodies have no value channel — + // brief Notes); `unknown` ≈ unit, the house convention. + if (t == .builtin and t.builtin == .task_handle) return ResolvedType.unknown; + if (t != .unknown) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(aw.arg_expr), "await target must be a direct async call or a TaskHandle", .{}); + } + return ResolvedType.unknown; + } return ResolvedType.unknown; }, .paren => unreachable, // parser doesn't emit a paren node — it returns the inner expr @@ -4770,6 +4992,16 @@ pub const TypeChecker = struct { fn synthLoop(self: *TypeChecker, data: u32, ctx_opt: ?*RuleCtx) TypeError!ResolvedType { const lp = self.arena.loop_exprs.items[data]; if (ctx_opt) |ctx| { + // M1.0.12 E3 — a `loop` opened here (incl. a labeled one) is + // INSIDE any enclosing concurrency branch: its `break`/`continue` + // (by depth or by label) are legal; E0907 fires only on the + // boundary-crossing ones. + self.conc_loop_depth += 1; + defer self.conc_loop_depth -= 1; + if (lp.label != 0) try self.conc_labels.append(self.gpa, lp.label); + defer if (lp.label != 0) { + _ = self.conc_labels.pop(); + }; var i: u32 = 0; while (i < lp.body_len) : (i += 1) { const stmt: NodeId = @bitCast(self.arena.extra.items[lp.body_start + i]); @@ -5007,6 +5239,12 @@ pub const TypeChecker = struct { // 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)}); + } else if (decl.is_async and !self.consumesAsyncEffect(id)) { + // M1.0.12 E3 — E0905: a BARE async call in an async context. The + // `await` is the SOLE call-grain consumer of the `{async}` effect + // (§9.2 revision 2) — the four constructs relocate the suspension, + // they do not consume it. + try self.emit(.unconsumed_async_effect, .error_, self.arena.exprSpan(id), "bare call to `async fn` '{s}' — consume the async effect with `await` (inside spawn/branch/race/sync bodies too: the constructs relocate the await into a child task, they do not replace it)", .{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; @@ -5647,6 +5885,21 @@ pub const TypeChecker = struct { return ResolvedType.unknown; } + // M1.0.12 E3 — builtin TaskHandle method (§9.8): `cancel()` — no args, + // statement-effect (`unknown` ≈ unit; idempotent at runtime, E5). The + // handle's only other operation is `await h` (handled in the await + // arm); anything else is an error with a pointer to both. + if (recv_t == .builtin and recv_t.builtin == .task_handle) { + if (std.mem.eql(u8, method_slice, "cancel")) { + if (mc.args_len != 0) { + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "TaskHandle method 'cancel' takes no arguments", .{}); + } + return ResolvedType.unknown; + } + try self.emit(.type_mismatch, .error_, self.arena.exprSpan(id), "no method '{s}' on a TaskHandle (only 'cancel()'; join with 'await h')", .{method_slice}); + return ResolvedType.unknown; + } + // M1.0.9 B2 — builtin extension methods on an `Entity` receiver, checked // BEFORE the trait-method resolution below (these are interpreter builtins, // not user traits; any other method on an Entity falls through to the @@ -5840,6 +6093,9 @@ pub const TypeChecker = struct { // 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)}); + } else if (method.is_async and !self.consumesAsyncEffect(id)) { + // M1.0.12 E3 — E0905 (mirror of the free-fn site, §9.2 revision 2). + try self.emit(.unconsumed_async_effect, .error_, self.arena.exprSpan(id), "bare call to `async` method '{s}' — consume the async effect with `await` (inside spawn/branch/race/sync bodies too: the constructs relocate the await into a child task, they do not replace it)", .{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; @@ -9658,3 +9914,328 @@ test "E0901 fires on an async call / await in a non-async context, not on a lega try std.testing.expectEqual(@as(usize, 0), ok.parse_diags.len); try expectNoCode(ok.diagnostics.items, .async_call_in_non_async_context); } + +test "E0901 fires on the four concurrency constructs outside an async context (M1.0.12 E3)" { + const gpa = std.testing.allocator; + // §4.2 (the async constructs are only available in an async context) — + // each of the four forms in a SYNC rule is E0901. + var bad = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\rule r() + \\ when resource Out + \\{ + \\ race { } + \\ sync { } + \\ branch { } + \\ spawn { } + \\} + ); + defer bad.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), bad.parse_diags.len); + var count: usize = 0; + for (bad.diagnostics.items) |d| { + if (d.code == .async_call_in_non_async_context) count += 1; + } + try std.testing.expectEqual(@as(usize, 4), count); + + // The same four forms in an ASYNC rule are clean. + var ok = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ race { } + \\ sync { } + \\ branch { } + \\ spawn { } + \\} + ); + 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); +} + +test "E0905 fires on every bare async call; await is the sole consumer (M1.0.12 E3)" { + const gpa = std.testing.allocator; + const af = + \\resource Out { n: int = 0 } + \\async fn af() -> int { + \\ await wait(0.02s) + \\ return 1 + \\} + \\ + ; + // Bare async calls in an async context — statement and let-init forms — + // leave the `{async}` effect unconsumed → E0905 (NOT E0901: the context + // IS async). + var bare = try parseAndCheck(gpa, af ++ + \\async rule r() + \\ when resource Out + \\{ + \\ af() + \\ let t = af() + \\} + ); + defer bare.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), bare.parse_diags.len); + var count: usize = 0; + for (bare.diagnostics.items) |d| { + if (d.code == .unconsumed_async_effect) count += 1; + } + try std.testing.expectEqual(@as(usize, 2), count); + try expectNoCode(bare.diagnostics.items, .async_call_in_non_async_context); + + // §9.2 revision 2: the four constructs RELOCATE the await into a child + // task, they do not replace it — a bare async call inside their bodies is + // E0905 like anywhere else (it would hit the sync call path's `is_async` + // fail-loud gate at execution). One per body → 4 × E0905. + var inbody = try parseAndCheck(gpa, af ++ + \\async rule r() + \\ when resource Out + \\{ + \\ race { af() } + \\ sync { af() } + \\ branch { af() } + \\ spawn { af() } + \\} + ); + defer inbody.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), inbody.parse_diags.len); + count = 0; + for (inbody.diagnostics.items) |d| { + if (d.code == .unconsumed_async_effect) count += 1; + } + try std.testing.expectEqual(@as(usize, 4), count); + + // Consumed by `await` — at the rule top AND inside each of the four + // construct bodies — no E0905. + var ok = try parseAndCheck(gpa, af ++ + \\async rule r() + \\ when resource Out + \\{ + \\ let x = await af() + \\ race { + \\ await af() + \\ { let a = await af() + \\ return } + \\ } + \\ sync { await af() } + \\ branch { let y = await af() } + \\ spawn { await af() } + \\} + ); + defer ok.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), ok.parse_diags.len); + try expectNoCode(ok.diagnostics.items, .unconsumed_async_effect); +} + +test "E0906 rejects return in sync/branch/spawn, accepts it in a race branch (M1.0.12 E3)" { + const gpa = std.testing.allocator; + // `return` in a sync branch / branch body / spawn body → E0906 each. + var bad = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ sync { { return } } + \\ branch { return } + \\ spawn { return } + \\} + ); + defer bad.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), bad.parse_diags.len); + var count: usize = 0; + for (bad.diagnostics.items) |d| { + if (d.code == .illegal_return_in_concurrency_branch) count += 1; + } + try std.testing.expectEqual(@as(usize, 3), count); + + // `return` in a race branch is LEGAL (winner-return propagation, §9.5) — + // the canonical timeout pattern. + var ok = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async fn fetch() -> int { + \\ await wait(0.02s) + \\ return 1 + \\} + \\async fn with_timeout() -> int { + \\ race { + \\ return await fetch() + \\ { await wait(5.0s) + \\ return 0 } + \\ } + \\ return 0 + \\} + ); + defer ok.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), ok.parse_diags.len); + try expectNoCode(ok.diagnostics.items, .illegal_return_in_concurrency_branch); +} + +test "E0907 rejects break/continue crossing the task boundary, accepts in-branch loops (M1.0.12 E3)" { + const gpa = std.testing.allocator; + // An unlabeled `break`/`continue` with no in-branch loop, and a labeled + // `break` targeting a loop OUTSIDE the construct, cross the boundary — + // E0907 (all four forms exercise the same check; race + spawn shown). + var bad = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ outer: loop { + \\ race { { break } } + \\ spawn { continue } + \\ sync { { break outer } } + \\ branch { break } + \\ break + \\ } + \\} + ); + defer bad.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), bad.parse_diags.len); + var count: usize = 0; + for (bad.diagnostics.items) |d| { + if (d.code == .control_flow_escapes_task_branch) count += 1; + } + try std.testing.expectEqual(@as(usize, 4), count); + + // Loops FULLY INSIDE a branch keep working: unlabeled and labeled + // break/continue targeting in-branch loops are clean. + var ok = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ branch { + \\ for i in 0..3 { + \\ continue + \\ } + \\ inner: loop { + \\ loop { break inner } + \\ break + \\ } + \\ } + \\ spawn { + \\ while true { + \\ break + \\ } + \\ } + \\} + ); + defer ok.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), ok.parse_diags.len); + try expectNoCode(ok.diagnostics.items, .control_flow_escapes_task_branch); +} + +test "TaskHandle: binding typed, cancel/await accepted, misuse rejected (M1.0.12 E3)" { + const gpa = std.testing.allocator; + // The bound spawn types `h` as the builtin TaskHandle: `h.cancel()` and + // `await h` are its two operations (§9.8) — clean. + var ok = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let h = spawn { + \\ await wait(0.02s) + \\ } + \\ h.cancel() + \\ let h2 = spawn { } + \\ await h2 + \\} + ); + defer ok.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), ok.parse_diags.len); + try std.testing.expectEqual(@as(usize, 0), ok.diagnostics.items.len); + + // `cancel` with arguments, and any other method on a TaskHandle → E0200. + var badm = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let h = spawn { } + \\ h.cancel(1) + \\ h.join() + \\} + ); + defer badm.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), badm.parse_diags.len); + var count: usize = 0; + for (badm.diagnostics.items) |d| { + if (d.code == .type_mismatch) count += 1; + } + try std.testing.expectEqual(@as(usize, 2), count); + + // `await` on a non-TaskHandle non-call expression → E0200. + var badh = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let h = 5 + \\ await h + \\} + ); + defer badh.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), badh.parse_diags.len); + try expectAnyCode(badh.diagnostics.items, .type_mismatch); +} + +test "TaskHandle is rejected as a component/resource field type (M1.0.12 E3)" { + const gpa = std.testing.allocator; + // Non-POD builtin (§2.2) — like `string` on components, a `TaskHandle` + // field is rejected on both a component and a resource. + var bad = try parseAndCheck(gpa, + \\component C { h: TaskHandle } + \\resource R { h: TaskHandle } + ); + defer bad.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), bad.parse_diags.len); + var count: usize = 0; + for (bad.diagnostics.items) |d| { + if (d.code == .undefined_symbol) count += 1; + } + try std.testing.expectEqual(@as(usize, 2), count); +} + +test "conditional branch guards type-check as bool in the parent scope (M1.0.12 E3)" { + const gpa = std.testing.allocator; + // A bool guard referencing a parent local is clean; a non-bool guard is + // E0200. Guards are evaluated in the parent scope at construct entry + // (§9.5), synchronously. + // + // NB: a guarded BLOCK branch must not END on a bare `await` — a block's + // trailing expression is its VALUE (synchronously evaluated), so a + // trailing await sits off the frame spine → E0904 (M1.0.11 placement, + // unchanged). The §9.5 pattern always ends on a statement (`return`). + var ok = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ let hard = true + \\ race { + \\ await wait(0.02s) + \\ if hard => await wait(2.0s) + \\ } + \\} + ); + defer ok.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), ok.parse_diags.len); + try std.testing.expectEqual(@as(usize, 0), ok.diagnostics.items.len); + + var bad = try parseAndCheck(gpa, + \\resource Out { n: int = 0 } + \\async rule r() + \\ when resource Out + \\{ + \\ sync { + \\ if 42 => await wait(0.02s) + \\ } + \\} + ); + defer bad.deinit(gpa); + try std.testing.expectEqual(@as(usize, 0), bad.parse_diags.len); + try expectAnyCode(bad.diagnostics.items, .type_mismatch); +} diff --git a/src/etch/value.zig b/src/etch/value.zig index 2ef1457..29557b6 100644 --- a/src/etch/value.zig +++ b/src/etch/value.zig @@ -108,6 +108,12 @@ pub const Value = union(enum) { /// and no interpreter store; `ptr == 0` ⇔ the empty string. Additive — does /// not disturb `string_id` (AST pool) / `string_run` (rule-arena) semantics. string_persistent: StrView, + /// A `TaskHandle` (M1.0.12 E5, `etch-grammar.md` §2.2): the pool index of + /// a spawned task in `Interpreter.async_tasks`. Safe as a bare index — + /// the pool is MONOTONIC (no slot reuse; a finished task parks as a husk), + /// so no generation is needed in Phase 1. Copyable/storable as a value; + /// its operations are `h.cancel()` (idempotent) and `await h` (§9.8). + task_handle: u32, unit, pub fn fromInt(x: i64) Value { @@ -158,6 +164,9 @@ pub const Value = union(enum) { const bb: [*]const u8 = @ptrFromInt(b.ptr); break :blk std.mem.eql(u8, ab[0..a.len], bb[0..b.len]); }, + // Handle equality is not an Etch v0.6 operation (no `==` on + // TaskHandle); identity comparison is reserved for a later spec. + .task_handle => false, .unit => true, }; }