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:
co-authored by
Claude Opus 5.5
parent
06bcab504d
commit
604ad7aadc
@@ -978,3 +978,77 @@ explicit instruction.
|
||||
her eligible).
|
||||
- Stage F's mechanism is now in C-b. Its remaining content is evidence only: work
|
||||
proceeds with the console idle, which this relay already shows for one message.
|
||||
- [x] **Stage D — kill of a parked-idle VM, and unregister on kill (§3.5, G6).** 2026-09-23.
|
||||
Captain Bob: *"go ahead with stage D."* §3.5's `parked_idle` flag is the frame predicate
|
||||
Stage FR already made possible, not a new field.
|
||||
|
||||
Changes:
|
||||
- `switch.c/.h`: new `sk_vm_parked_idle(vm)`. The VM has a saved context, and its frame
|
||||
stack is exactly one frame: its own `VM_FRAME_IDLE`. That describes a VM sitting at its
|
||||
trampoline with nothing in progress.
|
||||
- `capsule_birth.c/.h`: new `capsule_vm_kill_refusal(entry)`, the one place the rule
|
||||
lives. It refuses:
|
||||
- a `SWITCHED_OUT` VM that isn't parked idle (mid-task: a live frame awaits resuming);
|
||||
- one parked idle but with mail still pending (those messages hold their senders'
|
||||
Stadium heat, and nothing would drain them);
|
||||
- **the VM physically running**. A participant can now be the one executing `KILL`, and
|
||||
it can't free its own stack.
|
||||
|
||||
`capsule_vm_kill()` uses it and now calls `sk_vm_switch_signal_unregister()`, as
|
||||
`capsule_vm_force_reap()` already did. Before this, an ordinary `KILL` left the slot
|
||||
behind.
|
||||
- `mama_forth_words.c`: the `KILL` word used its own copy of the old blanket
|
||||
`SWITCHED_OUT` refusal. It now asks `capsule_vm_kill_refusal()`, and runs its heat
|
||||
retire and active-VM cleanup for any VM that will actually be killed, parked-idle ones
|
||||
included.
|
||||
|
||||
**Finding, fixed here: a C-b regression.**
|
||||
- `vm_registry_alloc()` never initialized `VMRegistryEntry.pending_reap`, and `kmalloc`
|
||||
doesn't zero memory, so every born VM started with whatever the allocation held.
|
||||
- This was latent since `FABRIC-3.md` §XXVIII Stage 4 (2026-09-14), because only the old
|
||||
checkpoint read it, on a timer-picked target.
|
||||
- C-b's `sk_vm_switch_reap_pending()` walks **every participant at every safe point**, so
|
||||
a garbage non-zero value reaps a healthy VM. Seen live in the first Stage D baseline
|
||||
(`logs/20260923-182435/amd64/`): `REAP: dprobe tombstone reclaimed` appears before any
|
||||
`KILL`.
|
||||
- Fixed at the root: `pending_reap = 0` in `vm_registry_alloc()`. It was the only
|
||||
`VMRegistryEntry` field left uninitialized.
|
||||
- The Tripod and `rajames~user` weren't reaped in C-b's runs only because their bytes
|
||||
happened to be 0.
|
||||
|
||||
**Evidence, as named in §4.** A temporary probe (`kernel_main.c`, reverted with
|
||||
`git checkout`):
|
||||
- It births `dprobe` from `workload-1-lite.4th` with `WORKER-BIRTH` and registers it by
|
||||
hand, standing in for Stage E.
|
||||
- It sends it `1 2 + DROP`. Hera runs `: DPX 1 DROP ; DPX`; that colon-word checkpoint,
|
||||
with a decision due from the mail, switches to `dprobe`. `dprobe` drains the message,
|
||||
finds nobody with work, and falls back to Hera.
|
||||
- It then checks that `dprobe` is parked idle, runs `S" dprobe" KILL`, and checks the
|
||||
slot count and registry state.
|
||||
|
||||
Runs:
|
||||
- **Baseline**, with the init fix but no Stage D, amd64 (`logs/20260923-182940/amd64/`):
|
||||
`KILL: refused -- switched-out`, `slots before=3 registered=4 after_kill=4
|
||||
parked_idle=1 dead=0`, **FAIL**. That proves G6.
|
||||
- A first run with Stage D (`-183452`) still failed identically. That is how the `KILL`
|
||||
word's own copy of the refusal was found; after that, the shared rule.
|
||||
- **With Stage D:** `logs/20260923-184026/amd64/`, `-184502/aarch64/` and
|
||||
`-184946/riscv64/` give `KILL: dprobe dead`, `slots before=3 registered=4 after_kill=3
|
||||
parked_idle=1 dead=1`, **PASS** on all three.
|
||||
|
||||
**Formal acceptance with the probe gone:** `logs/20260923-185515/amd64/`,
|
||||
`-190119/aarch64/`, `-190745/riscv64/`.
|
||||
- All reach `[zuse@Hera] ok>` with zero `UNKNOWN WORD` and zero failures or faults.
|
||||
- The `dict_hash` set is unchanged.
|
||||
- The relay gives `3` through a real switch.
|
||||
- Idle log growth: 0 bytes.
|
||||
- Clean build on all three plus hosted, no new warnings.
|
||||
|
||||
**Reported, not changed (pre-existing):**
|
||||
- Killing a **LIVE** VM that still has kernel-Hermes mail queued leaves those messages in
|
||||
its queue. They hold their senders' Stadium heat forever, because nothing drains a dead
|
||||
VM's queue. Stage D refuses the parked case for exactly this reason, but the LIVE path
|
||||
has always had it.
|
||||
- WIREBIND's detach path calls `capsule_vm_kill()` directly, without the `KILL` word's
|
||||
`vm_physics_retire()`. I have not checked whether a detached identity's VM-physics heat
|
||||
is retired elsewhere.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Capsule Block Manifest — Auto-generated
|
||||
<!-- Generated by mkcapsule --manifest 2026-09-23T21:45:34Z -->
|
||||
<!-- Generated by mkcapsule --manifest 2026-09-23T23:07:10Z -->
|
||||
<!-- DO NOT EDIT — re-run mkcapsule --manifest to refresh. -->
|
||||
<!-- Hand-written justifications and immutability notes live -->
|
||||
<!-- in MANIFEST.md alongside this auto-generated index. -->
|
||||
|
||||
@@ -236,6 +236,19 @@ void capsule_vm_registry_init(void *mama_vm_ptr);
|
||||
*/
|
||||
int capsule_vm_kill(const char *name);
|
||||
|
||||
/**
|
||||
* capsule_vm_kill_refusal - FABRIC-3.8.md §3.5 (Stage D, 2026-09-23): the
|
||||
* one place the "may this VM be killed right now" rule lives, shared by
|
||||
* capsule_vm_kill() and the KILL word (which must decide before its own
|
||||
* heat retire / active-VM cleanup runs). Returns NULL if killable, else a
|
||||
* " refused -- ..." reason suitable to print after "KILL: <name>". Refused:
|
||||
* a SWITCHED_OUT VM parked mid-task (a live frame awaits resuming) or
|
||||
* parked with mail still pending, and the VM physically running. Allowed:
|
||||
* LIVE VMs as before, and SWITCHED_OUT VMs parked idle at their own
|
||||
* trampoline (sk_vm_parked_idle()) with an empty queue.
|
||||
*/
|
||||
const char *capsule_vm_kill_refusal(const VMRegistryEntry *entry);
|
||||
|
||||
/**
|
||||
* capsule_vm_registry_get - Get VM registry entry by ID
|
||||
*
|
||||
|
||||
@@ -68,6 +68,14 @@ void sk_vm_switch_checkpoint(struct VM *vm);
|
||||
* switches to the coolest VM with work, if any. */
|
||||
void sk_vm_idle_point(void);
|
||||
|
||||
/* FABRIC-3.8.md §3.5 (Stage D): 1 if `vm` is parked at its own trampoline
|
||||
* with nothing in progress -- its frame stack holds exactly one frame, its
|
||||
* own VM_FRAME_IDLE. Its saved context is the bare trampoline loop; no
|
||||
* payload, no borrowed frame, nothing that would ever need resuming. This
|
||||
* is what makes a SWITCHED_OUT VM killable. (Hera parked at her idle point
|
||||
* also has one IDLE frame, but she can never be killed anyway.) */
|
||||
int sk_vm_parked_idle(const struct VM *vm);
|
||||
|
||||
#endif /* __STARKERNEL__ */
|
||||
|
||||
#endif /* STARKERNEL_VM_SWITCH_H */
|
||||
|
||||
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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user