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
+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();