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
+74
View File
@@ -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.