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:
@@ -4,7 +4,35 @@ Companion to [PLAN-FIBERS.md](PLAN-FIBERS.md). Update after every step (one step
|
||||
per the cadence rule). New corpus category: `18xx` concurrency.
|
||||
|
||||
## Last completed step
|
||||
**B1.5 — END-TO-END M:1 validation — STREAM B1 COMPLETE.** A single capstone exercises the whole
|
||||
**B1 follow-up — `Scheduler.deinit` (close the bounded leaks).** Post-B1 non-blocking cleanup: a
|
||||
terminal `deinit` on `library/modules/std/sched.sx`'s `Scheduler` releases the resources B1 documented
|
||||
as leaked. Frees, in order: (1) any fibers still enqueued ready (leak-safety net for `spawn`/`go`
|
||||
without `run()` — `munmap` stack + free struct; a suspended off-queue fiber is unreachable, but a clean
|
||||
`run()` aborts on orphans so none survive it); (2) every heap `*Task` from `go` — newly tracked via a
|
||||
`task_allocs: List(*void)` field appended in `go` (the scheduler otherwise has no handle on its generic
|
||||
`Task($R)`s); (3) the three `List` backings (`task_allocs`/`timers`/`io_waiters`, all grown through
|
||||
`own_allocator`); (4) the lazily-opened kqueue fd (`close`, reset to `-1`). NOT freed (unchanged
|
||||
language limitation): the per-`spawn`/`go` closure env (sx exposes no env-free). Idempotent (rests on
|
||||
`List.deinit` nulling `items` + the `kq`/`ready_head` resets); TERMINAL contract — no scheduler-owned
|
||||
handle (`*Task`, `*Fiber`, the scheduler) is usable after `deinit`.
|
||||
- Added a canonical `close :: (i32) -> i32 extern libc` (matches the dedupe-canonical signature 1816
|
||||
already uses) + the `task_allocs` field.
|
||||
- Locked by `examples/concurrency/1820-concurrency-fiber-scheduler-deinit.sx` (aarch64-macOS `.build
|
||||
{"target":"macos"}`, runs end-to-end): one run touches every freed resource — a SLEEPER (`timers`), a
|
||||
pipe READER `block_on_fd` + WRITER (kqueue fd + `io_waiters`), two `go` tasks (`Task`s + `task_allocs`)
|
||||
— then `deinit`. Verified by a tracking `GPA`: `freed by deinit: 5`, `live after deinit: 5` (the
|
||||
RESIDUAL = the 5 documented closure envs, not a bug), `kq open after run: true` → `kq after deinit:
|
||||
-1` (the genuinely-open kqueue fd is closed), `read: 3 [97 98 99]` (the fd path actually ran). Counts
|
||||
captured into locals BEFORE printing (`print` allocates format temporaries through the same GPA).
|
||||
- **Adversarially reviewed (worker):** no real memory-safety bug in the supported (deinit-after-`run`)
|
||||
path — reap-loop reads `f.next` before freeing `f`, the three freed List backings + Tasks + kq are all
|
||||
disjoint + scheduler-owned, no over-free, idempotent. The one CRITICAL it raised was a DOC contradiction
|
||||
(step-(1) defensive reap vs step-(2) "post-run only"), reconciled by spelling out the terminal contract.
|
||||
Its 0154-over-store concern (`.{}`→`List` writes in `init` could clobber `kq`) was PROBED and cleared:
|
||||
`kq == -1` immediately after `init`, all fields clean. Suite GREEN **759/0**.
|
||||
|
||||
### Earlier — B1.5 — END-TO-END M:1 validation — STREAM B1 COMPLETE
|
||||
A single capstone exercises the whole
|
||||
colorblind pure-sx async runtime together: the M:1 scheduler (B1.5a) + suspending fiber-task async
|
||||
`go`/`wait` (B1.4a) + deterministic virtual-time `sleep`/`now_ms` (B1.4b), over the `abi(.naked)`
|
||||
`swap_context` on guarded `mmap` stacks (B1.0–B1.3). `examples/concurrency/1817-concurrency-fiber-m1-end-to-end.sx`:
|
||||
@@ -366,11 +394,14 @@ async (B1.4a: `Task($R)`/`go`/`wait`/`cancel`), deterministic virtual-time timer
|
||||
`clock_ms`/`now_ms`/`sleep`, timer-driven `run`), AND real fd readiness via kqueue (B1.4c: lazy `kq`,
|
||||
`io_waiters`, `block_on_fd`, run-loop Mode 2) — all over the `abi(.naked)` `swap_context` on guarded
|
||||
`mmap` stacks (B1.0–B1.3), reusing `std/net/kqueue.sx`. Every park path (timer sleep, fd block, raw
|
||||
suspend) is balanced through `wake` (which evicts stale timer + fd waiters — the UAF guards). Locked
|
||||
by `18xx` 1800–1817 (naked-asm, context-snapshot, blocking async, the switch + §10.7 stress gate +
|
||||
guarded stacks + Win64 sibling, scheduler round-robin, suspend/wake, async go/wait/cancel, sim-timer
|
||||
ordering, timer early-wake eviction, kqueue pipe I/O, and the **1817 end-to-end capstone**). Suite
|
||||
GREEN **755/0**, master committed.
|
||||
suspend) is balanced through `wake` (which evicts stale timer + fd waiters — the UAF guards). A terminal
|
||||
`deinit` (B1 follow-up) closes the previously-documented leaks: heap `Task`s (tracked via `task_allocs`),
|
||||
the `timers`/`io_waiters`/`task_allocs` List backings, and the kqueue fd; the per-`spawn`/`go` closure
|
||||
env remains unfreeable (language limitation). Locked by `18xx` 1800–1820 (naked-asm, context-snapshot,
|
||||
blocking async, the switch + §10.7 stress gate + guarded stacks + Win64 sibling, scheduler round-robin,
|
||||
suspend/wake, async go/wait/cancel, sim-timer ordering, timer early-wake eviction, kqueue pipe I/O, the
|
||||
**1817 end-to-end capstone**, sleep-negative/double-wait guards, and **1820 scheduler-deinit**). Suite
|
||||
GREEN **759/0**, committed.
|
||||
|
||||
Future work (none blocking B1): a **linux epoll twin** of `block_on_fd` (mirror via `std/net/epoll`;
|
||||
OS-neutral facade `std.event`) — B1.4c wired macOS kqueue only; routing the suspending async through
|
||||
@@ -471,11 +502,13 @@ fibers/Io/scheduler code yet. Grounded floor facts:
|
||||
|
||||
## Next step
|
||||
**Stream B1 is COMPLETE — no next step in this stream.** The pure-sx M:1 async runtime is feature-
|
||||
complete and committed (1800–1817 green, 755/0). Pick up **Stream B2** (channels / structured cancel /
|
||||
async stdlib) as a fresh carve (PLAN-CHANNELS.md), OR one of the documented non-blocking follow-ups:
|
||||
the linux `epoll` twin of `block_on_fd`, a `Scheduler.deinit` (free the kq fd / heap Tasks / drain
|
||||
leaks), `Future(void)`/`timeout` (needs issue 0150), or routing the suspending async through the
|
||||
erased `context.io` for the M:N model. None of these block B1.
|
||||
complete and committed (1800–1820 green, 759/0), now WITH a `Scheduler.deinit` closing the bounded
|
||||
leaks. Pick up **Stream B2** (channels / structured cancel / async stdlib) as a fresh carve
|
||||
(PLAN-CHANNELS.md), OR one of the remaining non-blocking follow-ups: the linux `epoll` twin of
|
||||
`block_on_fd`, `Future(void)`/`timeout` (needs issue 0150), or routing the suspending async through the
|
||||
erased `context.io` for the M:N model. (`Scheduler.deinit` — DONE, see Last completed step.) None of
|
||||
these block B1. The closure-env leak survives `deinit` (no language affordance to free a closure env);
|
||||
revisit if/when sx grows closure-env ownership.
|
||||
|
||||
**Deferred (future B1.4c sibling): the linux epoll twin of `block_on_fd`.** B1.4c wired the **macOS
|
||||
kqueue** path only (the host is aarch64-macOS). The linux mirror would register interest via
|
||||
@@ -521,6 +554,14 @@ incomplete); a dedicated effort; lambda workers are the idiom meanwhile.
|
||||
codegen (`.unresolved` reaching LLVM emission, `src/backend/llvm/types.zig:196`). Found in the
|
||||
B1.5a review; the scheduler doesn't use it (array-field index + `.*` only). Filed for its own
|
||||
session: `issues/0155-scalar-pointer-index-llvm-panic.{md,sx}`.
|
||||
- **✅ issue 0158 — FIXED** — a plain `union` struct-literal (`b : Overlay = .{ f = 3.14 }`) fell
|
||||
through the generic struct-literal path (`getStructFields` empty for a union → malformed
|
||||
`structInit`, overlapping zero-fill clobbered the member → silent `0.0`). Fix: `lowerStructLiteral`
|
||||
detects a plain-union target → new `lowerUnionLiteral` (`src/ir/lower/stmt.zig`) writes each named
|
||||
member into a union-sized slot via the assignment-path lvalue resolver, then loads it back.
|
||||
Single-arm only (one direct member, or same-arm promoted members); overlapping/different-arm/
|
||||
positional literals are diagnosed. specs.md updated. Regressions: `examples/types/0194` +
|
||||
`examples/diagnostics/1191`.
|
||||
- **✅ issue 0157 — FIXED** (B1.4a) — a user generic `ufcs` method whose name collides with a
|
||||
stdlib re-export resolved via last-wins `fn_ast_map` with no receiver filtering → wrong overload →
|
||||
`$R` unbound → LLVM panic. Fix: `selectUfcsGenericByReceiver` (`src/ir/lower/call.zig`) — most
|
||||
@@ -593,6 +634,17 @@ incomplete); a dedicated effort; lambda workers are the idiom meanwhile.
|
||||
trusted. `18xx` asserts program-emitted ordering contracts, not raw interleaving.
|
||||
|
||||
## Log
|
||||
- **B1 follow-up — `Scheduler.deinit`.** Closes the bounded leaks B1 documented. Added a `task_allocs:
|
||||
List(*void)` field (appended in `go` so the scheduler can reach its generic `Task($R)`s) + a canonical
|
||||
`close` extern, then a terminal idempotent `deinit`: reap leftover ready fibers (`munmap` + free) →
|
||||
free tracked Tasks → `List.deinit` the 3 backings → `close` the lazy kqueue fd (reset `-1`). Closure
|
||||
envs stay unfreeable (documented). Probe-observed the accounting under a tracking GPA (deinit drives
|
||||
live allocs 7→3 in a spawn+sleep+2×go run; residual = envs). Locked by
|
||||
`1820-concurrency-fiber-scheduler-deinit.sx` (one run hits timers + kqueue fd + Tasks; `freed by
|
||||
deinit: 5`, `live after deinit: 5` (env residual), `kq open after run: true`→`kq after deinit: -1`,
|
||||
`read: 3 [97 98 99]`), `.build {"target":"macos"}`. Adversarial review: no real UAF/over-free in the
|
||||
supported deinit-after-`run` path; reconciled a doc contradiction (terminal-contract wording); 0154
|
||||
over-store concern probed + cleared (`kq == -1` right after `init`). Suite GREEN **759/0**.
|
||||
- **B1.4c — real fd-readiness blocking via kqueue (macOS).** De-risked first with a no-scheduler probe
|
||||
(confirmed `size_of(Kevent)==32` and the pipe→kevent roundtrip: `kq_wait` returned 1, `out.ident ==
|
||||
read_fd`, `out.filter == -1`, `out.data == 1` — the struct layout reads the fd back correctly). Then
|
||||
|
||||
@@ -37,9 +37,7 @@ append :: (sh: *Shared, v: i64) {
|
||||
}
|
||||
|
||||
main :: () -> i64 {
|
||||
sh : Shared = ---;
|
||||
sh.n = 0;
|
||||
sh.done[0] = 0; sh.done[1] = 0; sh.done[2] = 0;
|
||||
sh : Shared = .{ n = 0 }; // seq[] + done[] zero-filled
|
||||
|
||||
s := sched.Scheduler.init();
|
||||
ps := @s;
|
||||
|
||||
@@ -32,8 +32,7 @@ Log :: struct { seq: [16]i64; n: i64; }
|
||||
rec :: (l: *Log, v: i64) { l.seq[l.n] = v; l.n = l.n + 1; }
|
||||
|
||||
main :: () -> i64 {
|
||||
lg : Log = ---;
|
||||
lg.n = 0;
|
||||
lg : Log = .{ n = 0 }; // seq[] zero-filled
|
||||
|
||||
s := sched.Scheduler.init();
|
||||
ps := @s;
|
||||
|
||||
@@ -43,8 +43,7 @@ Log :: struct { ids: [16]i64; ts: [16]i64; n: i64; }
|
||||
rec :: (l: *Log, id: i64, t: i64) { l.ids[l.n] = id; l.ts[l.n] = t; l.n = l.n + 1; }
|
||||
|
||||
main :: () -> i64 {
|
||||
lg : Log = ---;
|
||||
lg.n = 0;
|
||||
lg : Log = .{ n = 0 }; // ids[] + ts[] zero-filled
|
||||
|
||||
s := sched.Scheduler.init();
|
||||
ps := @s;
|
||||
|
||||
@@ -41,10 +41,7 @@ S :: struct {
|
||||
}
|
||||
|
||||
main :: () -> i64 {
|
||||
st : S = ---;
|
||||
st.wrote = false;
|
||||
st.read_n = 0;
|
||||
st.read_done = false;
|
||||
st : S = .{ wrote = false, read_n = 0, read_done = false }; // bytes[] zero-filled
|
||||
|
||||
fds : [2]i32 = ---;
|
||||
if pipe(@fds[0]) != 0 {
|
||||
|
||||
124
examples/concurrency/1820-concurrency-fiber-scheduler-deinit.sx
Normal file
124
examples/concurrency/1820-concurrency-fiber-scheduler-deinit.sx
Normal file
@@ -0,0 +1,124 @@
|
||||
// Stream B1 (fibers) — `Scheduler.deinit` releases the scheduler's owned heap
|
||||
// + fd resources, closing the documented bounded leaks (kq fd / heap Tasks /
|
||||
// List backings). Verified by a tracking `GPA`: deinit drives the live
|
||||
// allocation count DOWN, and resets the kqueue fd to -1.
|
||||
//
|
||||
// Scenario (one run that touches every freed resource):
|
||||
// - a SLEEPER fiber `sleep(5)`s → exercises the `timers` List
|
||||
// - a READER fiber `block_on_fd`s a pipe → exercises the kqueue fd + the
|
||||
// `io_waiters` List
|
||||
// - a WRITER fiber writes 3 bytes → makes the pipe readable
|
||||
// - two `go` tasks compute 42 / 7 → exercise the heap `Task`s +
|
||||
// the `task_allocs` List
|
||||
// After `run()` drains all of it, `deinit()` frees: the 2 heap Tasks, the
|
||||
// `timers` / `io_waiters` / `task_allocs` List backings, and CLOSES the kqueue
|
||||
// fd (resetting `kq` to -1). The Fibers were already reaped during `run()`.
|
||||
//
|
||||
// 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`/`go`
|
||||
// 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.
|
||||
// - `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
|
||||
// kqueue, read 'a' 'b' 'c'), so the kq we close is a real, used fd.
|
||||
//
|
||||
// Counts are captured into locals BEFORE any `print` — `print` itself allocates
|
||||
// format temporaries through the same GPA, which would otherwise pollute the
|
||||
// reading.
|
||||
//
|
||||
// aarch64-macOS-pinned (`.build {"target":"macos"}`, matches host → runs
|
||||
// end-to-end): sched.sx's switch asm + the kqueue path are per-arch/Apple.
|
||||
#import "modules/std.sx";
|
||||
sched :: #import "modules/std/sched.sx";
|
||||
|
||||
// Raw libc fd primitives — canonical signatures (the extern dedupe rejects a
|
||||
// divergent re-binding of the same C symbol). `close` matches sched.sx's own.
|
||||
pipe :: (fds: *i32) -> i32 extern libc "pipe";
|
||||
read :: (fd: i32, buf: [*]u8, count: usize) -> isize extern libc "read";
|
||||
write :: (fd: i32, buf: [*]u8, count: usize) -> isize extern libc "write";
|
||||
close :: (fd: i32) -> i32 extern libc "close";
|
||||
|
||||
S :: struct {
|
||||
read_n: i64;
|
||||
bytes: [8]u8;
|
||||
read_done: bool;
|
||||
}
|
||||
|
||||
main :: () -> i64 {
|
||||
st : S = .{ read_n = 0, read_done = false }; // bytes[] zero-filled; read() fills it
|
||||
|
||||
fds : [2]i32 = ---;
|
||||
if pipe(@fds[0]) != 0 {
|
||||
print("1820: pipe() failed\n");
|
||||
return 1;
|
||||
}
|
||||
read_fd := fds[0];
|
||||
write_fd := fds[1];
|
||||
|
||||
// Captured under the GPA scope; printed after it closes.
|
||||
after_run : i64 = 0;
|
||||
after_deinit : i64 = 0;
|
||||
kq_open_run : bool = false;
|
||||
kq_after : i32 = 0;
|
||||
|
||||
gpa := mem.GPA.init();
|
||||
push Context.{ allocator = xx gpa, data = null } {
|
||||
s := sched.Scheduler.init();
|
||||
ps := @s; pst := @st;
|
||||
|
||||
// SLEEPER — arms a virtual-time timer, then parks.
|
||||
ps.spawn(() => { ps.sleep(5); });
|
||||
|
||||
// READER — blocks on the empty pipe until kqueue reports it readable.
|
||||
mk_reader :: (ps: *sched.Scheduler, pst: *S, rfd: i32) {
|
||||
ps.spawn(() => {
|
||||
ps.block_on_fd(rfd, true);
|
||||
n := read(rfd, xx @pst.bytes[0], xx 3);
|
||||
pst.read_n = xx n;
|
||||
pst.read_done = true;
|
||||
});
|
||||
}
|
||||
// WRITER — writes 'a' 'b' 'c', making the pipe readable.
|
||||
mk_writer :: (ps: *sched.Scheduler, wfd: i32) {
|
||||
ps.spawn(() => {
|
||||
buf : [3]u8 = ---;
|
||||
buf[0] = xx 97; buf[1] = xx 98; buf[2] = xx 99;
|
||||
write(wfd, xx @buf[0], xx 3);
|
||||
});
|
||||
}
|
||||
mk_reader(ps, pst, read_fd);
|
||||
mk_writer(ps, write_fd);
|
||||
|
||||
// Two async tasks — heap Tasks tracked for deinit to free.
|
||||
ps.go(() -> i64 => 42);
|
||||
ps.go(() -> i64 => 7);
|
||||
|
||||
ps.run();
|
||||
|
||||
after_run = gpa.alloc_count;
|
||||
kq_open_run = s.kq >= 0;
|
||||
ps.deinit();
|
||||
after_deinit = gpa.alloc_count;
|
||||
kq_after = s.kq;
|
||||
}
|
||||
|
||||
print("read: {} [", st.read_n);
|
||||
i := 0;
|
||||
while i < st.read_n {
|
||||
if i > 0 { print(" "); }
|
||||
print("{}", st.bytes[i]);
|
||||
i = i + 1;
|
||||
}
|
||||
print("]\n");
|
||||
print("freed by deinit: {}\n", after_run - after_deinit);
|
||||
print("live after deinit: {}\n", after_deinit);
|
||||
print("kq open after run: {}\n", kq_open_run);
|
||||
print("kq after deinit: {}\n", kq_after);
|
||||
|
||||
close(read_fd);
|
||||
close(write_fd);
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "target": "macos" }
|
||||
@@ -0,0 +1 @@
|
||||
0
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
read: 3 [97 98 99]
|
||||
freed by deinit: 5
|
||||
live after deinit: 5
|
||||
kq open after run: true
|
||||
kq after deinit: -1
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user