diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 1060033..f2a6ac3 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -137,7 +137,8 @@ fn cmd_build( ) -> anyhow::Result<()> { let source = std::fs::read_to_string(file)?; - let mut vm = ForthVM::::new()?; + // Exported modules are production artifacts: no stack guards by default + let mut vm = ForthVM::::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::::new()?; + let mut vm = ForthVM::::new_with_config(vm_config(true))?; match file { Some(file) => { diff --git a/crates/core/src/codegen.rs b/crates/core/src/codegen.rs index 46d26cc..279a392 100644 --- a/crates/core/src/codegen.rs +++ b/crates/core/src/codegen.rs @@ -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, } /// 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> = const { std::cell::Cell::new(None) }; +} + +/// With guards on: emit `if { 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 { + // 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)], local_fn_map: &HashMap, table_size: u32, + stack_guards: Option, ) -> WaferResult> { - 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, table_size: u32, export: &ExportSections<'_>, + stack_guards: Option, ) -> WaferResult> { - 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, table_size: u32, export: Option<&ExportSections<'_>>, + stack_guards: Option, ) -> WaferResult> { + // 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); diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index ac270ac..a80e71c 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -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, }, } } diff --git a/crates/core/src/consolidate.rs b/crates/core/src/consolidate.rs index 39477e5..9c340bb 100644 --- a/crates/core/src/consolidate.rs +++ b/crates/core/src/consolidate.rs @@ -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()); } } diff --git a/crates/core/src/export.rs b/crates/core/src/export.rs index d18a28a..02988e4 100644 --- a/crates/core/src/export.rs +++ b/crates/core/src/export.rs @@ -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)) } diff --git a/crates/core/src/memory.rs b/crates/core/src/memory.rs index 1a36b1b..952bfa7 100644 --- a/crates/core/src/memory.rs +++ b/crates/core/src/memory.rs @@ -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 { diff --git a/crates/core/src/outer.rs b/crates/core/src/outer.rs index a70a302..2767c35 100644 --- a/crates/core/src/outer.rs +++ b/crates/core/src/outer.rs @@ -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 { marker_states: HashMap, /// EMPTY's rollback target: boot state, or wherever GILD re-baselined. gild_state: Option>, + /// 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>>, /// 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 ForthVM { 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 ForthVM { 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 ForthVM { 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 ForthVM { 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 ForthVM { // Primitive registration // ----------------------------------------------------------------------- + /// `_STACK_FAULT_` table index when stack guards are enabled. + pub(crate) fn stack_guard_param(&self) -> Option { + 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 ForthVM { // 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 ForthVM { 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 ForthVM { // 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 ForthVM { // 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 ForthVM { // 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 ForthVM { // 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 ForthVM { // 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 ForthVM { 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 ForthVM { // 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 ForthVM { .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 ForthVM { 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 ForthVM { } 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 ForthVM { } 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 ForthVM { // 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 ForthVM { // 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 ForthVM { 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 ForthVM { // 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 ForthVM { .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 ForthVM { .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 ForthVM { 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 ForthVM { 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 ForthVM { 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 ForthVM { // 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::::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::::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::::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::::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::::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::::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::::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::::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::::new().unwrap(); diff --git a/crates/core/tests/comparison.rs b/crates/core/tests/comparison.rs index 5736677..8501295 100644 --- a/crates/core/tests/comparison.rs +++ b/crates/core/tests/comparison.rs @@ -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 { 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()) diff --git a/crates/core/tests/compliance.rs b/crates/core/tests/compliance.rs index 6f2f373..95978be 100644 --- a/crates/core/tests/compliance.rs +++ b/crates/core/tests/compliance.rs @@ -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 }