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
+15 -2
View File
@@ -137,7 +137,8 @@ fn cmd_build(
) -> anyhow::Result<()> {
let source = std::fs::read_to_string(file)?;
let mut vm = ForthVM::<NativeRuntime>::new()?;
// Exported modules are production artifacts: no stack guards by default
let mut vm = ForthVM::<NativeRuntime>::new_with_config(vm_config(false))?;
vm.set_recording(true);
vm.evaluate(&source)?;
@@ -260,9 +261,21 @@ fn cmd_run(file: &str) -> anyhow::Result<()> {
Ok(())
}
/// `WaferConfig` for CLI-created VMs. `WAFER_STACK_GUARDS=0|1` overrides
/// the per-command default (REPL/file execution on, build off).
fn vm_config(default_guards: bool) -> wafer_core::config::WaferConfig {
let mut cfg = wafer_core::config::WaferConfig::all();
cfg.codegen.stack_guards = match std::env::var("WAFER_STACK_GUARDS").ok().as_deref() {
Some("0") => false,
Some(_) => true,
None => default_guards,
};
cfg
}
/// `wafer` (REPL) or `wafer program.fth` (evaluate and exit)
fn cmd_eval_or_repl(file: Option<&str>) -> anyhow::Result<()> {
let mut vm = ForthVM::<NativeRuntime>::new()?;
let mut vm = ForthVM::<NativeRuntime>::new_with_config(vm_config(true))?;
match file {
Some(file) => {
+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);
+7
View File
@@ -7,6 +7,11 @@ use crate::optimizer::OptConfig;
pub struct CodegenOpts {
/// Enable stack-to-local promotion for straight-line words.
pub stack_to_local_promotion: bool,
/// Emit stack under/overflow guards in compiled words. Faults throw
/// standard codes (-3/-4/-5/-6/-44/-45) instead of silently
/// corrupting stack pointers. On by default; benchmarks and
/// exported production modules turn it off.
pub stack_guards: bool,
}
/// Master configuration for all WAFER optimizations.
@@ -32,6 +37,7 @@ impl WaferConfig {
},
codegen: CodegenOpts {
stack_to_local_promotion: true,
stack_guards: true,
},
}
}
@@ -49,6 +55,7 @@ impl WaferConfig {
},
codegen: CodegenOpts {
stack_to_local_promotion: false,
stack_guards: false,
},
}
}
+9 -9
View File
@@ -21,7 +21,7 @@ mod tests {
// Empty word list should produce nothing (but we guard against this at call site)
let words = vec![];
let map = HashMap::new();
let result = compile_consolidated_module(&words, &map, 16);
let result = compile_consolidated_module(&words, &map, 16, None);
// Empty is valid -- should produce a valid module with no functions
assert!(result.is_ok());
}
@@ -31,7 +31,7 @@ mod tests {
let words = vec![(WordId(1), vec![IrOp::PushI32(42)])];
let mut map = HashMap::new();
map.insert(WordId(1), 1u32); // function index 1 (after emit import)
let result = compile_consolidated_module(&words, &map, 16);
let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok());
}
@@ -49,7 +49,7 @@ mod tests {
map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32);
map.insert(WordId(3), 3u32);
let result = compile_consolidated_module(&words, &map, 16);
let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok());
}
@@ -59,7 +59,7 @@ mod tests {
let words = vec![(WordId(3), vec![IrOp::Call(WordId(99))])];
let mut map = HashMap::new();
map.insert(WordId(3), 1u32);
let result = compile_consolidated_module(&words, &map, 256);
let result = compile_consolidated_module(&words, &map, 256, None);
assert!(result.is_ok());
}
@@ -72,7 +72,7 @@ mod tests {
let mut map = HashMap::new();
map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16);
let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok());
}
@@ -95,7 +95,7 @@ mod tests {
let mut map = HashMap::new();
map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16);
let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok());
}
@@ -120,7 +120,7 @@ mod tests {
let mut map = HashMap::new();
map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16);
let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok());
}
@@ -141,7 +141,7 @@ mod tests {
let mut map = HashMap::new();
map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16);
let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok());
}
@@ -163,7 +163,7 @@ mod tests {
let mut map = HashMap::new();
map.insert(WordId(1), 1u32);
map.insert(WordId(2), 2u32);
let result = compile_consolidated_module(&words, &map, 16);
let result = compile_consolidated_module(&words, &map, 16, None);
assert!(result.is_ok());
}
}
+8 -2
View File
@@ -120,8 +120,14 @@ pub fn export_module(
metadata_json: metadata_json.as_bytes(),
};
let wasm_bytes = compile_exportable_module(&words, &local_fn_map, table_size, &export_sections)
.map_err(|e| anyhow::anyhow!("export codegen error: {e}"))?;
let wasm_bytes = compile_exportable_module(
&words,
&local_fn_map,
table_size,
&export_sections,
vm.stack_guard_param(),
)
.map_err(|e| anyhow::anyhow!("export codegen error: {e}"))?;
Ok((wasm_bytes, metadata))
}
+2
View File
@@ -106,6 +106,8 @@ pub const SYSVAR_NUM_TIB: u32 = SYSVAR_BASE + 24;
pub const SYSVAR_HLD: u32 = SYSVAR_BASE + 28;
/// LEAVE flag: nonzero when LEAVE has been called inside a DO loop.
pub const SYSVAR_LEAVE_FLAG: u32 = SYSVAR_BASE + 32;
/// Throw code left by a compiled stack-guard fault for `_STACK_FAULT_`.
pub const SYSVAR_FAULT_CODE: u32 = SYSVAR_BASE + 36;
#[cfg(test)]
mod tests {
+144 -121
View File
@@ -24,8 +24,8 @@ use crate::ir::IrOp;
use crate::memory::HASH_SCRATCH_BASE;
use crate::memory::{
CELL_SIZE, DATA_STACK_TOP, FLOAT_SIZE, FLOAT_STACK_BASE, FLOAT_STACK_TOP, INPUT_BUFFER_BASE,
INPUT_BUFFER_SIZE, RETURN_STACK_TOP, SYSVAR_BASE_VAR, SYSVAR_HERE, SYSVAR_LEAVE_FLAG,
SYSVAR_NUM_TIB, SYSVAR_STATE, SYSVAR_TO_IN,
INPUT_BUFFER_SIZE, RETURN_STACK_TOP, SYSVAR_BASE_VAR, SYSVAR_FAULT_CODE, SYSVAR_HERE,
SYSVAR_LEAVE_FLAG, SYSVAR_NUM_TIB, SYSVAR_STATE, SYSVAR_TO_IN,
};
use crate::optimizer::optimize;
@@ -270,6 +270,8 @@ pub struct ForthVM<R: Runtime> {
marker_states: HashMap<u32, MarkerState>,
/// EMPTY's rollback target: boot state, or wherever GILD re-baselined.
gild_state: Option<Box<MarkerState>>,
/// Table index of `_STACK_FAULT_` (compiled stack-guard target).
stack_fault_id: u32,
/// Pending MARKER restore: after a marker word executes, restore this state
pending_marker_restore: Arc<Mutex<Option<u32>>>,
/// Conditional compilation skip depth: >0 means we're skipping tokens for [IF]/[ELSE]
@@ -350,6 +352,7 @@ fn throw_message(code: i32) -> Option<&'static str> {
-31 => "Word not defined by CREATE",
-42 => "Floating-point divide by zero",
-43 => "Floating-point result out of range",
-44 => "Floating-point stack overflow",
-45 => "Floating-point stack underflow",
-56 => "QUIT",
_ => return None,
@@ -469,6 +472,7 @@ impl<R: Runtime> ForthVM<R> {
recording_toplevel: false,
marker_states: HashMap::new(),
gild_state: None,
stack_fault_id: 0,
pending_marker_restore: Arc::new(Mutex::new(None)),
conditional_skip_depth: 0,
next_block_label: 0,
@@ -2298,11 +2302,7 @@ impl<R: Runtime> ForthVM<R> {
self.ir_bodies.insert(word_id, ir.clone());
// Compile to WASM
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled =
compile_word(&name, &ir, &config).map_err(|e| anyhow::anyhow!("codegen error: {e}"))?;
@@ -2361,8 +2361,13 @@ impl<R: Runtime> ForthVM<R> {
let table_size = self.table_size();
// Compile the consolidated module
let module_bytes = compile_consolidated_module(&words, &local_fn_map, table_size)
.map_err(|e| anyhow::anyhow!("consolidation codegen error: {e}"))?;
let module_bytes = compile_consolidated_module(
&words,
&local_fn_map,
table_size,
self.stack_guard_param(),
)
.map_err(|e| anyhow::anyhow!("consolidation codegen error: {e}"))?;
// Instantiate: the element section in the module handles table placement
// We use fn_index=0 since the element section has the correct offsets
@@ -2386,8 +2391,13 @@ impl<R: Runtime> ForthVM<R> {
self.ensure_table_size(self.next_table_index)?;
let table_size = self.table_size();
let module_bytes = compile_consolidated_module(&words, &local_fn_map, table_size)
.map_err(|e| anyhow::anyhow!("batch compile error: {e}"))?;
let module_bytes = compile_consolidated_module(
&words,
&local_fn_map,
table_size,
self.stack_guard_param(),
)
.map_err(|e| anyhow::anyhow!("batch compile error: {e}"))?;
self.total_module_bytes += module_bytes.len() as u64;
// Instantiate: the element section in the module handles table placement
@@ -2644,6 +2654,24 @@ impl<R: Runtime> ForthVM<R> {
// Primitive registration
// -----------------------------------------------------------------------
/// `_STACK_FAULT_` table index when stack guards are enabled.
pub(crate) fn stack_guard_param(&self) -> Option<u32> {
self.config
.codegen
.stack_guards
.then_some(self.stack_fault_id)
}
/// Codegen configuration for compiling one word.
fn codegen_config(&mut self, base_fn_index: u32) -> CodegenConfig {
CodegenConfig {
base_fn_index,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
stack_guards: self.stack_guard_param(),
}
}
/// Register a primitive word by compiling its IR body and installing it.
fn register_primitive(
&mut self,
@@ -2666,11 +2694,7 @@ impl<R: Runtime> ForthVM<R> {
// Defer WASM compilation for batch processing
self.deferred_ir.push((word_id, ir_body));
} else {
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled = compile_word(name, &ir_body, &config)
.map_err(|e| anyhow::anyhow!("codegen error for {name}: {e}"))?;
self.instantiate_and_install(&compiled, word_id)?;
@@ -2709,6 +2733,19 @@ impl<R: Runtime> ForthVM<R> {
fn register_primitives(&mut self) -> anyhow::Result<()> {
self.batch_mode = true;
// _STACK_FAULT_ must exist before any guarded word is compiled:
// compiled guards write a throw code to SYSVAR_FAULT_CODE and
// call this word, which converts it into a Forth THROW.
let throw_code = Arc::clone(&self.throw_code);
let func: HostFn = Box::new(move |ctx: &mut dyn HostAccess| {
let code = ctx.mem_read_i32(SYSVAR_FAULT_CODE);
*throw_code.lock().unwrap() = Some(code);
Err(anyhow::anyhow!("forth-throw"))
});
self.stack_fault_id = self
.register_host_primitive("_STACK_FAULT_", false, func)?
.0;
// -- Stack manipulation --
self.register_primitive("DUP", false, vec![IrOp::Dup])?;
self.register_primitive("DROP", false, vec![IrOp::Drop])?;
@@ -3192,11 +3229,7 @@ impl<R: Runtime> ForthVM<R> {
// Compile a tiny word that pushes the variable's address
let ir_body = vec![IrOp::PushI32(var_addr as i32)];
self.ir_bodies.insert(word_id, ir_body.clone());
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled = compile_word(&name, &ir_body, &config)
.map_err(|e| anyhow::anyhow!("codegen error for VARIABLE {name}: {e}"))?;
@@ -3224,11 +3257,7 @@ impl<R: Runtime> ForthVM<R> {
// Compile a word that pushes the constant value
let ir_body = vec![IrOp::PushI32(value)];
self.ir_bodies.insert(word_id, ir_body.clone());
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled = compile_word(&name, &ir_body, &config)
.map_err(|e| anyhow::anyhow!("codegen error for CONSTANT {name}: {e}"))?;
@@ -3261,11 +3290,7 @@ impl<R: Runtime> ForthVM<R> {
// Compile a word that pushes the pfa
let ir_body = vec![IrOp::PushI32(pfa as i32)];
self.ir_bodies.insert(word_id, ir_body.clone());
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled = compile_word(&name, &ir_body, &config)
.map_err(|e| anyhow::anyhow!("codegen error for CREATE {name}: {e}"))?;
@@ -3307,11 +3332,7 @@ impl<R: Runtime> ForthVM<R> {
// Compile a word that fetches from the value's address
let ir_body = vec![IrOp::PushI32(val_addr as i32), IrOp::Fetch];
self.ir_bodies.insert(word_id, ir_body.clone());
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled = compile_word(&name, &ir_body, &config)
.map_err(|e| anyhow::anyhow!("codegen error for VALUE {name}: {e}"))?;
@@ -3349,11 +3370,7 @@ impl<R: Runtime> ForthVM<R> {
// Compile a word that fetches the xt and executes it
let ir_body = vec![IrOp::PushI32(defer_addr as i32), IrOp::Fetch, IrOp::Execute];
self.ir_bodies.insert(word_id, ir_body.clone());
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled = compile_word(&name, &ir_body, &config)
.map_err(|e| anyhow::anyhow!("codegen error for DEFER {name}: {e}"))?;
@@ -3386,11 +3403,7 @@ impl<R: Runtime> ForthVM<R> {
let ir_body = vec![IrOp::Call(word_id)];
self.ir_bodies.insert(new_word_id, ir_body.clone());
let config = CodegenConfig {
base_fn_index: new_word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(new_word_id.0);
let compiled = compile_word(&new_name, &ir_body, &config)
.map_err(|e| anyhow::anyhow!("codegen error for SYNONYM: {e}"))?;
self.instantiate_and_install(&compiled, new_word_id)?;
@@ -3438,11 +3451,7 @@ impl<R: Runtime> ForthVM<R> {
// Compile a word that pushes the buffer address
let ir_body = vec![IrOp::PushI32(buf_addr as i32)];
self.ir_bodies.insert(word_id, ir_body.clone());
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled = compile_word(&name, &ir_body, &config)
.map_err(|e| anyhow::anyhow!("codegen error for BUFFER: {name}: {e}"))?;
@@ -3530,11 +3539,7 @@ impl<R: Runtime> ForthVM<R> {
.ok_or_else(|| anyhow::anyhow!("_MARKER_RESTORE_ not found"))?;
let ir_body = vec![IrOp::PushI32(word_id.0 as i32), IrOp::Call(restore_id)];
self.ir_bodies.insert(word_id, ir_body.clone());
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled = compile_word(&name, &ir_body, &config)
.map_err(|e| anyhow::anyhow!("codegen error for MARKER {name}: {e}"))?;
@@ -4417,11 +4422,7 @@ impl<R: Runtime> ForthVM<R> {
let word_id = WordId(fn_index);
// Compile and replace
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let name = self
.dictionary
.word_name(latest)
@@ -4511,11 +4512,7 @@ impl<R: Runtime> ForthVM<R> {
}
let second_ir = std::mem::take(&mut self.compiling_ir);
let config = CodegenConfig {
base_fn_index: second_word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(second_word_id.0);
let compiled = compile_word("_does_action2_", &second_ir, &config)
.map_err(|e| anyhow::anyhow!("codegen error for DOES> body 2: {e}"))?;
self.instantiate_and_install(&compiled, second_word_id)?;
@@ -4573,11 +4570,7 @@ impl<R: Runtime> ForthVM<R> {
}
let does_ir = std::mem::take(&mut self.compiling_ir);
let config = CodegenConfig {
base_fn_index: does_word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(does_word_id.0);
let compiled = compile_word("_does_action_", &does_ir, &config)
.map_err(|e| anyhow::anyhow!("codegen error for DOES> body: {e}"))?;
self.instantiate_and_install(&compiled, does_word_id)?;
@@ -4603,11 +4596,7 @@ impl<R: Runtime> ForthVM<R> {
// Compile the defining word as a no-op (the actual work is done
// by the outer interpreter when it detects the does-definition).
let config = CodegenConfig {
base_fn_index: defining_word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(defining_word_id.0);
let compiled = compile_word(&defining_name, &[], &config)
.map_err(|e| anyhow::anyhow!("codegen error for defining word: {e}"))?;
self.instantiate_and_install(&compiled, defining_word_id)?;
@@ -4664,11 +4653,7 @@ impl<R: Runtime> ForthVM<R> {
// Temporarily install a "push PFA" word (will be patched later)
let ir_body = vec![IrOp::PushI32(pfa as i32)];
self.ir_bodies.insert(new_word_id, ir_body.clone());
let config = CodegenConfig {
base_fn_index: new_word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(new_word_id.0);
let compiled = compile_word(&name, &ir_body, &config)
.map_err(|e| anyhow::anyhow!("codegen: {e}"))?;
self.instantiate_and_install(&compiled, new_word_id)?;
@@ -4687,11 +4672,7 @@ impl<R: Runtime> ForthVM<R> {
let tmp_word_id = WordId(tmp_fn_idx);
self.next_table_index = self.next_table_index.max(tmp_fn_idx + 1);
let config = CodegenConfig {
base_fn_index: tmp_word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(tmp_word_id.0);
let compiled = compile_word("_create_part_", &create_ir, &config)
.map_err(|e| anyhow::anyhow!("codegen: {e}"))?;
self.instantiate_and_install(&compiled, tmp_word_id)?;
@@ -4700,11 +4681,7 @@ impl<R: Runtime> ForthVM<R> {
// Step 4: Patch the new word to push PFA and call does-action
self.refresh_user_here();
let patched_ir = vec![IrOp::PushI32(pfa as i32), IrOp::Call(does_action_id)];
let config = CodegenConfig {
base_fn_index: new_word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(new_word_id.0);
let compiled = compile_word(&name, &patched_ir, &config)
.map_err(|e| anyhow::anyhow!("DOES> patch codegen: {e}"))?;
self.instantiate_and_install(&compiled, new_word_id)?;
@@ -4728,11 +4705,7 @@ impl<R: Runtime> ForthVM<R> {
.map_err(|e| anyhow::anyhow!("{e}"))?;
let patched_ir = vec![IrOp::PushI32(pfa as i32), IrOp::Call(does_action_id)];
let config = CodegenConfig {
base_fn_index: target_word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(target_word_id.0);
let compiled = compile_word(&name, &patched_ir, &config)
.map_err(|e| anyhow::anyhow!("DOES> patch codegen: {e}"))?;
self.instantiate_and_install(&compiled, target_word_id)?;
@@ -5429,11 +5402,7 @@ impl<R: Runtime> ForthVM<R> {
.map_err(|e| anyhow::anyhow!("{e}"))?;
let patched_ir = vec![IrOp::PushI32(pfa as i32), IrOp::Call(WordId(action_id))];
let config = CodegenConfig {
base_fn_index: target_word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(target_word_id.0);
let compiled = compile_word(&name, &patched_ir, &config)
.map_err(|e| anyhow::anyhow!("runtime DOES> patch codegen: {e}"))?;
self.instantiate_and_install(&compiled, target_word_id)?;
@@ -6515,11 +6484,7 @@ impl<R: Runtime> ForthVM<R> {
let ir = vec![IrOp::PushI32(lo), IrOp::PushI32(hi)];
self.ir_bodies.insert(word_id, ir.clone());
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled = compile_word(&name, &ir, &config)
.map_err(|e| anyhow::anyhow!("2CONSTANT codegen: {e}"))?;
self.instantiate_and_install(&compiled, word_id)?;
@@ -6545,11 +6510,7 @@ impl<R: Runtime> ForthVM<R> {
let ir = vec![IrOp::PushI32(addr as i32)];
self.ir_bodies.insert(word_id, ir.clone());
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled = compile_word(&name, &ir, &config)
.map_err(|e| anyhow::anyhow!("2VARIABLE codegen: {e}"))?;
self.instantiate_and_install(&compiled, word_id)?;
@@ -6588,11 +6549,7 @@ impl<R: Runtime> ForthVM<R> {
IrOp::Fetch,
];
self.ir_bodies.insert(word_id, ir.clone());
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled = compile_word(&name, &ir, &config)
.map_err(|e| anyhow::anyhow!("2VALUE codegen: {e}"))?;
self.instantiate_and_install(&compiled, word_id)?;
@@ -7342,11 +7299,7 @@ impl<R: Runtime> ForthVM<R> {
// Compile a word that pushes the address onto the DATA stack
let ir_body = vec![IrOp::PushI32(addr as i32)];
self.ir_bodies.insert(word_id, ir_body.clone());
let config = CodegenConfig {
base_fn_index: word_id.0,
table_size: self.table_size(),
stack_to_local_promotion: self.config.codegen.stack_to_local_promotion,
};
let config = self.codegen_config(word_id.0);
let compiled = compile_word(&name, &ir_body, &config)
.map_err(|e| anyhow::anyhow!("codegen error for FVARIABLE {name}: {e}"))?;
@@ -9081,6 +9034,76 @@ mod tests {
assert_eq!(vm.data_stack(), vec![1]);
}
#[test]
fn test_stack_guard_underflow_interpreted() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
let e = vm.evaluate("DROP").unwrap_err();
assert_eq!(e.to_string(), "Stack underflow (throw -4)");
// Stack machinery intact afterwards
vm.evaluate("7 .").unwrap();
assert_eq!(vm.take_output(), "7 ");
}
#[test]
fn test_stack_guard_underflow_in_compiled_word() {
// Straight-line word (promoted path) and mid-word underflow
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate(": D2 DROP DROP ;").unwrap();
let e = vm.evaluate("1 D2").unwrap_err();
assert_eq!(e.to_string(), "Stack underflow (throw -4)");
// The guard fires BEFORE corruption: the 1 must still be there
assert_eq!(vm.data_stack(), vec![1]);
// Non-promoted (contains a call): same guard. Fresh VM — the
// failed D2 call above deliberately left its stack untouched.
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate(": D3 DUP . DROP DROP ;").unwrap();
let e = vm.evaluate("2 D3").unwrap_err();
assert_eq!(e.to_string(), "Stack underflow (throw -4)");
}
#[test]
fn test_stack_guard_overflow_compiled_loop() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate(": FLOOD BEGIN 1 AGAIN ;").unwrap();
let e = vm.evaluate("FLOOD").unwrap_err();
assert_eq!(e.to_string(), "Stack overflow (throw -3)");
}
#[test]
fn test_stack_guard_float_underflow() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate(": FD FDROP ;").unwrap();
let e = vm.evaluate("FD").unwrap_err();
assert_eq!(e.to_string(), "Floating-point stack underflow (throw -45)");
}
#[test]
fn test_stack_guard_return_stack_underflow() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate(": RU R> DROP ;").unwrap();
// R> with nothing user-pushed underflows the return stack
let e = vm.evaluate("RU").unwrap_err();
assert_eq!(e.to_string(), "Return stack underflow (throw -6)");
}
#[test]
fn test_stack_guard_catchable() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
vm.evaluate(": BAD DROP ;").unwrap();
vm.evaluate("' BAD CATCH").unwrap();
assert_eq!(vm.data_stack(), vec![-4]);
}
#[test]
fn test_stack_guards_off_config() {
let mut cfg = crate::config::WaferConfig::all();
cfg.codegen.stack_guards = false;
let mut vm = ForthVM::<NativeRuntime>::new_with_config(cfg).unwrap();
// Compiled DROP underflows silently (documented unguarded mode)
vm.evaluate(": D1 DROP ;").unwrap();
assert!(vm.evaluate("D1").is_ok());
}
#[test]
fn test_uncaught_throw_has_message() {
let mut vm = ForthVM::<NativeRuntime>::new().unwrap();
+2
View File
@@ -93,6 +93,8 @@ fn find_sf64() -> Option<&'static str> {
/// Spawn `binary`, write `input` to its stdin, and collect the output.
fn run_via_stdin(binary: &str, input: &str) -> Option<std::process::Output> {
Command::new(binary)
// Perf lanes measure unguarded code (only the wafer binary reads this)
.env("WAFER_STACK_GUARDS", "0")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
+6 -1
View File
@@ -105,8 +105,13 @@ fn expected_load_failures(path: &str) -> u32 {
// TRAVERSE-WORDLIST / NAME>COMPILE / NAME>INTERPRET blocks leak as
// unknown-word errors. Fix the SOURCE/`>IN` interaction with
// line-mode input and drop this to 0.
//
// The 38th: line 368 `R> DROP TRUE` runs interpreted (its enclosing
// definition aborted on the missing NAME?), and the bare `R>` used
// to underflow the return stack silently; stack guards now report
// it as "Return stack underflow (throw -6)".
if path.ends_with("/toolstest.fth") {
return 37;
return 38;
}
0
}