Stage 3 follow-on: fix stack-ownership corruption + DoE switch columns (FABRIC-3.md §XXVIII.1)
Build / build-amd64-iso (push) Canceled after 0s
Build / build-aarch64-iso (push) Canceled after 0s
Build / build-riscv64-img (push) Canceled after 0s

DoE CSV gained 6 switch-signal columns (switch_count_cumulative,
switch_current_slot, switch_*_readiness, switch_ticks_since), and verifying
them with a boot-time HB-ON probe surfaced a real livelock: the preemption
checkpoint could fire inside a VM-EXEC-nested execute_colon_word() call and
switch away from a stack it didn't own, parking a borrowed region of the
caller's stack under the wrong VM's saved-context pointer. The trampoline
bounce was the visible (safe) half of this; the corruption was the quiet
half, live in every prior "clean" Stage 3 boot without ever showing up in
the log.

Fixed by gating the checkpoint on being at the outermost vm_interpret()
call (g_vm_interpret_depth / sk_vm_at_outermost_interpret(), vm_core.c),
per Bob's decision. Also fixed two related bugs found in the same pass:
g_switch_back_to was a single global stale after first entry, now per-VM
state (native_switch_back_to); note_switch_performed() fired on resume
instead of switch-out, now called before the switch.

Verified on all 3 architectures: steady log growth (no freeze), zero
leaked QEMU processes, DoE columns internally consistent, Hermes/Artemis
confirmed genuinely executing (not just trampoline-bouncing). Temporary
HB-ON boot probe reverted after capture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016UNhH1mhi52i6Qihh7ZV5S
This commit is contained in:
Robert Allan James
2026-09-13 23:58:59 -04:00
co-authored by Claude Sonnet 5
parent 986d042aa7
commit 862d7d9c48
19 changed files with 79695 additions and 19 deletions
@@ -56,6 +56,11 @@ static int g_slot_count;
static int g_pending;
static VMUuid g_pending_target;
/* DoE CSV exposure (FABRIC-3.md §XXVIII Stage 3 follow-on, 2026-09-13). */
static int g_current_slot_cached = -1;
static uint64_t g_switch_count;
static uint32_t g_ticks_since_switch;
static int slot_for_vm_id(VMUuid vm_id) {
int i;
for (i = 0; i < g_slot_count; i++) {
@@ -81,6 +86,9 @@ void sk_vm_switch_signal_tick(void) {
current_slot = slot_for_vm_id(current->stadium_vm_id);
if (current_slot < 0) return; /* current VM isn't a registered participant */
g_current_slot_cached = current_slot;
g_ticks_since_switch++;
for (i = 0; i < g_slot_count; i++) {
if (i == current_slot) {
g_slots[i].readiness = 0;
@@ -106,4 +114,36 @@ VMUuid sk_vm_switch_signal_take_pending(void) {
return g_pending_target;
}
void sk_vm_switch_signal_note_switch_performed(void) {
g_switch_count++;
g_ticks_since_switch = 0;
}
uint64_t sk_vm_switch_signal_switch_count(void) {
return g_switch_count;
}
uint32_t sk_vm_switch_signal_ticks_since_switch(void) {
return g_ticks_since_switch;
}
int sk_vm_switch_signal_current_slot(void) {
return g_current_slot_cached;
}
int sk_vm_switch_signal_slot_count(void) {
return g_slot_count;
}
uint32_t sk_vm_switch_signal_readiness(int slot) {
if (slot < 0 || slot >= g_slot_count) return 0;
return g_slots[slot].readiness;
}
uint32_t sk_vm_switch_signal_readiness_of(VMUuid vm_id) {
int slot = slot_for_vm_id(vm_id);
if (slot < 0) return 0;
return g_slots[slot].readiness;
}
#endif /* __STARKERNEL__ */
+55 -3
View File
@@ -46,6 +46,29 @@
* 20. fleet_conserved uint32 — vm_physics_conserved() as 0/1: whether
* |fleet_k_q48 - Q48_ONE| < VM_PHYSICS_EPSILON_Q48
* at the moment this row was captured.
* 21. switch_count_cumulative uint64 — Stage 3 preemptive-switch count, cumulative
* since boot (capsule_vm_switch_signal.c).
* 22. switch_current_slot uint32 — which registered switch-signal participant is
* executing at this tick (0=Hera,1=Hermes,
* 2=Artemis by current registration order;
* 0xFFFFFFFF = none/unregistered at this moment).
* 23. switch_hera_readiness uint32 — Hera's raw readiness accumulator (slot 0).
* 24. switch_hermes_readiness uint32 — Hermes's raw readiness accumulator (slot 1).
* 25. switch_artemis_readiness uint32 — Artemis's raw readiness accumulator (slot 2).
* 26. switch_ticks_since uint32 — ticks elapsed since the last real switch
* (capsule_vm_switch_signal.c); empirically
* verifies the fixed 50-tick threshold under
* real DoE load rather than assuming it.
*
* Columns 21-26 (FABRIC-3.md §XXVIII Stage 3 follow-on, 2026-09-13): added
* alongside Stage 3's timer-driven preemptive context switching -- read-only
* exposure of state the switch-signal module already tracked internally for its
* own decision-making; no new measurement logic, just made observable. Slot
* readiness columns are hardcoded to the 3 known Tripod slots (same convention
* as columns 16-18) rather than a dynamic per-slot column set, for the same
* reason: this is a logging schema for the currently-known fleet, not the
* switch-signal mechanism itself (which stays slot-count-agnostic, headroom for
* up to 8 participants -- see capsule_vm_switch_signal.h).
*
* Note: avg_word_heat and estimated_jitter_ns are stored as double in the
* snapshot but the freestanding snprintf has no %%f support. avg_word_heat
@@ -67,6 +90,7 @@
#include "starkernel/timer.h"
#include "starkernel/capsule_birth.h"
#include "starkernel/capsule_vm_physics.h"
#include "starkernel/capsule_vm_switch_signal.h" /* FABRIC-3.md §XXVIII Stage 3 follow-on */
#include "freestanding/stdio.h"
#include "word_registry.h"
@@ -83,7 +107,10 @@ static const char *doe_header =
"window_width,actual_window_size,predicted_label_hits,jitter_bits,"
"apic_ticks,time_trust_q48,variance_q48,vm_call_depth_max,"
"hera_heat_q48,hermes_heat_q48,artemis_heat_q48,"
"fleet_k_q48,fleet_conserved";
"fleet_k_q48,fleet_conserved,"
"switch_count_cumulative,switch_current_slot,"
"switch_hera_readiness,switch_hermes_readiness,switch_artemis_readiness,"
"switch_ticks_since";
/* Looks up a Tripod VM's fleet heat by name; 0 if not currently registered
* (e.g. between KILL and rebirth). */
@@ -133,9 +160,28 @@ void doe_log_tick_row(VM *vm, const HeartbeatTickSnapshot *snap)
uint64_t fleet_k_q48 = vm_physics_fleet_heat_sum();
unsigned fleet_conserved = vm_physics_conserved() ? 1u : 0u;
/* FABRIC-3.md §XXVIII Stage 3 follow-on (2026-09-13): switch-signal
* exposure, looked up by name each tick (same convention as the heat
* columns above -- registration order is not assumed fixed). */
uint64_t switch_count = sk_vm_switch_signal_switch_count();
int switch_slot = sk_vm_switch_signal_current_slot();
unsigned switch_current_slot = (switch_slot >= 0) ? (unsigned)switch_slot : 0xFFFFFFFFu;
uint32_t switch_ticks_since = sk_vm_switch_signal_ticks_since_switch();
uint32_t hera_readiness = 0, hermes_readiness = 0, artemis_readiness = 0;
{
VMRegistryEntry e;
if (capsule_vm_find_by_name_nocase("Hera", &e) == 0)
hera_readiness = sk_vm_switch_signal_readiness_of(e.vm_id);
if (capsule_vm_find_by_name_nocase("Hermes", &e) == 0)
hermes_readiness = sk_vm_switch_signal_readiness_of(e.vm_id);
if (capsule_vm_find_by_name_nocase("Artemis", &e) == 0)
artemis_readiness = sk_vm_switch_signal_readiness_of(e.vm_id);
}
char buf[DOE_BUF_SIZE];
snprintf(buf, sizeof(buf),
"%u,%llu,%llu,%u,%u,%u,%llu,%llu,%u,%u,%u,%llu,%llu,%llu,%llu,%u,%llu,%llu,%llu,%llu,%u",
"%u,%llu,%llu,%u,%u,%u,%llu,%llu,%u,%u,%u,%llu,%llu,%llu,%llu,%u,%llu,%llu,%llu,%llu,%u,"
"%llu,%u,%u,%u,%u,%u",
snap->tick_number,
(unsigned long long)snap->elapsed_ns,
(unsigned long long)snap->tick_interval_ns,
@@ -156,7 +202,13 @@ void doe_log_tick_row(VM *vm, const HeartbeatTickSnapshot *snap)
(unsigned long long)hermes_heat_q48,
(unsigned long long)artemis_heat_q48,
(unsigned long long)fleet_k_q48,
fleet_conserved);
fleet_conserved,
(unsigned long long)switch_count,
switch_current_slot,
hera_readiness,
hermes_readiness,
artemis_readiness,
switch_ticks_since);
console_puts(DOE_PREFIX);
console_puts(buf);
+22 -13
View File
@@ -39,14 +39,17 @@
extern void sk_vm_switch_to(uint64_t *save_sp_out, uint64_t new_sp);
extern uint64_t sk_vm_switch_prime(uint64_t stack_top, void *entry);
/* Single-writer-mainline globals, safe under this stage's cooperative-
* only, one-VM-runs-at-a-time-on-one-hart discipline (same class of
* bookkeeping as vm_core.c's g_log_attrib_vm) -- set immediately before a
* first-ever switch into a VM, read exactly once, synchronously, at the
* very top of sk_vm_switch_entry() before any other switch could occur.
* Preemption (Stage 3) will need to revisit this; flagged there, not here. */
/* Single-writer-mainline global, safe under this stage's cooperative-only,
* one-VM-runs-at-a-time-on-one-hart discipline (same class of bookkeeping
* as vm_core.c's g_log_attrib_vm) -- identifies which VM a freshly-primed
* native stack belongs to. Read exactly once, synchronously, at the very
* top of sk_vm_switch_entry() before any other first-entry could occur.
* Safe to keep as a plain global (unlike the old g_switch_back_to this
* file used to also carry -- see native_switch_back_to's own doc comment
* in vm.h for why that one had to move to per-VM state): a VM's identity
* never changes across the lifetime of its own trampoline instance, only
* who most recently switched into it does. */
static VM *g_switch_entry_vm;
static VM *g_switch_back_to;
/**
* sk_vm_switch_entry - trampoline for a VM's first-ever entry.
@@ -55,15 +58,17 @@ static VM *g_switch_back_to;
* switched-to VM should actually DO once running is later-stage work
* (message dispatch, an idle loop, whatever Stage 3+ needs). For now this
* is a minimal, permanent placeholder: immediately yield back to whoever
* switched to it, forever. Must never fall off the end -- there is no
* legitimate return address below a synthetic first-entry frame, unlike
* an ordinary function.
* most recently switched to it, forever. Reads self->native_switch_back_to
* fresh every iteration (not a value captured once) so a later switch-in
* from a different VM is honored, not silently bounced to whoever entered
* first. Must never fall off the end -- there is no legitimate return
* address below a synthetic first-entry frame, unlike an ordinary
* function.
*/
void sk_vm_switch_entry(void) {
VM *self = g_switch_entry_vm;
VM *back = g_switch_back_to;
for (;;) {
sk_vm_context_switch(self, back);
sk_vm_context_switch(self, self->native_switch_back_to);
}
}
@@ -74,10 +79,14 @@ int sk_vm_context_switch(VM *from, VM *to) {
return -1;
}
/* Recorded unconditionally (first-ever entry or a resume) so whoever
* is parked at `to`'s native stack -- most often this placeholder
* trampoline -- always knows who most recently switched into it. */
to->native_switch_back_to = from;
if (to->native_stack_saved_sp == 0) {
/* First-ever entry: synthesize the initial frame. */
g_switch_entry_vm = to;
g_switch_back_to = from;
new_sp = sk_vm_switch_prime(to->native_stack_top, (void *)&sk_vm_switch_entry);
} else {
new_sp = to->native_stack_saved_sp;
+46 -2
View File
@@ -63,6 +63,12 @@
#include "starkernel/capsule_vm_switch_signal.h" /* FABRIC-3.md §XXVIII Stage 3 */
#include "starkernel/vm/switch.h" /* FABRIC-3.md §XXVIII Stage 2 -- sk_vm_context_switch() */
#include "starkernel/capsule_birth.h" /* capsule_vm_registry_get() */
/* FABRIC-3.md §XXVIII Stage 3 correction (2026-09-13): forward-declared
* here because execute_colon_word() (uses it) appears earlier in this file
* than g_vm_interpret_depth's definition (kept next to g_log_attrib_vm,
* near vm_interpret() -- see that doc comment for the full rationale). */
int sk_vm_at_outermost_interpret(void);
#endif
#include "word_source/include/vocabulary_words.h"
#include "vm_internal.h"
@@ -893,13 +899,35 @@ void execute_colon_word(VM* vm)
* Only ever acts when this word's own execution left the VM in a
* clean, resumable state (past the error/abort/exit_colon checks
* below would be too late -- this runs before them, deliberately,
* so a switch never happens mid-unwind). */
{
* so a switch never happens mid-unwind).
*
* Stage 3 correction (2026-09-13): also gated on
* sk_vm_at_outermost_interpret() -- see g_vm_interpret_depth's own
* doc comment above vm_interpret(). If this word is executing
* nested inside a VM-EXEC/VM-CALL dispatch (from another VM's own
* vm_interpret() call, still on the mainline stack), `vm` here does
* not own the physical stack it's currently running on, so a real
* switch must not fire -- it would park that borrowed stack region
* under this VM's saved-context pointer, corrupting the enclosing
* VM's own call chain the moment it resumes. Left pending
* (take_pending() is not called at all in that case) so the next
* checkpoint at the outermost frame picks it up instead of losing
* it.
*
* note_switch_performed() is called BEFORE sk_vm_context_switch(),
* not after (Stage 3 follow-on correction, 2026-09-13): the switch
* call does not return until something later switches back to
* `vm`, which could be an arbitrary number of ticks later --
* calling it after would attribute switch_count_cumulative/
* switch_ticks_since to the wrong (resume) event instead of the
* actual switch-out this counts. */
if (sk_vm_at_outermost_interpret()) {
VMUuid target_id = sk_vm_switch_signal_take_pending();
if (!vm_uuid_equal(target_id, vm_uuid_none())) {
VMRegistryEntry target_entry;
if (capsule_vm_registry_get(target_id, &target_entry) == 0 &&
target_entry.vm_ptr && target_entry.vm_ptr != (void *)vm) {
sk_vm_switch_signal_note_switch_performed();
sk_vm_context_switch(vm, (VM *)target_entry.vm_ptr);
/* Resumes here once something later switches back. */
}
@@ -1084,6 +1112,20 @@ static VM *g_log_attrib_vm; /* zero-initialized (BSS) */
VM *vm_log_attributed_vm(void) { return g_log_attrib_vm; }
/* FABRIC-3.md §XXVIII Stage 3 correction (2026-09-13): nesting depth of
* vm_interpret() calls, tracked the same save/restore-adjacent way as
* g_log_attrib_vm just above. A nested call (VM-EXEC/VM-CALL dispatching
* into a different VM's own dictionary mid-interpret) runs on physical
* stack memory it does not own -- the Stage 3 switch checkpoint in
* execute_colon_word() below must never fire a real context switch there,
* only at the outermost vm_interpret() call, or it parks a borrowed stack
* region under the wrong VM's saved-context pointer (silent corruption,
* not just the trampoline livelock this was first found chasing -- see
* FABRIC-3.md for the full writeup). */
static int g_vm_interpret_depth;
int sk_vm_at_outermost_interpret(void) { return g_vm_interpret_depth <= 1; }
void vm_interpret(VM* vm, const char* input)
{
if (!vm || !input) return;
@@ -1091,6 +1133,7 @@ void vm_interpret(VM* vm, const char* input)
VM *prev_attrib_vm = g_log_attrib_vm;
g_log_attrib_vm = vm;
g_vm_interpret_depth++;
/* Load into input buffer (cap + NUL) */
size_t n = 0, cap = INPUT_BUFFER_SIZE ? INPUT_BUFFER_SIZE - 1 : 0;
@@ -1123,6 +1166,7 @@ void vm_interpret(VM* vm, const char* input)
}
}
g_vm_interpret_depth--;
g_log_attrib_vm = prev_attrib_vm;
}