Commit Graph

15 Commits

Author SHA1 Message Date
agra
ada8d16256 fix: harden Phase 2 async/await per adversarial review (io.sx)
- await: add the one-awaiter-per-future guard `sched.Task.wait` has — a second
  concurrent `await` on the same pending future would overwrite the single
  `park` handle and orphan the first awaiter (silent deadlock). Now aborts
  loudly. (Fan-in over SEPARATE futures — `race` — registers one awaiter each,
  so it stays fine.)
- Document the Future/ThunkBox ALLOCATOR-LIFETIME contract: both are allocated
  from the `context.allocator` in force at `async`, which must outlive the
  future (the long-lived-container rule). Calling `async` inside a transient
  arena torn down before `run()` is a use-after-free; the common case (program
  GPA) is safe. A deeper own-allocator capture is deferred to convergence.
- Document that `cancel` does NOT stop an already-spawned worker (model (a) —
  the worker still runs; the sticky `canceled` atomic is the source of truth).
  True work-cancellation is Phase 3.
- Drop the dead `f.task = null` (immediately overwritten by spawn_raw).

The new `io_abort` extern shifts the prelude type table — 40 `.ir` snapshots
regenerated (behavior-preserving; no `.exit`/`.stdout`/`.stderr` changed).
Suite 829/0.
2026-06-27 08:13:57 +03:00
agra
967aed67d4 feat: async/await colorblind over the fiber Io (Phase 2 of Io unification)
`context.io.async(worker)` / `await` now run over the `Io` PROTOCOL, so the
same code interleaves under the fiber scheduler or runs inline under the
blocking `CBlockingIo` — one async stack, reached purely through `context.io`.

- Protocol: `suspend_raw(park: *ParkToken)` (was by-value). A suspending impl
  records the parked execution context into `park.handle` before parking, so a
  cross-context `ready(park)` knows whom to resume; `Scheduler.suspend_raw`
  writes `self.current`, `CBlockingIo` ignores it.
- io.sx async layer rewritten colorblind: `async` submits the worker through
  `io.spawn_raw` (inline under blocking, a fiber under the scheduler) and returns
  a HEAP `*Future($R)` the worker fills later; `await` suspends via `suspend_raw`
  until ready, then returns/raises. The generic worker is bridged to spawn_raw's
  raw `(*void)->void` entry via a monomorphic `ThunkBox` (a heap-boxed nullary
  completion closure) — all genericity lives in the closure env. Workers are
  nullary (inputs captured at the call site) because a variadic pack can't cross
  the fiber boundary. `CBlockingIo.spawn_raw` now runs the worker inline.
- Migrated 1805/1806 to the nullary `*Future` form; retrofit 1822/1823 to the
  `push .{ … }` partial-context literal (inherits allocator/data).
- The async machinery adds a few prelude types, shifting the type-name table —
  40 `.ir` snapshots regenerated (no behavior change; only `.exit`/`.stdout`/
  `.stderr` would signal that, and none changed).

Locked by examples/concurrency/1824 — two async tasks under the fiber Io, the
completion log proving deferral (1 2 then 10 20 then 123). Suite 829/0,
byte-identical aarch64-macOS host + aarch64-linux container.
2026-06-27 07:50:29 +03:00
agra
c4977247b7 test: commit example snapshots omitted from earlier feature commits
The race (9099735e), Phase 0 (2f2d7f1d), Phase 1 (5c30bfe0) and tuple-store
(6a976287) commits added each example's .sx but their `git add NNNN-*` glob
missed the sibling expected/ snapshots (a different directory). The suite
passed locally because snapshots are read from disk, but the commits were
incomplete — a fresh checkout would have no goldens. Add them now.
2026-06-27 07:42:19 +03:00
agra
5c30bfe0c2 feat: impl Io for Scheduler — fiber scheduler as a context.io vtable (Phase 1)
`impl Io for Scheduler` folds the M:1 fiber scheduler behind the same `Io`
protocol (core.sx) the blocking `CBlockingIo` implements, so the async layer
can run colorblind over whichever impl is installed via
`push Context { io = xx scheduler }`. The six methods are thin adapters over
the existing fiber primitives:

- `spawn_raw(entry, arg, opts)` — spawn a fiber that calls the erased
  `(*void)->void` worker thunk `entry(arg)` (fn-ptr round-trip through
  `*void`); returns the `*Fiber` handle. The fiber inherits this context
  (Phase 0), so the worker's own `context.io` is this scheduler.
- `suspend_raw(park) -> !` — park the running fiber; the `!` is the
  cancellation channel a suspending impl raises on (wired in Phase 3).
- `ready(park)` — `wake` the fiber recorded in the token (guarded on
  `.suspended`).
- `poll(deadline_ms)` — one step of the run loop (drain ready + fire the
  earliest virtual-time timer); fd-readiness stays on `run`.
- `now_ms` — the deterministic virtual clock.
- `arm_timer(deadline_ms, park)` — arm a virtual-time timer that re-readies
  the current fiber.

Locked by examples/concurrency/1823 — two workers spawned + suspended +
resumed entirely through `context.io`, deterministic deadline order
(byte-identical aarch64-macOS host + aarch64-linux container). Full suite
green (828/0).
2026-06-27 06:49:37 +03:00
agra
2f2d7f1db7 feat: fibers inherit the spawn-time context (Phase 0 of Io unification)
A fiber body previously ran under the static `__sx_default_context`: the
`abi(.c)` `fib_dispatch` frame has no implicit context param, so a
`push Context { … }` around `spawn` was invisible inside the fiber. That
makes it impossible to fold a fiber scheduler behind `context.io` — a
worker's `context.io.*` would resolve to the blocking default, not the
scheduler that spawned it.

`Scheduler.spawn` now snapshots the live `context` into `Fiber.dctx`, and
`fib_dispatch` re-pushes it (`push self.dctx { self.body() }`) around the
body. So a capability installed before `spawn` (allocator, io, data) is
visible to the worker, and a worker spawned under `push Context { io = … }`
sees that `io` as `context.io`.

Behavior-preserving for fibers spawned under the default context (the
snapshot just re-pushes that same default — full suite green). Context is
parameter-threaded per fiber stack, so interleaved fibers with different
contexts don't leak across the `swap_context` (verified: two fibers with
distinct `context.data` each keep their own across a `yield_now`).

Locks: examples/concurrency/1822-concurrency-fiber-context-inherit.sx
(byte-identical aarch64-macOS host + aarch64-linux container).
2026-06-27 06:32:56 +03:00
agra
9099735e88 feat: structured first-wins race over the M:1 fiber scheduler
`s.race((a: ta, b: tb, …))` takes a named tuple of already-spawned
`*Task(..)` handles, suspends the calling fiber until the FIRST task is
ready, and returns a comptime-synthesized tagged-union (`RaceResult`)
mirroring the tuple's labels — variant NAME = the tuple label, payload =
that task's result type. After picking the winner it CANCELS and JOINS
every loser, so no loser fiber outlives the call (structured concurrency).

- `RaceResult($T) -> Type` projects each `*Task(R)` element to `R` via
  `field_type(pointee(field_type(T, i)), 0)` and mints the union with
  `make_enum` (the 0649 composition shape).
- `race` Phase 1 registers the caller as waiter on all pending tasks and
  parks; on wake it DEREGISTERS from every task (a later loser completion
  must never wake it again) and re-scans, lowest-index-first. Phase 2
  builds the winner variant with `make_variant`. Phase 3 cancels + joins
  each loser one at a time — only the joined loser carries a waiter, so no
  other completion can wake the caller mid-join.
- Join correctness rides a new `Task.finished` flag, set at the very end of
  the `go` worker body (after the work ran OR was skipped on an early
  cancel) and checked before parking, so a worker that finishes between the
  cancel and the park can't be lost. Cancellation is cooperative (M:1, no
  preemption): a loser parked mid-`sleep` runs to its natural end, its value
  discarded — `race` returns only once every loser has `finished`.

The tuple must be NAMED; a positional `._0`/`._1` form is future work.

Locked by examples/concurrency/1821 — three tasks (i64/bool/f64) sleeping
10/20/30ms, shortest wins, losers cancelled + joined; byte-identical on
aarch64-macOS and aarch64-linux (deterministic virtual time).
2026-06-26 18:07:14 +03:00
agra
8d23aad4b9 refactor: compiler.sx imports only std/list.sx, not the whole std barrel
compiler.sx needs only `List` (string is a builtin), so import the std/list.sx
part-file instead of std.sx. Its standalone transitive footprint drops from
~16k to ~50 lines of IR. Enabled by core.sx now self-declaring its libc, so
list.sx → core.sx resolves without the std assembly.

Regenerates 40 .ir snapshots: compiler.sx sits in the std import graph
(std → cli → build → compiler), so narrowing its import shifts the
registration order in every std program, renumbering LLVM symbol suffixes
(@foo.N → @foo.N+1) and adding a redundant `declare void @out` (LLVM dedups
it). Verified the diffs are purely that — no .exit/.stdout/.stderr changed, no
instruction/type/constant changed — and the full suite is green (817/0).
2026-06-26 09:16:38 +03:00
agra
5cc45a2b38 refactor: List is slice-backed { items: []T; cap } — directly iterable
items is now a []T slice whose .len IS the live element count (cap = allocated
capacity), so a List iterates directly: `for xs.items (e) { ... }`. A
`len :: (self) -> i64 #get => items.len` accessor keeps `xs.len` reads working;
`.len` WRITES become `.items.len`. List stays 24 bytes (`[]T`=16 + cap=8).

- list.sx: append/ensure_capacity/deinit rewritten for the slice backing. deinit
  guards the free on `cap > 0` (true ownership) and resets via explicit
  ptr=null/len=0 (a `.{}` slice assignment yields a garbage len; `.[]` is the
  empty-slice literal but can't be assigned to a generic []T — both worked around).
- Compiler coupling updated: comptime_vm makeStringList/readStringList write/read
  items as a {ptr,len} fat pointer at field 0 + cap at field 1; control_flow
  listView views an `items: []T` slice (keeps the legacy {[*]T,len} shape too).
- Migrated List `.len` writes to `.items.len` in sched.sx + ui/{render,pipeline,
  glyph_cache} + platform/{sdl3,android,uikit}.
- Snapshots: List's type-table layout changed → ~40 .ir + memory/0800 (items now
  prints as a slice) regenerated; diagnostics/1183 retargeted to a genuine
  many-pointer (xs.items is a slice now). Example memory/0840 locks for-each.
2026-06-22 11:55:19 +03:00
agra
55ed9a248e fibers: Scheduler.deinit + struct-literal init cleanup
Scheduler.deinit closes the bounded leaks B1 documented: it reaps any leftover
ready fibers, frees every heap Task from go (now tracked via a task_allocs
field), frees the timers/io_waiters/task_allocs List backings, and closes the
lazily-opened kqueue fd. Terminal + idempotent; the per-spawn/go closure env
remains unfreeable (language limitation). Locked by
examples/concurrency/1820-concurrency-fiber-scheduler-deinit.sx, which exercises
every freed resource under a tracking GPA (freed by deinit: 5, kq reset to -1).

Also converts plain-struct '= ---'+field-assign init to '.{ ... }' literal init
where '---' carries no meaning: Scheduler.init, Dock.make, and the fiber
examples 1811/1813/1814/1816 (partial literals zero-fill the index-filled array
fields). Unions, '---'-feature tests, the 0154 regression, documented
generic-pack gaps, and loop/conditional inits are intentionally left on '---'.
2026-06-22 09:45:33 +03:00
agra
6ee4d066b3 fibers: address adversarial review of the B1 changes (6 findings)
UFCS generic overload resolution (issue 0157 follow-ups):
- P1-a: call planning (calls.zig) used the last-wins fn_ast_map winner
  while lowering reselected by receiver, so the planned result type
  could disagree with the dispatched function and misbox the result.
  Both now share selectUfcsGenericByReceiver(.., fd0).
- P1-b: selection scanned module_decls globally, flagging a
  transitively-hidden same-named overload as a false ambiguity. Now
  two-tier: directly-visible authors first (ambiguity only among
  those), global fallback for receiver-reachable namespaced methods
  (e.g. Task.cancel) that defers to fd0 on a hidden tie.
- P2-b: boolean specificity tied *$T with *Box($T). Now peels pointer
  layers so the structurally-narrower receiver wins.

Scheduler (sched.sx):
- P1-c: a second concurrent Task.wait overwrote the single waiter slot
  -> silent deadlock. Now one-awaiter-per-task loud abort.
- P2-c: sleep(negative) rewound the monotonic virtual clock. Rejected
  loudly.

(P2-a, non-generic-winner-hides-generic, did not reproduce -- the
non-generic arm already falls through.)

Regressions: examples/generics/0218 (receiver specificity +
plan/lowering agreement), examples/concurrency/1818 (negative-sleep
abort), 1819 (double-wait abort). Suite green 758/0.
2026-06-21 22:05:22 +03:00
agra
5949a88439 fibers: end-to-end M:1 capstone (B1.5) — Stream B1 complete
1817 composes the whole colorblind pure-sx async stack: the M:1
scheduler, suspending go/wait async, and deterministic virtual-time
sleep/now_ms, over the naked swap_context on guarded mmap stacks. A
coordinator launches three async tasks (sleep 30/10/20 -> return
100/20/3), awaits all three in spawn order, and sums them; tasks
complete in DEADLINE order (task 2@10, 3@20, 1@30), sum 123, final
virtual clock 30 -- fully deterministic.

Stream B1 (fibers + Io + M:1 scheduler) is feature-complete: examples
1800-1817, suite 755/0. Checkpoint + plan marked COMPLETE; next carve
is Stream B2 (channels / cancel / async stdlib).
2026-06-21 19:43:22 +03:00
agra
1b0d640f73 fibers: event-loop Io — real fd readiness via kqueue (B1.4c)
A fiber can block on a file descriptor and the run loop blocks on
kevent until the kernel reports it ready. Reuses the existing
std/net/kqueue.sx bindings. Scheduler gains a lazy kq fd + an
io_waiters list; block_on_fd arms a one-shot EVFILT_READ registration,
records an IoWaiter, and suspends. Run-loop Mode 2: when the ready
queue drains and no timer is pending, block on kq_wait(-1), match each
fired ident to its waiter, evict it, wake the fiber. wake evicts a
pending fd-waiter (cancel_io_waiter_for) so no stale IoWaiter outlives
a reaped fiber.

Adversarial review found two CRITICALs: (1) two fibers on the same fd
share one kqueue registration (macOS EV_ADD replaces), so one is lost
and the loop hangs -- fixed by enforcing one-waiter-per-fd with a loud
abort; (2) an fd-waiter on a never-ready fd 'hangs' -- reclassified as
correct event-loop semantics (a server idling on a socket), with the
misleading orphan-check comment corrected. UAF parity, ident width,
EINTR handling, timer/io precedence all probed safe.

Example: 1816 (pipe roundtrip -- reader blocks, writer writes, reader
wakes via kqueue). macOS only; linux epoll twin deferred. Suite green 754/0.
2026-06-21 19:39:16 +03:00
agra
62ffea0663 fibers: deterministic virtual-time timers (B1.4b)
Add a virtual clock + sleep timers to the M:1 scheduler so fibers
schedule in reproducible simulated time. Scheduler gains clock_ms (the
virtual clock, advances only as timers fire), a timers list, now_ms(),
sleep(ms) (arm {clock_ms+ms, current} + suspend), and a timer-driven
run (drain ready -> fire earliest timer -> advance clock -> wake ->
repeat; the orphan-suspend deadlock check is preserved for a genuine
no-timer park). Wakes fire in deadline order with a FIFO tiebreak.

Adversarial review found a use-after-free: a fiber woken early (manual
or Task wake) before its sleep timer fired was reaped while its Timer
kept a dangling *Fiber, so a later fire dereferenced freed memory.
Fixed: wake evicts the fiber's pending timer (cancel_timer_for) -- every
re-ready path funnels through wake, so no stale timer outlives its fiber.

Examples: 1814 (sim-timer deadline ordering), 1815 (early-wake timer
eviction regression). Suite green 753/0.
2026-06-21 19:09:22 +03:00
agra
8367ad18b1 fibers: M:1 scheduler core + suspending fiber-task async (B1.5a, B1.4a)
library/modules/std/sched.sx: a generic Fiber + Scheduler over the
proven naked swap_context on guarded mmap stacks --
init/spawn/yield_now/suspend_self/wake/run (B1.5a), then Task($R) +
go/wait/cancel, a truly-suspending nullary-thunk async layer (B1.4a).
go(work) runs a thunk as a real fiber; wait() parks the caller until it
completes. Self-contained in sched.sx (io.sx importing it would
duplicate the _fib_tramp global asm).

Hardened per adversarial review: wake guarded on .suspended (FIFO
corruption), suspend_self/yield_now guard a null current, loud
mmap/mprotect/OOM/deadlock bails, cancel skips not-yet-run work.
Closure-env + heap-Task leaks documented (bounded, default-GPA-invisible).

Examples: 1811 (round-robin), 1812 (suspend/wake + spurious-wake guard),
1813 (async interleave + await-suspend + cancel). Also files issue 0155
(scalar-pointer index panics codegen -- non-blocking, found in review).
2026-06-21 18:44:03 +03:00
agra
66bdc70bf1 test: group examples into per-category folders
Move examples/*.sx and their expected/ snapshots into per-category
subfolders (examples/<category>/...). Folder = leading filename token,
with ffi-objc/ffi-jni kept whole; filenames are unchanged. The corpus
runner and LSP sweep now discover each category's expected/ dir, while
issues/ stays flat. Example 1058's repo-root-relative companion import
is made file-relative. Path strings embedded in 164 snapshots were
regenerated (path-only changes). Test-layout docs in CLAUDE.md updated.
2026-06-21 14:41:34 +03:00