FABRIC-3.8.md Stage FR: VM frame stacks, each frame with an ID

Captain Bob: "give frames IDs and call by ID" -- frames stack per VM, each
with an ID. New vm_frame.h/.c: VMFrameEntry { VMFrameID id; VMUuid vm_id;
kind (INTERPRET/DRAIN/IDLE) }, a growable VMFrameStack embedded as VM.frames
(reserved at birth, freed in vm_cleanup, grown by allocate-copy-free).
vm_interpret() -> vm_interpret_kind() pushes a frame onto the running stack's
owner, holds only its ID, fails closed if it cannot grow, and pops by ID
(mismatch reported as VM FRAME CORRUPTION).

The Stage A/B loose fields are gone and everything is derived from the stack:
- ownership gate: one frame, and it is vm's own
- drain guard: a DRAIN frame for vm already on the running stack
- parked-idle: an IDLE frame the trampoline pushes around its yield
- log attribution: the top frame's vm_id, resolved by ID through the registry
The single saved-and-restored attribution global is removed.

Evidence: the probe baseline on the pre-frame code read HSSAS (the global
named Hestia while Hera resumed). With frames it reads HSHAS on all three
ISAs, with Hestia's top frame IDLE and Hera's frames back to 0; the probe is
reverted. Formal acceptance on all three ISAs: dict_hash identical and
unchanged, zero UNKNOWN WORD, faults, frame corruption or refused interprets,
and the WIREBIND relay 1 2 + . -> 3 works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Robert Allan James
2026-09-23 15:47:07 -04:00
co-authored by Claude Opus 5.5
parent 56da55aba9
commit 6a313c7bf2
26 changed files with 64522 additions and 53 deletions
+8 -7
View File
@@ -593,7 +593,10 @@ int sk_hermes_drain_checkpoint(VM *vm) {
* this function is already interpreting for `vm` runs at vm's own
* depth 1, so its in-word checkpoint (vm_core.c) would otherwise drain
* a SECOND message inside the first one's execution. */
if (vm->hermes_draining) return 0;
/* Stage FR (§3.12): derived from the frame stack -- a DRAIN frame for
* this VM already on the running stack means we are inside one of its
* own payloads. */
if (vm_frame_has(vm_frame_current_stack(), vm->stadium_vm_id, VM_FRAME_DRAIN)) return 0;
self = vm->stadium_vm_id;
msg = sk_hermes_pending_peek(self);
@@ -656,12 +659,10 @@ int sk_hermes_drain_checkpoint(VM *vm) {
vm->mode = MODE_INTERPRET; /* a payload is never compiled into whatever
* the enclosing context was mid-defining */
vm->hermes_draining = 1; /* FABRIC-3.8.md §3.3 -- set only once a message
* is actually in hand (every earlier return
* above leaves it untouched), cleared on the
* single path out below */
vm_interpret(vm, (const char *)msg->payload_addr);
vm->hermes_draining = 0;
/* FABRIC-3.8.md §3.3 / §3.12: interpreted as a DRAIN-kind frame -- the
* guard above finds it for as long as this payload runs, and it pops by
* ID when vm_interpret_kind() returns. */
vm_interpret_kind(vm, (const char *)msg->payload_addr, VM_FRAME_DRAIN);
drain_error = vm->error;
memcpy(vm->input_buffer, saved_input, sizeof(saved_input));
+17 -5
View File
@@ -133,12 +133,24 @@ void sk_vm_switch_entry(void) {
if (sk_hermes_drain_checkpoint(self) != 0) {
continue; /* consumed one message (1 = ok, -1 = payload erred) */
}
self->parked_idle = 1;
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");
/* 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." */
{
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");
}
vm_frame_pop(&self->frames, idle_id); /* resumed: someone switched back in */
}
self->parked_idle = 0; /* resumed: someone switched back in */
}
}
+6
View File
@@ -156,6 +156,12 @@ void vm_init_with_host(VM* vm, const VMHostServices *host)
*vm = (VM){0};
vm->host = host ? host : vm_default_host_services();
#ifdef __STARKERNEL__
/* FABRIC-3.8.md §3.12: reserve the frame stack at birth so vm_interpret()'s
* per-call push rarely has to grow. A failure here is not fatal to init --
* the first push retries the allocation and fails closed if it still can't. */
(void)vm_frame_reserve(&vm->frames, 8);
#endif
#ifndef __STARKERNEL__
vm_reset_hosted_fake_ns(vm);
#endif
+58 -15
View File
@@ -375,6 +375,7 @@ void vm_cleanup(VM* vm)
#ifdef __STARKERNEL__
sf_free(vm->call_stack);
vm->call_stack = NULL;
vm_frame_free(&vm->frames); /* FABRIC-3.8.md §3.12 */
/* FABRIC-3.md §XXVIII, Stage 1 (2026-09-13): free the native stack
* allocated at birth (capsule_birth_baby() / sk_vm_bootstrap_parity()).
@@ -1162,15 +1163,34 @@ void vm_interpret_word(VM* vm, const char* word_str, size_t len)
* @param vm Pointer to VM instance
* @param input String containing Forth code to interpret
*/
/* FABRIC-3.md §XXVI follow-on, Step 4 (log_attrib.h): which VM's dictionary
* context is currently executing, for log persistence attribution. Save/
* restore (not a plain set) so a nested vm_interpret() call -- VM-EXEC
* dispatching into a different VM's own dictionary, mid-interpret --
* attributes to whichever VM is actually running when it returns to the
* outer frame, not silently to the inner one. */
static VM *g_log_attrib_vm; /* zero-initialized (BSS) */
/* FABRIC-3.8.md §3.12 (Stage FR, 2026-09-23): frame stacks replace the old
* single saved-and-restored attribution global (FABRIC-3.md §XXVI follow-on,
* Step 4). That global assumed strictly nested calls; once VMs interleave on
* their own native stacks, a switch left it naming the VM that had just
* switched out -- found by this stage's probe baseline (HSSAS, not HSHAS).
*
* Each VM's frame stack (VM.frames) records the nested levels running on
* its own stack. Before the switch mechanism is seeded at boot there is no
* owning VM yet, so this boot stack stands in for that window only. */
static VMFrameStack g_boot_frames;
VM *vm_log_attributed_vm(void) { return g_log_attrib_vm; }
VMFrameStack *vm_frame_current_stack(void) {
VM *current = sk_vm_switch_current_vm();
return current ? &current->frames : &g_boot_frames;
}
/* Log attribution, by ID: the VM executing at the top frame of the running
* stack, resolved through the VM registry. NULL when nothing is
* interpreting on this stack (or the ID no longer resolves) --
* (LOG-APPEND-RAW) maps NULL to its documented "HADES" source, so this must
* never fall back to the current VM itself. */
VM *vm_log_attributed_vm(void) {
VMFrameStack *s = vm_frame_current_stack();
VMRegistryEntry e;
if (s->count == 0) return (VM *)0;
if (capsule_vm_registry_get(s->entries[s->count - 1].vm_id, &e) != 0) return (VM *)0;
return (VM *)e.vm_ptr;
}
/* FABRIC-3.md §XXVIII Stage 3 correction (2026-09-13): nesting depth of
* vm_interpret() calls, tracked the same save/restore-adjacent way as
@@ -1197,23 +1217,47 @@ int sk_vm_at_outermost_interpret(void) { return g_vm_interpret_depth <= 1; }
* fails the first clause by construction (that VM does not own the stack),
* so the existing defer-if-nested behavior (§XXVIII.1) is preserved. Before
* the switch mechanism is seeded at boot (current VM == NULL), nothing
* passes -- matching "no switch has happened yet." */
* passes -- matching "no switch has happened yet."
*
* Stage FR (§3.12): derived from the frame stack rather than a stored
* depth -- `vm` owns the stack, and that stack holds exactly one frame, and
* it is `vm`'s own (not a nested VM-EXEC target, not a second interpret of
* itself via EXEC/LOAD). */
int sk_vm_owns_outermost_frame(VM *vm) {
return vm &&
sk_vm_switch_current_vm() == vm &&
vm->interpret_depth == 1 &&
vm->frames.count == 1 &&
vm_uuid_equal(vm->frames.entries[0].vm_id, vm->stadium_vm_id) &&
vm->mode != MODE_COMPILE;
}
void vm_interpret(VM* vm, const char* input)
{
vm_interpret_kind(vm, input, VM_FRAME_INTERPRET);
}
void vm_interpret_kind(VM* vm, const char* input, uint32_t kind)
{
if (!vm || !input) return;
vm_assert_interpreter_enabled(vm, "vm_interpret");
VM *prev_attrib_vm = g_log_attrib_vm;
g_log_attrib_vm = vm;
/* FABRIC-3.8.md §3.12 (Stage FR): push this level onto the frame stack
* of the VM physically owning the running native stack -- chosen ONCE
* here and popped through the same stack at exit, never re-decided (a
* resume after a switch restores that stack's owner as current anyway,
* and caching also covers a call that began before the switch mechanism
* was seeded). Only the frame's ID is held across the body: a nested
* interpret on this same stack can grow (copy) the array. */
VMFrameStack *frame_stack = vm_frame_current_stack();
VMFrameID frame_id = vm_frame_push(frame_stack, vm->stadium_vm_id, kind);
if (frame_id == 0) {
/* Fail closed: interpreting without a frame would silently break
* the ownership gate and attribution. */
console_println("vm_interpret: frame stack could not grow -- refusing to interpret");
vm->error = 1;
return;
}
g_vm_interpret_depth++;
vm->interpret_depth++; /* FABRIC-3.8.md §3.1: per-VM, see sk_vm_owns_outermost_frame() */
/* Load into input buffer (cap + NUL) */
size_t n = 0, cap = INPUT_BUFFER_SIZE ? INPUT_BUFFER_SIZE - 1 : 0;
@@ -1247,8 +1291,7 @@ void vm_interpret(VM* vm, const char* input)
}
g_vm_interpret_depth--;
vm->interpret_depth--;
g_log_attrib_vm = prev_attrib_vm;
vm_frame_pop(frame_stack, frame_id); /* by ID -- a mismatch is logged as corruption */
}
/* ====================== VM memory helpers ======================= */
+133
View File
@@ -0,0 +1,133 @@
/*
StarForth — Steady-State Virtual Machine Runtime
Copyright (c) 2023–2025 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.
*/
/**
* vm_frame.c - per-VM execution frame stack (FABRIC-3.8.md §3.12, Stage FR).
* See vm_frame.h for the contract.
*/
#ifdef __STARKERNEL__
#include "starkernel/vm/vm_frame.h"
#include "platform_alloc.h" /* sf_malloc / sf_free */
#include "starkernel/console.h" /* console_println -- corruption is loud */
#include "freestanding/stdio.h" /* snprintf */
#include <string.h>
/* Single writer: vm_frame_push(), reached only from vm_interpret_kind() and
* the switch trampoline -- both mainline. No interrupt-context path calls
* vm_interpret() (checked by grep across arch/, heartbeat.c, the switch
* signal and hal/ when this was written), so this needs no lock -- the same
* single-writer discipline heartbeat.c and the switch signal already use. */
static VMFrameID g_next_frame_id = 1;
/* Grow by allocate-copy-free, deliberately not sf_realloc(): the kernel's
* sf_realloc() is documented as orphaning the old block. */
static int grow(VMFrameStack *s, int new_cap) {
VMFrameEntry *n;
if (new_cap <= s->cap) return 0;
n = (VMFrameEntry *)sf_malloc((size_t)new_cap * sizeof(VMFrameEntry));
if (!n) return -1;
if (s->entries && s->count > 0) {
memcpy(n, s->entries, (size_t)s->count * sizeof(VMFrameEntry));
}
if (s->entries) sf_free(s->entries);
s->entries = n;
s->cap = new_cap;
return 0;
}
int vm_frame_reserve(VMFrameStack *s, int cap) {
if (!s || cap <= 0) return -1;
return grow(s, cap);
}
VMFrameID vm_frame_push(VMFrameStack *s, VMUuid vm_id, uint32_t kind) {
VMFrameEntry *e;
if (!s) return 0;
if (s->count >= s->cap) {
if (grow(s, s->cap ? s->cap * 2 : 8) != 0) return 0;
}
e = &s->entries[s->count++];
e->id = g_next_frame_id++;
e->vm_id = vm_id;
e->kind = kind;
if (s->count > s->max) s->max = s->count;
return e->id;
}
void vm_frame_pop(VMFrameStack *s, VMFrameID id) {
int i;
if (!s || id == 0 || s->count == 0) return;
if (s->entries[s->count - 1].id == id) {
s->count--;
return;
}
/* Every frame stack is LIFO (a resume always restores that stack's owner
* as current), so reaching here is corruption. Loud, not log_message():
* that channel is invisible in this build's serial capture
* (FABRIC-3.6.md task 3.8's own finding), and this must never be silent. */
{
char line[128];
snprintf(line, sizeof(line),
"VM FRAME CORRUPTION: pop id=%llu but top id=%llu (depth %d)",
(unsigned long long)id,
(unsigned long long)s->entries[s->count - 1].id, s->count);
console_println(line);
}
/* Remove OUR frame wherever it is, never someone else's. */
for (i = s->count - 1; i >= 0; i--) {
if (s->entries[i].id == id) {
if (i < s->count - 1) {
memmove(&s->entries[i], &s->entries[i + 1],
(size_t)(s->count - 1 - i) * sizeof(VMFrameEntry));
}
s->count--;
return;
}
}
}
int vm_frame_top_is(const VMFrameStack *s, VMUuid vm_id, uint32_t kind) {
const VMFrameEntry *t;
if (!s || s->count == 0) return 0;
t = &s->entries[s->count - 1];
return t->kind == kind && vm_uuid_equal(t->vm_id, vm_id);
}
int vm_frame_has(const VMFrameStack *s, VMUuid vm_id, uint32_t kind) {
int i;
if (!s) return 0;
for (i = 0; i < s->count; i++) {
if (s->entries[i].kind == kind && vm_uuid_equal(s->entries[i].vm_id, vm_id)) return 1;
}
return 0;
}
void vm_frame_free(VMFrameStack *s) {
if (!s) return;
if (s->entries) sf_free(s->entries);
s->entries = (VMFrameEntry *)0;
s->count = s->cap = 0;
}
#endif /* __STARKERNEL__ */