feat(codegen): stack under/overflow guards in compiled words (WS-007)

Compiled code could silently move dsp/rsp/fsp out of their stack
regions (e.g. DROP on an empty stack), corrupting later pushes with
no diagnostic -- the addresses stay inside valid linear memory, so
nothing could trap. Host-side checks cannot catch it.

- Guards are emitted at the sp-adjustment choke points (dsp_inc/
  dsp_dec, fsp_inc/fsp_dec, rpush/rpop/rpeek, peek, TwoDup/TwoDrop,
  promoted prologue/epilogue -- DROP never loads its value, so
  guarding pop() alone is not enough). On fault: write the code to
  SYSVAR_FAULT_CODE, call _STACK_FAULT_, which THROWs it -- so
  guards are CATCHable and print standard messages (-3/-4/-5/-6/
  -44/-45).
- The batch/consolidated compile path (all boot primitives) and the
  export path are wired too; a thread-local carries the fault index
  into the shared emission helpers.
- Config: codegen.stack_guards, default ON. `wafer build` output
  defaults OFF (production artifact); WAFER_STACK_GUARDS=0|1
  overrides either. Perf comparison lanes run unguarded.
- Measured overhead in release loops: within noise (never-taken
  branches).
- toolstest.fth baseline 37 -> 38: line 368's bare interpreted `R>`
  used to underflow silently and count as passing; the guard now
  correctly reports -6.
This commit is contained in:
Oleksandr Kozachuk
2026-08-05 17:00:22 +02:00
parent e31407ab58
commit cda296aab5
9 changed files with 324 additions and 140 deletions
+131 -5
View File
@@ -19,7 +19,10 @@ use wasm_encoder::{
use crate::dictionary::WordId;
use crate::error::{WaferError, WaferResult};
use crate::ir::IrOp;
use crate::memory::{CELL_SIZE, SYSVAR_LEAVE_FLAG};
use crate::memory::{
CELL_SIZE, DATA_STACK_BASE, DATA_STACK_TOP, FLOAT_STACK_BASE, FLOAT_STACK_TOP,
RETURN_STACK_BASE, RETURN_STACK_TOP, SYSVAR_FAULT_CODE, SYSVAR_LEAVE_FLAG,
};
// ---------------------------------------------------------------------------
// Import indices (order matters: imports numbered sequentially by kind)
@@ -94,6 +97,9 @@ pub struct CodegenConfig {
pub table_size: u32,
/// Enable stack-to-local promotion for straight-line words.
pub stack_to_local_promotion: bool,
/// Table index of the `_STACK_FAULT_` host word; `Some` enables
/// stack under/overflow guards in the emitted code.
pub stack_guards: Option<u32>,
}
/// Result of compiling a word to WASM.
@@ -109,16 +115,73 @@ pub struct CompiledModule {
// Instruction-level helpers (free functions that take &mut Function)
// ---------------------------------------------------------------------------
/// Decrement the cached `$dsp` local by `CELL_SIZE`.
// Stack-guard emission. The fault word's table index is stashed in a
// thread-local by `compile_word` (None = guards off) so the low-level
// push/pop helpers can stay plain `&mut Function` free functions
// without threading config through every emitter.
thread_local! {
static GUARD_FAULT: std::cell::Cell<Option<u32>> = const { std::cell::Cell::new(None) };
}
/// With guards on: emit `if <cond> { mem[SYSVAR_FAULT_CODE] = code;
/// call _STACK_FAULT_ }`. `cond` must leave an i32 boolean on the
/// operand stack. The fault host word throws, so the `if` never falls
/// through on the failure path.
fn emit_guard(f: &mut Function, code: i32, cond: impl FnOnce(&mut Function)) {
let Some(fault_idx) = GUARD_FAULT.get() else {
return;
};
cond(f);
f.instruction(&Instruction::If(BlockType::Empty))
.instruction(&Instruction::I32Const(SYSVAR_FAULT_CODE as i32))
.instruction(&Instruction::I32Const(code))
.instruction(&Instruction::I32Store(MEM4))
.instruction(&Instruction::I32Const(fault_idx as i32))
.instruction(&Instruction::CallIndirect {
type_index: TYPE_VOID,
table_index: TABLE,
})
.instruction(&Instruction::End);
}
/// Guard: data stack has at least `n` cells (else throw -4).
fn guard_dsp_underflow(f: &mut Function, n: u32) {
emit_guard(f, -4, |f| {
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Const((n * CELL_SIZE) as i32))
.instruction(&Instruction::I32Add)
.instruction(&Instruction::I32Const(DATA_STACK_TOP as i32))
.instruction(&Instruction::I32GtU);
});
}
/// Guard: data stack has room for `n` more cells (else throw -3).
fn guard_dsp_overflow(f: &mut Function, n: u32) {
emit_guard(f, -3, |f| {
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Const(
(DATA_STACK_BASE + n * CELL_SIZE) as i32,
))
.instruction(&Instruction::I32LtU);
});
}
/// Decrement the cached `$dsp` local by `CELL_SIZE` (allocate one cell).
/// This is the single choke point for data-stack pushes, so the
/// overflow guard lives here.
fn dsp_dec(f: &mut Function) {
guard_dsp_overflow(f, 1);
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Const(CELL_SIZE as i32))
.instruction(&Instruction::I32Sub)
.instruction(&Instruction::LocalSet(CACHED_DSP_LOCAL));
}
/// Increment the cached `$dsp` local by `CELL_SIZE`.
/// Increment the cached `$dsp` local by `CELL_SIZE` (free one cell).
/// Single choke point for data-stack pops (`DROP` never loads the
/// value, so the underflow guard must sit here, not in `pop`).
fn dsp_inc(f: &mut Function) {
guard_dsp_underflow(f, 1);
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Const(CELL_SIZE as i32))
.instruction(&Instruction::I32Add)
@@ -160,6 +223,7 @@ fn pop_to(f: &mut Function, local: u32) {
/// Read the top of the data stack without popping (value on operand stack).
fn peek(f: &mut Function) {
guard_dsp_underflow(f, 1);
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Load(MEM4));
}
@@ -182,6 +246,13 @@ fn dsp_reload(f: &mut Function) {
/// Push a value from the WASM operand stack onto the return stack via `tmp`.
fn rpush_via_local(f: &mut Function, tmp: u32) {
emit_guard(f, -5, |f| {
f.instruction(&Instruction::GlobalGet(RSP))
.instruction(&Instruction::I32Const(
(RETURN_STACK_BASE + CELL_SIZE) as i32,
))
.instruction(&Instruction::I32LtU);
});
f.instruction(&Instruction::LocalSet(tmp));
// rsp -= CELL_SIZE
f.instruction(&Instruction::GlobalGet(RSP))
@@ -194,8 +265,18 @@ fn rpush_via_local(f: &mut Function, tmp: u32) {
.instruction(&Instruction::I32Store(MEM4));
}
/// Guard: return stack is non-empty (else throw -6).
fn guard_rsp_underflow(f: &mut Function) {
emit_guard(f, -6, |f| {
f.instruction(&Instruction::GlobalGet(RSP))
.instruction(&Instruction::I32Const(RETURN_STACK_TOP as i32))
.instruction(&Instruction::I32GeU);
});
}
/// Pop the return stack onto the WASM operand stack.
fn rpop(f: &mut Function) {
guard_rsp_underflow(f);
f.instruction(&Instruction::GlobalGet(RSP))
.instruction(&Instruction::I32Load(MEM4));
// rsp += CELL_SIZE
@@ -207,6 +288,7 @@ fn rpop(f: &mut Function) {
/// Peek at the top of the return stack (no pop).
fn rpeek(f: &mut Function) {
guard_rsp_underflow(f);
f.instruction(&Instruction::GlobalGet(RSP))
.instruction(&Instruction::I32Load(MEM4));
}
@@ -253,7 +335,9 @@ struct EmitCtx {
}
/// Decrement the FSP global by 8 (allocate space for one f64).
/// Single choke point for float pushes: overflow guard lives here.
fn fsp_dec(f: &mut Function) {
guard_fsp_overflow(f);
f.instruction(&Instruction::GlobalGet(FSP))
.instruction(&Instruction::I32Const(8))
.instruction(&Instruction::I32Sub)
@@ -261,7 +345,10 @@ fn fsp_dec(f: &mut Function) {
}
/// Increment the FSP global by 8 (free space for one f64).
/// Single choke point for float pops (`FDROP` never loads the value):
/// underflow guard lives here.
fn fsp_inc(f: &mut Function) {
guard_fsp_underflow(f);
f.instruction(&Instruction::GlobalGet(FSP))
.instruction(&Instruction::I32Const(8))
.instruction(&Instruction::I32Add)
@@ -278,6 +365,24 @@ fn fpush_via_local(f: &mut Function, tmp: u32) {
.instruction(&Instruction::F64Store(MEM8));
}
/// Guard: float stack has room for one more f64 (else throw -44).
fn guard_fsp_overflow(f: &mut Function) {
emit_guard(f, -44, |f| {
f.instruction(&Instruction::GlobalGet(FSP))
.instruction(&Instruction::I32Const((FLOAT_STACK_BASE + 8) as i32))
.instruction(&Instruction::I32LtU);
});
}
/// Guard: float stack is non-empty (else throw -45).
fn guard_fsp_underflow(f: &mut Function) {
emit_guard(f, -45, |f| {
f.instruction(&Instruction::GlobalGet(FSP))
.instruction(&Instruction::I32Const(FLOAT_STACK_TOP as i32))
.instruction(&Instruction::I32GeU);
});
}
/// Decrement FSP, then store the f64 from local `src` at [FSP].
fn fpush_from_local(f: &mut Function, src: u32) {
fsp_dec(f);
@@ -295,6 +400,7 @@ fn fpop(f: &mut Function) {
/// Load f64 from [FSP] onto the WASM operand stack without popping.
fn fpeek(f: &mut Function) {
guard_fsp_underflow(f);
f.instruction(&Instruction::GlobalGet(FSP))
.instruction(&Instruction::F64Load(MEM8));
}
@@ -796,6 +902,8 @@ fn emit_op(f: &mut Function, op: &IrOp, ctx: &mut EmitCtx) {
// -- Compound operations -----------------------------------------------
IrOp::TwoDup => {
// ( a b -- a b a b )
guard_dsp_underflow(f, 2);
guard_dsp_overflow(f, 2);
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Load(MEM4)); // b
f.instruction(&Instruction::LocalSet(SCRATCH_BASE));
@@ -822,6 +930,7 @@ fn emit_op(f: &mut Function, op: &IrOp, ctx: &mut EmitCtx) {
IrOp::TwoDrop => {
// ( a b -- )
guard_dsp_underflow(f, 2);
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL))
.instruction(&Instruction::I32Const((CELL_SIZE * 2) as i32))
.instruction(&Instruction::I32Add)
@@ -1545,6 +1654,11 @@ impl StackSim {
/// Emit the promoted prologue: load `preload` items from the memory stack
/// into WASM locals.
fn emit_promoted_prologue(f: &mut Function, preload: u32, sim: &mut StackSim) {
// One entry check covers the whole promoted word: the caller must
// have at least `preload` cells on the data stack.
if preload > 0 {
guard_dsp_underflow(f, preload);
}
// Load items: mem[dsp] = top of stack, mem[dsp+4] = second, etc.
// We load them top-first, then reverse the sim stack so that
// sim.stack[0] = deepest loaded, sim.stack[last] = top.
@@ -1575,6 +1689,7 @@ fn emit_promoted_prologue(f: &mut Function, preload: u32, sim: &mut StackSim) {
fn emit_promoted_epilogue(f: &mut Function, sim: &mut StackSim) {
let remaining = sim.stack.len() as u32;
if remaining > 0 {
guard_dsp_overflow(f, remaining);
// Decrement cached DSP for the items we're pushing back
f.instruction(&Instruction::LocalGet(CACHED_DSP_LOCAL));
f.instruction(&Instruction::I32Const((remaining * CELL_SIZE) as i32));
@@ -2407,6 +2522,9 @@ pub fn compile_word(
body: &[IrOp],
config: &CodegenConfig,
) -> WaferResult<CompiledModule> {
// Arm (or disarm) stack-guard emission for this compilation.
GUARD_FAULT.set(config.stack_guards);
let mut module = Module::new();
// -- Type section --
@@ -2871,8 +2989,9 @@ pub fn compile_consolidated_module(
words: &[(WordId, Vec<IrOp>)],
local_fn_map: &HashMap<WordId, u32>,
table_size: u32,
stack_guards: Option<u32>,
) -> WaferResult<Vec<u8>> {
compile_multi_word_module(words, local_fn_map, table_size, None)
compile_multi_word_module(words, local_fn_map, table_size, None, stack_guards)
}
/// Compile an exportable WASM module with embedded memory and metadata.
@@ -2885,8 +3004,9 @@ pub fn compile_exportable_module(
local_fn_map: &HashMap<WordId, u32>,
table_size: u32,
export: &ExportSections<'_>,
stack_guards: Option<u32>,
) -> WaferResult<Vec<u8>> {
compile_multi_word_module(words, local_fn_map, table_size, Some(export))
compile_multi_word_module(words, local_fn_map, table_size, Some(export), stack_guards)
}
/// Internal: build a multi-word WASM module. When `export` is `Some`, adds
@@ -2896,7 +3016,11 @@ fn compile_multi_word_module(
local_fn_map: &HashMap<WordId, u32>,
table_size: u32,
export: Option<&ExportSections<'_>>,
stack_guards: Option<u32>,
) -> WaferResult<Vec<u8>> {
// Arm (or disarm) stack-guard emission for this module.
GUARD_FAULT.set(stack_guards);
let has_data = export.is_some_and(|e| !e.memory_snapshot.is_empty());
let mut module = Module::new();
@@ -3125,6 +3249,7 @@ mod tests {
base_fn_index: 0,
table_size: 16,
stack_to_local_promotion: true,
stack_guards: None,
}
}
@@ -3349,6 +3474,7 @@ mod tests {
base_fn_index: 7,
table_size: 16,
stack_to_local_promotion: true,
stack_guards: None,
};
let m = compile_word("t", &[IrOp::PushI32(1)], &cfg).unwrap();
assert_eq!(m.fn_index, 7);