Files
LithosAnanake/src/starkernel/repl.c
T
Robert Allan JamesandClaude Sonnet 5 70421bdd43
Build / build-amd64-iso (push) Waiting to run
Build / build-aarch64-iso (push) Waiting to run
Build / build-riscv64-img (push) Waiting to run
Fix real WIREBIND crash: stale active-VM pointer dispatched after blocking read (FABRIC-3.md §XII.3)
The interpreter_enabled guard added in the previous commit (662ef44) was a
real but incomplete fix -- re-running the exact repro against it still
panicked (this time as a raw #PF page fault), proving something deeper
was wrong.

Root cause, found via targeted console_puts probes (not GDB --
starkernel_kernel.elf's symbols don't correspond to the actual running
starkernel_loader.efi binary for this monolithic build, same gotcha
already on record from the 2026-08-18 aarch64 investigation):

sk_repl_run()'s main loop captures `active` once, before calling
sk_console_readline(), which then blocks for the next full line. If the
identity `active` points at is killed while that read is still blocked,
the bailout meant to catch this (sk_console_identity_present()) only
checks a generic "is anyone attached" boolean, not "is the specific
identity active belonged to still attached" -- a fast detach of one
identity followed by attach of a different one never produces an
observable gap in that boolean, so the bailout never fires. The stale
`active`, now pointing at freed memory, gets dispatched into.

Fix: re-resolve `active` fresh from g_repl_active_vm immediately before
dispatch, right after sk_console_readline() returns. One line, no
registry lookup, no dereference of the stale pointer -- closes the race
regardless of whether the bailout catches it first.

Verified: rebuilt amd64 clean, reproduced the exact same attach/USE/
detach/attach/USE sequence against the fixed build -- clean switch, no
fault, exerciser runs correctly afterward.

FABRIC-3.md §XII.2 also corrected to stop claiming the interpreter_
enabled guard alone closed the crash -- it didn't, per the above.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXieurDfDSsDFdnSyusuWo
2026-09-10 17:19:01 -04:00

1344 lines
64 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) 20232025 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/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 "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-0.md §27.8/4.4s, unblocked 2026-09-04: extends the prompt to
* "[VM name] (user) ok>" (e.g. "[Hera] (zuse) ok>") whenever an
* identity is currently attached -- Zuse (mama_vm->zuse_session; there
* is only ever one, so no username lookup needed) or a regular WIREBIND
* user (capsule_wirebind_attached_username()). Checked independently of
* which VM's own bracket console.c is currently showing: both identities
* are console-level attach state, not per-VM dictionary state, so the
* segment reflects "who is at the console" the same way regardless of
* which VM you've USE'd into. Prints nothing (bare "ok> ", today's
* existing format, unchanged) when neither is attached -- Hera's own
* documented normal steady state (FABRIC-2.md §D.2). */
static void sk_print_prompt(void) {
VM *mama_vm = (VM *)sk_get_mama_vm();
const char *username;
if (mama_vm && mama_vm->zuse_session) {
console_puts("(zuse) ");
} else if ((username = capsule_wirebind_attached_username()) != (const char *)0) {
console_puts("(");
console_puts(username);
console_puts(") ");
}
console_puts(SK_PROMPT_TEXT);
}
/*===========================================================================
* 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;
}
/* 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;
} 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) { 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;
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;
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());
}
}
void sk_repl_register_words(VM *vm) {
register_word(vm, "BLK-ATTACH-ACK", sk_word_blk_attach_ack);
}
/*===========================================================================
* 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 MSG-TICK pump inside sk_repl_idle().
*
* sk_repl_idle() runs vm_interpret(mama, ...) (below) to VM-EXEC MSG-TICK
* into every live child VM (FABRIC-2.md Phase C). But 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 vm_interpret(mama, ...) at that
* point would re-enter mama's interpreter mid-parse and silently truncate
* the rest of the line; if the pump's own MSG-TICK work then triggers another
* blocking read, it would also recurse unboundedly. 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 vm_interpret call.
*/
static int g_mama_interpreting; /* zero-initialized (BSS) */
static int g_idle_pump_active; /* zero-initialized (BSS) */
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-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;
}
/* 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-2.md Phase C (2026-08-28): distributed messaging pump. Every
* live VM except Hera herself now owns its own MSG-ARENA/CH-ARENA and
* MSG-TICK word (see capsules/common/messaging.4th) instead of only
* Hermes having one -- "fully distributed, Hera pumps each VM's
* drain" was the confirmed design. Hera is excluded: she never loads
* common:messaging.4th (kernel_main.c's own comment at the Hermes-
* birth call site explains why -- the STADIUM-* primitives it needs
* are deliberately never registered in her own dictionary, to keep
* her dict_hash off item 4.1's baseline), so MSG-TICK is genuinely
* absent there, not just untried. She is the only VM with a
* persistent idle tick, so she walks the registry once per idle beat
* and VM-EXECs MSG-TICK into every OTHER live VM's own dictionary. */
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;
{
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) continue;
if (ent.state != VM_STATE_LIVE) continue;
if (ent.vm_ptr == (void *)mama) continue;
/* FABRIC-3.md, 2026-09-09: a per-VM flag (e.g. "this one is
* std79-locked") doesn't generalize -- any VM whose own
* dictionary lacks MSG-TICK, for whatever reason (never
* loaded common:messaging.4th, a future personality that
* drops it, ACL denial), hits the identical failure. So
* check the actual target VM's own dictionary fresh every
* tick, the same source of truth the interpreter's own ACL
* enforcement uses (vm_core.c), rather than pre-flag specific
* personalities. Skip silently rather than spam "VM-EXEC:
* ERROR in <name>" every idle tick forever for a VM that
* plain doesn't have -- or isn't allowed -- MSG-TICK
* (live-caught 2026-09-07/09). */
{
DictEntry *msgtick = vm_find_word((VM *)ent.vm_ptr, "MSG-TICK", 8);
if (!msgtick || !msgtick->acl_allow) continue;
}
static const char PFX[] = "S\" MSG-TICK\" S\" ";
static const char SFX[] = "\" VM-EXEC";
char cmd[128];
size_t p = 0;
size_t name_len = strlen(ent.name);
if (name_len > VM_NAME_MAX - 1u) name_len = VM_NAME_MAX - 1u;
memcpy(cmd + p, PFX, sizeof(PFX) - 1u); p += sizeof(PFX) - 1u;
memcpy(cmd + p, ent.name, name_len); p += name_len;
memcpy(cmd + p, SFX, sizeof(SFX) - 1u); p += sizeof(SFX) - 1u;
cmd[p] = '\0';
vm_interpret(mama, cmd);
}
}
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)
{
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;
sk_repl_idle(active_vm);
}
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)
{
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;
sk_repl_idle(active_vm);
}
/* 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');
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: halts VM on error (no fallthrough surface)
*===========================================================================*/
#if !EMERGENCY_CONSOLE_ENABLED
static void sk_fault_handler(VM *vm) {
console_println("VM fault — emergency console disabled; halting");
vm->halted = 1;
}
#endif
/*===========================================================================
* 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');
}
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 {
char cmd[INPUT_BUFFER_SIZE + 64];
/* to-index 3: the fixed convention this console's own
* VM-NAME-REG entry for its paired user VM uses (set
* once at pairing time -- see the pairing word). */
int n = snprintf(cmd, sizeof(cmd),
"CONSOLE-CMD-EVENT 0 3 S\" %s\" 0 MSG-SEND", input);
if (n > 0 && (size_t)n < sizeof(cmd)) {
vm_interpret(vm, cmd);
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
* "[VMName] " prefix (console.c, g_active_vm_name) 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. FABRIC-0.md 4.4s (2026-09-04):
* sk_print_prompt() extends this with a "(user)" segment when an
* identity is attached -- see its own doc comment. */
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
/* Wired 2026-09-05: sk_fault_handler() existed but was never
* called from here -- the "halts VM on error" half of this
* function's own doc comment was aspirational, not real, until
* now. Real for the headless-until-login default.
*
* FABRIC-3.md, 2026-09-09: scoped to Hera's own session only --
* see sk_repl_run()'s matching fix (and its own doc comment) for
* why a non-mama target must recover instead of halt here too. */
if (vm == (VM *)sk_get_mama_vm()) {
sk_fault_handler(vm);
} else {
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;
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
* "[VMName] " prefix (console.c, g_active_vm_name) 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. FABRIC-0.md 4.4s (2026-09-04):
* sk_print_prompt() extends this with a "(user)" segment. */
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
/* Wired 2026-09-05, same as sk_repl_step()'s matching branch
* above -- sk_fault_handler() existed but was never called.
*
* FABRIC-3.md, 2026-09-09: live-caught -- a standalone
* WIREBIND identity (no Zuse involved at all, USE never
* typed) hitting a denied word halted the *entire machine*,
* not just that identity's own session. Root cause: this
* loop's own exit condition (`while (!vm->halted)` above)
* checks vm -- Hera, the loop's original owner -- but
* sk_fault_handler() was being called on `active`, which
* capsule_wirebind's own attach flow had already redirected
* to the new identity's own VM (sk_repl_set_active_vm()) by
* the time any command could be typed. Setting active->halted
* left Hera's own vm->halted untouched -- the loop kept
* running -- but every subsequent iteration kept re-selecting
* the same now-halted, never-recovering `active`, producing
* no further output and no further progress: a de facto full
* freeze despite Hera's own loop technically still spinning.
* Captain Bob, 2026-09-09: "scope the halt to just that
* identity's session gracefully and restart the session or
* resume it." Hera's own direct session (active == vm) keeps
* the strict "no fallthrough surface" halt -- a fault in the
* root console is a genuine full-system emergency -- but any
* redirected (WIREBIND/USE'd) identity's own fault now
* recovers instead: clear the fault state and let that same
* identity keep going at its own prompt next iteration. */
if (active == vm) {
sk_fault_handler(active);
} else {
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 Emergency CLI");
console_println("FORTH-79 interpreter — type BYE or power off to exit");
console_println("");
sk_repl_run(vm);
}