Stage 0: trap-frame parity across all 3 arches (FABRIC-3.md §XXVIII)
First stage of the preemptive context-switching plan (see ~/.claude/plans/logical-snuggling-bear.md). Pure foundation work -- every arch's ISR now saves the full register set on interrupt entry, so a trap frame is in principle sufficient to resume execution anywhere it was taken. No FORTH-visible behavior changes. amd64: added FXSAVE/FXRSTOR, closing a genuine pre-existing correctness gap (not just future-preemption prep) -- confirmed live double-precision FP code reachable from ordinary interpreter dispatch (vm_runtime.c Loop #5/#6), and the ISR previously saved zero FP/SSE state. rbp repurposed as a fixed anchor so the 16-byte-aligned FXSAVE area can be carved out of an unpredictably-aligned rsp without disturbing existing argument reads. aarch64: extended the trap frame 672->800 bytes, adding v8-v15 (AAPCS64 callee-saved, previously excluded on call-site-only reasoning that doesn't hold for an async trap). riscv64: extended the trap frame 320->512 bytes, adding s0-s11 and fs0-fs11 (the latter still correctly gated behind sstatus.FS != Off). All 3 architectures re-verified clean boot to ok> under the new frames -- amd64 through hundreds of timer ticks with FXSAVE/FXRSTOR live on every interrupt, aarch64 through 987 ticks, riscv64 clean on the now-larger FS-conditional block. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016UNhH1mhi52i6Qihh7ZV5S
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
2a30212bd3
commit
15672ce17c
+86
@@ -3467,3 +3467,89 @@ project's known pattern of occasional riscv64/TCG timing flakiness (see the unex
|
||||
and aarch64 Stadium/COOL-scaling entries elsewhere in this doc), not assumed to be caused by
|
||||
this session's changes given the sector mismatch, but also not confirmed benign.
|
||||
|
||||
## XXVIII. Preemptive context switching between VMs -- staged plan approved, Stage 0 starting
|
||||
(2026-09-13)
|
||||
|
||||
**New thread, deliberately scoped as a multi-session effort, not a single pass.** Bob wants
|
||||
context switching between VMs -- preemptive (timer-interrupt-triggered), driven by a *new*
|
||||
Compudynamics-style physics signal purpose-built for "who runs next" (not a repurposing of the
|
||||
existing word-level execution-heat metric, and explicitly not a classic ready-queue/priority
|
||||
scheduler), in scope for ANY live VM including dynamically-birthed WIREBIND per-identity VMs,
|
||||
not just the fixed Tripod fleet. Full plan: `/home/rajames/.claude/plans/logical-snuggling-bear.md`
|
||||
(the canonical reference; summarized here for the permanent record).
|
||||
|
||||
**The load-bearing discovery that reframed the whole task:** there is no per-VM native stack
|
||||
anywhere in this codebase today. Every VM's execution -- including nested VM-EXEC dispatch into
|
||||
another VM's own dictionary -- runs as plain recursive C on the *one shared kernel C stack*
|
||||
(`vm_interpret()`, `vm_core.c:1043`; `execute_colon_word()`, `vm_core.c:685`; `g_log_attrib_vm`'s
|
||||
save/restore, `vm_core.c:1039/1048-1049/1082`, exists precisely because of this shared-stack
|
||||
nesting -- see §XXVII). Real preemption is therefore not "add an interrupt hook," it is "build
|
||||
lightweight kernel threads," with the interrupt hook as the last, smallest piece. No
|
||||
context-switch/coroutine/setjmp-longjmp primitive exists anywhere in `src/starkernel/` (confirmed
|
||||
by exhaustive grep) -- this is greenfield. `VM-STEP` (`mama_forth_words.c:637-685`), despite being
|
||||
documented as "the Compudynamics context-switch primitive," is purely cooperative, same-C-stack,
|
||||
zero register/stack save -- not a usable building block, and its doc comment is not to be
|
||||
inherited by the real primitive.
|
||||
|
||||
**Staged with explicit go/no-go gates, not attempted as one pass**, given the concrete UAF risk a
|
||||
"suspend mid-execution" capability creates the moment it becomes real: Stage 0 (trap-frame parity
|
||||
across all 3 arches -- amd64 already saves the full integer GPR set, aarch64/riscv64 currently
|
||||
save only caller-saved regs per ABI convention and need extending, pure cost, zero new behavior)
|
||||
-> Stage 1 (per-VM native stacks allocated at birth, following `arena.c`'s guard-page pattern,
|
||||
nothing executes on them yet) -> Stage 2 (the actual save/restore switch primitive, cooperative
|
||||
only, no timer, proven between Hera and one live Tripod VM -- this stage also closes a real,
|
||||
already-confirmed unguarded-kill UAF: `mama_word_kill()`/`capsule_vm_kill()` today free any live
|
||||
non-Hera VM unconditionally, with no check for "is there a live saved frame parked here") ->
|
||||
Stage 3 (timer-driven preemption, Tripod fleet only, the new run-readiness signal built to the
|
||||
same single-writer-mainline/single-reader-ISR discipline `heartbeat.c` already uses for
|
||||
`heartbeat_next_period_ns()` under its own §21.1 ruling -- reusing that sanctioned pattern for a
|
||||
second variable, not inventing new locking or overturning the ruling itself). **Stage 4
|
||||
(extending preemption to WIREBIND identity VMs) is deliberately a ratified-decision-only step in
|
||||
this plan, not implemented in the current pass** -- `capsule_wirebind_unclean_detach()`
|
||||
(`capsule_wirebind.c:266-351`) tears a VM down on *asynchronous hardware device removal*, a path
|
||||
under no FORTH-level control; if that VM were parked mid-instruction by the Stage 3 switcher when
|
||||
a device gets physically yanked, today's teardown would free memory a suspended context still
|
||||
points into. The fixed Tripod fleet has no equivalent async teardown path, which is exactly why
|
||||
Stage 3 stops short of WIREBIND scope. Recommended resolution when Stage 4 is picked up: mark the
|
||||
torn-down-while-switched-out VM as a tombstone rather than freeing it immediately, and let the
|
||||
Stage 3 switcher itself perform the deferred free the next time it would have tried to resume
|
||||
that VM -- keeping the async device path non-blocking while keeping "who frees a parked frame"
|
||||
under the switcher's own control, not a hardware interrupt's.
|
||||
|
||||
**Also corrects one assumption from initial exploration:** `VM_STATE_STOPPED` (`capsule_run.h:90`)
|
||||
is not unused as first thought -- `mama_word_start()` (`mama_forth_words.c:392-419`) sets it after
|
||||
a VM's own `STOP` word cleanly unwinds its C stack back to `START`'s frame. STOPPED means "no live
|
||||
native frame, safe to free," a cooperative top-level-boundary state -- it cannot represent "a live
|
||||
saved frame parked mid-instruction," so Stage 2 introduces a genuinely new, distinct marker
|
||||
instead of overloading it.
|
||||
|
||||
Each stage gets its own commit and its own full 3-architecture acceptance boot before the next
|
||||
stage begins, per this project's usual discipline -- no bundling. Stage 0 starts now.
|
||||
|
||||
**Stage 0 CLOSED, same day.** Trap-frame parity across all 3 arches:
|
||||
|
||||
- **amd64** (`isr.S`, `isr_common_entry`): audit turned up a genuine, pre-existing correctness
|
||||
gap, not just a future-preemption nicety -- confirmed live `double` arithmetic reachable from
|
||||
ordinary interpreter dispatch (`vm_runtime.c`'s Loop #5/#6 physics inference, called from
|
||||
`vm_tick()` during word execution, not just at boot). The existing ISR saved all 15 integer
|
||||
GPRs but zero FP/SSE state; since every XMM register is caller-saved under x86-64 SysV and an
|
||||
interrupt is not a call site the interrupted code's own instructions know about, nothing
|
||||
previously guaranteed FP state survived a timer tick landing mid-computation. Closed with
|
||||
FXSAVE/FXRSTOR, using `rbp` repurposed as a fixed anchor (its own true value already safe on
|
||||
the stack, restored later by the ordinary `pop rbp`) so the 16-byte-aligned FXSAVE area can be
|
||||
carved out of an unpredictably-aligned `rsp` without disturbing any of the existing
|
||||
`[rsp+N]`-relative argument reconstruction, which becomes `[rbp+N]`-relative instead.
|
||||
- **aarch64** (`isr.S`, `irq_spx_trampoline`): extended the 672-byte trap frame to 800 bytes,
|
||||
adding v8-v15 (previously excluded as AAPCS64 callee-saved, on the same call-site-only
|
||||
reasoning the amd64 gap shared).
|
||||
- **riscv64** (`isr.S`, `riscv64_trap_entry`): extended the 320-byte trap frame to 512 bytes,
|
||||
adding integer s0-s11 and FP fs0-fs11 (the latter still correctly gated behind the existing
|
||||
`sstatus.FS != Off` check, alongside the pre-existing caller-saved FP half).
|
||||
|
||||
All three still boot to `ok>` clean -- amd64 through hundreds of timer ticks with FXSAVE/FXRSTOR
|
||||
live on every single interrupt (not just the timer), aarch64 through 987 ticks with the extended
|
||||
v8-v15 save/restore, riscv64 clean on the FS-conditional block now carrying 24 more registers.
|
||||
No FORTH-visible behavior changed, as intended -- this stage was purely "make the trap frame
|
||||
complete," nothing yet reads or writes any of it beyond the ISR's own entry/exit. Stage 1 (per-VM
|
||||
native stacks) is next.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user