From a42476002c64ea94793d4d51d0c67376e55d11cc Mon Sep 17 00:00:00 2001 From: Tyler Chan Date: Sun, 18 Oct 2026 17:35:54 +0800 Subject: [PATCH] Encode the exception channel in the thin Wasm IR (EH-01..EH-04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 1 of the exception-handling adoption (DEC-23): the structured encoding gains the channel's region shape while the MIR lowering and the structurer stay untouched — every fixture in this slice builds the thin IR directly. - wasm/mod.rs: Op::Try { body, clauses }, CatchClause (Catch/CatchAll), TagDef, the TagIndex index domain, and Module::tags. - encode.rs: the tag section (id 13) between the memory and global sections, emitted only when the module defines a tag; try_table emission with an empty block type. - verify/: module-level tag-definition checks and a structured-body walk that tracks active-label result types; the verifier now rejects out-of-range throw/catch tag indices, unresolved clause label depths (resolved at the try_table's emission point, not counting its own internal label), and clause labels whose result type disagrees with the delivered payload. - capability gate: the profile-derived validator already gates assembled binaries; tests prove a tag-using module is rejected while exceptions is disabled and accepted when enabled, with no other feature enabled implicitly. The default profile keeps exceptions disabled. - driver fixtures (PSRS_REQUIRE_WASMTIME=1, wasmtime 48.0.1): a named catch delivering an i32 payload, catch_all observing a payload-less throw, a throw unwinding through two nested call frames, and a trap inside a protected region staying uncatchable. wasm/tests.rs and wasm/verify.rs grew past the 500-line limit and became module directories with their content preserved. The checklist records EH-01..EH-04 as Verified with the exact tests, commands, and engine version. --- .../src/mir/layout/storage_tests.rs | 1 + crates/psrs-backend/src/wasm/encode.rs | 44 +- .../src/wasm/lower/codec/tests.rs | 1 + crates/psrs-backend/src/wasm/lower/mod.rs | 1 + .../wasm/lower/post_return/tests/buffer.rs | 1 + .../src/wasm/lower/post_return/tests/mod.rs | 1 + .../structure/irreducible_dispatch_tests.rs | 3 + .../src/wasm/lower/structure/switch_tests.rs | 1 + .../src/wasm/lower/structure/tests.rs | 3 + crates/psrs-backend/src/wasm/mod.rs | 34 ++ .../src/wasm/tests/exception/encoding.rs | 112 +++++ .../src/wasm/tests/exception/mod.rs | 437 ++++++++++++++++++ .../src/wasm/{tests.rs => tests/mod.rs} | 7 + crates/psrs-backend/src/wasm/verify/body.rs | 260 +++++++++++ .../src/wasm/{verify.rs => verify/mod.rs} | 278 +++++------ .../src/tests/exception_handling.rs | 332 +++++++++++++ crates/psrs-driver/src/tests/mod.rs | 2 + .../backend/exception-handling.md | 111 ++++- 18 files changed, 1462 insertions(+), 167 deletions(-) create mode 100644 crates/psrs-backend/src/wasm/tests/exception/encoding.rs create mode 100644 crates/psrs-backend/src/wasm/tests/exception/mod.rs rename crates/psrs-backend/src/wasm/{tests.rs => tests/mod.rs} (98%) create mode 100644 crates/psrs-backend/src/wasm/verify/body.rs rename crates/psrs-backend/src/wasm/{verify.rs => verify/mod.rs} (51%) create mode 100644 crates/psrs-driver/src/tests/exception_handling.rs diff --git a/crates/psrs-backend/src/mir/layout/storage_tests.rs b/crates/psrs-backend/src/mir/layout/storage_tests.rs index b0dc9dfc..89c94cdd 100644 --- a/crates/psrs-backend/src/mir/layout/storage_tests.rs +++ b/crates/psrs-backend/src/mir/layout/storage_tests.rs @@ -150,6 +150,7 @@ fn consumer(merge_groups: bool) -> Vec { span, }], memories: Vec::new(), + tags: Vec::new(), data: Vec::new(), globals: Vec::new(), helpers: Vec::new(), diff --git a/crates/psrs-backend/src/wasm/encode.rs b/crates/psrs-backend/src/wasm/encode.rs index 7fd32315..3bccd102 100644 --- a/crates/psrs-backend/src/wasm/encode.rs +++ b/crates/psrs-backend/src/wasm/encode.rs @@ -2,10 +2,10 @@ use super::{Body, DataMode, ExportIndex, ExportKind, GlobalInit, Module, Op}; use crate::BackendError; use std::borrow::Cow; use wasm_encoder::{ - BlockType, CodeSection, ConstExpr, DataCountSection, DataSection, ElementSection, Elements, - EntityType, ExportKind as WasmExportKind, ExportSection, Function as EncoderFunction, + BlockType, Catch, CodeSection, ConstExpr, DataCountSection, DataSection, ElementSection, + Elements, EntityType, ExportKind as WasmExportKind, ExportSection, Function as EncoderFunction, FunctionSection, GlobalSection, GlobalType, ImportSection, Instruction, MemorySection, - MemoryType, Module as EncoderModule, TypeSection, ValType, + MemoryType, Module as EncoderModule, TagKind, TagSection, TagType, TypeSection, ValType, }; /// Encodes the thin Wasm IR into a binary module. @@ -68,6 +68,20 @@ pub fn encode_module(module: &Module) -> Result, Vec> { encoder.section(&memories); } + // The tag section (id 13) sits between the memory and global sections per + // the WebAssembly 3.0 binary format. Tags are emitted only when the module + // carries a `TagDef`; which tags a module defines belongs to the lowering. + if !module.tags.is_empty() { + let mut tags = TagSection::new(); + for tag in &module.tags { + tags.tag(TagType { + kind: TagKind::Exception, + func_type_idx: tag.type_index.0, + }); + } + encoder.section(&tags); + } + if !module.globals.is_empty() { let mut globals = GlobalSection::new(); for global in &module.globals { @@ -207,6 +221,9 @@ fn collect_references(body: &Body, references: &mut Vec) { Op::Block { body, .. } | Op::Loop { body, .. } => { collect_references(body, references); } + Op::Try { body, .. } => { + collect_references(body, references); + } Op::Leaf(_) => {} } } @@ -259,6 +276,27 @@ fn emit_body(body: &Body, function: &mut EncoderFunction) { emit_body(body, function); function.instruction(&Instruction::End); } + Op::Try { body, clauses, .. } => { + let catches = clauses + .iter() + .map(|clause| match clause { + super::CatchClause::Catch { tag, label } => Catch::One { + tag: tag.0, + label: *label, + }, + super::CatchClause::CatchAll { label } => Catch::All { label: *label }, + }) + .collect::>(); + // The thin encoding's try_table region always has an empty + // block type: the protected region's values flow through + // ordinary branches, never through the region's own label. + function.instruction(&Instruction::TryTable( + BlockType::Empty, + Cow::Owned(catches), + )); + emit_body(body, function); + function.instruction(&Instruction::End); + } } } } diff --git a/crates/psrs-backend/src/wasm/lower/codec/tests.rs b/crates/psrs-backend/src/wasm/lower/codec/tests.rs index bea41a52..fa90f0a6 100644 --- a/crates/psrs-backend/src/wasm/lower/codec/tests.rs +++ b/crates/psrs-backend/src/wasm/lower/codec/tests.rs @@ -106,6 +106,7 @@ fn decode_module(bytes: &[u8]) -> Module { minimum: 1, maximum: None, }], + tags: Vec::new(), globals: Vec::new(), data: vec![DataSegment { id: DataId(0), diff --git a/crates/psrs-backend/src/wasm/lower/mod.rs b/crates/psrs-backend/src/wasm/lower/mod.rs index 22e12d2a..b6046353 100644 --- a/crates/psrs-backend/src/wasm/lower/mod.rs +++ b/crates/psrs-backend/src/wasm/lower/mod.rs @@ -339,6 +339,7 @@ pub(crate) fn lower_module_with_plan( minimum, maximum: None, }], + tags: Vec::new(), globals, data, exports, diff --git a/crates/psrs-backend/src/wasm/lower/post_return/tests/buffer.rs b/crates/psrs-backend/src/wasm/lower/post_return/tests/buffer.rs index 06aefb25..0190c21e 100644 --- a/crates/psrs-backend/src/wasm/lower/post_return/tests/buffer.rs +++ b/crates/psrs-backend/src/wasm/lower/post_return/tests/buffer.rs @@ -200,6 +200,7 @@ pub(super) fn buffer_post_return_module() -> Module { minimum: 1, maximum: None, }], + tags: Vec::new(), data: Vec::new(), exports: vec![ Export { diff --git a/crates/psrs-backend/src/wasm/lower/post_return/tests/mod.rs b/crates/psrs-backend/src/wasm/lower/post_return/tests/mod.rs index b1eb69fe..7245d2ea 100644 --- a/crates/psrs-backend/src/wasm/lower/post_return/tests/mod.rs +++ b/crates/psrs-backend/src/wasm/lower/post_return/tests/mod.rs @@ -108,6 +108,7 @@ fn post_return_drops_an_owned_export_handle() { minimum: 1, maximum: None, }], + tags: Vec::new(), data: Vec::new(), exports: vec![ Export { 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 fc585ef6..752c4780 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 @@ -61,6 +61,7 @@ fn lower_and_validate(source: &Function) -> (WasmFunction, Vec) { type_defs: Vec::new(), functions: vec![lowered.clone()], memories: Vec::new(), + tags: Vec::new(), data: Vec::new(), exports: vec![Export { name: source.name.clone(), @@ -326,6 +327,7 @@ fn has_if_span(body: &[Op], span: TextRange) -> bool { .. } => *op_span == span || has_if_span(then_body, span) || has_if_span(else_body, span), Op::Block { body, .. } | Op::Loop { body, .. } => has_if_span(body, span), + Op::Try { body, .. } => has_if_span(body, span), Op::Leaf(_) => false, }) } @@ -401,6 +403,7 @@ fn dispatcher_uses_a_br_table_and_explicit_trap_for_invalid_state() { .. } => contains_dispatch(then_body) || contains_dispatch(else_body), Op::Block { body, .. } | Op::Loop { body, .. } => contains_dispatch(body), + Op::Try { body, .. } => contains_dispatch(body), Op::Leaf(_) => false, }) } diff --git a/crates/psrs-backend/src/wasm/lower/structure/switch_tests.rs b/crates/psrs-backend/src/wasm/lower/structure/switch_tests.rs index 6678941e..fc44efd9 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/switch_tests.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/switch_tests.rs @@ -105,6 +105,7 @@ fn structures_sparse_negative_switch_tags_and_executes_the_default() { .. } => contains_br_table(then_body) || contains_br_table(else_body), wasm::Op::Block { body, .. } | wasm::Op::Loop { body, .. } => contains_br_table(body), + wasm::Op::Try { body, .. } => contains_br_table(body), wasm::Op::Leaf(_) => false, }) } diff --git a/crates/psrs-backend/src/wasm/lower/structure/tests.rs b/crates/psrs-backend/src/wasm/lower/structure/tests.rs index 5384617a..2d985ef6 100644 --- a/crates/psrs-backend/src/wasm/lower/structure/tests.rs +++ b/crates/psrs-backend/src/wasm/lower/structure/tests.rs @@ -46,6 +46,7 @@ pub(super) fn lower_and_validate(source: &MirFunction) -> (Function, Vec) { type_defs: Vec::new(), functions: vec![lowered.clone()], memories: Vec::new(), + tags: Vec::new(), data: Vec::new(), exports: vec![Export { name: source.name.clone(), @@ -71,6 +72,7 @@ pub(super) fn count_loops(body: &[wasm::Op]) -> usize { .map(|op| match op { wasm::Op::Loop { body, .. } => 1 + count_loops(body), wasm::Op::Block { body, .. } => count_loops(body), + wasm::Op::Try { body, .. } => count_loops(body), wasm::Op::If { then_body, else_body, @@ -100,6 +102,7 @@ fn contains_branch_to_depth(body: &[wasm::Op], expected: u32) -> bool { wasm::Op::Block { body, .. } | wasm::Op::Loop { body, .. } => { contains_branch_to_depth(body, expected) } + wasm::Op::Try { body, .. } => contains_branch_to_depth(body, expected), wasm::Op::Leaf(_) => false, }) } diff --git a/crates/psrs-backend/src/wasm/mod.rs b/crates/psrs-backend/src/wasm/mod.rs index bab9de26..a0f1630c 100644 --- a/crates/psrs-backend/src/wasm/mod.rs +++ b/crates/psrs-backend/src/wasm/mod.rs @@ -36,6 +36,9 @@ pub struct DataIndex(pub u32); #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] pub struct GlobalIndex(pub u32); +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct TagIndex(pub u32); + /// A module-level global variable. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Global { @@ -135,6 +138,36 @@ pub enum Op { result: Option, span: TextRange, }, + /// A `try_table` region: the protected body executes inside the region's + /// own internal label, and the catch clauses arm its unwinding behavior. + /// A clause's `label` is depth-relative to the active-label stack at the + /// `try_table`'s emission point, not counting the region's internal label. + Try { + body: Body, + clauses: Vec, + span: TextRange, + }, +} + +/// A `try_table` catch clause. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CatchClause { + /// `catch $tag $label`: an exception of the named tag is delivered to the + /// label as its branch arguments, so the label's result type must equal + /// the tag's payload. + Catch { tag: TagIndex, label: u32 }, + /// `catch_all $label`: any exception is delivered to the label, which + /// must therefore take no arguments. + CatchAll { label: u32 }, +} + +/// A module-local exception tag. The tag attribute is always 0 (exception), +/// and this design neither imports nor exports a tag; a tag's function type +/// carries the payload as its parameters and produces no results. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TagDef { + pub type_index: TypeIndex, + pub span: TextRange, } /// A sequence of structured Wasm operations. @@ -190,6 +223,7 @@ pub struct Module { pub type_defs: Vec, pub functions: Vec, pub memories: Vec, + pub tags: Vec, pub globals: Vec, pub data: Vec, pub exports: Vec, diff --git a/crates/psrs-backend/src/wasm/tests/exception/encoding.rs b/crates/psrs-backend/src/wasm/tests/exception/encoding.rs new file mode 100644 index 00000000..ea95a00d --- /dev/null +++ b/crates/psrs-backend/src/wasm/tests/exception/encoding.rs @@ -0,0 +1,112 @@ +//! Tag-section encoding and profile-gate evidence (EH-02, EH-03). + +use super::*; + +/// EH-02: a tag-using module encodes with the tag section (id 13) in position +/// between the memory and global sections. +#[test] +fn encodes_the_tag_section_between_memory_and_global() { + let mut module = tag_fixture(catches_payload_body(), vec![WasmValType::I32]); + module.memories = vec![Memory { + id: crate::types::MemoryId(0), + index: super::super::MemoryIndex(0), + minimum: 1, + maximum: None, + }]; + module.globals = vec![Global { + index: super::super::GlobalIndex(0), + mutable: true, + ty: WasmValType::I32, + init: GlobalInit::I32(0), + }]; + verify(&module).expect("the memory and global skeleton should verify"); + let binary = encode_module(&module).expect("the tagged module should encode"); + + let mut order = Vec::new(); + for payload in wasmparser::Parser::new(0).parse_all(&binary) { + match payload.unwrap() { + Payload::TypeSection(_) => order.push("type"), + Payload::FunctionSection(_) => order.push("function"), + Payload::MemorySection(_) => order.push("memory"), + Payload::TagSection(_) => order.push("tag"), + Payload::GlobalSection(_) => order.push("global"), + Payload::ExportSection(_) => order.push("export"), + Payload::CodeSectionEntry(_) => order.push("code"), + _ => {} + } + } + let memory = order + .iter() + .position(|section| *section == "memory") + .unwrap(); + let tag = order.iter().position(|section| *section == "tag").unwrap(); + let global = order + .iter() + .position(|section| *section == "global") + .unwrap(); + assert!(memory < tag && tag < global, "section order: {order:?}"); + + crate::validator_for(exceptions_enabled_target()) + .validate_all(&binary) + .expect("wasmparser should accept the tagged module under the enabled profile"); +} + +/// EH-02: a module without tags emits no tag section. +#[test] +fn encodes_no_tag_section_without_tags() { + let module = module_with_function_body("NoTags", vec![Op::Leaf(Instruction::I32Const(0))]); + assert!(module.tags.is_empty()); + let binary = encode_module(&module).expect("the plain module should encode"); + for payload in wasmparser::Parser::new(0).parse_all(&binary) { + assert!( + !matches!(payload.unwrap(), Payload::TagSection(_)), + "a tag-less module must not carry a tag section" + ); + } +} + +/// EH-02: the encoded tagged module prints as WAT carrying the tag, the +/// try_table region, and the throw. +#[test] +fn prints_the_tagged_module_as_wat() { + let module = tag_fixture(catches_payload_body(), vec![WasmValType::I32]); + let binary = encode_module(&module).expect("the tagged module should encode"); + let wat = wasmprinter::print_bytes(&binary).expect("the tagged module should print"); + assert!(wat.contains("tag"), "{wat}"); + assert!(wat.contains("try_table"), "{wat}"); + assert!(wat.contains("throw"), "{wat}"); +} + +/// EH-03: the profile-derived validator rejects a tag-using module while +/// `exceptions` is disabled and accepts it once enabled; enabling `exceptions` +/// turns on exactly that one wasmparser feature. +#[test] +fn exceptions_gate_the_tag_using_module() { + let module = tag_fixture(catches_payload_body(), vec![WasmValType::I32]); + let binary = encode_module(&module).expect("the tagged module should encode"); + + let disabled = TargetCapabilities { + exceptions: false, + ..TargetCapabilities::default() + }; + let rejection = crate::validator_for(disabled) + .validate_all(&binary) + .err() + .expect("a tag-using module must not validate without exceptions"); + assert!( + rejection + .to_string() + .contains("exceptions proposal not enabled"), + "the rejection should name the disabled proposal: {rejection}" + ); + + let enabled = exceptions_enabled_target(); + crate::validator_for(enabled) + .validate_all(&binary) + .expect("the same module should validate with exceptions enabled"); + assert_eq!( + enabled.wasm_features() ^ disabled.wasm_features(), + WasmFeatures::EXCEPTIONS, + "enabling exceptions must touch no other feature" + ); +} diff --git a/crates/psrs-backend/src/wasm/tests/exception/mod.rs b/crates/psrs-backend/src/wasm/tests/exception/mod.rs new file mode 100644 index 00000000..0aa49695 --- /dev/null +++ b/crates/psrs-backend/src/wasm/tests/exception/mod.rs @@ -0,0 +1,437 @@ +//! Exception-channel thin-IR evidence (EH-01..EH-03). +//! +//! The MIR lowering and structurer do not produce try regions yet (EH-05+), +//! so these tests construct the thin Wasm IR directly and check the verifier, +//! the tag section, and the profile gate. Execution fixtures live in the +//! driver's test suite (`psrs-driver/src/tests/exception_handling.rs`). + +use super::*; +use crate::TargetCapabilities; +use wasmparser::{Payload, WasmFeatures}; + +fn span() -> TextRange { + TextRange::new(0, 1) +} + +fn function(name: &str, type_index: u32, body: Body) -> Function { + Function { + symbol: SymbolId::new(ModuleId(0), 0), + name: name.into(), + type_index: super::super::TypeIndex(type_index), + parameters: Vec::new(), + locals: Vec::new(), + body, + span: span(), + } +} + +fn tag(type_index: u32) -> TagDef { + TagDef { + type_index: super::super::TypeIndex(type_index), + span: span(), + } +} + +fn verify(module: &Module) -> Result<(), Vec> { + super::super::verify::verify_module(module) +} + +fn exceptions_enabled_target() -> TargetCapabilities { + TargetCapabilities { + exceptions: true, + ..TargetCapabilities::default() + } +} + +/// A module-local tag with the given payload parameters, plus a `run` +/// function returning one `i32`, exported as `run`. Type 0 is the tag's +/// function type; type 1 is the exported function's. +fn tag_fixture(body: Body, tag_parameters: Vec) -> Module { + super::super::Module { + name: "TagFixture".into(), + imports: Vec::new(), + types: vec![ + FuncType { + parameters: tag_parameters, + results: Vec::new(), + }, + FuncType { + parameters: Vec::new(), + results: vec![WasmValType::I32], + }, + ], + type_defs: Vec::new(), + functions: vec![function("run", 1, body)], + memories: Vec::new(), + tags: vec![tag(0)], + globals: Vec::new(), + data: Vec::new(), + exports: vec![Export { + name: "run".into(), + kind: ExportKind::Function, + index: super::super::ExportIndex::Function(super::super::FunctionIndex(0)), + }], + entry: None, + realloc: None, + helpers: Vec::new(), + span: span(), + } +} + +/// Like [`tag_fixture`] but the exported function returns nothing, so the +/// active labels' result types differ from the payload tests. +fn void_tag_fixture(body: Body, tag_parameters: Vec) -> Module { + let mut module = tag_fixture(body, tag_parameters); + module.types[1].results = Vec::new(); + module +} + +/// `block (result i32) [ try_table (catch $t 0) [i32.const 7; throw $t] end; +/// unreachable ] end`: the catch clause's depth-0 label is the enclosing +/// result-bearing block, so the thrown payload becomes the function result. +fn catches_payload_body() -> Body { + vec![Op::Block { + body: vec![ + Op::Try { + clauses: vec![CatchClause::Catch { + tag: TagIndex(0), + label: 0, + }], + body: vec![ + Op::Leaf(Instruction::I32Const(7)), + Op::Leaf(Instruction::Throw(0)), + ], + span: span(), + }, + Op::Leaf(Instruction::Unreachable), + ], + result: Some(WasmValType::I32), + span: span(), + }] +} + +/// `block (result i32) [ block [] [ try_table (catch_all 0) [throw $t] end; +/// unreachable ] end; i32.const 42 ] end`. +fn catches_all_body() -> Body { + vec![Op::Block { + body: vec![ + Op::Block { + body: vec![ + Op::Try { + clauses: vec![CatchClause::CatchAll { label: 0 }], + body: vec![Op::Leaf(Instruction::Throw(0))], + span: span(), + }, + Op::Leaf(Instruction::Unreachable), + ], + result: None, + span: span(), + }, + Op::Leaf(Instruction::I32Const(42)), + ], + result: Some(WasmValType::I32), + span: span(), + }] +} + +/// EH-01: the verifier accepts a try region whose catch clauses resolve, and +/// the node's fields round-trip for inspection. +#[test] +fn accepts_try_regions_with_resolved_catch_clauses() { + let payload_module = tag_fixture(catches_payload_body(), vec![WasmValType::I32]); + verify(&payload_module).expect("a resolved catch clause should verify"); + + let catch_all_module = tag_fixture(catches_all_body(), vec![WasmValType::I32]); + // The catch_all target block takes no arguments even though the function + // results are non-empty. + verify(&catch_all_module).expect("a resolved catch_all clause should verify"); + + let function = &payload_module.functions[0]; + match &function.body[0] { + Op::Block { body, result, .. } => { + assert_eq!(*result, Some(WasmValType::I32)); + match &body[0] { + Op::Try { clauses, body, .. } => { + assert_eq!( + clauses, + &vec![CatchClause::Catch { + tag: TagIndex(0), + label: 0 + }] + ); + assert_eq!(body.len(), 2); + assert!(matches!(body[1], Op::Leaf(Instruction::Throw(0)))); + } + other => panic!("expected a try region, got {other:?}"), + } + } + other => panic!("expected a block, got {other:?}"), + } +} + +/// EH-01: a raw `throw` leaf with an out-of-range tag index is rejected. +#[test] +fn rejects_an_out_of_range_throw_tag_index() { + let module = tag_fixture( + vec![Op::Leaf(Instruction::Throw(1))], + vec![WasmValType::I32], + ); + let errors = verify(&module).unwrap_err(); + assert!( + errors + .iter() + .any(|error| error.message.contains("throw tag index is out of range")), + "{errors:?}" + ); +} + +/// EH-01: a catch clause naming an out-of-range tag index is rejected. +#[test] +fn rejects_an_out_of_range_catch_clause_tag_index() { + let module = tag_fixture( + vec![Op::Try { + clauses: vec![CatchClause::Catch { + tag: TagIndex(1), + label: 0, + }], + body: Vec::new(), + span: span(), + }], + vec![WasmValType::I32], + ); + let errors = verify(&module).unwrap_err(); + assert!( + errors.iter().any(|error| error + .message + .contains("catch clause tag index is out of range")), + "{errors:?}" + ); +} + +/// EH-01: a catch clause whose label depth does not resolve against the +/// active labels at the try_table's emission point is rejected. +#[test] +fn rejects_an_unresolved_catch_clause_label_depth() { + let payload_module = tag_fixture( + vec![Op::Try { + clauses: vec![CatchClause::Catch { + tag: TagIndex(0), + label: 2, + }], + body: Vec::new(), + span: span(), + }], + vec![WasmValType::I32], + ); + let errors = verify(&payload_module).unwrap_err(); + assert!( + errors.iter().any(|error| error + .message + .contains("catch clause label does not target an enclosing label")), + "{errors:?}" + ); + + let catch_all_module = tag_fixture( + vec![Op::Try { + clauses: vec![CatchClause::CatchAll { label: 1 }], + body: Vec::new(), + span: span(), + }], + vec![WasmValType::I32], + ); + let errors = verify(&catch_all_module).unwrap_err(); + assert!( + errors.iter().any(|error| error + .message + .contains("catch_all clause label does not target an enclosing label")), + "{errors:?}" + ); +} + +/// EH-01: a catch clause's label result type must agree with the payload it +/// delivers; `catch_all` requires an empty label. +#[test] +fn rejects_a_catch_clause_label_result_that_mismatches_the_payload() { + // The function label is the only active label here and it delivers one + // i32, so a `catch` of a payload-less tag does not fit it. + let no_payload_tag = tag_fixture( + vec![Op::Try { + clauses: vec![CatchClause::Catch { + tag: TagIndex(0), + label: 0, + }], + body: Vec::new(), + span: span(), + }], + Vec::new(), + ); + let errors = verify(&no_payload_tag).unwrap_err(); + assert!( + errors.iter().any(|error| error + .message + .contains("catch clause label result does not match the tag payload")), + "{errors:?}" + ); + + // A payload-bearing tag caught to an empty label: an empty-payload tag + // fits an empty label, but the mismatching payload types are rejected. + let mismatched_payload = tag_fixture( + vec![Op::Try { + clauses: vec![CatchClause::Catch { + tag: TagIndex(0), + label: 0, + }], + body: Vec::new(), + span: span(), + }], + vec![WasmValType::I64], + ); + let errors = verify(&mismatched_payload).unwrap_err(); + assert!( + errors.iter().any(|error| error + .message + .contains("catch clause label result does not match the tag payload")), + "{errors:?}" + ); + + let catch_all_to_value_label = tag_fixture( + vec![Op::Try { + clauses: vec![CatchClause::CatchAll { label: 0 }], + body: Vec::new(), + span: span(), + }], + vec![WasmValType::I32], + ); + let errors = verify(&catch_all_to_value_label).unwrap_err(); + assert!( + errors.iter().any(|error| error + .message + .contains("catch_all clause label must deliver no values")), + "{errors:?}" + ); + + // The positive forms: an empty label accepts a payload-less catch, and an + // i32 label accepts a matching payload. + let empty_tag_to_empty_label = void_tag_fixture( + vec![Op::Try { + clauses: vec![CatchClause::Catch { + tag: TagIndex(0), + label: 0, + }], + body: Vec::new(), + span: span(), + }], + Vec::new(), + ); + verify(&empty_tag_to_empty_label) + .expect("a payload-less catch clause should target an empty label"); + let payload_tag_to_value_label = tag_fixture( + vec![Op::Try { + clauses: vec![CatchClause::Catch { + tag: TagIndex(0), + label: 0, + }], + body: Vec::new(), + span: span(), + }], + vec![WasmValType::I32], + ); + verify(&payload_tag_to_value_label) + .expect("an i32 payload should target an i32-delivering label"); +} + +/// EH-01: branch depths inside the protected body count the try_table's own +/// internal label, like a raw block label. +#[test] +fn counts_the_try_region_internal_label_in_branch_depths() { + // The function and block labels deliver nothing, so a `catch_all` clause + // may target the function label (depth 1) and a `br 1` inside the + // protected body targets the enclosing block. + let module = void_tag_fixture( + vec![Op::Block { + body: vec![ + Op::Try { + clauses: vec![CatchClause::CatchAll { label: 1 }], + body: vec![Op::Leaf(Instruction::Br(1))], + span: span(), + }, + Op::Leaf(Instruction::Unreachable), + ], + result: None, + span: span(), + }], + Vec::new(), + ); + verify(&module).expect("depth 1 inside the try region is the enclosing block"); + + let too_deep = void_tag_fixture( + vec![Op::Block { + body: vec![Op::Try { + clauses: vec![CatchClause::CatchAll { label: 0 }], + body: vec![Op::Leaf(Instruction::Br(3))], + span: span(), + }], + result: None, + span: span(), + }], + Vec::new(), + ); + let errors = verify(&too_deep).unwrap_err(); + assert!( + errors + .iter() + .any(|error| error.message.contains("does not target an enclosing label")), + "{errors:?}" + ); +} + +/// EH-01: a tag's function type carries the payload as its parameters and +/// produces no results; anything else is rejected. +#[test] +fn rejects_malformed_tag_definitions() { + let multi_payload = super::super::Module { + types: vec![FuncType { + parameters: vec![WasmValType::I32, WasmValType::I64], + results: Vec::new(), + }], + tags: vec![tag(0)], + ..tag_fixture(Vec::new(), Vec::new()) + }; + let errors = verify(&multi_payload).unwrap_err(); + assert!( + errors.iter().any(|error| error + .message + .contains("tag payload has more than one value")), + "{errors:?}" + ); + + let result_producing = super::super::Module { + types: vec![FuncType { + parameters: Vec::new(), + results: vec![WasmValType::I32], + }], + tags: vec![tag(0)], + ..tag_fixture(Vec::new(), Vec::new()) + }; + let errors = verify(&result_producing).unwrap_err(); + assert!( + errors.iter().any(|error| error + .message + .contains("tag function type must produce no results")), + "{errors:?}" + ); + + let out_of_range = super::super::Module { + tags: vec![tag(3)], + ..tag_fixture(Vec::new(), Vec::new()) + }; + let errors = verify(&out_of_range).unwrap_err(); + assert!( + errors + .iter() + .any(|error| error.message.contains("tag type index is out of range")), + "{errors:?}" + ); +} + +mod encoding; diff --git a/crates/psrs-backend/src/wasm/tests.rs b/crates/psrs-backend/src/wasm/tests/mod.rs similarity index 98% rename from crates/psrs-backend/src/wasm/tests.rs rename to crates/psrs-backend/src/wasm/tests/mod.rs index c8448c9d..6908413d 100644 --- a/crates/psrs-backend/src/wasm/tests.rs +++ b/crates/psrs-backend/src/wasm/tests/mod.rs @@ -6,6 +6,8 @@ use wasm_encoder::{ BlockType, HeapType, Instruction, RefType as WasmRefType, ValType as WasmValType, }; +mod exception; + fn span() -> TextRange { TextRange::new(0, 1) } @@ -29,6 +31,7 @@ fn module_with_function_body(name: &str, body: Body) -> Module { span: span(), }], memories: Vec::new(), + tags: Vec::new(), data: Vec::new(), exports: Vec::new(), entry: None, @@ -98,6 +101,7 @@ fn encodes_and_runs_a_gc_struct() { span: span(), }], memories: Vec::new(), + tags: Vec::new(), data: Vec::new(), exports: vec![Export { name: "sum".into(), @@ -155,6 +159,7 @@ fn defined_types_precede_function_types() { }])], functions: Vec::new(), memories: Vec::new(), + tags: Vec::new(), data: Vec::new(), exports: Vec::new(), entry: None, @@ -175,6 +180,7 @@ fn rejects_a_data_index_that_does_not_match_module_order() { type_defs: Vec::new(), functions: Vec::new(), memories: Vec::new(), + tags: Vec::new(), data: vec![DataSegment { id: crate::types::DataId(0), index: super::DataIndex(1), @@ -206,6 +212,7 @@ fn rejects_an_export_with_the_wrong_index_domain() { type_defs: Vec::new(), functions: Vec::new(), memories: Vec::new(), + tags: Vec::new(), data: Vec::new(), exports: vec![Export { name: "bad".into(), diff --git a/crates/psrs-backend/src/wasm/verify/body.rs b/crates/psrs-backend/src/wasm/verify/body.rs new file mode 100644 index 00000000..9e06e6e0 --- /dev/null +++ b/crates/psrs-backend/src/wasm/verify/body.rs @@ -0,0 +1,260 @@ +//! The structured-body walk of the thin Wasm IR verifier: label depths, catch +//! clauses, and leaf-instruction index checks. + +use super::super::{Body, CatchClause, Module, Op}; +use crate::BackendError; +use psrs_span::TextRange; +use wasm_encoder::Instruction; + +use super::{ + LabelType, block_type_label, structured_label, tag_payload, valid_function_type, wasm_error, +}; + +pub(crate) fn verify_body( + body: &Body, + module: &Module, + local_count: u32, + function_count: u32, + span: TextRange, + labels: &mut Vec, + errors: &mut Vec, +) { + let mut raw_label_depth = 0usize; + for op in body { + match op { + Op::Leaf(instruction) => { + verify_instruction( + instruction, + module, + local_count, + function_count, + span, + labels.len() as u32, + errors, + ); + match instruction { + Instruction::Block(block_type) + | Instruction::Loop(block_type) + | Instruction::If(block_type) => { + labels.push(block_type_label(module, block_type)); + raw_label_depth += 1; + } + Instruction::End if raw_label_depth > 0 => { + labels.pop(); + raw_label_depth -= 1; + } + Instruction::End => { + errors.push(wasm_error( + span, + "Wasm end does not close a raw structured label", + )); + } + _ => {} + } + } + Op::If { + then_body, + else_body, + result, + .. + } => { + labels.push(structured_label(result)); + verify_body( + then_body, + module, + local_count, + function_count, + span, + labels, + errors, + ); + verify_body( + else_body, + module, + local_count, + function_count, + span, + labels, + errors, + ); + labels.pop(); + } + Op::Block { body, result, .. } | Op::Loop { body, result, .. } => { + labels.push(structured_label(result)); + verify_body( + body, + module, + local_count, + function_count, + span, + labels, + errors, + ); + labels.pop(); + } + Op::Try { body, clauses, .. } => { + verify_try_clauses(clauses, module, labels, span, errors); + // The try_table's own internal label is a branch target inside + // the protected body; this encoding always gives it an empty + // block type. + labels.push(LabelType::Empty); + verify_body( + body, + module, + local_count, + function_count, + span, + labels, + errors, + ); + labels.pop(); + } + } + } + if raw_label_depth > 0 { + errors.push(wasm_error( + span, + "Wasm raw structured control label is not closed", + )); + } +} + +/// Checks a `try_table`'s catch clauses against the active-label stack at its +/// emission point. A clause's label is depth-relative to that stack, not +/// counting the `try_table`'s own internal label, so it is resolved here +/// exactly like a `br` depth taken before the region opens. +fn verify_try_clauses( + clauses: &[CatchClause], + module: &Module, + labels: &[LabelType], + span: TextRange, + errors: &mut Vec, +) { + for clause in clauses { + match clause { + CatchClause::Catch { tag, label } => { + if tag.0 as usize >= module.tags.len() { + errors.push(wasm_error( + span, + "Wasm catch clause tag index is out of range", + )); + continue; + } + // A malformed tag definition is already diagnosed in the + // module-level tag checks; its clause cannot match anything. + let payload = tag_payload(module, *tag).unwrap_or(LabelType::Many); + match resolve_label(labels, *label) { + None => errors.push(wasm_error( + span, + "Wasm catch clause label does not target an enclosing label", + )), + Some(resolved) if resolved == payload => {} + Some(_) => errors.push(wasm_error( + span, + "Wasm catch clause label result does not match the tag payload", + )), + } + } + CatchClause::CatchAll { label } => match resolve_label(labels, *label) { + None => errors.push(wasm_error( + span, + "Wasm catch_all clause label does not target an enclosing label", + )), + Some(LabelType::Empty) => {} + Some(_) => errors.push(wasm_error( + span, + "Wasm catch_all clause label must deliver no values", + )), + }, + } + } +} + +/// Resolves a depth-relative label: `0` is the innermost active label. +fn resolve_label(labels: &[LabelType], depth: u32) -> Option { + let depth = depth as usize; + if depth >= labels.len() { + None + } else { + Some(labels[labels.len() - 1 - depth]) + } +} + +fn verify_instruction( + instruction: &Instruction<'_>, + module: &Module, + local_count: u32, + function_count: u32, + span: TextRange, + label_depth: u32, + errors: &mut Vec, +) { + match instruction { + Instruction::LocalGet(index) + | Instruction::LocalSet(index) + | Instruction::LocalTee(index) => { + if *index >= local_count { + errors.push(wasm_error(span, "Wasm local index is out of range")); + } + } + Instruction::Call(index) if *index >= function_count => { + errors.push(wasm_error(span, "Wasm function index is out of range")); + } + Instruction::GlobalGet(index) | Instruction::GlobalSet(index) + if *index >= module.globals.len() as u32 => + { + errors.push(wasm_error(span, "Wasm global index is out of range")); + } + Instruction::RefFunc(index) if *index >= function_count => { + errors.push(wasm_error(span, "Wasm ref.func index is out of range")); + } + Instruction::CallRef(index) if !valid_function_type(module, *index) => { + errors.push(wasm_error(span, "Wasm call_ref type index is out of range")); + } + Instruction::Br(depth) | Instruction::BrIf(depth) if *depth >= label_depth => { + errors.push(wasm_error( + span, + "Wasm branch depth does not target an enclosing label", + )); + } + Instruction::Throw(tag) if *tag >= module.tags.len() as u32 => { + errors.push(wasm_error(span, "Wasm throw tag index is out of range")); + } + Instruction::TryTable(_, catches) + if catches.iter().any(|catch| match catch { + wasm_encoder::Catch::One { tag, label } + | wasm_encoder::Catch::OneRef { tag, label } => { + *tag >= module.tags.len() as u32 || *label >= label_depth + } + wasm_encoder::Catch::All { label } | wasm_encoder::Catch::AllRef { label } => { + *label >= label_depth + } + }) => + { + errors.push(wasm_error( + span, + "Wasm raw try_table catch clause does not resolve", + )); + } + Instruction::BrTable(targets, default) + if targets + .iter() + .chain(std::iter::once(default)) + .any(|depth| *depth >= label_depth) => + { + errors.push(wasm_error( + span, + "Wasm br_table depth does not target an enclosing label", + )); + } + Instruction::CallIndirect { type_index, .. } + if !valid_function_type(module, *type_index) => + { + errors.push(wasm_error( + span, + "Wasm call_indirect type index is out of range", + )); + } + _ => {} + } +} diff --git a/crates/psrs-backend/src/wasm/verify.rs b/crates/psrs-backend/src/wasm/verify/mod.rs similarity index 51% rename from crates/psrs-backend/src/wasm/verify.rs rename to crates/psrs-backend/src/wasm/verify/mod.rs index de0128af..947d331a 100644 --- a/crates/psrs-backend/src/wasm/verify.rs +++ b/crates/psrs-backend/src/wasm/verify/mod.rs @@ -1,13 +1,37 @@ -use super::{Body, ExportIndex, ExportKind, GlobalInit, Module, Op}; +//! The thin Wasm IR verifier: module-level invariants live here, and the +//! structured-body walk (label depths, catch clauses, leaf instructions) lives +//! in [`body`]. + +mod body; + +use super::{ExportIndex, ExportKind, GlobalInit, Module, TagIndex, TypeIndex}; use crate::BackendError; use crate::types::CompositeType; use psrs_span::TextRange; use std::collections::HashSet; -use wasm_encoder::{Instruction, RefType, ValType}; +use wasm_encoder::{BlockType, RefType, ValType}; -// A function body contributes an implicit label which branches can target to -// return from the function, even when there is no explicit block or loop. -const FUNCTION_LABEL_DEPTH: u32 = 1; +pub(super) use body::verify_body; + +/// The result type an active label delivers to a branch or catch clause that +/// targets it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum LabelType { + /// No result values. + Empty, + /// Exactly one result value. + Value(ValType), + /// More than one result value, or an unresolvable type index; a catch + /// clause can never target such a label. + Many, +} + +pub(super) fn structured_label(result: &Option) -> LabelType { + match result { + Some(ty) => LabelType::Value(*ty), + None => LabelType::Empty, + } +} /// Checks the structural invariants of the thin Wasm IR before encoding. pub fn verify_module(module: &Module) -> Result<(), Vec> { @@ -63,6 +87,25 @@ pub fn verify_module(module: &Module) -> Result<(), Vec> { )); } } + for tag in &module.tags { + match function_signature(module, tag.type_index.0) { + None => errors.push(wasm_error(tag.span, "Wasm tag type index is out of range")), + Some((payload, results)) => { + if !results.is_empty() { + errors.push(wasm_error( + tag.span, + "Wasm tag function type must produce no results", + )); + } + if payload.len() > 1 { + errors.push(wasm_error( + tag.span, + "Wasm tag payload has more than one value", + )); + } + } + } + } let function_count = (module.imports.len() + module.functions.len() + usize::from(module.entry.is_some()) @@ -108,7 +151,7 @@ pub fn verify_module(module: &Module) -> Result<(), Vec> { local_count, function_count, function.span, - FUNCTION_LABEL_DEPTH, + &mut vec![function_label_type(module, function.type_index)], &mut errors, ); } @@ -125,7 +168,7 @@ pub fn verify_module(module: &Module) -> Result<(), Vec> { 0, function_count, module.span, - FUNCTION_LABEL_DEPTH, + &mut vec![function_label_type(module, entry.type_index)], &mut errors, ); } @@ -143,7 +186,7 @@ pub fn verify_module(module: &Module) -> Result<(), Vec> { local_count, function_count, realloc.span, - FUNCTION_LABEL_DEPTH, + &mut vec![function_label_type(module, realloc.type_index)], &mut errors, ); } @@ -161,7 +204,7 @@ pub fn verify_module(module: &Module) -> Result<(), Vec> { local_count, function_count, helper.span, - FUNCTION_LABEL_DEPTH, + &mut vec![function_label_type(module, helper.type_index)], &mut errors, ); } @@ -172,165 +215,84 @@ pub fn verify_module(module: &Module) -> Result<(), Vec> { } } -fn verify_body( - body: &Body, - module: &Module, - local_count: u32, - function_count: u32, - span: TextRange, - label_depth: u32, - errors: &mut Vec, -) { - let mut current_label_depth = label_depth; - let mut raw_label_depth = 0; - for op in body { - match op { - Op::Leaf(instruction) => { - verify_instruction( - instruction, - module, - local_count, - function_count, - span, - current_label_depth, - errors, - ); - match instruction { - Instruction::Block(_) | Instruction::Loop(_) | Instruction::If(_) => { - current_label_depth += 1; - raw_label_depth += 1; - } - Instruction::End if raw_label_depth > 0 => { - current_label_depth -= 1; - raw_label_depth -= 1; - } - Instruction::End => { - errors.push(wasm_error( - span, - "Wasm end does not close a raw structured label", - )); - } - _ => {} - } - } - Op::If { - then_body, - else_body, - .. - } => { - verify_body( - then_body, - module, - local_count, - function_count, - span, - current_label_depth + 1, - errors, - ); - verify_body( - else_body, - module, - local_count, - function_count, - span, - current_label_depth + 1, - errors, - ); - } - Op::Block { body, .. } | Op::Loop { body, .. } => { - verify_body( - body, - module, - local_count, - function_count, - span, - current_label_depth + 1, - errors, - ); - } - } - } - if raw_label_depth > 0 { - errors.push(wasm_error( - span, - "Wasm raw structured control label is not closed", - )); - } +pub(super) fn valid_function_type(module: &Module, index: u32) -> bool { + function_signature(module, index).is_some() } -fn verify_instruction( - instruction: &Instruction<'_>, +/// The parameter and result value types of the function type behind a type +/// index, resolved across defined types and plain function types. `None` when +/// the index does not name a function type. +pub(super) fn function_signature( module: &Module, - local_count: u32, - function_count: u32, - span: TextRange, - label_depth: u32, - errors: &mut Vec, -) { - match instruction { - Instruction::LocalGet(index) - | Instruction::LocalSet(index) - | Instruction::LocalTee(index) => { - if *index >= local_count { - errors.push(wasm_error(span, "Wasm local index is out of range")); - } - } - Instruction::Call(index) if *index >= function_count => { - errors.push(wasm_error(span, "Wasm function index is out of range")); - } - Instruction::GlobalGet(index) | Instruction::GlobalSet(index) - if *index >= module.globals.len() as u32 => - { - errors.push(wasm_error(span, "Wasm global index is out of range")); - } - Instruction::RefFunc(index) if *index >= function_count => { - errors.push(wasm_error(span, "Wasm ref.func index is out of range")); - } - Instruction::CallRef(index) if !valid_function_type(module, *index) => { - errors.push(wasm_error(span, "Wasm call_ref type index is out of range")); - } - Instruction::Br(depth) | Instruction::BrIf(depth) if *depth >= label_depth => { - errors.push(wasm_error( - span, - "Wasm branch depth does not target an enclosing label", - )); - } - Instruction::BrTable(targets, default) - if targets - .iter() - .chain(std::iter::once(default)) - .any(|depth| *depth >= label_depth) => - { - errors.push(wasm_error( - span, - "Wasm br_table depth does not target an enclosing label", - )); - } - Instruction::CallIndirect { type_index, .. } - if !valid_function_type(module, *type_index) => - { - errors.push(wasm_error( - span, - "Wasm call_indirect type index is out of range", - )); - } - _ => {} - } -} - -fn valid_function_type(module: &Module, index: u32) -> bool { + index: u32, +) -> Option<(Vec, Vec)> { let defined = module.defined_type_count(); if index < defined { - return module + let definition = module .type_defs .iter() .flat_map(|group| group.0.iter()) - .nth(index as usize) - .is_some_and(|definition| matches!(definition.composite, CompositeType::Func { .. })); + .nth(index as usize)?; + return match &definition.composite { + CompositeType::Func { + parameters, + results, + } => Some(( + parameters + .iter() + .copied() + .map(super::convert::val_type) + .collect(), + results + .iter() + .copied() + .map(super::convert::val_type) + .collect(), + )), + _ => None, + }; } - index < defined + module.types.len() as u32 + let ty = module.types.get(index as usize - defined as usize)?; + Some((ty.parameters.clone(), ty.results.clone())) +} + +/// The label type of the implicit function-body label: branching to it returns +/// from the function with its result values. +fn function_label_type(module: &Module, type_index: TypeIndex) -> LabelType { + match function_signature(module, type_index.0) { + Some((_, results)) => results_label(&results), + None => LabelType::Empty, + } +} + +/// The label type of a raw leaf `block`/`loop`/`if`. +pub(super) fn block_type_label(module: &Module, block_type: &BlockType) -> LabelType { + match block_type { + BlockType::Empty => LabelType::Empty, + BlockType::Result(ty) => LabelType::Value(*ty), + BlockType::FunctionType(index) => match function_signature(module, *index) { + Some((_, results)) => results_label(&results), + None => LabelType::Many, + }, + } +} + +fn results_label(results: &[ValType]) -> LabelType { + match results { + [] => LabelType::Empty, + [ty] => LabelType::Value(*ty), + _ => LabelType::Many, + } +} + +/// The payload a `catch` clause delivers: the tag's function type carries the +/// payload as its parameters. `None` when the tag index is out of range. +pub(super) fn tag_payload(module: &Module, tag: TagIndex) -> Option { + let definition = module.tags.get(tag.0 as usize)?; + let (payload, _) = function_signature(module, definition.type_index.0)?; + Some(results_label(&payload)) } -fn wasm_error(span: TextRange, message: &'static str) -> BackendError { +pub(super) fn wasm_error(span: TextRange, message: &'static str) -> BackendError { BackendError::invalid_ir("P11 Wasm verification", span, message) } diff --git a/crates/psrs-driver/src/tests/exception_handling.rs b/crates/psrs-driver/src/tests/exception_handling.rs new file mode 100644 index 00000000..4a7c87d7 --- /dev/null +++ b/crates/psrs-driver/src/tests/exception_handling.rs @@ -0,0 +1,332 @@ +//! Exception-channel execution evidence (EH-04). +//! +//! The MIR lowering and structurer do not produce try regions yet (EH-05+), +//! so each fixture constructs the thin Wasm IR directly +//! (`psrs_backend::wasm`), validates the encoded binary under an explicitly +//! enabled profile, and executes it with the wasmtime CLI. A fixture whose +//! catch path is taken wrongly would trap (`unreachable`) and exit non-zero. + +use psrs_backend::TargetCapabilities; +use psrs_backend::wasm::{ + CatchClause, Export, ExportIndex, ExportKind, FuncType, Function, FunctionIndex, Module, Op, + TagDef, TagIndex, TypeIndex, +}; +use psrs_hir::{ModuleId, SymbolId}; +use psrs_span::TextRange; +use wasm_encoder::{Instruction, ValType}; + +fn required_wasmtime() -> bool { + std::env::var("PSRS_REQUIRE_WASMTIME").as_deref() == Ok("1") +} + +fn wasmtime_available() -> Option<()> { + let version = std::process::Command::new("wasmtime") + .arg("--version") + .output(); + let usable = matches!(&version, Ok(output) if output.status.success()); + if !usable { + if required_wasmtime() { + panic!("PSRS_REQUIRE_WASMTIME=1 but wasmtime is not usable: {version:?}"); + } + return None; + } + Some(()) +} + +/// Encodes, validates under the enabled profile, and invokes the module's +/// `run` export with the wasmtime CLI. +fn invoke_run(name: &str, module: &Module) -> Result, String> { + wasmtime_available().ok_or_else(|| format!("wasmtime is unavailable for `{name}`"))?; + let binary = + psrs_backend::wasm::encode_module(module).expect("the exception fixture should encode"); + let target = enabled_target(); + wasmparser::Validator::new_with_features(target.wasm_features()) + .validate_all(&binary) + .map_err(|error| { + format!("`{name}` failed validation under the enabled profile: {error}") + })?; + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNTER: AtomicU32 = AtomicU32::new(0); + let id = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = + std::env::temp_dir().join(format!("psrs-exception-{}-{id}.wasm", std::process::id())); + std::fs::write(&path, &binary).unwrap(); + let output = std::process::Command::new("wasmtime") + .arg("run") + .arg("--invoke") + .arg("run") + .arg(&path) + .output() + .unwrap(); + let _ = std::fs::remove_file(&path); + Ok(Some(output)) +} + +fn enabled_target() -> TargetCapabilities { + TargetCapabilities { + exceptions: true, + ..TargetCapabilities::default() + } +} + +fn span() -> TextRange { + TextRange::new(0, 1) +} + +fn symbol() -> SymbolId { + SymbolId::new(ModuleId(0), 0) +} + +/// A module whose type 0 is the tag's function type, whose remaining types +/// follow, and which exports its last function as `run`. +fn module(tag_payload: &[ValType], other_types: &[FuncType], functions: Vec) -> Module { + let mut types = vec![FuncType { + parameters: tag_payload.to_vec(), + results: Vec::new(), + }]; + types.extend_from_slice(other_types); + let run_index = functions.len() - 1; + Module { + name: "ExceptionFixtures".into(), + imports: Vec::new(), + types, + type_defs: Vec::new(), + functions, + memories: Vec::new(), + tags: vec![TagDef { + type_index: TypeIndex(0), + span: span(), + }], + globals: Vec::new(), + data: Vec::new(), + exports: vec![Export { + name: "run".into(), + kind: ExportKind::Function, + index: ExportIndex::Function(FunctionIndex(run_index as u32)), + }], + entry: None, + realloc: None, + helpers: Vec::new(), + span: span(), + } +} + +fn function(name: &str, type_index: u32, body: Vec) -> Function { + Function { + symbol: symbol(), + name: name.into(), + type_index: TypeIndex(type_index), + parameters: Vec::new(), + locals: Vec::new(), + body, + span: span(), + } +} + +/// `block (result i32) [ try_table (catch $t 0) [...] end; unreachable ] end`: +/// the catch clause's depth-0 label is the enclosing result-bearing block, so +/// the caught payload becomes the function result. +fn catch_block(catch_clause: CatchClause, protected: Vec) -> Vec { + vec![Op::Block { + body: vec![ + Op::Try { + clauses: vec![catch_clause], + body: protected, + span: span(), + }, + Op::Leaf(Instruction::Unreachable), + ], + result: Some(ValType::I32), + span: span(), + }] +} + +/// A payload-bearing throw, caught by a named `catch` clause: the result is +/// the thrown payload, not the throw site's unreachable path. +#[test] +fn named_catch_delivers_the_tag_payload() { + let module = module( + &[ValType::I32], + &[FuncType { + parameters: Vec::new(), + results: vec![ValType::I32], + }], + vec![function( + "run", + 1, + catch_block( + CatchClause::Catch { + tag: TagIndex(0), + label: 0, + }, + vec![ + Op::Leaf(Instruction::I32Const(7)), + Op::Leaf(Instruction::Throw(0)), + ], + ), + )], + ); + match invoke_run("named_catch", &module) { + Ok(Some(output)) => { + assert_eq!(output.status.code(), Some(0), "{output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "7", + "the catch handler must receive the thrown payload" + ); + } + Ok(None) => {} + Err(error) => panic!("{error}"), + } +} + +/// A payload-less throw, caught by `catch_all`: the empty unwind block ends, +/// then the handler's sentinel value becomes the function result. +#[test] +fn catch_all_observes_a_payload_less_throw() { + let module = module( + &[], + &[ + FuncType { + parameters: Vec::new(), + results: Vec::new(), + }, + FuncType { + parameters: Vec::new(), + results: vec![ValType::I32], + }, + ], + vec![function( + "run", + 2, + vec![Op::Block { + body: vec![ + Op::Block { + body: vec![ + Op::Try { + clauses: vec![CatchClause::CatchAll { label: 0 }], + body: vec![Op::Leaf(Instruction::Throw(0))], + span: span(), + }, + Op::Leaf(Instruction::Unreachable), + ], + result: None, + span: span(), + }, + Op::Leaf(Instruction::I32Const(42)), + ], + result: Some(ValType::I32), + span: span(), + }], + )], + ); + match invoke_run("catch_all", &module) { + Ok(Some(output)) => { + assert_eq!(output.status.code(), Some(0), "{output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "42", + "the catch_all handler must run instead of the unreachable path" + ); + } + Ok(None) => {} + Err(error) => panic!("{error}"), + } +} + +/// A throw in a callee two frames below the handler unwinds through both +/// frames to the handler's catch clause. +#[test] +fn throw_unwinds_through_nested_call_frames() { + // Function indices: leaf 0, middle 1, run 2. Types: tag 0, void 1, + // i32 result 2. + let module = module( + &[ValType::I32], + &[ + FuncType { + parameters: Vec::new(), + results: Vec::new(), + }, + FuncType { + parameters: Vec::new(), + results: vec![ValType::I32], + }, + ], + vec![ + function( + "leaf", + 1, + vec![ + Op::Leaf(Instruction::I32Const(5)), + Op::Leaf(Instruction::Throw(0)), + ], + ), + function("middle", 1, vec![Op::Leaf(Instruction::Call(0))]), + function( + "run", + 2, + catch_block( + CatchClause::Catch { + tag: TagIndex(0), + label: 0, + }, + vec![Op::Leaf(Instruction::Call(1))], + ), + ), + ], + ); + match invoke_run("nested_unwind", &module) { + Ok(Some(output)) => { + assert_eq!(output.status.code(), Some(0), "{output:?}"); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "5", + "the payload must cross two call frames to the handler" + ); + } + Ok(None) => {} + Err(error) => panic!("{error}"), + } +} + +/// A trap inside a protected region is not an exception: no catch clause +/// observes it, so `run` traps and exits non-zero. +#[test] +fn traps_are_not_exceptions() { + let module = module( + &[], + &[FuncType { + parameters: Vec::new(), + results: Vec::new(), + }], + vec![function( + "run", + 1, + vec![Op::Block { + body: vec![Op::Try { + clauses: vec![CatchClause::CatchAll { label: 0 }], + body: vec![Op::Leaf(Instruction::Unreachable)], + span: span(), + }], + result: None, + span: span(), + }], + )], + ); + match invoke_run("trap_not_caught", &module) { + Ok(Some(output)) => { + assert!( + !output.status.success(), + "the trap must not be converted into a catch: {output:?}" + ); + assert!( + String::from_utf8_lossy(&output.stderr) + .to_lowercase() + .contains("unreachable"), + "wasmtime should report the unreachable trap: {output:?}" + ); + } + Ok(None) => {} + Err(error) => panic!("{error}"), + } +} diff --git a/crates/psrs-driver/src/tests/mod.rs b/crates/psrs-driver/src/tests/mod.rs index 45268b8e..59f04e49 100644 --- a/crates/psrs-driver/src/tests/mod.rs +++ b/crates/psrs-driver/src/tests/mod.rs @@ -205,6 +205,8 @@ mod library_types; mod tail_calls; +mod exception_handling; + mod symbol_reflection; mod recursive_closures; diff --git a/docs/implementation/backend/exception-handling.md b/docs/implementation/backend/exception-handling.md index afd8a79b..a6f30882 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-17 Unverified. This is a design-first slice: no -row is claimed implemented and no evidence is recorded yet. +**Progress:** EH-01 through EH-04 Verified (slice 1, capability gate and +encoding). EH-05 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. @@ -33,10 +33,10 @@ record exact reproducible evidence for every Verified row. | ID | Design obligation | Required acceptance evidence | State | | --- | --- | --- | --- | -| EH-01 | The thin IR models `Op::Try` with catch clauses, `TagDef`, and the `TagIndex` domain; leaf opcodes stay `wasm_encoder::Instruction`. | Backend unit tests construct/inspect the nodes; the thin-IR verifier accepts them and rejects out-of-range tag indices and unresolved clause label depths. | Unverified | -| 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. | Unverified | -| 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. | Unverified | -| 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. | Unverified | +| EH-01 | The thin IR models `Op::Try` with catch clauses, `TagDef`, and the `TagIndex` domain; leaf opcodes stay `wasm_encoder::Instruction`. | Backend unit tests construct/inspect the nodes; the thin-IR verifier accepts them and rejects out-of-range tag indices and unresolved clause label depths. | Verified | +| 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 | @@ -51,6 +51,105 @@ record exact reproducible evidence for every Verified row. | EH-16 | `Test.Assert.checkThrows` is implemented end to end: checked `psrs:runtime-exception#checkThrows` contract, P9 discharge, stdlib adaptation with its recorded target-adaptation entry, and `TCOMutRec.purs` executing (all four `assertThrows` catches, the four depth-100 `assertEqual`s, exit 0). | The corpus case executes under `PSRS_REQUIRE_WASMTIME=1`; the stdlib adaptation record names the pinned upstream declaration and the diff. | Unverified | | EH-17 | The stable default profile enables `exceptions` (with the budget) as the adoption's final revision; [DEC-05](../../decision/DEC-05-wasmtime-feature-set.md)'s amendment and the capability-profile default table are updated with the evidence. | The full L6/M7 runtime board is re-run by the scoreboard protocol and the new number recorded in D-04; the disabled-profile rejection evidence and the enabled execution evidence are both cited in the profile's implementation notes. | Unverified | +## Slice 1 record — capability gate and encoding (EH-01..EH-04) + +The MIR lowering and the structurer do not produce try regions yet (EH-05+), +so every fixture constructs the thin Wasm IR directly. No MIR, structuring, +frontend, or stdlib change belongs to this slice; the thin-IR verifier's +clause checks also cover the tag-index and label-depth rules the design's +structured verifier names (`wasm/verify.rs`: tag index range, catch-clause +label depth resolution, and unwind-block result agreement with each clause's +payload). + +```text +EH-01: + Implementation: crates/psrs-backend/src/wasm/mod.rs (`Op::Try`, `CatchClause`, + `TagDef`, `TagIndex`, `Module::tags`); + crates/psrs-backend/src/wasm/verify/mod.rs (module-level tag checks, + function-type resolution) and crates/psrs-backend/src/wasm/verify/body.rs + (the structured-body walk: clause tag range, clause label depth, clause + payload agreement, try-region internal label in branch depths). + Tests: crates/psrs-backend/src/wasm/tests/exception/mod.rs — + accepts_try_regions_with_resolved_catch_clauses, + rejects_an_out_of_range_throw_tag_index, + rejects_an_out_of_range_catch_clause_tag_index, + rejects_an_unresolved_catch_clause_label_depth, + rejects_a_catch_clause_label_result_that_mismatches_the_payload, + counts_the_try_region_internal_label_in_branch_depths, + rejects_malformed_tag_definitions. + Input boundary: well-formed and malformed direct thin IR (no lowering). + Commands: PSRS_REQUIRE_WASMTIME=1 cargo test --workspace + (subset: cargo test -p psrs-backend --lib wasm::tests::exception) + Result: pass, 7/7 executed locally under the workspace run above. + Revision: this slice's commit. + Gaps: none for the row; the tag section is never emitted by the lowering + until EH-05+, so no lowered module reaches these checks yet. + +EH-02: + Implementation: crates/psrs-backend/src/wasm/encode.rs — the tag section + (id 13) between the memory and global sections, `Op::Try` emission + (`try_table` with an empty block type, clauses, `end`). + Tests: crates/psrs-backend/src/wasm/tests/exception/encoding.rs — + encodes_the_tag_section_between_memory_and_global (wasmparser payload walk + pins memory < tag < global and the enabled-profile validator accepts), + encodes_no_tag_section_without_tags, prints_the_tagged_module_as_wat. + Input boundary: direct thin IR with and without tags. + Commands: as EH-01 (subset: cargo test -p psrs-backend --lib + wasm::tests::exception::encoding). + Result: pass. + Revision: this slice's commit. + Gaps: none. + +EH-03: + Implementation: the pipeline's validator is already built from the profile + (`crates/psrs-backend/src/pipeline/emission.rs` calls + `validator_for(target)` on the assembled binary); `capability.rs` already + maps `exceptions` to `WasmFeatures::EXCEPTIONS`. This slice adds no + implicit feature and leaves both untouched. + Tests: crates/psrs-backend/src/wasm/tests/exception/encoding.rs — + exceptions_gate_the_tag_using_module (validator_for with + `exceptions: false` rejects the encoded tag-using module with + "exceptions proposal not enabled"; with `exceptions: true` it accepts, and + the feature-set difference is exactly `WasmFeatures::EXCEPTIONS`). + crates/psrs-backend/src/capability.rs's + wasm_feature_flags_follow_independent_profile_fields keeps passing + unchanged. + Input boundary: encoded direct thin IR under both profile settings. + Commands: as EH-01. + Result: pass; the default profile still has `exceptions: false`. + Revision: this slice's commit. + Gaps: none. + +EH-04: + Implementation: crates/psrs-driver/src/tests/exception_handling.rs — + hand-built thin-IR fixtures encoded by `psrs_backend::wasm::encode_module`, + validated by `wasmparser::Validator::new_with_features` over an explicitly + exceptions-enabled profile, and executed by the wasmtime CLI + (`wasmtime run --invoke run`). + Tests: named_catch_delivers_the_tag_payload (exit 0, stdout `7`), + catch_all_observes_a_payload_less_throw (exit 0, stdout `42`), + throw_unwinds_through_nested_call_frames (a throw two call frames below + the handler; exit 0, stdout `5`), traps_are_not_exceptions (a trap inside + a protected region is not caught: non-zero exit, `unreachable` reported). + Input boundary: hand-built direct thin IR; explicit profiles only (the + default profile keeps `exceptions` disabled). + Commands: PSRS_REQUIRE_WASMTIME=1 cargo test -p psrs-driver --lib + tests::exception_handling; workspace run as EH-01. + Result: pass, 4/4 fixtures executed under wasmtime 48.0.1 + (7bac2c277 2026-08-24); nothing skipped. + Revision: this slice's commit. + Gaps: none for the row; the fixtures exercise the channel only — no + budget, watermark, or entry synthesis exists yet (EH-12+). +``` + +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 +(2151 passed, 0 failed, 5 ignored — the ignored ones are the scoreboard +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. + ## Evidence record and completion rule For each ID record owning paths/functions, exact test names, input boundary,