fibers: deterministic virtual-time timers (B1.4b)

Add a virtual clock + sleep timers to the M:1 scheduler so fibers
schedule in reproducible simulated time. Scheduler gains clock_ms (the
virtual clock, advances only as timers fire), a timers list, now_ms(),
sleep(ms) (arm {clock_ms+ms, current} + suspend), and a timer-driven
run (drain ready -> fire earliest timer -> advance clock -> wake ->
repeat; the orphan-suspend deadlock check is preserved for a genuine
no-timer park). Wakes fire in deadline order with a FIFO tiebreak.

Adversarial review found a use-after-free: a fiber woken early (manual
or Task wake) before its sleep timer fired was reaped while its Timer
kept a dangling *Fiber, so a later fire dereferenced freed memory.
Fixed: wake evicts the fiber's pending timer (cancel_timer_for) -- every
re-ready path funnels through wake, so no stale timer outlives its fiber.

Examples: 1814 (sim-timer deadline ordering), 1815 (early-wake timer
eviction regression). Suite green 753/0.
This commit is contained in:
agra
2026-06-21 19:09:22 +03:00
parent 02ab077bfb
commit 62ffea0663
13 changed files with 363 additions and 64 deletions

View File

@@ -0,0 +1,74 @@
// Stream B1 (fibers) B1.4b — deterministic VIRTUAL-TIME timer scheduling (the
// KEYSTONE), in pure sx over the M:1 scheduler. A fiber `sleep(ms)`s in
// SIMULATED time; the scheduler wakes fibers in DEADLINE order, advancing a
// virtual clock that moves only when the ready queue drains and the earliest
// timer fires. No real wall clock is ever read — the wake ORDER and the
// observed timestamps are fully reproducible, which is exactly what a
// deterministic-sim Io test harness needs.
//
// HOW IT WORKS. `s.sleep(ms)` arms a timer `{ clock_ms + ms, current }` and
// parks the fiber off-queue. `s.run` drives ready fibers to quiescence, then
// fires the earliest pending timer: it advances `clock_ms` to that deadline and
// `wake`s the sleeper (re-readying it), and repeats until both the ready queue
// AND the timer set are empty. So a fiber that just woke reads `now_ms()` equal
// to its own deadline.
//
// WHAT THIS PROVES.
// - Deadline-ordered wake (NOT spawn order): spawn A, B, C in that order;
// A sleep(30), B sleep(10), C sleep(20). Wakes fire B(10), C(20), A(30) —
// reordered by deadline, not by spawn order.
// - Virtual timestamps: each fiber on wake reads `now_ms()` == its deadline
// (10, 20, 30) — the virtual clock landed exactly on the firing deadline.
// - FIFO tiebreak: two fibers D, E both sleep(15) — they wake in spawn
// (insertion) order D then E, the deterministic equal-deadline contract.
//
// §8.1.3 CALIBRATION NOTE. The deterministic virtual-time wake ORDER equals
// what real `sleep`s would produce: under real blocking sleeps the OS would
// also wake the shortest sleeper first, i.e. in deadline order. The sim
// reproduces blocking semantics' OBSERVABLE ordering (and the relative
// timestamps) without consuming real time or admitting nondeterminism — so a
// harness can assert exact orderings that a wall-clock test could only
// approximate. (No real-time variant is run here; the equivalence is the
// contract the deterministic test relies on.)
//
// aarch64-macOS-pinned (the scheduler's `swap_context` asm + guard-page mmap
// constants are per-arch / Apple-specific): runs end-to-end on a matching host,
// ir-only on a mismatch.
#import "modules/std.sx";
sched :: #import "modules/std/sched.sx";
// Shared wake log, captured by pointer into each fiber's thunk (closure
// capture-by-value does not write back, so outputs flow through `*Log`).
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;
s := sched.Scheduler.init();
ps := @s;
pl := @lg;
// Spawn order A, B, C, D, E — but the WAKE order is set by deadline.
ps.spawn(() => { ps.sleep(30); rec(pl, 1, ps.now_ms()); }); // A: latest
ps.spawn(() => { ps.sleep(10); rec(pl, 2, ps.now_ms()); }); // B: earliest
ps.spawn(() => { ps.sleep(20); rec(pl, 3, ps.now_ms()); }); // C: middle
// Same-deadline FIFO pair: D before E, both at t=15 → wake D then E.
ps.spawn(() => { ps.sleep(15); rec(pl, 4, ps.now_ms()); }); // D
ps.spawn(() => { ps.sleep(15); rec(pl, 5, ps.now_ms()); }); // E
s.run();
// Ordering contract: deadline order with a FIFO tiebreak → B, D, E, C, A
// at virtual times 10, 15, 15, 20, 30.
print("wake order (id @ virtual-ms):\n");
i := 0;
while i < lg.n {
print(" id={} @ {}ms\n", lg.ids[i], lg.ts[i]);
i = i + 1;
}
print("final virtual clock: {}ms\n", s.now_ms());
print("spawned: {}\n", s.n_spawned);
return 0;
}

View File

@@ -0,0 +1,47 @@
// Stream B1 (fibers) B1.4b — a fiber's pending `sleep` timer is EVICTED when it
// is woken early by another path, so a stale timer can never outlive (and
// dereference) a reaped fiber.
//
// Scenario: a "sleeper" fiber arms `sleep(100)` and parks; a "waker" fiber wakes
// it EARLY (at virtual t=0) via `wake`. The sleeper resumes, finishes, and is
// reaped (its stack `munmap`'d + `Fiber` freed). Its 100ms timer must already be
// gone — otherwise, when the run loop later fired that stale timer, it would
// `wake` a freed `*Fiber` (use-after-free) and wrongly advance the virtual clock
// to 100. Here `wake` evicts the timer, so the clock stays at 0 and nothing
// dereferences freed memory.
//
// Regression: the timer-vs-early-wake use-after-free found reviewing B1.4b.
// Contract: `log: 2 1` (waker records 2, then the early-woken sleeper records 1),
// `clock: 0` (no stale timer fired), `n_suspended: 0` (balanced).
//
// aarch64-macOS-pinned (the scheduler's per-arch asm + Apple mmap constants):
// runs end-to-end on a matching host, ir-only on a mismatch.
#import "modules/std.sx";
sched :: #import "modules/std/sched.sx";
S :: struct { sleeper: *sched.Fiber; log: [8]i64; n: i64; }
rec :: (s: *S, v: i64) { s.log[s.n] = v; s.n = s.n + 1; }
main :: () -> i64 {
st : S = ---; st.n = 0; st.sleeper = null;
s := sched.Scheduler.init();
ps := @s; pst := @st;
// Sleeper: arm sleep(100), park; when woken (early), record 1 and finish.
mk_sleeper :: (ps: *sched.Scheduler, pst: *S) {
pst.sleeper = ps.spawn(() => { ps.sleep(100); rec(pst, 1); });
}
// Waker: record 2, then wake the sleeper BEFORE its 100ms timer fires.
mk_waker :: (ps: *sched.Scheduler, pst: *S) {
ps.spawn(() => { rec(pst, 2); ps.wake(pst.sleeper); });
}
mk_sleeper(ps, pst);
mk_waker(ps, pst);
s.run();
print("log:");
i := 0; while i < st.n { print(" {}", st.log[i]); i = i + 1; }
print("\n");
print("clock: {} n_suspended: {}\n", s.now_ms(), s.n_suspended);
return 0;
}

View File

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

View File

@@ -0,0 +1,8 @@
wake order (id @ virtual-ms):
id=2 @ 10ms
id=4 @ 15ms
id=5 @ 15ms
id=3 @ 20ms
id=1 @ 30ms
final virtual clock: 30ms
spawned: 5

View File

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

View File

@@ -0,0 +1,2 @@
log: 2 1
clock: 0 n_suspended: 0