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 '---'.
This commit is contained in:
agra
2026-06-22 09:45:33 +03:00
parent 1e0015d6b4
commit 55ed9a248e
12 changed files with 299 additions and 53 deletions

View File

@@ -32,6 +32,10 @@ kqb :: #import "modules/std/net/kqueue.sx";
mmap :: (addr: *void, len: i64, prot: i32, flags: i32, fd: i32, off: i64) -> *void extern libc "mmap";
mprotect :: (addr: *void, len: i64, prot: i32) -> i32 extern libc "mprotect";
munmap :: (addr: *void, len: i64) -> i32 extern libc "munmap";
// Canonical libc `close` signature `(i32) -> i32` — must match any other
// binding in the program (the extern dedupe rejects a divergent one). Used by
// `Scheduler.deinit` to close the lazily-opened kqueue fd.
close :: (fd: i32) -> i32 extern libc "close";
abort :: () -> noreturn extern libc "abort";
PROT_NONE :: 0;
@@ -109,33 +113,36 @@ Scheduler :: struct {
// `block_on_fd` opens it, so a pure-compute /
// virtual-timer scheduler never opens a kqueue
// fd (no leak for the common case). Once opened it
// lives for the scheduler's lifetime; there is no
// deinit yet, so it leaks one fd at program exit
// (bounded, harmless — same class as the spawn
// env / go Task leaks documented above).
// lives for the scheduler's lifetime; `deinit`
// closes it (and resets this back to -1).
io_waiters: List(IoWaiter); // fibers parked on fd readiness, grown through
// `own_allocator` (long-lived-container rule: a
// waiter outlives the `block_on_fd` call's scope).
// --- deinit bookkeeping: heap Tasks allocated by `go` --------------------
task_allocs: List(*void); // every heap `*Task` from `go`, recorded so
// `deinit` can free them. The scheduler does not
// otherwise know its Tasks (they are generic
// `Task($R)` handed back to the caller); without
// this list they would leak. Grown through
// `own_allocator` (a Task outlives the `go` call).
// Construct a scheduler BY VALUE (allocator value-return convention).
// Captures the current `context.allocator` into `own_allocator` — fibers and
// their heap `Fiber` structs outlive their spawn scope, so all internal
// allocation must go through this captured (long-lived) allocator, not
// whatever transient one happens to be current at a later call.
init :: () -> Scheduler {
s : Scheduler = ---;
s.current = null;
s.ready_head = null;
s.ready_tail = null;
s.own_allocator = context.allocator;
s.next_id = 0;
s.n_spawned = 0;
s.n_suspended = 0;
s.clock_ms = 0;
s.timers = .{};
s.kq = -1; // lazy: opened by the first block_on_fd
s.io_waiters = .{};
return s;
// Literal init (by value). `sched_ctx` is intentionally unnamed — the
// partial literal zero-fills it, and it is written by the first
// `swap_context` before ever being read. `kq = -1` is the lazy sentinel
// (opened by the first `block_on_fd`).
return Scheduler.{
current = null, ready_head = null, ready_tail = null,
own_allocator = context.allocator,
next_id = 0, n_spawned = 0, n_suspended = 0,
clock_ms = 0, timers = .{}, kq = -1, io_waiters = .{}, task_allocs = .{}
};
}
// Spawn a fiber running `body`. Heap-allocates the `Fiber` and a guarded
@@ -445,6 +452,65 @@ Scheduler :: struct {
abort();
}
}
// Release the scheduler's owned resources. TERMINAL: the scheduler is dead
// after this — no scheduler-owned handle (the `*Task`s returned by `go`, a
// `*Fiber` from `spawn`, the scheduler itself) may be used afterward; doing
// so is a use-after-free, the universal deinit contract. Idempotent: a
// second `deinit` is a no-op (it rests on `List.deinit` nulling `items` +
// zeroing `len`, and on `kq`/`ready_head` being reset below).
//
// Call AFTER `run()` has returned: a clean `run()` leaves the ready queue
// empty and aborts loudly on any orphaned suspend, so nothing is mid-flight
// and every `task_allocs` entry is a COMPLETED task (safe to free). Frees,
// in order:
// 1. any fibers still enqueued ready — a leak-SAFETY NET for the misuse
// path (`spawn`/`go` without a following `run()`, or after it returned),
// NOT a blessed reuse pattern: reaping a `go`'s fiber here while step (2)
// frees its paired `*Task` is self-consistent ONLY because the contract
// already forbade touching those handles post-`deinit`. A suspended
// (off-queue) fiber is unreachable from here, but a clean `run()` never
// leaves one (it aborts on an orphaned suspend);
// 2. every heap `*Task` from `go` (recorded in `task_allocs`);
// 3. the three `List` backings (`task_allocs`, `timers`, `io_waiters`),
// each grown through `own_allocator`;
// 4. the kqueue fd, if `block_on_fd` ever opened it (lazy `-1` otherwise).
//
// NOT freed (documented language limitation, unchanged): one closure env per
// `spawn`/`go`. The env is heap-allocated at the closure-literal site and sx
// exposes no way to free it (the scheduler cannot name the env pointer), so
// it leaks until program exit — bounded by the spawn/go count, invisible
// under the default GPA. Freeing it needs a closure-env-ownership affordance.
deinit :: (self: *Scheduler) {
// (1) Reap leftover ready fibers: unmap the stack, free the Fiber.
f := self.ready_head;
while f != null {
nxt := f.next;
munmap(f.stack_region, f.stack_len);
self.own_allocator.dealloc_bytes(xx f);
f = nxt;
}
self.ready_head = null;
self.ready_tail = null;
// (2) Free every heap Task allocated by `go`.
i := 0;
while i < self.task_allocs.len {
self.own_allocator.dealloc_bytes(self.task_allocs.items[i]);
i = i + 1;
}
// (3) Free the List backings (all grown through `own_allocator`).
self.task_allocs.deinit(self.own_allocator);
self.timers.deinit(self.own_allocator);
self.io_waiters.deinit(self.own_allocator);
// (4) Close the kqueue fd if it was ever opened (lazy: -1 if never used).
if self.kq >= 0 {
close(self.kq);
self.kq = -1;
}
}
}
// --- the context switch (naked) + first-entry trampoline -------------------
@@ -719,6 +785,9 @@ go :: ufcs (self: *Scheduler, work: Closure() -> $R) -> *Task($R) {
t.waiter = null;
t.sched = self;
t.canceled = 0;
// Record the heap Task so `deinit` can free it (the scheduler otherwise has
// no handle on its generic Tasks). Long-lived: a Task outlives this call.
self.task_allocs.append(xx t, self.own_allocator);
self.spawn(() => {
// Cooperative cancel: skip the work entirely if cancel already landed
// before this fiber was scheduled (saves the compute + side effects). A

View File

@@ -472,20 +472,20 @@ Dock :: struct {
on_dock: ?Closure(i64, DockZone);
make :: (interaction: *DockInteraction, delta_time: *f32) -> Dock {
d : Dock = ---;
d.children = List(ViewChild).{};
d.alignments = List(Alignment).{};
d.interaction = interaction;
d.delta_time = delta_time;
d.background = null;
d.corner_radius = 0.0;
d.hint_size = 40.0;
d.hint_color = Color.rgba(77, 153, 255, 153);
d.hint_active_color = Color.rgba(77, 153, 255, 230);
d.preview_color = Color.rgba(77, 153, 255, 64);
d.enable_corners = true;
d.on_dock = null;
d
Dock.{
children = List(ViewChild).{},
alignments = List(Alignment).{},
interaction = interaction,
delta_time = delta_time,
background = null,
corner_radius = 0.0,
hint_size = 40.0,
hint_color = Color.rgba(77, 153, 255, 153),
hint_active_color = Color.rgba(77, 153, 255, 230),
preview_color = Color.rgba(77, 153, 255, 64),
enable_corners = true,
on_dock = null
}
}
add_panel :: (self: *Dock, panel: DockPanel) {