From d11204defb637094f5d96b4a29bdefdbfef9d890 Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sun, 18 Oct 2026 19:20:21 +0800 Subject: [PATCH] Represent the exception channel in MIR (EH-05..EH-08) MIR gains the module-local tag table and the two channel terminators, and the verifier checks their unwind edges. The protected-region partition is one shared analysis (mir/try_region.rs) that the verifier, tail-call marking, and the structuring slice all consume. - mir/tag.rs, mir/mod.rs: Tag/TagId, Module::tags, Terminator::Throw with the documented fields (tag, arguments, handler, span) and Terminator::Try (protected, catches, catch_all, span); a tag has at most one payload value, rejected with an explicit diagnostic. - mir/try_region.rs: the dominance-carved protected region (blocks dominated by the protected entry, minus inner tries' claims, minus handler blocks), the nesting-or-disjoint check, and the no-back-edge- into-a-try-block check; handler ownership with conflict recording. - mir/verify/exception.rs: clause-tag resolution, handler parameters typed as the caught payload, no normal predecessors, single-owner handler, Throw handler resolution (the innermost enclosing region that catches the tag; a handler block resolves from its owning try, whose try_table is disarmed on its own catch path), and no forced ReturnCall/ReturnCallRef inside a protected region. - mir/lower/tail.rs: mark_tail computes the shared partition and keeps protected blocks' calls ordinary; self-call loopification stays active inside regions. - mir/verify/capability.rs: RequiredCapabilities.exceptions, set by a declared tag or a Throw/Try terminator, rejected under a disabled profile at module.span; wasmtime_wasi_0_2() unchanged. - Wasm structuring and the dispatcher reject the terminators they cannot lower yet ('MIR exception-channel terminators are not lowered to Wasm yet'); the optimizer keeps handler blocks that normal reachability would prune. Checklist rows EH-05..EH-08 verified with direct-MIR fixtures. --- .../psrs-backend/src/linking/runtime_tests.rs | 1 + crates/psrs-backend/src/linking/tests.rs | 1 + crates/psrs-backend/src/mir/cfg.rs | 9 +- crates/psrs-backend/src/mir/effects.rs | 8 +- crates/psrs-backend/src/mir/lower/tail.rs | 12 +- crates/psrs-backend/src/mir/mod.rs | 22 + .../src/mir/number_format_tests.rs | 1 + crates/psrs-backend/src/mir/opt/cfg.rs | 28 ++ crates/psrs-backend/src/mir/opt/constants.rs | 8 +- crates/psrs-backend/src/mir/opt/imports.rs | 1 + crates/psrs-backend/src/mir/opt/tests/mod.rs | 1 + crates/psrs-backend/src/mir/opt/values.rs | 6 +- crates/psrs-backend/src/mir/tag.rs | 22 + .../src/mir/tests/exception/capability.rs | 93 +++++ .../src/mir/tests/exception/mod.rs | 383 +++++++++++++++++ .../src/mir/tests/exception/regions.rs | 242 +++++++++++ .../src/mir/tests/exception/tail.rs | 110 +++++ .../src/mir/tests/exception/unwind.rs | 393 ++++++++++++++++++ crates/psrs-backend/src/mir/tests/lowering.rs | 4 + crates/psrs-backend/src/mir/tests/mod.rs | 1 + crates/psrs-backend/src/mir/tests/runtime.rs | 1 + crates/psrs-backend/src/mir/tests/verify.rs | 1 + crates/psrs-backend/src/mir/try_region.rs | 319 ++++++++++++++ .../src/mir/verify/array_map/tests.rs | 1 + .../psrs-backend/src/mir/verify/capability.rs | 332 +-------------- .../psrs-backend/src/mir/verify/exception.rs | 275 ++++++++++++ .../psrs-backend/src/mir/verify/function.rs | 15 +- crates/psrs-backend/src/mir/verify/mod.rs | 6 +- .../src/mir/verify/tests/capability.rs | 317 ++++++++++++++ .../src/mir/verify/tests/dominance.rs | 1 + .../psrs-backend/src/mir/verify/tests/mod.rs | 2 + .../src/mir/verify/tests/structure.rs | 1 + .../src/mir/verify/tests/subtype.rs | 1 + .../psrs-backend/src/mir/verify/tests/tail.rs | 1 + .../src/mir/verify/variants/tests.rs | 1 + .../src/wasm/lower/extent/tests/mod.rs | 1 + .../src/wasm/lower/structure/cfg/mod.rs | 4 +- .../src/wasm/lower/structure/dispatcher.rs | 14 +- .../structure/irreducible_dispatch_tests.rs | 2 + .../src/wasm/lower/structure/region.rs | 9 + .../src/wasm/lower/structure/tests.rs | 1 + crates/psrs-backend/src/wasm/tests/mod.rs | 1 + .../backend/exception-handling.md | 149 ++++++- 43 files changed, 2468 insertions(+), 333 deletions(-) create mode 100644 crates/psrs-backend/src/mir/tag.rs create mode 100644 crates/psrs-backend/src/mir/tests/exception/capability.rs create mode 100644 crates/psrs-backend/src/mir/tests/exception/mod.rs create mode 100644 crates/psrs-backend/src/mir/tests/exception/regions.rs create mode 100644 crates/psrs-backend/src/mir/tests/exception/tail.rs create mode 100644 crates/psrs-backend/src/mir/tests/exception/unwind.rs create mode 100644 crates/psrs-backend/src/mir/try_region.rs create mode 100644 crates/psrs-backend/src/mir/verify/exception.rs create mode 100644 crates/psrs-backend/src/mir/verify/tests/capability.rs diff --git a/crates/psrs-backend/src/linking/runtime_tests.rs b/crates/psrs-backend/src/linking/runtime_tests.rs index 138b2c9b..9ee3741c 100644 --- a/crates/psrs-backend/src/linking/runtime_tests.rs +++ b/crates/psrs-backend/src/linking/runtime_tests.rs @@ -166,6 +166,7 @@ fn fixture() -> mir::Module { }, ]; mir::Module { + tags: Vec::new(), name: "RuntimeStorageConsumer".into(), types, strings: vec![], diff --git a/crates/psrs-backend/src/linking/tests.rs b/crates/psrs-backend/src/linking/tests.rs index ea523bc4..1971039d 100644 --- a/crates/psrs-backend/src/linking/tests.rs +++ b/crates/psrs-backend/src/linking/tests.rs @@ -6,6 +6,7 @@ use crate::types::{ fn module(import: mir::Import) -> mir::Module { mir::Module { + tags: Vec::new(), name: "ConsumerAbi".into(), entry: None, types: vec![], diff --git a/crates/psrs-backend/src/mir/cfg.rs b/crates/psrs-backend/src/mir/cfg.rs index 9f9a22c9..358b37fd 100644 --- a/crates/psrs-backend/src/mir/cfg.rs +++ b/crates/psrs-backend/src/mir/cfg.rs @@ -117,10 +117,15 @@ pub(crate) fn reachable_without( Some(false) } -/// The successor blocks named by a terminator. +/// The successor blocks named by a terminator's normal edges. Exception +/// unwind edges are not normal edges: a `Throw` has none (its handler is +/// reached by unwinding), and a handler block must never become a normal +/// successor. pub(crate) fn successors(terminator: &Terminator) -> Vec { match terminator { Terminator::Return { .. } | Terminator::Trap { .. } => Vec::new(), + Terminator::Throw { .. } => Vec::new(), + Terminator::Try { protected, .. } => vec![*protected], Terminator::Jump { target, .. } => vec![*target], Terminator::Branch { then_block, @@ -199,6 +204,8 @@ pub(crate) fn join_blocks(function: &Function) -> HashSet { Terminator::Trap { .. } | Terminator::Return { .. } | Terminator::Jump { .. } + | Terminator::Throw { .. } + | Terminator::Try { .. } | Terminator::ReturnCall { .. } | Terminator::ReturnCallRef { .. } => continue, }; diff --git a/crates/psrs-backend/src/mir/effects.rs b/crates/psrs-backend/src/mir/effects.rs index f0ad48c3..4d11a252 100644 --- a/crates/psrs-backend/src/mir/effects.rs +++ b/crates/psrs-backend/src/mir/effects.rs @@ -163,7 +163,13 @@ pub(in crate::mir) fn total_functions( Some(Terminator::ReturnCall { function, .. }) => { calls.insert(*function); } - Some(Terminator::ReturnCallRef { .. } | Terminator::Trap { .. }) => safe = false, + Some( + Terminator::ReturnCallRef { .. } + | Terminator::Trap { .. } + | Terminator::Throw { .. }, + ) => { + safe = false; + } _ => {} } } diff --git a/crates/psrs-backend/src/mir/lower/tail.rs b/crates/psrs-backend/src/mir/lower/tail.rs index 976a9d0d..d7d05631 100644 --- a/crates/psrs-backend/src/mir/lower/tail.rs +++ b/crates/psrs-backend/src/mir/lower/tail.rs @@ -29,6 +29,11 @@ pub(in crate::mir) fn rewrite( function: &mut Function, target: TargetCapabilities, ) -> Result<(), Vec> { + // EH-07: no forced tail call may live in a protected try region — Wasm + // pops the caller's frame at a `return_call`, discarding the armed + // `try_table`. Self-call loopification is unaffected: a loop allocates + // no frame. + let protected = crate::mir::try_region::plan(function, "P9 MIR lowering")?.protected_blocks(); let returned = return_forwarded_values(function); let uses = use_counts(function); let symbol = function.symbol; @@ -60,8 +65,12 @@ pub(in crate::mir) fn rewrite( span, .. } if *callee == symbol => { + // Self-call loopification is unaffected by protection: a + // loop allocates no frame. self_calls.push((block.id, arguments.clone(), *span)); } + // EH-07: keep a protected block's call ordinary plus its return. + _ if protected.contains(&block.id) => continue, Instruction::Call { contract, function: callee, @@ -393,12 +402,13 @@ fn use_counts(function: &Function) -> HashMap { fn terminator_operands(terminator: &Terminator) -> Vec { match terminator { - Terminator::Trap { .. } => Vec::new(), + Terminator::Trap { .. } | Terminator::Try { .. } => Vec::new(), Terminator::Return { value, .. } => vec![*value], Terminator::Jump { arguments, .. } => arguments.clone(), Terminator::Branch { condition, .. } => vec![*condition], Terminator::Switch { value, .. } => vec![*value], Terminator::ReturnCall { arguments, .. } => arguments.clone(), + Terminator::Throw { arguments, .. } => arguments.clone(), Terminator::ReturnCallRef { function, arguments, diff --git a/crates/psrs-backend/src/mir/mod.rs b/crates/psrs-backend/src/mir/mod.rs index e32796c5..9b193049 100644 --- a/crates/psrs-backend/src/mir/mod.rs +++ b/crates/psrs-backend/src/mir/mod.rs @@ -18,6 +18,8 @@ pub mod opt; mod planner; mod reachable; mod runtime; +mod tag; +mod try_region; mod value_identity; mod verify; mod wit; @@ -29,6 +31,7 @@ use planner::{GcPlanner, RepresentationPlanner}; pub use instruction::{Instruction, ListDirection}; pub use numeric::{NumericOp, UnaryOp}; pub use runtime::RuntimeImport; +pub use tag::{Tag, TagId}; pub use verify::{verify_module, verify_module_with_capabilities}; #[cfg(test)] @@ -57,6 +60,8 @@ pub struct Module { /// Checked physical ABI imports, selected from WIT or raw runtime contracts. pub imports: Vec, pub functions: Vec, + /// Compiler-defined exception tags; a `TagId` indexes this list. + pub tags: Vec, /// The program entry declaration, if selected by the driver. pub entry: Option, /// The concrete target layout the module was planned from. P10 reads the @@ -164,6 +169,22 @@ pub enum Terminator { arguments: Vec, span: TextRange, }, + /// Raises a tag's exception; the optional `handler` names the block it + /// unwinds to within this function (`None` unwinds to the caller). + Throw { + tag: TagId, + arguments: Vec, + handler: Option, + span: TextRange, + }, + /// Arms a protected region: control proceeds to `protected` as a + /// parameterless `Jump` would; catch clauses declare unwind behavior. + Try { + protected: BlockId, + catches: Vec<(TagId, BlockId)>, + catch_all: Option, + span: TextRange, + }, } /// Lowers a CC module to MIR, returning the module and the ABI registry that @@ -419,6 +440,7 @@ fn lower_module_after_binding_validation( strings, imports, functions, + tags: Vec::new(), entry: module.entry, layout: Some(layout), span: module.span, diff --git a/crates/psrs-backend/src/mir/number_format_tests.rs b/crates/psrs-backend/src/mir/number_format_tests.rs index 5a4df6f1..402b958c 100644 --- a/crates/psrs-backend/src/mir/number_format_tests.rs +++ b/crates/psrs-backend/src/mir/number_format_tests.rs @@ -17,6 +17,7 @@ fn registry() -> crate::abi::WasiRegistry { /// formatter artifact's raw export, and returns the initialized byte length. fn number_format_module() -> Module { Module { + tags: Vec::new(), name: "NumberFormat".into(), entry: Some(SymbolId::new(ModuleId(0), 0)), types: Vec::new(), diff --git a/crates/psrs-backend/src/mir/opt/cfg.rs b/crates/psrs-backend/src/mir/opt/cfg.rs index 92759f6c..a1ae7e5a 100644 --- a/crates/psrs-backend/src/mir/opt/cfg.rs +++ b/crates/psrs-backend/src/mir/opt/cfg.rs @@ -8,6 +8,32 @@ pub(super) fn prune_unreachable(module: &mut Module) -> bool { let mut changed = false; for function in &mut module.functions { let reachable = reachable_blocks(function.entry, &function.blocks); + // Exception handlers are reached only through unwind edges, which the + // normal-successor walk does not follow; keep every handler and + // everything it reaches normally. + let reachable = match crate::mir::try_region::handler_closure(function) { + handlers if handlers.is_empty() => reachable, + handlers => { + let by_id = function + .blocks + .iter() + .map(|block| (block.id, block)) + .collect::>(); + let mut kept = reachable; + let mut pending = VecDeque::from_iter(handlers); + while let Some(id) = pending.pop_back() { + if !kept.insert(id) { + continue; + } + if let Some(terminator) = + by_id.get(&id).and_then(|block| block.terminator.as_ref()) + { + pending.extend(crate::mir::cfg::successors(terminator)); + } + } + kept + } + }; let original_len = function.blocks.len(); function .blocks @@ -57,6 +83,8 @@ pub(super) fn simplify_terminators(module: &mut Module) -> bool { Terminator::Trap { .. } | Terminator::Return { .. } | Terminator::Jump { .. } + | Terminator::Throw { .. } + | Terminator::Try { .. } | Terminator::ReturnCall { .. } | Terminator::ReturnCallRef { .. } => return None, }; diff --git a/crates/psrs-backend/src/mir/opt/constants.rs b/crates/psrs-backend/src/mir/opt/constants.rs index 39cb89e2..379231f1 100644 --- a/crates/psrs-backend/src/mir/opt/constants.rs +++ b/crates/psrs-backend/src/mir/opt/constants.rs @@ -167,10 +167,14 @@ fn incoming_values(function: &Function, reachable: &HashSet } // Switch successors cannot carry block parameters, so there are no // incoming values to add to the block-parameter lattice. A tail - // call passes its arguments to a callee, not to a block. + // call passes its arguments to a callee, not to a block. A try + // region's protected entry cannot carry block parameters, and a + // throw's edges are unwinding, not parameter passing. Terminator::Switch { .. } | Terminator::ReturnCall { .. } - | Terminator::ReturnCallRef { .. } => {} + | Terminator::ReturnCallRef { .. } + | Terminator::Throw { .. } + | Terminator::Try { .. } => {} Terminator::Return { .. } | Terminator::Trap { .. } => {} } } diff --git a/crates/psrs-backend/src/mir/opt/imports.rs b/crates/psrs-backend/src/mir/opt/imports.rs index ff8c78f5..9afc6ca9 100644 --- a/crates/psrs-backend/src/mir/opt/imports.rs +++ b/crates/psrs-backend/src/mir/opt/imports.rs @@ -87,6 +87,7 @@ mod tests { let imported = SymbolId::new(ModuleId(1), 0); let span = TextRange::new(0, 1); Module { + tags: Vec::new(), name: "ImportProjectionTest".into(), types: Vec::new(), strings: Vec::new(), diff --git a/crates/psrs-backend/src/mir/opt/tests/mod.rs b/crates/psrs-backend/src/mir/opt/tests/mod.rs index 6635fc99..02fd87d6 100644 --- a/crates/psrs-backend/src/mir/opt/tests/mod.rs +++ b/crates/psrs-backend/src/mir/opt/tests/mod.rs @@ -20,6 +20,7 @@ fn value(id: u32, ty: ValueType) -> ValueDecl { fn module(functions: Vec, imports: Vec, entry: SymbolId) -> Module { Module { + tags: Vec::new(), name: "OptimizationTest".into(), types: Vec::new(), strings: Vec::new(), diff --git a/crates/psrs-backend/src/mir/opt/values.rs b/crates/psrs-backend/src/mir/opt/values.rs index a4e3c3cd..7aa71672 100644 --- a/crates/psrs-backend/src/mir/opt/values.rs +++ b/crates/psrs-backend/src/mir/opt/values.rs @@ -98,12 +98,13 @@ fn eliminate_function(function: &mut Function) { fn terminator_operands(terminator: &Terminator) -> Vec { match terminator { - Terminator::Trap { .. } => Vec::new(), + Terminator::Trap { .. } | Terminator::Try { .. } => Vec::new(), Terminator::Return { value, .. } => vec![*value], Terminator::Jump { arguments, .. } => arguments.clone(), Terminator::Branch { condition, .. } => vec![*condition], Terminator::Switch { value, .. } => vec![*value], Terminator::ReturnCall { arguments, .. } => arguments.clone(), + Terminator::Throw { arguments, .. } => arguments.clone(), Terminator::ReturnCallRef { function, arguments, @@ -373,12 +374,13 @@ pub(crate) fn remap_terminator(terminator: &mut Terminator, mapping: &HashMap {} + Terminator::Trap { .. } | Terminator::Try { .. } => {} Terminator::Return { value, .. } => replace(value), Terminator::Jump { arguments, .. } => arguments.iter_mut().for_each(replace), Terminator::Branch { condition, .. } => replace(condition), Terminator::Switch { value, .. } => replace(value), Terminator::ReturnCall { arguments, .. } => arguments.iter_mut().for_each(replace), + Terminator::Throw { arguments, .. } => arguments.iter_mut().for_each(replace), Terminator::ReturnCallRef { function, arguments, diff --git a/crates/psrs-backend/src/mir/tag.rs b/crates/psrs-backend/src/mir/tag.rs new file mode 100644 index 00000000..a6a6419c --- /dev/null +++ b/crates/psrs-backend/src/mir/tag.rs @@ -0,0 +1,22 @@ +//! The MIR tag table: module-local exception identities for the Wasm +//! exception channel. See `docs/design/backend/fp/exception-handling-and-stack-exhaustion.md`. + +use crate::types::ValueType; +use psrs_span::TextRange; + +/// Module-local tag identity. Distinct from `FunctionId`, `DefinedTypeId`, and +/// every other index domain; a `TagId` indexes `Module::tags` by position. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct TagId(pub u32); + +/// A compiler-defined tag: an exception kind with a payload type +/// `[t*] -> []`. The channel defines only module-local tags, so a tag is +/// neither imported nor exported. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Tag { + pub name: String, + /// At most one payload value; a multi-value payload needs the multi-value + /// block work and is rejected by the verifier. + pub payload: Vec, + pub span: TextRange, +} diff --git a/crates/psrs-backend/src/mir/tests/exception/capability.rs b/crates/psrs-backend/src/mir/tests/exception/capability.rs new file mode 100644 index 00000000..cb4ceed0 --- /dev/null +++ b/crates/psrs-backend/src/mir/tests/exception/capability.rs @@ -0,0 +1,93 @@ +//! EH-08 capability inference for the exception channel. + +use super::{at, block, module, simple_function, tag, tag_only_module}; +use crate::capability::TargetCapabilities; +use crate::mir::{BlockId, Terminator, ValueId, verify_module_with_capabilities}; + +fn try_module() -> crate::mir::Module { + module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![], + catch_all: Some(BlockId(2)), + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(11), + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(12), + }, + ), + ], + Vec::new(), + ), + Vec::new(), + ) +} + +#[test] +fn exception_terminators_require_the_exceptions_capability() { + let exception_module = try_module(); + let disabled = TargetCapabilities { + exceptions: false, + ..TargetCapabilities::default() + }; + let errors = verify_module_with_capabilities(&exception_module, disabled) + .expect_err("try regions require the exceptions gate"); + assert!(errors.iter().any(|error| { + error + .message + .contains("exception-handling proposal required by this module") + && error.span == exception_module.span + && error.module == exception_module.entry.map(|entry| entry.module) + })); + + let enabled = TargetCapabilities { + exceptions: true, + ..TargetCapabilities::default() + }; + verify_module_with_capabilities(&exception_module, enabled) + .expect("the enabled profile admits the try region"); +} + +#[test] +fn a_declared_tag_requires_the_exceptions_capability() { + let module = tag_only_module(vec![tag(0, Vec::new())]); + let disabled = TargetCapabilities { + exceptions: false, + ..TargetCapabilities::default() + }; + let errors = verify_module_with_capabilities(&module, disabled) + .expect_err("a declared tag requires the exceptions gate"); + assert!( + errors + .iter() + .any(|error| error.message.contains("exception-handling proposal")), + "{errors:?}" + ); + + let enabled = TargetCapabilities { + exceptions: true, + ..TargetCapabilities::default() + }; + verify_module_with_capabilities(&module, enabled).expect("the enabled profile admits the tag"); +} diff --git a/crates/psrs-backend/src/mir/tests/exception/mod.rs b/crates/psrs-backend/src/mir/tests/exception/mod.rs new file mode 100644 index 00000000..029a17a3 --- /dev/null +++ b/crates/psrs-backend/src/mir/tests/exception/mod.rs @@ -0,0 +1,383 @@ +//! MIR exception-channel tests (EH-05..EH-08): tag table, `Try`/`Throw` +//! terminators, unwind-edge verification, protected-region tail-call rules, +//! and capability inference. Fixtures are hand-built direct MIR. + +mod capability; +mod regions; +mod tail; +mod unwind; + +use crate::mir::{BasicBlock, BlockId, Function, Instruction, Module, Tag, TagId, Terminator}; +use crate::types::{FunctionId, ValueDecl, ValueId, ValueType}; +use psrs_hir::{ModuleId, SymbolId}; +use psrs_span::TextRange; + +pub(super) fn at(offset: u32) -> TextRange { + TextRange::new(offset, offset + 1) +} + +pub(super) fn tag(id: u32, payload: Vec) -> Tag { + Tag { + name: format!("psrs:test-{id}"), + payload, + span: at(800 + id), + } +} + +fn declarations(types: &[(u32, ValueType)]) -> Vec { + types + .iter() + .map(|(id, ty)| ValueDecl { + id: ValueId(*id), + ty: *ty, + }) + .collect() +} + +pub(super) fn block( + id: u32, + parameters: Vec, + instructions: Vec, + terminator: Terminator, +) -> BasicBlock { + BasicBlock { + id: BlockId(id), + parameters: parameters.into_iter().map(ValueId).collect(), + instructions, + terminator: Some(terminator), + } +} + +/// A fixture whose single `i32` function parameter is the result, so every +/// block can return it. +pub(super) fn simple_function( + blocks: Vec, + extra_values: Vec<(u32, ValueType)>, +) -> Function { + let mut values = vec![(0u32, ValueType::I32)]; + values.extend(extra_values); + Function { + id: FunctionId(0), + symbol: SymbolId::new(ModuleId(0), 0), + name: "try_fixture".into(), + parameters: vec![ValueId(0)], + values: declarations(&values), + entry: BlockId(0), + blocks, + result: ValueId(0), + result_type: ValueType::I32, + span: at(900), + } +} + +pub(super) fn module(function: Function, tags: Vec) -> Module { + let entry = function.symbol; + Module { + name: "ExceptionTest".into(), + types: Vec::new(), + strings: Vec::new(), + tags, + layout: None, + imports: Vec::new(), + functions: vec![function], + entry: Some(entry), + span: at(950), + } +} + +pub(super) fn tag_only_module(tags: Vec) -> Module { + Module { + name: "ExceptionTest".into(), + types: Vec::new(), + strings: Vec::new(), + tags, + layout: None, + imports: Vec::new(), + functions: Vec::new(), + entry: Some(SymbolId::new(ModuleId(0), 0)), + span: at(950), + } +} + +pub(super) fn terminator(block: &BasicBlock) -> &Terminator { + block + .terminator + .as_ref() + .expect("every fixture block terminates") +} + +use crate::mir::verify_module; + +// ---------------------------------------------------------------- EH-05 + +#[test] +fn try_and_throw_terminators_expose_the_documented_fields() { + let module = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![(TagId(0), BlockId(2))], + catch_all: None, + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Throw { + tag: TagId(0), + arguments: vec![], + handler: Some(BlockId(2)), + span: at(11), + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(12), + }, + ), + ], + Vec::new(), + ), + vec![tag(0, Vec::new())], + ); + verify_module(&module).expect("a well-formed try/throw module verifies"); + + let function = &module.functions[0]; + let Terminator::Try { + protected, + catches, + catch_all, + span, + } = terminator(&function.blocks[0]) + else { + panic!("the entry terminator is a Try"); + }; + assert_eq!(*protected, BlockId(1)); + assert_eq!(*catches, vec![(TagId(0), BlockId(2))]); + assert_eq!(*catch_all, None); + assert_eq!(*span, at(10)); + + let Terminator::Throw { + tag, + arguments, + handler, + span, + } = terminator(&function.blocks[1]) + else { + panic!("the protected terminator is a Throw"); + }; + assert_eq!(*tag, TagId(0)); + assert_eq!(*arguments, Vec::::new()); + assert_eq!(*handler, Some(BlockId(2))); + assert_eq!(*span, at(11)); + + assert_eq!(module.tags.len(), 1); + assert_eq!(module.tags[0].name, "psrs:test-0"); + assert_eq!(module.tags[0].payload, Vec::::new()); + assert_eq!(module.tags[0].span, at(800)); +} + +#[test] +fn rejects_a_throw_with_an_unknown_tag() { + let module = module( + simple_function( + vec![block( + 0, + vec![], + vec![], + Terminator::Throw { + tag: TagId(7), + arguments: vec![], + handler: None, + span: at(10), + }, + )], + Vec::new(), + ), + Vec::new(), + ); + let errors = verify_module(&module).expect_err("an unknown throw tag is rejected"); + assert!( + errors + .iter() + .any(|error| error.message.contains("unknown tag") && error.span == at(10)), + "{errors:?}" + ); +} + +#[test] +fn rejects_a_throw_payload_that_differs_from_its_tag() { + // Arity: the tag carries no payload, the throw passes one argument. + let arity = module( + simple_function( + vec![block( + 0, + vec![], + vec![], + Terminator::Throw { + tag: TagId(0), + arguments: vec![ValueId(0)], + handler: None, + span: at(10), + }, + )], + Vec::new(), + ), + vec![tag(0, Vec::new())], + ); + let errors = verify_module(&arity).expect_err("a payload arity mismatch is rejected"); + assert!( + errors + .iter() + .any(|error| error.message.contains("payload arity") && error.span == at(10)), + "{errors:?}" + ); + + // Type: the tag carries an i64 payload, the throw passes an i32 value. + let mismatched = module( + simple_function( + vec![block( + 0, + vec![], + vec![], + Terminator::Throw { + tag: TagId(0), + arguments: vec![ValueId(0)], + handler: None, + span: at(11), + }, + )], + Vec::new(), + ), + vec![tag(0, vec![ValueType::I64])], + ); + let errors = verify_module(&mismatched).expect_err("a payload type mismatch is rejected"); + assert!( + errors + .iter() + .any(|error| error.message.contains("payload has the wrong type") + && error.span == at(11)), + "{errors:?}" + ); + + // A payload agreement is accepted. + let agreeing = module( + simple_function( + vec![block( + 0, + vec![], + vec![], + Terminator::Throw { + tag: TagId(0), + arguments: vec![ValueId(0)], + handler: None, + span: at(12), + }, + )], + Vec::new(), + ), + vec![tag(0, vec![ValueType::I32])], + ); + verify_module(&agreeing).expect("an agreeing payload verifies"); +} + +#[test] +fn rejects_a_multi_value_tag_payload() { + let module = tag_only_module(vec![tag(0, vec![ValueType::I32, ValueType::I32])]); + let errors = verify_module(&module).expect_err("a multi-value payload is rejected"); + assert!( + errors + .iter() + .any(|error| error.message.contains("more than one value") && error.span == at(800)), + "{errors:?}" + ); +} + +#[test] +fn rejects_a_try_naming_a_missing_block() { + let missing_entry = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(9), + catches: vec![], + catch_all: Some(BlockId(1)), + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(11), + }, + ), + ], + Vec::new(), + ), + Vec::new(), + ); + let errors = verify_module(&missing_entry).expect_err("a missing protected entry is rejected"); + assert!( + errors.iter().any(|error| { + error + .message + .contains("protected entry block does not exist") + && error.span == at(10) + }), + "{errors:?}" + ); + + let missing_handler = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![], + catch_all: Some(BlockId(9)), + span: at(12), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(13), + }, + ), + ], + Vec::new(), + ), + Vec::new(), + ); + let errors = verify_module(&missing_handler).expect_err("a missing handler is rejected"); + assert!( + errors.iter().any(|error| { + error.message.contains("handler block does not exist") && error.span == at(12) + }), + "{errors:?}" + ); +} diff --git a/crates/psrs-backend/src/mir/tests/exception/regions.rs b/crates/psrs-backend/src/mir/tests/exception/regions.rs new file mode 100644 index 00000000..e298564a --- /dev/null +++ b/crates/psrs-backend/src/mir/tests/exception/regions.rs @@ -0,0 +1,242 @@ +//! EH-06 structural region fixtures: normal predecessors, handler +//! ownership, back edges, and forced tail calls in protected regions. + +use super::{at, block, module, simple_function, tag}; +use crate::mir::{BlockId, TagId, Terminator, ValueId, verify_module}; + +#[test] +fn rejects_a_handler_with_a_normal_predecessor() { + let module = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![], + catch_all: Some(BlockId(2)), + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Jump { + target: BlockId(2), + arguments: vec![], + span: at(11), + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(12), + }, + ), + ], + Vec::new(), + ), + Vec::new(), + ); + let errors = verify_module(&module).expect_err("a normal predecessor of a handler is rejected"); + assert!( + errors.iter().any(|error| { + error + .message + .contains("handler block has a normal predecessor") + && error.span == at(11) + }), + "{errors:?}" + ); +} + +#[test] +fn rejects_a_handler_claimed_by_two_try_regions() { + let module = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![(TagId(0), BlockId(4))], + catch_all: None, + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Jump { + target: BlockId(2), + arguments: vec![], + span: at(11), + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Try { + protected: BlockId(3), + catches: vec![(TagId(0), BlockId(4))], + catch_all: None, + span: at(12), + }, + ), + block( + 3, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(13), + }, + ), + block( + 4, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(14), + }, + ), + ], + Vec::new(), + ), + vec![tag(0, Vec::new())], + ); + let errors = verify_module(&module).expect_err("a handler claimed twice is rejected"); + assert!( + errors.iter().any(|error| { + error + .message + .contains("claimed by more than one try region") + && error.span == at(12) + }), + "{errors:?}" + ); +} + +#[test] +fn rejects_a_back_edge_targeting_a_try_block() { + let module = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![], + catch_all: Some(BlockId(2)), + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Jump { + target: BlockId(3), + arguments: vec![], + span: at(11), + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(12), + }, + ), + block( + 3, + vec![], + vec![], + Terminator::Jump { + target: BlockId(0), + arguments: vec![], + span: at(13), + }, + ), + ], + Vec::new(), + ), + Vec::new(), + ); + let errors = verify_module(&module).expect_err("a back edge into a try block is rejected"); + assert!( + errors.iter().any(|error| { + error.message.contains("back edge targets a try block") && error.span == at(13) + }), + "{errors:?}" + ); +} + +#[test] +fn rejects_a_forced_tail_call_in_a_protected_region() { + let span = at(20); + let module = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![], + catch_all: Some(BlockId(2)), + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::ReturnCall { + contract: None, + function: psrs_hir::SymbolId::new(psrs_hir::ModuleId(0), 0), + arguments: vec![ValueId(0)], + span, + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(12), + }, + ), + ], + Vec::new(), + ), + Vec::new(), + ); + let errors = verify_module(&module).expect_err("a forced tail call in a region is rejected"); + assert!( + errors.iter().any(|error| { + error + .message + .contains("protected try-region block ends in a forced tail call") + && error.span == span + }), + "{errors:?}" + ); +} diff --git a/crates/psrs-backend/src/mir/tests/exception/tail.rs b/crates/psrs-backend/src/mir/tests/exception/tail.rs new file mode 100644 index 00000000..e184e92b --- /dev/null +++ b/crates/psrs-backend/src/mir/tests/exception/tail.rs @@ -0,0 +1,110 @@ +//! EH-07 tail-call marking inside protected try regions. + +use super::{at, block, simple_function, terminator}; +use crate::capability::TargetCapabilities; +use crate::mir::lower::tail::rewrite; +use crate::mir::{BlockId, Instruction, Terminator, ValueId, ValueType}; +use psrs_hir::{ModuleId, SymbolId}; + +#[test] +fn mark_tail_keeps_a_protected_region_call_an_ordinary_call() { + let callee = SymbolId::new(ModuleId(0), 1); + let mut function = simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![], + catch_all: Some(BlockId(2)), + span: at(10), + }, + ), + // The protected block's call is forwarded to the join. + block( + 1, + vec![], + vec![Instruction::Call { + contract: None, + destination: ValueId(1), + function: callee, + arguments: vec![], + span: at(20), + }], + Terminator::Jump { + target: BlockId(3), + arguments: vec![ValueId(1)], + span: at(21), + }, + ), + // The handler's call is not protected. + block( + 2, + vec![], + vec![Instruction::Call { + contract: None, + destination: ValueId(2), + function: callee, + arguments: vec![], + span: at(22), + }], + Terminator::Jump { + target: BlockId(3), + arguments: vec![ValueId(2)], + span: at(23), + }, + ), + block( + 3, + vec![4], + vec![], + Terminator::Return { + value: ValueId(4), + span: at(24), + }, + ), + ], + vec![ + (1, ValueType::I32), + (2, ValueType::I32), + (4, ValueType::I32), + ], + ); + let target = TargetCapabilities { + tail_call: true, + exceptions: true, + ..TargetCapabilities::default() + }; + rewrite(&mut function, target).expect("tail marking succeeds"); + + let protected = &function.blocks[1]; + assert!( + protected + .instructions + .iter() + .any(|instruction| matches!(instruction, Instruction::Call { .. })), + "the protected call stays an instruction" + ); + assert!( + matches!(terminator(protected), Terminator::Jump { .. }), + "the protected block keeps its ordinary return path" + ); + + let handler = &function.blocks[2]; + assert!( + !handler + .instructions + .iter() + .any(|instruction| matches!(instruction, Instruction::Call { .. })), + "the unprotected call is consumed by the tail terminator" + ); + assert!( + matches!( + terminator(handler), + Terminator::ReturnCall { function, .. } if *function == callee + ), + "the unprotected call becomes a forced tail call" + ); +} diff --git a/crates/psrs-backend/src/mir/tests/exception/unwind.rs b/crates/psrs-backend/src/mir/tests/exception/unwind.rs new file mode 100644 index 00000000..75de917e --- /dev/null +++ b/crates/psrs-backend/src/mir/tests/exception/unwind.rs @@ -0,0 +1,393 @@ +//! EH-06 unwind-edge verification fixtures. + +use super::{at, block, module, simple_function, tag}; +use crate::mir::{BlockId, TagId, Terminator, ValueId, verify_module}; + +#[test] +fn accepts_a_well_formed_try_region_and_nested_tries() { + // A single `catch_all` region. + let plain = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![], + catch_all: Some(BlockId(2)), + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(11), + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(12), + }, + ), + ], + Vec::new(), + ), + Vec::new(), + ); + verify_module(&plain).expect("a catch_all region verifies"); + + // Nested regions: the inner region catches tag 1, the outer tag 0. The + // protected block throws to the inner handler; the inner handler's throw + // of the outer tag resolves outward, past the disarmed inner region. + let nested = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![(TagId(0), BlockId(5))], + catch_all: None, + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Try { + protected: BlockId(2), + catches: vec![(TagId(1), BlockId(4))], + catch_all: None, + span: at(11), + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Throw { + tag: TagId(1), + arguments: vec![], + handler: Some(BlockId(4)), + span: at(12), + }, + ), + block( + 4, + vec![], + vec![], + Terminator::Throw { + tag: TagId(0), + arguments: vec![], + handler: Some(BlockId(5)), + span: at(14), + }, + ), + block( + 5, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(15), + }, + ), + ], + Vec::new(), + ), + vec![tag(0, Vec::new()), tag(1, Vec::new())], + ); + verify_module(&nested).expect("nested try regions verify"); +} + +#[test] +fn rejects_a_throw_whose_handler_is_not_the_matching_innermost_handler() { + let module = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![(TagId(0), BlockId(5))], + catch_all: None, + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Try { + protected: BlockId(2), + catches: vec![(TagId(1), BlockId(4))], + catch_all: None, + span: at(11), + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Throw { + tag: TagId(1), + arguments: vec![], + handler: Some(BlockId(5)), + span: at(12), + }, + ), + block( + 4, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(14), + }, + ), + block( + 5, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(15), + }, + ), + ], + Vec::new(), + ), + vec![tag(0, Vec::new()), tag(1, Vec::new())], + ); + let errors = verify_module(&module).expect_err("a mismatched inner handler is rejected"); + assert!( + errors.iter().any(|error| { + error + .message + .contains("does not resolve to the innermost enclosing region's matching handler") + && error.span == at(12) + }), + "{errors:?}" + ); +} + +#[test] +fn rejects_a_throw_tag_no_enclosing_region_catches() { + // The inner region catches tag 1 only; a tag-0 throw inside it cannot + // name a handler because no enclosing region catches tag 0 either. + let module = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![(TagId(1), BlockId(2))], + catch_all: None, + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Throw { + tag: TagId(0), + arguments: vec![], + handler: Some(BlockId(2)), + span: at(11), + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(12), + }, + ), + ], + Vec::new(), + ), + vec![tag(0, Vec::new()), tag(1, Vec::new())], + ); + let errors = verify_module(&module).expect_err("an uncaught tag inside a region is rejected"); + assert!( + errors.iter().any(|error| { + error + .message + .contains("not caught by any enclosing try region") + && error.span == at(11) + }), + "{errors:?}" + ); +} + +#[test] +fn rejects_a_throw_outside_every_region_that_names_a_handler() { + let module = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Throw { + tag: TagId(0), + arguments: vec![], + handler: Some(BlockId(1)), + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(11), + }, + ), + ], + Vec::new(), + ), + vec![tag(0, Vec::new())], + ); + let errors = + verify_module(&module).expect_err("a handler named outside every region is rejected"); + assert!( + errors.iter().any(|error| { + error + .message + .contains("not caught by any enclosing try region") + && error.span == at(10) + }), + "{errors:?}" + ); +} + +#[test] +fn rejects_a_throw_without_a_handler_inside_a_protected_region() { + // The region catches a different tag, so no handler exists to name: the + // handler-less throw lies inside a protected region and is rejected. + let uncaught = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![(TagId(1), BlockId(2))], + catch_all: None, + span: at(10), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Throw { + tag: TagId(0), + arguments: vec![], + handler: None, + span: at(11), + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(12), + }, + ), + ], + Vec::new(), + ), + vec![tag(0, Vec::new()), tag(1, Vec::new())], + ); + let errors = + verify_module(&uncaught).expect_err("a handler-less throw inside a region is rejected"); + assert!( + errors.iter().any(|error| { + error + .message + .contains("without a handler lies inside a protected try region") + && error.span == at(11) + }), + "{errors:?}" + ); + + // When the region does catch the tag, the handler-less throw is rejected + // with the resolution rule instead. + let caught = module( + simple_function( + vec![ + block( + 0, + vec![], + vec![], + Terminator::Try { + protected: BlockId(1), + catches: vec![], + catch_all: Some(BlockId(2)), + span: at(15), + }, + ), + block( + 1, + vec![], + vec![], + Terminator::Throw { + tag: TagId(0), + arguments: vec![], + handler: None, + span: at(16), + }, + ), + block( + 2, + vec![], + vec![], + Terminator::Return { + value: ValueId(0), + span: at(17), + }, + ), + ], + Vec::new(), + ), + vec![tag(0, Vec::new())], + ); + let errors = verify_module(&caught) + .expect_err("a handler-less throw that the region catches is rejected"); + assert!( + errors.iter().any(|error| { + error + .message + .contains("must name the matching handler of the innermost enclosing try region") + && error.span == at(16) + }), + "{errors:?}" + ); +} diff --git a/crates/psrs-backend/src/mir/tests/lowering.rs b/crates/psrs-backend/src/mir/tests/lowering.rs index e2bc5172..e2299488 100644 --- a/crates/psrs-backend/src/mir/tests/lowering.rs +++ b/crates/psrs-backend/src/mir/tests/lowering.rs @@ -11,6 +11,7 @@ use psrs_hir::{ModuleId, SymbolId}; #[test] fn defined_types_flow_into_the_wasm_type_section() { let mir = Module { + tags: Vec::new(), name: "MirTypes".into(), entry: Some(SymbolId::new(ModuleId(0), 0)), types: vec![RecGroup(vec![DefinedType { @@ -67,6 +68,7 @@ fn defined_types_flow_into_the_wasm_type_section() { fn lowers_and_validates_f64_to_f32_abi_conversions() { let symbol = SymbolId::new(ModuleId(0), 0); let mir = Module { + tags: Vec::new(), name: "FloatAbi".into(), entry: Some(symbol), types: Vec::new(), @@ -156,6 +158,7 @@ fn lowers_an_imported_call() { .expect("get-stdout should resolve"); let callee = import.symbol; let mir = Module { + tags: Vec::new(), name: "MirImport".into(), entry: Some(SymbolId::new(ModuleId(0), 0)), types: Vec::new(), @@ -217,6 +220,7 @@ fn lowers_an_imported_call() { #[test] fn wasm_lowering_requires_an_explicit_entry_symbol() { let mir = Module { + tags: Vec::new(), name: "NoEntry".into(), entry: None, types: Vec::new(), diff --git a/crates/psrs-backend/src/mir/tests/mod.rs b/crates/psrs-backend/src/mir/tests/mod.rs index 1b19dd4c..b21fa239 100644 --- a/crates/psrs-backend/src/mir/tests/mod.rs +++ b/crates/psrs-backend/src/mir/tests/mod.rs @@ -1,6 +1,7 @@ //! MIR-level tests grouped by the boundary each one exercises. use psrs_span::TextRange; +mod exception; mod lowering; mod runtime; mod verify; diff --git a/crates/psrs-backend/src/mir/tests/runtime.rs b/crates/psrs-backend/src/mir/tests/runtime.rs index 39711cec..dcec5ef7 100644 --- a/crates/psrs-backend/src/mir/tests/runtime.rs +++ b/crates/psrs-backend/src/mir/tests/runtime.rs @@ -30,6 +30,7 @@ fn runs_a_mir_gc_struct_under_wasmtime() { heap: HeapType::Index(crate::types::DefinedTypeId(0)), }); let mir = Module { + tags: Vec::new(), name: "MirGc".into(), entry: Some(SymbolId::new(ModuleId(0), 0)), types: vec![RecGroup(vec![struct_type])], diff --git a/crates/psrs-backend/src/mir/tests/verify.rs b/crates/psrs-backend/src/mir/tests/verify.rs index 6ce8c572..1bee133c 100644 --- a/crates/psrs-backend/src/mir/tests/verify.rs +++ b/crates/psrs-backend/src/mir/tests/verify.rs @@ -11,6 +11,7 @@ use psrs_hir::{ModuleId, SymbolId}; #[test] fn rejects_a_struct_new_with_a_mistyped_field() { let mir = Module { + tags: Vec::new(), name: "BadStruct".into(), entry: None, types: vec![RecGroup(vec![DefinedType { diff --git a/crates/psrs-backend/src/mir/try_region.rs b/crates/psrs-backend/src/mir/try_region.rs new file mode 100644 index 00000000..004f2cfa --- /dev/null +++ b/crates/psrs-backend/src/mir/try_region.rs @@ -0,0 +1,319 @@ +//! The try-region partition: one definition of a `Try` terminator's protected +//! region, shared by MIR verification (`verify/exception.rs`), tail-call +//! marking (`lower/tail.rs`), and the Wasm structurer's try units. See the +//! design's "Try-region partition" algorithm in +//! `docs/design/backend/fp/exception-handling-and-stack-exhaustion.md`. +//! +//! The protected region of a `Try` (try block `t`, protected entry `p`) is the +//! set of blocks dominated by `p` over the normal CFG — the `Try` terminator +//! contributes the ordinary edge `t -> p` — minus the protected regions of +//! inner tries, minus every handler block. Dominance is a tree order, so raw +//! regions are nested or disjoint by construction; the carve-out turns nested +//! raw regions into sibling claims. The verifier's nesting check guards that +//! invariant. + +use super::cfg; +use super::{BlockId, Function, Terminator}; +use crate::BackendError; +use crate::mir::tag::TagId; +use psrs_span::TextRange; +use std::collections::{HashMap, HashSet}; + +/// One `Try` terminator's region. +pub(crate) struct TryRegion { + /// The block whose terminator is the `Try`. Consumed by the Wasm + /// structurer's try units (EH-10); the verifier and `mark_tail` do not + /// need it yet. + #[allow(dead_code)] + pub try_block: BlockId, + /// The first protected block; control enters it normally from `try_block`. + pub entry: BlockId, + pub catches: Vec<(TagId, BlockId)>, + pub catch_all: Option, + /// Blocks dominated by `entry`, minus the protected regions of inner + /// tries, minus every handler block. + pub protected: HashSet, + pub span: TextRange, +} + +impl TryRegion { + /// The handler the region assigns to a throw with `tag`: the matching + /// tag clause's handler, else the `catch_all` handler, else `None`. + pub(crate) fn matching_handler(&self, tag: TagId) -> Option { + self.catches + .iter() + .find(|(clause_tag, _)| *clause_tag == tag) + .map(|(_, handler)| *handler) + .or(self.catch_all) + } +} + +/// The partition of a function's blocks into try regions. +pub(crate) struct TryRegions { + /// Outermost protected regions first (largest protected set first). + pub regions: Vec, + /// Every clause handler mapped to its owning try block. + pub owners: HashMap, + /// Handler blocks claimed by a second `Try`, paired with that try's span. + pub conflicts: Vec<(BlockId, TextRange)>, +} + +impl TryRegions { + /// Every protected block, across all regions. + pub(crate) fn protected_blocks(&self) -> HashSet { + self.regions + .iter() + .flat_map(|region| region.protected.iter().copied()) + .collect() + } + + /// Every handler block named by any clause of any region. + pub(crate) fn handler_blocks(&self) -> HashSet { + self.owners.keys().copied().collect() + } +} + +/// Computes the partition and its structural checks. Fails with the offending +/// terminator's span when a region's blocks do not exist, when protected +/// regions overlap without nesting, or when a back edge targets a try block. +pub(crate) fn plan( + function: &Function, + pass: &'static str, +) -> Result> { + let tries = try_blocks(function); + if tries.is_empty() { + return Ok(TryRegions { + regions: Vec::new(), + owners: HashMap::new(), + conflicts: Vec::new(), + }); + } + let blocks = function + .blocks + .iter() + .map(|block| (block.id, block)) + .collect::>(); + let try_blocks = tries + .iter() + .map(|(try_block, ..)| *try_block) + .collect::>(); + + // Handler ownership: each clause handler belongs to exactly one `Try`. + let mut owners = HashMap::::new(); + let mut conflicts = Vec::new(); + for (try_block, terminator, span) in &tries { + for handler in clause_handlers(terminator) { + if owners.insert(handler, *try_block).is_some() { + conflicts.push((handler, *span)); + } + } + } + + // Existence of the blocks the terminator names, before dominance runs. + let mut errors = Vec::new(); + let mut valid = Vec::new(); + for (try_block, terminator, span) in &tries { + let Terminator::Try { + protected, + catches, + catch_all, + .. + } = terminator + else { + continue; + }; + if !blocks.contains_key(protected) { + errors.push(invalid( + pass, + *span, + "MIR try region's protected entry block does not exist", + )); + continue; + } + if clause_handlers(terminator) + .iter() + .any(|handler| !blocks.contains_key(handler)) + { + errors.push(invalid( + pass, + *span, + "MIR try clause handler block does not exist", + )); + continue; + } + valid.push((*try_block, *protected, catches.clone(), *catch_all, *span)); + } + + let dominators = cfg::dominators(function); + // Raw regions: blocks dominated by the protected entry. Innermost first + // (the stable sort keeps declaration order for equal sizes), so the + // carve-out below always sees an inner try's final claim. + let mut ordered = valid + .iter() + .map(|(try_block, entry, catches, catch_all, span)| { + let raw = function + .blocks + .iter() + .filter(|block| { + dominators + .get(&block.id) + .is_some_and(|dominators| dominators.contains(entry)) + }) + .map(|block| block.id) + .collect::>(); + (*try_block, *entry, catches.clone(), *catch_all, *span, raw) + }) + .collect::>(); + ordered.sort_by_key(|(_, _, _, _, _, raw)| raw.len()); + + let mut computed: Vec<(BlockId, HashSet)> = Vec::new(); + let mut regions = Vec::new(); + for (try_block, entry, catches, catch_all, span, raw) in ordered { + let mut protected = raw.clone(); + // Carve out every already-computed inner region's claim. Equal raw + // sets are one region claimed by two tries; neither carves the other, + // and the equal sets satisfy the nesting check as written. + for (_, done) in &computed { + if done.is_subset(&raw) && *done != raw { + protected.retain(|block| !done.contains(block)); + } + } + protected.retain(|block| !owners.contains_key(block)); + computed.push((try_block, protected.clone())); + regions.push(TryRegion { + try_block, + entry, + catches, + catch_all, + protected, + span, + }); + } + + // Protected regions of distinct tries are nested or are disjoint. + for (index, left) in regions.iter().enumerate() { + for right in ®ions[index + 1..] { + if left.protected.is_disjoint(&right.protected) { + continue; + } + if !left.protected.is_subset(&right.protected) + && !right.protected.is_subset(&left.protected) + { + errors.push(invalid( + pass, + right.span, + "MIR try regions overlap without nesting; the control flow is irreducible", + )); + } + } + } + + // No back edge targets a try block. + for block in &function.blocks { + let Some(terminator) = &block.terminator else { + continue; + }; + for target in cfg::successors(terminator) { + if try_blocks.contains(&target) + && dominators + .get(&block.id) + .is_some_and(|dominators| dominators.contains(&target)) + { + errors.push(invalid( + pass, + terminator_span(terminator), + "MIR back edge targets a try block", + )); + } + } + } + + if errors.is_empty() { + Ok(TryRegions { + regions, + owners, + conflicts, + }) + } else { + Err(errors) + } +} + +/// The handler blocks named by any try clause, catch_all, or throw, without +/// the partition's dominance work. Passes that must not drop handler blocks +/// (reachability pruning) use this; the partition derives the same set from +/// the clauses it plans. +pub(crate) fn handler_closure(function: &Function) -> HashSet { + let mut handlers = HashSet::new(); + for block in &function.blocks { + let Some(terminator) = &block.terminator else { + continue; + }; + match terminator { + Terminator::Try { + catches, catch_all, .. + } => { + for (_, handler) in catches { + handlers.insert(*handler); + } + if let Some(handler) = catch_all { + handlers.insert(*handler); + } + } + Terminator::Throw { + handler: Some(handler), + .. + } => { + handlers.insert(*handler); + } + _ => {} + } + } + handlers +} + +fn try_blocks(function: &Function) -> Vec<(BlockId, &Terminator, TextRange)> { + function + .blocks + .iter() + .filter_map(|block| match &block.terminator { + Some(terminator @ Terminator::Try { span, .. }) => Some((block.id, terminator, *span)), + _ => None, + }) + .collect() +} + +fn clause_handlers(terminator: &Terminator) -> Vec { + let Terminator::Try { + catches, catch_all, .. + } = terminator + else { + return Vec::new(); + }; + let mut handlers = catches + .iter() + .map(|(_, handler)| *handler) + .collect::>(); + if let Some(handler) = catch_all { + handlers.push(*handler); + } + handlers +} + +fn terminator_span(terminator: &Terminator) -> TextRange { + match terminator { + Terminator::Trap { span } + | Terminator::Return { span, .. } + | Terminator::Jump { span, .. } + | Terminator::Branch { span, .. } + | Terminator::Switch { span, .. } + | Terminator::ReturnCall { span, .. } + | Terminator::ReturnCallRef { span, .. } + | Terminator::Throw { span, .. } + | Terminator::Try { span, .. } => *span, + } +} + +fn invalid(pass: &'static str, span: TextRange, message: &'static str) -> BackendError { + BackendError::invalid_ir(pass, span, message) +} 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 63bbd711..57f6e459 100644 --- a/crates/psrs-backend/src/mir/verify/array_map/tests.rs +++ b/crates/psrs-backend/src/mir/verify/array_map/tests.rs @@ -8,6 +8,7 @@ fn verified_array_map_module(mut function: Function) -> crate::mir::Module { use crate::types::{CompositeType, DefinedType, FieldType, RecGroup, StorageType}; function.parameters.push(ValueId(8)); crate::mir::Module { + tags: Vec::new(), name: "ArrayMapVerifier".into(), types: vec![ RecGroup(vec![DefinedType { diff --git a/crates/psrs-backend/src/mir/verify/capability.rs b/crates/psrs-backend/src/mir/verify/capability.rs index af2ef46a..8bbb3714 100644 --- a/crates/psrs-backend/src/mir/verify/capability.rs +++ b/crates/psrs-backend/src/mir/verify/capability.rs @@ -11,6 +11,7 @@ struct RequiredCapabilities { gc: bool, multi_value: bool, tail_call: bool, + exceptions: bool, } /// Checks the capabilities that the current MIR module actually requires. @@ -66,8 +67,18 @@ pub(super) fn validate_target_capabilities( { required.tail_call = true; } + if let Some(mir::Terminator::Throw { .. } | mir::Terminator::Try { .. }) = + &block.terminator + { + required.exceptions = true; + } } } + // Declaring a tag commits the module to the exception channel even before + // any terminator uses it. + if !module.tags.is_empty() { + required.exceptions = true; + } let mut errors = Vec::new(); if required.reference_types && !target.reference_types { @@ -100,6 +111,12 @@ pub(super) fn validate_target_capabilities( "the selected target does not support the WebAssembly tail-call proposal required by this module", )); } + if required.exceptions && !target.exceptions { + errors.extend(mir_error( + module.span, + "the selected target does not support the WebAssembly exception-handling proposal required by this module", + )); + } if errors.is_empty() { Ok(()) } else { @@ -181,318 +198,3 @@ fn mark_instruction(instruction: &mir::Instruction, required: &mut RequiredCapab _ => {} } } - -#[cfg(test)] -mod tests { - use super::validate_target_capabilities; - use crate::TargetCapabilities; - use crate::mir; - use crate::types::{CompositeType, DefinedType, HeapType, RecGroup, RefType, ValueType}; - use psrs_hir::{ModuleId, SymbolId}; - use psrs_span::TextRange; - - #[test] - fn rejects_gc_when_the_target_disables_it() { - let module = mir::Module { - name: "gc".into(), - types: vec![RecGroup(vec![DefinedType { - final_type: true, - supertype: None, - composite: CompositeType::Struct(Vec::new()), - }])], - strings: Vec::new(), - layout: None, - imports: Vec::new(), - functions: Vec::new(), - entry: None, - span: TextRange::new(0, 1), - }; - let target = TargetCapabilities { - gc: false, - reference_types: false, - ..TargetCapabilities::default() - }; - let errors = validate_target_capabilities(&module, target) - .expect_err("a GC type must be rejected by a non-GC target"); - assert!(errors.iter().any(|error| error.message.contains("GC"))); - } - - #[test] - fn any_and_eq_reference_types_require_gc_with_source_attribution() { - for heap in [HeapType::Any, HeapType::Eq] { - let module = module_with_function_type( - vec![ValueType::Ref(RefType { - nullable: true, - heap, - })], - Vec::new(), - ); - let target = TargetCapabilities { - gc: false, - ..TargetCapabilities::default() - }; - - let errors = validate_target_capabilities(&module, target) - .expect_err("anyref and eqref require the GC capability"); - assert!(errors.iter().any(|error| { - error.message.contains("WebAssembly GC") - && error.span == module.span - && error.module == module.entry.map(|entry| entry.module) - })); - } - } - - #[test] - fn function_references_require_both_reference_capability_gates() { - let module = module_with_function_type( - vec![ValueType::Ref(RefType { - nullable: true, - heap: HeapType::Func, - })], - Vec::new(), - ); - let target = TargetCapabilities { - function_references: false, - ..TargetCapabilities::default() - }; - let errors = validate_target_capabilities(&module, target) - .expect_err("typed function references require function_references"); - assert!(errors.iter().any(|error| { - error.message.contains("typed function references") - && error.span == module.span - && error.module == module.entry.map(|entry| entry.module) - })); - - let target = TargetCapabilities { - reference_types: false, - ..TargetCapabilities::default() - }; - let errors = validate_target_capabilities(&module, target) - .expect_err("typed function references also require reference_types"); - assert!(errors.iter().any(|error| { - error.message.contains("reference types") && error.span == module.span - })); - } - - #[test] - fn extern_references_require_reference_types_but_not_gc() { - let module = module_with_function_type( - vec![ValueType::Ref(RefType { - nullable: true, - heap: HeapType::Extern, - })], - Vec::new(), - ); - let target = TargetCapabilities { - gc: false, - reference_types: false, - ..TargetCapabilities::default() - }; - let errors = validate_target_capabilities(&module, target) - .expect_err("extern references require reference_types"); - assert_eq!(errors.len(), 1); - assert!(errors[0].message.contains("reference types")); - - let target = TargetCapabilities { - gc: false, - ..TargetCapabilities::default() - }; - validate_target_capabilities(&module, target).expect("extern references do not require GC"); - } - - #[test] - fn multi_value_function_types_are_rejected_when_the_gate_is_disabled() { - let module = module_with_function_type(Vec::new(), vec![ValueType::I32, ValueType::I64]); - let target = TargetCapabilities { - multi_value: false, - ..TargetCapabilities::default() - }; - - let errors = validate_target_capabilities(&module, target) - .expect_err("multiple function results require multi_value"); - assert!(errors.iter().any(|error| { - error.message.contains("multi-value") - && error.span == module.span - && error.module == module.entry.map(|entry| entry.module) - })); - } - - #[test] - fn imported_function_reference_signatures_are_checked() { - let mut module = module_with_function_type(Vec::new(), Vec::new()); - module.types.clear(); - module.imports.push(mir::Import { - runtime: None, - symbol: SymbolId::new(ModuleId(4), 9), - parameters: vec![ValueType::Ref(RefType { - nullable: true, - heap: HeapType::Func, - })], - result: None, - }); - let target = TargetCapabilities { - function_references: false, - ..TargetCapabilities::default() - }; - - let errors = validate_target_capabilities(&module, target) - .expect_err("an imported function reference requires its target capability"); - assert!(errors.iter().any(|error| { - error.message.contains("typed function references") - && error.span == module.span - && error.module == module.entry.map(|entry| entry.module) - })); - } - - fn module_with_function_type( - parameters: Vec, - results: Vec, - ) -> mir::Module { - let span = TextRange::new(17, 31); - mir::Module { - name: "capability-test".into(), - types: vec![RecGroup(vec![DefinedType { - final_type: true, - supertype: None, - composite: CompositeType::Func { - parameters, - results, - }, - }])], - strings: Vec::new(), - layout: None, - imports: Vec::new(), - functions: Vec::new(), - entry: Some(SymbolId::new(ModuleId(4), 0)), - span, - } - } - - fn module_with_instruction(instruction: mir::Instruction) -> mir::Module { - let span = TextRange::new(17, 31); - mir::Module { - name: "capability-instruction-test".into(), - types: Vec::new(), - strings: Vec::new(), - layout: None, - imports: Vec::new(), - functions: vec![mir::Function { - id: crate::types::FunctionId(0), - symbol: SymbolId::new(ModuleId(4), 0), - name: "capability".into(), - parameters: Vec::new(), - values: Vec::new(), - entry: mir::BlockId(0), - blocks: vec![mir::BasicBlock { - id: mir::BlockId(0), - parameters: Vec::new(), - instructions: vec![instruction], - terminator: Some(mir::Terminator::Return { - value: crate::types::ValueId(0), - span, - }), - }], - result: crate::types::ValueId(0), - result_type: ValueType::I32, - span, - }], - entry: Some(SymbolId::new(ModuleId(4), 0)), - span, - } - } - - #[test] - fn closure_operations_require_typed_function_references() { - let module = module_with_instruction(mir::Instruction::ClosureGetCapture { - destination: crate::types::ValueId(0), - closure: crate::types::ValueId(1), - closure_type: crate::types::DefinedTypeId(0), - capture_array_type: crate::types::DefinedTypeId(1), - boxed_integer_type: None, - boxed_f64_type: None, - index: 0, - span: TextRange::new(0, 1), - }); - let target = TargetCapabilities { - function_references: false, - ..TargetCapabilities::default() - }; - let errors = validate_target_capabilities(&module, target) - .expect_err("a closure operation requires typed function references"); - assert!( - errors - .iter() - .any(|error| error.message.contains("typed function references")), - "{errors:?}" - ); - } - - #[test] - fn i31_operations_require_reference_types() { - let module = module_with_instruction(mir::Instruction::I31GetS { - destination: crate::types::ValueId(0), - value: crate::types::ValueId(1), - span: TextRange::new(0, 1), - }); - let target = TargetCapabilities { - reference_types: false, - ..TargetCapabilities::default() - }; - let errors = validate_target_capabilities(&module, target) - .expect_err("an i31 operation requires reference types"); - assert!( - errors - .iter() - .any(|error| error.message.contains("reference types")), - "{errors:?}" - ); - } - - #[test] - fn return_calls_require_the_tail_call_proposal() { - let span = TextRange::new(17, 31); - let module = mir::Module { - name: "capability-tail-test".into(), - types: Vec::new(), - strings: Vec::new(), - layout: None, - imports: Vec::new(), - functions: vec![mir::Function { - id: crate::types::FunctionId(0), - symbol: SymbolId::new(ModuleId(4), 0), - name: "tail".into(), - parameters: Vec::new(), - values: Vec::new(), - entry: mir::BlockId(0), - blocks: vec![mir::BasicBlock { - id: mir::BlockId(0), - parameters: Vec::new(), - instructions: Vec::new(), - terminator: Some(mir::Terminator::ReturnCall { - contract: None, - function: SymbolId::new(ModuleId(4), 1), - arguments: Vec::new(), - span, - }), - }], - result: crate::types::ValueId(0), - result_type: ValueType::I32, - span, - }], - entry: Some(SymbolId::new(ModuleId(4), 0)), - span, - }; - let target = TargetCapabilities { - tail_call: false, - ..TargetCapabilities::default() - }; - let errors = validate_target_capabilities(&module, target) - .expect_err("a tail call requires the tail-call proposal"); - assert!( - errors - .iter() - .any(|error| error.message.contains("tail-call")), - "{errors:?}" - ); - } -} diff --git a/crates/psrs-backend/src/mir/verify/exception.rs b/crates/psrs-backend/src/mir/verify/exception.rs new file mode 100644 index 00000000..d7fc13aa --- /dev/null +++ b/crates/psrs-backend/src/mir/verify/exception.rs @@ -0,0 +1,275 @@ +//! MIR verification of the exception channel: unwind edges, handler blocks, +//! protected regions, and tail calls inside them. The protected-region +//! partition itself is `crate::mir::try_region`; this module checks the +//! terminator-level rules on top of it. + +use super::util::{mir_error, value_type}; +use crate::mir::tag::Tag; +use crate::mir::try_region::{self, TryRegions}; +use crate::mir::{BasicBlock, BlockId, Function, Terminator, ValueType}; +use crate::types::ValueId; +use psrs_span::TextRange; +use std::collections::HashMap; + +/// Module-level tag checks: the single-payload rule and payload type range. +pub(super) fn verify_tags( + module: &crate::mir::Module, + defined_types: super::DefinedTypeId, + errors: &mut Vec, +) { + for tag in &module.tags { + if tag.payload.len() > 1 { + errors.extend(mir_error( + tag.span, + "MIR tag payload has more than one value; multi-value payloads need the multi-value block work", + )); + } + for payload in &tag.payload { + super::verify_value_type(payload, defined_types, tag.span, errors); + } + } +} + +/// Per-function checks of every `Try` and `Throw` terminator, plus the +/// protected-region rule that no block inside a region ends in a forced tail +/// call. The operand existence and dominance checks for `Throw` arguments run +/// in `super::function` like every other terminator's. +pub(super) fn verify_function( + function: &Function, + blocks: &HashMap, + definitions: &HashMap, + tags: &[Tag], +) -> Result<(), Vec> { + let regions = try_region::plan(function, "P9 MIR verification")?; + let mut errors = Vec::new(); + let handlers = regions.handler_blocks(); + + // Clause tags resolve, and each clause's handler receives its caught + // payload as block parameters typed exactly as the tag's payload (none + // for `catch_all`). + for region in ®ions.regions { + for (tag_id, _) in ®ion.catches { + if tags.get(tag_id.0 as usize).is_none() { + errors.extend(mir_error( + region.span, + "MIR catch clause names an unknown tag", + )); + } + } + for (tag_id, handler) in ®ion.catches { + let payload = tags + .get(tag_id.0 as usize) + .map(|tag| tag.payload.as_slice()) + .unwrap_or(&[]); + check_handler_parameters(function, region.span, *handler, payload, &mut errors); + } + if let Some(handler) = region.catch_all { + check_handler_parameters(function, region.span, handler, &[], &mut errors); + } + } + // A handler belongs to exactly one `Try`. + for (_, span) in ®ions.conflicts { + errors.extend(mir_error( + *span, + "MIR handler block is claimed by more than one try region", + )); + } + // A handler has no normal predecessors: every predecessor edge is an + // unwind edge of a `Throw` naming it or a catch clause of its `Try`. + if handlers.contains(&function.entry) { + errors.extend(mir_error( + function.span, + "MIR handler block is the function entry", + )); + } + for block in &function.blocks { + let Some(terminator) = &block.terminator else { + continue; + }; + for target in crate::mir::cfg::successors(terminator) { + if handlers.contains(&target) { + errors.extend(mir_error( + terminator_span(terminator), + "MIR handler block has a normal predecessor", + )); + } + } + } + // Control enters the protected entry as a parameterless `Jump` would. + for region in ®ions.regions { + if blocks + .get(®ion.entry) + .is_some_and(|block| !block.parameters.is_empty()) + { + errors.extend(mir_error( + region.span, + "MIR try region's protected entry has block parameters", + )); + } + } + verify_throws(function, ®ions, tags, definitions, &mut errors); + verify_no_forced_tail_calls(function, ®ions, &mut errors); + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } +} + +fn check_handler_parameters( + function: &Function, + span: TextRange, + handler: BlockId, + payload: &[ValueType], + errors: &mut Vec, +) { + let Some(block) = function.blocks.iter().find(|block| block.id == handler) else { + return; + }; + if block.parameters.len() != payload.len() { + errors.extend(mir_error( + span, + "MIR catch handler parameters differ from the caught payload", + )); + return; + } + for (parameter, expected) in block.parameters.iter().zip(payload) { + match value_type(function, *parameter) { + Some(actual) if actual == *expected => {} + Some(_) => errors.extend(mir_error( + span, + "MIR catch handler parameter has the wrong type", + )), + None => errors.extend(mir_error( + span, + "MIR catch handler parameter has no value type", + )), + } + } +} + +fn verify_throws( + function: &Function, + regions: &TryRegions, + tags: &[Tag], + definitions: &HashMap, + errors: &mut Vec, +) { + for block in &function.blocks { + let Some(Terminator::Throw { + tag, + arguments, + handler, + span, + }) = &block.terminator + else { + continue; + }; + let Some(tag_def) = tags.get(tag.0 as usize) else { + errors.extend(mir_error(*span, "MIR throw names an unknown tag")); + continue; + }; + if arguments.len() != tag_def.payload.len() { + errors.extend(mir_error( + *span, + "MIR throw payload arity differs from the tag", + )); + } else if let Some(expected) = tag_def.payload.first() { + match definitions.get(&arguments[0]) { + Some(actual) if actual == expected => {} + Some(_) => errors.extend(mir_error(*span, "MIR throw payload has the wrong type")), + None => errors.extend(mir_error(*span, "MIR throw payload has no value type")), + } + } + // The handler resolves to the innermost enclosing region's matching + // handler: the first region, walking outward, that catches the tag (a + // tag clause or `catch_all`). For a throw inside a handler block the + // owning region's `try_table` is already disarmed, so the enclosing + // chain starts at the regions containing that try block. Regions + // nest, so the containing regions form a chain. + let base = regions.owners.get(&block.id).copied().unwrap_or(block.id); + let is_handler_block = regions.owners.contains_key(&block.id); + let mut enclosing = regions + .regions + .iter() + .filter(|region| region.protected.contains(&base)) + .collect::>(); + enclosing.sort_by_key(|region| region.protected.len()); + let landing = enclosing + .iter() + .find(|region| region.matching_handler(*tag).is_some()) + .and_then(|region| region.matching_handler(*tag)); + match handler { + None => { + if landing.is_some() { + errors.extend(mir_error( + *span, + "MIR throw must name the matching handler of the innermost enclosing try region", + )); + } else if !is_handler_block + && regions + .regions + .iter() + .any(|region| region.protected.contains(&block.id)) + { + errors.extend(mir_error( + *span, + "MIR throw without a handler lies inside a protected try region", + )); + } + } + Some(resolved) => match landing { + None => errors.extend(mir_error( + *span, + "MIR throw tag is not caught by any enclosing try region", + )), + Some(landing) => { + if landing != *resolved { + errors.extend(mir_error( + *span, + "MIR throw handler does not resolve to the innermost enclosing region's matching handler", + )); + } + } + }, + } + } +} + +fn verify_no_forced_tail_calls( + function: &Function, + regions: &TryRegions, + errors: &mut Vec, +) { + let protected = regions.protected_blocks(); + for block in &function.blocks { + let Some(terminator) = &block.terminator else { + continue; + }; + if protected.contains(&block.id) + && matches!( + terminator, + Terminator::ReturnCall { .. } | Terminator::ReturnCallRef { .. } + ) + { + errors.extend(mir_error( + terminator_span(terminator), + "MIR protected try-region block ends in a forced tail call", + )); + } + } +} + +fn terminator_span(terminator: &Terminator) -> TextRange { + match terminator { + Terminator::Trap { span } + | Terminator::Return { span, .. } + | Terminator::Jump { span, .. } + | Terminator::Branch { span, .. } + | Terminator::Switch { span, .. } + | Terminator::ReturnCall { span, .. } + | Terminator::ReturnCallRef { span, .. } + | Terminator::Throw { span, .. } + | Terminator::Try { span, .. } => *span, + } +} diff --git a/crates/psrs-backend/src/mir/verify/function.rs b/crates/psrs-backend/src/mir/verify/function.rs index 78fb98d9..d7d747b4 100644 --- a/crates/psrs-backend/src/mir/verify/function.rs +++ b/crates/psrs-backend/src/mir/verify/function.rs @@ -14,6 +14,7 @@ pub(super) fn verify_function( function: &Function, signatures: &HashMap>, defined: &[&DefinedType], + tags: &[crate::mir::Tag], ) -> Result<(), Vec> { let blocks = function .blocks @@ -172,6 +173,9 @@ pub(super) fn verify_function( } super::array_map::verify_array_maps(function)?; super::array_map::verify_conversion_helpers(function, defined)?; + // The exception channel's unwind edges, handlers, and protected-region + // rules; they need the same block map and dominance the checks above use. + super::exception::verify_function(function, &blocks, &definitions, tags)?; Ok(()) } @@ -197,12 +201,13 @@ fn value_available( fn terminator_operands(terminator: &Terminator) -> Vec { match terminator { - Terminator::Trap { .. } => Vec::new(), + Terminator::Trap { .. } | Terminator::Try { .. } => Vec::new(), Terminator::Return { value, .. } => vec![*value], Terminator::Jump { arguments, .. } => arguments.clone(), Terminator::Branch { condition, .. } => vec![*condition], Terminator::Switch { value, .. } => vec![*value], Terminator::ReturnCall { arguments, .. } => arguments.clone(), + Terminator::Throw { arguments, .. } => arguments.clone(), Terminator::ReturnCallRef { function, arguments, @@ -221,7 +226,9 @@ fn terminator_span(terminator: &Terminator) -> psrs_span::TextRange { | Terminator::Branch { span, .. } | Terminator::Switch { span, .. } | Terminator::ReturnCall { span, .. } - | Terminator::ReturnCallRef { span, .. } => *span, + | Terminator::ReturnCallRef { span, .. } + | Terminator::Throw { span, .. } + | Terminator::Try { span, .. } => *span, } } @@ -352,6 +359,10 @@ fn verify_terminator( defined, )?; } + // `Throw`/`Try` payload and unwind-edge checks live in + // `super::exception`, which runs once per function with the + // protected-region partition. + Terminator::Throw { .. } | Terminator::Try { .. } => {} } Ok(()) } diff --git a/crates/psrs-backend/src/mir/verify/mod.rs b/crates/psrs-backend/src/mir/verify/mod.rs index c6cf74c2..47a3492a 100644 --- a/crates/psrs-backend/src/mir/verify/mod.rs +++ b/crates/psrs-backend/src/mir/verify/mod.rs @@ -12,6 +12,7 @@ mod array_map; mod call; mod capability; mod captures; +mod exception; mod function; mod instruction; mod records; @@ -36,6 +37,7 @@ pub fn verify_module(module: &Module) -> Result<(), Vec> { let mut errors = Vec::new(); super::control::verify(module, &mut errors); verify_defined_types(module, &mut errors); + exception::verify_tags(module, defined_type_count(module), &mut errors); variants::verify(module, &mut errors); let mut signatures = module .functions @@ -83,7 +85,9 @@ pub fn verify_module(module: &Module) -> Result<(), Vec> { for value in &function.values { verify_value_type(&value.ty, defined_types, module.span, &mut errors); } - if let Err(function_errors) = function::verify_function(function, &signatures, &defined) { + if let Err(function_errors) = + function::verify_function(function, &signatures, &defined, &module.tags) + { errors.extend( function_errors .into_iter() diff --git a/crates/psrs-backend/src/mir/verify/tests/capability.rs b/crates/psrs-backend/src/mir/verify/tests/capability.rs new file mode 100644 index 00000000..ec6eb8d1 --- /dev/null +++ b/crates/psrs-backend/src/mir/verify/tests/capability.rs @@ -0,0 +1,317 @@ +//! Capability-inference tests, moved out of `verify/capability.rs` for the +//! 500-line limit. EH-08's `exceptions` inference tests live here too. + +use super::super::capability::validate_target_capabilities; +use crate::TargetCapabilities; +use crate::mir; +use crate::types::{CompositeType, DefinedType, HeapType, RecGroup, RefType, ValueType}; +use psrs_hir::{ModuleId, SymbolId}; +use psrs_span::TextRange; + +#[test] +fn rejects_gc_when_the_target_disables_it() { + let module = mir::Module { + name: "gc".into(), + types: vec![RecGroup(vec![DefinedType { + final_type: true, + supertype: None, + composite: CompositeType::Struct(Vec::new()), + }])], + strings: Vec::new(), + tags: Vec::new(), + layout: None, + imports: Vec::new(), + functions: Vec::new(), + entry: None, + span: TextRange::new(0, 1), + }; + let target = TargetCapabilities { + gc: false, + reference_types: false, + ..TargetCapabilities::default() + }; + let errors = validate_target_capabilities(&module, target) + .expect_err("a GC type must be rejected by a non-GC target"); + assert!(errors.iter().any(|error| error.message.contains("GC"))); +} + +#[test] +fn any_and_eq_reference_types_require_gc_with_source_attribution() { + for heap in [HeapType::Any, HeapType::Eq] { + let module = module_with_function_type( + vec![ValueType::Ref(RefType { + nullable: true, + heap, + })], + Vec::new(), + ); + let target = TargetCapabilities { + gc: false, + ..TargetCapabilities::default() + }; + + let errors = validate_target_capabilities(&module, target) + .expect_err("anyref and eqref require the GC capability"); + assert!(errors.iter().any(|error| { + error.message.contains("WebAssembly GC") + && error.span == module.span + && error.module == module.entry.map(|entry| entry.module) + })); + } +} + +#[test] +fn function_references_require_both_reference_capability_gates() { + let module = module_with_function_type( + vec![ValueType::Ref(RefType { + nullable: true, + heap: HeapType::Func, + })], + Vec::new(), + ); + let target = TargetCapabilities { + function_references: false, + ..TargetCapabilities::default() + }; + let errors = validate_target_capabilities(&module, target) + .expect_err("typed function references require function_references"); + assert!(errors.iter().any(|error| { + error.message.contains("typed function references") + && error.span == module.span + && error.module == module.entry.map(|entry| entry.module) + })); + + let target = TargetCapabilities { + reference_types: false, + ..TargetCapabilities::default() + }; + let errors = validate_target_capabilities(&module, target) + .expect_err("typed function references also require reference_types"); + assert!( + errors + .iter() + .any(|error| error.message.contains("reference types") && error.span == module.span) + ); +} + +#[test] +fn extern_references_require_reference_types_but_not_gc() { + let module = module_with_function_type( + vec![ValueType::Ref(RefType { + nullable: true, + heap: HeapType::Extern, + })], + Vec::new(), + ); + let target = TargetCapabilities { + gc: false, + reference_types: false, + ..TargetCapabilities::default() + }; + let errors = validate_target_capabilities(&module, target) + .expect_err("extern references require reference_types"); + assert_eq!(errors.len(), 1); + assert!(errors[0].message.contains("reference types")); + + let target = TargetCapabilities { + gc: false, + ..TargetCapabilities::default() + }; + validate_target_capabilities(&module, target).expect("extern references do not require GC"); +} + +#[test] +fn multi_value_function_types_are_rejected_when_the_gate_is_disabled() { + let module = module_with_function_type(Vec::new(), vec![ValueType::I32, ValueType::I64]); + let target = TargetCapabilities { + multi_value: false, + ..TargetCapabilities::default() + }; + + let errors = validate_target_capabilities(&module, target) + .expect_err("multiple function results require multi_value"); + assert!(errors.iter().any(|error| { + error.message.contains("multi-value") + && error.span == module.span + && error.module == module.entry.map(|entry| entry.module) + })); +} + +#[test] +fn imported_function_reference_signatures_are_checked() { + let mut module = module_with_function_type(Vec::new(), Vec::new()); + module.types.clear(); + module.imports.push(mir::Import { + runtime: None, + symbol: SymbolId::new(ModuleId(4), 9), + parameters: vec![ValueType::Ref(RefType { + nullable: true, + heap: HeapType::Func, + })], + result: None, + }); + let target = TargetCapabilities { + function_references: false, + ..TargetCapabilities::default() + }; + + let errors = validate_target_capabilities(&module, target) + .expect_err("an imported function reference requires its target capability"); + assert!(errors.iter().any(|error| { + error.message.contains("typed function references") + && error.span == module.span + && error.module == module.entry.map(|entry| entry.module) + })); +} + +fn module_with_function_type(parameters: Vec, results: Vec) -> mir::Module { + let span = TextRange::new(17, 31); + mir::Module { + name: "capability-test".into(), + types: vec![RecGroup(vec![DefinedType { + final_type: true, + supertype: None, + composite: CompositeType::Func { + parameters, + results, + }, + }])], + strings: Vec::new(), + tags: Vec::new(), + layout: None, + imports: Vec::new(), + functions: Vec::new(), + entry: Some(SymbolId::new(ModuleId(4), 0)), + span, + } +} + +fn module_with_instruction(instruction: mir::Instruction) -> mir::Module { + let span = TextRange::new(17, 31); + mir::Module { + name: "capability-instruction-test".into(), + types: Vec::new(), + strings: Vec::new(), + tags: Vec::new(), + layout: None, + imports: Vec::new(), + functions: vec![mir::Function { + id: crate::types::FunctionId(0), + symbol: SymbolId::new(ModuleId(4), 0), + name: "capability".into(), + parameters: Vec::new(), + values: Vec::new(), + entry: mir::BlockId(0), + blocks: vec![mir::BasicBlock { + id: mir::BlockId(0), + parameters: Vec::new(), + instructions: vec![instruction], + terminator: Some(mir::Terminator::Return { + value: crate::types::ValueId(0), + span, + }), + }], + result: crate::types::ValueId(0), + result_type: ValueType::I32, + span, + }], + entry: Some(SymbolId::new(ModuleId(4), 0)), + span, + } +} + +#[test] +fn closure_operations_require_typed_function_references() { + let module = module_with_instruction(mir::Instruction::ClosureGetCapture { + destination: crate::types::ValueId(0), + closure: crate::types::ValueId(1), + closure_type: crate::types::DefinedTypeId(0), + capture_array_type: crate::types::DefinedTypeId(1), + boxed_integer_type: None, + boxed_f64_type: None, + index: 0, + span: TextRange::new(0, 1), + }); + let target = TargetCapabilities { + function_references: false, + ..TargetCapabilities::default() + }; + let errors = validate_target_capabilities(&module, target) + .expect_err("a closure operation requires typed function references"); + assert!( + errors + .iter() + .any(|error| error.message.contains("typed function references")), + "{errors:?}" + ); +} + +#[test] +fn i31_operations_require_reference_types() { + let module = module_with_instruction(mir::Instruction::I31GetS { + destination: crate::types::ValueId(0), + value: crate::types::ValueId(1), + span: TextRange::new(0, 1), + }); + let target = TargetCapabilities { + reference_types: false, + ..TargetCapabilities::default() + }; + let errors = validate_target_capabilities(&module, target) + .expect_err("an i31 operation requires reference types"); + assert!( + errors + .iter() + .any(|error| error.message.contains("reference types")), + "{errors:?}" + ); +} + +#[test] +fn return_calls_require_the_tail_call_proposal() { + let span = TextRange::new(17, 31); + let module = mir::Module { + name: "capability-tail-test".into(), + types: Vec::new(), + strings: Vec::new(), + tags: Vec::new(), + layout: None, + imports: Vec::new(), + functions: vec![mir::Function { + id: crate::types::FunctionId(0), + symbol: SymbolId::new(ModuleId(4), 0), + name: "tail".into(), + parameters: Vec::new(), + values: Vec::new(), + entry: mir::BlockId(0), + blocks: vec![mir::BasicBlock { + id: mir::BlockId(0), + parameters: Vec::new(), + instructions: Vec::new(), + terminator: Some(mir::Terminator::ReturnCall { + contract: None, + function: SymbolId::new(ModuleId(4), 1), + arguments: Vec::new(), + span, + }), + }], + result: crate::types::ValueId(0), + result_type: ValueType::I32, + span, + }], + entry: Some(SymbolId::new(ModuleId(4), 0)), + span, + }; + let target = TargetCapabilities { + tail_call: false, + ..TargetCapabilities::default() + }; + let errors = validate_target_capabilities(&module, target) + .expect_err("a tail call requires the tail-call proposal"); + assert!( + errors + .iter() + .any(|error| error.message.contains("tail-call")), + "{errors:?}" + ); +} diff --git a/crates/psrs-backend/src/mir/verify/tests/dominance.rs b/crates/psrs-backend/src/mir/verify/tests/dominance.rs index a8bb0a14..ff34639c 100644 --- a/crates/psrs-backend/src/mir/verify/tests/dominance.rs +++ b/crates/psrs-backend/src/mir/verify/tests/dominance.rs @@ -14,6 +14,7 @@ fn span() -> TextRange { fn module_with(function: Function) -> Module { Module { + tags: Vec::new(), name: "DominanceTest".into(), types: Vec::new(), strings: Vec::new(), diff --git a/crates/psrs-backend/src/mir/verify/tests/mod.rs b/crates/psrs-backend/src/mir/verify/tests/mod.rs index ed88bf2c..96564f91 100644 --- a/crates/psrs-backend/src/mir/verify/tests/mod.rs +++ b/crates/psrs-backend/src/mir/verify/tests/mod.rs @@ -11,6 +11,7 @@ fn span() -> TextRange { fn module_with_function(function: Function, types: Vec) -> Module { Module { + tags: Vec::new(), name: "VerifierTest".into(), types, strings: Vec::new(), @@ -368,6 +369,7 @@ fn rejects_a_load_from_an_unknown_memory_id() { } mod arrays; +mod capability; mod dictionary; mod dominance; mod effects; diff --git a/crates/psrs-backend/src/mir/verify/tests/structure.rs b/crates/psrs-backend/src/mir/verify/tests/structure.rs index 0eb9715a..84a92fd5 100644 --- a/crates/psrs-backend/src/mir/verify/tests/structure.rs +++ b/crates/psrs-backend/src/mir/verify/tests/structure.rs @@ -150,6 +150,7 @@ fn recursive_references_are_scoped_to_their_declared_group() { }]), }; let mut source = Module { + tags: Vec::new(), name: "recursive_group_scope".into(), types: vec![RecGroup(vec![definition(1), definition(0)])], strings: Vec::new(), diff --git a/crates/psrs-backend/src/mir/verify/tests/subtype.rs b/crates/psrs-backend/src/mir/verify/tests/subtype.rs index 25795a48..9c8b5f66 100644 --- a/crates/psrs-backend/src/mir/verify/tests/subtype.rs +++ b/crates/psrs-backend/src/mir/verify/tests/subtype.rs @@ -14,6 +14,7 @@ fn span() -> TextRange { fn module_with(types: Vec) -> Module { Module { + tags: Vec::new(), name: "SubtypeTest".into(), types, strings: Vec::new(), diff --git a/crates/psrs-backend/src/mir/verify/tests/tail.rs b/crates/psrs-backend/src/mir/verify/tests/tail.rs index 432df1d0..e932884a 100644 --- a/crates/psrs-backend/src/mir/verify/tests/tail.rs +++ b/crates/psrs-backend/src/mir/verify/tests/tail.rs @@ -17,6 +17,7 @@ fn symbol(index: u32) -> SymbolId { fn module_with(caller: Function, callee: Function) -> Module { Module { + tags: Vec::new(), name: "TailVerifierTest".into(), types: Vec::new(), strings: Vec::new(), diff --git a/crates/psrs-backend/src/mir/verify/variants/tests.rs b/crates/psrs-backend/src/mir/verify/variants/tests.rs index 7e97ea42..d7064370 100644 --- a/crates/psrs-backend/src/mir/verify/variants/tests.rs +++ b/crates/psrs-backend/src/mir/verify/variants/tests.rs @@ -51,6 +51,7 @@ fn constructor(tag: i32, copy: bool) -> Module { }); let symbol = SymbolId::new(ModuleId(0), 0); Module { + tags: Vec::new(), name: "NominalConstructor".into(), types: layout.types().to_vec(), layout: Some(layout), 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 4fd089c3..0a7cc459 100644 --- a/crates/psrs-backend/src/wasm/lower/extent/tests/mod.rs +++ b/crates/psrs-backend/src/wasm/lower/extent/tests/mod.rs @@ -42,6 +42,7 @@ fn function( fn module(function: Function) -> Module { Module { + tags: Vec::new(), name: "ExtentTest".into(), types: Vec::new(), strings: Vec::new(), 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 8845b137..35864928 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/cfg/mod.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/cfg/mod.rs @@ -409,7 +409,9 @@ fn block_span(block: &BasicBlock, fallback: psrs_span::TextRange) -> psrs_span:: | Terminator::Branch { span, .. } | Terminator::Switch { span, .. } | Terminator::ReturnCall { span, .. } - | Terminator::ReturnCallRef { span, .. } => *span, + | Terminator::ReturnCallRef { span, .. } + | Terminator::Throw { span, .. } + | Terminator::Try { span, .. } => *span, }) } diff --git a/crates/psrs-backend/src/wasm/lower/structure/dispatcher.rs b/crates/psrs-backend/src/wasm/lower/structure/dispatcher.rs index a7b5944f..44f252ae 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/dispatcher.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/dispatcher.rs @@ -163,6 +163,16 @@ impl Structurer<'_> { self.emit_return_call_ref(*function, arguments, span, body)?; return Ok(()); } + // The structuring slice of the exception channel (EH-10/EH-11) is + // not implemented yet; a try/throw terminator reaching the + // dispatcher is rejected at its own span instead of being + // miscompiled. + Terminator::Throw { span, .. } | Terminator::Try { span, .. } => { + return Err(wasm_error( + *span, + "MIR exception-channel terminators are not lowered to Wasm yet", + )); + } } body.push(Op::Leaf(Instruction::Br(index as u32 + 1))); Ok(()) @@ -250,6 +260,8 @@ fn terminator_span(terminator: &Terminator) -> TextRange { | Terminator::Branch { span, .. } | Terminator::Switch { span, .. } | Terminator::ReturnCall { span, .. } - | Terminator::ReturnCallRef { span, .. } => *span, + | Terminator::ReturnCallRef { span, .. } + | Terminator::Throw { span, .. } + | Terminator::Try { span, .. } => *span, } } 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 752c4780..c07e47cb 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 @@ -32,6 +32,7 @@ fn block( fn lower_and_validate(source: &Function) -> (WasmFunction, Vec) { crate::mir::verify_module(&MirModule { + tags: Vec::new(), name: source.name.clone(), types: Vec::new(), strings: Vec::new(), @@ -336,6 +337,7 @@ fn has_if_span(body: &[Op], span: TextRange) -> bool { fn structures_and_executes_irreducible_cfg_with_block_parameters_and_sparse_switch() { let function = irreducible_function(); let mir = MirModule { + tags: Vec::new(), name: function.name.clone(), types: Vec::new(), strings: Vec::new(), diff --git a/crates/psrs-backend/src/wasm/lower/structure/region.rs b/crates/psrs-backend/src/wasm/lower/structure/region.rs index cfeb70a6..ea12a630 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/region.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/region.rs @@ -199,6 +199,15 @@ impl Structurer<'_> { } => { self.emit_return_call_ref(*function, arguments, *span, body)?; } + // The structuring slice of the exception channel (EH-10/EH-11) is + // not implemented yet; a try/throw terminator reaching Wasm + // lowering is rejected at its own span instead of being miscompiled. + Terminator::Throw { span, .. } | Terminator::Try { span, .. } => { + return Err(wasm_error( + *span, + "MIR exception-channel terminators are not lowered to Wasm yet", + )); + } } Ok(()) } diff --git a/crates/psrs-backend/src/wasm/lower/structure/tests.rs b/crates/psrs-backend/src/wasm/lower/structure/tests.rs index 2d985ef6..8c9edd2b 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/tests.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/tests.rs @@ -17,6 +17,7 @@ pub(super) fn span() -> TextRange { pub(super) fn lower_and_validate(source: &MirFunction) -> (Function, Vec) { crate::mir::verify_module(&crate::mir::Module { + tags: Vec::new(), name: source.name.clone(), types: Vec::new(), strings: Vec::new(), diff --git a/crates/psrs-backend/src/wasm/tests/mod.rs b/crates/psrs-backend/src/wasm/tests/mod.rs index 6908413d..437af242 100644 --- a/crates/psrs-backend/src/wasm/tests/mod.rs +++ b/crates/psrs-backend/src/wasm/tests/mod.rs @@ -330,6 +330,7 @@ fn interns_repeated_string_literals_in_one_lazy_global() { }); let symbol = SymbolId::new(ModuleId(0), 0); let mir = MirModule { + tags: Vec::new(), name: "InternedStrings".into(), entry: Some(symbol), types: vec![RecGroup(vec![DefinedType { diff --git a/docs/implementation/backend/exception-handling.md b/docs/implementation/backend/exception-handling.md index a6f30882..1e131a9f 100644 --- a/docs/implementation/backend/exception-handling.md +++ b/docs/implementation/backend/exception-handling.md @@ -4,8 +4,8 @@ **Design:** [Exception handling and stack exhaustion](../../design/backend/fp/exception-handling-and-stack-exhaustion.md) -**Progress:** EH-01 through EH-04 Verified (slice 1, capability gate and -encoding). EH-05 through EH-17 Unverified. +**Progress:** EH-01 through EH-08 Verified (slice 1: capability gate and +encoding; slice 2: the MIR representation). EH-09 through EH-17 Unverified. **Roadmap:** [D-04 backend matrix](../../design/D-04-suite-roadmap.md#backend-feature-matrix), row **BC-08** (the exceptions family) and the runtime board rows that reference `Test.Assert.checkThrows` (#95's blocker, the L6/M7 `P8 closure/linking` failure row for `TCOMutRec.purs`). Status claims in D-04 land with the evidence of the slices below, never from this checklist alone. @@ -37,10 +37,10 @@ record exact reproducible evidence for every Verified row. | EH-02 | The encoder emits the tag section (id 13) between memory and global; tags are declared only when used. | Binary/WAT regression tests: a tag-using module encodes with the tag section in position; a module without tags emits none; wasmparser accepts under the enabled profile. | Verified | | EH-03 | The validator is built from the profile so `exceptions` gates the encoding; no other feature is enabled implicitly. | wasmparser rejects a tag-using module built with `exceptions: false` and accepts it enabled; the independent-field test still passes. | Verified | | EH-04 | Hand-built direct-MIR/wasm fixtures execute under wasmtime: `throw` + named `catch` with a payload, `throw` + `catch_all` payload-less, unwinding through nested call frames. | `PSRS_REQUIRE_WASMTIME=1` execution asserts caught values/results and exit codes. | Verified | -| EH-05 | MIR defines `Tag`/`TagId`, `Module::tags`, and the `Throw`/`Try` terminators with the documented fields. | Constructor inspection plus malformed fixtures: missing tag, wrong payload arity/type, unknown handler block. | Unverified | -| EH-06 | The MIR verifier checks unwind edges: `Throw` handlers resolve to the innermost enclosing region's matching handler (or `None` outside every region); handlers have no normal predecessors and belong to exactly one `Try`; regions nest or are disjoint; no back edge targets a try block. | Negative fixtures for each rule, each diagnostic carrying the offending span. | Unverified | -| EH-07 | No protected block ends in a forced `ReturnCall`/`ReturnCallRef`; `mark_tail` skips calls in protected blocks. | A protected-region fixture stays an ordinary call plus return under the enabled profile; a hand-written forced tail terminator in a protected region is rejected. | Unverified | -| EH-08 | The MIR capability verifier requires `exceptions` when a tag exists or `Throw`/`Try` appears, and rejects a module requiring it under a disabled profile with a source-associated diagnostic. | Required-set inference tests; disabled-profile rejection diagnostic; `wasmtime_wasi_0_2()` unchanged in this slice. | Unverified | +| EH-05 | MIR defines `Tag`/`TagId`, `Module::tags`, and the `Throw`/`Try` terminators with the documented fields. | Constructor inspection plus malformed fixtures: missing tag, wrong payload arity/type, unknown handler block. | Verified | +| EH-06 | The MIR verifier checks unwind edges: `Throw` handlers resolve to the innermost enclosing region's matching handler (or `None` outside every region); handlers have no normal predecessors and belong to exactly one `Try`; regions nest or are disjoint; no back edge targets a try block. | Negative fixtures for each rule, each diagnostic carrying the offending span. | Verified | +| EH-07 | No protected block ends in a forced `ReturnCall`/`ReturnCallRef`; `mark_tail` skips calls in protected blocks. | A protected-region fixture stays an ordinary call plus return under the enabled profile; a hand-written forced tail terminator in a protected region is rejected. | Verified | +| EH-08 | The MIR capability verifier requires `exceptions` when a tag exists or `Throw`/`Try` appears, and rejects a module requiring it under a disabled profile with a source-associated diagnostic. | Required-set inference tests; disabled-profile rejection diagnostic; `wasmtime_wasi_0_2()` unchanged in this slice. | Verified | | EH-09 | `mir/lower/exception.rs` discharges a call to a `psrs:runtime-exception` binding into the inline try region; when the profile disables `exceptions` the call is rejected at the binding's span. | Direct-MIR discharge fixtures execute under wasmtime (catch returns the handler's value; normal path the protected value). | Unverified | | EH-10 | The structurer's region plan gains `UnitPlan::Try` with the dominance carve-out; try regions nest or are disjoint; reducible try-free functions are unchanged. | Structuring fixtures: nested tries, try inside loop, loop inside protected region; no dispatcher local on reducible input; WAT inspection shows `try_table` at the expected nesting. | Unverified | | EH-11 | A function whose CFG is irreducible and contains a try region is rejected with a diagnostic; try-free irreducible input still uses the dispatcher. | Direct-MIR fixture pair: the rejection and the unchanged dispatcher fixture. | Unverified | @@ -150,6 +150,143 @@ suites, out of scope here). The `wasm/tests.rs` and `wasm/verify.rs` files grew past the 500-line limit and are now module directories (`wasm/tests/`, `wasm/verify/`) with the existing content preserved. +## Slice 2 record — MIR representation (EH-05..EH-08) + +No producer creates `Try`/`Throw` terminators yet (the P9 discharge is +EH-09+), so every fixture is hand-built direct MIR verified by +`verify_module` / `verify_module_with_capabilities`. The tag payloads draw +from the existing MIR `ValueType` set; `TagId` is module-local and indexes +`Module::tags` by position, mirroring the thin IR's `TagIndex` rule. + +One interpretation the implementation fixes (design-conformant, recorded for +slice 3): a `Throw`'s handler field names the handler of the innermost +enclosing region **that catches the tag** — a tag clause for the throw's tag, +else `catch_all` — walking outward through the containing regions, which nest +and therefore form a chain. A throw inside a *handler block* resolves from its +owning try's block, because the owning region's `try_table` is disarmed when +its catch path executes. A handler-less throw is rejected unless the throw +block lies outside every protected region (and outside every armed extent). +The "regions nest or are disjoint" check is structurally implied by dominance +over the normal CFG (dominance is a tree order), so it has no violating +fixture; it is kept as the partition's stated invariant check, and the +closest structural violations (shared handlers) are caught by the +single-owner rule. + +```text +EH-05: + Implementation: crates/psrs-backend/src/mir/mod.rs (`Terminator::Throw`, + `Terminator::Try`, `Module::tags`), crates/psrs-backend/src/mir/tag.rs + (`Tag`, `TagId`). + Tests: crates/psrs-backend/src/mir/tests/exception/mod.rs — + try_and_throw_terminators_expose_the_documented_fields (constructs the + module, verifies it, and pattern-matches every documented field of both + terminators and of `Tag`), + rejects_a_throw_with_an_unknown_tag, + rejects_a_throw_payload_that_differs_from_its_tag (arity, type, and + agreement cases), + rejects_a_multi_value_tag_payload, + rejects_a_try_naming_a_missing_block (missing protected entry, missing + clause handler). + Input boundary: well-formed and malformed direct MIR (no lowering). + Commands: PSRS_STDLIB_ROOT=… PSRS_REQUIRE_WASMTIME=1 cargo test --workspace + (subset: cargo test -p psrs-backend --lib mir::tests::exception) + Result: pass, 17/17 executed locally under the workspace run above; + nothing runtime-gated (no executable artifact in this slice). + Revision: this slice's commit. + Gaps: none for the row; tags are never emitted by the lowering until + EH-09+, so no lowered module carries a tag table yet. + +EH-06: + Implementation: crates/psrs-backend/src/mir/try_region.rs (the shared + try-region partition: dominance carve-out, handler exclusion, nesting + check, back-edge check) and crates/psrs-backend/src/mir/verify/exception.rs + (clause-tag resolution, handler parameter typing, normal-predecessor and + single-owner rules, `Throw` handler resolution, wired through + mir/verify/function.rs into `verify_module`). + Tests: crates/psrs-backend/src/mir/tests/exception/unwind.rs — + accepts_a_well_formed_try_region_and_nested_tries, + rejects_a_throw_whose_handler_is_not_the_matching_innermost_handler, + rejects_a_throw_tag_no_enclosing_region_catches, + rejects_a_throw_outside_every_region_that_names_a_handler, + rejects_a_throw_without_a_handler_inside_a_protected_region; + crates/psrs-backend/src/mir/tests/exception/regions.rs — + rejects_a_handler_with_a_normal_predecessor, + rejects_a_handler_claimed_by_two_try_regions, + rejects_a_back_edge_targeting_a_try_block. + Input boundary: malformed direct MIR; every diagnostic asserts the + offending terminator's span. + Commands: as EH-05. + Result: pass under the workspace run above. Gaps: the nesting check is + structurally implied (see the interpretation note); no fixture can + violate it. + +EH-07: + Implementation: crates/psrs-backend/src/mir/lower/tail.rs (`rewrite` + computes the shared partition once and skips tail conversion for calls + whose block is protected; self-call loopification stays active inside + regions) and the verifier rule in mir/verify/exception.rs + (`verify_no_forced_tail_calls`). + Tests: mir/tests/exception/tail.rs — + mark_tail_keeps_a_protected_region_call_an_ordinary_call (the protected + call stays `Call` + jump-to-join while the handler's identical call + becomes `ReturnCall`, under `tail_call: true, exceptions: true`); + mir/tests/exception/regions.rs — + rejects_a_forced_tail_call_in_a_protected_region (a hand-written + `ReturnCall` in a protected block is rejected at its own span). + Input boundary: hand-built direct MIR at the function level. + Commands: as EH-05. + Result: pass. + Revision: this slice's commit. + Gaps: none for the row. + +EH-08: + Implementation: crates/psrs-backend/src/mir/verify/capability.rs — + `RequiredCapabilities.exceptions`, set when the module declares a tag or + contains a `Throw`/`Try` terminator, rejected with + "the selected target does not support the WebAssembly exception-handling + proposal required by this module" at `module.span` (the same shape as the + other proposal gates). `TargetCapabilities::wasmtime_wasi_0_2()` and + `Default` are untouched. + Tests: crates/psrs-backend/src/mir/tests/exception/capability.rs — + exception_terminators_require_the_exceptions_capability, + a_declared_tag_requires_the_exceptions_capability; the existing + stable_profile_matches_the_documented_wasi_0_2_target (src/capability.rs) + still pins `exceptions: false`. The capability tests moved out of + mir/verify/capability.rs into mir/verify/tests/capability.rs for the + 500-line limit; all pre-existing assertions preserved. + Input boundary: direct MIR under explicitly enabled and disabled profiles. + Commands: as EH-05. + Result: pass. + Revision: this slice's commit. + Gaps: none for the row; the stable default profile still disables + `exceptions` (the flip is EH-17). +``` + +Slice-2 notes for the structuring slice (EH-10/EH-11): + +- The protected-region partition lives once, in `mir/try_region.rs` + (`plan(function, pass) -> TryRegions`): `regions` (outermost protected sets + first, each with `try_block`, `entry`, `catches`, `catch_all`, `protected`, + `span`), `owners` (handler → owning try block), `conflicts`, plus + `protected_blocks()`/`handler_blocks()` and `handler_closure(function)` for + passes that must retain handler blocks without the dominance work. The + verifier, `mark_tail`, and the structurer all consume this one definition. +- Wasm lowering (`wasm/lower/structure/region.rs`, `structure/dispatcher.rs`) + rejects a `Throw`/`Try` terminator with "MIR exception-channel terminators + are not lowered to Wasm yet" at the terminator's span; no lowered path + reaches it until EH-09/EH-10 produce try regions. +- Optimizer awareness: `prune_unreachable` keeps handler blocks (they are + unreachable along normal edges); terminator simplification treats + `Throw`/`Try` blocks as opaque. + +Validation of the slice: `cargo fmt --all --check`, +`cargo clippy --workspace --all-targets -- -D warnings`, and +`PSRS_STDLIB_ROOT=… PSRS_REQUIRE_WASMTIME=1 cargo test --workspace` all pass +(2168 passed, 0 failed, 5 ignored — the ignored ones are the scoreboard +suites, out of scope here). The MIR verifier files stay under the +500-line limit: `mir/verify/capability.rs` shed its test module into +`mir/verify/tests/capability.rs`, and `mir/mod.rs` sits at exactly 500 lines. + ## Evidence record and completion rule For each ID record owning paths/functions, exact test names, input boundary,