Files
sx/current/PLAN-RACE.md
agra 3328d3fe52 docs: mark fiber-async / race plans SUPERSEDED by the unified context.io stack
Phase 5 of PLAN-IO-UNIFY retired the bespoke fiber-task API (Task/go/wait/cancel)
and re-homed race onto *Future. Prepend SUPERSEDED banners to the three historical
planning docs (PLAN-FIBERS, CHECKPOINT-FIBERS, PLAN-RACE) pointing readers to
PLAN-IO-UNIFY for the current design, noting that the Scheduler engine primitives
+ the race type-machinery they document remain accurate. Bodies kept as the
historical record. Completes the plan's 'update roadmap/checkpoints' item.

Verified by parallel agents: a completeness pass (no retired API in compilable
code; sched.sx surface fully retired; migrated examples meaningful; readme on the
unified API) and an adversarial memory-safety pass over the heap reclamation (no
UAF/double-free; await/worker orderings, cancel timings, 8-way double-free+deinit,
blocking impl all reclaim cleanly with exact values).
2026-06-28 16:50:31 +03:00

10 KiB
Raw Blame History

SUPERSEDED (2026-06-28). The race LOGIC described here shipped, then was RE-HOMED onto *Future + the Io protocol as context.io.race in PLAN-IO-UNIFY Phase 4. The Task-based sched.race over *Task documented below is RETIRED. See current/PLAN-IO-UNIFY.md (## Status (2026-06-28)) for the current design. The type-level machinery this doc documents (RaceResult / make_variant / tuple reflection) is UNCHANGED and still in use. Body below is a historical record.

PLAN-RACE — Stream B2/A1: race over the M:1 fiber scheduler

Carved from the async roadmap (../design/execution-evolution-roadmap.md §4.5, §4.6, §7 step 3). The headline A1 feature still missing after Stream B1: race — start N async tasks, return when the FIRST completes, and structurally cancel + join the losers before returning. The result is a synthesized tagged-union mirroring the input named tuple's labels.

fa := s.go(() -> A => read_a(conn));      // *Task(A)
fb := s.go(() -> B => read_b(conn));      // *Task(B)
winner := s.race((a: fa, b: fb));         // RaceResult = enum { a: A; b: B }
if winner == {
    case .a: (v) { handle_a(v); }         // v : A   (fb cancelled + joined)
    case .b: (v) { handle_b(v); }         // v : B   (fa cancelled + joined)
}
// positional form: s.race((fa, fb)) → tags ._0 / ._1

Design decisions (grounded against the tree, 2026-06-26)

  • Built over the M:1 Task layer, NOT context.io/Future. The suspending async is sched.go/wait/cancel over *Task($R) (B1.4a); context.io.asyncFuture is the BLOCKING impl (workers run inline → racing is meaningless there). The roadmap's "Future" maps to our *Task. race is a Scheduler/Task UFCS function in library/modules/std/sched.sx.
  • The result is a comptime-synthesized nominal tagged-union (RaceResult), one variant per input tuple element: variant NAME = the tuple label (positional → _0/_1), payload = the task's result type. Synthesis uses the proven declare/define/make_enum + field_count/field_name/ field_type reflection (examples 06190623, 0646). The input arrives as an inferred type PARAMETER $T (a named tuple of *Task(..)), which reflects correctly (issue 0195 fixed; the tuple-alias gap is issue 0196, NOT on this path).
  • Cancellation rides the existing cooperative Task.cancel (sets the flag + .canceled state). race cancels every loser, then waits each (joins) so no loser fiber outlives the race call — structured. Reuses suspend_self/wake; no new scheduler machinery.

The one net-new compiler primitive (step 1)

pointee($P: Type) -> Type #builtin — given a pointer type *X, return X. This is the only missing reflection capability: race must project each tuple element *Task(A) to its result type A, and there is currently NO way to get a pointer's target type at comptime (field_count(*X)=0, type_info has no pointer variant). With it the projection is pure sx:

TaskResult :: ($P: Type) -> Type {      // P = *Task(A)  →  A
    return field_type(pointee(P), 0);   // pointee → Task(A); field 0 = `value: A`
}

Small + generally useful (reflection is currently complete for aggregates but blind to pointer targets). Mirror field_type's #builtin plumbing (src/ir/lower/call.zig + src/ir/calls.zig), backed by the pointer TypeInfo's pointee TypeId (src/ir/types.zig). Lock with a comptime example.

Steps (each: implement → lock with an example → zig build test green → both platforms)

  1. pointee reflection builtin. Add pointee($P: Type) -> Type (core.sx + compiler). Example: pointee(*i64) = i64, field_type(pointee(*Task(i64)), 0) = the task value type. (worker+review)
  2. RaceResult($T) -> Type synthesis. Type-fn: reflect the named-tuple $T of *Task(..), project each element via TaskResult, mint the tagged-union (labels → variants). Comptime-only example asserting the minted type's field_count/field_name match the input tuple.
  3. Task.Value projection + result construction. Confirm a winner's value can be boxed into the minted variant by label/index (uses the existing variant-construction path).
  4. Runtime race(tasks: $T) -> RaceResult(T). Suspend the caller until the first task is .ready; build the winner variant; then cancel + wait-join every loser before returning. Single-winner (first by completion order; FIFO tiebreak). Example: 2 tasks, deterministic winner via sleep ordering (like 1817), asserting the loser is cancelled + joined.
  5. Positional tuple form (._0/._1) + edge cases (already-ready task → immediate, single-task race, all-cancelled). Examples.
  6. Validate every new example byte-identical on aarch64-macOS host AND aarch64-linux container; full zig build test green; adversarially review each step.

Status

Prereqs DONE (each committed + adversarially reviewed + suite-green):

  • issue 0195 (tuple/array/vector field reflection) fixed (8ac6c573). Tuple reflection works on inline + $T-param forms. Issue 0196 (tuple alias) filed, not on the critical path.

  • pointee($P) -> Type builtin added (f1d29876) — projects *Task(A)Task(A).

  • field_count/size_of/align_of fold as comptime constants (2a6ef398) — so a generic ($T) -> Type builder can inline for 0..field_count(T) and size [field_count(T)]EnumVariant. Verified: the variable-arity loop + array dim now work inside RaceResult.

  • comptime type-call composition fixed (eb18bbc6) — a field_type(...)/pointee(...) result is now usable as a Type-typed struct-field value, a generic $P: Type arg, and a nested type-call arg (incl. with an inline for loop-var index). The variable-arity RaceResult synthesis works end-to-end (proven by examples/comptime/0649-comptime-typecall-composition.sx: reflect a named tuple of *Box(..) handles → mint a tagged-union with the tuple's labels, projecting *Box(A)A).

  • return inside inline if fixed (84c2ae4f) — the natural early-return-per-arm pattern (a return in an inline if/comptime-case branch inside an inline for) no longer drops the function's trailing statements. Lets race build the winner variant with a clean inline if i == { case 0: … else: … } per-arm form.

  • make_variant($E, idx, payload) added to modules/std/meta.sx (1c26944e) — the WRITE side of the metatype triad: construct a minted tagged-union value by variant INDEX (the winner is chosen at runtime; its label can't be a literal). Pure sx (writes the i64 tag@0 + payload@8). Verified for complex payloads (struct / string / 40-byte struct). This resolves the variant-construction gap.

GAP 1 — comptime-cursor indexing of a named-tuple VALUE — DONE (fee86adf). tasks[i] with a comptime cursor now reads the i-th element with its concrete type (option (a), a structGet).

GAP 2 — surfaced during the runtime, all DONE (6a976287): three compiler enablers the runtime needed beyond the read path. (1) a named-tuple LITERAL passed directly as $T lost its element names (field_name → ""), breaking RaceResult's make_enum; (2) tuple-element L-VALUES by comptime index (tasks[i].waiter = …) panicked at LLVM emit (an index_gep with ptrTo(.unresolved)); (3) a user ($X) -> Type call couldn't bind a $E: Type arg, blocking make_variant(RaceResult(T), …). All fixed + adversarially reviewed; OOB comptime tuple indices now diagnose loudly on every L-value path.

Runtime in sched.sx — DONE (9099735e).

  • RaceResult :: ($T) -> Type over *Task(..) (the 0649 shape, with Task).
  • race :: ufcs (self: *Scheduler, tasks: $T) -> RaceResult(T): Phase 1 suspend until the FIRST .ready (register waiter on all pending; on wake DEREGISTER from all; lowest-index winner). Phase 2 build the winner with make_variant. Phase 3 cancel + JOIN each loser one-at-a-time (only the joined loser carries a waiter → no mid-join double-wake). Join rides a new Task.finished flag (set at the end of the go body, checked before parking). Cooperative-cancel: a loser parked mid-sleep runs to its natural end before race returns.
  • Locked by examples/concurrency/1821-concurrency-fiber-race.sx (3 tasks i64/bool/f64, sleep 10/20/30, shortest wins, losers cancelled + joined). Byte-identical on aarch64-macOS host AND aarch64-linux container; full zig build test green (826/0).

Remaining (step 5, future work):

  • POSITIONAL tuple form (._0/._1): field_name yields "" for an unnamed element → make_enum rejects the duplicate. Needs a _N fallback in RaceResult (a comptime int→string) or a compiler field_name default for positional tuples.
  • Bare named-arg call form s.race(a = ta, b = tb) (no .(…) wrapper) — see the note below.
  • Edge cases: already-ready-at-call (works today), all-cancelled (would deadlock-abort loudly).
  • By-design caveat to document: race returns only when the SLOWEST loser finishes (cooperative cancel can't preempt a mid-sleep loser; a loser blocked on block_on_fd that never fires blocks the join forever — cancel doesn't unblock an fd waiter).

Future work — bare named-arg call form s.race(a = ta, b = tb)

Today the result-tuple is passed as a named-tuple LITERAL: s.race(.(a = ta, b = tb)). The user asked about dropping the .(…) so it reads s.race(a = ta, b = tb). That is a CALL-SITE syntax/binding feature, independent of the race runtime:

  • The parser would have to accept name = expr call arguments and, for a (tasks: $T) parameter whose type is inferred, MATERIALIZE them into a single named-tuple value bound to $T (rather than treating each name = expr as a separate positional/keyword arg). Effectively "collect trailing k = v call args into one anonymous named-tuple when the callee has a single inferred aggregate param".
  • Risk: name = expr in call position currently has no meaning (or would collide with a future keyword-argument feature). Decide whether k = v call args are (a) always tuple-materialized for an inferred aggregate param, or (b) a dedicated .. / sugar. Option (a) is the least new syntax.
  • Once the call site produces the same named-tuple value, the entire race runtime is unchanged (it already reflects $T). So this is purely front-end sugar — schedule it with the keyword-args work, not the concurrency stream.