Stage 2: cooperative VM context switch primitive, proven 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

Third stage of the preemptive context-switching plan. The real
save/restore switch mechanism now exists -- the first time anything has
ever executed on a VM's own native stack (Stage 1 allocated them,
unused).

New sk_vm_switch_to() (switch.S, one per arch) is an ordinary function
call, not an interrupt -- so unlike Stage 0's trap frame, the ABI already
covers every caller-saved register; only the callee-saved set needs
explicit save/restore (amd64: rbx/rbp/r12-r15, no FP at all since SysV
has no callee-saved XMM; aarch64: x19-x28/x29/x30 + d8-d15; riscv64:
s0-s11/ra + fs0-fs11, FS-gated like Stage 0 but read once and reused for
both halves within one call, since FS is genuine global CPU state, not
part of what's switched). A sibling sk_vm_switch_prime() in the same file
builds the synthetic first-entry frame, kept in assembly so the layout
can never drift out of sync with sk_vm_switch_to() itself.

New switch.c/switch.h: sk_vm_context_switch(from, to) handles first-entry
priming vs. resuming a parked context, and updates registry state (new
VM_STATE_SWITCHED_OUT, distinct from VM_STATE_STOPPED -- STOPPED means no
live frame, this means the opposite). sk_vm_switch_entry() is the minimal
permanent trampoline every freshly-entered VM lands in: no production
behavior defined yet, so it just yields straight back to whoever switched
to it, forever.

Closes the confirmed unguarded-KILL UAF found during planning:
capsule_vm_kill(), mama_word_kill(), and capsule_vm_kill_all_nonmama()
all now refuse (or silently leak rather than free, on the cold-restart
path where arch_cold_reset() wipes everything immediately after anyway)
tearing down a switched-out VM. Side effect found, not built on purpose:
the existing MSG-TICK idle-pump already filters on VM_STATE_LIVE, so it
automatically stopped dispatching into a switched-out VM with zero
changes needed there.

Verified via a temporary SWITCH-TEST probe (boot-triggered, since nothing
can type interactively into a foreground-only QEMU session) that
round-tripped a sentinel through 5 real Hera<->Hermes switches on all 3
architectures: 5/5 rounds, 0 failures, clean continuation to ok>. Probe
fully reverted after capture; kernel_main.c shows zero diff.

Also: Makefile.starkernel's LOADER_EXTRA_SRCS/LOADER_ASM needed the new
files added explicitly (this project's "loader" PE binary is the full
running kernel, not a thin bootstrap stage), and aarch64's switch.S
needed the same #ifndef _WIN32 guard around .hidden that isr.S already
carries (aarch64's loader assembles via clang targeting a PE/COFF
target with no .hidden equivalent) -- caught by a build failure, fixed.

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 15:25:39 -04:00
co-authored by Claude Sonnet 5
parent 57ac3fc304
commit f790d0995e
25 changed files with 54894 additions and 7 deletions
+56
View File
@@ -3579,3 +3579,59 @@ All 3 architectures re-verified clean boot to `ok>`, no native-stack allocation
for any Tripod-fleet VM on any arch. Stage 2 (the actual save/restore switch primitive, for any Tripod-fleet VM on any arch. Stage 2 (the actual save/restore switch primitive,
cooperative only, no timer) is next. cooperative only, no timer) is next.
**Stage 2 CLOSED, same day.** The real save/restore switch primitive now exists and is proven
correct on all 3 architectures -- the first time anything has ever executed on a VM's own
native stack (Stage 1's stacks were allocated but unused until now).
Deliberately NOT built the same way as Stage 0's interrupt trap frame -- `sk_vm_switch_to()`
(new `switch.S` beside each arch's `isr.S`) is an ordinary function call, so the standard ABI
already guarantees every caller-saved register is the caller's own problem; only the
callee-saved set needs explicit handling (amd64: rbx/rbp/r12-r15, no FP at all since SysV has
no callee-saved XMM; aarch64: x19-x28/x29/x30 + d8-d15; riscv64: s0-s11/ra + fs0-fs11, the FP
half still correctly FS-gated like Stage 0's trap frame, but read once and reused for both the
save and restore decision within one call rather than persisted -- FS is genuine single global
CPU state, not part of what's "switched"). A sibling `sk_vm_switch_prime()` in each same file
builds the synthetic first-entry frame (zeroed callee-saved slots + a trampoline address in the
return-address slot) so a VM's first-ever switch-in lands somewhere real instead of into
whatever real prior caller the layout would otherwise imply -- kept in assembly, not C, so the
frame layout can never drift out of sync between the two functions.
New C glue (`vm/switch.c`, `vm/switch.h`): `sk_vm_context_switch(from, to)` -- handles first-entry
priming vs. resuming a previously-parked context, and updates registry state around the switch
(new `VM_STATE_SWITCHED_OUT`, distinct from `VM_STATE_STOPPED` per the Stage 1 correction --
STOPPED means no live frame, SWITCHED_OUT means the opposite). `sk_vm_switch_entry()` is the
minimal permanent trampoline every freshly-entered VM lands in: no production behavior is
defined yet (that's later-stage work), so it just yields straight back to whoever switched to
it, forever -- must never fall off the end, since there is no legitimate return address below a
synthetic first-entry frame.
**Closed the confirmed unguarded-KILL UAF** found during planning: `capsule_vm_kill()`,
`mama_word_kill()`, and `capsule_vm_kill_all_nonmama()` all now refuse (or, for the
cold-restart-only path, silently leak rather than free -- harmless there specifically, since
`arch_cold_reset()` always follows immediately and wipes everything regardless) tearing down a
VM in `VM_STATE_SWITCHED_OUT`. A nice side effect discovered while verifying, not something
built deliberately: the existing MSG-TICK idle-pump (`repl.c`) already filters on
`VM_STATE_LIVE`, so it automatically stopped dispatching into Hermes the moment she was
switched out, with zero code changes needed there.
**Verification, per this project's write-probe-capture-revert discipline:** a temporary
`SWITCH-TEST` word (registered in Hera's own vocabulary, triggered once at boot right after
Hermes's birth, since there's no way to type interactively into a foreground-only QEMU session)
round-tripped a distinctive sentinel through 5 real Hera<->Hermes context switches. All 3
architectures: 5/5 rounds, 0 failures, PASS, clean continuation of boot to `ok>` afterward. The
probe word, its boot-time trigger, and its helper were then fully reverted -- re-verified clean
on all 3 architectures again with the probe gone, confirming no residual effects (Hermes back to
normal `VM_STATE_LIVE` MSG-TICK dispatch once no longer switched out).
Also fixed along the way: `Makefile.starkernel`'s `LOADER_EXTRA_SRCS`/`LOADER_ASM` lists needed
`switch.c`/`switch.S` added explicitly (this project's "loader" PE binary is effectively the
full running kernel, not a thin bootstrap stage -- confirmed by `isr.S` already being in
`LOADER_ASM`); and aarch64's `switch.S` needed the same `#ifndef _WIN32` guard around `.hidden`
that `isr.S` already carries, since aarch64's loader is assembled via clang targeting
`aarch64-pc-windows-msvc` (PE/COFF, no `.hidden` equivalent) -- missed on the first pass, caught
by the aarch64 build failing outright.
Stage 3 (timer-driven preemption, Tripod fleet only, the new run-readiness signal) is next --
the biggest remaining stage, and the first one that actually amends the §21.1
nothing-is-concurrent-on-one-hart ruling for real.
+3 -1
View File
@@ -462,7 +462,8 @@ LOADER_SRCS_BASE := \
LOADER_ASM := \ LOADER_ASM := \
$(KERNEL_SRC)/arch/$(ARCH)/boot.S \ $(KERNEL_SRC)/arch/$(ARCH)/boot.S \
$(KERNEL_SRC)/arch/$(ARCH)/isr.S $(KERNEL_SRC)/arch/$(ARCH)/isr.S \
$(KERNEL_SRC)/arch/$(ARCH)/switch.S
ifeq ($(ARCH),amd64) ifeq ($(ARCH),amd64)
LOADER_ASM += $(KERNEL_SRC)/arch/$(ARCH)/kernel_entry.S LOADER_ASM += $(KERNEL_SRC)/arch/$(ARCH)/kernel_entry.S
@@ -539,6 +540,7 @@ VM_CORE_SRCS := $(filter-out $(VM_EXCLUDE),$(VM_ALL_SRCS))
LOADER_EXTRA_SRCS := \ LOADER_EXTRA_SRCS := \
$(KERNEL_SRC)/vm/arena.c \ $(KERNEL_SRC)/vm/arena.c \
$(KERNEL_SRC)/vm/switch.c \
$(KERNEL_SRC)/vm/parity.c \ $(KERNEL_SRC)/vm/parity.c \
$(KERNEL_SRC)/vm/bootstrap/sk_vm_bootstrap.c \ $(KERNEL_SRC)/vm/bootstrap/sk_vm_bootstrap.c \
$(KERNEL_SRC)/vm/host/shim.c \ $(KERNEL_SRC)/vm/host/shim.c \
+1 -1
View File
@@ -1,5 +1,5 @@
# Capsule Block Manifest — Auto-generated # Capsule Block Manifest — Auto-generated
<!-- Generated by mkcapsule --manifest 2026-09-13T18:28:32Z --> <!-- Generated by mkcapsule --manifest 2026-09-13T19:23:06Z -->
<!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. --> <!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. -->
<!-- Hand-written justifications and immutability notes live --> <!-- Hand-written justifications and immutability notes live -->
<!-- in MANIFEST.md alongside this auto-generated index. --> <!-- in MANIFEST.md alongside this auto-generated index. -->
BIN
View File
Binary file not shown.
+15 -1
View File
@@ -87,9 +87,23 @@ typedef struct {
typedef enum { typedef enum {
VM_STATE_EMBRYO = 0, /* Allocated but not yet born */ VM_STATE_EMBRYO = 0, /* Allocated but not yet born */
VM_STATE_LIVE, /* Successfully born, operational */ VM_STATE_LIVE, /* Successfully born, operational */
VM_STATE_STOPPED, /* Suspended — execution state saved */ VM_STATE_STOPPED, /* Suspended — execution state saved. Set by
* the START word after STOP cleanly unwinds
* its C stack back to START's own frame: no
* live native frame remains, safe to free. */
VM_STATE_STILLBORN, /* Birth failed */ VM_STATE_STILLBORN, /* Birth failed */
VM_STATE_DEAD, /* Terminated */ VM_STATE_DEAD, /* Terminated */
VM_STATE_SWITCHED_OUT, /* FABRIC-3.md §XXVIII, Stage 2 (2026-09-13):
* a live saved register/stack context is
* parked on this VM's own native stack
* (SWITCH-TO switched control away mid-
* execution). Deliberately distinct from
* VM_STATE_STOPPED -- that state means "no
* live native frame," this one means the
* opposite. KILL must refuse/defer here: the
* parked frame still points into vm->memory
* and the native stack, both of which a free
* would invalidate out from under it. */
} VMState; } VMState;
typedef struct { typedef struct {
+51
View File
@@ -0,0 +1,51 @@
/*
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.
*/
/**
* switch.h - Cooperative VM context switch (FABRIC-3.md §XXVIII, Stage 2,
* 2026-09-13).
*
* sk_vm_context_switch() suspends `from`'s execution exactly where it is
* (mid-C-call-stack, on from's own native stack -- Stage 1) and resumes
* `to` -- either for the first time ever (a synthesized initial frame,
* entering sk_vm_switch_entry()) or exactly where `to` was itself last
* switched out. Returns to the caller only once something later switches
* back to `from` -- from from's own point of view, this call simply
* takes a while to return, like any blocking call.
*
* Cooperative only: nothing here is interrupt-driven yet (that's Stage
* 3), so this is safe to call only from ordinary FORTH word dispatch,
* never from ISR context.
*/
#ifndef STARKERNEL_VM_SWITCH_H
#define STARKERNEL_VM_SWITCH_H
#ifdef __STARKERNEL__
struct VM;
int sk_vm_context_switch(struct VM *from, struct VM *to);
#endif /* __STARKERNEL__ */
#endif /* STARKERNEL_VM_SWITCH_H */
+8 -3
View File
@@ -650,9 +650,14 @@ typedef struct VM
*/ */
uint64_t native_stack_paddr; /**< Physical base (for teardown); 0 = not allocated */ uint64_t native_stack_paddr; /**< Physical base (for teardown); 0 = not allocated */
uint64_t native_stack_guard_vaddr; /**< Virtual base of the whole guarded region */ uint64_t native_stack_guard_vaddr; /**< Virtual base of the whole guarded region */
uint64_t native_stack_top; /**< Initial SP value once something switches onto uint64_t native_stack_top; /**< Top of the allocated region (fixed, for the
* this stack -- Stage 1 only allocates it; nothing * initial frame construction); 0 = not allocated. */
* yet runs here (that's Stage 2). */ uint64_t native_stack_saved_sp; /**< Stage 2 (FABRIC-3.md §XXVIII, 2026-09-13): the
* parked SP of a live, switched-out register
* context on this VM's own native stack. 0 = never
* entered (no context parked yet); non-zero only
* while VMRegistryEntry.state ==
* VM_STATE_SWITCHED_OUT for this VM. */
/** @} */ /** @} */
/** @name Stadium Identity (item 4.2, FABRIC-0.md §25.5) /** @name Stadium Identity (item 4.2, FABRIC-0.md §25.5)
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
+105
View File
@@ -0,0 +1,105 @@
/*
* switch.S (aarch64) - Cooperative VM context switch primitive.
*
* FABRIC-3.md §XXVIII (Stage 2, preemptive-context-switch save/restore
* primitive, 2026-09-13). Deliberately NOT isr.S's interrupt trap frame
* (Stage 0) -- that saves everything because an interrupt has no call
* site. sk_vm_switch_to() is an ordinary `bl`, so AAPCS64 already makes
* every caller-saved register (x0-x18, v0-v7, v16-v31) the caller's own
* problem across the call. Only the callee-saved set needs explicit
* handling: x19-x28, x29 (fp), x30 (lr), and d8-d15 (only the low 64
* bits of v8-v15 are callee-saved per AAPCS64).
*
* void sk_vm_switch_to(uint64_t *save_sp_out, uint64_t new_sp);
* x0 = save_sp_out, x1 = new_sp
*
* For a VM that has never run before, new_sp must point at a synthetic
* 160-byte frame built by the C glue (switch.c) to this exact layout,
* with x30 (lr) set to the trampoline entry address and everything else
* zeroed, so the final `ret` below (which branches to lr) jumps there.
*/
.section .text
.balign 4
.equ SW_X19, 0
.equ SW_X21, 16
.equ SW_X23, 32
.equ SW_X25, 48
.equ SW_X27, 64
.equ SW_X29, 80 /* x29 (fp), x30 (lr) */
.equ SW_D8, 96
.equ SW_D10, 112
.equ SW_D12, 128
.equ SW_D14, 144
.equ SW_SIZE, 160
/* .hidden is an ELF-only visibility directive with no COFF equivalent;
* this file is also assembled for the aarch64 loader, which clang targets
* at aarch64-pc-windows-msvc (PE/COFF) -- guard on _WIN32 (defined only by
* that target), matching isr.S's own precedent exactly. */
#ifndef _WIN32
.hidden sk_vm_switch_to
#endif
.global sk_vm_switch_to
sk_vm_switch_to:
sub sp, sp, #SW_SIZE
stp x19, x20, [sp, #SW_X19]
stp x21, x22, [sp, #SW_X21]
stp x23, x24, [sp, #SW_X23]
stp x25, x26, [sp, #SW_X25]
stp x27, x28, [sp, #SW_X27]
stp x29, x30, [sp, #SW_X29]
stp d8, d9, [sp, #SW_D8]
stp d10, d11, [sp, #SW_D10]
stp d12, d13, [sp, #SW_D12]
stp d14, d15, [sp, #SW_D14]
mov x2, sp
str x2, [x0] /* *save_sp_out = sp (this context's parked SP) */
mov sp, x1 /* sp = new_sp (switch stacks) */
ldp x19, x20, [sp, #SW_X19]
ldp x21, x22, [sp, #SW_X21]
ldp x23, x24, [sp, #SW_X23]
ldp x25, x26, [sp, #SW_X25]
ldp x27, x28, [sp, #SW_X27]
ldp x29, x30, [sp, #SW_X29]
ldp d8, d9, [sp, #SW_D8]
ldp d10, d11, [sp, #SW_D10]
ldp d12, d13, [sp, #SW_D12]
ldp d14, d15, [sp, #SW_D14]
add sp, sp, #SW_SIZE
ret
/*
* uint64_t sk_vm_switch_prime(uint64_t stack_top, void *entry);
*
* Builds the synthetic frame above at the top of a fresh native stack, so
* the first ever sk_vm_switch_to() into it lands at `entry` (x30/lr) via
* its final `ret`. Kept in this file (not C) so the layout can never
* drift from sk_vm_switch_to() above -- one set of offsets, not two.
*/
#ifndef _WIN32
.hidden sk_vm_switch_prime
#endif
.global sk_vm_switch_prime
sk_vm_switch_prime:
sub x2, x0, #SW_SIZE
stp xzr, xzr, [x2, #SW_X19]
stp xzr, xzr, [x2, #SW_X21]
stp xzr, xzr, [x2, #SW_X23]
stp xzr, xzr, [x2, #SW_X25]
stp xzr, xzr, [x2, #SW_X27]
stp xzr, x1, [x2, #SW_X29] /* x29=0, x30(lr)=entry */
stp xzr, xzr, [x2, #SW_D8]
stp xzr, xzr, [x2, #SW_D10]
stp xzr, xzr, [x2, #SW_D12]
stp xzr, xzr, [x2, #SW_D14]
mov x0, x2
ret
+79
View File
@@ -0,0 +1,79 @@
/*
* switch.S (amd64) - Cooperative VM context switch primitive.
*
* FABRIC-3.md §XXVIII (Stage 2, preemptive-context-switch save/restore
* primitive, 2026-09-13). This is deliberately NOT the same mechanism as
* isr.S's interrupt trap frame (Stage 0) -- that saves every register
* because an interrupt is not a call site the interrupted code knows
* about. sk_vm_switch_to() IS an ordinary function call (SWITCH-TO's C
* handler calls it directly), so the standard System V AMD64 ABI already
* guarantees every caller-saved register (rax, rcx, rdx, rsi, rdi, r8-r11,
* all XMM) is the caller's own problem to preserve across the call, exactly
* like any other call. Only the callee-saved set -- rbx, rbp, r12-r15 --
* needs explicit handling here, because this function does not really
* "return" in the normal sense: it swaps to a completely different stack
* and (from the newly-resumed context's point of view) appears to return
* from whichever earlier call to this same function originally parked it.
* SysV has no callee-saved XMM registers at all, so there is no FP state
* to carry here.
*
* void sk_vm_switch_to(uint64_t *save_sp_out, uint64_t new_sp);
* rdi = save_sp_out, rsi = new_sp
*
* For a VM that has never run before, new_sp must point at a synthetic
* frame built to this exact layout by the C glue (switch.c): six zeroed
* quadwords (r15, r14, r13, r12, rbx, rbp -- values irrelevant, never a
* real prior context) followed by the trampoline entry address at
* new_sp+48, so the final `ret` below jumps there instead of into some
* prior caller.
*/
.intel_syntax noprefix
.section .text
.hidden sk_vm_switch_to
.global sk_vm_switch_to
sk_vm_switch_to:
push rbp
push rbx
push r12
push r13
push r14
push r15
mov [rdi], rsp /* *save_sp_out = rsp (this context's parked SP) */
mov rsp, rsi /* rsp = new_sp (switch stacks) */
pop r15
pop r14
pop r13
pop r12
pop rbx
pop rbp
ret
/*
* uint64_t sk_vm_switch_prime(uint64_t stack_top, void *entry);
*
* Builds the synthetic frame described above at the top of a fresh native
* stack, so the first ever sk_vm_switch_to() into it lands at `entry`
* instead of some real prior caller. Returns the resulting sp value to
* pass as sk_vm_switch_to()'s new_sp. Kept in this file (not C) so the
* layout can never drift from what sk_vm_switch_to() above actually
* expects -- one set of offsets, not two copies to keep in sync.
*/
.hidden sk_vm_switch_prime
.global sk_vm_switch_prime
sk_vm_switch_prime:
mov rax, rdi
sub rax, 56
mov qword ptr [rax+0], 0 /* r15 */
mov qword ptr [rax+8], 0 /* r14 */
mov qword ptr [rax+16], 0 /* r13 */
mov qword ptr [rax+24], 0 /* r12 */
mov qword ptr [rax+32], 0 /* rbx */
mov qword ptr [rax+40], 0 /* rbp */
mov [rax+48], rsi /* return address = entry */
ret
+162
View File
@@ -0,0 +1,162 @@
/*
* switch.S (riscv64) - Cooperative VM context switch primitive.
*
* FABRIC-3.md §XXVIII (Stage 2, preemptive-context-switch save/restore
* primitive, 2026-09-13). Deliberately NOT isr.S's interrupt trap frame
* (Stage 0) -- that saves everything because an async trap has no call
* site. sk_vm_switch_to() is an ordinary `call`, so the LP64D psABI
* already makes every caller-saved register (ra excepted -- see below --
* t0-t6, a0-a7, ft0-ft11, fa0-fa7) the caller's own problem across the
* call. Only the callee-saved set needs explicit handling: s0-s11,
* fs0-fs11, and ra itself (ra is not "callee-saved" in the usual sense,
* but this function does not really return to its own caller -- it swaps
* stacks and later resumes as if returning from whichever earlier call
* parked this context, so ra must be captured explicitly here rather than
* left to a normal prologue/epilogue).
*
* The FP half is saved only when sstatus.FS != Off -- same reasoning and
* bit test as isr.S's trap frame, checked once per call and reused for
* both the save and restore decision (nothing changes FS in between; it
* is genuine single global CPU state, not part of what's "switched," so
* this function reads it but never writes it).
*
* void sk_vm_switch_to(uint64_t *save_sp_out, uint64_t new_sp);
* a0 = save_sp_out, a1 = new_sp
*
* For a VM that has never run before, new_sp must point at a synthetic
* 208-byte frame built by the C glue (switch.c) with ra set to the
* trampoline entry address and everything else zeroed, so the final
* `ret` below (jalr via ra) jumps there.
*/
.section .text
.balign 4
.equ SW_S0, 0
.equ SW_RA, 96
.equ SW_FS0, 104
.equ SW_SIZE, 208
.equ SSTATUS_FS_SHIFT, 13
.equ SSTATUS_FS_MASK, 3
.hidden sk_vm_switch_to
.global sk_vm_switch_to
sk_vm_switch_to:
addi sp, sp, -SW_SIZE
sd s0, SW_S0+0(sp)
sd s1, SW_S0+8(sp)
sd s2, SW_S0+16(sp)
sd s3, SW_S0+24(sp)
sd s4, SW_S0+32(sp)
sd s5, SW_S0+40(sp)
sd s6, SW_S0+48(sp)
sd s7, SW_S0+56(sp)
sd s8, SW_S0+64(sp)
sd s9, SW_S0+72(sp)
sd s10, SW_S0+80(sp)
sd s11, SW_S0+88(sp)
sd ra, SW_RA(sp)
csrr t0, sstatus
srli t1, t0, SSTATUS_FS_SHIFT
andi t1, t1, SSTATUS_FS_MASK
beqz t1, 1f
fsd fs0, SW_FS0+0(sp)
fsd fs1, SW_FS0+8(sp)
fsd fs2, SW_FS0+16(sp)
fsd fs3, SW_FS0+24(sp)
fsd fs4, SW_FS0+32(sp)
fsd fs5, SW_FS0+40(sp)
fsd fs6, SW_FS0+48(sp)
fsd fs7, SW_FS0+56(sp)
fsd fs8, SW_FS0+64(sp)
fsd fs9, SW_FS0+72(sp)
fsd fs10, SW_FS0+80(sp)
fsd fs11, SW_FS0+88(sp)
1:
sd sp, 0(a0) /* *save_sp_out = sp (this context's parked SP) */
mv sp, a1 /* sp = new_sp (switch stacks) */
ld s0, SW_S0+0(sp)
ld s1, SW_S0+8(sp)
ld s2, SW_S0+16(sp)
ld s3, SW_S0+24(sp)
ld s4, SW_S0+32(sp)
ld s5, SW_S0+40(sp)
ld s6, SW_S0+48(sp)
ld s7, SW_S0+56(sp)
ld s8, SW_S0+64(sp)
ld s9, SW_S0+72(sp)
ld s10, SW_S0+80(sp)
ld s11, SW_S0+88(sp)
ld ra, SW_RA(sp)
beqz t1, 2f
fld fs0, SW_FS0+0(sp)
fld fs1, SW_FS0+8(sp)
fld fs2, SW_FS0+16(sp)
fld fs3, SW_FS0+24(sp)
fld fs4, SW_FS0+32(sp)
fld fs5, SW_FS0+40(sp)
fld fs6, SW_FS0+48(sp)
fld fs7, SW_FS0+56(sp)
fld fs8, SW_FS0+64(sp)
fld fs9, SW_FS0+72(sp)
fld fs10, SW_FS0+80(sp)
fld fs11, SW_FS0+88(sp)
2:
addi sp, sp, SW_SIZE
ret
/*
* uint64_t sk_vm_switch_prime(uint64_t stack_top, void *entry);
*
* Builds the synthetic frame above at the top of a fresh native stack, so
* the first ever sk_vm_switch_to() into it lands at `entry` (ra) via its
* final `ret`. Kept in this file (not C) so the layout can never drift
* from sk_vm_switch_to() above -- one set of offsets, not two. FP slots
* are zeroed via plain integer stores (sd, not fsd) -- this is just
* writing zero bytes to memory, not an FP computation, so it needs no
* FS-enabled check at all, unlike every other f-register touch in this
* file.
*/
.hidden sk_vm_switch_prime
.global sk_vm_switch_prime
sk_vm_switch_prime:
addi t0, a0, -SW_SIZE
sd zero, SW_S0+0(t0)
sd zero, SW_S0+8(t0)
sd zero, SW_S0+16(t0)
sd zero, SW_S0+24(t0)
sd zero, SW_S0+32(t0)
sd zero, SW_S0+40(t0)
sd zero, SW_S0+48(t0)
sd zero, SW_S0+56(t0)
sd zero, SW_S0+64(t0)
sd zero, SW_S0+72(t0)
sd zero, SW_S0+80(t0)
sd zero, SW_S0+88(t0)
sd a1, SW_RA(t0)
sd zero, SW_FS0+0(t0)
sd zero, SW_FS0+8(t0)
sd zero, SW_FS0+16(t0)
sd zero, SW_FS0+24(t0)
sd zero, SW_FS0+32(t0)
sd zero, SW_FS0+40(t0)
sd zero, SW_FS0+48(t0)
sd zero, SW_FS0+56(t0)
sd zero, SW_FS0+64(t0)
sd zero, SW_FS0+72(t0)
sd zero, SW_FS0+80(t0)
sd zero, SW_FS0+88(t0)
mv a0, t0
ret
+24 -1
View File
@@ -392,6 +392,19 @@ int capsule_vm_kill(const char *name) {
return 0; return 0;
} }
/* FABRIC-3.md §XXVIII, Stage 2 (2026-09-13): a switched-out VM has a
* live saved context parked on its own native stack -- freeing vm_ptr
* here would leave that saved SP pointing into freed memory, a real
* use-after-free the moment anything ever tried to resume it. Refuse
* rather than silently corrupt; the caller can retry once the VM has
* been switched back in and reaches a normal LIVE state. */
if (entry->state == VM_STATE_SWITCHED_OUT) {
console_puts("KILL: ");
console_puts(name);
console_println(" refused -- switched-out (context parked)");
return -1;
}
vm_id = entry->vm_id; vm_id = entry->vm_id;
vm = (VM *)entry->vm_ptr; vm = (VM *)entry->vm_ptr;
@@ -440,7 +453,17 @@ void capsule_vm_kill_all_nonmama(void) {
(void)stadium_evict(node->entry.stadium_patron_cell); (void)stadium_evict(node->entry.stadium_patron_cell);
node->entry.stadium_patron_cell = STADIUM_CELL_NONE; node->entry.stadium_patron_cell = STADIUM_CELL_NONE;
} }
if (vm) { /* FABRIC-3.md §XXVIII, Stage 2 (2026-09-13): a switched-out VM has
* a live saved context parked on its own native stack -- skip the
* free (leaked, not corrupted) rather than invalidate memory a
* parked SP still points into. Harmless here specifically: every
* caller of this function (BYE/cold-restart) calls
* arch_cold_reset() immediately afterward, which wipes all memory
* regardless of what this function left allocated. */
if (vm && node->entry.state == VM_STATE_SWITCHED_OUT) {
log_message(LOG_WARN, "capsule_vm_kill_all_nonmama: leaving %s allocated (switched-out)",
node->entry.name);
} else if (vm) {
vm->halted = 1; vm->halted = 1;
vm_cleanup(vm); vm_cleanup(vm);
sf_free(vm); sf_free(vm);
@@ -586,6 +586,15 @@ void mama_word_kill(VM *vm)
* before capsule_vm_kill()'s own Hera guard below ever runs. */ * before capsule_vm_kill()'s own Hera guard below ever runs. */
{ {
VMRegistryEntry entry; VMRegistryEntry entry;
/* FABRIC-3.md §XXVIII, Stage 2 (2026-09-13): mirror the refusal
* capsule_vm_kill() itself now enforces -- checked here too so the
* heat-fanout/active-VM cleanup below never runs against a VM
* that's about to be refused anyway. */
if (capsule_vm_find_by_name_nocase(name_buf, &entry) == 0 &&
entry.state == VM_STATE_SWITCHED_OUT) {
console_println("KILL: refused -- switched-out (context parked)");
return;
}
if (capsule_vm_find_by_name_nocase(name_buf, &entry) == 0 && if (capsule_vm_find_by_name_nocase(name_buf, &entry) == 0 &&
entry.state == VM_STATE_LIVE) { entry.state == VM_STATE_LIVE) {
if (!vm_uuid_is_hera(entry.vm_id)) { if (!vm_uuid_is_hera(entry.vm_id)) {
+98
View File
@@ -0,0 +1,98 @@
/*
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.
*/
/**
* switch.c - Cooperative VM context switch glue (FABRIC-3.md §XXVIII,
* Stage 2, 2026-09-13). See switch.h for the contract.
*/
#ifdef __STARKERNEL__
#include "starkernel/vm/switch.h"
#include "vm.h"
#include "starkernel/capsule_birth.h" /* capsule_vm_set_state */
#include <stdint.h>
/* Arch-specific (switch.S in each arch dir). sk_vm_switch_prime() builds
* the exact synthetic first-entry frame sk_vm_switch_to() expects -- kept
* in assembly, not duplicated here, so the layout can never drift out of
* sync between the two. */
extern void sk_vm_switch_to(uint64_t *save_sp_out, uint64_t new_sp);
extern uint64_t sk_vm_switch_prime(uint64_t stack_top, void *entry);
/* Single-writer-mainline globals, safe under this stage's cooperative-
* only, one-VM-runs-at-a-time-on-one-hart discipline (same class of
* bookkeeping as vm_core.c's g_log_attrib_vm) -- set immediately before a
* first-ever switch into a VM, read exactly once, synchronously, at the
* very top of sk_vm_switch_entry() before any other switch could occur.
* Preemption (Stage 3) will need to revisit this; flagged there, not here. */
static VM *g_switch_entry_vm;
static VM *g_switch_back_to;
/**
* sk_vm_switch_entry - trampoline for a VM's first-ever entry.
*
* A freshly-entered VM has no production behavior defined yet -- what a
* switched-to VM should actually DO once running is later-stage work
* (message dispatch, an idle loop, whatever Stage 3+ needs). For now this
* is a minimal, permanent placeholder: immediately yield back to whoever
* switched to it, forever. Must never fall off the end -- there is no
* legitimate return address below a synthetic first-entry frame, unlike
* an ordinary function.
*/
void sk_vm_switch_entry(void) {
VM *self = g_switch_entry_vm;
VM *back = g_switch_back_to;
for (;;) {
sk_vm_context_switch(self, back);
}
}
int sk_vm_context_switch(VM *from, VM *to) {
uint64_t new_sp;
if (!from || !to || to->native_stack_top == 0) {
return -1;
}
if (to->native_stack_saved_sp == 0) {
/* First-ever entry: synthesize the initial frame. */
g_switch_entry_vm = to;
g_switch_back_to = from;
new_sp = sk_vm_switch_prime(to->native_stack_top, (void *)&sk_vm_switch_entry);
} else {
new_sp = to->native_stack_saved_sp;
to->native_stack_saved_sp = 0; /* about to be running, not parked */
}
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_to(&from->native_stack_saved_sp, new_sp);
/* Resumes here only once something later switches back to `from` --
* from `from`'s own point of view, this call simply took a while. */
capsule_vm_set_state(from->stadium_vm_id, VM_STATE_LIVE);
return 0;
}
#endif /* __STARKERNEL__ */