Stage O-B: Hera drains her own mail at the idle point, not on the ~1 s idle beat (FABRIC-3.9.0 §2.5)

- sk_vm_idle_point(may_drain_self): with no frame live and mail pending,
  Hera drains one message and returns to the poll loop. Readline passes
  n == 0 (the mid-edit gate), headless 1, the key read 0.
- Removed sk_repl_idle()'s timer-paced drain of Hera's queue and the two
  flags that guarded it (g_mama_interpreting, g_idle_pump_active) -- the
  idle point's frames check already covers that re-entry hazard.

Probe (reverted), all three ISAs: admission -> drain for mail to an idle
Hera 0-1 beats (was up to 100); a message arriving mid-edit is held until
the line is entered (2080-3587 beats), never printed into typed text; an
event queued during a busy line drains when it ends; BLK-ATTACH at boot 1
beat. Acceptance passes; parity, dict_hash set and relay ledger unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Robert Allan James
2026-09-24 22:33:33 -04:00
co-authored by Claude Opus 5.5
parent fe55038e9a
commit ee4c401d1c
16 changed files with 55062 additions and 65 deletions
+58 -1
View File
@@ -1,6 +1,6 @@
# FABRIC-3.9.8.md — Observers and participants: execution plan and log
**Status: IN PROGRESS.** Done: O-A, the stack-convention fix, S-1a, S-1b, S-1c, M-0, M-A, the dead-VM departure fix and M-B (§2). The design and
**Status: IN PROGRESS.** Done: O-A, the stack-convention fix, S-1a, S-1b, S-1c, M-0, M-A, the dead-VM departure fix, M-B and O-B (§2). The design and
its rulings (O-1 to O-4, the discard, and §7/§8's M- and S- rulings) are in `FABRIC-3.9.0.md`.
Each stage:
@@ -681,3 +681,60 @@ Evidence is named **before** each stage runs.
- The relay ledger shows `held=2048`: every event's heat was released.
- Idle log growth is 0, there are no faults, and QEMU exits cleanly. Clean builds on all
three ISAs plus the hosted build.
- [x] **Stage O-B — Hera acts on her own mail the moment she is idle (`FABRIC-3.9.0.md` §2.5).**
2026-09-24. Captain Bob: *"go ahead with O-B."*
**The change:**
- **`sk_vm_idle_point(int may_drain_self)`** (`switch.c`): when Hera is running with no frame
live and has mail, it drains one message, then returns to the caller's poll loop, which calls
it again.
- The existing `frames.count != 0` check already refuses whenever a line, word, `KEY`/
`EXPECT`/`QUERY` or drain of hers is in progress. So the drain can't re-enter her
interpreter mid-parse, which is the hazard the old flags guarded.
- **The callers** (`repl.c`):
- readline passes **`n == 0`**, keeping the mid-edit gate: a drained payload never prints
into a line being typed;
- the headless wait passes 1;
- the blocking key read passes 0, since a key read is someone mid-interaction, and it is
always inside a word anyway.
- **Removed:** `sk_repl_idle()`'s timer-paced drain of Hera's queue (one message per
`SK_IDLE_BEAT_INTERVAL` = 100 beats, about 1 s), and with it the two flags that guarded it
(`g_mama_interpreting`, `g_idle_pump_active`).
- Nothing else read them. The dispatch's set/restore of `g_mama_interpreting` is gone too.
- `sk_repl_idle()` keeps its USB and block-sync servicing, unchanged.
**Evidence.** A probe (reverted) stamped each message to Hera with the beat count at
admission, and printed the gap when she drained it. On the same boots, run from the console:
- `wa` defines `PINGH`, which sends `72 EMIT` to Hera. Both strings are built in `wa`'s own
`PAD`, because an `S"` can't nest inside the `S"` that carries the job.
- Jobs `: PZW 30000 0 DO LOOP PINGH ; PZW` (idle case) and `3000000` iterations (mid-edit
case).
Logs: `logs/20260924-213049/amd64/`, `-214100/aarch64/`, `-214803/riscv64/`.
| Case | amd64 | aarch64 | riscv64 |
|---|---|---|---|
| **Idle at the prompt**, 3 runs: admission → drain | **0, 0, 0 beats** | 0, 1, 0 | 1, 0, 0 |
| **Mid-edit:** the message arrives while `1 2` is typed but not entered; ` + .` Enter about 40 s later | drained only after the line ran (`3 ok`, then `H`), **3587 beats** | drained at the entered line's first word boundary (`H3 ok`), 2080 beats | same as aarch64, 2613 beats |
| **Busy:** `EV-BORN` queued while Hera runs `WORKER-BIRTH` | 14 beats, the moment the line ended | 1 | 0 |
| **`BLK-ATTACH`** (type 9) at boot, from Artemis | 1 beat | 1 | 1 |
- Before this stage, a message to an idle Hera waited for the next idle beat: up to 100
beats. Now it is 0–1.
- The gate held on every ISA: nothing drained while the line was being edited.
- Once Enter is pressed the message goes at the first clean boundary. That is either Hera's
existing in-word drain checkpoint (FABRIC-3.8 Stage B) at the entered line's first word
boundary, or the idle point after the line. Which comes first is timing, so the order of `H`
and `3` differs by ISA. Either way it is a line boundary, never inside typed text.
- The amd64 log also holds a first pass of the script whose job texts used a bare
`DO … LOOP` in interpret mode (`DO: compile-only`). That was a script error, corrected in the
same boot, and all the figures above come from the corrected pass.
**Acceptance** on the clean tree (`logs/20260924-215452/amd64/`, `-220741/aarch64/`,
`-222049/riscv64/`):
- Boot parity and the `dict_hash` set are unchanged from M-B (546 words,
`0x99b01134c3d3049c`).
- The O-A checks pass (40/40/48, `HHH`, `3` mid-stream).
- The relay and `BLK-ATTACH` flows are unchanged: mint → WIREBIND → `USE` → `3`, with the
ledger at the standard `held=2048 pulled=258048 returned=255263 consumed=737`.
- Idle log growth is 0, there are no faults, and QEMU exits cleanly.
+8 -2
View File
@@ -65,8 +65,14 @@ void sk_vm_switch_checkpoint(struct VM *vm);
/* FABRIC-3.8.md §3.13.5 (Stage C-b, ruling C-5): the idle point. Called
* from the console's input poll loops (repl.c) each pass with no input.
* Acts only when Hera is running with no frame live (between lines); then
* switches to the coolest VM with work, if any. */
void sk_vm_idle_point(void);
* switches to the coolest VM with work, if any.
*
* FABRIC-3.9.8 Stage O-B (FABRIC-3.9.0.md §2.5): Hera's own mail is drained
* here first, one message per call, instead of on the timer-paced idle beat
* -- she is an observer that acts on events, promptly. may_drain_self is
* the caller's gate: readline passes (n == 0), so a drained payload never
* prints into a line being typed. */
void sk_vm_idle_point(int may_drain_self);
/* FABRIC-3.8.md §3.5 (Stage D): 1 if `vm` is parked at its own trampoline
* with nothing in progress -- its frame stack holds exactly one frame, its
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+27 -61
View File
@@ -832,30 +832,13 @@ static void sk_cursor_show(void)
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.9.8 Stage O-B (FABRIC-3.9.0.md §2.5): the reentrancy flags
* g_mama_interpreting / g_idle_pump_active that used to live here guarded
* this file's timer-paced drain of Hera's own mail inside sk_repl_idle().
* That drain moved to sk_vm_idle_point() (switch.c), which acts only when
* Hera has no frame at all -- not mid-line, not inside KEY/EXPECT/QUERY,
* not inside a drain -- so the hazard the flags existed for (re-entering
* her interpreter mid-parse) cannot arise there, and the flags are gone. */
/* 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
@@ -1174,23 +1157,6 @@ static void sk_repl_idle(VM *active_vm)
* 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.8.md Stage I (2026-09-24): the loop that used to drain every
* other live VM's queue here, on Hera's stack (G3), is removed. Since
* Stage E every born VM is a switch participant -- births are capped at
@@ -1200,11 +1166,11 @@ static void sk_repl_idle(VM *active_vm)
* here. Shown dead three ways before removal: by the condition it
* required (a live non-participant), by that construction, and at run
* time (a probe printed nothing through a boot, the Stage G interleave,
* a full 8-worker DoE trial and the WIREBIND relay). What remains is
* Hera's own queue: she is the one VM that is never switched to for her
* mail, since she is the one running here. */
(void)sk_hermes_drain_checkpoint(mama);
g_idle_pump_active = 0;
* a full 8-worker DoE trial and the WIREBIND relay). What remained was
* Hera's own queue -- and FABRIC-3.9.8 Stage O-B (FABRIC-3.9.0.md §2.5)
* moved that too, to sk_vm_idle_point(): as an observer acting on
* events, her mail is acted on the moment she is idle, not at this
* beat's ~1 s cadence. Nothing is drained here now. */
if (console_tx_count() == tx_before_idle) {
console_cancel_deferred_line_start();
@@ -1359,7 +1325,8 @@ int sk_console_getkey(VM *active_vm)
VM *live_active = g_repl_active_vm ? g_repl_active_vm : (VM *)sk_get_mama_vm();
sk_repl_idle(live_active);
}
sk_vm_idle_point(); /* FABRIC-3.8.md §3.13.5 -- acts only between lines */
sk_vm_idle_point(0); /* FABRIC-3.8.md §3.13.5 -- acts only between lines; a key
* read is someone mid-interaction: no self-drain (O-B) */
arch_relax();
}
}
@@ -1383,7 +1350,8 @@ void sk_repl_headless_wait(VM *mama)
g_last_beat_tick = now;
sk_repl_idle(mama);
}
sk_vm_idle_point(); /* FABRIC-3.8.md §3.13.5 */
sk_vm_idle_point(1); /* FABRIC-3.8.md §3.13.5; no line is being typed: Hera's mail
* is drained here too (O-B) */
arch_relax();
}
}
@@ -1585,8 +1553,11 @@ int sk_console_readline(char* buf, int size, VM* active_vm, int reanchor_prompt)
*/
/* FABRIC-3.8.md §3.13.5 (Stage C-b): Hera waiting for a line is
* the idle point -- the coolest VM with work runs now, not at
* the next SK_IDLE_BEAT_INTERVAL (that cadence is the timer's). */
sk_vm_idle_point();
* the next SK_IDLE_BEAT_INTERVAL (that cadence is the timer's).
* FABRIC-3.9.8 Stage O-B: so is Hera's own mail -- but only at
* n == 0, so a drained payload never prints into a line being
* typed (the gate the old idle-beat drain had). */
sk_vm_idle_point(n == 0);
arch_relax(); /* PAUSE — reduce power, maintain tight poll */
continue;
}
@@ -1735,16 +1706,11 @@ 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;
/* (The H1 reentrancy flag, g_mama_interpreting, is gone -- FABRIC-3.9.8
* Stage O-B. It guarded sk_repl_idle()'s timer-paced drain of Hera's
* mail; that drain moved to sk_vm_idle_point(), whose own
* frames.count != 0 check already refuses while this line, or any
* word or drain inside it, is running on Hera.) */
if (sk_repl_line_calls_use(input)) {
vm_interpret(vm, input);
@@ -1807,7 +1773,7 @@ static void sk_repl_dispatch_line(VM *vm, const char *input)
vm_interpret(vm, input);
out:
g_mama_interpreting = saved;
return;
}
/*===========================================================================
+12 -1
View File
@@ -218,13 +218,24 @@ void sk_vm_switch_checkpoint(VM *vm) {
* frame (nothing in progress), so she becomes eligible again only through
* mail or pending console input, or is returned to by a trampoline with
* nothing left to do. */
void sk_vm_idle_point(void) {
void sk_vm_idle_point(int may_drain_self) {
VM *self = g_switch_current_vm;
VM *next;
if (!self || self != (VM *)sk_get_mama_vm()) return;
if (self->frames.count != 0) return; /* inside a word (KEY/EXPECT): not idle */
/* FABRIC-3.9.8 Stage O-B (FABRIC-3.9.0.md §2.5): Hera acts on her own
* mail the moment she is idle -- one message, then back to the caller's
* poll loop, which calls again. Was sk_repl_idle()'s drain, paced by the
* ~1 s SK_IDLE_BEAT_INTERVAL. Safe here by the check just above: no
* frame means no line, word or drain of hers is in progress, so the
* drain can't re-enter her interpreter. */
if (may_drain_self && sk_hermes_pending_count(self->stadium_vm_id) > 0) {
(void)sk_hermes_drain_checkpoint(self);
return;
}
sk_vm_switch_reap_pending();
next = sk_vm_switch_decide(self, 0);
if (!next) return;