Drain at the outermost checkpoint -- FABRIC-3.6.md task 3.4

sk_hermes_drain_checkpoint() interprets one queued payload per checkpoint
(ruled: one message per checkpoint), reusing sk_vm_at_outermost_interpret()
and placed before the switch-signal block in vm_core.c's existing
cooperative checkpoint (sk_vm_context_switch() doesn't return until
switched back to, so drain must come first or it silently never runs on
a switching checkpoint).

Amends FABRIC-3.5.md SXLIII.5, caught by advisor() before writing the
naive version: "recursive drain is prevented for free" via
g_vm_interpret_depth is true but only for same-message re-drain -- it
doesn't cover the separate same-VM reentrancy hazard FABRIC-3.md SXX
already named for Hera specifically (VMCallState saves rsp/exit_colon/
ecw_nesting only, never input_buffer/input_length/input_pos). Draining
calls vm_interpret() on the same vm whose own vm_interpret() call is
still paused mid-word at the checkpoint; without saving and restoring
the cursor by hand, the enclosing REPL line or LOAD block would be
silently truncated. sk_hermes_drain_checkpoint() snapshots and restores
input_buffer/input_length/input_pos/mode/error/abort_requested around
the call. Not a divergence from the ruling -- cursor preservation is the
implementer's own obligation inside the ruled mechanism.

Gated behind a system-wide pending-total counter so the common
no-message-in-flight case costs one integer read per word dispatch, not
a stadium_max_vm_count()-sized queue scan (also flagged by advisor() as
a real hot-path cost, not deferred).

Verified live on all three architectures: a self-test publishes a real
payload to Hermes, proves the depth gate via VM-EXEC-ing an existing
harmless colon word into Hermes (genuine nested vm_interpret(), depth 2,
must not drain), then drains directly from genuinely-outermost context
and confirms exactly one clean drain. dict_hash unmoved and identical
across architectures.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Robert Allan James
2026-09-22 01:27:11 -04:00
co-authored by Claude Sonnet 5
parent 1f6343bc03
commit 2b1ba031a5
9 changed files with 28050 additions and 1 deletions
+72
View File
@@ -1432,6 +1432,78 @@ static void kernel_main_deep(BootInfo *boot_info) {
}
}
/* FABRIC-3.6.md task 3.4 self-test: drain at the outermost checkpoint.
* Publishes one real, stack-neutral payload ("1 2 + DROP") to Hermes
* (a real, already-born VM -- not a synthetic one, since this test
* needs a genuine live dictionary to interpret against) and proves
* the depth gate two ways:
*
* 1. VM-EXEC-ing "WELCOME" (an existing, harmless colon word
* already in Hermes's own dictionary, block 4855) from Hera's
* context nests a SECOND, genuine vm_interpret() call via
* VM-EXEC's own already-proven-safe mechanism
* (mama_forth_words.c's `vm_interpret(target, cmd_buf)`).
* Hermes's own checkpoint fires there at depth 2 and must NOT
* drain -- the pending message must still be there afterward.
* 2. Calling sk_hermes_drain_checkpoint() directly from this
* self-test's own C context -- genuinely outermost, since
* kernel_main.c is not itself inside any vm_interpret() call --
* must drain exactly the one message, and a further call with
* nothing left must be a clean no-op.
*
* Deliberately avoids the block/LOAD mechanism for the nested case:
* LOAD's nested vm_interpret() is real, but block storage is real
* disk-backed state (`block_subsystem.c`) that a throwaway
* diagnostic has no business touching -- VM-EXEC's cross-VM nesting
* proves the same depth gate without it. */
{
VMUuid pub_id3, hermes_id;
VMRegistryEntry hermes_drain_entry;
int drain_ok = 1;
int grant_rc;
int ch;
pub_id3.hi = 0;
pub_id3.lo = 11;
console_puts("Kernel-Hermes drain self-test: ");
if (capsule_vm_find_by_name_nocase("Hermes", &hermes_drain_entry) != 0 ||
hermes_drain_entry.state != VM_STATE_LIVE || !hermes_drain_entry.vm_ptr) {
console_println("SKIPPED (Hermes not live)");
} else {
hermes_id = hermes_drain_entry.vm_id;
grant_rc = stadium_grant_quota(pub_id3, vm_uuid_hera());
if (grant_rc != 0) {
console_println("SKIPPED (quota grant failed)");
} else {
ch = sk_hermes_channel_create();
if (ch < 0) drain_ok = 0;
if (drain_ok && sk_hermes_channel_subscribe(ch, hermes_id) != 0) drain_ok = 0;
if (drain_ok &&
sk_hermes_publish(pub_id3, ch, 0, (void *)"1 2 + DROP", 0) != 1) drain_ok = 0;
if (drain_ok && sk_hermes_pending_count(hermes_id) != 1) drain_ok = 0;
/* Nested (depth 2 during VM-EXEC's own call): must not drain. */
if (drain_ok) {
vm_interpret(mama, "S\" WELCOME\" S\" Hermes\" VM-EXEC");
if (mama->error) { mama->error = 0; drain_ok = 0; }
}
if (drain_ok && sk_hermes_pending_count(hermes_id) != 1) drain_ok = 0;
/* Outermost (this self-test's own C context): must drain. */
if (drain_ok &&
sk_hermes_drain_checkpoint((VM *)hermes_drain_entry.vm_ptr) != 1) drain_ok = 0;
if (drain_ok && sk_hermes_pending_count(hermes_id) != 0) drain_ok = 0;
if (drain_ok &&
sk_hermes_drain_checkpoint((VM *)hermes_drain_entry.vm_ptr) != 0) drain_ok = 0;
if (drain_ok && sk_hermes_channel_destroy(ch) != 0) drain_ok = 0;
console_println(drain_ok ? "PASS" : "FAIL");
}
}
}
/* Decided 2026-09-05: no console for the running system unless a
* thumbdrive is present -- headless by default (EMERGENCY_CONSOLE_
* ENABLED off), reusing that flag's own existing "does this build
+55
View File
@@ -50,6 +50,7 @@
#include "starkernel/q48_16.h"
#include "starkernel/kmalloc.h"
#include "console.h"
#include "vm.h" /* task 3.4 -- VM struct fields, vm_interpret() */
/* Freestanding: no libc printf. Prints an unsigned decimal, no leading
* zeros -- same small helper stadium.c/capsule_vm_switch_signal.c each
@@ -463,12 +464,20 @@ static SkHermesPendingQueue *find_or_create_queue(VMUuid vm_id) {
return &sk_hermes_queues[free_slot];
}
/* Task 3.4: system-wide sum of every queue's count, maintained alongside
* queue_push()/sk_hermes_pending_pop() below -- lets
* sk_hermes_drain_checkpoint() skip its per-VM queue lookup entirely
* (stadium_max_vm_count()-sized linear scan) on every word dispatch when
* nothing is pending anywhere, the overwhelmingly common case. */
static int sk_hermes_pending_total = 0;
static int queue_push(SkHermesPendingQueue *q, int msg_index) {
size_t tail;
if (q->count >= SK_HERMES_PENDING_MAX) return -1;
tail = (q->head + q->count) % SK_HERMES_PENDING_MAX;
q->slots[tail] = msg_index;
q->count++;
sk_hermes_pending_total++;
return 0;
}
@@ -529,7 +538,53 @@ int sk_hermes_pending_pop(VMUuid vm_id) {
if (!q || q->count == 0) return -1;
q->head = (q->head + 1) % SK_HERMES_PENDING_MAX;
q->count--;
sk_hermes_pending_total--;
return 0;
}
int sk_hermes_drain_checkpoint(VM *vm) {
SkHermesMessage *msg;
VMUuid self;
char saved_input[INPUT_BUFFER_SIZE];
size_t saved_length, saved_pos;
vm_mode_t saved_mode;
int saved_error, saved_abort;
int drain_error;
if (!vm) return -1;
if (sk_hermes_pending_total == 0) return 0; /* fast path -- see this
* counter's own doc comment */
self = vm->stadium_vm_id;
msg = sk_hermes_pending_peek(self);
if (!msg) return 0;
/* See kernel_hermes.h's own doc comment on this function for why all
* six of these must be preserved, not just the obvious ones. */
memcpy(saved_input, vm->input_buffer, sizeof(saved_input));
saved_length = vm->input_length;
saved_pos = vm->input_pos;
saved_mode = vm->mode;
saved_error = vm->error;
saved_abort = vm->abort_requested;
vm->mode = MODE_INTERPRET; /* a payload is never compiled into whatever
* the enclosing context was mid-defining */
vm_interpret(vm, (const char *)msg->payload_addr);
drain_error = vm->error;
memcpy(vm->input_buffer, saved_input, sizeof(saved_input));
vm->input_length = saved_length;
vm->input_pos = saved_pos;
vm->mode = saved_mode;
vm->error = saved_error; /* a bad message must not abort the
* enclosing execution */
vm->abort_requested = saved_abort;
sk_hermes_release(msg);
sk_hermes_pending_pop(self);
return drain_error ? -1 : 1;
}
#endif /* __STARKERNEL__ */
+14
View File
@@ -62,6 +62,7 @@
#include "starkernel/vm/stadium_words.h" /* item 4.1: word patrons on the Stadium */
#include "starkernel/capsule_vm_switch_signal.h" /* FABRIC-3.md §XXVIII Stage 3 */
#include "starkernel/vm/switch.h" /* FABRIC-3.md §XXVIII Stage 2 -- sk_vm_context_switch() */
#include "starkernel/vm/kernel_hermes.h" /* FABRIC-3.6.md task 3.4 -- sk_hermes_drain_checkpoint() */
#include "starkernel/capsule_birth.h" /* capsule_vm_registry_get() */
/* FABRIC-3.md §XXVIII Stage 3 correction (2026-09-13): forward-declared
@@ -910,6 +911,19 @@ void execute_colon_word(VM* vm)
}
#ifdef __STARKERNEL__
/* FABRIC-3.6.md task 3.4 (SXLIII.3-.5): the target drains its own
* pending queue here, in its own context, at its own outermost
* interpret checkpoint -- same gate, same placement, same
* defer-if-nested discipline the switch-signal checkpoint just
* below already uses (SXLIII.2/.4: this IS that machinery,
* generalized). Placed BEFORE the switch-signal block, not after:
* sk_vm_context_switch() below does not return until something
* later switches back to `vm`, so a drain placed after it would
* silently never run on any checkpoint that switches. */
if (sk_vm_at_outermost_interpret()) {
(void)sk_hermes_drain_checkpoint(vm);
}
/* FABRIC-3.md §XXVIII, Stage 3 (2026-09-13): the cooperative
* preemption checkpoint. Deliberately unconditional (every word,
* not throttled like the heartbeat cycle above) -- the check