Found live during identity-heap-capacity testing (2026-09-07, hotplugging Zuse + 8 identities one at a time and measuring the kernel heap arena via a temporary allocator-stats probe, since reverted): every WIREBIND identity attach births two VMs in sequence -- a "console" VM, then the real "user" VM. When the second birth failed (arena fragmentation under concurrent VM load, a separate, not-yet-fixed capacity issue), capsule_wirebind_try_attach() logged the failure and returned, but the console VM that had *already succeeded* was never torn down. It stays live and registered under the identity's username, consuming its own ~228KB of the fixed 4MB kernel heap arena forever -- nothing ever points a real user at it, since WIREBIND only ever hands the caller the user VM's id. This turns every failed identity attach into a permanent net loss of heap rather than a neutral retry: confirmed live that a failed attach left the arena 228,576 bytes worse off than before the attempt, and every subsequent attempt starts from that worse baseline, compounding. Fix: call capsule_vm_kill(username) on the now-orphaned console VM before returning from the failure path -- the same teardown capsule_wirebind_eject()/capsule_wirebind_unclean_detach() already use elsewhere in this file (vm_cleanup() + sf_free(), confirmed live to actually reclaim per-word dictionary allocations, FABRIC-3.md §IX.2/§IX.3). Verified live with the same allocator-stats probe (written, captured, reverted -- not part of this commit): after the fix, a forced user-VM birth failure now returns the arena to exactly its pre-attempt byte count (3,526,256, matching the baseline precisely) instead of leaking 228,576 bytes. Three-arch clean qemu acceptance (single Zuse device, the standard regression case) passed on amd64, aarch64, and riscv64. The underlying capacity/fragmentation question (why the 7th concurrent identity's arena allocation fails at all despite technically-sufficient free bytes) is a separate, open architecture question -- not addressed here. See project memory for the full measured numbers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014Ec88YKxxhZGG1RNnune78
358 lines
15 KiB
C
358 lines
15 KiB
C
/*
|
||
StarForth — Steady-State Virtual Machine Runtime
|
||
|
||
Copyright (c) 2023–2025 Robert A. James
|
||
All rights reserved.
|
||
|
||
Licensed under the StarForth License, Version 1.0
|
||
*/
|
||
|
||
#ifndef __STARKERNEL__
|
||
#error "capsule_wirebind.c is kernel-only"
|
||
#endif
|
||
|
||
#include "starkernel/capsule_wirebind.h"
|
||
#include "starkernel/capsule_birth.h"
|
||
#include "starkernel/capsule_runcap.h"
|
||
#include "starkernel/capsule_console.h"
|
||
#include "starkernel/vm_identity.h"
|
||
#include "starkernel/user_identity_seed.h"
|
||
#include "starkernel/console.h"
|
||
#include "starkernel/repl.h" /* sk_repl_get/set_active_vm() -- FABRIC-2.md §F.10 EJECT */
|
||
#include "blkio.h"
|
||
#include "block_subsystem.h" /* compute_crc64() */
|
||
#include "word_source/include/block_words.h" /* blk_vm_flush_all() -- §F.10 EJECT */
|
||
#include "freestanding/stdio.h"
|
||
#include <string.h>
|
||
#include <stddef.h>
|
||
|
||
/* FABRIC-2.md §F.10: "the VM currently attached via the home-blocks USB
|
||
* path" -- WIREBIND's own to track, set at successful birth in
|
||
* capsule_wirebind_try_attach(), read and cleared by both
|
||
* capsule_wirebind_eject() and capsule_wirebind_unclean_detach(). Single-
|
||
* USB-device constraint (§F.8) means there is never more than one
|
||
* candidate, so one tracked id is enough -- no set/list. */
|
||
static VMUuid g_wirebind_attached_vm_id;
|
||
static int g_wirebind_attached_valid = 0;
|
||
/* Plain username (no "~user" registry-name suffix), tracked separately
|
||
* from g_wirebind_attached_vm_id -- the registry entry's own name is
|
||
* user_vm_name ("<username>~user"), not the bare form the (user) prompt
|
||
* segment wants (FABRIC-2.md §I.1/4.4s). */
|
||
static char g_wirebind_attached_username[USER_IDENTITY_USERNAME_MAX] = {0};
|
||
/* FABRIC-2.md §I.2 overflow trigger, 2026-09-05: the attached drive's own
|
||
* blkio_dev pointer, tracked alongside the two fields above so
|
||
* capsule_wirebind_overflow_idle_check() can query its free space later
|
||
* without a new attach-time argument threaded through the idle loop. */
|
||
static struct blkio_dev *g_wirebind_attached_dev = (struct blkio_dev *) 0;
|
||
|
||
/* WIREBIND_CERT_MAX_DEVBLOCKS: a sane upper bound on how much cert
|
||
* content this reads, independent of whatever sig->cert_devblocks
|
||
* claims -- MINT (capsule_mint.c) only ever writes 1 devblock's worth
|
||
* (a real cert is ~150-250 bytes), so 4 devblocks (16KB) is already
|
||
* generous headroom, not a real constraint. */
|
||
#define WIREBIND_CERT_MAX_DEVBLOCKS 4u
|
||
|
||
/* FABRIC-2.md §I.2 overflow trigger, 2026-09-05: fixed constants, "fixed
|
||
* first" per this project's own standing sequencing (MIGRATION_WEAR_
|
||
* THRESHOLD, ZUSE_SESSION_TTL_SECONDS -- adaptive only after a real,
|
||
* measured baseline exists, not from day one). A single DoE-tunable knob
|
||
* apiece, not yet wired to Kconfig -- revisit once real usage data exists.
|
||
* 10% is a conservative first guess (extend well before actually full);
|
||
* 8 devblocks (24 Forth blocks, ~24 KiB) is a modest first extension, not
|
||
* a large land-grab. */
|
||
#define WIREBIND_OVERFLOW_FREE_THRESHOLD_PCT 10u
|
||
#define WIREBIND_OVERFLOW_CLAIM_DEVBLOCKS 8u
|
||
|
||
/* Read exactly one devblock (4096 bytes) at devblock offset `devblock`
|
||
* -- same convention capsule_runcap.c/capsule_mint.c already use. */
|
||
static int read_devblock(struct blkio_dev *dev, uint32_t devblock, uint8_t *buf4096) {
|
||
uint32_t base = devblock * 4u;
|
||
for (uint32_t i = 0; i < 4u; i++) {
|
||
if (blkio_read((blkio_dev_t *)dev, base + i,
|
||
buf4096 + (size_t)i * BLKIO_FORTH_BLOCK_SIZE) != BLKIO_OK) {
|
||
return -1;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
int capsule_wirebind_verify_cert(struct blkio_dev *dev,
|
||
const homeblocks_sig_t *sig,
|
||
VM *mama_vm, VMIdentity *out) {
|
||
if (!dev || !sig || !mama_vm || !out) return -1;
|
||
|
||
/* Genesis-mode Zuse drives have no cert region -- capsule_zuse_boot_
|
||
* try_attach() owns that case, not this one. */
|
||
if (sig->cert_offset == 0 || sig->cert_devblocks == 0) return -1;
|
||
|
||
/* Nothing to verify a regular cert against until Zuse herself has
|
||
* authenticated this boot. */
|
||
if (!mama_vm->zuse_cert_installed) return -1;
|
||
|
||
/* Read the cert region into a local buffer. */
|
||
uint32_t n_devblocks = sig->cert_devblocks;
|
||
if (n_devblocks > WIREBIND_CERT_MAX_DEVBLOCKS) n_devblocks = WIREBIND_CERT_MAX_DEVBLOCKS;
|
||
uint8_t cert_der[WIREBIND_CERT_MAX_DEVBLOCKS * 4096];
|
||
for (uint32_t d = 0; d < n_devblocks; d++) {
|
||
if (read_devblock(dev, sig->cert_offset + d, cert_der + (size_t)d * 4096) != 0) {
|
||
return -1;
|
||
}
|
||
}
|
||
size_t cert_len = (size_t)n_devblocks * 4096;
|
||
|
||
memset(out, 0, sizeof(*out));
|
||
return vm_identity_from_cert(out, cert_der, cert_len,
|
||
mama_vm->zuse_cert_pubkey, sig->drive_uuid,
|
||
0 /* acl_caps: no bits assigned yet, §F.2 */);
|
||
}
|
||
|
||
void capsule_wirebind_try_attach(struct blkio_dev *dev,
|
||
const homeblocks_sig_t *sig,
|
||
VM *mama_vm) {
|
||
if (!dev || !sig || !mama_vm) return;
|
||
|
||
/* Genesis-mode Zuse drives have no cert region -- capsule_zuse_boot_
|
||
* try_attach() already owns that case. Check here too so the right
|
||
* "not applicable" case stays silent (matches capsule_wirebind_
|
||
* verify_cert()'s own -1, but avoids a misleading refusal message
|
||
* for a drive this function was never going to act on anyway). */
|
||
if (sig->cert_offset == 0 || sig->cert_devblocks == 0) return;
|
||
if (!mama_vm->zuse_cert_installed) return;
|
||
|
||
VMIdentity identity;
|
||
if (capsule_wirebind_verify_cert(dev, sig, mama_vm, &identity) != 0) {
|
||
console_println("WIREBIND: cert verification FAILED -- drive refused");
|
||
return;
|
||
}
|
||
|
||
/* Verified. Read the drive's own profile for its username. */
|
||
if (sig->identity_src_offset == 0 || sig->identity_src_devblocks < 1) {
|
||
console_println("WIREBIND: verified cert but no identity_src region -- refusing");
|
||
return;
|
||
}
|
||
user_identity_seed_t idrec;
|
||
if (read_devblock(dev, sig->identity_src_offset, (uint8_t *)&idrec) != 0) {
|
||
console_println("WIREBIND: verified cert but identity record unreadable -- refusing");
|
||
return;
|
||
}
|
||
/* Same magic -> version -> CRC-64 discipline as capsule_zuse_boot.c:
|
||
* the identity record is the same on-disk format, so a corrupt or
|
||
* format-mismatched record must be refused rather than trusted. */
|
||
if (idrec.magic != USER_IDENTITY_SEED_MAGIC ||
|
||
idrec.version != USER_IDENTITY_SEED_VERSION) {
|
||
console_println("WIREBIND: verified cert but identity record unreadable -- refusing");
|
||
return;
|
||
}
|
||
uint64_t want_crc = compute_crc64((const uint8_t *)&idrec,
|
||
offsetof(user_identity_seed_t, crc));
|
||
if (want_crc != idrec.crc) {
|
||
console_println("WIREBIND: verified cert but identity record corrupt -- refusing");
|
||
return;
|
||
}
|
||
|
||
char username[USER_IDENTITY_USERNAME_MAX];
|
||
memcpy(username, idrec.username, sizeof(username));
|
||
username[sizeof(username) - 1] = '\0'; /* defensive; MINT already NUL-terminates */
|
||
|
||
char user_vm_name[USER_IDENTITY_USERNAME_MAX + 8];
|
||
size_t ulen = strlen(username);
|
||
if (ulen == 0 || ulen + 6 > sizeof(user_vm_name)) {
|
||
console_println("WIREBIND: empty or oversized username -- refusing");
|
||
return;
|
||
}
|
||
memcpy(user_vm_name, username, ulen);
|
||
memcpy(user_vm_name + ulen, "~user", 6);
|
||
|
||
/* Idempotent: already bound this session (console VM already live
|
||
* under this username) -- nothing to do. Matches BIRTH's own
|
||
* idempotent convention. */
|
||
{
|
||
VMRegistryEntry existing;
|
||
if (capsule_vm_find_by_name(username, &existing) == 0 &&
|
||
existing.state == VM_STATE_LIVE) {
|
||
return;
|
||
}
|
||
}
|
||
|
||
VMUuid console_id, user_id;
|
||
void *console_ctx = (void *)0;
|
||
void *user_ctx = (void *)0;
|
||
if (capsule_console_birth(username, mama_vm->stadium_vm_id, &console_id, &console_ctx)
|
||
!= CAPSULE_RUN_OK) {
|
||
console_println("WIREBIND: console VM birth FAILED");
|
||
return;
|
||
}
|
||
if (capsule_runcap_birth(dev, sig, user_vm_name, mama_vm->stadium_vm_id, &user_id,
|
||
&user_ctx) != CAPSULE_RUN_OK) {
|
||
console_println("WIREBIND: user VM birth FAILED");
|
||
/* The console VM above already succeeded and is live/registered
|
||
* under `username` -- without this, it's an orphan: nothing ever
|
||
* points a real user at it (WIREBIND only ever hands the caller
|
||
* the user VM's id), so it just sits there permanently holding
|
||
* its own kernel-heap arena allocation. Confirmed live 2026-09-07
|
||
* (identity-heap-capacity testing): this is exactly what turns a
|
||
* single failed identity attach into a permanent net loss of
|
||
* heap, compounding every subsequent attempt's odds of failing
|
||
* too, rather than a neutral retry. capsule_vm_kill() is the same
|
||
* teardown capsule_wirebind_eject()/capsule_wirebind_unclean_
|
||
* detach() already use elsewhere in this file -- vm_cleanup() +
|
||
* sf_free(), verified to actually reclaim per-word dictionary
|
||
* allocations (FABRIC-3.md §IX.2/§IX.3, 2026-09-06). */
|
||
capsule_vm_kill(username);
|
||
return;
|
||
}
|
||
|
||
/* Install the verified identity onto the user VM -- the one BINDSTEP
|
||
* (§F.9) will later re-verify against on every USE. */
|
||
if (user_ctx) {
|
||
((VM *)user_ctx)->identity = identity;
|
||
}
|
||
|
||
/* FABRIC-2.md §F.10: this is the successful-birth point EJECT/UNCLEAN
|
||
* need a tracked VMUuid from -- the user VM, not the console VM
|
||
* (blk_vm_flush_all()/capsule_vm_kill() below both act on the VM that
|
||
* actually owns block-subsystem state). */
|
||
g_wirebind_attached_vm_id = user_id;
|
||
g_wirebind_attached_valid = 1;
|
||
g_wirebind_attached_dev = dev;
|
||
memcpy(g_wirebind_attached_username, username, sizeof(g_wirebind_attached_username));
|
||
|
||
/* Decided 2026-09-05: no console for the running system unless a
|
||
* thumbdrive is present. Revised 2026-09-06: this used to call
|
||
* sk_console_mark_login() here (a one-way sticky flag) -- the gate is
|
||
* now a live check (sk_console_identity_present(), repl.c) driven
|
||
* directly by g_wirebind_attached_username being set above, re-checked
|
||
* continuously by sk_repl_run()'s own main loop rather than a one-shot
|
||
* signal at login time. Nothing to call here anymore. */
|
||
|
||
/* Register the pairing in the console's own routing table, index 3
|
||
* -- the fixed convention sk_repl_dispatch_line() (repl.c) uses. */
|
||
{
|
||
char reg_cmd[sizeof(user_vm_name) + 32];
|
||
int n = snprintf(reg_cmd, sizeof(reg_cmd), "S\" %s\" 3 VM-NAME-REG", user_vm_name);
|
||
if (n > 0 && (size_t)n < sizeof(reg_cmd)) {
|
||
vm_interpret((VM *)console_ctx, reg_cmd);
|
||
}
|
||
}
|
||
|
||
console_puts("WIREBIND: ");
|
||
console_puts(username);
|
||
console_println(" attached and ready -- USE it to begin");
|
||
}
|
||
|
||
/* Shared by capsule_wirebind_eject() and capsule_wirebind_unclean_detach():
|
||
* resolve the tracked attached-VM id to a live entry, or clear the tracked
|
||
* state and report "nothing attached" if it's already gone (idempotent --
|
||
* matches capsule_vm_kill()'s own convention). */
|
||
static int wirebind_resolve_attached(VMRegistryEntry *out) {
|
||
if (!g_wirebind_attached_valid) return -1;
|
||
if (capsule_vm_registry_get(g_wirebind_attached_vm_id, out) != 0 ||
|
||
out->state != VM_STATE_LIVE) {
|
||
g_wirebind_attached_valid = 0;
|
||
g_wirebind_attached_username[0] = '\0';
|
||
return -1;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
int capsule_wirebind_eject(void) {
|
||
VMRegistryEntry entry;
|
||
if (wirebind_resolve_attached(&entry) != 0) {
|
||
console_println("EJECT: nothing attached via WIREBIND");
|
||
return -1;
|
||
}
|
||
|
||
/* Drive is still physically present -- flush before it's safe to
|
||
* remove (§F.10 decision 1). */
|
||
blk_vm_flush_all((VM *)entry.vm_ptr);
|
||
|
||
if (sk_repl_get_active_vm() == (VM *)entry.vm_ptr) {
|
||
sk_repl_set_active_vm((VM *)0);
|
||
}
|
||
|
||
capsule_vm_kill(entry.name);
|
||
g_wirebind_attached_valid = 0;
|
||
g_wirebind_attached_dev = (struct blkio_dev *) 0;
|
||
g_wirebind_attached_username[0] = '\0';
|
||
|
||
console_puts("EJECT: ");
|
||
console_puts(entry.name);
|
||
console_println(" flushed and detached");
|
||
return 0;
|
||
}
|
||
|
||
void capsule_wirebind_unclean_detach(struct blkio_dev *dev) {
|
||
/* FABRIC-3.md §VII follow-on, 2026-09-06: same defect class as
|
||
* capsule_zuse_boot_logout()'s own fix, found in the same live
|
||
* verification session -- this used to take no device parameter at
|
||
* all, so an unrelated device detaching (Zuse's own drive, or general-
|
||
* purpose USB use) while a WIREBIND user stayed attached would
|
||
* incorrectly tear down that user's session too. The single-USB-
|
||
* device constraint this was written under never let a *different*
|
||
* device be the one detaching while a WIREBIND user's own stayed live
|
||
* -- stale now that genuine multi-device attach exists. */
|
||
if (dev != g_wirebind_attached_dev) return;
|
||
|
||
VMRegistryEntry entry;
|
||
if (wirebind_resolve_attached(&entry) != 0) return;
|
||
|
||
/* Device is already gone (§F.10 decision 2) -- no flush attempted;
|
||
* data since the last flush is lost, matching real-OS unclean-removal
|
||
* semantics. */
|
||
if (sk_repl_get_active_vm() == (VM *)entry.vm_ptr) {
|
||
sk_repl_set_active_vm((VM *)0);
|
||
}
|
||
|
||
capsule_vm_kill(entry.name);
|
||
g_wirebind_attached_valid = 0;
|
||
g_wirebind_attached_dev = (struct blkio_dev *) 0;
|
||
g_wirebind_attached_username[0] = '\0';
|
||
|
||
console_puts("WIREBIND: ");
|
||
console_puts(entry.name);
|
||
console_println(" detached without flush (device already gone)");
|
||
}
|
||
|
||
const char *capsule_wirebind_attached_username(void) {
|
||
if (!g_wirebind_attached_valid) return (const char *)0;
|
||
return g_wirebind_attached_username;
|
||
}
|
||
|
||
void capsule_wirebind_overflow_idle_check(void) {
|
||
if (!g_wirebind_attached_valid || !g_wirebind_attached_dev) return;
|
||
|
||
VMRegistryEntry entry;
|
||
if (capsule_vm_registry_get(g_wirebind_attached_vm_id, &entry) != 0 ||
|
||
entry.state != VM_STATE_LIVE || !entry.vm_ptr) {
|
||
return;
|
||
}
|
||
|
||
uint64_t free_blocks, total_blocks;
|
||
if (blk_get_device_free_blocks(g_wirebind_attached_dev, &free_blocks, &total_blocks)
|
||
!= BLK_OK || total_blocks == 0) {
|
||
return;
|
||
}
|
||
|
||
if (free_blocks * 100u >= total_blocks * WIREBIND_OVERFLOW_FREE_THRESHOLD_PCT) {
|
||
return; /* plenty of room left on the drive itself */
|
||
}
|
||
|
||
uint8_t owner_fp[8];
|
||
memcpy(owner_fp, ((VM *)entry.vm_ptr)->identity.owner_pubkey, sizeof(owner_fp));
|
||
|
||
if (blk_owner_has_claim(owner_fp)) {
|
||
return; /* already extended once -- not a growth loop, see header doc */
|
||
}
|
||
|
||
uint32_t chain_head;
|
||
if (blk_firsttouch_claim(owner_fp, WIREBIND_OVERFLOW_CLAIM_DEVBLOCKS, &chain_head)
|
||
!= BLK_OK) {
|
||
console_println("WIREBIND: overflow trigger fired but Artemis has no room to extend into");
|
||
return;
|
||
}
|
||
|
||
console_puts("WIREBIND: ");
|
||
console_puts(g_wirebind_attached_username);
|
||
console_println(" running low on drive space -- extended onto system-resident storage");
|
||
}
|