Four bugs found live verifying the 8 identity thumbdrives (FABRIC-3.md §IX)
Build / build-amd64-iso (push) Waiting to run
Build / build-aarch64-iso (push) Waiting to run
Build / build-riscv64-img (push) Waiting to run

All found by actually running the identity workflow §VII/§VIII made
possible, not by code review:

1. Zuse/WIREBIND cross-contamination on detach: capsule_zuse_boot_logout()
   and capsule_wirebind_unclean_detach() both had no device parameter, so
   an unrelated device detaching (while the real owner's own stayed
   attached) incorrectly tore down the wrong session. Both now compare
   the departing device against their own tracked one, mirroring
   capsule_wirebind.c's pre-existing g_wirebind_attached_dev precedent.

2. Dictionary-entry memory leak: vm_create_word()'s sf_malloc()'d
   DictEntry (plus a second per-entry allocation for transition_metrics)
   was never freed by vm_cleanup(), in both the hosted and kernel
   implementations. Caused a real kernel PANIC after 8-9 repeated VM
   birth/kill cycles in one boot. Fixed by walking vm->latest in both.

3. sf_malloc/sf_free (alloc_kernel.c) was a 4MB bump arena with a
   deliberate no-op free, sized on "VM born once, never killed" -- fix #2
   alone didn't stop the panic because free() itself discarded the
   pointer regardless. Given a real free list (first-fit reuse).

4. Headless-console gate didn't re-engage after a mid-boot logout: the
   original fix (sk_console_mark_login(), one-way sticky) only gated the
   first login of the boot. Replaced with a live check
   (sk_console_identity_present()) re-evaluated continuously, including
   inside sk_console_readline()'s own blocking idle loop -- the console
   is normally sitting blocked there when a hot-unplug logout happens, so
   checking only at the top of the REPL loop wasn't enough.

Also: MINT now verifies its own write (verify_mint(), capsule_mint.c) by
reading back through the same check a real attach performs, rather than
trusting blkio_write()'s BLK_OK alone -- logged via log_message(), not
console_println(), per direct instruction.

Verified live, amd64: the full 8-identity repeated attach/detach cycle
that previously panicked at the same point every time now completes
clean, and a full serial-log sweep found zero bare unauthenticated
prompts anywhere in the run. Three-arch clean-qemu acceptance passed.

Still open, not fixed here: a 3+-simultaneous-device USB enumeration
failure found in a separate live test, not yet root-caused.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018EjXFo7mPXjUMjfJeuUUz4
This commit is contained in:
Robert Allan James
2026-09-06 01:49:13 -04:00
co-authored by Claude Sonnet 5
parent 0bae928aad
commit 2c1b3cd695
23 changed files with 45486 additions and 124 deletions
+155
View File
@@ -1340,3 +1340,158 @@ headless-console work) — no regression in existing `KILL`/`USE` behavior. Not
stress-tested against the specific dangling-pointer scenario (kill the actively-`USE`'d VM,
then dispatch another command) — the fix is small and directly addresses the confirmed gap,
but that specific repro was not re-run live after the fix landed.
## IX. Four bugs found live minting/verifying the 8 identity thumbdrives — CLOSED 2026-09-06
All four surfaced by actually exercising the system this project's own documentation exists
to describe — the thumbdrive-identity workflow §VII/§VIII made possible — not by code review.
Each is documented here with the same discipline: what broke, why, the fix, and what was
verified afterward.
### IX.1 — Zuse/WIREBIND cross-contamination on detach
**Bug:** with §VII's multi-device fix in place, live-verifying all 8 minted identities (boot
with Zuse attached, hot-plug each target drive one at a time, confirm `WIREBIND: <username>
attached and ready`) surfaced a real cross-contamination bug: detaching drive `00` printed
`Zuse: session ended -- reattach to re-authenticate` even though Zuse's own drive was never
touched. Root cause: `capsule_zuse_boot_logout()` (`capsule_zuse_boot.c:164`, called
unconditionally from `repl.c`'s per-slot detach loop for every departing device) had no way
to tell "some other device detached" from "my own device detached" — its only guard was a
bare boolean (`g_zuse_attached_this_device`), not a device comparison, despite its own header
doc comment already claiming that no-op existed. The exact same defect class existed in the
sibling function, `capsule_wirebind_unclean_detach()` (`capsule_wirebind.c:267`) — also no
device parameter, so an unrelated device detaching while a WIREBIND user's own stayed
attached would have incorrectly torn down that user's session too (not separately triggered
live, found by inspection once the first instance was understood).
**Fix:** both functions now take the departing `struct blkio_dev *dev` and compare it against
their own tracked device (`g_zuse_attached_dev`, new — mirrors `capsule_wirebind.c`'s own
pre-existing `g_wirebind_attached_dev` precedent exactly) before acting; every other value is
a no-op. `repl.c`'s detach loop passes the actual departing `usb_blk_dev` pointer it already
had in scope. `EJECT` (`mama_forth_words.c`'s `mama_word_eject()`, an explicit operator
command with no specific detaching device to check against) now calls a new accessor,
`capsule_zuse_boot_attached_dev()`, passing her own tracked device straight back in — trivially
matches when she's genuinely attached, correctly no-ops via the existing boolean guard
otherwise.
**Verified:** live, amd64 — a full 8-identity sequential attach/detach cycle (Zuse detached
first, then bob/rajames, 00-06 one at a time) with zero cross-contamination; `(zuse) ok>`
correctly persisted through unrelated detaches before this fix's own follow-on (§IX.3) closed
the remaining gap where her session ending correctly still left a bare prompt visible.
### IX.2 — Dictionary-entry memory leak (`vm_cleanup()`), causing a real kernel PANIC
**Bug:** repeating the WIREBIND birth/kill cycle 8-9 times in one boot (exactly what
identity verification does) produced a genuine kernel PANIC on the 8th-9th cycle every time:
cascading `vm_create_word: malloc failed` errors during capsule loading into the newly-born
VM, followed by `PANIC: Stadium: eviction selected patron zero (Hera) -- governor invariant
broken` and a full system halt. Root cause, found by tracing the allocation path: every
dictionary word (`vm_create_word()`, `dictionary_management.c:429`) allocates its own
`DictEntry` via `sf_malloc()` — separate from the VM's own `vm->memory` arena entirely — but
`vm_cleanup()` (**both** `src/starkernel/vm/vm_core.c:286` kernel and `src/vm_bootstrap.c:370`
hosted) freed `vm->memory`, the hotwords cache, rolling window, SSM state, and call stack, but
never walked the VM's own dictionary chain (`vm->latest`) to free the individual `DictEntry`
allocations — confirmed via grep that no such free existed anywhere in the codebase. Every
word a VM ever defined (hundreds per birth, given capsule loading) leaked permanently on kill.
Never noticed before this session: the hosted binary normally only calls `vm_cleanup()` once
at process exit (the OS reclaims everything anyway), and kernel VMs were normally born once
and kept alive for a whole boot, not repeatedly born and killed.
**Fix:** both `vm_cleanup()` implementations now walk `vm->latest` via each entry's own
`->link`, freeing `entry->transition_metrics` (a second, separate per-entry `sf_malloc()`,
`dictionary_management.c:455` — also unfreed anywhere) before the entry itself, saving the
next-link before each free since freeing destroys it.
**Verified:** live, amd64, the exact repeated-cycle scenario that panicked — no longer panics
alone (see §IX.3, this fix alone was insufficient until the allocator itself was also fixed).
### IX.3 — `sf_malloc`/`sf_free` was a bump allocator with a deliberate no-op free
**Bug, found immediately after §IX.2's fix didn't change the panic at all:** the kernel's own
`sf_malloc`/`sf_free` (`src/starkernel/vm/alloc_kernel.c`) is a fixed 4MB static bump arena
whose `sf_free()` was a **documented, deliberate no-op** — "VM is long-lived (no restart)...
free is a no-op... no fragmentation issues in practice." That premise was true until this
session's own repeated-birth-kill workflow. §IX.2's fix, while correct application-level
hygiene, was functionally inert against this specific allocator: calling `sf_free()`
diligently changes nothing when `sf_free()` itself throws the block away regardless.
**Fix:** `sf_malloc`/`sf_free` now implement a real free list. Every allocation carries a
fixed header (`size` + a free-list `next` link, live only while free) immediately before the
returned pointer. `sf_free()` pushes the block onto `g_free_list`; `sf_malloc()` searches it
first-fit before falling back to bump-allocating a fresh block from the arena, exactly as
before. Deliberately no splitting/coalescing — this workload's repeated allocations are for
the same capsules loaded into a fresh VM each time, so freed blocks from a just-killed VM's
dictionary are typically an exact or near-exact fit for the next VM's own, making first-fit
reuse the right simplicity tradeoff. `sf_realloc()` (pre-existing "orphans the old block"
behavior) was deliberately left untouched — out of scope for this fix, no evidence it's
exercised in the failure path.
**Verified:** live, amd64, the full 8-identity repeated birth/kill cycle that panicked at the
identical point on every prior run (confirmed twice) — no panic, all 9 sessions (Zuse + 8)
confirmed in one continuous boot. Three-arch `clean qemu` acceptance also re-run clean after
§IX.1/§IX.3/§IX.4 landed together.
### IX.4 — Headless-until-login gate didn't re-engage after a mid-boot logout
**Bug:** §VIII's original headless-console gate (`sk_console_mark_login()`/`sk_console_
login_occurred()`) was a one-way sticky flag, gating only the very first login of the boot
before `sk_repl(mama)` was ever called. Once tripped once, it never reset — a later full
logout (nobody attached at all) fell through to a bare, unauthenticated `ok>` instead of going
silent again, for the rest of the boot. Found live during the same 8-identity verification
run, flagged directly: *"we should never see an unauthorized, no session forth prompt."*
Fixing just the top-level loop wasn't enough either: the console is typically sitting blocked
inside `sk_console_readline()` waiting for input at the moment a logout happens (via hot-
unplug detected from *inside* that same call's own idle-service loop), so a check only at the
top of `sk_repl_run()`'s loop wouldn't take effect until a line was actually typed next.
**Fix:** `sk_console_mark_login()`/`sk_console_login_occurred()` retired entirely, replaced by
a live check, `sk_console_identity_present()` (`repl.c`) — mirrors `sk_print_prompt()`'s own
`zuse_session`/WIREBIND-username check exactly, evaluated fresh every time rather than latched
once. Three call sites updated: `sk_repl_headless_wait()`'s own loop condition; a new check at
the top of `sk_repl_run()`'s main loop (re-enters headless wait whenever nobody is currently
attached, not just before the very first login); and, critically, inside `sk_console_
readline()`'s own idle branch itself — when the identity present at prompt-print time logs out
while the call is still blocked waiting for input with nothing typed (`n == 0`), it now
returns -1 instead of re-printing a (correctly bare, but still unauthenticated) prompt and
continuing to wait. Callers with `reanchor_prompt` nonzero (the REPL's own top-level prompt
sites) check for -1 and loop back without printing `" ok"`, landing back at the top-of-loop
check, which re-enters silence immediately. `shim.c`'s `fgets()` (`reanchor_prompt == 0`)
never receives -1.
**Verified:** live, amd64, full 8-identity sequential cycle (Zuse's own detach, then each of 8
WIREBIND users' own detach) — grepped the entire session's serial log for every prompt-prefix
occurrence: 9 total `ok>` lines, every single one carrying a name (`(zuse)`, `(rajames)`,
`(00)`...`(06)`) — zero bare, unauthenticated prompts anywhere in the run. Three-arch
`clean qemu` acceptance re-run clean with this fix included.
### IX.5 — `MINT` now verifies its own write
**Motivation:** a separate live test (attaching Zuse + 8 identities simultaneously without
ever detaching any, exercising genuine 3+-device concurrent attach for the first time) hit a
4th-device USB enumeration failure (`xhci: not a Mass Storage/SCSI/BOT device`) — a real,
still-open defect (not yet root-caused, tracked as follow-on work, not part of this closure).
That failure raised a fair question: could `MINT` itself silently report success without the
identity actually being readable back afterward? `capsule_mint_identity()`
(`capsule_mint.c`) previously trusted every `blkio_write()`'s own `BLK_OK` return as
sufficient proof; it never confirmed any of it was actually readable back.
**Fix:** a new `verify_mint()` (`capsule_mint.c`) runs immediately after all writes and the
flush, before returning `MINT_OK`: re-runs `homeblocks_sig_check()` — the exact same check a
real later attach performs — then separately re-reads the identity record devblock and checks
magic/version/CRC/full-content match against what was written. A new result code,
`MINT_ERR_VERIFY_FAILED` (`capsule_mint.h`), is returned (and reported by the `MINT` word's
own existing console switch, `mama_forth_words.c`) if any check fails. Logged entirely via
`log_message()` (`LOG_ERROR` per specific mismatch, `LOG_INFO` on success) rather than
`console_println()`, per direct instruction — no added unconditional console/serial noise;
the `MINT` word's own final pass/fail line still reports to the console as it already did for
every other `MintResult` case.
**Not yet re-verified against a real failure** — every mint performed after this fix landed
(the original 8 identities were already minted before it existed) has succeeded, so the
failure-path logging itself hasn't been exercised live yet, only compiled. Three-arch
`clean qemu` acceptance passed with this change included.
**Still open, not addressed by this closure:** the 3+-simultaneous-device USB enumeration
failure that motivated §IX.5 (`xhci: not a Mass Storage/SCSI/BOT device` on a 4th concurrent
attach) remains unexplained and unfixed — tracked as the next item, separate from the four
closures above.