Stage 3: timer-driven preemptive switching, live on all 3 arches (FABRIC-3.md §XXVIII)
Build / build-amd64-iso (push) Waiting to run
Build / build-aarch64-iso (push) Waiting to run
Build / build-riscv64-img (push) Waiting to run

Fourth stage of the preemptive context-switching plan, and the biggest.
LithosAnanke now genuinely, continuously preempts between Hera, Hermes,
and Artemis -- timer-driven, running live for the entire remainder of
every boot once the Tripod fleet registers, not a bounded probe.

A real design fork was resolved before writing code: the naive approach
(the timer ISR calling Stage 2's sk_vm_context_switch() directly) is
broken -- Stage 0's trap frame lives on whatever stack was active at
interrupt time, and jumping to a different stack via Stage 2's own
independent swap mid-handler would abandon that trap frame unresumed,
guaranteed corruption on the first tick. Chose the safer of two named
options: the ISR only ever sets a flag and returns completely normally
through its own full epilogue; the actual switch happens moments later,
via Stage 2's already-proven mechanism, at a safe cooperative checkpoint
on the mainline (execute_colon_word()'s per-word dispatch loop, checked
on literally every word, not throttled to the existing 256-word
heartbeat-tuning cadence) -- confirmed with the user that word-level
granularity is fine-grained enough given the eventual Zynq FPGA target
where a word is a mnemonic.

New capsule_vm_switch_signal.c/.h: a purpose-built run-readiness signal,
deliberately separate from capsule_vm_physics.c's execution-heat engine
(that one's own header documents itself as never touched from interrupt
context, by design). Slot table sized with headroom (8) rather than
hardcoded to today's 3 participants, so extending participation later is
another register() call, not a redesign -- per direct request to leave
room for swapping the participant set. Simple linear accumulate-then-
threshold for this first cut; a fancier law can replace it later without
touching the mechanism around it. heartbeat_tick() gains its one
deliberate, documented amendment to this file's own top-half/bottom-half
discipline -- the first time this codebase reaches into VM-scheduling
state from real ISR context.

Registration happens only after all three VMs are fully born, right
before the REPL starts -- no critical-section protection yet against
being switched away mid-birth-setup.

Known, flagged rough edge (not reconciled this pass): MSG-TICK's own
idle-pump and this new mechanism can still independently move control
between the same VMs; not observed to interact badly in verification,
but not fully unified either.

Verified interactively at the console on all 3 architectures with
continuous background preemption running throughout -- amd64 computed
`1 1 + .` -> 2, aarch64 computed `1 1 + dup DUP * . CR` -> 4, both
correct, REPL fully responsive, zero fault indicators over sustained
runtime.

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 19:32:36 -04:00
co-authored by Claude Sonnet 5
parent f790d0995e
commit 986d042aa7
14 changed files with 27452 additions and 1 deletions
+63
View File
@@ -3635,3 +3635,66 @@ Stage 3 (timer-driven preemption, Tripod fleet only, the new run-readiness signa
the biggest remaining stage, and the first one that actually amends the §21.1
nothing-is-concurrent-on-one-hart ruling for real.
**Stage 3 CLOSED, same day.** LithosAnanke now genuinely, continuously preempts between Hera,
Hermes, and Artemis, timer-driven, for real -- not a bounded probe window like Stage 2, but live
for the entire remainder of every boot from the moment the Tripod fleet registers onward.
Verified interactively at the console on all 3 architectures (`1 1 + .` -> `2` on amd64,
`1 1 + dup DUP * . CR` -> `4` on aarch64), with continuous background switching running the
whole time underneath -- the REPL stayed fully responsive and computed correctly throughout.
**A real design fork was resolved before writing any code, not glossed over:** the naive
approach -- the timer ISR's C handler calling Stage 2's `sk_vm_context_switch()` directly --
is broken. Stage 0's trap frame lives on whatever stack was active when the interrupt fired;
calling into Stage 2's *own, independent* stack-swap from inside that handler would abandon the
ISR's own trap frame mid-flight, unresumed, while jumping to a completely different stack via a
*second*, unrelated swap -- guaranteed corruption on the first tick. Two ways to do this
correctly were named and one deliberately chosen over the other: (1) the ISR only ever sets a
flag ("switch requested, target X") and returns completely normally through its own full Stage-0
epilogue, with the actual switch happening moments later via Stage 2's already-proven
`sk_vm_context_switch()` at a safe cooperative checkpoint on the mainline; vs. (2) true
mid-interrupt preemption, where the ISR epilogue itself swaps to a *different* VM's own trap
frame before returning -- genuinely preemptable at any instruction, but requires a second,
different parked-context representation from Stage 2's and new logic in three arches' raw
assembly epilogues, real risk of a silent, unrecoverable crash if subtly wrong. Chose (1):
confirmed with Bob that word-level granularity is "fine grained enough," given the eventual Zynq
FPGA target where a word is a mnemonic -- the checkpoint fires on literally every single word
dispatch (not throttled to the existing 256-word heartbeat-tuning cadence), so in practice the
observable latency between "the clock wants a switch" and "the switch happens" is at most one
word's worth of execution.
**The new run-readiness signal** (`capsule_vm_switch_signal.c`/`.h`, new files beside
`capsule_vm_physics.c`, deliberately NOT folded into it -- that engine's own header explicitly
documents itself as never touched from interrupt context, by design, unlocked): a slot table
sized with headroom (8 slots) rather than hardcoded to exactly today's 3 participants, so
extending participation later (Stage 4+, or any future VM) is another
`sk_vm_switch_signal_register()` call, not a redesign -- a deliberate choice made after Bob asked
for room to swap the participant set later, not just the fixed Tripod trio. Simple linear
accumulate-then-threshold for this first cut (50 ticks idle before a switch is requested, reset
to 0 the instant a slot becomes current) -- correctness and a clean single-writer(ISR)/
single-reader(mainline) story mattered more than the exact law; a fancier relaxation curve is
available to a later pass without touching the mechanism around it. `heartbeat_tick()` gains its
one deliberate, documented amendment to this file's own top-half/bottom-half discipline: it now
calls `sk_vm_switch_signal_tick()`, genuinely reaching into VM-scheduling state from real ISR
context for the first time in this codebase's history -- named explicitly in the code, not
silently slipped in, and kept safe by the same single-hart/nothing-concurrent property §21.1
already established for everything else here.
**The MSG-TICK ownership question the plan flagged turned out to be less sharp than expected**
under approach (1): since the actual switch is itself an ordinary cooperative call (just
timer-triggered rather than hand-written), it doesn't fight MSG-TICK's own idle-pump for a
stack-swap the way true interrupt-driven switching would have. Not fully reconciled this
pass -- both mechanisms can still independently decide to move control between the same three
VMs, which is a live, accepted rough edge for this first cut, flagged here rather than silently
assumed fine. Noted for a later pass, not blocking: the two have not been observed to interact
badly in verification so far.
Registration deliberately happens only after all three VMs are fully confirmed born (right
before the interactive REPL starts) -- this stage has no critical-section protection against
being switched away mid-birth-setup, so registering any earlier was rejected as a real risk, not
a hypothetical one.
All 3 architectures: clean build, clean boot to `ok>`, sustained runtime with continuous live
switching and zero fault indicators, interactive console commands computed correctly. Stage 4
(WIREBIND-scope extension) remains a ratified-decision-only step, not attempted -- the async
unclean-detach UAF risk it names is unchanged by anything built in Stages 0-3.
+1 -1
View File
@@ -1,5 +1,5 @@
# Capsule Block Manifest — Auto-generated
<!-- Generated by mkcapsule --manifest 2026-09-13T19:23:06Z -->
<!-- Generated by mkcapsule --manifest 2026-09-13T23:29:44Z -->
<!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. -->
<!-- Hand-written justifications and immutability notes live -->
<!-- in MANIFEST.md alongside this auto-generated index. -->
BIN
View File
Binary file not shown.
@@ -0,0 +1,79 @@
/*
StarForth Steady-State Virtual Machine Runtime
Copyright (c) 20232025 Robert A. James
All rights reserved.
This file is part of the StarForth project.
Licensed under the StarForth License, Version 1.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at:
https://github.com/star.4th@proton.me/StarForth/LICENSE.txt
This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND,
express or implied, including but not limited to the warranties of
merchantability, fitness for a particular purpose, and noninfringement.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* capsule_vm_switch_signal.h - New, purpose-built "who runs next" signal
* for preemptive context switching (FABRIC-3.md §XXVIII, Stage 3,
* 2026-09-13).
*
* Deliberately NOT a repurposing of capsule_vm_physics.c's execution-heat
* engine -- that measures word-level dispatch fairness over millions of
* executions on a different timescale, and its own header explicitly
* documents it as never touched from interrupt context (unlocked, by
* design). This is a different physical quantity: instant-by-instant
* run-readiness, consulted from real ISR context (heartbeat_tick()) every
* timer tick.
*
* Concurrency discipline mirrors heartbeat.c's own §21.1-sanctioned
* pattern for heartbeat_next_period_ns(): single-writer-ISR (tick()) /
* single-reader-mainline (take_pending(), called from the cooperative
* checkpoint in execute_colon_word()), no lock, because nothing on this
* single hart is concurrent with the ISR while it runs.
*
* NOT truly interrupt-driven register/stack swapping (that was
* considered and rejected for this stage -- see FABRIC-3.md §XXVIII
* Stage 3 for why): the ISR only ever sets a flag. The actual switch
* (Stage 2's already-proven sk_vm_context_switch()) happens later, at a
* safe cooperative checkpoint on the mainline, once per word dispatch.
*
* Slot table is sized with headroom, not hardcoded to exactly today's 3
* participants (Hera/Hermes/Artemis) -- extending participation later
* (Stage 4+) is another sk_vm_switch_signal_register() call, not a
* redesign.
*/
#ifndef STARKERNEL_CAPSULE_VM_SWITCH_SIGNAL_H
#define STARKERNEL_CAPSULE_VM_SWITCH_SIGNAL_H
#ifdef __STARKERNEL__
#include "starkernel/vm_uuid.h"
/* Register a VM as a switch-signal participant. Returns its slot index,
* or -1 if the slot table is full. Call once per participating VM,
* after that VM is fully born (never mid-birth -- this stage has no
* critical-section protection against being switched away mid-setup). */
int sk_vm_switch_signal_register(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);
/* 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);
#endif /* __STARKERNEL__ */
#endif /* STARKERNEL_CAPSULE_VM_SWITCH_SIGNAL_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
@@ -0,0 +1,109 @@
/*
StarForth Steady-State Virtual Machine Runtime
Copyright (c) 20232025 Robert A. James
All rights reserved.
This file is part of the StarForth project.
Licensed under the StarForth License, Version 1.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at:
https://github.com/star.4th@proton.me/StarForth/LICENSE.txt
This software is provided "AS IS", WITHOUT WARRANTY OF ANY KIND,
express or implied, including but not limited to the warranties of
merchantability, fitness for a particular purpose, and noninfringement.
See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* capsule_vm_switch_signal.c - see the header for the full contract
* (FABRIC-3.md §XXVIII, Stage 3, 2026-09-13).
*/
#ifdef __STARKERNEL__
#include "starkernel/capsule_vm_switch_signal.h"
#include "vm.h"
#include "starkernel/log_attrib.h" /* vm_log_attributed_vm() */
#include <stddef.h>
/* Headroom beyond today's 3 participants (Hera/Hermes/Artemis) -- see
* the header's own doc comment on why this isn't hardcoded to 3. */
#define SK_SWITCH_MAX_SLOTS 8
/* 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
typedef struct {
VMUuid vm_id;
uint32_t readiness;
} sk_switch_slot_entry_t;
static sk_switch_slot_entry_t g_slots[SK_SWITCH_MAX_SLOTS];
static int g_slot_count;
static int g_pending;
static VMUuid g_pending_target;
static int slot_for_vm_id(VMUuid vm_id) {
int i;
for (i = 0; i < g_slot_count; i++) {
if (vm_uuid_equal(g_slots[i].vm_id, vm_id)) return i;
}
return -1;
}
int sk_vm_switch_signal_register(VMUuid vm_id) {
if (g_slot_count >= SK_SWITCH_MAX_SLOTS) return -1;
g_slots[g_slot_count].vm_id = vm_id;
g_slots[g_slot_count].readiness = 0;
return g_slot_count++;
}
void sk_vm_switch_signal_tick(void) {
VM *current;
int current_slot, i;
current = vm_log_attributed_vm();
if (!current) return; /* nothing interpreting right now (boot/driver/HAL context) */
current_slot = slot_for_vm_id(current->stadium_vm_id);
if (current_slot < 0) return; /* current VM isn't a registered participant */
for (i = 0; i < g_slot_count; i++) {
if (i == current_slot) {
g_slots[i].readiness = 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_pending = 1;
g_pending_target = g_slots[i].vm_id;
break;
}
}
}
}
VMUuid sk_vm_switch_signal_take_pending(void) {
if (!g_pending) return vm_uuid_none();
g_pending = 0;
return g_pending_target;
}
#endif /* __STARKERNEL__ */
+15
View File
@@ -36,6 +36,7 @@
*/
#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
@@ -162,6 +163,20 @@ void heartbeat_tick(void)
g_pending_counter = heartbeat_read_counter();
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();
}
void heartbeat_service(void)
+22
View File
@@ -63,6 +63,7 @@ EFI_RUNTIME_SERVICES *g_sk_runtime_services = NULL;
#include "starkernel/capsule_loader.h"
#include "starkernel/capsule_birth.h" /* capsule_birth_mama, capsule_find_mama_init */
#include "starkernel/capsule_zuse_boot.h" /* capsule_zuse_boot_load_root_pubkey */
#include "starkernel/capsule_vm_switch_signal.h" /* FABRIC-3.md §XXVIII Stage 3 */
#include "starkernel/artemis_sig.h" /* artemis_sig_check/genesis_stamp */
#include "starkernel/kmalloc.h"
#include "starkernel/repl.h"
@@ -986,6 +987,27 @@ static void kernel_main_deep(BootInfo *boot_info) {
}
console_println("");
/* FABRIC-3.md §XXVIII, Stage 3 (2026-09-13): register the Tripod fleet
* as preemptive-switch-signal participants now, only after all three
* are confirmed fully born above -- never earlier. This stage has no
* critical-section protection against being switched away mid-setup,
* so registering any earlier would risk the signal firing during
* Hermes/Artemis's own birth sequencing. */
{
VMRegistryEntry hera_entry, hermes_entry, artemis_entry;
if (capsule_vm_registry_get(vm_uuid_hera(), &hera_entry) == 0) {
sk_vm_switch_signal_register(hera_entry.vm_id);
}
if (capsule_vm_find_by_name_nocase("Hermes", &hermes_entry) == 0 &&
hermes_entry.state == VM_STATE_LIVE) {
sk_vm_switch_signal_register(hermes_entry.vm_id);
}
if (capsule_vm_find_by_name_nocase("Artemis", &artemis_entry) == 0 &&
artemis_entry.state == VM_STATE_LIVE) {
sk_vm_switch_signal_register(artemis_entry.vm_id);
}
}
/* Decided 2026-09-05: no console for the running system unless a
* thumbdrive is present -- headless by default (EMERGENCY_CONSOLE_
* ENABLED off), reusing that flag's own existing "does this build
+27
View File
@@ -60,6 +60,9 @@
#include "starkernel/console.h" /* g_sk_fault_word */
#include "platform_alloc.h" /* sf_free for call_stack */
#include "starkernel/vm/stadium_words.h" /* item 4.1: word patrons on the Stadium */
#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() */
#endif
#include "word_source/include/vocabulary_words.h"
#include "vm_internal.h"
@@ -880,6 +883,30 @@ void execute_colon_word(VM* vm)
vm->heartbeat.check_counter = 0;
}
#ifdef __STARKERNEL__
/* FABRIC-3.md §XXVIII, Stage 3 (2026-09-13): the cooperative
* preemption checkpoint. Deliberately unconditional (every word,
* not throttled like the heartbeat cycle above) -- the check
* itself is a single flag read, cheap regardless of frequency,
* and "a word is a mnemonic" (this project's own eventual FPGA
* framing) is the granularity preemption should approximate here.
* 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). */
{
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_context_switch(vm, (VM *)target_entry.vm_ptr);
/* Resumes here once something later switches back. */
}
}
}
#endif
if (vm->error) { vm->ecw_nesting--; return; }
/* ABORT clears stacks and unwinds to QUIT: leave the flag set so