Stage D: parked-idle VMs are killable, KILL unregisters; fix uninitialized pending_reap (FABRIC-3.8 §3.5, G6)

sk_vm_parked_idle(): saved context + exactly its own IDLE frame.
capsule_vm_kill_refusal() is the single kill rule, shared by
capsule_vm_kill() and the KILL word: refuse SWITCHED_OUT mid-task, parked
with mail pending, or the VM running; allow parked-idle. capsule_vm_kill()
now unregisters the switch slot (G6).

Finding fixed: vm_registry_alloc() never initialized pending_reap (kmalloc
does not zero); C-b's reap_pending walks every participant, so garbage
reaped a healthy VM (seen live before any KILL). Now zeroed at alloc.

Probe (reverted): baseline KILL refused, slots 3->4->4 (FAIL, proves G6);
with Stage D dprobe parked idle, KILL dead, slots 3->4->3, PASS on amd64/
aarch64/riscv64. Acceptance: dict_hash unchanged, relay 3, idle growth 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Robert Allan James
2026-09-23 19:14:50 -04:00
co-authored by Claude Opus 5.5
parent 06bcab504d
commit 604ad7aadc
25 changed files with 82548 additions and 15 deletions
+53 -6
View File
@@ -38,6 +38,7 @@
#include "starkernel/session.h" /* session_register()/session_set_pinned() -- FABRIC-2.md §H.12 step 5 */
#include "starkernel/timer.h" /* timer_calibration_record()->vm_mode -- FABRIC-2.md §I.5 CONTRIB trust tier */
#include "starkernel/capsule_vm_switch_signal.h" /* FABRIC-3.md §XXVIII Stage 4 -- capsule_vm_force_reap() */
#include "starkernel/vm/switch.h" /* FABRIC-3.8.md §3.5 -- sk_vm_parked_idle(), current VM */
#include "starkernel/vm/kernel_hermes.h" /* FABRIC-3.6.md task 3.2 -- sk_hermes_channel_subscribe() */
#include "word_source/include/framebuffer_words.h" /* FABRIC-3.6.md task 1.8 -- register_framebuffer_words() */
#include "vm.h"
@@ -204,6 +205,14 @@ static VMRegistryEntry *vm_registry_alloc(void) {
* out of scope" for making this dynamic */
node->entry.vm_ptr = (void *)0;
node->entry.stadium_patron_cell = STADIUM_CELL_NONE;
/* FABRIC-3.8.md Stage D finding (2026-09-23): was never initialized --
* kmalloc does not zero, so every born VM started with whatever
* pending_reap the allocation held. Latent while only the old Stage 3
* checkpoint read it (on a timer-picked target); live once Stage C-b's
* sk_vm_switch_reap_pending() began walking every participant at every
* safe point -- a garbage non-zero reaped a healthy VM (seen live:
* "REAP: dprobe tombstone reclaimed" before any KILL). */
node->entry.pending_reap = 0;
for (i = 0; i < VM_NAME_MAX; i++) node->entry.name[i] = '\0';
node->next = (void *)0;
@@ -409,6 +418,33 @@ static void dispatch_init_forth(void *vm_ctx) {
* VM Kill
*===========================================================================*/
/* FABRIC-3.8.md §3.5 (Stage D, 2026-09-23). See the header. */
const char *capsule_vm_kill_refusal(const VMRegistryEntry *entry) {
if (!entry || entry->state == VM_STATE_DEAD) return (const char *)0;
/* A VM parked mid-task has a live frame on its native stack that must
* be resumed; a VM parked at its own trampoline with nothing in
* progress (sk_vm_parked_idle(): exactly its own IDLE frame) has only
* the trampoline loop there, which nothing ever needs back. It must
* also have no mail waiting: those messages hold their senders' Stadium
* heat, and nothing would ever drain them once the VM is gone. */
if (entry->state == VM_STATE_SWITCHED_OUT) {
if (!sk_vm_parked_idle((const VM *)entry->vm_ptr)) {
return " refused -- switched-out mid-task (context parked)";
}
if (sk_hermes_pending_count(entry->vm_id) > 0) {
return " refused -- parked with mail still pending";
}
}
/* The VM physically running can't free its own stack out from under
* itself -- a switch participant can now be the one executing KILL. */
if (entry->vm_ptr && entry->vm_ptr == (void *)sk_vm_switch_current_vm()) {
return " refused -- it is the VM running";
}
return (const char *)0;
}
int capsule_vm_kill(const char *name) {
VMRegistryEntry *entry;
VM *vm;
@@ -456,12 +492,17 @@ int capsule_vm_kill(const char *name) {
* 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;
* been switched back in and reaches a normal LIVE state. Stage D
* narrows this to what actually needs resuming -- see
* capsule_vm_kill_refusal(). */
{
const char *why = capsule_vm_kill_refusal(entry);
if (why) {
console_puts("KILL: ");
console_puts(name);
console_println(why);
return -1;
}
}
vm_id = entry->vm_id;
@@ -482,6 +523,12 @@ int capsule_vm_kill(const char *name) {
sf_free(vm);
}
/* Stage D (§3.5): same generic cleanup capsule_vm_force_reap() already
* does -- a dead VM is no longer a switch participant. Before this,
* an ordinary KILL left its slot behind (G6), to be walked by every
* decision and never freed. No-op for a VM that never registered. */
sk_vm_switch_signal_unregister(vm_id);
entry->vm_ptr = (void *)0;
entry->state = VM_STATE_DEAD;
for (i = 0; i < VM_NAME_MAX; i++) entry->name[i] = '\0';
+14 -8
View File
@@ -607,17 +607,23 @@ void mama_word_kill(VM *vm)
* before capsule_vm_kill()'s own Hera guard below ever runs. */
{
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
/* FABRIC-3.md §XXVIII, Stage 2 (2026-09-13): refuse 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;
* that's about to be refused anyway. FABRIC-3.8.md §3.5 (Stage D):
* the rule itself now lives in one place, capsule_vm_kill_refusal(),
* rather than a copy of capsule_vm_kill()'s old blanket
* SWITCHED_OUT refusal -- a VM parked idle is killable. */
if (capsule_vm_find_by_name_nocase(name_buf, &entry) == 0) {
const char *why = capsule_vm_kill_refusal(&entry);
if (why) {
console_puts("KILL: ");
console_puts(name_buf);
console_println(why);
return;
}
}
if (capsule_vm_find_by_name_nocase(name_buf, &entry) == 0 &&
entry.state == VM_STATE_LIVE) {
(entry.state == VM_STATE_LIVE || entry.state == VM_STATE_SWITCHED_OUT)) {
if (!vm_uuid_is_hera(entry.vm_id)) {
vm_physics_retire(entry.vm_id);
}
+7
View File
@@ -222,6 +222,13 @@ void sk_vm_idle_point(void) {
}
}
int sk_vm_parked_idle(const VM *vm) {
return vm && vm->native_stack_saved_sp != 0 &&
vm->frames.count == 1 &&
vm->frames.entries[0].kind == VM_FRAME_IDLE &&
vm_uuid_equal(vm->frames.entries[0].vm_id, vm->stadium_vm_id);
}
int sk_vm_context_switch(VM *from, VM *to) {
uint64_t new_sp;