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
+28
View File
@@ -4593,6 +4593,34 @@ any checkpoint reached *during* the drain sees `depth > 1` and will not drain ag
counter that defines the safe boundary also makes the drain non-reentrant**, with no flag, no
lock and no new state.
**Amendment, found while building task 3.4 (2026-09-22): this claim is true and covers a
different hazard than the one that actually mattered.** Non-reentrant drain (the same message
cannot drain twice) is real, but `g_vm_interpret_depth` was built for *cross-VM* nesting — the
checkpoint's own comment frames it as "nested inside a `VM-EXEC`/`VM-CALL` dispatch **from
another VM's own `vm_interpret()` call**." A different VM means a different `VM` struct, so
`input_buffer`/`input_length`/`input_pos` never collide in that case. **Same-VM reentrancy —
draining calls `vm_interpret()` on the very `vm` whose own `vm_interpret()` call is still
paused on the C stack, mid-word, at the checkpoint that triggered the drain — is a genuinely
separate hazard §XLIII.5 never named.** `FABRIC-3.md` §XX had already flagged this exact class
for Hera specifically: the old messaging pump skipped her because "self-targeting `VM-EXEC`
would hit the same reentrancy class… (`input_buffer`/`input_pos` not being saved by
`vm_state_push`/`pop`)." Read directly: `VMCallState` (`vm_state_push()`/`vm_state_pop()`,
`mama_forth_words.c`) saves only `rsp`/`exit_colon`/`ecw_nesting` — never `input_buffer`,
`input_length`, or `input_pos`. Left unaddressed, the enclosing `vm_interpret()` call's own
while loop would silently lose the rest of its input line or block the moment a drain fires
mid-line — the same failure shape as the `INPUT_BUFFER_SIZE` 256 defect `.claude/CLAUDE.md`
calls non-negotiable, and trap #1 in `FABRIC-3.6.md`'s own START HERE ("a green boot is weak
evidence").
**Not a divergence from this section's ruling — the mechanism is exactly as ruled.** Cursor
preservation is the implementer's own obligation *inside* the ruled mechanism, the same way
§XLIV.1 found `mkcapsule`'s "1024-byte limit" was arithmetically right and operationally
incomplete: fixing one thing while leaving a real gap unnamed. `FABRIC-3.6.md` task 3.4's own
implementation snapshots `vm->input_buffer`/`input_length`/`input_pos`, plus `vm->mode` (a
checkpoint reached mid-colon-definition must not let a drained payload's words compile into
the enclosing definition) and `vm->error`/`abort_requested` (a bad message must not abort the
enclosing execution), around the `vm_interpret()` call, restoring all six afterward.
### XLIII.6 — Three constraints, named rather than discovered later
1. **`INPUT_BUFFER_SIZE` is 1025** — 1024 content bytes plus NUL, and `.claude/CLAUDE.md` calls