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
+110 -3
View File
@@ -1,7 +1,7 @@
# FABRIC-3.8.md — Real concurrency: finishing what the reshuffle started
**Status: OPEN — Stages A and B done (§6); Stages C–I not started. §3.11 (compudynamic preemption) needs
a ruling before Stage C.** Each stage below needs Captain
**Status: OPEN — Stages A, B and FR (VM frames) done (§6); C–I not started. §3.11 ruled
(coolest VM with pending work, by physical comparison; no scheduler).** Each stage below needs Captain
Bob's explicit go, gets its own commit, and gets its own three-architecture acceptance (`clean
qemu`, amd64 → aarch64 → riscv64, foreground, visible framebuffer, one at a time).
@@ -276,7 +276,18 @@ the OS sees an empty queue (§3.2), and every switch decision is kernel-side.
holds only checkpoint-free payloads (primitive words, no colon calls) never yields. Noted in
`sk_vm_switch_entry()`'s own comment; not built in Stage B.
**This is a design question for Captain Bob before Stage C** (Stage C is the first stage where
**RULED 2026-09-23, Captain Bob:** *"VM frames first, then coolest VM with work goes next"* —
and, immediately after: ***"no scheduler allowed."*** So the switch decision at Stage C is a
**physical comparison made at the ownership-gated checkpoint**. Among VMs with pending work, the
one with the lowest heat is favored over the one running, the same least-dense judgment
`stadium_admit()` already makes for eviction and the §XIX turn-attractor argument. It is **not**
a scheduler: no run queue, no priority list, no ordering data structure, no component that owns
or computes an order, nothing that runs on a timer. The comparison is re-evaluated only at the
moments the physics already exposes (checkpoints, the trampoline's between-message point, idle
points), and the heat it reads is the heat the fleet already conserves. The per-tick readiness
accrual and the fixed `50` threshold are removed at Stage C, not tuned.
~~**This is a design question for Captain Bob before Stage C**~~ (settled above) (Stage C is the first stage where
anything actually switches). Stages A and B don't depend on it.
### 3.12 VM frames: per-VM execution state that travels with the VM (OPEN, raised by Captain Bob)
@@ -306,6 +317,71 @@ saved global. The vendored-source statics are `FABRIC-4.md`'s scope, but the fra
go. **Should land before or with Stage C**, since Stage C is the first stage where switches
happen for real.
**RULED 2026-09-23: VM frames land first, as Stage FR, before Stage C.** Concrete design:
- **`include/starkernel/vm/vm_frame.h`**, new: `typedef struct VMFrame { int interpret_depth;
int hermes_draining; int parked_idle; struct VM *attrib_vm; } VMFrame;`. `VM` gets one field,
`VMFrame frame;`. The three loose fields Stages A and B added (`interpret_depth`,
`hermes_draining`, `parked_idle`) **move into it**, since they are exactly this cluster. All
their uses are renamed; behavior is unchanged.
- **Stack-scoped log attribution.** `attrib_vm` lives in the frame of the VM that **owns the
physical stack** (`sk_vm_switch_current_vm()`), and means "the innermost VM interpreting on
this stack." `vm_interpret(vm, …)` saves and restores `owner->frame.attrib_vm` instead of the
global. A nested `VM-EXEC` into Artemis on Hera's stack still attributes to Artemis, exactly
as today. `vm_log_attributed_vm()` returns `current->frame.attrib_vm` (or `current` itself when
no interpret is live on its stack). Because each stack carries its own chain, a switch can
never leave attribution pointing at the VM that just switched out. Before the switch
mechanism is seeded at boot (no current VM), the existing global path is kept as the fallback.
- **Not in the frame this stage:** `g_prng_state` (vendored `starforth_words.c`, shared with
the hosted build, open ruling §5 item 5), the compile-time statics (covered by the Stage A
`MODE_COMPILE` gate; `FABRIC-4.md` scope), and `g_vm_interpret_depth` (retired in Stage I).
**REVISED 2026-09-23, Captain Bob: *"give frames IDs and call by ID"*; chose *"frames stack per
VM, each with an ID."*** This supersedes the single-struct shape above (written, never booted):
- **`VMFrameEntry { VMFrameID id; VMUuid vm_id; uint32_t kind; }`**. `id` is a system-wide
monotonic 64-bit frame ID (0 = none, never reused). `vm_id` is the VM executing at that level,
**held as an ID, never a pointer**, so nothing can dangle after a VM dies. `kind` is
`VM_FRAME_INTERPRET` or `VM_FRAME_DRAIN`.
- **`VMFrameStack { entries, count, cap, max }`**, one per VM, embedded as `VM.frames`. It holds the
nested interpret levels running **on that VM's own native stack** (a nested `VM-EXEC` into
Artemis on Hera's stack is a frame on *Hera's* stack whose `vm_id` is Artemis). Growable like
`VMCallState`, but grown by allocate-copy-free rather than `sf_realloc` (which orphans the old
block). Before the switch mechanism is seeded, a boot-time stack in `vm_core.c` stands in.
- **`vm_interpret()` pushes one frame and pops it by ID.** It keeps its own frame's ID and, at
exit, checks the top is that ID before popping. A mismatch is logged as frame corruption.
That's the "call by ID" integrity check across switches.
- **Everything the loose fields held is now derived from the stack:**
- ownership gate (§3.1): `current == vm`, the stack holds exactly one frame, and its `vm_id` is
`vm`;
- drain guard (§3.3): a `VM_FRAME_DRAIN` frame for `vm` is already on the current stack (the
drain pushes its interpret as `DRAIN` kind);
- parked-idle (§3.5): `VM_STATE_SWITCHED_OUT` **and the top frame is `VM_FRAME_IDLE`**. The
trampoline pushes that frame just around its idle yield and pops it on resume, *before*
draining. An empty stack alone is not enough: Hera parked at an idle point (Stage F) has zero
interpret frames but her whole REPL call chain live on her stack. The frame records *where*
the VM is parked, which the flag used to;
- log attribution: the top frame's `vm_id` on the current owner's stack, **resolved by ID**
through the VM registry. An empty stack means NULL, which maps to `HADES`.
- **Failure rules.** A frame push that can't grow **fails closed**: log it, set `vm->error`,
don't interpret. Interpreting without a frame would silently break the gate and attribution.
An initial capacity is allocated at birth so the hot path almost never allocates. A
**pop-by-ID mismatch** means corruption (every stack is LIFO, since resume restores the owner as
current): log both IDs, count it, and remove *our own* ID wherever it sits rather than popping
someone else's frame. No code holds a `VMFrameEntry *` across an interpret body (growth
copies), only the ID. The frame-ID counter has a single writer: `vm_interpret()`, mainline
only, with no interrupt-context caller (checked by grep).
**Evidence, named in advance (probe, reverted after capture).** Two temporary C words, `SB-GO`
(switch to Hestia) and `SB-BACK` (switch back to whoever switched in), plus `SB-ATTRIB` (record
`vm_log_attributed_vm()`'s name). These are *probe-only* control words, never committed; they
exist to force the interleaving that Stage C will produce naturally. Hestia is queued a
message `SB-ATTRIB SB-BACK SB-ATTRIB`, then Hera runs `SB-ATTRIB SB-GO SB-ATTRIB SB-GO`. The
recorded sequence must be **Hera, Hestia, Hera, Hestia**. The third entry is the discriminator:
Hera resuming while Hestia is still parked mid-payload. **Baseline first:** the same probe on
the pre-frame code must *fail* on that third entry (reading Hestia), proving the test detects
the defect before the fix is claimed.
---
## 4. Staged plan
@@ -318,6 +394,7 @@ healthy idle produce the same serial log (`FABRIC-3.md` §XXVIII.1).
|---|---|---|
| **A** | §3.1 per-VM ownership gate (+ `MODE_COMPILE` gate). Nothing else. | Byte-identical behavior: `dict_hash` triple unchanged, identical boot, WIREBIND mint→attach→`USE`→relay (the `FABRIC-3.6.md` Phase 4 acceptance sequence) still `1 2 + .` → `3`. |
| **B** | §3.2 trampoline idle loop + §3.3 `hermes_draining` + §3.5 parked-idle flag. Still inert (nothing marks work yet). | Same as A: nothing can reach the new trampoline branch yet, so behavior is identical. A synthetic self-test (kernel_main.c, the §XXVIII Stage 2 `SWITCH-TEST` pattern: write, capture, revert) switches into **Hestia** (not Artemis, whom the Zuse attach flow `VM-EXEC`s; a parked VM refuses `VM-EXEC`) with **two** messages queued, the first calling a colon word so the in-word drain checkpoint is actually reached. The ordering witness `SB-SEQ` must equal 12 (one-at-a-time drain), not 21 (nested). She must end parked-idle at depth 0 with her context saved on her own stack, current VM back to Hera, and the ledger and `stadium_conserved()` restored. |
| **FR** | §3.12 VM frames: `VMFrame` header; Stage A/B fields move into it; stack-scoped log attribution. | Probe `SB-ATTRIB`/`SB-GO`/`SB-BACK` (reverted): attribution sequence Hera, Hestia, **Hera**, Hestia on all three ISAs, and the **same probe fails on the pre-frame baseline** (third entry reads Hestia). Then formal acceptance: `dict_hash` unchanged, relay works. |
| **C** | §3.4 eligibility marked + pump skips participants (one commit). | The WIREBIND relay still works, now delivered by a real switch: `switch_count_cumulative` increments across the relay and the `~user` VM's own drain line appears. The ledger and `stadium_conserved()` hold (`fleet_conserved` is not evidence). No `native_stack_saved_sp` ever equals an address inside Hera's stack range (temporary probe). |
| **D** | §3.5 kill of parked-idle + unregister in `capsule_vm_kill()`. | Birth, message, idle-park, `KILL` succeeds; the switch-signal slot count returns to its prior value. |
| **E** | §3.6 generic registration in `capsule_birth_baby()`, duplicate call sites removed. | The Tripod and WIREBIND VMs still register exactly once (slot count), and a `BIRTH`-ed VM appears as a participant. |
@@ -410,3 +487,33 @@ explicit instruction.
`[zuse@Hera] ok>` with zero `UNKNOWN WORD`/failures/faults, the `dict_hash` set identical
across ISAs and unchanged from Stage A, and the full mint → WIREBIND → `USE` → `1 2 + .` →
`3` relay with `stadium_conserved(self)=true`.
- [x] **Stage FR — VM frames: a frame stack per VM, each frame with an ID (§3.12).** 2026-09-23.
Captain Bob: *"give frames IDs and call by ID"*, *"frames stack per VM, each with an ID."*
New `include/starkernel/vm/vm_frame.h` / `src/starkernel/vm/vm_frame.c`: `VMFrameEntry`
`{ VMFrameID id; VMUuid vm_id; kind }` (kinds `INTERPRET`/`DRAIN`/`IDLE`); `VMFrameStack`
embedded as `VM.frames`, reserved at birth (`vm_init_with_host`), freed in `vm_cleanup`,
grown by allocate-copy-free. `vm_interpret()` → `vm_interpret_kind()`: pushes one frame onto
the running stack's owner (`vm_frame_current_stack()`; a boot stack before seeding), holds
only its ID, **fails closed** if the push can't grow, and **pops by ID** at exit (a mismatch
is printed as `VM FRAME CORRUPTION` and only our own frame is removed). The Stage A/B loose
fields are gone, all derived now: the ownership gate is "the owner's stack holds exactly one
frame and it's `vm`'s"; the drain guard is "a `DRAIN` frame for `vm` is on the running
stack"; parked-idle is a `VM_FRAME_IDLE` frame the trampoline pushes around its yield and
pops before draining; log attribution is the top frame's `vm_id`, **resolved by ID** through
the VM registry (empty stack → NULL → `HADES`). The attribution global is removed.
**Evidence, as named in §4:**
- **Baseline (pre-frame code, probe only):** `logs/20260923-150901/amd64/` →
`attribution=HSSAS want=HSHAS FAIL`. The old global named Hestia while Hera resumed,
which is exactly the defect.
- **With frames, probe on all three ISAs:** `logs/20260923-152109/amd64/`,
`-152424/aarch64/`, `-152828/riscv64/` → `attribution=HSHAS hestia_idle_top=1
hera_frames=0 PASS`. That covers interleaved attribution, nested `VM-EXEC` into Artemis
still attributed to Artemis, Hestia parked with an `IDLE` top frame, and Hera's frames
back to zero. The probe (`SB-GO`/`SB-BACK`/`SB-ATTRIB`, probe-only control words) was
reverted (`git checkout -- src/starkernel/kernel_main.c`).
- **Formal acceptance without the probe, coupled images reset before each:**
`logs/20260923-153227/amd64/`, `-153644/aarch64/`, `-154208/riscv64/`. All reach
`[zuse@Hera] ok>`, with zero `UNKNOWN WORD`/failures/faults, **zero `VM FRAME CORRUPTION`
and zero refused interprets**. The `dict_hash` set is identical across ISAs and unchanged
from Stages A/B. The full mint → WIREBIND → `USE` → `1 2 + .` → `3` relay works (the path
with a nested frame on Hera's stack). Clean build on all three plus hosted, no new warnings.
+1
View File
@@ -555,6 +555,7 @@ VM_CORE_SRCS := $(filter-out $(VM_EXCLUDE),$(VM_ALL_SRCS))
LOADER_EXTRA_SRCS := \
$(KERNEL_SRC)/vm/arena.c \
$(KERNEL_SRC)/vm/switch.c \
$(KERNEL_SRC)/vm/vm_frame.c \
$(KERNEL_SRC)/vm/parity.c \
$(KERNEL_SRC)/vm/bootstrap/sk_vm_bootstrap.c \
$(KERNEL_SRC)/vm/host/shim.c \
+1 -1
View File
@@ -1,5 +1,5 @@
# Capsule Block Manifest — Auto-generated
<!-- Generated by mkcapsule --manifest 2026-09-23T18:51:49Z -->
<!-- Generated by mkcapsule --manifest 2026-09-23T19:41:33Z -->
<!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. -->
<!-- Hand-written justifications and immutability notes live -->
<!-- in MANIFEST.md alongside this auto-generated index. -->
+6 -1
View File
@@ -54,7 +54,12 @@ extern "C" {
struct VM;
/* vm_log_attributed_vm - The VM whose dictionary context is currently
* executing, or NULL if none (see this header's own doc comment). */
* executing, or NULL if none (see this header's own doc comment).
* FABRIC-3.8.md §3.12 (Stage FR, 2026-09-23): stack-scoped -- the innermost
* VM interpreting on the stack owned by sk_vm_switch_current_vm(), kept in
* that owner's VMFrame, so it stays correct across context switches. NULL
* means nothing is interpreting on the current stack; callers map that to
* the unattributed "HADES" source, never to the current VM itself. */
struct VM *vm_log_attributed_vm(void);
#ifdef __cplusplus
+97
View File
@@ -0,0 +1,97 @@
/*
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.h - per-VM execution frame stack (FABRIC-3.8.md §3.12, Stage FR).
*
* Captain Bob, 2026-09-23: "give frames IDs and call by ID" -- frames stack
* per VM, each with an ID. Each VM owns one VMFrameStack describing the
* nested execution levels running on ITS OWN native stack: an interpret of
* its own dictionary, a nested VM-EXEC into another VM's dictionary (a
* frame on the caller's stack whose vm_id is the target), a drain of one of
* its messages, or its parked idle point. Every frame carries a system-wide
* unique VMFrameID and names the executing VM by VMUuid -- by ID, never by
* pointer, so nothing here can dangle after a VM dies.
*
* What used to be loose per-VM flags is now derived from this stack
* (vm_core.c / kernel_hermes.c / switch.c): the ownership gate, the drain
* guard, parked-idle, and log attribution.
*
* Embedded in `struct VM` as `VM.frames` (own header per
* feedback_dedicated_headers_not_inline_fields). `vm.h` is shared with the
* hosted build, so this header must not typedef `VM`; it uses `struct VM *`.
*/
#ifndef STARKERNEL_VM_FRAME_H
#define STARKERNEL_VM_FRAME_H
#include <stdint.h>
#include "starkernel/vm_uuid.h" /* VMUuid */
struct VM;
/** System-wide unique, monotonic, never reused. 0 = no frame. */
typedef uint64_t VMFrameID;
typedef enum {
VM_FRAME_INTERPRET = 1, /**< vm_interpret() of vm_id's dictionary */
VM_FRAME_DRAIN = 2, /**< interpreting one of vm_id's own kernel-Hermes messages */
VM_FRAME_IDLE = 3 /**< vm_id parked at its switch trampoline's idle point */
} VMFrameKind;
typedef struct VMFrameEntry {
VMFrameID id;
VMUuid vm_id; /**< the VM executing at this level (held by ID) */
uint32_t kind; /**< VMFrameKind */
} VMFrameEntry;
typedef struct VMFrameStack {
VMFrameEntry *entries; /**< growable; grown by allocate-copy-free */
int count;
int cap;
int max; /**< high-water mark (DoE-observable) */
} VMFrameStack;
#ifdef __STARKERNEL__
/** Reserve initial capacity (called at birth so the hot path rarely grows). 0 ok, -1 fail. */
int vm_frame_reserve(VMFrameStack *s, int cap);
/** Push a frame; returns its new ID, or 0 if the stack could not grow (caller fails closed). */
VMFrameID vm_frame_push(VMFrameStack *s, VMUuid vm_id, uint32_t kind);
/** Pop by ID. A top that isn't `id` is corruption: logged, and `id` is removed wherever it is. */
void vm_frame_pop(VMFrameStack *s, VMFrameID id);
/** Nonzero if the top frame exists and has this kind and vm_id. */
int vm_frame_top_is(const VMFrameStack *s, VMUuid vm_id, uint32_t kind);
/** Nonzero if any frame on the stack has this kind and vm_id. */
int vm_frame_has(const VMFrameStack *s, VMUuid vm_id, uint32_t kind);
/** Release the stack's storage (vm_cleanup). */
void vm_frame_free(VMFrameStack *s);
/** The frame stack of the VM physically owning the running native stack
* (sk_vm_switch_current_vm()), or the boot stack before the switch
* mechanism is seeded. Defined in vm_core.c. */
VMFrameStack *vm_frame_current_stack(void);
/** vm_interpret() with an explicit frame kind (VM_FRAME_INTERPRET for plain
* interpretation; the drain uses VM_FRAME_DRAIN). Defined in vm_core.c. */
void vm_interpret_kind(struct VM *vm, const char *input, uint32_t kind);
#endif
#endif /* STARKERNEL_VM_FRAME_H */
+8 -21
View File
@@ -442,6 +442,7 @@ typedef struct {
#ifdef __STARKERNEL__
#include "starkernel/vm_uuid.h" /* VMUuid -- item 4.2, VM.stadium_vm_id */
#include "starkernel/vm_identity.h" /* VMIdentity -- FABRIC-2.md §F.2/§F.16 */
#include "starkernel/vm/vm_frame.h" /* VMFrame -- FABRIC-3.8.md §3.12 */
/* Saved interpreter state for VM lifecycle calls (BIRTH, VM-EXEC, START).
* Dynamically allocated; grows on demand via sf_realloc. */
@@ -669,27 +670,13 @@ typedef struct VM
* let a later switch-in from a different VM get
* silently bounced to the original caller instead
* of itself. */
int interpret_depth; /**< FABRIC-3.8.md §3.1 (Stage A, 2026-09-23): THIS
* VM's own vm_interpret() nesting depth. Replaces
* the single global g_vm_interpret_depth as the
* ownership gate's depth test -- once VMs genuinely
* interleave on their own native stacks, a global
* count across all VMs stops meaning "this VM is
* at its own outermost frame." 0 = not
* interpreting; 1 = outermost frame. */
int hermes_draining; /**< FABRIC-3.8.md §3.3 (Stage B): nonzero while
* sk_hermes_drain_checkpoint() is interpreting one
* of this VM's own messages. No drain site drains
* again while it is set -- a payload running at the
* VM's depth 1 would otherwise let its own in-word
* checkpoint drain a SECOND message inside the
* first one's execution. */
int parked_idle; /**< FABRIC-3.8.md §3.5 (Stage B): nonzero only while
* this VM is parked in its switch trampoline's idle
* branch -- nothing borrowed on its stack, no work
* in progress. Distinct from VM_STATE_SWITCHED_OUT,
* which is also true of a VM parked mid-work.
* Consumed by the kill path in Stage D. */
VMFrameStack frames; /**< FABRIC-3.8.md §3.12 (Stage FR, 2026-09-23):
* this VM's execution frame stack -- the nested
* levels running on its own native stack, each
* with a frame ID and the executing VM's ID. The
* ownership gate, drain guard, parked-idle and log
* attribution are all derived from it. See
* starkernel/vm/vm_frame.h. */
/** @} */
/** @name Stadium Identity (item 4.2, FABRIC-0.md §25.5)
Binary file not shown.
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
+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__ */