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.
This commit is contained in:
agra
2026-06-28 16:19:04 +03:00
parent aae7d72a66
commit 2b1307a0dc
52 changed files with 168342 additions and 160106 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,10 +16,10 @@
//
// WHAT IT PROVES (the contract; numbers below are the snapshot):
// - `freed by deinit: N` — live allocations reclaimed by `deinit` (> 0).
// - `live after deinit` — the RESIDUAL. This is NOT zero and NOT a bug: it is
// exactly the documented closure-env leak — one heap env per `spawn`
// that sx cannot free (the runtime has no name for the env pointer). deinit
// reclaims everything it CAN; the env residual is a language limitation.
// - `live after deinit: 0` — NO residual. Each spawned fiber's body-closure heap
// env is reclaimed at reap (`reap_fiber` frees `body.env` via the spawn-time
// allocator snapshotted in `dctx`), and `deinit` frees the List backings + kq
// fd — so the live count returns to zero.
// - `kq open after run: 1` then `kq after deinit: -1` — the lazily-opened
// kqueue fd was genuinely open after the fd round and is closed by deinit.
// - `read: 3 [97 98 99]` — the fd path actually ran (reader blocked, woke via

View File

@@ -0,0 +1,46 @@
// The unified `context.io.async` layer reclaims its per-task heap once a future is
// AWAITED (PLAN-IO-UNIFY follow-up — closing the documented leaks). Each `async`
// allocates: the `Future`, the `ThunkBox`, the completion-closure env, the worker's
// env, and the spawn_raw fiber-body env. With ownership wired through, ALL of it is
// freed: the box + envs by `sx_run_boxed_closure` the instant the worker completes,
// the fiber-body env at fiber reap, and the `Future` by the last of {worker,
// `await`} (the two-flag handshake). Verified by a tracking `GPA`: after running +
// awaiting three workers and `deinit`, the live-allocation count returns to the
// pre-spawn baseline — zero residual.
//
// (A future that is never awaited — fire-and-forget, or a `race` loser — keeps only
// its `Future` struct, since nothing consumes it; that remainder needs a
// structured-concurrency scope and is out of scope here.)
//
// aarch64-pinned (the scheduler's per-arch asm): runs end-to-end on a matching
// host (macOS + linux), ir-only on a mismatch.
#import "modules/std.sx";
sched :: #import "modules/std/sched.sx";
main :: () -> i64 {
sum : i64 = 0; psum := @sum;
base : i64 = 0; pbase := @base;
after : i64 = 0; pafter := @after;
gpa := mem.GPA.init();
push Context.{ allocator = xx gpa, data = null } {
s := sched.Scheduler.init();
ps := @s;
pbase.* = gpa.alloc_count; // baseline: scheduler is live, no tasks yet
push .{ io = xx s, allocator = xx gpa, data = null } {
ps.spawn(() => {
a := context.io.async(() -> (i64, !) => { try context.io.sleep(10); 100 });
b := context.io.async(() -> (i64, !) => { try context.io.sleep(20); 20 });
c := context.io.async(() -> (i64, !) => { try context.io.sleep(30); 3 });
psum.* = (a.await() or 0) + (b.await() or 0) + (c.await() or 0);
});
ps.run();
}
s.deinit();
pafter.* = gpa.alloc_count; // after run + await-all + deinit
}
print("sum: {}\n", sum);
print("residual above baseline: {}\n", after - base); // 0 — every async heap reclaimed
return 0;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
read: 3 [97 98 99]
freed by deinit: 2
live after deinit: 3
live after deinit: 0
kq open after run: true
kq after deinit: -1

View File

@@ -0,0 +1 @@
{ "target": "macos" }

View File

@@ -0,0 +1,2 @@
sum: 123
residual above baseline: 0

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long