Files
LithosAnanake/kernel/src/repl.c
T
rajamesandJunie a8b70e88d3 Reorganize source tree: kernel/, v3/, v4/ split and board infrastructure
Source tree reorganization:
- Move StarForth v3 engine to v3/ (src/, include/, Makefile)
- Move kernel to kernel/ (src/, include/, linker/, Makefile)
- Create v4/ skeleton for F18-ISA golden model (DECOMPOSITION.md, JUSTIFICATION.md)
- Move FABRIC-0..4.md to docs/fabric/
- Move ONTOLOGY.md and ROADMAP.md to docs/

Board infrastructure:
- Add boards/ser5/, boards/raspi/, boards/milkv/, boards/zynq7020/
- Each board has board.mk (ISA, CPU flags, boot recipe) and README.md
- Root Makefile becomes thin dispatcher: boot_image, all, clean, docs take TARGET
- make boot_image TARGET=SER5|RASPI|MILKV builds one GPT/MBR image per board
- ZYNQ7020 target exists but stops with clear error (ARMv7 port not built yet)
- scripts/mkdiskimage.sh builds disk images for all boards

Docs pipeline:
- docs/book/ with LaTeX master (main.tex) and Makefile
- pandoc converts Markdown to LaTeX at build time
- Two Lua filters: table-widths.lua (wide tables wrap), code-breaks.lua (inline code breaks)
- make docs builds single PDF (754 pages, 0 missing characters)
- make docs TARGET=<board> adds board appendix
- build/docs/<book|board>/meta.tex stamps git commit into PDF

Bug fixes:
- 42 include paths that only worked by accident now use correct relative paths
- clang-18 hardcode replaced with configurable CC variable (fixed aarch64 build)
- Pi 5: kernel_2712.img linked at 0x80000, .bss zeroed, memory reserved
- Doxyfile, .clang-tidy, README.md, Kconfig paths updated

Verified:
- Hosted v3 build passes 1012 tests, 0 failures
- SER5 image boots in QEMU (OVMF), POST passes, K exact (65536 = Q48_ONE)
- Milk-V image boots in QEMU (OpenSBI + U-Boot + bootefi), POST passes
- make clean TARGET=<board> removes only that board and its ISA objects
- make all builds all boards, hosted v3, and docs in one run

Co-authored-by: Junie <junie@jetbrains.com>
2026-10-01 15:40:09 -04:00

1750 lines
89 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
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 <stdint.h>
#include <string.h>
/* 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: <name> 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\" <dev-addr> 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 "<name>~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);
}