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.