diff --git a/crates/psrs-backend/src/linking/runtime_tests.rs b/crates/psrs-backend/src/linking/runtime_tests.rs index 9ee3741c..a70e25aa 100644 --- a/crates/psrs-backend/src/linking/runtime_tests.rs +++ b/crates/psrs-backend/src/linking/runtime_tests.rs @@ -215,6 +215,7 @@ fn fixture() -> mir::Module { }], result: value(10), result_type: ValueType::I32, + frame_weight: 0, span, }], entry: Some(symbol(20)), diff --git a/crates/psrs-backend/src/mir/budget.rs b/crates/psrs-backend/src/mir/budget.rs new file mode 100644 index 00000000..d9eaa289 --- /dev/null +++ b/crates/psrs-backend/src/mir/budget.rs @@ -0,0 +1,67 @@ +//! The stack-exhaustion budget's weight model (EH-12): the per-function frame +//! weight `W_f` and the profile-fixed constants the emitted budget check +//! compares against. These are the design's provisional calibration targets +//! (`docs/design/backend/fp/exception-handling-and-stack-exhaustion.md`, +//! "The stack-exhaustion budget"); the coordination fixture of EH-15 is the +//! protocol that validates them under the pinned engine, and this file's +//! documented values record the measured result of that protocol. + +/// The fixed part of every frame's estimated native stack weight, in weight +/// units. +pub const FRAME_WEIGHT_FIXED: i32 = 32; + +/// The per-local part of every frame's estimated native stack weight, in +/// weight units. `locals(f)` is the lowered function's parameter and local +/// value count; P10's list-copy and dispatcher locals are not known at P9 and +/// stay inside the headroom the budget reserves against the engine limit. +pub const FRAME_WEIGHT_PER_LOCAL: i32 = 16; + +/// The budget the emitted prologue check compares the depth global against, +/// in the same weight units. The counter `depth` accumulates the current +/// call stack's weights, so a frame whose prologue pushes `depth` past this +/// value throws the `psrs:stack-exhaustion` tag instead of consuming the +/// native stack the engine's `max_wasm_stack` limit would trap on. +/// +/// Calibration contract (EH-15): on the pinned wasmtime baseline with the +/// engine stack limit pinned for budget-dependent evidence, deep non-tail +/// recursion must produce the compiler's stack-exhaustion exception and never +/// the engine's `call stack exhausted` trap, and the corpus's deepest +/// *returning* recursion (TCOMutRec's depth-100 calls) must still return. The +/// value below is the measured outcome of that protocol for wasmtime 48.0.1 +/// (7bac2c277 2026-08-24) with `-W max-wasm-stack=524288`; see the checklist +/// record for the measurement. +pub const STACK_BUDGET: i32 = 262_144; + +/// The estimated native stack weight of one frame of a lowered function with +/// `locals` parameters and local values: +/// `W_f = FRAME_WEIGHT_FIXED + FRAME_WEIGHT_PER_LOCAL × locals`. +/// +/// Recorded on each MIR function as `Function::frame_weight` at P9; the +/// structuring emission (P10) reads it back for the prologue increment and +/// the epilogue/tail decrements. +pub fn frame_weight(locals: usize) -> u32 { + (FRAME_WEIGHT_FIXED + FRAME_WEIGHT_PER_LOCAL * (locals as i32)) as u32 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_weight_adds_a_fixed_weight_per_local() { + // The design's formula: fixed 32, 16 per local. + assert_eq!(frame_weight(0), FRAME_WEIGHT_FIXED as u32); + assert_eq!(frame_weight(3), 32 + 16 * 3); + assert_eq!( + frame_weight(12), + (FRAME_WEIGHT_FIXED + FRAME_WEIGHT_PER_LOCAL * 12) as u32 + ); + // The constants are the design's provisional targets. + assert_eq!(FRAME_WEIGHT_FIXED, 32); + assert_eq!(FRAME_WEIGHT_PER_LOCAL, 16); + // The budget leaves the depth-100 returning recursion of the corpus + // (TCOMutRec) inside the budget: its frames weigh well below + // `STACK_BUDGET` at depth 100. + assert!(frame_weight(16) as i64 * 100 < STACK_BUDGET as i64); + } +} diff --git a/crates/psrs-backend/src/mir/imports.rs b/crates/psrs-backend/src/mir/imports.rs new file mode 100644 index 00000000..c423c911 --- /dev/null +++ b/crates/psrs-backend/src/mir/imports.rs @@ -0,0 +1,55 @@ +//! The import symbols a lowered MIR module references: the one place P9 and +//! the optimizer consult shared call/list-copy reachability. +use crate::mir::{Function, Instruction, Terminator}; +use psrs_hir::SymbolId; +use std::collections::HashSet; + +/// Whether a list element carries byte-list payloads that need the string +/// boundary helpers. +pub fn element_has_bytes(element: &crate::abi::canonical::CanonicalType) -> bool { + use crate::abi::canonical::CanonicalType; + match element { + CanonicalType::String => true, + CanonicalType::List(inner) | CanonicalType::FixedList { element: inner, .. } => { + element_has_bytes(inner) + } + CanonicalType::Record(fields) => fields + .iter() + .any(|field| field.ty.is_byte_list() || element_has_bytes(&field.ty)), + CanonicalType::Option(inner) => element_has_bytes(inner), + CanonicalType::Result { ok, err } => { + ok.as_deref().is_some_and(element_has_bytes) + || err.as_deref().is_some_and(element_has_bytes) + } + CanonicalType::Variant(cases) => cases + .iter() + .any(|case| case.payload.as_deref().is_some_and(element_has_bytes)), + _ => false, + } +} + +/// The import symbols referenced by any call in the module. +pub fn referenced_imports(functions: &[Function]) -> HashSet { + let mut used = HashSet::new(); + for function in functions { + for block in &function.blocks { + for instruction in &block.instructions { + match instruction { + Instruction::Call { function, .. } | Instruction::CallVoid { function, .. } => { + used.insert(*function); + } + Instruction::ListCopy { element, .. } if element_has_bytes(element) => { + used.insert(crate::abi::STRING_TO_BYTES_SYMBOL); + used.insert(crate::abi::BYTES_TO_STRING_SYMBOL); + used.insert(crate::abi::REALLOC_SYMBOL); + } + _ => {} + } + } + if let Some(Terminator::ReturnCall { function, .. }) = &block.terminator { + used.insert(*function); + } + } + } + used +} diff --git a/crates/psrs-backend/src/mir/lower/mod.rs b/crates/psrs-backend/src/mir/lower/mod.rs index 8bdb48d7..58f0f30a 100644 --- a/crates/psrs-backend/src/mir/lower/mod.rs +++ b/crates/psrs-backend/src/mir/lower/mod.rs @@ -1,4 +1,5 @@ use super::BoundWasiImport; +use super::budget; use super::layout::{LayoutError, PlannedLayout}; use super::literals::StringLiterals; use super::wit; @@ -112,8 +113,12 @@ pub(super) fn lower_function( result_type: layout .value_type(&source.result_type) .map_err(|error| layout_error(source.span, error))?, + frame_weight: 0, span: source.span, }; + // EH-12: the weight formula reads the *lowered* function's local count — + // P9's lowering may add fresh values beyond the CC source's. + function.frame_weight = budget::frame_weight(function.parameters.len() + function.values.len()); for plan in &lowerer.wit_calls { plan.verify(&function)?; } diff --git a/crates/psrs-backend/src/mir/mod.rs b/crates/psrs-backend/src/mir/mod.rs index 34ebb0cc..59ed47fb 100644 --- a/crates/psrs-backend/src/mir/mod.rs +++ b/crates/psrs-backend/src/mir/mod.rs @@ -4,11 +4,13 @@ use crate::types::{FunctionId, RecGroup, ValueDecl, ValueId, ValueType}; use crate::{BackendError, annotate_errors, cc}; use psrs_hir::SymbolId; use psrs_span::TextRange; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; +pub mod budget; pub(crate) mod cfg; mod control; mod effects; +mod imports; mod instruction; pub(crate) mod layout; mod literals; @@ -28,6 +30,7 @@ use literals::StringLiterals; use lower::lower_function; use planner::{GcPlanner, RepresentationPlanner}; +pub use imports::{element_has_bytes, referenced_imports}; pub use instruction::{Instruction, ListDirection}; pub use numeric::{NumericOp, UnaryOp}; pub use runtime::RuntimeImport; @@ -109,6 +112,10 @@ pub struct Function { pub blocks: Vec, pub result: ValueId, pub result_type: ValueType, + /// The estimated native stack weight of one frame of this function + /// (`mir/budget.rs`), recorded at P9 and consumed by the structuring + /// emission of the stack-exhaustion budget. + pub frame_weight: u32, pub span: TextRange, } @@ -446,53 +453,3 @@ fn lower_module_after_binding_validation( verify_module_with_capabilities(&mir, target)?; Ok((mir, wasi)) } - -/// Whether a list element carries byte-list payloads that need the string -/// boundary helpers. -fn element_has_bytes(element: &crate::abi::canonical::CanonicalType) -> bool { - use crate::abi::canonical::CanonicalType; - match element { - CanonicalType::String => true, - CanonicalType::List(inner) | CanonicalType::FixedList { element: inner, .. } => { - element_has_bytes(inner) - } - CanonicalType::Record(fields) => fields - .iter() - .any(|field| field.ty.is_byte_list() || element_has_bytes(&field.ty)), - CanonicalType::Option(inner) => element_has_bytes(inner), - CanonicalType::Result { ok, err } => { - ok.as_deref().is_some_and(element_has_bytes) - || err.as_deref().is_some_and(element_has_bytes) - } - CanonicalType::Variant(cases) => cases - .iter() - .any(|case| case.payload.as_deref().is_some_and(element_has_bytes)), - _ => false, - } -} - -/// The import symbols referenced by any call in the module. -fn referenced_imports(functions: &[Function]) -> HashSet { - let mut used = HashSet::new(); - for function in functions { - for block in &function.blocks { - for instruction in &block.instructions { - match instruction { - Instruction::Call { function, .. } | Instruction::CallVoid { function, .. } => { - used.insert(*function); - } - Instruction::ListCopy { element, .. } if element_has_bytes(element) => { - used.insert(crate::abi::STRING_TO_BYTES_SYMBOL); - used.insert(crate::abi::BYTES_TO_STRING_SYMBOL); - used.insert(crate::abi::REALLOC_SYMBOL); - } - _ => {} - } - } - if let Some(Terminator::ReturnCall { function, .. }) = &block.terminator { - used.insert(*function); - } - } - } - used -} diff --git a/crates/psrs-backend/src/mir/number_format_tests.rs b/crates/psrs-backend/src/mir/number_format_tests.rs index 402b958c..2ed8eda3 100644 --- a/crates/psrs-backend/src/mir/number_format_tests.rs +++ b/crates/psrs-backend/src/mir/number_format_tests.rs @@ -115,6 +115,7 @@ fn number_format_module() -> Module { }], result: ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span: span(), }], span: span(), diff --git a/crates/psrs-backend/src/mir/opt/imports.rs b/crates/psrs-backend/src/mir/opt/imports.rs index 9afc6ca9..0fd38e97 100644 --- a/crates/psrs-backend/src/mir/opt/imports.rs +++ b/crates/psrs-backend/src/mir/opt/imports.rs @@ -135,6 +135,7 @@ mod tests { nullable: false, heap: HeapType::Index(DefinedTypeId(0)), }), + frame_weight: 0, span, }], entry: Some(entry), diff --git a/crates/psrs-backend/src/mir/opt/tests/constants.rs b/crates/psrs-backend/src/mir/opt/tests/constants.rs index 9555440f..feb3a85c 100644 --- a/crates/psrs-backend/src/mir/opt/tests/constants.rs +++ b/crates/psrs-backend/src/mir/opt/tests/constants.rs @@ -57,6 +57,7 @@ fn folds_total_arithmetic_but_keeps_an_unused_trapping_operation() { }], result: ValueId(2), result_type: ValueType::I32, + frame_weight: 0, span: span(), }; diff --git a/crates/psrs-backend/src/mir/opt/tests/control_flow.rs b/crates/psrs-backend/src/mir/opt/tests/control_flow.rs index cb2bfc4a..d36bfb82 100644 --- a/crates/psrs-backend/src/mir/opt/tests/control_flow.rs +++ b/crates/psrs-backend/src/mir/opt/tests/control_flow.rs @@ -79,6 +79,7 @@ fn folds_branch_merges_prunes_blocks_and_projects_imports() { ], result: ValueId(3), result_type: ValueType::I32, + frame_weight: 0, span: span(), }; let imports = vec![Import { @@ -200,6 +201,7 @@ fn preserves_switch_selectors_and_all_successors_through_p10() { ], result: ValueId(4), result_type: ValueType::I32, + frame_weight: 0, span: span(), }; diff --git a/crates/psrs-backend/src/mir/opt/tests/copy.rs b/crates/psrs-backend/src/mir/opt/tests/copy.rs index 2ef1fdae..ed4da81d 100644 --- a/crates/psrs-backend/src/mir/opt/tests/copy.rs +++ b/crates/psrs-backend/src/mir/opt/tests/copy.rs @@ -25,6 +25,7 @@ fn copy_forwarding_updates_the_structurer_result_value() { }], result: ValueId(1), result_type: ValueType::I32, + frame_weight: 0, span: span(), }; diff --git a/crates/psrs-backend/src/mir/opt/tests/inline.rs b/crates/psrs-backend/src/mir/opt/tests/inline.rs index 702dcc9b..6f1ae956 100644 --- a/crates/psrs-backend/src/mir/opt/tests/inline.rs +++ b/crates/psrs-backend/src/mir/opt/tests/inline.rs @@ -35,6 +35,7 @@ fn inlines_small_direct_functions_and_folds_the_result() { }], result: ValueId(1), result_type: ValueType::I32, + frame_weight: 0, span: span(), }; let callee = Function { @@ -72,6 +73,7 @@ fn inlines_small_direct_functions_and_folds_the_result() { }], result: ValueId(2), result_type: ValueType::I32, + frame_weight: 0, span: span(), }; @@ -130,6 +132,7 @@ fn inlining_uses_the_callee_function_result_value() { }], result: ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span: span(), }; let callee = Function { @@ -161,6 +164,7 @@ fn inlining_uses_the_callee_function_result_value() { }], result: ValueId(1), result_type: ValueType::I32, + frame_weight: 0, span: span(), }; diff --git a/crates/psrs-backend/src/mir/tests/exception/budget/budget_execution.rs b/crates/psrs-backend/src/mir/tests/exception/budget/budget_execution.rs new file mode 100644 index 00000000..9390a28c --- /dev/null +++ b/crates/psrs-backend/src/mir/tests/exception/budget/budget_execution.rs @@ -0,0 +1,366 @@ +//! The budget's runtime fixtures (EH-13, EH-14): deep tail recursion must not +//! fire the budget, and a throw/catch loop followed by deep non-tail +//! recursion must reach the budget point — the watermark contract's leak +//! fixture. Shared fixture builders live in the parent `budget` module. The +//! EH-15 coordination fixture (the CC `checkThrows` discharge) lives in +//! `coordination`. + +use super::*; + +fn run(mir: &crate::mir::Module) -> Option { + if !super::execution::require_engine() { + return None; + } + super::execution::run_output(mir, enabled_target()) +} + +fn boolean(value: u32, v: i32, span: TextRange) -> Instruction { + Instruction::Constant { + destination: ValueId(value), + value: v, + span, + } +} + +/// `main` of the leak fixture: a loop of guarded `thrower` calls — each +/// iteration throws and catches, which leaks the unwound frame's weight when +/// the watermark restore is missing — then a *shallow* guarded `rec(100)` +/// that must return (a leak fires the budget here, exit 2), then the deep +/// guarded `rec(DEEP)` whose catch is the expected exit 1. +fn leak_main() -> Function { + let blocks = vec![ + // The throw/catch loop. + block( + 0, + vec![], + vec![constant(20, 0, at(80))], + jump(1, vec![20], at(80)), + ), + block( + 1, + vec![0], + vec![ + constant(1, leak_iterations() as i32, at(80)), + primitive(2, NumericOp::I32LtS, 0, 1, at(80)), + ], + Terminator::Branch { + condition: ValueId(2), + then_block: BlockId(2), + else_block: BlockId(6), + span: at(80), + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Try { + protected: BlockId(3), + catches: Vec::new(), + catch_all: Some(BlockId(4)), + span: at(80), + }, + ), + block( + 3, + vec![], + vec![call(3, THROWER, vec![], at(80))], + jump(5, vec![0], at(80)), + ), + block(4, vec![], vec![], jump(5, vec![0], at(84))), + block( + 5, + vec![19], + vec![ + constant(4, 1, at(84)), + primitive(5, NumericOp::I32Add, 19, 4, at(84)), + ], + jump(1, vec![5], at(84)), + ), + // The shallow guarded recursion: must return normally (exit 2 means + // the counter leaked past the budget during the loop). + block( + 6, + vec![], + vec![], + Terminator::Try { + protected: BlockId(7), + catches: Vec::new(), + catch_all: Some(BlockId(8)), + span: at(84), + }, + ), + block( + 7, + vec![], + vec![ + constant(6, 100, at(84)), + call(7, REC, vec![6], at(84)), + boolean(8, 0, at(84)), + ], + jump(12, vec![8], at(84)), + ), + block( + 8, + vec![], + vec![boolean(9, 1, at(88))], + jump(12, vec![9], at(88)), + ), + // The deep guarded recursion: must fire the budget (exit 1); a + // program that never fires it exits 3, and an engine trap is a + // different failure entirely. + block( + 9, + vec![], + vec![], + Terminator::Try { + protected: BlockId(10), + catches: Vec::new(), + catch_all: Some(BlockId(11)), + span: at(88), + }, + ), + block( + 10, + vec![], + vec![ + constant(11, 100_000, at(88)), + call(12, REC, vec![11], at(88)), + boolean(13, 0, at(88)), + ], + jump(15, vec![13], at(88)), + ), + block( + 11, + vec![], + vec![boolean(14, 1, at(91))], + jump(15, vec![14], at(91)), + ), + block( + 12, + vec![17], + vec![], + Terminator::Branch { + condition: ValueId(17), + then_block: BlockId(16), + else_block: BlockId(9), + span: at(91), + }, + ), + block( + 16, + vec![], + vec![constant(10, 2, at(96))], + return_of(10, at(96)), + ), + block( + 15, + vec![18], + vec![], + Terminator::Branch { + condition: ValueId(18), + then_block: BlockId(17), + else_block: BlockId(19), + span: at(96), + }, + ), + block( + 17, + vec![], + vec![constant(15, 1, at(97))], + return_of(15, at(97)), + ), + block( + 19, + vec![], + vec![constant(16, 3, at(99))], + return_of(16, at(99)), + ), + ]; + Function { + id: FunctionId(MAIN as u32), + symbol: symbol(MAIN), + name: "main".into(), + parameters: Vec::new(), + values: (0..=20u32) + .map(|id| ValueDecl { + id: ValueId(id), + ty: if matches!(id, 2 | 8 | 9 | 13 | 14 | 17 | 18) { + ValueType::Boolean + } else { + ValueType::I32 + }, + }) + .collect(), + entry: BlockId(0), + blocks, + result: ValueId(0), + result_type: ValueType::I32, + frame_weight: budget::frame_weight(20), + span: at(905), + } +} + +fn return_of(value: u32, span: TextRange) -> Terminator { + Terminator::Return { + value: ValueId(value), + span, + } +} + +/// Enough throw/catch iterations that a *missing* watermark restore — which +/// leaks the thrower's frame weight per iteration — pushes the counter past +/// `STACK_BUDGET`, firing the budget on the fixture's shallow call; with the +/// restore, the counter is settled each iteration and the shallow call runs. +fn leak_iterations() -> usize { + let leak_per_iteration = budget::frame_weight(1) as usize; + 4 * (budget::STACK_BUDGET as usize / leak_per_iteration + 1) +} + +/// EH-13: deep tail recursion still exits 0 — the `return_call` decrements +/// keep the counter bounded, so the budget never fires and no false throw +/// replaces the normal result. +#[test] +fn deep_tail_recursion_exits_0_without_a_false_throw() { + let span = at(85); + let main = Function { + id: FunctionId(MAIN as u32), + symbol: symbol(MAIN), + name: "main".into(), + parameters: Vec::new(), + values: vec![ + ValueDecl { + id: ValueId(0), + ty: ValueType::I32, + }, + ValueDecl { + id: ValueId(1), + ty: ValueType::I32, + }, + ], + entry: BlockId(0), + blocks: vec![block( + 0, + vec![], + vec![constant(0, 1_000_000, span), call(1, EVEN, vec![0], span)], + return_of(1, span), + )], + result: ValueId(1), + result_type: ValueType::I32, + frame_weight: budget::frame_weight(2), + span: at(906), + }; + let mir = module( + vec![main, rec_function(), even_function(), odd_function()], + Vec::new(), + MAIN, + ); + let Some(output) = run(&mir) else { + return; + }; + assert_eq!( + output.status.code(), + Some(0), + "deep tail recursion must return normally: {output:?}" + ); + assert!( + !String::from_utf8_lossy(&output.stderr).contains("call stack exhausted"), + "the budget must not misfire on tail recursion: {output:?}" + ); +} + +pub(super) const EVEN: usize = 2; +pub(super) const ODD: usize = 3; + +/// `parity(n) = if n == 0 then base else parity(n - 1)` — a `return_call`, so +/// the frames replace each other and the counter stays bounded. +fn tail_function(index: usize, name: &str, other: usize, base: i32) -> Function { + let span = at(88); + let blocks = vec![ + block( + 0, + vec![], + vec![ + constant(1, 0, span), + primitive(2, NumericOp::I32Eq, 0, 1, span), + ], + Terminator::Branch { + condition: ValueId(2), + then_block: BlockId(1), + else_block: BlockId(2), + span, + }, + ), + block(1, vec![], vec![constant(3, base, span)], return_of(3, span)), + block( + 2, + vec![], + vec![ + constant(4, 1, span), + primitive(5, NumericOp::I32Sub, 0, 4, span), + ], + Terminator::ReturnCall { + contract: None, + function: symbol(other), + arguments: vec![ValueId(5)], + span, + }, + ), + ]; + Function { + id: FunctionId(index as u32), + symbol: symbol(index), + name: name.into(), + parameters: vec![ValueId(0)], + values: (0..=5u32) + .map(|id| ValueDecl { + id: ValueId(id), + ty: if id == 2 { + ValueType::Boolean + } else { + ValueType::I32 + }, + }) + .collect(), + entry: BlockId(0), + blocks, + result: ValueId(3), + result_type: ValueType::I32, + frame_weight: budget::frame_weight(6), + span: at(907), + } +} + +fn even_function() -> Function { + tail_function(EVEN, "even", ODD, 0) +} + +fn odd_function() -> Function { + tail_function(ODD, "odd", EVEN, 0) +} + +/// EH-14: the leak fixture. Without the watermark restore, the loop leaks the +/// thrower's frame weight per iteration past `STACK_BUDGET`, the shallow +/// guard fires, and the fixture exits 2; with the restore the shallow guard +/// returns and the deep guard reaches the budget point, exiting 1. +#[test] +fn a_throw_catch_loop_does_not_leak_the_depth_counter() { + let main = leak_main(); + let mir = module( + vec![main, rec_function(), thrower_function()], + vec![exhaustion_tag()], + MAIN, + ); + let Some(output) = run(&mir) else { + return; + }; + assert_eq!( + output.status.code(), + Some(1), + "the deep recursion must reach the budget point after the loop: {output:?}" + ); + assert!( + !String::from_utf8_lossy(&output.stderr).contains("call stack exhausted"), + "the budget must fire before the engine's trap: {output:?}" + ); +} diff --git a/crates/psrs-backend/src/mir/tests/exception/budget/coordination.rs b/crates/psrs-backend/src/mir/tests/exception/budget/coordination.rs new file mode 100644 index 00000000..ee9abd9c --- /dev/null +++ b/crates/psrs-backend/src/mir/tests/exception/budget/coordination.rs @@ -0,0 +1,302 @@ +//! EH-15: the budget/limit coordination fixture — the protocol that validates +//! the budget, weight, and headroom constants. With the engine stack limit +//! pinned, deep non-tail recursion under the `checkThrows` discharge yields +//! the compiler's stack-exhaustion exception and never the engine's +//! `call stack exhausted` trap. A trap here means the constants are wrong. + +use super::*; +use crate::bindings::{CHECK_THROWS_EXPORT, ExternalBindings, RUNTIME_EXCEPTION_MODULE}; +use crate::cc::{ + Assignment, AssignmentKind, BinaryOp, External, Function as CcFunction, Module as CcModule, + RefShape, Reference, Signature, UnaryOp as CcUnaryOp, +}; +use crate::mir::verify_module_with_capabilities; +use psrs_hir::FOREIGN_SYMBOL_BASE; + +fn enabled_target() -> TargetCapabilities { + TargetCapabilities { + exceptions: true, + ..TargetCapabilities::default() + } +} + +/// Builds the CC module: `main` discharges `checkThrows` around a callable +/// that applies the genuinely non-tail self-recursive `rec` at `depth`. +pub(super) fn module(depth: i32) -> (CcModule, ExternalBindings) { + let mut representations = crate::cc::RepresentationTable::default(); + let callable = representations.add_signature(Signature { + parameters: vec![crate::cc::ValueShape::Integer], + result: crate::cc::ValueShape::Integer, + }); + let callable_shape = crate::cc::ValueShape::Reference(Reference { + nullable: false, + heap: RefShape::Closure(callable), + }); + let receiver = crate::cc::ValueShape::Reference(Reference { + nullable: false, + heap: RefShape::Aggregate, + }); + let check_throws = SymbolId::new(ModuleId(0), FOREIGN_SYMBOL_BASE); + let apply = SymbolId::new(ModuleId(0), FOREIGN_SYMBOL_BASE + 1); + let rec = SymbolId::new(ModuleId(0), FOREIGN_SYMBOL_BASE + 2); + let span = at(0); + let cc = CcModule { + name: "StackExhaustionCoordination".into(), + externals: vec![External { + projection: None, + symbol: check_throws, + signature: Some(Signature { + parameters: vec![callable_shape], + result: crate::cc::ValueShape::Boolean, + }), + }], + representations, + functions: vec![ + // main: the guard around the deep call; its Int result is the + // process exit code (0 = returned normally, 1 = caught). + CcFunction { + symbol: check_throws, + name: "main".into(), + parameters: Vec::new(), + values: vec![ + crate::cc::ValueDecl { + id: ValueId(0), + ty: callable_shape, + }, + crate::cc::ValueDecl { + id: ValueId(1), + ty: crate::cc::ValueShape::Boolean, + }, + crate::cc::ValueDecl { + id: ValueId(2), + ty: crate::cc::ValueShape::Integer, + }, + ], + assignments: vec![ + Assignment { + destination: ValueId(0), + kind: AssignmentKind::FunctionRef { + function: apply, + signature: callable, + captures: Vec::new(), + }, + span, + }, + Assignment { + destination: ValueId(1), + kind: AssignmentKind::DirectCall { + contract: None, + function: check_throws, + arguments: vec![ValueId(0)], + }, + span, + }, + Assignment { + destination: ValueId(2), + kind: AssignmentKind::Unary { + op: CcUnaryOp::BooleanToInt, + value: ValueId(1), + }, + span, + }, + ], + result: ValueId(2), + result_type: crate::cc::ValueShape::Integer, + span, + }, + // The guarded callable: applies `rec` at the fixed depth. + CcFunction { + symbol: apply, + name: "apply".into(), + parameters: vec![ValueId(3), ValueId(4)], + values: vec![ + crate::cc::ValueDecl { + id: ValueId(3), + ty: receiver, + }, + crate::cc::ValueDecl { + id: ValueId(4), + ty: crate::cc::ValueShape::Integer, + }, + crate::cc::ValueDecl { + id: ValueId(5), + ty: crate::cc::ValueShape::Integer, + }, + crate::cc::ValueDecl { + id: ValueId(6), + ty: crate::cc::ValueShape::Integer, + }, + ], + assignments: vec![ + Assignment { + destination: ValueId(5), + kind: AssignmentKind::Constant(depth), + span, + }, + Assignment { + destination: ValueId(6), + kind: AssignmentKind::DirectCall { + contract: None, + function: rec, + arguments: vec![ValueId(5)], + }, + span, + }, + ], + result: ValueId(6), + result_type: crate::cc::ValueShape::Integer, + span, + }, + // `rec(n) = if n <= 0 then 0 else 1 + rec(n - 1)`: the `1 +` + // after the self call forces a real frame per level, so P9's + // tail marking cannot convert it and every level consumes both + // the estimated weight and the real native stack. + CcFunction { + symbol: rec, + name: "rec".into(), + parameters: vec![ValueId(10)], + values: (10..=17) + .map(|id| crate::cc::ValueDecl { + id: ValueId(id), + ty: if id == 12 { + crate::cc::ValueShape::Boolean + } else { + crate::cc::ValueShape::Integer + }, + }) + .collect(), + assignments: vec![ + Assignment { + destination: ValueId(11), + kind: AssignmentKind::Constant(0), + span, + }, + Assignment { + destination: ValueId(12), + kind: AssignmentKind::Primitive { + op: BinaryOp::IntLe, + left: ValueId(10), + right: ValueId(11), + }, + span, + }, + Assignment { + destination: ValueId(17), + kind: AssignmentKind::If { + condition: ValueId(12), + then_assignments: Vec::new(), + then_value: ValueId(11), + else_assignments: vec![ + Assignment { + destination: ValueId(13), + kind: AssignmentKind::Constant(1), + span, + }, + Assignment { + destination: ValueId(14), + kind: AssignmentKind::Primitive { + op: BinaryOp::IntSub, + left: ValueId(10), + right: ValueId(13), + }, + span, + }, + Assignment { + destination: ValueId(15), + kind: AssignmentKind::DirectCall { + contract: None, + function: rec, + arguments: vec![ValueId(14)], + }, + span, + }, + Assignment { + destination: ValueId(16), + kind: AssignmentKind::Primitive { + op: BinaryOp::IntAdd, + left: ValueId(15), + right: ValueId(13), + }, + span, + }, + ], + else_value: ValueId(16), + }, + span, + }, + ], + result: ValueId(17), + result_type: crate::cc::ValueShape::Integer, + span, + }, + ], + entry: Some(check_throws), + span, + }; + let binding = crate::bindings::RuntimeBinding { + symbol: check_throws, + source_module: ModuleId(0), + module: RUNTIME_EXCEPTION_MODULE.into(), + function: CHECK_THROWS_EXPORT.into(), + type_id: None, + span: at(300), + }; + ( + cc, + ExternalBindings { + imports: Vec::new(), + runtime: vec![binding], + }, + ) +} + +fn run(depth: i32) -> Option { + if !super::execution::require_engine() { + return None; + } + let (cc, bindings) = module(depth); + let (mir, _) = crate::mir::lower_module_with_bindings(cc, bindings, enabled_target()) + .expect("the discharge lowers"); + verify_module_with_capabilities(&mir, enabled_target()).expect("the module verifies"); + super::execution::run_output(&mir, enabled_target()) +} + +/// The coordination invariant: deep non-tail recursion under the discharged +/// `checkThrows` yields the compiler's stack-exhaustion exception — the guard +/// catches it and the program exits with the handler's value — never the +/// engine's `call stack exhausted` trap. +#[test] +fn deep_non_tail_recursion_yields_the_compiler_s_exception_never_a_trap() { + let Some(output) = run(100_000) else { + return; + }; + let stderr = String::from_utf8_lossy(&output.stderr); + assert_eq!( + output.status.code(), + Some(1), + "the guard must catch the compiler's stack-exhaustion throw: {output:?}" + ); + assert!( + !stderr.contains("call stack exhausted"), + "the engine's trap must never surface: {output:?}" + ); + assert!( + !stderr.contains("unreachable"), + "no guest trap may surface either: {output:?}" + ); +} + +/// The same program at the corpus's returning depth (TCOMutRec's depth-100 +/// shape): the recursion must return and `checkThrows` must report false — +/// the budget must not fire grossly early. +#[test] +fn depth_100_recursion_still_returns_under_the_pinned_budget() { + let Some(output) = run(100) else { + return; + }; + assert_eq!( + output.status.code(), + Some(0), + "the depth-100 recursion must return normally: {output:?}" + ); +} diff --git a/crates/psrs-backend/src/mir/tests/exception/budget/mod.rs b/crates/psrs-backend/src/mir/tests/exception/budget/mod.rs new file mode 100644 index 00000000..33c51170 --- /dev/null +++ b/crates/psrs-backend/src/mir/tests/exception/budget/mod.rs @@ -0,0 +1,488 @@ +//! EH-12/EH-13/EH-14 local inspection: the recorded frame weight, the budget +//! prologue/epilogue emission, and the watermark save/restore, inspected on +//! the lowered thin Wasm IR and its WAT. The runtime fixtures (tail +//! recursion, the leak fixture, the coordination fixture) live in +//! `budget_execution`. + +mod budget_execution; +mod coordination; +mod watermark; + +use super::*; +use crate::TargetCapabilities; +use crate::mir::NumericOp; +use crate::mir::budget; +use crate::mir::verify_module_with_capabilities; +use psrs_hir::SymbolId; + +pub(super) fn enabled_target() -> TargetCapabilities { + TargetCapabilities { + exceptions: true, + ..TargetCapabilities::default() + } +} + +/// The symbols the shared fixtures use: main (entry), the non-tail recursive +/// `rec`, and the immediate `thrower`. +pub(super) const MAIN: usize = 0; +pub(super) const REC: usize = 1; +pub(super) const THROWER: usize = 2; + +pub(super) fn symbol(index: usize) -> SymbolId { + SymbolId::new(ModuleId(0), index as u32) +} + +pub(super) fn at(offset: u32) -> TextRange { + TextRange::new(offset, offset + 1) +} + +pub(super) fn constant(destination: u32, value: i32, span: TextRange) -> Instruction { + Instruction::Constant { + destination: ValueId(destination), + value, + span, + } +} + +pub(super) fn primitive( + destination: u32, + op: NumericOp, + left: u32, + right: u32, + span: TextRange, +) -> Instruction { + Instruction::Primitive { + destination: ValueId(destination), + op, + left: ValueId(left), + right: ValueId(right), + span, + } +} + +pub(super) fn call( + destination: u32, + function: usize, + arguments: Vec, + span: TextRange, +) -> Instruction { + Instruction::Call { + contract: None, + destination: ValueId(destination), + function: symbol(function), + arguments: arguments.into_iter().map(ValueId).collect(), + span, + } +} + +pub(super) fn jump(target: u32, arguments: Vec, span: TextRange) -> Terminator { + Terminator::Jump { + target: BlockId(target), + arguments: arguments.into_iter().map(ValueId).collect(), + span, + } +} + +pub(super) fn block( + id: u32, + parameters: Vec, + instructions: Vec, + terminator: Terminator, +) -> crate::mir::BasicBlock { + crate::mir::BasicBlock { + id: BlockId(id), + parameters: parameters.into_iter().map(ValueId).collect(), + instructions, + terminator: Some(terminator), + } +} + +fn values(types: &[(u32, ValueType)]) -> Vec { + types + .iter() + .map(|(id, ty)| ValueDecl { + id: ValueId(*id), + ty: *ty, + }) + .collect() +} + +/// Builds a multi-function module; `entry` is the entry function's index. +pub(super) fn module(functions: Vec, tags: Vec, entry: usize) -> crate::mir::Module { + crate::mir::Module { + name: "BudgetFixture".into(), + types: Vec::new(), + strings: Vec::new(), + tags, + layout: None, + imports: Vec::new(), + entry: Some(functions[entry].symbol), + functions, + span: at(950), + } +} + +fn function( + index: usize, + name: &str, + parameters: Vec, + value_types: Vec<(u32, ValueType)>, + result: u32, + blocks: Vec, +) -> Function { + Function { + id: FunctionId(index as u32), + symbol: symbol(index), + name: name.into(), + parameters: parameters.into_iter().map(ValueId).collect(), + values: values(&value_types), + entry: BlockId(0), + blocks, + result: ValueId(result), + result_type: ValueType::I32, + frame_weight: 0, + span: at(900 + index as u32), + } +} + +/// `rec(n) = if n <= 0 then 0 else 1 + rec(n - 1)` — genuine non-tail +/// recursion: the `1 +` after the call forces a real frame per level, and P9 +/// tail marking (not run on hand-built MIR) would not convert it either. +pub(super) fn rec_function() -> Function { + let blocks = vec![ + block( + 0, + vec![], + vec![ + constant(1, 0, at(60)), + primitive(2, NumericOp::I32LtS, 0, 1, at(60)), + ], + Terminator::Branch { + condition: ValueId(2), + then_block: BlockId(1), + else_block: BlockId(2), + span: at(60), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Return { + value: ValueId(1), + span: at(61), + }, + ), + block( + 2, + vec![], + vec![ + constant(3, 1, at(62)), + primitive(4, NumericOp::I32Sub, 0, 3, at(62)), + call(5, REC, vec![4], at(62)), + primitive(6, NumericOp::I32Add, 5, 3, at(62)), + ], + Terminator::Return { + value: ValueId(6), + span: at(62), + }, + ), + ]; + let value_types = vec![ + (0, ValueType::I32), + (1, ValueType::I32), + (2, ValueType::Boolean), + (3, ValueType::I32), + (4, ValueType::I32), + (5, ValueType::I32), + (6, ValueType::I32), + ]; + let mut rec = function(REC, "rec", vec![0], value_types, 6, blocks); + rec.frame_weight = budget::frame_weight(8); + rec +} + +/// An immediate payload-less throw of the stack-exhaustion tag. +pub(super) fn thrower_function() -> Function { + let blocks = vec![block( + 0, + vec![], + vec![], + Terminator::Throw { + tag: TagId(0), + arguments: Vec::new(), + handler: None, + span: at(70), + }, + )]; + function( + THROWER, + "thrower", + vec![], + vec![(0, ValueType::I32)], + 0, + blocks, + ) +} + +/// The stack-exhaustion tag as a MIR tag: empty payload, `TagId(0)`. +pub(super) fn exhaustion_tag() -> Tag { + Tag { + name: "psrs:stack-exhaustion".into(), + payload: Vec::new(), + span: at(940), + } +} + +/// Verifies and lowers the module to thin Wasm with the given target. +pub(super) fn lower(mir: &crate::mir::Module, target: TargetCapabilities) -> crate::wasm::Module { + verify_module_with_capabilities(mir, target).expect("the fixture verifies"); + let mut wasi = crate::abi::WasiRegistry::load_with_capabilities(target) + .expect("the vendored WASI WIT should load"); + let context = crate::linking::default_context().expect("default target context"); + let link = crate::linking::plan_for_module(&context, mir, &mut wasi, target) + .expect("checked target plan"); + crate::wasm::lower_module_with_plan(mir, &mut wasi, target, &link) + .expect("the MIR should lower to Wasm") +} + +pub(super) fn wat(wasm: &crate::wasm::Module) -> String { + let binary = crate::wasm::encode_module(wasm).expect("encoding Wasm"); + wasmprinter::print_bytes(&binary).expect("the module should print") +} + +// ------------------------------------------------------------------ EH-12 + +/// P9 records `frame_weight = FRAME_WEIGHT_FIXED + FRAME_WEIGHT_PER_LOCAL × +/// (parameters + values)` on every lowered function. +#[test] +fn records_the_frame_weight_on_every_lowered_function() { + let (cc, bindings) = coordination::module(0); + let (mir, _) = crate::mir::lower_module_with_bindings(cc, bindings, enabled_target()) + .expect("the module lowers"); + for function in &mir.functions { + let expected = budget::frame_weight(function.parameters.len() + function.values.len()); + assert_eq!(function.frame_weight, expected, "{}", function.name); + assert!(function.frame_weight > 0, "{}", function.name); + } +} + +// ------------------------------------------------------------------ EH-13 + +/// A try-free function with a trapping block, an ordinary return, and a tail +/// call: the prologue sits before the first structured block, the decrement +/// runs on the return and tail paths, and the trapping block carries none. +#[test] +fn emits_the_prologue_and_the_return_and_tail_decrements() { + // main(): n = -1, so the branch takes the trap arm; the tail-call path + // still carries its decrement in the emitted body. The entry function + // must be a zero-argument Int declaration. + let main = function( + MAIN, + "main", + vec![], + vec![ + (0, ValueType::I32), + (1, ValueType::I32), + (2, ValueType::Boolean), + (3, ValueType::I32), + ], + 0, + vec![ + block( + 0, + vec![], + vec![ + constant(0, -1, at(40)), + constant(1, 0, at(40)), + primitive(2, NumericOp::I32LtS, 0, 1, at(40)), + ], + Terminator::Branch { + condition: ValueId(2), + then_block: BlockId(1), + else_block: BlockId(2), + span: at(40), + }, + ), + block(1, vec![], vec![], Terminator::Trap { span: at(41) }), + block( + 2, + vec![], + vec![primitive(3, NumericOp::I32Sub, 0, 1, at(42))], + Terminator::ReturnCall { + contract: None, + function: symbol(REC), + arguments: vec![ValueId(3)], + span: at(42), + }, + ), + ], + ); + let mir = module( + vec![main, rec_function(), thrower_function()], + vec![exhaustion_tag()], + MAIN, + ); + let wasm = lower(&mir, enabled_target()); + let wat = wat(&wasm); + // The module's WAT holds every function; this test inspects the first + // defined function's text (functions[0] is encoded first; the `(func` + // occurrences at line starts mark the defined functions after the + // import). + let starts: Vec = wat + .match_indices("\n (func") + .map(|(index, _)| index) + .collect(); + assert!(starts.len() >= 2, "{wat}"); + let wat = &wat[starts[0]..starts[1]]; + + // The prologue: exactly one overflow check, before the function's first + // structured block. + assert_eq!(wat.matches("i32.gt_s").count(), 1, "{wat}"); + let prologue = wat.find("i32.gt_s").expect("the prologue check"); + let if_open = wat[prologue..].find("if").expect("the overflow if") + prologue; + let throw = wat[if_open..].find("throw").expect("the overflow throw") + if_open; + let first_block = wat.find("block ;; label").expect("the first block"); + assert!(prologue < first_block, "{wat}"); + assert!(throw < first_block, "{wat}"); + + // The decrements, counted on the thin IR so rec's own `i32.sub` + // arithmetic cannot confuse the count: main's single tail-call site and + // rec's two return paths each carry one. The trapping block carries + // none — if it did, main's count would be two. + assert_eq!(count_decrements(&wasm.functions[0].body), 1, "{wat}"); + assert_eq!(count_decrements(&wasm.functions[1].body), 2, "{wat}"); + // The tail decrement sits between the prologue and the tail call. + let tail = wat.find("return_call").expect("the tail call"); + let between = &wat[first_block..tail]; + assert!( + between.contains("i32.sub") && between.contains("global.set"), + "{wat}" + ); +} + +/// Counts `[global.get, i32.const, i32.sub, global.set]` sequences — the +/// budget decrement — anywhere in a structured body. +fn count_decrements(body: &[crate::wasm::Op]) -> usize { + use wasm_encoder::Instruction; + let count = body + .windows(4) + .filter(|w| { + matches!( + (&w[0], &w[1], &w[2], &w[3]), + ( + crate::wasm::Op::Leaf(Instruction::GlobalGet(_)), + crate::wasm::Op::Leaf(Instruction::I32Const(_)), + crate::wasm::Op::Leaf(Instruction::I32Sub), + crate::wasm::Op::Leaf(Instruction::GlobalSet(_)) + ) + ) + }) + .count(); + count + + body + .iter() + .map(|op| match op { + crate::wasm::Op::Block { body, .. } | crate::wasm::Op::Loop { body, .. } => { + count_decrements(body) + } + crate::wasm::Op::Try { body, .. } => count_decrements(body), + crate::wasm::Op::If { + then_body, + else_body, + .. + } => count_decrements(then_body) + count_decrements(else_body), + _ => 0, + }) + .sum::() +} + +// -------------------------------------------------------- disabled profile + +/// The disabled profile emits none of the budget: no depth global, no +/// stack-exhaustion tag, no prologue check, no decrements — the try-free +/// bodies are unchanged. (A module with a tag or a `Throw` is rejected at the +/// MIR capability gate under this profile, so the fixture carries none.) +#[test] +fn the_disabled_profile_emits_no_budget_code() { + let main = function( + MAIN, + "main", + vec![], + vec![ + (0, ValueType::I32), + (1, ValueType::I32), + (2, ValueType::Boolean), + (3, ValueType::I32), + ], + 0, + vec![ + block( + 0, + vec![], + vec![ + constant(0, -1, at(40)), + constant(1, 0, at(40)), + primitive(2, NumericOp::I32LtS, 0, 1, at(40)), + ], + Terminator::Branch { + condition: ValueId(2), + then_block: BlockId(1), + else_block: BlockId(2), + span: at(40), + }, + ), + block(1, vec![], vec![], Terminator::Trap { span: at(41) }), + block( + 2, + vec![], + vec![primitive(3, NumericOp::I32Sub, 0, 1, at(42))], + Terminator::ReturnCall { + contract: None, + function: symbol(REC), + arguments: vec![ValueId(3)], + span: at(42), + }, + ), + ], + ); + let mir = module(vec![main, rec_function()], Vec::new(), MAIN); + let wasm = lower(&mir, TargetCapabilities::default()); + assert!(wasm.globals.is_empty(), "no depth global"); + assert!(wasm.tags.is_empty(), "no stack-exhaustion tag"); + let wat = wat(&wasm); + assert!(!wat.contains("throw"), "{wat}"); + assert!(!wat.contains("i32.gt_s"), "{wat}"); + // rec's only `i32.sub` is its own arithmetic, not the epilogue decrement; + // no `global.set` occurs in any body. + assert!(!wat.contains("global.set"), "{wat}"); +} + +/// The enabled profile installs the budget even on a module with no MIR tag +/// of its own: the compiler-defined tag and the depth global appear. +#[test] +fn the_enabled_profile_adds_the_depth_global_and_tag() { + let main = function( + MAIN, + "main", + vec![], + vec![(0, ValueType::I32)], + 0, + vec![block( + 0, + vec![], + vec![constant(0, 7, at(50))], + Terminator::Return { + value: ValueId(0), + span: at(50), + }, + )], + ); + let mir = module(vec![main], Vec::new(), MAIN); + let wasm = lower(&mir, enabled_target()); + assert_eq!(wasm.globals.len(), 1, "the depth global"); + assert_eq!(wasm.globals[0].index, crate::wasm::GlobalIndex(0)); + assert!(wasm.globals[0].mutable); + assert_eq!(wasm.globals[0].ty, wasm_encoder::ValType::I32); + assert_eq!(wasm.tags.len(), 1, "the stack-exhaustion tag"); +} diff --git a/crates/psrs-backend/src/mir/tests/exception/budget/watermark.rs b/crates/psrs-backend/src/mir/tests/exception/budget/watermark.rs new file mode 100644 index 00000000..1e4b65c0 --- /dev/null +++ b/crates/psrs-backend/src/mir/tests/exception/budget/watermark.rs @@ -0,0 +1,148 @@ +//! EH-14: every emitted `try_table` is immediately preceded by the +//! watermark save into the try region's fresh `i32` local, and its catch +//! path — the code right after the enclosing unwind block's `end` — begins +//! with the restore. Shared builders live in the parent `budget` module. + +use super::*; + +// ------------------------------------------------------------------ EH-14 + +/// Every `try_table` in the lowered module is immediately preceded by the +/// watermark save (`global.get depth; local.set saved`) and its catch path — +/// the code right after the enclosing unwind block's `end` — begins with the +/// restore (`local.get saved; global.set depth`). +#[test] +fn every_try_table_carries_the_watermark_save_and_restore() { + // One nested-try function: outer try over an inner try, the throw caught + // by the inner handler — two try regions, two watermark locals. + fn try_blocks(protected: BlockId, catch: BlockId) -> Terminator { + Terminator::Try { + protected, + catches: Vec::new(), + catch_all: Some(catch), + span: at(10), + } + } + let main = function( + MAIN, + "main", + vec![], + vec![ + (0, ValueType::I32), + (1, ValueType::I32), + (2, ValueType::I32), + ], + 0, + vec![ + block(0, vec![], vec![], try_blocks(BlockId(1), BlockId(8))), + block(1, vec![], vec![], try_blocks(BlockId(2), BlockId(3))), + block( + 2, + vec![], + vec![], + Terminator::Throw { + tag: TagId(0), + arguments: Vec::new(), + handler: Some(BlockId(3)), + span: at(12), + }, + ), + block(3, vec![], vec![], Terminator::Trap { span: at(13) }), + block( + 8, + vec![], + vec![constant(1, 20, at(14))], + jump(9, vec![1], at(14)), + ), + block( + 9, + vec![(0)], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(15), + }, + ), + ], + ); + let mir = module(vec![main], vec![exhaustion_tag()], MAIN); + let wasm = lower(&mir, enabled_target()); + let wat = wat(&wasm); + + // Two try regions in the function, plus the entry's synthesized + // `catch_all` region: three `try_table`s in the module. + assert_eq!(wat.matches("try_table").count(), 3, "{wat}"); + // The body walk checks the save/restore adjacency precisely on the thin + // IR, per try region, in emission order. + let function = &wasm.functions[0]; + let saves = count_watermark_saves(&function.body); + let restores = count_watermark_restores(&function.body); + assert_eq!(saves, 2, "one save per try region: {wat}"); + assert_eq!(restores, 2, "one restore per catch path: {wat}"); + + // The entry's synthesized region carries its own save (at entry) and its + // catch path begins with the restore. + let entry = wasm.entry.as_ref().expect("the run entry"); + assert_eq!(count_watermark_saves(&entry.body), 1, "{wat}"); + assert_eq!(count_watermark_restores(&entry.body), 1, "{wat}"); +} + +fn count_watermark_saves(body: &[crate::wasm::Op]) -> usize { + use wasm_encoder::Instruction; + body.windows(2) + .filter(|window| { + matches!( + (&window[0], &window[1]), + ( + crate::wasm::Op::Leaf(Instruction::GlobalGet(_)), + crate::wasm::Op::Leaf(Instruction::LocalSet(_)) + ) + ) + }) + .count() + + body + .iter() + .map(|op| match op { + crate::wasm::Op::Block { body, .. } | crate::wasm::Op::Loop { body, .. } => { + count_watermark_saves(body) + } + crate::wasm::Op::Try { body, .. } => count_watermark_saves(body), + crate::wasm::Op::If { + then_body, + else_body, + .. + } => count_watermark_saves(then_body) + count_watermark_saves(else_body), + _ => 0, + }) + .sum::() +} + +fn count_watermark_restores(body: &[crate::wasm::Op]) -> usize { + use wasm_encoder::Instruction; + body.windows(2) + .filter(|window| { + matches!( + (&window[0], &window[1]), + ( + crate::wasm::Op::Leaf(Instruction::LocalGet(_)), + crate::wasm::Op::Leaf(Instruction::GlobalSet(_)) + ) + ) + }) + .count() + + body + .iter() + .map(|op| match op { + crate::wasm::Op::Block { body, .. } | crate::wasm::Op::Loop { body, .. } => { + count_watermark_restores(body) + } + crate::wasm::Op::Try { body, .. } => count_watermark_restores(body), + crate::wasm::Op::If { + then_body, + else_body, + .. + } => count_watermark_restores(then_body) + count_watermark_restores(else_body), + _ => 0, + }) + .sum::() +} diff --git a/crates/psrs-backend/src/mir/tests/exception/dispatch_boundary.rs b/crates/psrs-backend/src/mir/tests/exception/dispatch_boundary.rs index 12c71631..3c17dfe4 100644 --- a/crates/psrs-backend/src/mir/tests/exception/dispatch_boundary.rs +++ b/crates/psrs-backend/src/mir/tests/exception/dispatch_boundary.rs @@ -18,6 +18,7 @@ fn function(blocks: Vec, values: &[(u32, ValueType)], result: u32) - blocks, result: ValueId(result), result_type: ValueType::I32, + frame_weight: 0, span: at(950), } } diff --git a/crates/psrs-backend/src/mir/tests/exception/execution.rs b/crates/psrs-backend/src/mir/tests/exception/execution.rs index 82abb92a..4c1efaa3 100644 --- a/crates/psrs-backend/src/mir/tests/exception/execution.rs +++ b/crates/psrs-backend/src/mir/tests/exception/execution.rs @@ -6,6 +6,13 @@ use crate::TargetCapabilities; use std::sync::atomic::{AtomicU32, Ordering}; +/// The engine stack limit every budget-dependent execution pins (EH-15): +/// wasmtime's CLI `-W max-wasm-stack=N`, in bytes. The value is the measured +/// calibration condition recorded in the checklist's EH-15 record; it is +/// passed explicitly rather than left to the engine's default, so the +/// budget-above-limit hazard cannot hide behind a moved engine default. +pub(crate) const PINNED_MAX_WASM_STACK: u32 = 524_288; + pub(super) fn next_artifact_id() -> u32 { static COUNTER: AtomicU32 = AtomicU32::new(0); COUNTER.fetch_add(1, Ordering::Relaxed) @@ -38,15 +45,25 @@ pub(super) fn run_output( next_artifact_id() )); std::fs::write(&path, &component).expect("writing component"); - let output = std::process::Command::new("wasmtime") - .arg("run") - .arg(&path) - .output() - .expect("running component"); + let output = run_component(&path, target.exceptions); let _ = std::fs::remove_file(&path); Some(output) } +/// Runs one component artifact under the wasmtime CLI. Budget-dependent +/// evidence (any target with `exceptions` enabled) pins the engine's stack +/// limit explicitly with `-W max-wasm-stack`. +pub(super) fn run_component(path: &std::path::Path, exceptions: bool) -> std::process::Output { + let mut command = std::process::Command::new("wasmtime"); + command.arg("run"); + if exceptions { + command + .arg("-W") + .arg(format!("max-wasm-stack={PINNED_MAX_WASM_STACK}")); + } + command.arg(path).output().expect("running component") +} + /// Panics when `PSRS_REQUIRE_WASMTIME=1` but the engine is unusable; returns /// `false` when a skip is allowed. pub(super) fn require_engine() -> bool { diff --git a/crates/psrs-backend/src/mir/tests/exception/mod.rs b/crates/psrs-backend/src/mir/tests/exception/mod.rs index 1c2a3f0d..f7bee840 100644 --- a/crates/psrs-backend/src/mir/tests/exception/mod.rs +++ b/crates/psrs-backend/src/mir/tests/exception/mod.rs @@ -5,6 +5,7 @@ //! boundary. Fixtures are hand-built direct MIR (or direct CC for the //! discharge) executed under wasmtime where the row requires it. +mod budget; mod capability; mod discharge; mod dispatch_boundary; @@ -82,6 +83,7 @@ pub(super) fn simple_function( blocks, result: ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span: at(900), } } diff --git a/crates/psrs-backend/src/mir/tests/exception/structuring/dispatcher_local.rs b/crates/psrs-backend/src/mir/tests/exception/structuring/dispatcher_local.rs index 1297e753..1bfb9574 100644 --- a/crates/psrs-backend/src/mir/tests/exception/structuring/dispatcher_local.rs +++ b/crates/psrs-backend/src/mir/tests/exception/structuring/dispatcher_local.rs @@ -21,9 +21,12 @@ fn does_not_allocate_a_dispatcher_local_on_reducible_input() { let wasm = crate::wasm::lower_module_with_plan(&mir, &mut wasi, enabled_target(), &link) .expect("the MIR should lower to Wasm"); let non_parameters = nested_values().len(); + // The budget slice's watermark locals: the fixture has two try regions, + // each with one fresh `i32` save local. Still no dispatcher state local. + let watermark_locals = 2; assert_eq!( wasm.functions[0].locals.len(), - non_parameters, + non_parameters + watermark_locals, "no dispatcher local" ); } diff --git a/crates/psrs-backend/src/mir/tests/exception/structuring/loops.rs b/crates/psrs-backend/src/mir/tests/exception/structuring/loops.rs index 8b97387c..90acc251 100644 --- a/crates/psrs-backend/src/mir/tests/exception/structuring/loops.rs +++ b/crates/psrs-backend/src/mir/tests/exception/structuring/loops.rs @@ -118,7 +118,9 @@ fn re_arms_a_try_table_across_loop_iterations() { function(blocks.clone(), &values, vec![], 0), vec![tag(0, Vec::new())], ); - assert_eq!(wat.matches("try_table").count(), 1, "{wat}"); + // One try region in the function; the second `try_table` is the budget + // slice's synthesized `run` entry `try_table (catch_all)`. + assert_eq!(wat.matches("try_table").count(), 2, "{wat}"); let loop_start = wat.find("loop").expect("the loop label"); let try_table = wat.find("try_table").expect("the armed region"); assert!( @@ -233,7 +235,9 @@ fn keeps_a_loop_inside_the_protected_region() { function(blocks.clone(), &values, vec![], 0), vec![tag(0, Vec::new())], ); - assert_eq!(wat.matches("try_table").count(), 1, "{wat}"); + // One try region in the function; the second `try_table` is the budget + // slice's synthesized `run` entry `try_table (catch_all)`. + assert_eq!(wat.matches("try_table").count(), 2, "{wat}"); let try_table = wat.find("try_table").expect("the armed region"); let loop_start = wat.find("loop").expect("the loop label"); assert!( diff --git a/crates/psrs-backend/src/mir/tests/exception/structuring/mod.rs b/crates/psrs-backend/src/mir/tests/exception/structuring/mod.rs index 8c19e35e..3129e2db 100644 --- a/crates/psrs-backend/src/mir/tests/exception/structuring/mod.rs +++ b/crates/psrs-backend/src/mir/tests/exception/structuring/mod.rs @@ -36,6 +36,7 @@ pub(super) fn function( blocks, result: ValueId(result), result_type: ValueType::I32, + frame_weight: 0, span: at(900), } } @@ -171,14 +172,19 @@ fn structures_nested_tries_with_the_inner_try_inside_the_outer() { function(nested_blocks(), &nested_values(), vec![], 0), vec![tag(0, Vec::new())], ); - assert_eq!(wat.matches("try_table").count(), 2, "{wat}"); + // Two try regions in the function plus the budget slice's synthesized + // `run` entry `try_table (catch_all)` — three in the module. + assert_eq!(wat.matches("try_table").count(), 3, "{wat}"); let outer = wat.find("try_table").expect("the outer try_table"); let inner = wat[outer + 1..] .find("try_table") .expect("the inner try_table") + outer + 1; - let throw = wat.find("throw").expect("the throw"); + // The budget prologue's own `throw` (the stack-exhaustion tag) sits at the + // function's start; the channel throw this test inspects comes after the + // inner try_table. + let throw = wat[inner..].find("throw").expect("the channel throw") + inner; assert!(outer < inner && inner < throw, "{wat}"); let mir = module( diff --git a/crates/psrs-backend/src/mir/tests/lowering.rs b/crates/psrs-backend/src/mir/tests/lowering.rs index e2299488..1f58dea7 100644 --- a/crates/psrs-backend/src/mir/tests/lowering.rs +++ b/crates/psrs-backend/src/mir/tests/lowering.rs @@ -50,6 +50,7 @@ fn defined_types_flow_into_the_wasm_type_section() { }], result: ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span: span(), }], span: span(), @@ -134,6 +135,7 @@ fn lowers_and_validates_f64_to_f32_abi_conversions() { }], result: ValueId(3), result_type: ValueType::I32, + frame_weight: 0, span: span(), }], span: span(), @@ -197,6 +199,7 @@ fn lowers_an_imported_call() { }], result: ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span: span(), }], span: span(), @@ -252,6 +255,7 @@ fn wasm_lowering_requires_an_explicit_entry_symbol() { }], result: ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span: span(), }], span: span(), diff --git a/crates/psrs-backend/src/mir/tests/runtime.rs b/crates/psrs-backend/src/mir/tests/runtime.rs index dcec5ef7..2f443ca5 100644 --- a/crates/psrs-backend/src/mir/tests/runtime.rs +++ b/crates/psrs-backend/src/mir/tests/runtime.rs @@ -118,6 +118,7 @@ fn runs_a_mir_gc_struct_under_wasmtime() { }], result: ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span: span(), }], span: span(), diff --git a/crates/psrs-backend/src/mir/tests/verify.rs b/crates/psrs-backend/src/mir/tests/verify.rs index 1bee133c..5920ee6e 100644 --- a/crates/psrs-backend/src/mir/tests/verify.rs +++ b/crates/psrs-backend/src/mir/tests/verify.rs @@ -71,6 +71,7 @@ fn rejects_a_struct_new_with_a_mistyped_field() { }], result: ValueId(0), result_type: ValueType::I64, + frame_weight: 0, span: span(), }], span: span(), diff --git a/crates/psrs-backend/src/mir/verify/array_map/tests.rs b/crates/psrs-backend/src/mir/verify/array_map/tests.rs index 57f6e459..68e32ef9 100644 --- a/crates/psrs-backend/src/mir/verify/array_map/tests.rs +++ b/crates/psrs-backend/src/mir/verify/array_map/tests.rs @@ -194,6 +194,7 @@ fn array_map_function() -> Function { ], result: destination, result_type: array_ref(1), + frame_weight: 0, span: span(), } } diff --git a/crates/psrs-backend/src/mir/verify/function.rs b/crates/psrs-backend/src/mir/verify/function.rs index d7d747b4..05252163 100644 --- a/crates/psrs-backend/src/mir/verify/function.rs +++ b/crates/psrs-backend/src/mir/verify/function.rs @@ -110,7 +110,14 @@ pub(super) fn verify_function( } } - let dominators = crate::mir::cfg::dominators(function); + // Value availability is dominance over the CFG that includes unwind + // edges: a handler block's only predecessors are unwind edges, so the + // normal-CFG view would make every enclosing value unavailable inside a + // handler even though the structured handler reads the function's live + // locals. The unwind-edge view keeps the check truthful in both + // directions — a join shared by a normal and a handler path no longer + // claims values defined on only one of them. + let dominators = crate::mir::cfg::dominators_with_unwind_edges(function); for block in &function.blocks { let mut available = block.parameters.iter().copied().collect::>(); for instruction in &block.instructions { diff --git a/crates/psrs-backend/src/mir/verify/tests/arrays.rs b/crates/psrs-backend/src/mir/verify/tests/arrays.rs index 306ce3ff..f1b61464 100644 --- a/crates/psrs-backend/src/mir/verify/tests/arrays.rs +++ b/crates/psrs-backend/src/mir/verify/tests/arrays.rs @@ -76,6 +76,7 @@ fn array_conversion_function( }], result: ValueId(3), result_type: array_reference, + frame_weight: 0, span: span(), }; let types = vec![RecGroup(vec![DefinedType { @@ -252,6 +253,7 @@ fn clone_function(element_storage: crate::types::StorageType) -> (Function, Vec< }], result: ValueId(1), result_type: array, + frame_weight: 0, span: span(), }; let types = vec![RecGroup(vec![DefinedType { diff --git a/crates/psrs-backend/src/mir/verify/tests/capability.rs b/crates/psrs-backend/src/mir/verify/tests/capability.rs index ec6eb8d1..3fe4a4c3 100644 --- a/crates/psrs-backend/src/mir/verify/tests/capability.rs +++ b/crates/psrs-backend/src/mir/verify/tests/capability.rs @@ -213,6 +213,7 @@ fn module_with_instruction(instruction: mir::Instruction) -> mir::Module { }], result: crate::types::ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span, }], entry: Some(SymbolId::new(ModuleId(4), 0)), @@ -297,6 +298,7 @@ fn return_calls_require_the_tail_call_proposal() { }], result: crate::types::ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span, }], entry: Some(SymbolId::new(ModuleId(4), 0)), diff --git a/crates/psrs-backend/src/mir/verify/tests/dictionary.rs b/crates/psrs-backend/src/mir/verify/tests/dictionary.rs index 72f8bcc9..48f7237f 100644 --- a/crates/psrs-backend/src/mir/verify/tests/dictionary.rs +++ b/crates/psrs-backend/src/mir/verify/tests/dictionary.rs @@ -90,6 +90,7 @@ fn return_function( }], result, result_type, + frame_weight: 0, span: span(), } } @@ -233,6 +234,7 @@ fn method_function() -> Function { }], result, result_type: ValueType::I32, + frame_weight: 0, span: span(), } } @@ -341,6 +343,7 @@ fn rejects_a_non_dominating_dictionary_projection() { ], result: projected, result_type: reference(HeapType::Func), + frame_weight: 0, span: span(), }; let errors = diff --git a/crates/psrs-backend/src/mir/verify/tests/dominance.rs b/crates/psrs-backend/src/mir/verify/tests/dominance.rs index ff34639c..bc6ff7c1 100644 --- a/crates/psrs-backend/src/mir/verify/tests/dominance.rs +++ b/crates/psrs-backend/src/mir/verify/tests/dominance.rs @@ -37,6 +37,7 @@ fn function(values: Vec, blocks: Vec) -> Function { blocks, result: ValueId(99), result_type: ValueType::I32, + frame_weight: 0, span: span(), } } diff --git a/crates/psrs-backend/src/mir/verify/tests/effects.rs b/crates/psrs-backend/src/mir/verify/tests/effects.rs index 51fd911e..7d4c80bf 100644 --- a/crates/psrs-backend/src/mir/verify/tests/effects.rs +++ b/crates/psrs-backend/src/mir/verify/tests/effects.rs @@ -105,6 +105,7 @@ fn effect_closure_call( }], result: ValueId(2), result_type: destination_type, + frame_weight: 0, span: span(), } } diff --git a/crates/psrs-backend/src/mir/verify/tests/gaps.rs b/crates/psrs-backend/src/mir/verify/tests/gaps.rs index 7972272b..49deed7c 100644 --- a/crates/psrs-backend/src/mir/verify/tests/gaps.rs +++ b/crates/psrs-backend/src/mir/verify/tests/gaps.rs @@ -85,6 +85,7 @@ fn rejects_ref_cast_between_unrelated_heaps() { }], result: output, result_type: reference(HeapType::Index(DefinedTypeId(1))), + frame_weight: 0, span: span(), }; let errors = crate::mir::verify_module(&custom_module( @@ -134,6 +135,7 @@ fn accepts_an_erased_upcast_to_eqref() { }], result: output, result_type: reference(HeapType::Eq), + frame_weight: 0, span: span(), }; crate::mir::verify_module(&custom_module(function, vec![RecGroup(vec![struct_type])])) @@ -189,6 +191,7 @@ fn closure_callee() -> Function { }], result: argument, result_type: ValueType::I32, + frame_weight: 0, span: span(), } } @@ -237,6 +240,7 @@ fn rejects_a_closure_capture_outside_the_eq_hierarchy() { }], result: closure, result_type: reference(HeapType::Index(DefinedTypeId(1))), + frame_weight: 0, span: span(), }, closure_types(), @@ -290,6 +294,7 @@ fn helper_function(blocks: Vec, values: Vec) -> Function blocks, result: output, result_type: reference(HeapType::Index(DefinedTypeId(1))), + frame_weight: 0, span: span(), } } @@ -395,6 +400,7 @@ fn get_capture_function(destination_type: ValueType) -> Function { }], result: destination, result_type: destination_type, + frame_weight: 0, span: span(), } } diff --git a/crates/psrs-backend/src/mir/verify/tests/mod.rs b/crates/psrs-backend/src/mir/verify/tests/mod.rs index 96564f91..0d4be34d 100644 --- a/crates/psrs-backend/src/mir/verify/tests/mod.rs +++ b/crates/psrs-backend/src/mir/verify/tests/mod.rs @@ -50,6 +50,7 @@ fn rejects_constants_with_incompatible_result_types() { }], result: ValueId(0), result_type: ValueType::F64, + frame_weight: 0, span: span(), }; let errors = verify_module(&module_with_function(function, Vec::new())).unwrap_err(); @@ -97,6 +98,7 @@ fn rejects_a_unary_primitive_with_mistyped_operands() { }], result: output, result_type: ValueType::I32, + frame_weight: 0, span: span(), }; for op in [ @@ -170,6 +172,7 @@ fn rejects_duplicate_switch_case_values() { ], result: selector, result_type: ValueType::I32, + frame_weight: 0, span: span(), }; let errors = verify_module(&module_with_function(function, Vec::new())).unwrap_err(); @@ -223,6 +226,7 @@ fn rejects_values_used_before_definition() { }], result: ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span: span(), }; let errors = verify_module(&module_with_function(function, Vec::new())).unwrap_err(); @@ -270,6 +274,7 @@ fn rejects_ref_func_with_a_different_target_signature() { }], result: ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span: span(), }; let reference = ValueType::Ref(crate::types::RefType { @@ -302,6 +307,7 @@ fn rejects_ref_func_with_a_different_target_signature() { }], result: ValueId(0), result_type: reference, + frame_weight: 0, span: span(), }; let mut module = module_with_function(caller, vec![RecGroup(vec![function_type])]); @@ -357,6 +363,7 @@ fn rejects_a_load_from_an_unknown_memory_id() { }], result: ValueId(1), result_type: ValueType::I32, + frame_weight: 0, span: span(), }; let errors = verify_module(&module_with_function(function, Vec::new())).unwrap_err(); @@ -454,6 +461,7 @@ fn rejects_a_branch_target_with_block_parameters() { ], result: merge_parameter, result_type: ValueType::I32, + frame_weight: 0, span: span(), }; let errors = verify_module(&module_with_function(function, Vec::new())).unwrap_err(); diff --git a/crates/psrs-backend/src/mir/verify/tests/scalar.rs b/crates/psrs-backend/src/mir/verify/tests/scalar.rs index 8ef059a9..9d8a96a6 100644 --- a/crates/psrs-backend/src/mir/verify/tests/scalar.rs +++ b/crates/psrs-backend/src/mir/verify/tests/scalar.rs @@ -46,6 +46,7 @@ fn primitive_function( }], result: output, result_type: result_ty, + frame_weight: 0, span: span(), } } @@ -134,6 +135,7 @@ fn rejects_a_boolean_constant_that_is_not_canonical() { }], result: output, result_type: ValueType::Boolean, + frame_weight: 0, span: span(), }; let errors = verify_module(&module_with_function(function, Vec::new())).unwrap_err(); @@ -175,6 +177,7 @@ fn rejects_a_call_with_the_wrong_argument_type() { }], result: ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span: span(), }; let argument = ValueId(0); @@ -212,6 +215,7 @@ fn rejects_a_call_with_the_wrong_argument_type() { }], result: call_result, result_type: ValueType::I32, + frame_weight: 0, span: span(), }; let mut module = module_with_function(caller, Vec::new()); diff --git a/crates/psrs-backend/src/mir/verify/tests/structure.rs b/crates/psrs-backend/src/mir/verify/tests/structure.rs index 84a92fd5..e8a2fcc2 100644 --- a/crates/psrs-backend/src/mir/verify/tests/structure.rs +++ b/crates/psrs-backend/src/mir/verify/tests/structure.rs @@ -14,6 +14,7 @@ fn simple_function(values: Vec, blocks: Vec) -> Function blocks, result: ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span: span(), } } diff --git a/crates/psrs-backend/src/mir/verify/tests/switch.rs b/crates/psrs-backend/src/mir/verify/tests/switch.rs index e1c09804..35efe970 100644 --- a/crates/psrs-backend/src/mir/verify/tests/switch.rs +++ b/crates/psrs-backend/src/mir/verify/tests/switch.rs @@ -40,6 +40,7 @@ fn rejects_a_switch_with_a_non_i32_selector() { ], result: selector, result_type: ValueType::Boolean, + frame_weight: 0, span: span(), }; let errors = verify_module(&module_with_function(function, Vec::new())).unwrap_err(); @@ -122,6 +123,7 @@ fn rejects_a_projection_that_is_not_dominated_by_its_tag_test() { ], result, result_type: ValueType::I32, + frame_weight: 0, span: span(), }; let errors = verify_module(&module_with_function(function, Vec::new())).unwrap_err(); diff --git a/crates/psrs-backend/src/mir/verify/tests/tail.rs b/crates/psrs-backend/src/mir/verify/tests/tail.rs index e932884a..b185dd07 100644 --- a/crates/psrs-backend/src/mir/verify/tests/tail.rs +++ b/crates/psrs-backend/src/mir/verify/tests/tail.rs @@ -51,6 +51,7 @@ fn callee(parameters: Vec, result_type: ValueType) -> Function { }], result, result_type: parameter_ty, + frame_weight: 0, span: span(), }; } @@ -88,6 +89,7 @@ fn callee(parameters: Vec, result_type: ValueType) -> Function { }], result, result_type, + frame_weight: 0, span: span(), } } @@ -113,6 +115,7 @@ fn caller( }], result: ValueId(99), result_type, + frame_weight: 0, span: span(), } } diff --git a/crates/psrs-backend/src/mir/verify/variants/tests.rs b/crates/psrs-backend/src/mir/verify/variants/tests.rs index d7064370..db4a60cc 100644 --- a/crates/psrs-backend/src/mir/verify/variants/tests.rs +++ b/crates/psrs-backend/src/mir/verify/variants/tests.rs @@ -90,6 +90,7 @@ fn constructor(tag: i32, copy: bool) -> Module { }], result: ValueId(2), result_type, + frame_weight: 0, span, }], } diff --git a/crates/psrs-backend/src/mir/wit/tests/handles.rs b/crates/psrs-backend/src/mir/wit/tests/handles.rs index bd8e4179..b4623281 100644 --- a/crates/psrs-backend/src/mir/wit/tests/handles.rs +++ b/crates/psrs-backend/src/mir/wit/tests/handles.rs @@ -137,6 +137,7 @@ fn function_calling(instructions: Vec) -> Function { }], result: ValueId(1), result_type: ValueType::I32, + frame_weight: 0, span: span(), } } diff --git a/crates/psrs-backend/src/wasm/encode.rs b/crates/psrs-backend/src/wasm/encode.rs index 3bccd102..a6a6494c 100644 --- a/crates/psrs-backend/src/wasm/encode.rs +++ b/crates/psrs-backend/src/wasm/encode.rs @@ -5,7 +5,7 @@ use wasm_encoder::{ BlockType, Catch, CodeSection, ConstExpr, DataCountSection, DataSection, ElementSection, Elements, EntityType, ExportKind as WasmExportKind, ExportSection, Function as EncoderFunction, FunctionSection, GlobalSection, GlobalType, ImportSection, Instruction, MemorySection, - MemoryType, Module as EncoderModule, TagKind, TagSection, TagType, TypeSection, ValType, + MemoryType, Module as EncoderModule, TagKind, TagSection, TagType, TypeSection, }; /// Encodes the thin Wasm IR into a binary module. @@ -144,7 +144,7 @@ pub fn encode_module(module: &Module) -> Result, Vec> { code.function(&encoded); } if let Some(entry) = &module.entry { - let mut encoded = EncoderFunction::new_with_locals_types(std::iter::empty::()); + let mut encoded = EncoderFunction::new_with_locals_types(entry.locals.iter().copied()); emit_body(&entry.body, &mut encoded); encoded.instruction(&Instruction::End); code.function(&encoded); diff --git a/crates/psrs-backend/src/wasm/lower/budget.rs b/crates/psrs-backend/src/wasm/lower/budget.rs new file mode 100644 index 00000000..9efed048 --- /dev/null +++ b/crates/psrs-backend/src/wasm/lower/budget.rs @@ -0,0 +1,188 @@ +//! The stack-exhaustion budget's emission (EH-13, EH-14): the prologue check +//! and epilogue/tail decrements every lowered function carries when the +//! target enables `exceptions`, plus the try-region watermark save/restore +//! that keeps the depth counter from leaking across an EH throw. The weight +//! and budget constants live in [`crate::mir::budget`]; this module owns only +//! their instruction shape. +//! +//! Shape (design "Budget emission"): +//! +//! ```text +//! prologue: depth += W_f; if depth > STACK_BUDGET then throw $stack-exhaustion +//! epilogue: depth -= W_f // before every `return` +//! tail: depth -= W_f // before every `return_call*` +//! trap: nothing // the leak is unobservable +//! ``` + +use super::structure::Structurer; +use super::wasm_error; +use crate::BackendError; +use crate::wasm::{Body, Op}; +use psrs_span::TextRange; +use wasm_encoder::Instruction; + +/// The budget context one lowered module carries: the depth global's final +/// index, the `psrs:stack-exhaustion` tag's final index, and the profile-fixed +/// budget constant. Constructed by `lower_module_with_plan` when +/// `target.exceptions` is enabled; `None` lowers every function unchanged. +#[derive(Clone, Copy, Debug)] +pub(super) struct Budget { + /// The depth counter global: a mutable `i32` initialized to 0, allocated + /// after the literal-interning globals and never exported. + pub(super) depth: u32, + /// The module-local index of the `psrs:stack-exhaustion` tag (empty + /// payload). The prologue's overflow path throws it; it unwinds out of + /// the function because every try region arms only after the prologue. + pub(super) tag: u32, + /// The budget the prologue check compares against. + pub(super) stack_budget: i32, +} + +impl Budget { + /// Plans the module's budget context when `target.exceptions` is enabled, + /// appending the stack-exhaustion tag's function type and its `TagDef`. + /// The tag is appended after the MIR tags, so a MIR `TagId`'s position — + /// the final `TagIndex` — is unchanged. `literal_global_count` is the + /// number of already-planned interning globals: the depth global takes + /// the next global index. + pub(super) fn for_module( + module: &crate::mir::Module, + target: crate::capability::TargetCapabilities, + defined: u32, + types: &mut Vec, + tag_defs: &mut Vec, + literal_global_count: usize, + ) -> Result, Vec> { + if !target.exceptions { + return Ok(None); + } + if !target.mutable_globals { + return Err(wasm_error( + module.span, + "the stack-exhaustion budget requires mutable WebAssembly globals", + )); + } + // The depth counter: one mutable `i32` global initialized to 0, + // allocated after the literal-interning globals, invisible to source + // and never exported. + let depth = super::GlobalIndex(literal_global_count as u32); + let tag = super::TagIndex(module.tags.len() as u32); + let tag_type = super::TypeIndex(defined + types.len() as u32); + types.push(super::FuncType { + parameters: Vec::new(), + results: Vec::new(), + }); + tag_defs.push(super::TagDef { + type_index: tag_type, + span: module.span, + }); + Ok(Some(Budget { + depth: depth.0, + tag: tag.0, + stack_budget: crate::mir::budget::STACK_BUDGET, + })) + } + + /// `depth += W_f` — the prologue's accounting half. + pub(super) fn increment(&self, weight: u32, body: &mut Body) { + body.push(Op::Leaf(Instruction::GlobalGet(self.depth))); + body.push(Op::Leaf(Instruction::I32Const(weight as i32))); + body.push(Op::Leaf(Instruction::I32Add)); + body.push(Op::Leaf(Instruction::GlobalSet(self.depth))); + } + + /// `depth -= W_f` — the epilogue and tail-path decrement. + pub(super) fn decrement(&self, weight: u32, body: &mut Body) { + body.push(Op::Leaf(Instruction::GlobalGet(self.depth))); + body.push(Op::Leaf(Instruction::I32Const(weight as i32))); + body.push(Op::Leaf(Instruction::I32Sub)); + body.push(Op::Leaf(Instruction::GlobalSet(self.depth))); + } + + /// The prologue's overflow check: when `depth > STACK_BUDGET`, throw the + /// stack-exhaustion tag. Emitted after the increment; the throw unwinds + /// out of the function (no try region of this function is armed yet). + pub(super) fn overflow_check(&self, body: &mut Body, span: TextRange) { + body.push(Op::Leaf(Instruction::GlobalGet(self.depth))); + body.push(Op::Leaf(Instruction::I32Const(self.stack_budget))); + body.push(Op::Leaf(Instruction::I32GtS)); + let overflow = vec![Op::Leaf(Instruction::Throw(self.tag))]; + body.push(Op::If { + then_body: overflow, + else_body: Vec::new(), + result: None, + span, + }); + } + + /// `saved = depth` — the watermark save at a `try_table`'s entry, into the + /// try region's fresh `i32` local. + pub(super) fn watermark_save(&self, saved: u32, body: &mut Body) { + body.push(Op::Leaf(Instruction::GlobalGet(self.depth))); + body.push(Op::Leaf(Instruction::LocalSet(saved))); + } + + /// `depth = saved` — the catch path's first action, settling every frame + /// the EH throw unwound (whose epilogues never ran) at once. + pub(super) fn watermark_restore(&self, saved: u32, body: &mut Body) { + body.push(Op::Leaf(Instruction::LocalGet(saved))); + body.push(Op::Leaf(Instruction::GlobalSet(self.depth))); + } +} + +impl Structurer<'_> { + /// The budget context, or the error a try region would otherwise hit + /// silently: P9's capability gate rejects a module with try regions under + /// a disabled profile, so a missing context cannot reach an armed region + /// in a checked pipeline. + pub(super) fn budget(&self, span: TextRange) -> Result<&Budget, Vec> { + match &self.budget { + Some(budget) => Ok(budget), + None => Err(wasm_error( + span, + "a try region needs the stack-exhaustion budget context", + )), + } + } + + /// The fresh `i32` watermark local of the try region owned by `try_block`: + /// one local per try region, allocated after the lowered values, the + /// list-copy locals, and (mutually exclusive with them) the dispatcher + /// state local. Try regions are numbered in block order, which is + /// deterministic. + pub(super) fn watermark_local(&self, try_block: crate::mir::BlockId) -> u32 { + let ordinal = self + .function + .blocks + .iter() + .filter(|block| { + block.id.0 < try_block.0 + && matches!(&block.terminator, Some(crate::mir::Terminator::Try { .. })) + }) + .count() as u32; + self.watermark_base + ordinal + } + + /// The epilogue decrement on a `return` path (EH-13). Nothing when the + /// profile disables the budget. + pub(super) fn emit_budget_epilogue(&self, body: &mut Body) -> Result<(), Vec> { + if let Some(budget) = &self.budget { + budget.decrement(self.function.frame_weight, body); + } + Ok(()) + } + + /// The tail-path decrement before a `return_call`/`return_call_ref` + /// (EH-13): the callee replaces this frame, so this frame's weight must + /// come off the counter before the transfer. Nothing when the profile + /// disables the budget. + pub(super) fn emit_budget_tail_decrement( + &self, + body: &mut Body, + ) -> Result<(), Vec> { + if let Some(budget) = &self.budget { + budget.decrement(self.function.frame_weight, body); + } + Ok(()) + } +} diff --git a/crates/psrs-backend/src/wasm/lower/entry.rs b/crates/psrs-backend/src/wasm/lower/entry.rs new file mode 100644 index 00000000..a6aa9585 --- /dev/null +++ b/crates/psrs-backend/src/wasm/lower/entry.rs @@ -0,0 +1,84 @@ +//! The synthesized `run` entry: the WASI command export that calls `main` and +//! exits through `exit-with-code`. When the target enables `exceptions`, the +//! entry additionally wraps the call in a `try_table (catch_all)`: an +//! exception no guest handler observes — the stack-exhaustion throw among +//! them — restores the depth counter to the value saved at entry and leaves +//! the artifact through the deterministic exit code 1 (design "Budget +//! emission"). + +use super::budget::Budget; +use super::{Entry, FunctionIndex, Op, TypeIndex}; +use psrs_span::TextRange; +use wasm_encoder::{Instruction, ValType}; + +pub(super) fn synthesize( + main_index: FunctionIndex, + exit_index: Option, + entry_type: TypeIndex, + budget: Option<&Budget>, + span: TextRange, +) -> Entry { + let mut body: Vec = Vec::new(); + let mut locals: Vec = Vec::new(); + match budget { + None => { + body.push(Op::Leaf(Instruction::Call(main_index.0))); + if let Some(exit_index) = exit_index { + body.push(Op::Leaf(Instruction::Call(exit_index.0))); + body.push(Op::Leaf(Instruction::I32Const(0))); + } + } + Some(budget) => { + // The watermark save at entry: the handler restores the counter + // to this value, settling `main`'s own prologue increment, which + // never reaches its epilogue on the throw path. + let saved = locals.len() as u32; + locals.push(ValType::I32); + body.push(Op::Leaf(Instruction::GlobalGet(budget.depth))); + body.push(Op::Leaf(Instruction::LocalSet(saved))); + + // The protected call: `main`, then the exit with `main`'s code, + // then the entry's own `0` result — handed back with `return`, + // because the `try_table`'s empty block type carries no values. + let mut protected = vec![Op::Leaf(Instruction::Call(main_index.0))]; + if let Some(exit_index) = exit_index { + protected.push(Op::Leaf(Instruction::Call(exit_index.0))); + protected.push(Op::Leaf(Instruction::I32Const(0))); + } + protected.push(Op::Leaf(Instruction::Return)); + + body.push(Op::Block { + // The catch target: an empty-result block, so the `catch_all` + // clause can deliver nothing to it. + result: None, + span, + body: vec![ + Op::Try { + body: protected, + clauses: vec![super::CatchClause::CatchAll { label: 0 }], + span, + }, + // The protected body always returns, so the `try_table` + // cannot complete normally. + Op::Leaf(Instruction::Unreachable), + ], + }); + // The catch path: restore the counter FIRST — `main`'s frames + // unwound without their epilogues — then leave with exit code 1. + body.push(Op::Leaf(Instruction::LocalGet(saved))); + body.push(Op::Leaf(Instruction::GlobalSet(budget.depth))); + body.push(Op::Leaf(Instruction::I32Const(1))); + if let Some(exit_index) = exit_index { + body.push(Op::Leaf(Instruction::Call(exit_index.0))); + // `exit-with-code` never returns, but the entry's result type + // still needs its `i32` on the validator's stack. + body.push(Op::Leaf(Instruction::Unreachable)); + } + } + } + Entry { + type_index: entry_type, + locals, + body, + } +} diff --git a/crates/psrs-backend/src/wasm/lower/extent/tests/mod.rs b/crates/psrs-backend/src/wasm/lower/extent/tests/mod.rs index 0a7cc459..39da2f62 100644 --- a/crates/psrs-backend/src/wasm/lower/extent/tests/mod.rs +++ b/crates/psrs-backend/src/wasm/lower/extent/tests/mod.rs @@ -36,6 +36,7 @@ fn function( blocks, result, result_type, + frame_weight: 0, span: ACCESS_SPAN, } } diff --git a/crates/psrs-backend/src/wasm/lower/function/mod.rs b/crates/psrs-backend/src/wasm/lower/function/mod.rs index fac4df4e..ccdb04b4 100644 --- a/crates/psrs-backend/src/wasm/lower/function/mod.rs +++ b/crates/psrs-backend/src/wasm/lower/function/mod.rs @@ -13,6 +13,7 @@ use psrs_hir::SymbolId; use std::collections::HashMap; use wasm_encoder::ValType; +#[allow(clippy::too_many_arguments)] pub(super) fn lower_function( source: &MirFunction, type_index: TypeIndex, @@ -21,6 +22,7 @@ pub(super) fn lower_function( literal_globals: &HashMap, layout: Option<&crate::mir::layout::PlannedLayout>, tags: &[crate::mir::Tag], + budget: Option<&super::budget::Budget>, ) -> Result> { let locals = local_indices(source)?; let list_depth = structure::list_copy_depth(source); @@ -33,6 +35,20 @@ pub(super) fn lower_function( depth: list_depth, } }); + // EH-14: one fresh `i32` watermark local per try region, allocated after + // the lowered values and the list-copy locals. A function with try regions + // never allocates a dispatcher state local (an irreducible CFG with a try + // region is rejected at `ControlFlowPlan::build`), so the two extra-local + // families never share an index. + let watermark_base = source.values.len() as u32 + + list_locals.map_or(0, |locals| { + locals.index_count() + locals.scratch_count() + locals.array_count() + }); + let watermark_count = source + .blocks + .iter() + .filter(|block| matches!(&block.terminator, Some(crate::mir::Terminator::Try { .. }))) + .count(); let parameters = source .parameters .iter() @@ -62,8 +78,17 @@ pub(super) fn lower_function( list_locals, layout, tags, + budget: budget.copied(), + watermark_base, }; let mut body = Body::new(); + // EH-13: the budget prologue precedes the function's first structured + // instruction, and its overflow throw unwinds out of the function because + // no try region of this function is armed yet. + if let Some(budget) = budget { + budget.increment(source.frame_weight, &mut body); + budget.overflow_check(&mut body, source.span); + } let uses_dispatcher = structurer.emit_control_flow(&mut body)?; structurer.emit_load(source.result, source.span, &mut body)?; let mut locals = local_types; @@ -84,6 +109,10 @@ pub(super) fn lower_function( if uses_dispatcher { locals.push(ValType::I32); } + // EH-14: the try regions' fresh watermark locals close the local list. + for _ in 0..watermark_count { + locals.push(ValType::I32); + } Ok(Function { symbol: source.symbol, name: source.name.clone(), diff --git a/crates/psrs-backend/src/wasm/lower/mod.rs b/crates/psrs-backend/src/wasm/lower/mod.rs index 4c1d7b29..3c93378c 100644 --- a/crates/psrs-backend/src/wasm/lower/mod.rs +++ b/crates/psrs-backend/src/wasm/lower/mod.rs @@ -1,7 +1,8 @@ +use self::budget::Budget; use super::convert::val_type; use super::{ - Entry, Export, ExportIndex, ExportKind, FuncType, FunctionIndex, Import, Memory, MemoryIndex, - Module, Op, TypeIndex, + CatchClause, Entry, Export, ExportIndex, ExportKind, FuncType, FunctionIndex, Global, + GlobalIndex, GlobalInit, Import, Memory, MemoryIndex, Module, Op, TagDef, TagIndex, TypeIndex, }; use crate::BackendError; use crate::abi::{self, names}; @@ -11,10 +12,12 @@ use crate::types::{MemoryId, ValueId, ValueType}; use psrs_hir::SymbolId; use psrs_span::TextRange; use std::collections::HashMap; -use wasm_encoder::{Instruction, RefType, ValType}; +use wasm_encoder::{RefType, ValType}; mod asm; +mod budget; mod codec; +mod entry; mod extent; mod function; mod post_return; @@ -128,6 +131,19 @@ pub(crate) fn lower_module_with_plan( }); } + // The stack-exhaustion budget (EH-12..EH-15): when the target enables + // `exceptions`, every lowered function carries the prologue check and the + // epilogue decrements against the depth global; `Budget::for_module` + // plans the context and appends the tag's type. + let budget = Budget::for_module( + module, + target, + defined, + &mut types, + &mut tag_defs, + literal_globals.globals.len(), + )?; + // Core imports: the WASI imports MIR declared, then `exit-with-code` used // by the synthesized `run` entry when the target exposes WASI CLI. let mut imports = Vec::new(); @@ -250,6 +266,7 @@ pub(crate) fn lower_module_with_plan( &literal_globals.indices, module.layout.as_ref(), &module.tags, + budget.as_ref(), ) .map_err(|errors| { errors @@ -278,7 +295,15 @@ pub(crate) fn lower_module_with_plan( }, ]; let minimum = link.plan.memory().minimum_pages; - let globals = literal_globals.globals; + let mut globals = literal_globals.globals; + if let Some(budget) = &budget { + globals.push(Global { + index: GlobalIndex(budget.depth), + mutable: true, + ty: ValType::I32, + init: GlobalInit::I32(0), + }); + } let mut realloc = None; if needs_realloc { let import_index = *import_indices.get(&abi::REALLOC_SYMBOL).ok_or_else(|| { @@ -360,17 +385,13 @@ pub(crate) fn lower_module_with_plan( globals, data, exports, - entry: Some(Entry { - type_index: entry_type, - body: { - let mut body = vec![Op::Leaf(Instruction::Call(main_index.0))]; - if let Some(exit_index) = exit_index { - body.push(Op::Leaf(Instruction::Call(exit_index.0))); - body.push(Op::Leaf(Instruction::I32Const(0))); - } - body - }, - }), + entry: Some(entry::synthesize( + main_index, + exit_index, + entry_type, + budget.as_ref(), + module.span, + )), realloc, helpers, span: module.span, diff --git a/crates/psrs-backend/src/wasm/lower/structure/cfg/irreducible.rs b/crates/psrs-backend/src/wasm/lower/structure/cfg/irreducible.rs index f89d3d00..615a8ab2 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/cfg/irreducible.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/cfg/irreducible.rs @@ -17,9 +17,12 @@ pub(super) fn has_irreducible_component( let next = blocks .get(&block_id) .and_then(|block| block.terminator.as_ref()) - .map(successors) + .map(|terminator| { + successors(terminator) + .into_iter() + .chain(super::unwind_only_successors(terminator)) + }) .unwrap_or_default() - .into_iter() .filter(|target| reachable_set.contains(target)) .collect::>(); (block_id, next) diff --git a/crates/psrs-backend/src/wasm/lower/structure/cfg/mod.rs b/crates/psrs-backend/src/wasm/lower/structure/cfg/mod.rs index c6dd33eb..9b2698d9 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/cfg/mod.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/cfg/mod.rs @@ -157,7 +157,7 @@ fn reachable_blocks( /// The unwind edges alone, so the region plan's walk can follow the catch /// paths without re-listing the normal successors. -fn unwind_only_successors(terminator: &Terminator) -> Vec { +pub(super) fn unwind_only_successors(terminator: &Terminator) -> Vec { match terminator { Terminator::Try { catches, catch_all, .. @@ -191,7 +191,10 @@ fn predecessors( else { continue; }; - for target in successors(terminator) { + for target in successors(terminator) + .into_iter() + .chain(unwind_only_successors(terminator)) + { if reachable_set.contains(&target) { result.entry(target).or_default().insert(*source); } diff --git a/crates/psrs-backend/src/wasm/lower/structure/irreducible_dispatch_tests.rs b/crates/psrs-backend/src/wasm/lower/structure/irreducible_dispatch_tests.rs index 0635e4d4..af02d7d4 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/irreducible_dispatch_tests.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/irreducible_dispatch_tests.rs @@ -51,6 +51,7 @@ fn lower_and_validate(source: &Function) -> (WasmFunction, Vec) { &std::collections::HashMap::new(), None, &[], + None, ) .expect("the irreducible CFG should structure through the dispatcher"); let module = WasmModule { @@ -316,6 +317,7 @@ fn irreducible_function() -> Function { ], result: ValueId(0), result_type: ValueType::I32, + frame_weight: 0, span: span(), } } diff --git a/crates/psrs-backend/src/wasm/lower/structure/mod.rs b/crates/psrs-backend/src/wasm/lower/structure/mod.rs index ed16f08f..767ac79d 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/mod.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/mod.rs @@ -63,6 +63,16 @@ pub(super) struct Structurer<'a> { /// The module's exception tags; a MIR `TagId` indexes this slice, and the /// final `TagIndex` is the same position. pub(super) tags: &'a [crate::mir::Tag], + /// The stack-exhaustion budget context (depth global, tag, budget), present + /// only when `target.exceptions` is enabled. `lower_function` prepends the + /// per-function prologue; the structurer emits the epilogue, the tail + /// decrements, and the try-region watermarks. + pub(super) budget: Option, + /// The first local index the try regions' fresh watermark locals occupy. + /// A function with try regions never uses the dispatcher (an irreducible + /// CFG with a try region is rejected), so these sit after the list-copy + /// locals with no dispatcher local between them. + pub(super) watermark_base: u32, } impl Structurer<'_> { pub(super) fn emit_control_flow(&self, body: &mut Body) -> Result> { diff --git a/crates/psrs-backend/src/wasm/lower/structure/reducible_tests/acyclic.rs b/crates/psrs-backend/src/wasm/lower/structure/reducible_tests/acyclic.rs index b5183327..22ce26ce 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/reducible_tests/acyclic.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/reducible_tests/acyclic.rs @@ -59,6 +59,7 @@ fn rejects_an_acyclic_branch_to_a_missing_target() { &std::collections::HashMap::new(), None, &[], + None, ) .expect_err("a branch to a missing block must not structure"); assert!( diff --git a/crates/psrs-backend/src/wasm/lower/structure/region/mod.rs b/crates/psrs-backend/src/wasm/lower/structure/region/mod.rs index 2987a41d..23498c07 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/region/mod.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/region/mod.rs @@ -152,6 +152,9 @@ impl Structurer<'_> { body.push(Op::Leaf(Instruction::Unreachable)); } Terminator::Return { value, span } => { + // EH-13: the epilogue decrement precedes every `return`; a + // trapping block (the `Trap` arm) needs none. + self.emit_budget_epilogue(body)?; self.emit_load(*value, *span, body)?; body.push(Op::Leaf(Instruction::Return)); } @@ -206,6 +209,9 @@ impl Structurer<'_> { span, .. } => { + // EH-13: the callee replaces this frame, so this frame's + // weight comes off the counter before the transfer. + self.emit_budget_tail_decrement(body)?; self.emit_return_call(*function, arguments, *span, body)?; } Terminator::ReturnCallRef { @@ -213,6 +219,7 @@ impl Structurer<'_> { arguments, span, } => { + self.emit_budget_tail_decrement(body)?; self.emit_return_call_ref(*function, arguments, *span, body)?; } // EH-09/EH-10: a throw rides the channel to the innermost diff --git a/crates/psrs-backend/src/wasm/lower/structure/region/try_unit.rs b/crates/psrs-backend/src/wasm/lower/structure/region/try_unit.rs index 9dbdb3b8..3657dd6b 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/region/try_unit.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/region/try_unit.rs @@ -53,6 +53,13 @@ impl Structurer<'_> { if block_traps(block) { return Ok(()); } + // EH-14, the watermark save: the region's fresh `i32` local takes the + // depth counter's value immediately before the `try_table` arms. An + // EH throw unwinds frames without their epilogues; the catch path + // restores this value and settles every unwound frame at once. + let budget = self.budget(span)?; + let saved = self.watermark_local(try_block); + budget.watermark_save(saved, body); // The clause labels resolve at the try_table's emission point against // the active labels not counting the region's own internal label; // `0` is the synthesized unwind block immediately enclosing it. @@ -99,7 +106,10 @@ impl Structurer<'_> { ], }); // The catch path lands here, payload on the stack (if any). The - // watermark restore is the budget slice's first action here (EH-14). + // watermark restore is the catch path's first action (EH-14): every + // frame the throw unwound skipped its epilogue, and this one store + // settles all of them. + budget.watermark_restore(saved, body); let Some(handler) = handler else { return Ok(()); }; diff --git a/crates/psrs-backend/src/wasm/lower/structure/tests.rs b/crates/psrs-backend/src/wasm/lower/structure/tests.rs index a5b3464f..ca22d07d 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/tests.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/tests.rs @@ -36,6 +36,7 @@ pub(super) fn lower_and_validate(source: &MirFunction) -> (Function, Vec) { &HashMap::new(), None, &[], + None, ) .unwrap(); let module = Module { @@ -125,6 +126,7 @@ pub(super) fn function( blocks, result, result_type: ValueType::I32, + frame_weight: 0, span: span(), } } diff --git a/crates/psrs-backend/src/wasm/mod.rs b/crates/psrs-backend/src/wasm/mod.rs index a0f1630c..ac211dd8 100644 --- a/crates/psrs-backend/src/wasm/mod.rs +++ b/crates/psrs-backend/src/wasm/mod.rs @@ -206,6 +206,9 @@ pub struct DataSegment { #[derive(Clone, Debug)] pub struct Entry { pub type_index: TypeIndex, + /// The entry's declared locals. Empty except for the budget's watermark + /// save local when the target enables `exceptions`. + pub locals: Vec, pub body: Body, } diff --git a/crates/psrs-backend/src/wasm/tests/mod.rs b/crates/psrs-backend/src/wasm/tests/mod.rs index 437af242..4f36a25c 100644 --- a/crates/psrs-backend/src/wasm/tests/mod.rs +++ b/crates/psrs-backend/src/wasm/tests/mod.rs @@ -393,6 +393,7 @@ fn interns_repeated_string_literals_in_one_lazy_global() { }], result: ValueId(2), result_type: ValueType::I32, + frame_weight: 0, span: span(), }], span: span(), diff --git a/crates/psrs-backend/src/wasm/verify/mod.rs b/crates/psrs-backend/src/wasm/verify/mod.rs index 947d331a..76581add 100644 --- a/crates/psrs-backend/src/wasm/verify/mod.rs +++ b/crates/psrs-backend/src/wasm/verify/mod.rs @@ -165,7 +165,7 @@ pub fn verify_module(module: &Module) -> Result<(), Vec> { verify_body( &entry.body, module, - 0, + entry.locals.len() as u32, function_count, module.span, &mut vec![function_label_type(module, entry.type_index)], diff --git a/crates/psrs-driver/src/tests/mod.rs b/crates/psrs-driver/src/tests/mod.rs index 59f04e49..7fcbecd2 100644 --- a/crates/psrs-driver/src/tests/mod.rs +++ b/crates/psrs-driver/src/tests/mod.rs @@ -77,6 +77,14 @@ fn wasmtime_available() -> Option<()> { Some(()) } +/// The engine stack limit every budget-dependent execution pins (EH-15): the +/// wasmtime CLI's `-W max-wasm-stack=N`, in bytes. Passed explicitly rather +/// than left to the engine default, so the budget-above-limit hazard cannot +/// hide behind a moved engine default; the pinned 512 KiB was measured equal +/// to wasmtime 48.0.1's default. The budget slice's runtime evidence runs +/// through `psrs-backend`'s own runner, which pins the same value. +const PINNED_MAX_WASM_STACK: u32 = 524_288; + /// Runs a compiled multi-module program under Wasmtime. fn run_program_with_wasmtime(sources: &[(&str, &str)]) -> Option { wasmtime_available()?; @@ -86,6 +94,8 @@ fn run_program_with_wasmtime(sources: &[(&str, &str)]) -> Option Option + a) -> Effect Boolean` over `Target.perform`, with its vendoring record). +- The guarded callable must be a `FunctionRef` over a function whose body + calls the *deep* function directly: the fabricated Unit operand is the + callable's only argument, so the depth travels inside the closure's body, + not through the guard. TCOMutRec's `ntcoN` are plain functions; the + stdlib-side closure `\_ -> ntco1 100000` maps onto the coordination + fixture's `apply`/`rec` split exactly. +- The budget executes under the pinned engine (512 KiB) with about 4× + trap-headroom and depth-100 at ~7% of the budget; the depth-100 + `assertEqual` halves of TCOMutRec run inside the margin the coordination + fixture measured. EH-16's run must use the driver helpers, which now pin + `-W max-wasm-stack=524288`. +- Residual gap before TCOMutRec executes: none known in the channel — + `assertThrows` needs a `Boolean` result and the discharge produces one; + `assert'`'s failure protocol (storage trap) is untouched in this slice. + ## Evidence record and completion rule For each ID record owning paths/functions, exact test names, input boundary,