Commit Graph

8 Commits

Author SHA1 Message Date
agra
2b1307a0dc feat: reclaim fiber + async heap (close the closure-env / Future leaks)
Closes the documented per-spawn closure-env leak and most of the async leak,
using only the existing closure.env / closure.fn_ptr field accessors — no compiler
change. Also names the fat-pointer ABI in core.sx (ClosureRaw / SliceRaw) so the
underlying {fn_ptr, env} / {ptr, len} layout is discoverable in one place.

- Fiber body env: Scheduler.reap_fiber frees f.body.env via f.dctx.allocator (the
  spawn-time allocator snapshotted in dctx) at all three reap sites (run/poll/
  deinit). 1820's 'live after deinit' 3 -> 0.
- Async box + closure envs: sx_run_boxed_closure frees the ThunkBox, the
  completion-closure env, and the worker's env (new ThunkBox.worker_env) the
  instant the worker completes.
- Async Future: two-flag ownership — Future.worker_done (set at the end of the
  completion closure) + consumed (set at the end of await); fut_release frees the
  heap Future (via the captured Future.alloc) when BOTH are set, so the LAST of
  {worker, await} reclaims it. await now CONSUMES the future (single-use; touching
  it afterward is a use-after-free — documented). Residual for an AWAITED future
  is 0 (lock: examples/concurrency/1827); a never-awaited future (fire-and-forget /
  race loser) keeps only its Future struct — the structured-concurrency remainder.

Self-reviewed across orderings (await-after/before-complete, cancel-then-await,
cancel-while-parked, double-free via await+deinit, race residual, blocking impl,
cross-allocator reap) — all deterministic, no UAF/double-free. Suite 855/0;
byte-identical on aarch64-macOS + aarch64-linux; .ir churn is the core.sx +
Future/ThunkBox field additions.
2026-06-28 16:19:04 +03:00
agra
97b0abef66 feat: race over Futures via context.io.race (PLAN-IO-UNIFY Phase 4)
Re-home the proven first-wins race from sched.race(*Task) onto *Future handles
+ the Io protocol; the old Task-based race is REPLACED (ufcs overload-by-receiver
is rejected, and only 1821 used it).

- Protocol: add Io.current_park() -> ParkToken — the running fiber as a token,
  captured WITHOUT parking — so race can register the SAME coordinator across N
  futures' park slots, then park once via suspend_raw; any completion readies it.
  Scheduler returns {self.current} (bails outside a fiber); CBlockingIo returns
  {null} (race never parks there — futures are born .ready).
- race :: ufcs (io: Io, futures: $T) -> RaceResult(T), kept in sched.sx (it needs
  meta.sx's make_enum/make_variant; pulling that into the io.sx prelude part-file
  would cycle). Winner scan -> register/park/deregister -> make_variant the winner
  -> Phase-3 cancel each still-.pending loser (no join). RaceResult reused
  unchanged (*Future(R) projects field 0 'value' -> R).
- TRUE-cancel: parked losers stop at their next suspend (timers evicted by cancel's
  wake), so race returns at WINNER-time, not slowest-loser-time.
- Adversarial review fixes: (1) an all-failing/all-cancelling racer set no longer
  deadlock-aborts the scheduler — race bails loudly ('all futures settled without
  a winner') when nothing is .ready and nothing is still .pending; (2) only
  .pending losers are cancelled, so a loser that already .failed keeps its real
  outcome label instead of being stomped to .canceled.

Re-point 1821 to context.io.async + context.io.race (winner a=111, losers
.canceled, completion log only 'task 1 @ 10ms', final clock 10ms — was 30 under
the old cooperative join). New 1826 locks the failing-loser case. Byte-identical
on aarch64-macOS + aarch64-linux. Suite 853/0; .ir churn is the current_park
vtable method.
2026-06-28 09:50:10 +03:00
agra
8bacb2b01c feat: true cancellation for the fiber Io layer (PLAN-IO-UNIFY Phase 3)
A cancelled async worker now abandons its body at its next suspend instead
of running to completion.

- Cancel-flag back-ref (D4): SpawnOpts.cancel_flag (core.sx) + Fiber.cancel_flag
  (sched.sx), set from opts.cancel_flag in Scheduler.spawn_raw; async passes
  xx @f.canceled (the Future.canceled Atomic(bool) erased to *void).
- Delivery: Scheduler.suspend_raw consults fiber_canceled(self.current) PRE-park
  (raise without parking — no deadlock if cancel landed before the worker ran)
  and POST-resume (cancel landed while parked), raising error.Canceled.
  cancel(f) flips the sticky flag, marks .canceled, and wakes the worker.
- async worker is failable Closure() -> ($R, !); the completion closure
  f.value = worker() catch {…} marks .canceled/.failed and wakes the awaiter,
  so post-suspend side effects never run. New failable io.sleep(ms) is the
  cancellation point.
- Compiler: a -> ! fn whose only error source is try-ing a protocol method
  (io.suspend_raw) was wrongly flagged 'declared ! but never errors';
  collectErrorSites now marks a try of a non-identifier callee as a dynamic
  (opaque) error source, suppressing the warning.
- Two UAFs found by adversarial review and fixed: (1) cancel-before-park
  orphaned io.sleep's armed timer — suspend_raw's pre-park raise now evicts the
  current fiber's timer/waiter first; (2) cancel(f) could wake a reaped worker —
  now only wakes when was_pending.

Migrated 1805/1806/1824 to failable workers. Lock: example 1825 (seq: 1 -99,
post-suspend line never runs); byte-identical on aarch64-macOS + aarch64-linux.
.ir churn is the SpawnOpts layout change (type-table string renumbering).
2026-06-28 09:19:01 +03:00
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
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
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