/* StarForth — Steady-State Virtual Machine Runtime Copyright (c) 2023–2025 Robert A. James All rights reserved. Licensed under the StarForth License, Version 1.0 */ /** * repl.c - Emergency FORTH REPL for LithosAnanke kernel * * Direct adaptation of src/repl.c for the freestanding kernel context. * Replaces libc stdio (fgets/printf/fflush) with HAL serial I/O: * - Input: console_getc() non-blocking poll with local echo and backspace * - Output: console_puts() / console_putc() * * Idle spin: polls console_getc() and services the adaptive heartbeat. * The timer ISR's top half (heartbeat_tick()) latches one * sample per interrupt; the idle spin drains it every * iteration via heartbeat_service() (item 0.8, FABRIC-0.md §26) * and calls sk_repl_idle() once per SK_IDLE_BEAT_INTERVAL ticks * for coarser subsystem dispatch. On QEMU TCG the ISR must fire * for ticks to advance — check "Heartbeat: N ticks" in the * serial log to confirm. * * Runs with interrupts enabled so the APIC heartbeat fires normally. * Designed as the last thing kernel_main does before the idle loop. */ #include "starkernel/repl.h" #include "console.h" #include "log.h" #include "vm.h" #include "version.h" #include "starkernel/timer.h" #include "starkernel/arch.h" #include "starkernel/xhci_driver.h" #include "starkernel/blkio_usb.h" #include "starkernel/kmalloc.h" #include "starkernel/homeblocks_sig.h" #include "starkernel/artemis_sig.h" #include "starkernel/capsule_birth.h" #include "starkernel/capsule_zuse_boot.h" #include "starkernel/capsule_wirebind.h" #include "starkernel/capsule_run.h" #include "starkernel/vm/bootstrap/sk_vm_bootstrap.h" #include "starkernel/vm/kernel_hermes.h" /* FABRIC-3.6.md task 3.8 -- sk_hermes_send_one() */ #include "starkernel/vm/stadium.h" /* task 3.8 -- stadium_conserved() evidence print */ #include "block_subsystem.h" #include "word_source/include/keyboard_words.h" #include "word_source/include/block_words.h" #include "word_registry.h" #include "freestanding/stdio.h" #include #include /* FABRIC-0.md 4.4: "ok>" (including its trailing space) renders in bright * cyan, 0x55FFFF -- reuses FB_ANSI_PALETTE[14]. Sent as a real SGR escape * so it colors both the framebuffer (parsed by vt100.c's apply_sgr()) and * any ANSI-aware serial terminal, per 4.4c's "identical on both" goal. */ #define SK_PROMPT_TEXT "\x1b[38;2;85;255;255mok> \x1b[39m" const char lithos_version[64] = LITHOS_VERSION_STR; /* FABRIC-3.md SXXV follow-up (2026-09-13): was "[VM name] (user) ok>" * (e.g. "[Hera] (zuse) ok>") -- the VM-name bracket and the user segment * were computed independently, and neither one told you whether the * bracketed VM was the console proxy WIREBIND births per identity or the * actual restricted identity VM behind it (e.g. "rajames" vs. * "rajames~user") -- confirmed live to cause real confusion debugging * the std79 lockdown. Unified into the single "[user@VM name] ok>" line * prefix (console.c's emit_prefix(), via console_set_user_prefix_ * provider() below) -- one tag, always accurate about both who's at the * console AND which VM a line actually came from/a command actually * reaches. This function now only prints the bare prompt text; the user * segment moved into the line prefix, which is a different call path per * console.c's own commenting elsewhere but reaches every line including * this prompt. */ static void sk_print_prompt(void) { console_puts(SK_PROMPT_TEXT); } /* console_user_prefix_fn provider (console.h). 2026-09-22, Captain * Bob's own instruction, restated precisely across several corrections * this session: once a WIREBIND identity's own console is active (the * physical console has been `USE`'d into it), the bracket must show * that SAME name on both sides -- "[rajames@rajames]", not * "[zuse@rajames]" -- because the identity console_get_vm_name() * already tracks (WIREBIND births the console VM literally named after * the human, mama_forth_words.c's capsule_console_birth()) IS her own * VM, not a separate "who's driving" label layered on top of it. * "R.A. James is also a VM" was the exact reasoning given. * * Below the top level (console_get_vm_name() != "Hera", i.e. USE or a * BIRTH/RUN/CONNECT-* redirect has pointed the console somewhere else), * that target's own name already answers "who/what is this" -- return * it directly, and emit_prefix() ends up printing it on both sides * (this function's return @ console_get_vm_name(), unchanged). At the * top level (still on Hera, nothing has redirected the console yet), * her own name doesn't say WHO is driving her, so this keeps the * original zuse_session/WIREBIND-username logic for that one case -- * unchanged from before this fix, still correct: a live WIREBIND * attach announces itself before USE is ever typed * ("WIREBIND: attached and ready -- USE it to begin"), and the * prompt should already reflect that. */ static const char *sk_console_user_prefix(void) { const char *vn = console_get_vm_name(); if (vn && strcmp(vn, "Hera") != 0) return vn; VM *mama_vm = (VM *)sk_get_mama_vm(); if (mama_vm && mama_vm->zuse_session) return "zuse"; return capsule_wirebind_attached_username(); } /*=========================================================================== * USE-word dispatch: which VM receives REPL input. * * NULL means "use the REPL's own vm parameter" (default — Mama). * Set via sk_repl_set_active_vm(); read by sk_repl_run() each iteration. *===========================================================================*/ static VM *g_repl_active_vm = (void *)0; void sk_repl_set_active_vm(VM *vm) { g_repl_active_vm = vm; } VM *sk_repl_get_active_vm(void) { return g_repl_active_vm; } /*=========================================================================== * Headless-until-login gate, decided 2026-09-05: no console for the * running system unless a thumbdrive is present. * * Revised 2026-09-06: this was originally a one-way sticky flag * (sk_console_mark_login(), set once by either login path and never * cleared), gating only the very first entry into sk_repl_run() at boot. * That let a real security gap through, found live during this session's * own repeated identity-verification workflow: once anyone logged in even * once, the console stayed visible for the rest of the boot -- a later * full logout (nobody attached at all) fell through to a bare, * unauthenticated "ok>" instead of going silent again. sk_console_ * identity_present() replaces the sticky flag with a live check (mirrors * sk_print_prompt()'s own zuse_session/WIREBIND-username check exactly), * and sk_repl_run()'s own main loop now re-checks it every iteration, not * just once before the loop starts -- see its own call site below. */ static int sk_console_identity_present(void) { VM *mama_vm = (VM *)sk_get_mama_vm(); if (mama_vm && mama_vm->zuse_session) return 1; if (capsule_wirebind_attached_username() != (const char *)0) return 1; return 0; } /*=========================================================================== * Currently attached home-blocks device: mirrors g_repl_active_vm's own * shape (FABRIC-2.md §F.9's own precedent for this exact accessor). Set * once sk_repl_idle()'s own attach handling confirms HOMEBLOCKS_SIG_OK * below; cleared on detach. RUNCAP (§F.6/§F.18) and, later, BINDSTEP's * re-verify-live check (§F.9) both need this -- neither lives in this * file, and usb_blk_dev/xdev below are function-static, invisible outside * sk_repl_idle() without an accessor like this one. *===========================================================================*/ static blkio_dev_t *g_homeblocks_dev = (void *)0; static homeblocks_sig_t g_homeblocks_sig; static int g_homeblocks_sig_valid = 0; blkio_dev_t *sk_repl_get_homeblocks_dev(void) { return g_homeblocks_sig_valid ? g_homeblocks_dev : (void *)0; } const homeblocks_sig_t *sk_repl_get_homeblocks_sig(void) { return g_homeblocks_sig_valid ? &g_homeblocks_sig : (void *)0; } /* The currently attached USB block device, regardless of whether it * checks out as a recognized home-blocks drive -- MINT (§F.8/§F.19) * targets a blank/unminted drive, which by definition never sets * g_homeblocks_dev above (that only latches on HOMEBLOCKS_SIG_OK). * Set once blk_subsys_attach_device() succeeds below, cleared on detach * alongside g_homeblocks_dev. */ static blkio_dev_t *g_attached_blk_dev = (void *)0; blkio_dev_t *sk_repl_get_attached_blk_dev(void) { return g_attached_blk_dev; } /* FABRIC-3.md §XXVI follow-on (2026-09-13): Artemis's own disk, once found * generically via USB-MSC content signature (artemis_sig_t, 'ARTM') rather * than the QEMU-only PCI virtio-blk vendor/device scan kernel_main.c still * does synchronously at boot. Mirrors g_homeblocks_dev's own accessor * shape. NULL on QEMU (virtio-blk finds Artemis before this file's idle * loop ever runs) and on any boot where no USB-MSC device presents the * 'ARTM' signature -- real bare-metal hardware is the case this exists * for. Set once sk_word_blk_attach_ack() below confirms the storage-attach * succeeded for a device this loop already recognized as Artemis's own; * cleared on detach alongside g_homeblocks_dev/g_attached_blk_dev. */ static blkio_dev_t *g_artemis_usb_dev = (void *)0; blkio_dev_t *sk_repl_get_artemis_usb_dev(void) { return g_artemis_usb_dev; } /* Storage-attach messaging migration (Bob, 2026-09-07): Hera keeps * polling/sig-checking, but no longer registers a newly-attached drive * into the block subsystem herself -- that's Artemis's own domain now, * reached via a real message (HERA-BLK-ATTACH-REQ, artemis:init.4th) * instead of a direct blk_subsys_attach_device() call. Hera cannot use * her own MSG-SEND for the outbound leg (kernel_main.c's own comment, * ~line 784: loading common:messaging.4th into her dictionary was * already tried and confirmed to silently drop every colon-definition * touching a STADIUM-* primitive) -- she uses VM-EXEC directly instead, * the same mechanism she already pumps MSG-TICK through. The reply * leg needs no such workaround: Artemis's own MSG-TICK delivers her * ack via VM-EXEC into Hera, which only requires BLK-ATTACH-ACK below * to exist as an ordinary word here -- not a full messaging vocabulary. * * usb_blk_dev_slots/usb_blk_dev_slot_count were function-local statics * inside sk_repl_idle() until now -- promoted to file scope so * sk_word_blk_attach_ack() below (a real dictionary word, called from a * completely different call stack than the idle loop) can resolve an * incoming ack's raw pointer back to the slot it belongs to. */ static blkio_dev_t *g_usb_blk_dev_slots = (void *)0; static uint32_t g_usb_blk_dev_slot_count = 0; /* One pending entry per slot, indexed the same way msc_slots[]/ * usb_blk_dev_slots[] already are (index 0 unused, matches precedent). * Holds the sig-check result from the moment the storage-attach request * was sent, so the deferred Zuse/WIREBIND birth calls -- which need that * result -- can run once Artemis's ack confirms storage succeeded, * without re-reading the drive a second time. */ typedef struct { int pending; homeblocks_sig_result_t sig_rc; homeblocks_sig_t sig; /* FABRIC-3.md §XXVI follow-on: set when the attach loop below already * recognized this device as Artemis's own disk (artemis_sig_t 'ARTM' * check, only attempted when sig_rc is HOMEBLOCKS_SIG_BLANK -- a * device can't be both an identity thumbdrive and Artemis's disk). * The ack handler uses this to run capsule_zuse_boot_load_root_pubkey() * once storage-attach is confirmed, same as kernel_main.c's own * virtio-blk path already does synchronously. */ int is_artemis; } sk_blk_attach_pending_t; static sk_blk_attach_pending_t *g_blk_attach_pending = (void *)0; /* BLK-ATTACH-ACK ( dev-addr ok? -- ): VM-EXEC'd into Hera by Artemis's * own MSG-TICK once HERA-BLK-ATTACH-REQ's BLK-ATTACH call resolves. * Finds which slot the raw pointer belongs to, and -- only on success -- * runs the same Zuse/WIREBIND attach logic sk_repl_idle() used to run * immediately and synchronously, now deferred until storage is * confirmed ("wait for ack, safer for identity data" -- Bob, 2026-09-07). * On failure, logs the same error sk_repl_idle() already logged for a * failed blk_subsys_attach_device() call, and simply never births * anything for this attach. */ static void sk_word_blk_attach_ack(VM *vm) { if (vm->dsp < 1) { log_message(LOG_ERROR, "BLK-ATTACH-ACK: stack underflow"); vm->error = 1; return; } cell_t ok_flag = vm_pop(vm); cell_t dev_addr = vm_pop(vm); blkio_dev_t *dev = (blkio_dev_t *)(uintptr_t)dev_addr; if (!g_usb_blk_dev_slots || !g_blk_attach_pending) return; uint32_t found_slot = 0; for (uint32_t i = 1; i < g_usb_blk_dev_slot_count; i++) { if (&g_usb_blk_dev_slots[i] == dev) { found_slot = i; break; } } if (found_slot == 0 || !g_blk_attach_pending[found_slot].pending) return; g_blk_attach_pending[found_slot].pending = 0; if (!ok_flag) { log_message(LOG_ERROR, "xhci: USB MSC block-subsystem attach failed"); return; } xhci_dev_t *xdev = xhci_get_dev(); xhci_msc_slot_t *ms = xdev ? xhci_msc_slot_for(xdev, found_slot) : (void *)0; if (ms) ms->bot_msc_attached = 1; g_attached_blk_dev = dev; /* FABRIC-3.6.md task 3.8 evidence: this handler IS the real drain * target -- sk_hermes_drain_checkpoint() (task 3.4) called * vm_interpret() on the ack payload, which is how we got here. * stadium_conserved() (task 0.7/2.7's four-term form) holds at * every instant, mid-hold included, so printing it here -- while * this message's heat is still held, before sk_hermes_drain_ * checkpoint()'s own release/pop run just after this function * returns -- is real evidence, not a synthetic self-test. Note the * limit: this is evidence for the mid-hold instant, not the * post-release state (release/pop happen after this function * returns, in the caller) -- see FABRIC-3.6.md task 3.8's own * write-up. console_println, not log_message(): confirmed live * (this repl.c's own log_message() calls, at every level, do not * appear anywhere in a real serial-log boot capture in this build * -- log_message()'s fprintf(stderr, ...) is not wired to the * serial console here) -- log_message() would have been silently * invisible, defeating the point of evidence. One line, not two * (dropped the earlier pre-send line from KH-BLK-ATTACH-SEND * below): this fires once per real USB attach, not per word, so * it is not the console_println-overuse case memory * project_production_logging_cleanup_needed flags. */ { VMRegistryEntry artemis_entry; uint64_t held, pulled, returned, consumed; char line[160]; int conserved = -1; /* -1 = Artemis not found */ sk_hermes_ledger(&held, &pulled, &returned, &consumed); if (capsule_vm_find_by_name_nocase("Artemis", &artemis_entry) == 0 && artemis_entry.vm_ptr) { conserved = stadium_conserved(artemis_entry.vm_id); } snprintf(line, sizeof(line), "Kernel-Hermes BLK-ATTACH-EVENT (real, Stage C): ledger held=%llu " "pulled=%llu returned=%llu consumed=%llu stadium_conserved(Artemis)=%s", (unsigned long long)held, (unsigned long long)pulled, (unsigned long long)returned, (unsigned long long)consumed, conserved < 0 ? "UNKNOWN" : (conserved ? "true" : "FALSE")); console_println(line); } homeblocks_sig_result_t sig_rc = g_blk_attach_pending[found_slot].sig_rc; homeblocks_sig_t sig = g_blk_attach_pending[found_slot].sig; int is_artemis = g_blk_attach_pending[found_slot].is_artemis; if (is_artemis) { /* FABRIC-3.md §XXVI follow-on: bus-agnostic Artemis discovery. * Mirrors kernel_main.c's own virtio-blk-found branch exactly * (blk_subsys_attach_device() there is this device's equivalent, * already done for us above via HERA-BLK-ATTACH-REQ/BLK-ATTACH -- * ok_flag being true is that confirmation). Safe to call even if * virtio-blk already found Artemis first (the common QEMU case, * since that path runs synchronously before this idle loop ever * gets a beat): capsule_zuse_boot_load_root_pubkey() is a no-op * once mama_vm->zuse_root_pubkey_known is already set. */ g_artemis_usb_dev = dev; log_message(LOG_INFO, "xhci: Artemis's own disk attached via USB-MSC"); capsule_zuse_boot_load_root_pubkey((VM *)sk_get_mama_vm()); } capsule_zuse_boot_try_attach(dev, sig_rc, &sig, (VM *)sk_get_mama_vm()); if (sig_rc == HOMEBLOCKS_SIG_OK) { capsule_wirebind_try_attach(dev, &sig, (VM *)sk_get_mama_vm()); } } /* FABRIC-3.6.md task 3.8 (Stage C, SXXXIV.2/.3): BLK-ATTACH-EVENT's real * cutover -- the reply leg (Artemis -> Hera ack) that used to flow * through common:messaging.4th's MSG-SEND/MSG-TICK now goes through * kernel-Hermes's sk_hermes_send_one()/sk_hermes_drain_checkpoint() * (tasks 3.3/3.4/3.6) instead. FORTH Hermes never sees a BLK-ATTACH- * EVENT message again -- exactly SXXXIV.2's partition rule ("one owner * per message, never shared"). The request leg (Hera -> Artemis, * kernel_main.c's own "S\" HERA-BLK-ATTACH-REQ\" S\" * Artemis\" VM-EXEC" a few lines up) was never real FORTH messaging * traffic to begin with -- no type tag, no arena, a direct VM-EXEC * forced by Hera's own STADIUM-* colon-word limitation documented * above -- so it is untouched here; this task cuts over the one * message type that actually flowed through a messaging layer. * * sk_hermes_drain_checkpoint() (task 3.4) calls vm_interpret() directly * on a message's payload_addr, so it must be NUL-terminated -- Artemis's * own ATTACH-ACK-BUF (artemis:init.4th) is a raw CMOVE'd byte buffer * with no such guarantee, so this copies into its own NUL-terminated * buffer rather than passing ATTACH-ACK-BUF's address through * unchanged. * * CORRECTED 2026-09-22 (real defect, this task's own findings log): * this comment used to claim "static, not stack-local" was load-bearing * because sk_hermes_send_one() stored payload_addr out-of-line, the * caller having to keep it alive until drained -- and that a second * attach before the first drains overwriting this buffer was "the same * shape ATTACH-ACK-BUF itself already had, not a new hazard." That * claim was wrong: it WAS a new, real payload-aliasing defect (two * sends before either drains both pointing at this same buffer, * whichever wrote last silently winning), confirmed live and fixed at * the source (kernel_hermes.c's sk_hermes_send_one() now copies into * the message's own storage immediately, kernel_hermes.h's own doc * comment on SkHermesMessage has the full account). This buffer no * longer needs to survive past the KH-BLK-ATTACH-SEND call that sends * it -- staying `static` here is now just a convenience (avoids an * 80-byte stack frame), not a correctness requirement. */ #define SK_KH_BLK_ATTACH_BUF_SIZE 80 static char g_kh_blk_attach_buf[SK_KH_BLK_ATTACH_BUF_SIZE]; /* KH-BLK-ATTACH-SEND ( paddr plen -- ok? ): copies the caller's payload * (Artemis's own ATTACH-ACK-BUF content) into the NUL-terminated buffer * above and sends it to Hera via kernel-Hermes. `from`/`to` are derived * from the calling VM and sk_get_mama_vm() -- the caller never has to * name a VMUuid, matching how HERA-BLK-ATTACH-REQ never had to before * either (FORTH's own IDX 0/1/2 convention did that translation for * it). Refusal (reservoir/arena/destination-queue exhaustion) is logged * rather than silently dropped -- FABRIC-3.5.md SXXXV.0 names silent * failure as this project's single most common defect shape, and this * is exactly the class of message (identity birth gates on it) where a * silent drop would be expensive to ever notice. */ static void sk_word_kh_blk_attach_send(VM *vm) { if (vm->dsp < 1) { log_message(LOG_ERROR, "KH-BLK-ATTACH-SEND: stack underflow"); vm->error = 1; return; } cell_t plen = vm_pop(vm); cell_t paddr = vm_pop(vm); /* paddr is a VM-relative offset (vaddr_t), not a host pointer -- * CLAUDE.md's own "Important Conventions": "Stack values are VM * offsets (vaddr_t), not C pointers -- use VM_ADDR()/CELL()". * mama_forth_words.c's own VM-EXEC/VM-CALL words all translate a * popped paddr the same way (vm_ptr(vm, (vaddr_t)caddr)) before * touching it as a C pointer -- found live, not assumed: the first * cut here raw-cast paddr directly and silently read all-zero * memory (some unmapped/zeroed low address), producing an empty * NUL-terminated payload that vm_interpret() executed as a no-op -- * no crash, no error, just a message that arrived and did nothing. * Diagnosed via a temporary probe printing the copied buffer content * at send time (per feedback_revert_probes_after_capture); reverted * once this fix confirmed correct. */ const char *src = (const char *)vm_ptr(vm, (vaddr_t)paddr); size_t n = (size_t)plen; VM *mama_vm = (VM *)sk_get_mama_vm(); size_t i; int rc; if (n >= SK_KH_BLK_ATTACH_BUF_SIZE) n = SK_KH_BLK_ATTACH_BUF_SIZE - 1; for (i = 0; i < n; i++) g_kh_blk_attach_buf[i] = src[i]; g_kh_blk_attach_buf[n] = '\0'; rc = sk_hermes_send_one(vm->stadium_vm_id, mama_vm->stadium_vm_id, SK_HERMES_MSG_TYPE_BLK_ATTACH, 0, g_kh_blk_attach_buf, (uint32_t)(n + 1)); if (rc != 0) { log_message(LOG_ERROR, "KH-BLK-ATTACH-SEND: kernel-Hermes refused the send"); } vm_push(vm, rc == 0 ? 1 : 0); } /* FABRIC-3.6.md task 3.10 (Stage D): sized to match messaging.4th's own * ELEVATE-REQ-BUF (256 bytes) -- the FORTH-side sender never builds a * longer payload than that buffer allows, so this is a safe match, not * an arbitrary choice. static, not stack-local: safe now that * sk_hermes_send_one() copies into the message's own storage (the * payload-aliasing fix landed before this task started) -- no lifetime * caveat to carry forward this time, unlike g_kh_blk_attach_buf/ * g_kh_console_cmd_buf's own now-corrected history. */ #define SK_KH_ELEVATE_BUF_SIZE 256 static char g_kh_elevate_buf[SK_KH_ELEVATE_BUF_SIZE]; /* KH-ELEVATE-SEND ( paddr plen -- ok? ): built for messaging.4th's own * SEND-ELEVATE-REQUEST (a FORTH command string calling ELEVATE-GRANT, * zuse-eligibility.4th block 4022), which handed its payload here * instead of CH-REQUEST's old COMMON-CH/MSG-SEND path. `from`/`to` are * derived the same way KH-BLK-ATTACH-SEND's own already does (the * calling VM and sk_get_mama_vm() -- ELEVATE-GRANT always runs on Hera, * per zuse-eligibility.4th's own header comment) -- the caller never * supplies either, which is a real correctness improvement over the * FORTH path it replaced: CH-REQUEST's own "initiator-only gate... * refuses if from doesn't match MY-CH-ID" existed only because a caller * COULD claim to be a different VM by passing the wrong stack value; * deriving `from` from the C-level calling VM's own identity makes that * spoof structurally impossible rather than merely gated. FABRIC-3.6.md * Phase 4, Stage E deleted messaging.4th -- SEND-ELEVATE-REQUEST no * longer exists anywhere, so this word currently has no FORTH caller. * Found, not fixed; see FABRIC-3.6.md's own Phase 4 entry. */ static void sk_word_kh_elevate_send(VM *vm) { if (vm->dsp < 1) { log_message(LOG_ERROR, "KH-ELEVATE-SEND: stack underflow"); vm->error = 1; return; } cell_t plen = vm_pop(vm); cell_t paddr = vm_pop(vm); const char *src = (const char *)vm_ptr(vm, (vaddr_t)paddr); size_t n = (size_t)plen; VM *mama_vm = (VM *)sk_get_mama_vm(); size_t i; int rc; if (n >= SK_KH_ELEVATE_BUF_SIZE) n = SK_KH_ELEVATE_BUF_SIZE - 1; for (i = 0; i < n; i++) g_kh_elevate_buf[i] = src[i]; g_kh_elevate_buf[n] = '\0'; rc = sk_hermes_send_one(vm->stadium_vm_id, mama_vm->stadium_vm_id, SK_HERMES_MSG_TYPE_ELEVATE_REQUEST, 0, g_kh_elevate_buf, (uint32_t)(n + 1)); if (rc != 0) { log_message(LOG_ERROR, "KH-ELEVATE-SEND: kernel-Hermes refused the send"); } vm_push(vm, rc == 0 ? 1 : 0); } void sk_repl_register_words(VM *vm) { register_word(vm, "BLK-ATTACH-ACK", sk_word_blk_attach_ack); register_word(vm, "KH-BLK-ATTACH-SEND", sk_word_kh_blk_attach_send); register_word(vm, "KH-ELEVATE-SEND", sk_word_kh_elevate_send); } /*=========================================================================== * Idle heartbeat service * * Called from sk_console_readline()/sk_console_getkey() when heartbeat_ticks() has advanced by at least * SK_IDLE_BEAT_INTERVAL since the last service call. Extend this function * as higher-level subsystems (msg_fabric, capsule scheduler) come online. * * TODO: cadence policy and subsystem dispatch belong in Compudynamics once * that layer governs cooperative VM execution. *===========================================================================*/ #define SK_IDLE_BEAT_INTERVAL 100u /* ticks between idle service calls (1 s at 100 Hz) */ static uint64_t g_last_beat_tick; /* zero-initialized (BSS) */ /* Cursor blink, 2026-09-05: the framebuffer cursor (now a thin vertical * bar, vt100.c's vt100_draw_cursor()) blinks on/off every * SK_CURSOR_BLINK_INTERVAL ticks while sk_console_readline()'s idle loop * is spinning -- i.e. whenever nothing has been typed for that long, * whether sitting at a bare prompt or paused mid-edit. g_cursor_visible * tracks which half of the blink cycle is current; sk_cursor_show() below * is the single place that resets the cycle back to "on" and redraws -- * every deterministic draw site (fresh prompt, echoed character, * backspace) calls it instead of vt100_draw_cursor() directly, so typing * always shows a solid cursor rather than possibly landing mid-blink. */ #define SK_CURSOR_BLINK_INTERVAL 50u /* ticks between blink toggles (500 ms at 100 Hz) */ static uint64_t g_cursor_blink_tick; /* zero-initialized (BSS) */ static int g_cursor_visible = 1; static void sk_cursor_show(void) { g_cursor_visible = 1; g_cursor_blink_tick = heartbeat_ticks(); console_fb_draw_cursor(); } /* Reentrancy guards for the kernel-Hermes drain pump inside sk_repl_idle(). * * FABRIC-3.6.md Phase 4 (Stage E), 2026-09-22: this used to guard a * VM-EXEC "MSG-TICK" dispatch into every live child VM (FABRIC-2.md * Phase C, common:messaging.4th, now removed). The mechanism changed -- * sk_repl_idle() now calls sk_hermes_drain_checkpoint() directly per VM, * not VM-EXEC -- but the reentrancy hazard these two flags exist for is * unchanged: drain_checkpoint() still calls vm_interpret() internally * (on the drained message's own payload), and sk_repl_idle() is itself * called from the blocking KEY/EXPECT/QUERY reads (sk_console_getkey()/ * sk_console_readline(), which run *from inside* the executing VM's own * vm_interpret once a FORTH word reads input mid-line). vm_interpret() * is not reentrant -- it resets the VM's single input_buffer/input_pos/ * input_length (vm_core.c) on entry. Calling it on mama at that point * would re-enter her interpreter mid-parse and silently truncate the * rest of the line. Two flags keep the pump off every path that could * re-enter an interpreter: * - g_mama_interpreting: set while Hera is executing a dispatched line, so * the pump defers to the next safe (prompt) boundary. * - g_idle_pump_active: belt-and-suspenders; stops recursive re-entry * from inside the pump's own drain call. */ static int g_mama_interpreting; /* zero-initialized (BSS) */ static int g_idle_pump_active; /* zero-initialized (BSS) */ /* FABRIC-3.md SXXII (2026-09-12) recorded a real O(N) bottleneck here -- * the messaging pump below used to walk the entire live-VM registry * every idle beat and dispatch a full VM-EXEC "MSG-TICK" into every one * of them, live-caught as a wall-time-dominating cost at just 9 VMs on * riscv64 -- and fixed it with round-robin batching (a persistent * cursor, a fixed number of dispatches per beat). FABRIC-3.6.md Phase 4 * (Stage E), 2026-09-22: that whole mechanism (MSG-TICK, the batching * cap, and this comment's own prior form) is gone along with * common:messaging.4th itself -- see sk_repl_idle()'s own current * comment, just below, for why the replacement (kernel-Hermes drain) * does not carry the same O(N) cost and needs no cap. */ static void sk_repl_idle(VM *active_vm) { /* Close any dangling output line before this bottom half emits its own * chatter (xhci attach/detach progress, block-subsystem notices). If we * are mid-prompt-line -- the REPL started the attach while sitting at * "ok> " -- a bare console_println() would otherwise glue its text onto * the prompt and inherit no {VMName} prefix (g_line_start is 0). A * fresh line first keeps every idle line prefix-tagged and readable, * matching what an interactive typing session expects. No-op when the * console is already at a line boundary. * * FABRIC-2.md §I.9 fix, 2026-09-05: this newline is now deferred (see * console_ensure_line_start()'s own doc comment) -- it only actually * reaches the console if something below really prints. tx_before_idle * lets this function tell "nothing happened" apart from "something did" * the same way the reanchor check further up this file already does, * so a beat with nothing to report can cancel the deferred newline * before returning, leaving the bare prompt line completely untouched * instead of visibly snapping it to a fresh blank line every ~1s. */ console_ensure_line_start(); uint64_t tx_before_idle = console_tx_count(); /* Artemis Milestone 2d: xHCI Event Ring servicing. This is exactly the * "interrupt-driven, coarse cadence, cheap early-exit" trigger Section * U item 6 asked for -- xhci_poll_events() is a no-op read (loop * condition false immediately) whenever nothing is pending, and this * hook already runs at a deliberately coarser cadence than the raw * per-tick ISR (SK_IDLE_BEAT_INTERVAL, ~1s at 100Hz), matching "quick * check... done... ignore what we can... done." A no-op call if no * controller was found/brought up (xhci_bringup() never latched a * device). */ xhci_poll_events(); /* Milestone 2h: a Mass Storage/BOT device finished SET_CONFIGURATION * during the xhci_poll_events() call just above -- run the * synchronous capacity query + block-subsystem attach here, strictly * after that call has already returned (see bot_msc_attach_pending's * own doc comment in xhci_driver.h for why: xhci_bot_wait_for_idle()'s * busy-wait -- which blkio_usb_open_msc() uses internally -- must * never run from inside xhci_poll_events()'s own call frame). */ xhci_dev_t *xdev = xhci_get_dev(); /* FABRIC-3.md §VII (2026-09-05): was `static blkio_dev_t usb_blk_dev` * ("single-device scope, matching the xHCI driver's own") -- now a * per-slot registry, same sizing/allocation precedent as xhci_dev_t's * own msc_slots[] (sized off xdev->max_slots, allocated once on first * idle tick after xdev is known, since this file has no bringup-time * hook of its own). Every slot with a pending attach/detach flag is * serviced this tick, not just one -- a single `if` here used to mean * a second device's pending flag would sit unnoticed until the first's * flag was consumed and cleared. */ if (xdev && (!g_usb_blk_dev_slots || g_usb_blk_dev_slot_count < xdev->max_slots + 1)) { size_t bytes = (size_t)(xdev->max_slots + 1) * sizeof(blkio_dev_t); blkio_dev_t *fresh = (blkio_dev_t *)kmalloc_aligned(bytes, 64); size_t pending_bytes = (size_t)(xdev->max_slots + 1) * sizeof(sk_blk_attach_pending_t); sk_blk_attach_pending_t *fresh_pending = (sk_blk_attach_pending_t *)kmalloc_aligned(pending_bytes, 64); if (fresh && fresh_pending) { memset(fresh, 0, bytes); memset(fresh_pending, 0, pending_bytes); g_usb_blk_dev_slots = fresh; g_usb_blk_dev_slot_count = xdev->max_slots + 1; g_blk_attach_pending = fresh_pending; } } for (uint32_t slot_id = 1; xdev && g_usb_blk_dev_slots && slot_id <= xdev->max_slots; slot_id++) { xhci_msc_slot_t *ms = xhci_msc_slot_for(xdev, slot_id); if (!ms || !ms->bot_msc_attach_pending) continue; ms->bot_msc_attach_pending = 0; blkio_dev_t *usb_blk_dev = &g_usb_blk_dev_slots[slot_id]; int rc = blkio_usb_open_msc(usb_blk_dev, xdev, slot_id); if (rc == 0) { /* FABRIC-2.md Milestone 4: warn on blank/foreign/unrecognized * media -- the "warn" half. No "refuse" half yet: blkio_usb.c * has no SCSI WRITE(10) support at all (Milestone 2's biggest * open item), so there is no write path today to refuse -- * only read-only attach, which is also the general-purpose USB * block I/O path this repo already relies on for unrelated * testing, not exclusively a home-blocks identity workflow. * Refusing attach on blank media here would break that * legitimate use without protecting anything real yet. Refuse * belongs on the write path, once WRITE(10) gives it something * to gate. * * HOMEBLOCKS_SIG_START_FBLOCK (devblock 1): the real, final * location -- GPT was dropped permanently, this is not an * interim value (FABRIC-2.md §F.8/§F.13). */ homeblocks_sig_t sig; homeblocks_sig_result_t sig_rc = homeblocks_sig_check(usb_blk_dev, HOMEBLOCKS_SIG_START_FBLOCK, &sig); switch (sig_rc) { case HOMEBLOCKS_SIG_OK: log_message(LOG_DEBUG, "xhci: USB drive recognized as a home-blocks drive"); /* FABRIC-3.md §VII (2026-09-05): g_homeblocks_dev/ * g_attached_blk_dev stay single "most recently * attached" pointers by deliberate, scoped choice -- * the multi-device fix's target was the driver/backend * corrupting each other's live state when two devices * are attached at once (fixed above and in xhci.c/ * blkio_usb.c), not making every console-facing FORTH * word (RUNCAP et al, mama_forth_words.c) multi-device * aware -- the console still interacts with one device * at a time, matching its own single-active-REPL * design. Revisit if a real use case needs otherwise. */ g_homeblocks_dev = usb_blk_dev; g_homeblocks_sig = sig; g_homeblocks_sig_valid = 1; break; case HOMEBLOCKS_SIG_BLANK: log_message(LOG_DEBUG, "xhci: USB drive not recognized (blank or foreign media) -- read-only general use only"); break; case HOMEBLOCKS_SIG_BAD_VERSION: log_message(LOG_WARN, "xhci: USB drive has a home-blocks header of an unrecognized version -- read-only general use only"); break; case HOMEBLOCKS_SIG_BAD_CRC: log_message(LOG_WARN, "xhci: USB drive has a home-blocks header that fails its checksum (corrupt or tampered) -- read-only general use only"); break; case HOMEBLOCKS_SIG_READ_ERROR: log_message(LOG_ERROR, "xhci: USB drive signature check failed to read the device -- read-only general use only"); break; } /* FABRIC-3.md §XXVI follow-on: a device isn't an identity * thumbdrive (sig_rc above came back BLANK, i.e. no 'LAHB' * magic) -- check whether it's Artemis's own disk instead * (distinct 'ARTM' magic, same devblock-1 convention). Only * attempted on BLANK, not on every device: a drive that * already checked out as home-blocks (or failed a home-blocks * version/CRC check) can't also be Artemis's disk, and * skipping the second read keeps the common identity-drive * case down to one signature check per attach, same as * before this feature existed. */ int is_artemis_disk = 0; if (sig_rc == HOMEBLOCKS_SIG_BLANK) { artemis_sig_t asig; artemis_sig_result_t art_rc = artemis_sig_check(usb_blk_dev, &asig); if (art_rc == ARTEMIS_SIG_OK) { log_message(LOG_DEBUG, "xhci: USB drive recognized as Artemis's own disk"); is_artemis_disk = 1; } } /* FABRIC-2.md §F.20/§F.21 / §F.5/§F.23 (WIREBIND): Zuse * genesis-mint/attach-authenticate and regular-identity * verify-then-birth-then-pair both used to run synchronously, * right here, before storage was even registered. Moved * (Bob, 2026-09-07, "wait for ack, safer for identity data") * to sk_word_blk_attach_ack() above, run only once Artemis * confirms the storage-attach succeeded -- identity birth * no longer happens on top of storage that might not have * registered. Stash what that deferred call needs. */ if (g_blk_attach_pending) { g_blk_attach_pending[slot_id].pending = 1; g_blk_attach_pending[slot_id].sig_rc = sig_rc; g_blk_attach_pending[slot_id].sig = sig; g_blk_attach_pending[slot_id].is_artemis = is_artemis_disk; } /* Storage-attach registration (blk_subsys_attach_device(), * BLK-ATTACH C primitive) is Artemis's own domain now, not * Hera's -- she keeps polling/sig-checking but no longer * performs this step herself. Hera can't use her own * MSG-SEND (see g_usb_blk_dev_slots's own doc comment * above for why), so this is a direct VM-EXEC into * Artemis's dictionary -- the same mechanism the MSG-TICK * pump below already uses -- rather than a real enqueued * message. HERA-BLK-ATTACH-REQ (capsules/artemis/init.4th) * runs BLK-ATTACH then replies via her own real MSG-SEND, * delivered back to Hera by the ordinary MSG-TICK pump. */ { char cmd[96]; int n = snprintf(cmd, sizeof(cmd), "S\" %llu HERA-BLK-ATTACH-REQ\" S\" Artemis\" VM-EXEC", (unsigned long long)(uintptr_t)usb_blk_dev); if (n > 0 && (size_t)n < sizeof(cmd)) { vm_interpret((VM *)sk_get_mama_vm(), cmd); } } } } /* Milestone 2h hot-detach: the device disconnected (PORTSC, inside the * xhci_poll_events() call above) after having actually attached. * blk_subsys_detach_device() is local block_subsystem.c bookkeeping -- * no device round-trip, so it wouldn't strictly need to run outside * xhci_poll_events()'s own call frame -- but handling it here anyway * matches the attach path's shape and keeps xhci.c decoupled from * block_subsystem.c (see bot_msc_detach_pending's own doc comment). * Per-slot loop now (FABRIC-3.md §VII, 2026-09-05), same reasoning as * the attach loop above. */ for (uint32_t slot_id = 1; xdev && g_usb_blk_dev_slots && slot_id <= xdev->max_slots; slot_id++) { xhci_msc_slot_t *ms = xhci_msc_slot_for(xdev, slot_id); if (!ms || !ms->bot_msc_detach_pending) continue; ms->bot_msc_detach_pending = 0; blkio_dev_t *usb_blk_dev = &g_usb_blk_dev_slots[slot_id]; blk_subsys_detach_device(usb_blk_dev); if (g_homeblocks_dev == usb_blk_dev) { g_homeblocks_dev = (void *)0; g_homeblocks_sig_valid = 0; } if (g_attached_blk_dev == usb_blk_dev) { g_attached_blk_dev = (void *)0; } /* FABRIC-2.md §F.10 decision 2 (UNCLEAN, closed alongside EJECT): * the device is already gone -- no-op if WIREBIND never had * anything tracked (general-purpose USB use, not a home-blocks * identity drive), OR if the device that left wasn't the one * WIREBIND tracks (FABRIC-3.md §VII follow-on, 2026-09-06 -- * genuine multi-device attach means it might be a different * device leaving while a WIREBIND user's own stays attached). */ capsule_wirebind_unclean_detach(usb_blk_dev); /* FABRIC-2.md §I.8, re-scoped 2026-09-04: Zuse logs out on device * removal exactly like a WIREBIND user -- no-op if the device * that just left wasn't hers (FABRIC-3.md §VII follow-on, * 2026-09-06: that no-op is now real, see capsule_zuse_boot_ * logout()'s own updated doc comment). */ capsule_zuse_boot_logout((VM *)sk_get_mama_vm(), usb_blk_dev); } /* FABRIC-0.md/FABRIC-1.md Section V item 6: "a cheap 'anything dirty? * no? done' block-sync check", the same "interrupt-driven, coarse * cadence, cheap early-exit" trigger shape as the xHCI servicing * above -- this was the one piece of that design already fully * specified and waiting for this hook to actually be non-empty. * blk_vm_flush_all() (block_words.c, the same code SAVE-BUFFERS * itself runs) is cheap to call when nothing is dirty -- every * check inside is a small fixed-size scan, no disk I/O happens * unless something genuinely needs writing -- so no separate * "is anything dirty" pre-check is needed here. * * active_vm is passed in by the caller (sk_console_readline(), itself passed * through from sk_repl_run()/sk_repl_step()'s own already-resolved * VM) rather than read via sk_repl_get_active_vm() here -- that * accessor returns NULL whenever Tripod's USE word hasn't redirected * it, which is the common case, not "no VM is active." An earlier * version of this code called sk_repl_get_active_vm() directly and * silently no-op'd for exactly that reason, confirmed live: a BUFFER * write with no UPDATE, followed by an idle wait and an abrupt kill, * did not survive a reboot until this fix. */ blk_vm_flush_all(active_vm); /* FABRIC-2.md §I.2, built 2026-09-04: heat/wear-leveling migration * trigger -- one linear scan of Artemis's own device per idle tick * (same ~1 Hz SK_IDLE_BEAT_INTERVAL cadence this whole function * already runs at, chosen so a hot devblock is caught proactively * rather than only on a failed write). See block_subsystem.c's own * doc comment on blk_migration_idle_check() for what's built (heat- * based relocation) vs. deliberately left open (overflow-triggered * migration, needs a call site threaded from WIREBIND). * * FABRIC-3.md, 2026-09-09: skipped while any g_blk_attach_pending * entry is still pending -- this scan and the storage-attach message * round-trip (HERA-BLK-ATTACH-REQ/BLK-ATTACH-ACK) both touch the * block subsystem/Artemis's own virtio-backed storage, and live- * caught the two interleaving is where a real vblk_io() request stops * getting a used-ring completion (root cause not fully isolated, see * virtio_blk.c's own doc comment on vblk_io()'s reduced spin bound -- * that's the safety net; this is the actual avoidance). One deferred * scan is harmless -- next idle tick retries, same as any other tick * with nothing to do. */ { int attach_in_flight = 0; if (g_blk_attach_pending) { uint32_t pi; for (pi = 0; pi < g_usb_blk_dev_slot_count; pi++) { if (g_blk_attach_pending[pi].pending) { attach_in_flight = 1; break; } } } if (!attach_in_flight) blk_migration_idle_check(); } /* FABRIC-2.md §I.2's own overflow trigger, closed 2026-09-05: the * call site named above, now built. Same cadence, same idle-tick * neighbor -- see capsule_wirebind_overflow_idle_check()'s own doc * comment for what it does and why it's a one-time extension, not a * growth loop. */ capsule_wirebind_overflow_idle_check(); /* FABRIC-3.md §XXVIII Stage 4 (2026-09-14): same cadence, cleans up * the per-device live-identity table once the Stage 3 checkpoint has * actually reaped a pending_reap VM -- see that function's own doc * comment. */ capsule_wirebind_reap_idle_check(); /* FABRIC-3.6.md Phase 4 (Stage E, Category B strip), 2026-09-22: * this used to be a distributed FORTH messaging pump -- FABRIC-2.md * Phase C's own design, VM-EXEC'ing MSG-TICK into every live VM's * own dictionary once per idle beat (common:messaging.4th, now * removed entirely). That mechanism, and the comment that used to * sit here explaining it, are both gone: kernel-Hermes's own drain * (below) has needed no FORTH word since task 3.9, and every * messaging.4th-owned message type had a real cutover by task 3.10 * (FABRIC-3.6.md task 3.11, Phase 3 gate). The prior version of this * comment also claimed "Hera never loads common:messaging.4th" -- * that was already wrong before this strip (capsules/init.4th line * 21 EXECs it directly; task 3.10's own live FIND-based check * confirmed it), not just made moot by removing the file. She still * has the only persistent idle tick, so she is still the one that * walks the registry once per idle beat -- now purely to give every * live VM's own kernel-Hermes queue a chance to drain, batched the * same round-robin way the old MSG-TICK dispatch was (SK_MSG_PUMP_ * BATCH below), kept for the same reason: bounding this to O(K) * regardless of total VM count, not O(N) every beat. */ VM *mama = (VM *)sk_get_mama_vm(); /* Reentrancy guard: never run the pump while mama is mid-interpret * (a dispatched line, or recursively from within the pump's own * vm_interpret). vm_interpret() clobbers the VM's single input * buffer, so re-entering it here while KEY/EXPECT/QUERY blocks inside * a live parse truncates the rest of that line. Deferring the MSG-TICK * drain to the next prompt boundary is safe -- draining is best-effort * and simply resumes next beat. */ if (g_mama_interpreting || g_idle_pump_active) { if (console_tx_count() == tx_before_idle) { console_cancel_deferred_line_start(); } return; } g_idle_pump_active = 1; { /* FABRIC-3.6.md Phase 4 (Stage E), 2026-09-22: the SK_MSG_PUMP_ * BATCH cap this loop used to enforce existed to bound the cost * of the old MSG-TICK VM-EXEC dispatch -- a genuinely expensive * per-VM vm_interpret() call, confirmed live as a real O(N) * bottleneck at just 9 VMs on riscv64 (see this section's own * history above). sk_hermes_drain_checkpoint() (task 3.4) is not * that: its own fast path is a single global-counter read * (sk_hermes_pending_total == 0 -> return immediately, * kernel_hermes.c's own doc comment), so walking every live VM * every beat costs one cheap integer read per VM in the common * case (nothing in flight), not an O(N) wall -- the cap this * loop's own comment history worried about does not apply to * this call. Removed: every live VM (except mama, handled * separately below) is now visited every idle beat, matching * what task 3.9's own testing already exercised, without the * ceil(N/SK_MSG_PUMP_BATCH)-beat delivery latency the cap was * accidentally imposing on kernel-Hermes drain specifically. */ uint32_t count = capsule_vm_registry_count(); uint32_t i; for (i = 0; i < count; i++) { VMRegistryEntry ent; if (capsule_vm_registry_get_by_index(i, &ent) == 0 && ent.state == VM_STATE_LIVE && ent.vm_ptr != (void *)mama) { (void)sk_hermes_drain_checkpoint((VM *)ent.vm_ptr); } } } /* Hera's own kernel-Hermes queue needs draining too, same as every * other VM's -- she is excluded from the loop above not because she * has nothing to drain, but because she IS the pump: a self- * targeting call here runs directly, no VM-EXEC/reentrancy concern * (sk_hermes_drain_checkpoint() is a plain C call, not a dispatch * into anyone else's input buffer). */ (void)sk_hermes_drain_checkpoint(mama); g_idle_pump_active = 0; if (console_tx_count() == tx_before_idle) { console_cancel_deferred_line_start(); } } /*=========================================================================== * FABRIC-0.md item 4.4v: keyboard-to-REPL bridge. * * Translates sk_key_event_poll()'s converged Linux-keycode-namespace * stream (keyboard_words.c -- one implementation shared with KEY-EVENT, * live-verified on all three architectures per item 4.3.5f) into the same * byte stream sk_console_readline() already reads from console_getc(): -1 for * "nothing ready", else a raw ASCII byte with '\n'/0x7F meaning the same * thing they mean for the serial path below. * * Table covers exactly the keys a line editor needs -- letters, digits, * the standard US-QWERTY punctuation row, space, enter, backspace, tab * (for the Ctrl+TAB toggle interception, 4.4y/4.4u step 8) -- not full * keyboard coverage. Keycodes are Linux input-event-codes.h values, * confirmed against this build host's own header, not guessed (§25.0 * rule 4). Index 0 means "no mapping"; arrows/F-keys/etc. fall through * unmapped and are silently dropped, consistent with this REPL's * append/backspace-only editing model (4.4u: no mid-line cursor * movement). *===========================================================================*/ #define SK_KBD_TABLE_SIZE 98u /* highest keycode used below is KEY_RIGHTCTRL=97 */ static const char sk_kbd_unshifted[SK_KBD_TABLE_SIZE] = { [2]='1',[3]='2',[4]='3',[5]='4',[6]='5',[7]='6',[8]='7',[9]='8',[10]='9',[11]='0', [12]='-',[13]='=', [16]='q',[17]='w',[18]='e',[19]='r',[20]='t',[21]='y',[22]='u',[23]='i',[24]='o',[25]='p', [26]='[',[27]=']', [30]='a',[31]='s',[32]='d',[33]='f',[34]='g',[35]='h',[36]='j',[37]='k',[38]='l', [39]=';',[40]='\'',[41]='`',[43]='\\', [44]='z',[45]='x',[46]='c',[47]='v',[48]='b',[49]='n',[50]='m', [51]=',',[52]='.',[53]='/', [57]=' ', }; static const char sk_kbd_shifted[SK_KBD_TABLE_SIZE] = { [2]='!',[3]='@',[4]='#',[5]='$',[6]='%',[7]='^',[8]='&',[9]='*',[10]='(',[11]=')', [12]='_',[13]='+', [16]='Q',[17]='W',[18]='E',[19]='R',[20]='T',[21]='Y',[22]='U',[23]='I',[24]='O',[25]='P', [26]='{',[27]='}', [30]='A',[31]='S',[32]='D',[33]='F',[34]='G',[35]='H',[36]='J',[37]='K',[38]='L', [39]=':',[40]='"',[41]='~',[43]='|', [44]='Z',[45]='X',[46]='C',[47]='V',[48]='B',[49]='N',[50]='M', [51]='<',[52]='>',[53]='?', [57]=' ', }; #define SK_KEY_BACKSPACE 14u #define SK_KEY_TAB 15u #define SK_KEY_ENTER 28u #define SK_KEY_LEFTSHIFT 42u #define SK_KEY_RIGHTSHIFT 54u #define SK_KEY_LEFTALT 56u #define SK_KEY_RIGHTALT 100u static int g_kbd_shift_down; /* zero-initialized (BSS) */ static int g_kbd_alt_down; /* Drains and translates one physically-typed key. Modifier state persists * across calls (a real keyboard's shift/alt state is global, not * per-line). Alt+TAB is intercepted here and drives the graphics/text * toggle directly (console_fb_toggle_graphics(), the same state-machine * transition the ALT+TAB FORTH word calls) -- never reaches the line * buffer as a character either way. */ static int sk_kbd_getc(void) { uint16_t keycode; int pressed; while (sk_key_event_poll(&keycode, &pressed)) { if (keycode == SK_KEY_LEFTSHIFT || keycode == SK_KEY_RIGHTSHIFT) { g_kbd_shift_down = pressed; continue; } if (keycode == SK_KEY_LEFTALT || keycode == SK_KEY_RIGHTALT) { g_kbd_alt_down = pressed; continue; } if (!pressed) continue; /* only act on press/repeat */ if (keycode == SK_KEY_TAB) { if (g_kbd_alt_down) console_fb_toggle_graphics(); continue; /* bare TAB: not mapped, same as arrows/F-keys */ } if (keycode == SK_KEY_ENTER) return '\n'; if (keycode == SK_KEY_BACKSPACE) return 0x7F; if (keycode < SK_KBD_TABLE_SIZE) { char c = g_kbd_shift_down ? sk_kbd_shifted[keycode] : sk_kbd_unshifted[keycode]; if (c) return (unsigned char)c; } /* unmapped keycode -- drop and keep draining */ } return -1; } /* One raw byte from either input source (serial console or the keyboard- * event bridge), non-blocking, -1 if neither has one ready right now. Not * itself a FORTH word -- the shared byte-fetch underneath sk_console_getkey()/ * sk_console_key_available() (the standard dictionary's KEY/?TERMINAL, wired * through shim.c's getchar()) and sk_console_readline() (QUERY/EXPECT, wired * through shim.c's fgets()) alike. */ static int sk_console_getc_raw(void) { int c = console_getc(); if (c < 0) c = sk_kbd_getc(); /* FABRIC-0.md 4.4v: second source, same buffer */ return c; } /* One-byte pushback so sk_console_key_available() can peek without losing * the byte -- ?TERMINAL must be non-destructive (a caller checks readiness, * then still expects KEY to return that same key). */ static int g_console_pending_key = -1; /* KEY's real body (shim.c's getchar() calls this): blocks until a key is * available, servicing the heartbeat/idle loop while waiting -- same * cadence sk_console_readline() already uses below, so a KEY call mid-word * never stalls the heartbeat or Hera's own idle dispatch. No echo -- that's * the caller's job, same as any standard KEY implementation. */ int sk_console_getkey(VM *active_vm) { /* No longer used directly -- the idle branch below re-resolves the * live active VM itself (FABRIC-3.md SXIII, 2026-09-10) rather than * trusting this parameter, which can go stale mid-block. Kept in the * signature: repl.h declares it, other callers still pass one. */ (void)active_vm; for (;;) { int c; if (g_console_pending_key >= 0) { c = g_console_pending_key; g_console_pending_key = -1; } else { c = sk_console_getc_raw(); } if (c >= 0) return c; heartbeat_service(); uint64_t now = heartbeat_ticks(); if (now - g_last_beat_tick >= SK_IDLE_BEAT_INTERVAL) { g_last_beat_tick = now; /* Same use-after-free class fixed in sk_console_readline()'s * idle branch (FABRIC-3.md SXIII, 2026-09-10) -- `active_vm` * is a parameter captured once by the caller before this * (possibly long) blocking wait for a key began, and can be * killed mid-wait. Re-resolve fresh every tick instead. */ VM *live_active = g_repl_active_vm ? g_repl_active_vm : (VM *)sk_get_mama_vm(); sk_repl_idle(live_active); } arch_relax(); } } /* sk_repl_headless_wait - see repl.h's own doc comment. Same idle-service * shape as sk_console_getkey() above, minus the key-reading entirely: no * banner, no prompt, no console_getc()/readline of any kind -- this is * exactly the "no console for the running system unless a thumbdrive is * present" boundary, decided 2026-09-05. Exits the moment sk_console_ * identity_present() becomes true -- called both once at boot * (kernel_main.c, before the first ever login) and again from inside * sk_repl_run()'s own main loop whenever the last attached identity logs * out mid-boot (2026-09-06 revision, see sk_console_identity_present()'s * own doc comment for why the boot-only version wasn't enough). */ void sk_repl_headless_wait(VM *mama) { while (!sk_console_identity_present()) { heartbeat_service(); uint64_t now = heartbeat_ticks(); if (now - g_last_beat_tick >= SK_IDLE_BEAT_INTERVAL) { g_last_beat_tick = now; sk_repl_idle(mama); } arch_relax(); } } /* ?TERMINAL's real body (sf_terminal_ready(), shim.c): non-blocking peek -- * a single poll, no idle-servicing loop (a false result must return * immediately, not block). Buffers a found byte in g_console_pending_key so * a following sk_console_getkey() returns the exact same key, not a * different/later one. */ int sk_console_key_available(void) { if (g_console_pending_key >= 0) return 1; int c = sk_console_getc_raw(); if (c >= 0) { g_console_pending_key = c; return 1; } return 0; } /*=========================================================================== * sk_console_readline - line read from serial console with echo * * Non-blocking poll of console_getc(). While no character is ready the idle * spin services the adaptive heartbeat at SK_IDLE_BEAT_INTERVAL tick cadence. * Supports backspace (0x7F and \b) and ignores other control characters. * Returns the number of characters placed in buf (not counting '\0'), or * -1 (2026-09-06) when called with reanchor_prompt nonzero and the * identity that was attached when the caller's prompt was printed logs * out while this call is still blocked waiting for input with nothing yet * typed (n == 0) -- callers with reanchor_prompt nonzero (the REPL's own * top-level prompt sites) must check for this and route back to * sk_repl_headless_wait() rather than treating it as an empty line; buf * is left as an empty string in this case too, matching a real empty * line, so a caller that doesn't check the return value degrades to the * pre-fix behavior (an extra harmless " ok") rather than misbehaving. * shim.c's fgets() (reanchor_prompt == 0) never receives -1. * * Public (declared in repl.h): shim.c's fgets()/QUERY's own real body call * this directly -- same line-editing behavior for a mid-word EXPECT/QUERY as * for the REPL's own top-level prompt, since it's the same underlying * console. Any g_console_pending_key left over from a ?TERMINAL peek is * consumed first so a line read never drops a byte ?TERMINAL already saw. * @param reanchor_prompt nonzero from the REPL's own prompt sites (which * print SK_PROMPT_TEXT immediately before): re-print the prompt whenever an * idle bottom half wrote to the console while this call blocked at the bare * prompt (see the re-anchor block in the idle branch). shim.c's fgets() * passes 0 -- its prompt context is caller-owned. *===========================================================================*/ int sk_console_readline(char* buf, int size, VM* active_vm, int reanchor_prompt) { /* No longer used directly -- see sk_console_getkey()'s matching comment; * the idle branch below re-resolves the live active VM itself instead * of trusting this parameter across a potentially long block. Kept in * the signature: repl.h declares it, other callers still pass one. */ (void)active_vm; int n = 0; /* TX counter value right after the caller printed its prompt. Any * console output that lands while this readline blocks (heartbeat * status, sk_repl_idle()'s USB attach/detach chatter) pushes the * counter past this mark and away from a bare prompt; when that * happens, re-anchor the prompt (below). */ uint64_t prompt_tx_mark = console_tx_count(); buf[0] = '\0'; sk_cursor_show(); /* show the cursor at the bare prompt, before any input */ for (;;) { int c; if (g_console_pending_key >= 0) { c = g_console_pending_key; g_console_pending_key = -1; } else { c = sk_console_getc_raw(); } if (c < 0) { /* Service the heartbeat bottom half every idle iteration, not * gated by SK_IDLE_BEAT_INTERVAL (item 0.8, FABRIC-0.md §26): * heartbeat_service() drains at most one latched sample per * call, so a coarse gate here would silently lose or merge * samples between ISR-latched ticks. sk_repl_idle() below is * a separate, deliberately coarser cadence for higher-level * subsystem dispatch, unrelated to sample fidelity. */ heartbeat_service(); uint64_t now = heartbeat_ticks(); /* n == 0 gate: sk_repl_idle() opens with console_ensure_line_start(), * closing off a dangling prompt line before any chatter it might * print (xhci attach/detach, block-sync, MSG-TICK pump). Before the * FABRIC-2.md §I.9 fix (2026-09-05), that newline was unconditional * and immediate, so it fired on every elapsed SK_IDLE_BEAT_INTERVAL * regardless of whether sk_repl_idle() actually had anything to * print -- including mid-edit (n > 0, characters typed but Enter * not yet pressed), visually snapping the in-progress line to a * fresh blank one, indistinguishable from Enter having been * pressed. console_ensure_line_start()'s newline is now deferred * and self-cancelling when nothing follows it (console.c), which * fixes that regardless of n -- but this gate is kept for its own, * independent reason: deferring the *whole* idle beat while a line * is being edited (same n > 0 guard the prompt reanchor below * already uses) means a genuine xhci/block-sync/MSG-TICK event * cannot interrupt output mid-line while the user is actively * typing, only delaying that servicing by at most one more * interval, which its own "coarse cadence, cheap early-exit" * design already tolerates. */ if (n == 0 && now - g_last_beat_tick >= SK_IDLE_BEAT_INTERVAL) { g_last_beat_tick = now; /* Found live 2026-09-10 (FABRIC-3.md SXIII): `active_vm` is * this call's parameter, captured once by the caller before * this (possibly very long) block began -- see the matching * comment at this function's dispatch-side fix, below, for * the full mechanism. That fix re-resolves `active` fresh * right before dispatch, but every idle tick serviced * *during* this same blocked call used to pass the stale * parameter straight into sk_repl_idle() -> blk_vm_flush_all(), * which reads and writes vm->blk_vm_lbn[]/cbuf[]/dirty[]/ * epoch on whatever `active_vm` points at. If that VM was * killed (WIREBIND detach) while this call sat idle, those * fields live in a kmalloc block already back on the free * list -- and blk_vm_check_epoch()'s unconditional field * writes silently corrupt that block's own free-list * metadata (next/size/free), observed live as free_blocks/ * largest_free collapsing to 0 a tick or two after a third * WIREBIND identity attached following two prior attach/ * detach cycles. Re-resolve fresh from the global here too, * same pattern as the dispatch-side fix -- sk_get_mama_vm() * is the safe fallback (never freed) matching sk_repl_run()'s * own `g_repl_active_vm ? g_repl_active_vm : vm` shape. */ VM *live_active = g_repl_active_vm ? g_repl_active_vm : (VM *)sk_get_mama_vm(); sk_repl_idle(live_active); } /* Blink the cursor while idle (no key ready this iteration), * regardless of n -- a real terminal blinks whether sitting at * a bare prompt or paused mid-edit. sk_cursor_show() (called * from every deterministic draw site below and at entry) resets * this cycle to "on" on every real keystroke, so typing never * looks like it landed mid-blink. */ if (now - g_cursor_blink_tick >= SK_CURSOR_BLINK_INTERVAL) { g_cursor_blink_tick = now; g_cursor_visible = !g_cursor_visible; if (g_cursor_visible) console_fb_draw_cursor(); else console_fb_erase_cursor(); } /* * Re-anchor the prompt (FABRIC-0.md 4.4a unified prompt: print * only "ok> " here -- console_putc() auto-prefixes the current * [VMName] on a fresh line). When an idle bottom half above * pushed output past prompt_tx_mark, the console cursor is now * below/after new lines and the "ok> " the caller printed has * been scrolled or buried -- once the flood passes, the screen * and serial log would end on a stale line with no prompt * (FABRIC-2.md: the bare prompt must be the last thing shown * while the REPL sits idle). Reprinting it restores that * invariant. Skipped while a line is being edited (n > 0) so * partial echo stays attached to its own prompt; shim.c's * fgets() (QUERY/EXPECT/ACCEPT) calls in with reanchor_prompt * == 0 for the same reason -- its prompt line is caller-owned * text, not the REPL's. Each silent beat leaves the mark * unchanged, so the final state after the chatter dies down is * a fresh prompt on the last visible line, cursor on it. */ /* Headless-until-login gate, 2026-09-06: the identity that was * attached when the caller printed its prompt (sk_print_prompt(), * reflected in reanchor_prompt callers only -- shim.c's fgets() * passes 0 and is unaffected) may have logged out while we sat * here blocked waiting for input -- WIREBIND EJECT/unclean * detach, or Zuse's own logout, both reachable from * sk_repl_idle() just above. Re-printing the prompt in that * case (the block below) would just show a *correct* bare * "ok>" -- true to current state, but still an unauthenticated * interactive surface sitting on screen, which the headless- * until-login design (Kconfig.heartbeat's EMERGENCY_CONSOLE_ * ENABLED) exists specifically to prevent. Bail out instead so * the caller (sk_repl_run()'s own main loop) can drop back into * sk_repl_headless_wait() -- confirmed live as a real gap * before this fix (a bare, unauthenticated prompt stayed on * screen after every logout for the rest of the boot). n == 0 * only: never abandon a line the user is actively typing. */ if (reanchor_prompt && n == 0 && !sk_console_identity_present()) { return -1; } if (reanchor_prompt && n == 0 && console_tx_count() != prompt_tx_mark) { sk_print_prompt(); sk_cursor_show(); prompt_tx_mark = console_tx_count(); } /* * Do NOT use hlt here: QEMU single-threaded TCG can't process * its APIC timer callbacks while the guest CPU is halted (the * event loop and the TCG thread share the same OS thread). * Interrupts are delivered at TB boundaries in a tight loop. * On real hardware a wfi/hlt would be appropriate; add it here * under an #ifdef REAL_HARDWARE guard when that path is needed. */ arch_relax(); /* PAUSE — reduce power, maintain tight poll */ continue; } if (c == '\r' || c == '\n') { console_fb_erase_cursor(); /* leaving this cell without drawing a char over it */ console_putc('\n'); /* CRLF pairing, found live 2026-09-22: a terminator sent as * both bytes (a real terminal in CRLF mode, or any scripted * sender writing "\r\n") used to submit TWICE -- this line * on the '\r' (or '\n'), then an immediate empty line on the * OTHER byte the next time this function is called, each * producing its own " ok" -- confirmed live as the doubled * "ok" this project's own session interaction showed, not an * async-relay artifact as first suspected. Non-blocking peek * for the paired byte right here, before returning -- discard * it if present, push it back via g_console_pending_key * (already the mechanism sk_console_key_available() uses for * exactly this "peeked but not this call's to consume" case) * if it's unrelated input for the NEXT line. A byte that * hasn't arrived yet by this point is not waited for -- * matches every other non-blocking read in this function. */ { int pair = (c == '\r') ? '\n' : '\r'; int next = sk_console_getc_raw(); if (next >= 0 && next != pair) g_console_pending_key = next; } break; } /* backspace: DEL (0x7F) or BS (0x08) */ if ((c == 0x7F || c == '\b') && n > 0) { n--; buf[n] = '\0'; /* VT100 erase: move back, overwrite with space, move back again */ console_putc('\b'); console_putc(' '); console_putc('\b'); sk_cursor_show(); continue; } if (c < 0x20) continue; /* ignore other control characters */ if (n >= size - 1) continue; /* buffer full — drop character */ buf[n++] = (char)c; buf[n] = '\0'; console_putc((char)c); /* echo */ sk_cursor_show(); } buf[n] = '\0'; return n; } /*=========================================================================== * sk_repl - FORTH REPL * * FABRIC-2.md §F.20/§F.21 (2026-08-28): the unauthenticated emergency-CLI * ACL bypass this REPL used to grant itself on Hera's own bare prompt is * retired -- every word runs under ordinary ACL enforcement here now, * console identity included. emergency_console still exists as a field * (vm.h) and is still set, briefly, by the genuine C-level VM fault * handler (EMERGENCY_CONSOLE_ENABLED build flag) for crash recovery -- * that's a distinct, narrower mechanism this REPL no longer touches. * * Mirrors vm_repl() from src/repl.c: * - Reads a line via sk_console_readline (non-blocking, heartbeat-serviced) * - Calls vm_interpret * - Prints " ok" or " ERROR" * - When EMERGENCY_CONSOLE_ENABLED=1: resets vm->error and loops (recovery) * - When EMERGENCY_CONSOLE_ENABLED=0: an interactive REPL-turn fault on * any VM (Hera included, as of FABRIC-3.md §XXXII.1) recovers the same * way -- prints a message, clears vm->error/halted/abort_requested, and * the loop continues at that VM's own next prompt. Boot-time faults * (capsule load, birth scripts) never reach this code at all; they are * caught and cleared directly in kernel_main.c before sk_repl_run() is * ever entered, so no "no fallthrough surface" halt is needed or given * up here -- the C-level `sk_fault_handler()` this comment used to * describe was retired the same day, having no remaining caller. *===========================================================================*/ /*=========================================================================== * sk_repl_dispatch_line - console-VM + user-VM pair relay (FABRIC-2.md * Phase F, 2026-08-28). * * If `vm`'s own registered name has a live "~user" counterpart, * this is a console session: relay the raw line as a real, async * CONSOLE-CMD-EVENT message (common:messaging.4th) instead of * interpreting it directly -- "every line is a message," not a * C-level redirect. This is one particular consumer of the general * VM-to-VM messaging system built in Phase C: any VM can already * MSG-SEND to any other VM for its own reasons regardless of a human * ever being at a physical console at all; this hook only wires the * physical-terminal-input path into that same general mechanism, it * doesn't gate or replace it. * * Falls back to direct vm_interpret() (today's unchanged behavior) when * there's no live paired user VM, or when the line contains a `"` * character this simple S"-embedding can't safely carry yet (a known * v1 limitation -- warned about, not silently mishandled). *===========================================================================*/ /* USE (FABRIC-2.md §F.24) is a REPL-control word, not a command for * whatever VM happens to be paired to a console -- it must always run * on the active VM directly, never get relayed as a message. Real * FORTH syntax always puts USE last (S" name" USE), so a trailing- * token match is a reliable, non-tokenizing-required check: trim * trailing whitespace, then confirm the line ends with "USE" as its * own word (preceded by whitespace or the whole line). */ static int sk_repl_line_calls_use(const char *input) { size_t len = strlen(input); while (len > 0 && (input[len - 1] == ' ' || input[len - 1] == '\t')) len--; if (len < 3) return 0; if (input[len - 3] != 'U' || input[len - 2] != 'S' || input[len - 1] != 'E') return 0; return (len == 3) || (input[len - 4] == ' ' || input[len - 4] == '\t'); } /* FABRIC-3.6.md task 3.9. Sized to the largest single payload * kernel-Hermes will ever accept (SK_HERMES_CHUNK_MAX_PAYLOAD, task * 3.5's one-block bound) -- console lines up to INPUT_BUFFER_SIZE-1 * (1024) content bytes are silently capped to SK_HERMES_CHUNK_MAX_ * PAYLOAD-1 (1023) to leave room for the NUL terminator * sk_hermes_drain_checkpoint() requires; chunking a console line across * multiple messages is out of this task's scope (task 3.5 built the * primitives, not a sender for this use) and the cap is not expected to * be reached by ordinary interactive typing. * * CORRECTED 2026-09-22 (real defect, found while starting this task, * fixed at the source): this comment originally justified `static, not * stack-local` on the claim that sk_hermes_send_one() stored * payload_addr out-of-line and the caller had to keep it alive until * drained -- which was true, and which g_kh_blk_attach_buf (task 3.8) * turned out to make into a genuine payload-aliasing defect (two sends * before either drains, whichever wrote last silently winning). Fixed * at the source (kernel_hermes.c's sk_hermes_send_one() now copies into * the message's own storage immediately, kernel_hermes.h's own doc * comment on SkHermesMessage has the full account) -- this buffer no * longer needs to survive past the send call, for this or any future * console-proxy session (console.c's own singleton today, per memory * project_multiseat_console_idea). Staying `static` is now just a * convenience (avoids a 1024-byte stack frame), not a correctness * requirement. */ static char g_kh_console_cmd_buf[SK_HERMES_CHUNK_MAX_PAYLOAD]; static void sk_repl_dispatch_line(VM *vm, const char *input) { /* H1 reentrancy guard: while this dispatched line executes on Hera * herself, sk_repl_idle() must defer its MSG-TICK pump -- calling * vm_interpret(mama, ...) from inside a mid-line KEY/EXPECT would * re-enter mama's interpreter and clobber its in-flight input buffer * (see the guard's comment at sk_repl_idle()). A child VM's console * turn leaves mama idle, so the pump stays safe there and the guard is * only latched for Hera. */ int on_mama = (vm == (VM *)sk_get_mama_vm()); int saved = g_mama_interpreting; if (on_mama) g_mama_interpreting = 1; if (sk_repl_line_calls_use(input)) { vm_interpret(vm, input); goto out; } const char *vn = console_get_vm_name(); if (vn) { char paired_name[VM_NAME_MAX + 8]; size_t vnlen = strlen(vn); if (vnlen + 6 <= sizeof(paired_name)) { memcpy(paired_name, vn, vnlen); memcpy(paired_name + vnlen, "~user", 6); /* includes NUL */ VMRegistryEntry paired; if (capsule_vm_find_by_name(paired_name, &paired) == 0 && paired.state == VM_STATE_LIVE) { if (strchr(input, '"')) { console_println("console: line contains '\"' -- can't relay " "as a message safely yet, interpreting directly"); } else { /* FABRIC-3.6.md task 3.9 (Stage D, SXXXIV.2/.3): * CONSOLE-CMD-EVENT's real cutover -- the FORTH * "CONSOLE-CMD-EVENT 0 3 S\" ...\" 0 MSG-SEND" string * interpret is gone; this now calls kernel-Hermes * directly. `paired.vm_id` (just resolved above) is * the real target VMUuid -- no name-index translation * needed, unlike the FORTH convention's local "index * 3" hack. `from` is the console-proxy VM's own real * identity (`vm->stadium_vm_id`), not the FORTH * convention's hardcoded "0" (which read as Hera in * every console-proxy's shared VM-NAMES-INIT table, * regardless of which console actually sent it) -- * more correct provenance, a deliberate refinement * task 3.9 makes possible, not a silent behavior * change: the receiving side never read `from` for * anything but bookkeeping. The `"` guard above is * kept even though kernel-Hermes's payload is a raw * pointer+length, not a FORTH string literal, and no * longer needs it -- removing a documented v1 * limitation is its own decision, not a side effect * of this one (per advisor() review). */ size_t ilen = strlen(input); if (ilen >= SK_HERMES_CHUNK_MAX_PAYLOAD) ilen = SK_HERMES_CHUNK_MAX_PAYLOAD - 1; /* leave room for the NUL -- * see g_kh_console_cmd_buf's * own doc comment */ memcpy(g_kh_console_cmd_buf, input, ilen); g_kh_console_cmd_buf[ilen] = '\0'; if (sk_hermes_send_one(vm->stadium_vm_id, paired.vm_id, SK_HERMES_MSG_TYPE_CONSOLE_CMD, 0, g_kh_console_cmd_buf, (uint32_t)(ilen + 1)) != 0) { log_message(LOG_ERROR, "console: kernel-Hermes refused CONSOLE-CMD-EVENT send"); } goto out; } } } } vm_interpret(vm, input); out: g_mama_interpreting = saved; } /*=========================================================================== * sk_repl_step - Execute one REPL turn on a VM and return. * * Prints the VM's prompt, reads one input line, interprets it, prints * ok/ERROR, then returns. Used by the Compudynamics VM-STEP primitive * so Hera can give a single REPL quantum to any child VM without * surrendering control for the full sk_repl_run() loop. * * Returns 1 if the VM is still running, 0 if it halted during this turn. *===========================================================================*/ int sk_repl_step(VM *vm) { char input[INPUT_BUFFER_SIZE]; /* FABRIC-0.md 4.4w: matches the strip's input width */ if (!vm || vm->halted) return 0; { /* Unified prompt (FABRIC-0.md 4.4a): console_putc()'s existing per-line * "[user@VMName] " prefix (console.c's emit_prefix(), FABRIC-3.md * SXXV follow-up) already supplies the bracket -- print only * "ok> " here, don't build a second one. emergency_console is no * longer set from here (FABRIC-2.md §F.20/§F.21: the emergency-CLI * ACL bypass is retired) -- it's driven only by the genuine C-level * fault handler now (vm.c's own emergency-fault-recovery use, * EMERGENCY_CONSOLE_ENABLED). Every word run from this REPL, * Hera's bare prompt included, goes through ordinary ACL * enforcement. */ sk_print_prompt(); } sk_console_readline(input, sizeof(input), vm, 1); if (input[0] == '\0') { console_puts(" ok\n"); return vm->halted ? 0 : 1; } sk_repl_dispatch_line(vm, input); /* ABORT stops mid-line but leaves the flag set for the caller to * consume -- this REPL step is that boundary. Clear it here so the * next line isn't silently refused by vm_interpret's own check. */ vm->abort_requested = 0; if (vm->error) { #if EMERGENCY_CONSOLE_ENABLED console_puts(" ERROR\n"); vm->error = 0; #else /* FABRIC-3.md §XXXII.1, 2026-09-15: an ordinary interactive * mistake (a typo, an unrecognized word, a stack-convention * violation) at Zuse's own console used to hard-halt the entire * kernel here, because this branch treated Hera's own session * as a "no fallthrough surface" emergency and every other VM's * session as recoverable (2026-09-09 fix, see git history). That * asymmetry was never actually protecting a boot-time fault -- * boot/capsule-load errors are caught and cleared entirely * separately, in kernel_main.c, well before sk_repl_run() is * ever entered -- so by the time this function runs at all, any * vm->error here is by construction an interactive REPL-turn * fault, on any VM including Hera. Recovers unconditionally now, * matching every other VM's own session exactly. */ console_println("VM fault -- session recovered, resuming"); vm->error = 0; vm->halted = 0; vm->abort_requested = 0; #endif } else { console_puts(" ok\n"); } return vm->halted ? 0 : 1; } void sk_repl_run(VM *vm) { char input[INPUT_BUFFER_SIZE]; /* FABRIC-0.md 4.4w: matches the strip's input width */ VM *active; /* FABRIC-3.md SXXV follow-up: one-time registration so console.c's * emit_prefix() can compose "[user@VMName] " instead of the bare * "[VMName] " it falls back to while this is still unregistered * (early boot output, before sk_repl_run() is ever reached). */ console_set_user_prefix_provider(sk_console_user_prefix); vm->halted = 0; while (!vm->halted) { #if !EMERGENCY_CONSOLE_ENABLED /* Headless-until-login gate, revised 2026-09-06: re-checked every * iteration, not just once before this loop starts (kernel_main.c's * own sk_repl_headless_wait() call, still in place, only covers the * very first login of the boot). Whoever was attached may have * logged out since the last iteration (WIREBIND EJECT/unclean * detach, Zuse's own logout) -- if nobody is attached right now, * go back to silent waiting instead of falling through to a bare, * unauthenticated prompt. See sk_console_identity_present()'s own * doc comment for the live bug this closes. */ if (!sk_console_identity_present()) { sk_repl_headless_wait(vm); if (vm->halted) break; continue; } #endif /* USE may redirect input to a different VM each iteration */ active = g_repl_active_vm ? g_repl_active_vm : vm; /* Unified prompt (FABRIC-0.md 4.4a): console_putc()'s existing per-line * "[user@VMName] " prefix (console.c's emit_prefix(), FABRIC-3.md * SXXV follow-up) already supplies the bracket -- print only "ok> " * here, don't build a second one. emergency_console is no longer * set from here (FABRIC-2.md §F.20/§F.21: the emergency-CLI ACL * bypass is retired) -- see sk_repl_step()'s matching comment * above. */ sk_print_prompt(); int n = sk_console_readline(input, sizeof(input), active, 1); #if EMERGENCY_CONSOLE_ENABLED /* n only consumed below under !EMERGENCY_CONSOLE_ENABLED (the * logged-out-mid-read bailout doesn't apply when the emergency * console bypasses login entirely) -- silence -Wunused-variable * rather than drop the assignment (sk_console_readline()'s return * value is still meaningful, just not acted on in this build). */ (void)n; #endif #if !EMERGENCY_CONSOLE_ENABLED /* n < 0: sk_console_readline() bailed out because the identity * that was attached when this prompt was printed logged out * while we were still blocked waiting for input (2026-09-06 -- * see sk_console_readline()'s own doc comment on this return * value). No " ok" here -- nothing was typed, nothing ran -- * just loop back to the top, where the check above re-enters * headless silence immediately instead of showing yet another * prompt first. */ if (n < 0) { continue; } #endif if (input[0] == '\0') { console_puts(" ok\n"); continue; } /* Found live 2026-09-10: `active` was captured once, above, before * sk_console_readline() blocked for this line -- but that call can * block for an arbitrarily long time, during which the VM `active` * points at can be killed (WIREBIND detach) and its memory freed. * The n<0 bailout above is supposed to catch a logout mid-read, but * it only fires on sk_console_identity_present() -- a generic "is * ANYONE attached" boolean, not "is the specific identity `active` * belonged to still attached" -- so a fast detach-then-reattach of * a *different* identity while this call was blocked (n still 0) * never trips it: presence reads true throughout, no gap is ever * observed. The stale `active` then gets dispatched into freed * memory. Confirmed live via targeted probes: g_repl_active_vm is * correctly reset to NULL by the kill/teardown path the moment it * happens, but this loop iteration's *local* `active` was already * snapshotted and never re-read. Re-resolve fresh from the global * right before dispatch -- cheap, and closes the race regardless * of whether the bailout above catches it first. */ active = g_repl_active_vm ? g_repl_active_vm : vm; sk_repl_dispatch_line(active, input); /* ABORT stops mid-line but leaves the flag set for the caller to * consume -- this REPL step is that boundary. Clear it here so the * next line isn't silently refused by vm_interpret's own check. */ active->abort_requested = 0; if (active->error) { #if EMERGENCY_CONSOLE_ENABLED console_puts(" ERROR\n"); active->error = 0; #else /* FABRIC-3.md §XI.4 (2026-09-09) scoped this recovery to any * *redirected* (WIREBIND/USE'd) identity, keeping Hera's own * direct session (active == vm) on the strict "no fallthrough * surface" halt -- reasoned at the time as protecting against * a genuine full-system emergency. §XXXII.1 (2026-09-15) found * that reasoning didn't hold: boot/capsule-load errors are * caught and cleared entirely separately in kernel_main.c, * before this loop is ever entered, so any active->error seen * here -- Hera's own session included -- is by construction * an ordinary interactive REPL-turn fault (a typo, an * unrecognized word), not a boot-time catastrophe. An * unqualified typo at Zuse's own console was hard-halting the * entire kernel as a direct result of this asymmetry. Recovers * unconditionally now, matching every redirected identity's * own session exactly -- no special case for `active == vm`. */ console_println("VM fault -- session recovered, resuming"); active->error = 0; active->halted = 0; active->abort_requested = 0; #endif } else { console_puts(" ok\n"); } } } void sk_repl(VM *vm) { /* FABRIC-0.md item 4.4j: boot and POST (both already returned by the time * sk_repl() is called) stay on font_8x16.c/VT100 by design; the * interactive REPL -- this function -- is the boundary where TTF-TEXT * takes over. One-shot: console_fb_enable_ttf() no-ops on any later * call. */ console_fb_enable_ttf(); console_println(lithos_version); console_puts("StarForth Version "); console_println(STARFORTH_VERSION); console_println(""); console_println("StarForth CLI"); console_println("FORTH-79 interpreter type BYE or power off to exit"); console_println(""); sk_repl_run(vm); }