From 561f954085624c6dcafa151d6d645303cede359a Mon Sep 17 00:00:00 2001 From: InauguralPhysicist Date: Thu, 9 Jul 2026 23:54:07 -0500 Subject: [PATCH] fix(tasks): same-instant sleeper wake order is spawn-order, not id-order (#535) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sched_wake_sleepers woke due sleepers in ascending handle-id order, but ids come from a rotating next-fit cursor — once slots recycle (#530), the tie-break encoded the process's whole allocation history, so two identical seeded runs in one process could interleave differently. Surfaced by liferaft's M4 fault-injection gate: an in-sweep violation failed to reproduce standalone (verified=0). Tasks now carry a monotonic spawn_seq (main = 0) and same-instant wakes order by it — a pure function of the run. Red/green: a wraparound-straddling sleeper race (ids 254,255,1) observed abc/cab pre-fix, identical post-fix. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 9 +++++++ src/vm.c | 31 ++++++++++++++++++------ src/vm.h | 4 ++++ tests/run_all_tests.sh | 1 + tests/test_task_sleep_order.eigs | 41 ++++++++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 tests/test_task_sleep_order.eigs diff --git a/CHANGELOG.md b/CHANGELOG.md index afc6844..656a1f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -207,6 +207,15 @@ All notable changes to EigenScript are documented here. (the one legitimate empty-expression position) still yields `null` (#494). ### Fixed +- **Same-instant sleeper wake order no longer depends on handle IDs (#535).** + `sched_wake_sleepers` woke due sleepers in ascending handle-id order, but + ids come from a rotating next-fit cursor — so once slots recycle (#530), + the tie-break encoded the process's whole allocation history and two + identical seeded runs in one process could interleave differently + (surfaced by liferaft's in-sweep fault verify failing to reproduce + standalone). Tasks now carry a monotonic `spawn_seq` and same-instant + wakes order by it — a pure function of the run. Regression-pinned with a + wraparound-straddling sleeper race (`tests/test_task_sleep_order.eigs`). - **JIT: task code now stays interpreted at EVERY entry point (#533).** The #408 ruling ("task code runs interpreted") was enforced only at the fresh-entry gate: the OSR back-edge counter/handoff and the OP_CALL / diff --git a/src/vm.c b/src/vm.c index 94ba669..c755fe0 100644 --- a/src/vm.c +++ b/src/vm.c @@ -5191,6 +5191,7 @@ typedef struct { int active; /* armed on first spawn */ int dead_letters; /* inc 2: sends to finished/unknown tasks */ int detached_err_count; /* #530: reaped detached tasks that died unobserved (#493 gate) */ + uint64_t spawn_counter; /* #535: monotonically increasing; stamps Task.spawn_seq */ double now; /* inc 3: virtual clock (logical, starts 0) */ int seeded; /* inc 4: 1 once task_sched_seed installs a seed */ uint64_t rng_state; /* inc 4: splitmix64 state for the seeded pick */ @@ -5382,6 +5383,12 @@ void task_sched_on_spawn(int id) { TaskScheduler *s = sched_ensure(); s->active = 1; s->live++; + /* #535: stamp spawn order. Handle IDs come from a rotating cursor, so + * they encode the process's WHOLE allocation history; any id-ordered + * tie-break makes the interleaving history-dependent. Spawn order is a + * pure function of the run. Main is 0 (spawn_counter starts at 1). */ + Task *t = sched_lookup(s, id); + if (t) t->spawn_seq = ++s->spawn_counter; sched_ready_push(s, id); } @@ -5510,18 +5517,28 @@ static int sched_wake_sleepers(TaskScheduler *s) { } if (!have_best) return 0; s->now = best; - /* Wake main first, then tasks in ascending id order — a fixed tie-break. */ + /* Wake main first, then tasks in ascending SPAWN order (#535) — NOT id + * order: ids come from a rotating next-fit cursor, so id order encodes + * the process's whole allocation history and two identical seeded runs + * in one process could interleave differently once slots recycle + * (surfaced by liferaft's in-sweep fault verify failing to reproduce + * standalone). Spawn order is a pure function of the run itself. */ if (s->main_task.state == TASK_SUSPENDED && s->main_task.sleeping && s->main_task.wake_at <= s->now) { s->main_task.sleeping = 0; sched_ready_push(s, 0); } - for (int i = 1; i < HANDLE_TABLE_SIZE; i++) { - Task *t = (Task *)handle_lookup(i, HANDLE_TASK); - if (t && t->state == TASK_SUSPENDED && t->sleeping && t->wake_at <= s->now) { - t->sleeping = 0; - sched_ready_push(s, t->id); - } + for (;;) { + Task *next = NULL; + for (int i = 1; i < HANDLE_TABLE_SIZE; i++) { + Task *t = (Task *)handle_lookup(i, HANDLE_TASK); + if (t && t->state == TASK_SUSPENDED && t->sleeping && t->wake_at <= s->now && + (!next || t->spawn_seq < next->spawn_seq)) + next = t; + } + if (!next) break; + next->sleeping = 0; + sched_ready_push(s, next->id); } return 1; } diff --git a/src/vm.h b/src/vm.h index 8f10111..38cb1ea 100644 --- a/src/vm.h +++ b/src/vm.h @@ -439,6 +439,10 @@ typedef struct Task { * handle slot released) the moment it finishes or is killed, instead of * lingering joinable until process exit. */ int detached; + /* #535: monotonic spawn stamp (scheduler counter; main is 0). The + * same-instant sleeper-wake tie-break orders by THIS, never by handle + * id — ids come from a rotating cursor and encode allocation history. */ + uint64_t spawn_seq; } Task; /* Free a Task's held refs and the struct. Safe on any state; called by diff --git a/tests/run_all_tests.sh b/tests/run_all_tests.sh index a62315a..df50584 100755 --- a/tests/run_all_tests.sh +++ b/tests/run_all_tests.sh @@ -2177,6 +2177,7 @@ check_eigs_suite "cooperative tasks: yield/join/deadlock/teardown (#408)" test_t # so the recv loop crosses it fast) — a mid-task OSR compile made task_recv # return its placeholder null and corrupted the task at the next call site. EIGS_JIT_OSR_THRESHOLD=20 check_eigs_suite "task loops stay interpreted past the OSR threshold (#533)" test_task_osr.eigs "task-osr: all passed" 1 +check_eigs_suite "sleeper wake order is allocation-history-independent (#535)" test_task_sleep_order.eigs "sleeper-order: all passed" 1 # lib/sync — cooperative-task lock gives mutual exclusion across yield points # (#488): unlocked non-atomic RMW loses updates, the lock closes the race, diff --git a/tests/test_task_sleep_order.eigs b/tests/test_task_sleep_order.eigs new file mode 100644 index 0000000..bd81a21 --- /dev/null +++ b/tests/test_task_sleep_order.eigs @@ -0,0 +1,41 @@ +# #535: same-instant sleeper wake order must be a pure function of the run, +# not of handle-id allocation history. Ids come from a rotating next-fit +# cursor, so after enough churn a batch of spawns straddles the table +# wraparound and id order diverges from spawn order. Pre-fix the wake +# tie-break was ascending handle id — the SAME race observed a different +# order depending on how much allocation history preceded it. Post-fix the +# tie-break is spawn order: both observations must be identical. +g_order is [] +define racer(tagout) as: + task_sleep of 1 + append of [g_order, tagout] + +define run_race() as: + g_order is [] + local a is task_spawn of [racer, "a"] + task_detach of a + local b is task_spawn of [racer, "b"] + task_detach of b + local c is task_spawn of [racer, "c"] + task_detach of c + task_sleep of 2 + return f"{g_order[0]}{g_order[1]}{g_order[2]}" + +define churn_w() as: + return 1 + +first is run_race of null +# March the slot cursor to just before the 255-slot wrap so the second +# race's three racers get ids like (254, 255, 1) — id order then disagrees +# with spawn order. 250 = 253 - the 3 slots the first race consumed. +n is 0 +loop while n < 250: + t is task_spawn of churn_w + task_detach of t + task_yield of null + n is n + 1 +second is run_race of null +if first == second: + print of f"sleeper-order: all passed ({first})" +else: + print of f"sleeper-order: FAILED first={first} second={second}"