Stage C-b: compudynamic switch decision, idle point, timer path removed (FABRIC-3.8 §3.13.3-§3.13.5)

Who runs next is now sk_vm_switch_decide(): among participants with work
(pending kernel-Hermes mail, parked mid-task, or Hera with console input),
the coolest by VM-physics heat; ties with the incumbent keep the incumbent,
ties among candidates go to the longest waiting (unique wait stamps). No
scheduler state, no timer: heartbeat_tick() no longer touches switching,
readiness/threshold/has_work/take_pending removed. Decided at owned word
checkpoints (colon words and, new, between top-level words) after a beat
or new mail, at the trampoline between messages, and at Hera's idle point
in the console poll loops. The idle pump skips participants (G3 closed for
them): the WIREBIND relay is delivered by a real switch to rajames~user.

Probe (restored away): relay via switch Hera->rajames~user->Hera on all
three ISAs, parked saved_sp inside own stack, walk-order disagreements 0
over tens of millions of decisions, 0 bytes idle log growth. Acceptance:
dict_hash unchanged, relay 3, no faults. Rulings: C-3, C-5 as proposed;
C-4 taken as (alpha), consequence recorded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Robert Allan James
2026-09-23 17:53:55 -04:00
co-authored by Claude Opus 5.5
parent 7c3736841d
commit 06bcab504d
27 changed files with 64636 additions and 218 deletions
+99
View File
@@ -879,3 +879,102 @@ explicit instruction.
her Stage 3 registration onward and is charged for her own beats. She holds nearly all the
heat, so her charges pull almost nothing; that is the C-4 arithmetic playing out as
expected. The per-beat DoE heat columns will read differently from pre-C-a runs.
- [x] **Stage C-b — the compudynamic switch decision, the idle point, the timer path removed
(§3.13.3–§3.13.5).** 2026-09-23. Captain Bob: *"go ahead with C-b as proposed."*
- Rulings: **C-3** as proposed (the incumbent stays on a tie; among candidates, the one
waiting longest, by unique wait stamp). **C-5** as proposed (the idle point folded in;
Hera's pending console input is her mail).
- **C-4** had no proposal, so I took **(α) accept**. No special case for Hera; the
consequence is recorded below.
Changes:
- `capsule_vm_switch_signal.c/.h`:
- Removed: readiness, `has_work`, the 50-tick threshold, `tick()`, `take_pending()` and
the readiness getters.
- New: `sk_vm_switch_decide(self, self_competes)`. It considers participants other
than `self` that are alive, not `pending_reap`, and have a native stack. A candidate
is eligible with pending kernel-Hermes mail, or when parked mid-task (frame stack
non-empty, top frame not IDLE), or for Hera, pending console input. The lowest
VM-physics heat wins; on equal heat, the lowest wait stamp. Stamps are unique:
either a message's admission or the last time the VM was parked. With
`self_competes` set, it returns a candidate only if that candidate is **strictly**
cooler than `self`. It keeps no state between calls.
- Also new: wait stamps (`next_stamp`, `note_parked`), the "decision due" flag (set by
`note_beat` and `note_mail`, consumed at the next owned checkpoint), `is_participant`,
and `sk_vm_switch_reap_pending()`. The latter is the Stage 4 `pending_reap` handling,
now run at every safe point, one reap per call.
- `ticks_since_switch` now counts beats.
- `heartbeat.c`: the ISR's `sk_vm_switch_signal_tick()` call is gone. The timer decides
nothing.
- `kernel_hermes.c/.h`: each admitted message carries `wait_stamp`, and a send sets
decision-due.
- `vm_runtime.c`: the running VM's beat also calls `note_beat()`, right after its C-a
charge.
- `switch.c/.h`:
- `sk_vm_context_switch()` stamps `from` as parked.
- `sk_vm_switch_checkpoint(vm)` re-decides only when a decision is due.
- The trampoline decides after every drain: with mail left, it switches only if
someone is strictly cooler; with no mail, it goes to the coolest VM with work, or to
Hera when nobody has any. That fallback isn't `native_switch_back_to`, because two
idle trampolines would bounce into each other forever. A drain that leaves mail
unconsumed panics rather than spins.
- `sk_vm_idle_point()`: Hera, running with no frame live (between lines), parks with an
IDLE frame and switches to the coolest VM with work.
- `vm_core.c`:
- The `execute_colon_word` checkpoint calls `sk_vm_switch_checkpoint()`.
- **New checkpoint between top-level words** in `vm_interpret_kind`, with the same
ownership gate, so a primitives-only payload can be displaced.
- `repl.c`:
- The idle pump skips participants. Their mail goes on their own stacks now; this
closes G3 for participants.
- `sk_vm_idle_point()` is called on every no-input pass of all three console poll
loops (`sk_console_getkey`, `sk_repl_headless_wait`, `sk_console_readline`), **not**
at the `SK_IDLE_BEAT_INTERVAL` cadence. That cadence comes from the timer.
- `doe_log.c`: the three readiness columns are kept and always 0, so the CSV shape
doesn't change. Stage I decides whether to drop them.
**Evidence, as named in §4.** A temporary probe, restored afterwards from a pre-probe
snapshot and verified free of probe code:
- a walk-order check that recomputes every decision walking the slot table in reverse;
- a line per switch (names, heats, decision and disagreement counts);
- a check that each parked VM's `native_stack_saved_sp` lies inside its own native stack
(guard, top).
Probe runs with the full relay: `logs/20260923-171359/amd64/`, `-172010/aarch64/`,
`-172635/riscv64/` (plus an earlier amd64 run, `-170740`, before the stack check could
print). On every ISA:
- **`switch 1 Hera(heat 65536) → rajames~user(heat 0)`** happens at Hera's idle point
after she forwards the console line. `rajames~user` drains `CONSOLE-CMD-EVENT` **on its
own stack** (`stadium_conserved(self)=true`) and prints `3`.
- **`switch 2 rajames~user → Hera`** is the trampoline's no-work fallback.
- `switch_count` reached 2.
- **`parked rajames~user saved_sp inside own stack (checked=1 bad=0)`**.
- **`disagree=0` over 40,246,330 (amd64), 4,182,971 (aarch64) and 25,030,081 (riscv64)
decisions.** The decision doesn't depend on walk order.
- **Idle log growth over 20 s after the relay: 0 bytes.** No switch storm.
- Nothing switched during boot. No participant has mail then, and Hera is hottest.
**Formal acceptance with the probe gone:** `logs/20260923-173341/amd64/`,
`-173947/aarch64/`, `-174610/riscv64/`.
- All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD`, failures, faults, frame corruption
and refused interprets.
- The `dict_hash` set is identical and unchanged.
- Mint → WIREBIND → `USE` → `1 2 + .` → **`3`**, delivered by a real switch.
- Idle growth: 0 bytes.
- Clean build on all three plus hosted, no new warnings.
**Observed and recorded, not changed:**
- The relayed line's output now follows Hera's `ok>` on the same line
(`[rajames@rajames] ok> Kernel-Hermes CONSOLE-CMD-EVENT …`), because delivery happens
at her idle point after she prints the prompt. This is cosmetic.
- **Idle cost:** the idle point runs the decision on every poll pass (tens of millions of
decisions per boot while idle). Each walks the participant table, but the CPU was
spinning there anyway.
- **C-4 (α) consequence, now concrete:** Hera's other idle work, including xHCI event
servicing, USB attach and detach, and block sync, runs from her idle beat. Under
sustained worker load Hera is the hottest VM with work and gets no turn, so those
services wait until the workers go idle. This doesn't affect the relay. It will matter
for Stage G/H, and for any USB keyboard input under load (serial input still makes
her eligible).
- Stage F's mechanism is now in C-b. Its remaining content is evidence only: work
proceeds with the console idle, which this relay already shows for one message.
+1 -1
View File
@@ -1,5 +1,5 @@
# Capsule Block Manifest — Auto-generated
<!-- Generated by mkcapsule --manifest 2026-09-23T20:43:24Z -->
<!-- Generated by mkcapsule --manifest 2026-09-23T21:45:34Z -->
<!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. -->
<!-- Hand-written justifications and immutability notes live -->
<!-- in MANIFEST.md alongside this auto-generated index. -->
+59 -27
View File
@@ -68,6 +68,8 @@
#include <stdint.h>
#include "starkernel/vm_uuid.h"
struct VM;
/* Boot-time allocation (FABRIC-3.6.md task 3.1, 2026-09-21): kmalloc's the
* slot table to stadium_max_vm_count() entries. Must run after
* stadium_boot_init() (that bound is 0, and this fails, until Stadium has
@@ -97,44 +99,74 @@ int sk_vm_switch_signal_register(VMUuid vm_id);
* No-op (returns -1) if vm_id was never registered. */
int sk_vm_switch_signal_unregister(VMUuid vm_id);
/* Called from heartbeat_tick() (ISR context) every timer tick. Cheap:
* iterates only the registered slots (bounded, small). */
void sk_vm_switch_signal_tick(void);
/*
* FABRIC-3.8.md §3.13 (Stage C-b, 2026-09-23): the ISR half of this module
* (sk_vm_switch_signal_tick() from heartbeat_tick(), readiness accrual
* against a fixed 50-tick threshold, a pending flag) is removed -- the
* timer no longer decides anything. This module's own header comment above
* (not a repurposing of VM-physics heat) is reversed by Captain Bob's
* §3.11 ruling: the decision below IS a VM-physics heat comparison, made
* workable at switch granularity by Stage C-a's per-beat charge.
*/
/* Called from the cooperative checkpoint (execute_colon_word(), mainline,
* once per word dispatch). Returns the VMUuid of a VM that should now be
* switched to, or vm_uuid_none() if nothing is pending. Clears the
* pending flag as a side effect -- call at most once per checkpoint. */
VMUuid sk_vm_switch_signal_take_pending(void);
/* 1 if vm_id is a registered participant (the idle pump skips these: their
* mail is delivered on their own stacks, by a switch). */
int sk_vm_switch_signal_is_participant(VMUuid vm_id);
/* Call once, from the same checkpoint, immediately after a switch
* sk_vm_switch_signal_take_pending() requested actually executes (not if
* the target turned out invalid/self) -- feeds the DoE CSV counters
* below. Also resets `target_id`'s own readiness/has_work directly
* (Stage 3 follow-on correction, 2026-09-14) -- see the .c file's own doc
* comment on why this can't be left to tick()'s current-slot polling. */
/* A fresh, unique wait stamp (mainline, single writer). kernel_hermes.c
* stamps each admitted message with one. */
uint64_t sk_vm_switch_signal_next_stamp(void);
/* vm_id just stopped running (sk_vm_context_switch(), for `from`): record
* when, for the "waiting longest" tie-break. */
void sk_vm_switch_signal_note_parked(VMUuid vm_id);
/* Physical events after which the decision may differ: the running VM
* produced a beat (vm_tick(), right after its Stage C-a charge), or mail
* was admitted (sk_hermes_send_one()). take_decision_due() consumes the
* flag at the next owned checkpoint. */
void sk_vm_switch_signal_note_beat(void);
void sk_vm_switch_signal_note_mail(void);
int sk_vm_switch_signal_take_decision_due(void);
/*
* sk_vm_switch_decide - who runs next, by physical comparison (§3.13.3).
*
* Among registered participants other than `self` that have work -- a
* pending kernel-Hermes message, or parked on their own stack mid-task
* (top frame not VM_FRAME_IDLE), or (Hera only) pending console input --
* returns the one with the lowest VM-physics heat; among equally cool
* candidates, the one waiting longest (unique wait stamps). With
* self_competes set (the caller still has work), returns a candidate only
* if it is strictly cooler than `self`. Returns NULL when nobody should
* run instead. A pure function of physical state: it keeps nothing
* between calls, and its answer does not depend on table walk order. Not
* a scheduler -- no queue, no priority list, nothing on a clock.
*/
struct VM *sk_vm_switch_decide(struct VM *self, int self_competes);
/* Reap one participant marked pending_reap (a WIREBIND identity whose
* device detached while it was parked), if any. Call only at a safe point
* (checkpoint, trampoline, idle point) -- same contract the old Stage 3
* checkpoint's inline reap had. */
void sk_vm_switch_reap_pending(void);
/* Call once, immediately before a decided switch executes -- feeds the DoE
* CSV counters below. */
void sk_vm_switch_signal_note_switch_performed(VMUuid target_id);
/* Message-arrival eligibility hook (FABRIC-3.md §XXVIII Stage 3 follow-on,
* 2026-09-14): mark that VM `vm_id` was just sent a message (the FORTH-side
* MSG-SEND hook in capsules/common/messaging.4th calls this via the new
* SWITCH-MARK-WORK primitive, mainline, single-writer). Consulted by
* sk_vm_switch_signal_tick()'s own readiness->pending decision so a VM
* with nothing recently sent to it never becomes a switch target purely by
* sitting idle long enough -- closes the wasteful (but, since the Stage 3
* stack-ownership fix, no longer corrupting) trampoline-bounce cycle for
* an idle participant. No-op for an unregistered vm_id. */
/* SWITCH-MARK-WORK's body. Stage C-b: no flag to set any more (the queue
* is the eligibility); only asks for a fresh decision. */
void sk_vm_switch_signal_mark_work(VMUuid vm_id);
/* DoE CSV read-only exposure (FABRIC-3.md §XXVIII Stage 3 follow-on,
* 2026-09-13) -- all of this state already existed for the switch
* decision itself; these just make it observable. */
* 2026-09-13). Stage C-b: ticks_since_switch now counts beats of the
* running VM since the last switch (§3.13.4), not ISR ticks; the per-VM
* readiness getters are gone with the readiness itself. */
uint64_t sk_vm_switch_signal_switch_count(void); /* cumulative, since boot */
uint32_t sk_vm_switch_signal_ticks_since_switch(void);
int sk_vm_switch_signal_current_slot(void); /* -1 = none/unregistered */
int sk_vm_switch_signal_slot_count(void);
uint32_t sk_vm_switch_signal_readiness(int slot); /* 0 if slot out of range */
uint32_t sk_vm_switch_signal_readiness_of(VMUuid vm_id); /* 0 if not registered */
/* FABRIC-3.6.md task 3.1 (2026-09-21): the table's boot-time-computed
* capacity (0 if sk_vm_switch_signal_boot_init() was never called or
+4
View File
@@ -165,6 +165,10 @@ typedef struct {
uint32_t orig_type;
int in_use;
VMUuid owner; /* VM whose reservoir funded this message (task 2.7) */
uint64_t wait_stamp; /* FABRIC-3.8.md §3.13.3 (Stage C-b): unique
* admission stamp (sk_vm_switch_signal_next_stamp())
* -- how long the recipient has been waiting,
* for the switch decision's tie-break */
} SkHermesMessage;
/*
+13
View File
@@ -55,6 +55,19 @@ int sk_vm_context_switch(struct VM *from, struct VM *to);
struct VM *sk_vm_switch_current_vm(void);
void sk_vm_switch_set_current(struct VM *vm);
/* FABRIC-3.8.md §3.13.3 (Stage C-b): the word checkpoint's switch point.
* Call only where `vm` owns its outermost frame (vm_core.c gates this with
* sk_vm_owns_outermost_frame()). Re-decides only if something physical
* changed since the last decision (a beat of the running VM, or mail);
* switches to a strictly cooler VM with work, if there is one. */
void sk_vm_switch_checkpoint(struct VM *vm);
/* FABRIC-3.8.md §3.13.5 (Stage C-b, ruling C-5): the idle point. Called
* from the console's input poll loops (repl.c) each pass with no input.
* Acts only when Hera is running with no frame live (between lines); then
* switches to the coolest VM with work, if any. */
void sk_vm_idle_point(void);
#endif /* __STARKERNEL__ */
#endif /* STARKERNEL_VM_SWITCH_H */
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+160 -124
View File
@@ -33,6 +33,12 @@
#include "starkernel/vm/stadium.h" /* stadium_max_vm_count() -- task 3.1's sizing bound */
#include "starkernel/kmalloc.h"
#include "starkernel/console.h"
#include "starkernel/capsule_birth.h" /* registry, capsule_vm_force_reap() */
#include "starkernel/capsule_vm_physics.h" /* vm_physics_heat_of() -- the decision's heat */
#include "starkernel/vm/kernel_hermes.h" /* sk_hermes_pending_peek() -- eligibility is the queue */
#include "starkernel/vm/vm_frame.h" /* top frame kind -- in progress vs parked idle */
#include "starkernel/vm/bootstrap/sk_vm_bootstrap.h" /* sk_get_mama_vm() */
#include "starkernel/repl.h" /* sk_console_key_available() -- Hera's mail (C-5) */
#include <stddef.h>
/* FABRIC-3.6.md task 3.1 (2026-09-21, ruled B2 / FABRIC-3.5.md §XLV.2):
@@ -47,36 +53,42 @@
* feedback_no_hardcoded_small_scale_bounds in project memory -- a fixed
* cap here was rejected before; this closes the same class of bound. */
/* Ticks a non-running participant must accumulate readiness before a
* switch to it is requested. Simple linear accumulate-then-threshold,
* not the fancier relaxation law a later pass might want -- correctness
* and a clean single-writer/single-reader story mattered more than the
* exact law for this first cut (this stage's own design-review latitude,
* per the plan). Reset to 0 the instant a slot becomes current, so a
* VM that just ran needs a full threshold's worth of ticks idle before
* it can be selected again -- a crude but real fairness property. */
#define SK_SWITCH_READINESS_THRESHOLD 50u
/* FABRIC-3.8.md §3.13 (Stage C-b, 2026-09-23): the timer-driven readiness
* accrual (SK_SWITCH_READINESS_THRESHOLD = 50 ISR ticks, per-slot
* readiness/has_work, a pending flag armed from heartbeat_tick()) is gone.
* Captain Bob: "the timer is NOT the source of preemption, compudynamics
* is" and "no scheduler allowed." Whether and to whom a VM switches is now
* sk_vm_switch_decide() below: a physical comparison (VM-physics heat,
* charged per beat of work -- Stage C-a) over the VMs that have work,
* made only at the moments the physics exposes. Nothing here runs from
* interrupt context any more. */
typedef struct {
VMUuid vm_id;
uint32_t readiness;
int has_work; /* Stage 3 follow-on (2026-09-14): set by
* sk_vm_switch_signal_mark_work() (mainline,
* MSG-SEND hook), cleared here in tick() the
* instant this slot becomes current -- see that
* function's own doc comment in the header. */
uint64_t parked_stamp; /* wait stamp taken when this VM last stopped
* running (sk_vm_switch_signal_note_parked());
* 0 = never parked. See sk_vm_switch_decide(). */
} sk_switch_slot_entry_t;
static sk_switch_slot_entry_t *g_slots = (sk_switch_slot_entry_t *)0;
static int g_slot_capacity; /* 0 until sk_vm_switch_signal_boot_init() succeeds */
static int g_slot_count;
static int g_pending;
static VMUuid g_pending_target;
/* Single-writer, mainline-only event count behind every wait stamp (mail
* admission and parking). Unique per event, so the "waiting longest"
* tie-break (§3.13.3, ruling C-3) always has exactly one answer. Starts
* at 1 so 0 can mean "no stamp". */
static uint64_t g_stamp_next = 1;
/* Set when something physical changed that could change the decision --
* the running VM produced a beat (and was just charged for it), or mail
* was admitted -- and consumed at the next owned checkpoint. Between such
* events every decision would repeat the last one (§3.13.3). */
static int g_decision_due;
/* 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 uint32_t g_beats_since_switch; /* §3.13.4: was ISR ticks, now beats */
/* Freestanding: no libc printf. Prints an unsigned decimal, no leading
* zeros -- same small helper stadium.c/stadium_blocks.c each carry their
@@ -115,9 +127,8 @@ int sk_vm_switch_signal_boot_init(void) {
if (!slots) return -1;
for (i = 0; i < max_vm_count; i++) {
slots[i].vm_id = vm_uuid_none();
slots[i].readiness = 0;
slots[i].has_work = 0;
slots[i].vm_id = vm_uuid_none();
slots[i].parked_stamp = 0;
}
g_slots = slots;
@@ -133,9 +144,8 @@ int sk_vm_switch_signal_boot_init(void) {
int sk_vm_switch_signal_register(VMUuid vm_id) {
if (g_slot_count >= g_slot_capacity) return -1;
g_slots[g_slot_count].vm_id = vm_id;
g_slots[g_slot_count].readiness = 0;
g_slots[g_slot_count].has_work = 0;
g_slots[g_slot_count].vm_id = vm_id;
g_slots[g_slot_count].parked_stamp = 0;
return g_slot_count++;
}
@@ -148,104 +158,140 @@ int sk_vm_switch_signal_unregister(VMUuid vm_id) {
}
g_slot_count--;
/* A pending switch targeting this VM must not survive its removal --
* the checkpoint in execute_colon_word() would otherwise try to
* switch into a VM no longer tracked here. */
if (g_pending && vm_uuid_equal(g_pending_target, vm_id)) {
g_pending = 0;
}
/* g_current_slot_cached is a DoE-observable value only (see its own
* declaration comment); if compaction just invalidated it, the very
* next tick() recomputes it from scratch via slot_for_vm_id(), so a
* momentarily-stale cache here is harmless. */
/* No pending target to clear any more (Stage C-b): the decision keeps
* no state between calls, so a removed VM simply stops being walked. */
return 0;
}
void sk_vm_switch_signal_tick(void) {
VM *current;
int current_slot, i;
/* FABRIC-3.md §XXVIII Stage 3 follow-on correction (2026-09-14): was
* vm_log_attributed_vm() -- wrong source. That tracks nested
* vm_interpret() calls for log attribution and has no way to know a
* VM is running while parked inside switch.c's own raw-C trampoline
* (sk_vm_switch_entry()), which never itself calls vm_interpret(). A
* VM that only ever ran the trampoline was therefore never seen as
* "current," so its own readiness/has_work below never reset --
* tick() re-armed the very next timer tick, forever, once nothing
* else happened to be independently flipping attribution around it
* (found live: a switch storm, 101% CPU, log frozen). sk_vm_switch_
* current_vm() is authoritative instead: the switch mechanism itself
* updates it on every switch, so it reflects who is physically
* running regardless of whether that VM ever calls vm_interpret(). */
current = sk_vm_switch_current_vm();
if (!current) return; /* boot: no switch has happened yet, not seeded */
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;
g_slots[i].has_work = 0;
} else {
g_slots[i].readiness++;
}
}
if (!g_pending) {
for (i = 0; i < g_slot_count; i++) {
if (i != current_slot &&
g_slots[i].readiness >= SK_SWITCH_READINESS_THRESHOLD &&
g_slots[i].has_work) {
g_pending = 1;
g_pending_target = g_slots[i].vm_id;
break;
}
}
}
int sk_vm_switch_signal_is_participant(VMUuid vm_id) {
return slot_for_vm_id(vm_id) >= 0;
}
VMUuid sk_vm_switch_signal_take_pending(void) {
if (!g_pending) return vm_uuid_none();
g_pending = 0;
return g_pending_target;
uint64_t sk_vm_switch_signal_next_stamp(void) {
return g_stamp_next++;
}
void sk_vm_switch_signal_note_parked(VMUuid vm_id) {
int slot = slot_for_vm_id(vm_id);
if (slot >= 0) g_slots[slot].parked_stamp = g_stamp_next++;
}
void sk_vm_switch_signal_note_beat(void) {
g_beats_since_switch++;
g_decision_due = 1;
}
void sk_vm_switch_signal_note_mail(void) {
g_decision_due = 1;
}
int sk_vm_switch_signal_take_decision_due(void) {
int due = g_decision_due;
g_decision_due = 0;
return due;
}
/* One candidate's physical standing: its heat, and how long it has been
* waiting (the oldest of its first pending message's admission stamp and
* the stamp from when it last stopped running). Returns 0 if `v` has no
* work at all -- not eligible. */
static int candidate_standing(VM *v, VM *mama, uint64_t parked_stamp,
uint64_t *heat_out, uint64_t *stamp_out) {
SkHermesMessage *m = sk_hermes_pending_peek(v->stadium_vm_id);
int eligible = 0;
uint64_t stamp = UINT64_MAX;
if (m) { /* has mail */
eligible = 1;
stamp = m->wait_stamp;
}
if (v->native_stack_saved_sp != 0) { /* parked on its own stack */
const VMFrameStack *fs = &v->frames;
int in_progress = fs->count > 0 &&
fs->entries[fs->count - 1].kind != VM_FRAME_IDLE;
/* §3.13.5 / ruling C-5: pending console input is Hera's mail. */
int console_input = (v == mama) && sk_console_key_available();
if (in_progress || console_input) {
eligible = 1;
if (parked_stamp != 0 && parked_stamp < stamp) stamp = parked_stamp;
}
}
if (!eligible) return 0;
*heat_out = vm_physics_heat_of(v->stadium_vm_id);
*stamp_out = stamp;
return 1;
}
VM *sk_vm_switch_decide(VM *self, int self_competes) {
VM *mama = (VM *)sk_get_mama_vm();
VM *best = (VM *)0;
uint64_t best_heat = 0, best_stamp = 0;
int i;
for (i = 0; i < g_slot_count; i++) {
VMRegistryEntry e;
VM *v;
uint64_t heat, stamp;
if (self && vm_uuid_equal(g_slots[i].vm_id, self->stadium_vm_id)) continue;
if (capsule_vm_registry_get(g_slots[i].vm_id, &e) != 0) continue;
v = (VM *)e.vm_ptr;
if (!v || v == self || e.pending_reap) continue;
if (e.state == VM_STATE_DEAD || e.state == VM_STATE_STILLBORN) continue;
if (v->native_stack_top == 0) continue;
if (!candidate_standing(v, mama, g_slots[i].parked_stamp, &heat, &stamp)) continue;
/* Coolest wins; among equally cool, the one waiting longest.
* Stamps are unique, so the winner doesn't depend on the order
* this loop walks the table in -- the "not a scheduler" property
* (§3.13.3), checked directly by C-b's walk-order probe. */
if (!best || heat < best_heat || (heat == best_heat && stamp < best_stamp)) {
best = v;
best_heat = heat;
best_stamp = stamp;
}
}
/* A running VM that still has work keeps the CPU unless the best
* candidate is strictly cooler: a tie with the incumbent is "no reason
* to move," not a coin toss (ruling C-3). */
if (best && self_competes && !(best_heat < vm_physics_heat_of(self->stadium_vm_id))) {
return (VM *)0;
}
return best;
}
void sk_vm_switch_reap_pending(void) {
VM *current = sk_vm_switch_current_vm();
int i;
/* One per call: capsule_vm_force_reap() unregisters the VM, which
* compacts this table under the walk. Every safe point calls this, so
* any further pending reaps are picked up at the next one. */
for (i = 0; i < g_slot_count; i++) {
VMRegistryEntry e;
if (capsule_vm_registry_get(g_slots[i].vm_id, &e) == 0 &&
e.pending_reap && e.vm_ptr != (void *)current) {
capsule_vm_force_reap(e.vm_id);
return;
}
}
}
void sk_vm_switch_signal_note_switch_performed(VMUuid target_id) {
/* FABRIC-3.md §XXVIII Stage 3 follow-on correction (2026-09-14):
* reset the target's own readiness/has_work HERE, deterministically,
* at the switch decision point -- not by waiting for tick()'s
* current-slot polling to observe it running. That polling depends on
* sk_vm_switch_current_vm() catching a real ISR tick during whatever
* window the target is genuinely "current" for, and for a VM whose
* only production behavior is switch.c's placeholder trampoline (an
* immediate yield-back), that window can be as small as a few
* instructions -- too narrow for a timer tick to reliably land in.
* Without this, the target's eligibility never clears, tick() re-arms
* on the very next timer tick, and the result is a continuous switch
* storm (found live: 101% CPU, zero forward progress). This makes
* correctness independent of ISR timing; current_slot (via
* sk_vm_switch_current_vm()/tick()) remains useful as a DoE-observable
* value and for excluding the running slot from readiness accrual,
* but is no longer load-bearing for storm prevention. */
int slot = slot_for_vm_id(target_id);
if (slot >= 0) {
g_slots[slot].readiness = 0;
g_slots[slot].has_work = 0;
}
/* Stage C-b: counters only -- there is no readiness left to reset. */
(void)target_id;
g_switch_count++;
g_ticks_since_switch = 0;
g_beats_since_switch = 0;
}
void sk_vm_switch_signal_mark_work(VMUuid vm_id) {
int slot = slot_for_vm_id(vm_id);
if (slot < 0) return;
g_slots[slot].has_work = 1;
/* Stage C-b: eligibility is read from the kernel-Hermes queue itself,
* so there is no has_work flag to set. Kept (SWITCH-MARK-WORK still
* calls it) as a "something changed, decide again" hint; Stage I
* strips it if nothing calls it. */
(void)vm_id;
g_decision_due = 1;
}
uint64_t sk_vm_switch_signal_switch_count(void) {
@@ -253,11 +299,12 @@ uint64_t sk_vm_switch_signal_switch_count(void) {
}
uint32_t sk_vm_switch_signal_ticks_since_switch(void) {
return g_ticks_since_switch;
return g_beats_since_switch;
}
int sk_vm_switch_signal_current_slot(void) {
return g_current_slot_cached;
VM *current = sk_vm_switch_current_vm();
return current ? slot_for_vm_id(current->stadium_vm_id) : -1;
}
int sk_vm_switch_signal_slot_count(void) {
@@ -268,15 +315,4 @@ int sk_vm_switch_signal_slot_capacity(void) {
return g_slot_capacity;
}
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__ */
+5 -9
View File
@@ -216,16 +216,12 @@ void doe_log_tick_row(VM *vm, const HeartbeatTickSnapshot *snap)
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();
/* FABRIC-3.8.md §3.13.4 (Stage C-b): readiness no longer exists (the
* timer-driven accrual it measured is removed). The three columns are
* kept, always 0, so the CSV's shape and every existing reader stay
* valid; Stage I decides whether to drop them. switch_ticks_since above
* now counts beats of the running VM since the last 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),
+5 -14
View File
@@ -36,7 +36,6 @@
*/
#include "starkernel/timer.h"
#include "starkernel/capsule_vm_switch_signal.h" /* FABRIC-3.md §XXVIII Stage 3 */
#if defined(ARCH_AMD64)
#define HEARTBEAT_HAS_VARIANCE 1
@@ -164,19 +163,11 @@ void heartbeat_tick(void)
g_heartbeat.ticks++;
g_pending_valid = 1;
/* FABRIC-3.md §XXVIII, Stage 3 (2026-09-13): the one deliberate,
* documented amendment to this file's own top-half/bottom-half
* discipline above. sk_vm_switch_signal_tick() reaches into VM
* scheduling state from real ISR context -- something this file's
* header comment previously said nothing here did. It stays cheap
* (bounded iteration over a small slot table, no locking needed for
* the same reason nothing else here needs it: single hart, nothing
* concurrent with this ISR while it runs) and it never itself
* switches anything -- it only ever sets a flag, consumed later at a
* safe cooperative checkpoint on the mainline
* (execute_colon_word()). The GAP-A1 split itself is not overturned:
* everything else in this function is exactly as minimal as before. */
sk_vm_switch_signal_tick();
/* FABRIC-3.8.md §3.13.4 (Stage C-b, 2026-09-23): the FABRIC-3.md
* §XXVIII Stage 3 amendment that called sk_vm_switch_signal_tick()
* from here is removed. Captain Bob: "the timer is NOT the source of
* preemption, compudynamics is." This ISR is back to its original
* minimal top half; no switch decision reads anything it produces. */
}
void heartbeat_service(void)
+15 -1
View File
@@ -46,6 +46,8 @@
#include "starkernel/capsule_run.h"
#include "starkernel/vm/bootstrap/sk_vm_bootstrap.h"
#include "starkernel/vm/kernel_hermes.h" /* FABRIC-3.6.md task 3.8 -- sk_hermes_send_one() */
#include "starkernel/vm/switch.h" /* FABRIC-3.8.md §3.13.5 -- sk_vm_idle_point() */
#include "starkernel/capsule_vm_switch_signal.h" /* §3.13.5 -- pump skips participants */
#include "starkernel/vm/stadium.h" /* task 3.8 -- stadium_conserved() evidence print */
#include "block_subsystem.h"
#include "word_source/include/keyboard_words.h"
@@ -912,9 +914,15 @@ static void sk_repl_idle(VM *active_vm)
for (i = 0; i < count; i++) {
VMRegistryEntry ent;
/* FABRIC-3.8.md §3.4 / §3.13.5 (Stage C-b, G3): switch
* participants are skipped -- their mail is delivered on their
* own stacks, by a switch (sk_vm_idle_point() and the trampoline),
* never drained here on Hera's. Only VMs not yet participants
* (Stage E makes every born VM one) are still pumped here. */
if (capsule_vm_registry_get_by_index(i, &ent) == 0 &&
ent.state == VM_STATE_LIVE &&
ent.vm_ptr != (void *)mama) {
ent.vm_ptr != (void *)mama &&
!sk_vm_switch_signal_is_participant(ent.vm_id)) {
(void)sk_hermes_drain_checkpoint((VM *)ent.vm_ptr);
}
}
@@ -1081,6 +1089,7 @@ int sk_console_getkey(VM *active_vm)
VM *live_active = g_repl_active_vm ? g_repl_active_vm : (VM *)sk_get_mama_vm();
sk_repl_idle(live_active);
}
sk_vm_idle_point(); /* FABRIC-3.8.md §3.13.5 -- acts only between lines */
arch_relax();
}
}
@@ -1104,6 +1113,7 @@ void sk_repl_headless_wait(VM *mama)
g_last_beat_tick = now;
sk_repl_idle(mama);
}
sk_vm_idle_point(); /* FABRIC-3.8.md §3.13.5 */
arch_relax();
}
}
@@ -1303,6 +1313,10 @@ int sk_console_readline(char* buf, int size, VM* active_vm, int reanchor_prompt)
* On real hardware a wfi/hlt would be appropriate; add it here
* under an #ifdef REAL_HARDWARE guard when that path is needed.
*/
/* FABRIC-3.8.md §3.13.5 (Stage C-b): Hera waiting for a line is
* the idle point -- the coolest VM with work runs now, not at
* the next SK_IDLE_BEAT_INTERVAL (that cadence is the timer's). */
sk_vm_idle_point();
arch_relax(); /* PAUSE — reduce power, maintain tight poll */
continue;
}
+7
View File
@@ -52,6 +52,7 @@
#include "starkernel/kmalloc.h"
#include "console.h"
#include "vm.h" /* task 3.4 -- VM struct fields, vm_interpret() */
#include "starkernel/capsule_vm_switch_signal.h" /* FABRIC-3.8.md §3.13.3 -- wait stamps */
/* Freestanding: no libc printf. Prints an unsigned decimal, no leading
* zeros -- same small helper stadium.c/capsule_vm_switch_signal.c each
@@ -531,6 +532,12 @@ int sk_hermes_send_one(VMUuid from, VMUuid to, uint32_t type, uint32_t channel_i
return -1;
}
/* FABRIC-3.8.md §3.13.3 (Stage C-b): stamped only once admitted, and
* the switch decision told something changed -- the recipient may now
* be the coolest VM with work. */
msg->wait_stamp = sk_vm_switch_signal_next_stamp();
sk_vm_switch_signal_note_mail();
return 0;
}
+86 -17
View File
@@ -32,6 +32,8 @@
#include "starkernel/capsule_birth.h" /* capsule_vm_set_state */
#include "starkernel/vm/kernel_hermes.h" /* FABRIC-3.8.md §3.2 -- sk_hermes_drain_checkpoint() */
#include "starkernel/hal/hal.h" /* sk_hal_panic() */
#include "starkernel/capsule_vm_switch_signal.h" /* FABRIC-3.8.md §3.13.3 -- the decision */
#include "starkernel/vm/bootstrap/sk_vm_bootstrap.h" /* sk_get_mama_vm() -- the idle root */
#include <stdint.h>
/* Arch-specific (switch.S in each arch dir). sk_vm_switch_prime() builds
@@ -121,39 +123,105 @@ void sk_vm_switch_set_current(VM *vm) { g_switch_current_vm = vm; }
* indistinguishable from a switch storm. A failed yield is a kernel
* invariant violation, not something to retry, so it panics loudly.
*
* Deliberately NOT here yet (FABRIC-3.8.md §3.11, a Stage C design
* question): this point between messages is also a clean switch point
* (own stack, depth 0), and the compudynamic switch decision should be
* consulted here too, so a VM with a queue of checkpoint-free payloads
* cannot hold the CPU indefinitely.
* Stage C-b (below) adds the compudynamic decision at this point between
* messages, so a VM with a queue of checkpoint-free payloads cannot hold
* the CPU indefinitely.
*/
/*
* FABRIC-3.8.md §3.13.3 (Stage C-b, 2026-09-23): the trampoline now asks
* the compudynamic decision at its between-messages point, instead of
* always draining to empty and then yielding to whoever switched in:
*
* - still has mail: keep draining unless some VM with work is strictly
* cooler (then switch to it; this VM stays eligible through its mail);
* - no mail: switch to the coolest VM with work, or -- nobody has any --
* to Hera, the console root, whose idle point polls for input.
*
* The no-work fallback is Hera rather than native_switch_back_to because
* back_to is "whoever switched in last": two idle trampolines that switched
* into each other would each yield to the other forever -- a switch storm.
* Hera is never waiting on a trampoline, so the chain always ends at her.
*/
void sk_vm_switch_entry(void) {
VM *self = g_switch_entry_vm;
for (;;) {
if (sk_hermes_drain_checkpoint(self) != 0) {
continue; /* consumed one message (1 = ok, -1 = payload erred) */
VM *next;
int drained, has_mail;
drained = sk_hermes_drain_checkpoint(self); /* at most one message */
has_mail = sk_hermes_pending_count(self->stadium_vm_id) > 0;
/* Stage B's rule, kept: never spin on mail the drain won't take --
* with no word checkpoint in this loop, that would be a silent storm. */
if (drained == 0 && has_mail) {
sk_hal_panic("switch trampoline: pending mail the drain did not consume");
}
/* Stage FR (§3.12): parked-idle is a VM_FRAME_IDLE frame on this VM's
* own stack, pushed only around the yield and popped (by ID) on
* resume, BEFORE any further drain -- so the drain's own frame is
* still the only frame and the ownership gate's exactly-one-frame
* rule holds. It records WHERE the VM is parked: an empty stack alone
* would also describe Hera parked at an idle point with her REPL call
* chain live, which is not "nothing in progress." */
sk_vm_switch_reap_pending();
next = sk_vm_switch_decide(self, has_mail);
if (!next) {
if (has_mail) continue; /* still coolest with work: keep going */
next = (VM *)sk_get_mama_vm(); /* nobody has work: back to the root */
if (!next || next == self) sk_hal_panic("switch trampoline: no root to idle to");
}
/* Stage FR (§3.12): parked is a VM_FRAME_IDLE frame on this VM's own
* stack, pushed only around the switch and popped (by ID) on resume,
* BEFORE any further drain -- so the drain's own frame is still the
* only frame and the ownership gate's exactly-one-frame rule holds.
* It records WHERE the VM is parked: at its trampoline, nothing in
* progress (any remaining mail keeps it eligible by itself). */
{
VMFrameID idle_id = vm_frame_push(&self->frames, self->stadium_vm_id, VM_FRAME_IDLE);
if (idle_id == 0) {
sk_hal_panic("switch trampoline: idle frame push failed");
}
if (!self->native_switch_back_to ||
sk_vm_context_switch(self, self->native_switch_back_to) != 0) {
sk_hal_panic("switch trampoline: idle yield failed");
sk_vm_switch_signal_note_switch_performed(next->stadium_vm_id);
if (sk_vm_context_switch(self, next) != 0) {
sk_hal_panic("switch trampoline: switch failed");
}
vm_frame_pop(&self->frames, idle_id); /* resumed: someone switched back in */
}
}
}
/* See switch.h. The caller has already established that `vm` owns its
* outermost frame (sk_vm_owns_outermost_frame(), vm_core.c). */
void sk_vm_switch_checkpoint(VM *vm) {
VM *next;
if (!sk_vm_switch_signal_take_decision_due()) return;
sk_vm_switch_reap_pending();
next = sk_vm_switch_decide(vm, 1); /* vm is mid-task: it still has work */
if (!next) return;
sk_vm_switch_signal_note_switch_performed(next->stadium_vm_id);
(void)sk_vm_context_switch(vm, next);
/* Resumes here once the decision, somewhere, switches back to vm. */
}
/* See switch.h. FABRIC-3.8.md §3.13.5 (ruling C-5): Hera between lines,
* polling for input, is the idle point. She has no work of her own there,
* so any VM with work goes -- the coolest one. She is parked with an IDLE
* frame (nothing in progress), so she becomes eligible again only through
* mail or pending console input, or is returned to by a trampoline with
* nothing left to do. */
void sk_vm_idle_point(void) {
VM *self = g_switch_current_vm;
VM *next;
if (!self || self != (VM *)sk_get_mama_vm()) return;
if (self->frames.count != 0) return; /* inside a word (KEY/EXPECT): not idle */
sk_vm_switch_reap_pending();
next = sk_vm_switch_decide(self, 0);
if (!next) return;
{
VMFrameID idle_id = vm_frame_push(&self->frames, self->stadium_vm_id, VM_FRAME_IDLE);
if (idle_id == 0) return; /* fail closed: stay put rather than park unmarked */
sk_vm_switch_signal_note_switch_performed(next->stadium_vm_id);
(void)sk_vm_context_switch(self, next);
vm_frame_pop(&self->frames, idle_id);
}
}
int sk_vm_context_switch(VM *from, VM *to) {
uint64_t new_sp;
@@ -177,6 +245,7 @@ int sk_vm_context_switch(VM *from, VM *to) {
capsule_vm_set_state(to->stadium_vm_id, VM_STATE_LIVE);
capsule_vm_set_state(from->stadium_vm_id, VM_STATE_SWITCHED_OUT);
sk_vm_switch_signal_note_parked(from->stadium_vm_id); /* §3.13.3 wait stamp */
g_switch_current_vm = to;
sk_vm_switch_to(&from->native_stack_saved_sp, new_sp);
+17 -25
View File
@@ -967,32 +967,14 @@ void execute_colon_word(VM* vm)
* stack AND is at its own outermost frame AND is not compiling --
* instead of the global depth count, which stops being meaningful
* once VMs genuinely interleave on their own native stacks. */
/* FABRIC-3.8.md §3.13.3 (Stage C-b, 2026-09-23): no longer a
* timer-armed pending target -- the compudynamic decision itself,
* re-made only after a beat of this VM or new mail. The pending_reap
* handling that lived inline here (FABRIC-3.md §XXVIII Stage 4) is
* now sk_vm_switch_reap_pending(), run at every safe point. */
if (sk_vm_owns_outermost_frame(vm)) {
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) {
/* FABRIC-3.md §XXVIII Stage 4 (2026-09-14): a target
* marked pending_reap is a WIREBIND identity whose
* owning device has physically detached while it sat
* parked SWITCHED_OUT -- capsule_vm_kill() already
* refused to free it when that happened (see
* VMRegistryEntry.pending_reap's own doc comment).
* This checkpoint is the safe point the mark-and-
* defer design calls for: reap instead of resuming,
* and skip the switch this tick -- take_pending()
* already consumed the pending flag, so no other
* checkpoint will try this same dead end. */
if (target_entry.pending_reap) {
capsule_vm_force_reap(target_id);
} else {
sk_vm_switch_signal_note_switch_performed(target_id);
sk_vm_context_switch(vm, (VM *)target_entry.vm_ptr);
/* Resumes here once something later switches back. */
}
}
}
sk_vm_switch_checkpoint(vm);
/* Resumes here once something later switches back. */
}
#endif
@@ -1288,6 +1270,16 @@ void vm_interpret_kind(VM* vm, const char* input, uint32_t kind)
vm_heartbeat_run_cycle(vm);
vm->heartbeat.check_counter = 0;
}
#ifdef __STARKERNEL__
/* FABRIC-3.8.md §3.13.3 (Stage C-b): between two top-level words
* is a clean switch point too -- without it, a payload made only
* of primitives (no colon word, so no execute_colon_word()
* checkpoint) could never be displaced. Same ownership gate. */
if (!vm->error && !vm->abort_requested && sk_vm_owns_outermost_frame(vm)) {
sk_vm_switch_checkpoint(vm);
}
#endif
}
g_vm_interpret_depth--;
+4
View File
@@ -52,6 +52,7 @@
#include "vm_internal.h"
#include "starkernel/capsule_vm_physics.h"
#include "starkernel/vm/switch.h" /* sk_vm_switch_current_vm() -- FABRIC-3.8.md §3.13.2 */
#include "starkernel/capsule_vm_switch_signal.h" /* note_beat() -- §3.13.3 */
#include "starkernel/timer.h"
#include <errno.h>
@@ -150,6 +151,9 @@ void vm_tick(VM* vm)
* executes can ask for, skip, or give back a charge. */
if (vm == sk_vm_switch_current_vm()) {
vm_physics_charge(vm->stadium_vm_id);
/* Stage C-b (§3.13.3): the running VM's heat just changed, so the
* next owned checkpoint re-decides who runs. */
sk_vm_switch_signal_note_beat();
}
/* Plugin 2: System State Monitoring (Future) */