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:
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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "target": "macos" }
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user