From 53150a89a04397ae13d4561329f66324239eda9a Mon Sep 17 00:00:00 2001 From: fffonion Date: Sat, 17 Oct 2026 19:30:23 +0800 Subject: [PATCH 1/5] feat(result): add typed Result values and exhaustive matching --- pd-vm-nostd/src/vm.rs | 43 +- pd-vm-nostd/src/vmbc.rs | 37 +- pd-vm-nostd/tests/call_script_tests.rs | 27 + pd-vm-nostd/tests/embedded_vmbc.rs | 21 +- src/compiler/codegen.rs | 94 +++ src/compiler/frontends/mod.rs | 4 + src/compiler/frontends/rustscript.rs | 4 + src/compiler/host_call_resolve.rs | 16 + src/compiler/host_conversion.rs | 12 + src/compiler/ir.rs | 81 +- src/compiler/lifetime/availability.rs | 89 +- .../lifetime/availability/captures.rs | 7 + .../lifetime/availability/consumption.rs | 19 + src/compiler/lifetime/liveness.rs | 216 ++++- src/compiler/linker.rs | 206 ++++- src/compiler/materialization.rs | 3 + src/compiler/mod.rs | 1 + src/compiler/parser/expressions.rs | 301 ++++++- src/compiler/parser/mod.rs | 4 + src/compiler/parser/statements.rs | 20 + src/compiler/pipeline.rs | 31 +- src/compiler/result.rs | 730 +++++++++++++++++ src/compiler/semantic_model.rs | 5 + src/compiler/source_loader/graph.rs | 20 + src/compiler/typing.rs | 260 ++++++ src/compiler/typing/collect.rs | 130 +++ src/compiler/typing/context.rs | 126 ++- src/compiler/typing/helpers.rs | 118 ++- src/compiler/typing/validate.rs | 95 +++ src/host_api.rs | 136 +++- src/vm/aot/compile.rs | 53 ++ src/vm/aot/ssa.rs | 20 +- src/vm/host.rs | 19 + src/vm/jit/recorder.rs | 7 +- src/vm/mod.rs | 76 ++ src/vmbc.rs | 73 +- tests/compiler/frontend_plugin_tests.rs | 621 ++++++++++++++ tests/pr24_resource_tests.rs | 404 +++++++++- tests/repl_public_api.rs | 296 +++++++ tests/result_language_tests.rs | 763 ++++++++++++++++++ tests/wire/wire_tests.rs | 91 ++- 41 files changed, 5142 insertions(+), 137 deletions(-) create mode 100644 src/compiler/result.rs create mode 100644 tests/result_language_tests.rs diff --git a/pd-vm-nostd/src/vm.rs b/pd-vm-nostd/src/vm.rs index 0ab210f0..05d9b302 100644 --- a/pd-vm-nostd/src/vm.rs +++ b/pd-vm-nostd/src/vm.rs @@ -811,7 +811,8 @@ impl Vm { fn call_core_builtin(&mut self, index: u16, arity: u8) -> Option> { use super::generated_builtin_ids::{ ARRAY_NEW_CALL_INDEX, ARRAY_PUSH_CALL_INDEX, BIND_CALLABLE_CALL_INDEX, - CONCAT_CALL_INDEX, DETACH_LOCAL_CALL_INDEX, MAP_NEW_CALL_INDEX, SET_CALL_INDEX, + CONCAT_CALL_INDEX, DETACH_LOCAL_CALL_INDEX, GET_CALL_INDEX, HAS_CALL_INDEX, + MAP_NEW_CALL_INDEX, SET_CALL_INDEX, }; Some(match index { @@ -881,6 +882,46 @@ impl Vm { self.stack.push(Value::map(Vec::new())); Ok(()) } + GET_CALL_INDEX => { + if let Err(error) = self.require_builtin_arity("get", arity, 2) { + return Some(Err(error)); + } + let start = match self.stack.len().checked_sub(2) { + Some(start) => start, + None => return Some(Err(VmError::StackUnderflow)), + }; + let key = self.stack[start + 1].clone(); + let Value::Map(entries) = self.stack[start].clone() else { + return Some(Err(VmError::TypeMismatch("map"))); + }; + let Some(value) = entries + .iter() + .find(|(current, _)| current == &key) + .map(|(_, value)| value.clone()) + else { + return Some(Err(VmError::HostError("map key not found"))); + }; + self.stack.truncate(start); + self.stack.push(value); + Ok(()) + } + HAS_CALL_INDEX => { + if let Err(error) = self.require_builtin_arity("has", arity, 2) { + return Some(Err(error)); + } + let start = match self.stack.len().checked_sub(2) { + Some(start) => start, + None => return Some(Err(VmError::StackUnderflow)), + }; + let key = self.stack[start + 1].clone(); + let present = match &self.stack[start] { + Value::Map(entries) => entries.iter().any(|(current, _)| current == &key), + _ => return Some(Err(VmError::TypeMismatch("map"))), + }; + self.stack.truncate(start); + self.stack.push(Value::Bool(present)); + Ok(()) + } SET_CALL_INDEX => { if let Err(error) = self.require_builtin_arity("set", arity, 3) { return Some(Err(error)); diff --git a/pd-vm-nostd/src/vmbc.rs b/pd-vm-nostd/src/vmbc.rs index 1e825c16..227ab154 100644 --- a/pd-vm-nostd/src/vmbc.rs +++ b/pd-vm-nostd/src/vmbc.rs @@ -8,8 +8,6 @@ use super::{ }; const MAGIC: [u8; 4] = *b"VMBC"; -const VERSION_V11: u16 = 11; -const VERSION_V12: u16 = 12; const VERSION_V13: u16 = 13; const FLAGS: u16 = 0; const MAX_WIRE_PAYLOAD_BYTES: usize = 64 * 1024 * 1024; @@ -73,11 +71,9 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - let has_host_import_schemas = match version { - VERSION_V11 => false, - VERSION_V12 | VERSION_V13 => true, - _ => return Err(WireError::UnsupportedVersion(version)), - }; + if version != VERSION_V13 { + return Err(WireError::UnsupportedVersion(version)); + } let flags = cursor.read_u16()?; if flags != FLAGS { return Err(WireError::UnsupportedFlags(flags)); @@ -94,10 +90,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { let mut code = Vec::new(); reserve(&mut code, "code", code_bytes.len())?; code.extend_from_slice(code_bytes); - if version == VERSION_V11 && code.contains(&(OpCode::CallScript as u8)) { - return Err(WireError::UnsupportedVersion(VERSION_V11)); - } - let import_count = cursor.read_count("imports", if has_host_import_schemas { 7 } else { 6 })?; + let import_count = cursor.read_count("imports", 7)?; let mut imports = Vec::new(); reserve(&mut imports, "imports", import_count)?; for _ in 0..import_count { @@ -106,12 +99,10 @@ pub fn decode_program(bytes: &[u8]) -> Result { arity: cursor.read_u8()?, return_type: read_value_type(cursor.read_u8()?)?, }); - if has_host_import_schemas { - match cursor.read_u8()? { - 0 => {} - 1 => skip_host_import_schema(&mut cursor)?, - value => return Err(WireError::InvalidBool(value)), - } + match cursor.read_u8()? { + 0 => {} + 1 => skip_host_import_schema(&mut cursor)?, + value => return Err(WireError::InvalidBool(value)), } } @@ -124,9 +115,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { root_callable_bindings, exported_callables, ) = read_callable_metadata(&mut cursor)?; - if version >= VERSION_V13 { - skip_named_struct_decls(&mut cursor)?; - } + skip_named_struct_decls(&mut cursor)?; if !cursor.is_empty() { return Err(WireError::TrailingBytes); } @@ -243,6 +232,10 @@ fn skip_host_schema(cursor: &mut Cursor<'_>, depth: usize) -> Result<(), WireErr } Ok(()) } + 14 => { + skip_host_schema(cursor, depth + 1)?; + skip_host_schema(cursor, depth + 1) + } value => Err(WireError::InvalidValueType(value)), } } @@ -295,6 +288,10 @@ fn skip_schema(cursor: &mut Cursor<'_>, depth: usize) -> Result<(), WireError> { skip_schema(cursor, nested_depth) } 17 => skip_resource_key(cursor), + 18 => { + skip_schema(cursor, nested_depth)?; + skip_schema(cursor, nested_depth) + } value => Err(WireError::InvalidValueType(value)), } } diff --git a/pd-vm-nostd/tests/call_script_tests.rs b/pd-vm-nostd/tests/call_script_tests.rs index 28d7ef35..b342cbe9 100644 --- a/pd-vm-nostd/tests/call_script_tests.rs +++ b/pd-vm-nostd/tests/call_script_tests.rs @@ -85,6 +85,33 @@ fn call_script_executes_direct_call() { assert_eq!(vm.stack(), &[EmbeddedValue::Int(42)]); } +#[test] +fn nostd_executes_std_encoded_result_ok_and_err_without_source() { + let cases = [ + ( + "let value: Result = Result::Ok(7); match value { Result::Ok(v) => v, Result::Err(e) => e };", + EmbeddedValue::Int(7), + ), + ( + "let value: Result = Result::Err(4); match value { Result::Ok(v) => v, Result::Err(e) => e };", + EmbeddedValue::Int(4), + ), + ]; + for (source, expected) in cases { + let compiled = compile_source(source).expect("Result source should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("Result program should encode"); + assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 13); + let program = decode_program(&bytes).expect("no-std should decode v13 Result program"); + let mut vm = EmbeddedVm::new(program); + assert_eq!( + vm.run().expect("decoded Result program should halt"), + EmbeddedVmStatus::Halted + ); + assert_eq!(vm.stack().last().cloned(), Some(expected)); + } +} + #[test] fn call_script_executes_nested_direct_calls() { let compiled = compile_source( diff --git a/pd-vm-nostd/tests/embedded_vmbc.rs b/pd-vm-nostd/tests/embedded_vmbc.rs index ab6c0c7c..183d501c 100644 --- a/pd-vm-nostd/tests/embedded_vmbc.rs +++ b/pd-vm-nostd/tests/embedded_vmbc.rs @@ -146,7 +146,7 @@ fn embedded_decoder_skips_named_host_schema_from_std_encode() { } #[test] -fn embedded_decoder_reads_legacy_v11_without_schema_markers() { +fn embedded_decoder_rejects_legacy_v11_without_schema_markers() { let program = Program::new( vec![Value::Int(7)], vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Ret as u8], @@ -156,14 +156,16 @@ fn embedded_decoder_reads_legacy_v11_without_schema_markers() { bytes.truncate(bytes.len() - 4); bytes[4..6].copy_from_slice(&11u16.to_le_bytes()); - let decoded = decode_program(&bytes).expect("embedded decoder should accept VMBC v11"); - assert_eq!(decoded.constants()[0], EmbeddedValue::Int(7)); + assert!(matches!( + decode_program(&bytes), + Err(WireError::UnsupportedVersion(11)) + )); } fn minimal_vmbc_prefix(constant_count: u32, code: &[u8], import_count: u32) -> Vec { let mut bytes = Vec::new(); bytes.extend_from_slice(b"VMBC"); - bytes.extend_from_slice(&12u16.to_le_bytes()); + bytes.extend_from_slice(&13u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&constant_count.to_le_bytes()); bytes.extend_from_slice(&(code.len() as u32).to_le_bytes()); @@ -541,6 +543,7 @@ fn v12_with_named_host_return_schema(schema: &[u8]) -> Vec { bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); bytes } @@ -685,7 +688,13 @@ fn embedded_decoder_rejects_v12_trailing_zero_named_struct_garbage() { assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]); bytes.truncate(bytes.len() - 4); bytes[4..6].copy_from_slice(&12u16.to_le_bytes()); - decode_program(&bytes).expect("clean v12 should decode"); + assert_eq!( + decode_program(&bytes), + Err(WireError::UnsupportedVersion(12)) + ); bytes.extend_from_slice(&0u32.to_le_bytes()); - assert_eq!(decode_program(&bytes), Err(WireError::TrailingBytes)); + assert_eq!( + decode_program(&bytes), + Err(WireError::UnsupportedVersion(12)) + ); } diff --git a/src/compiler/codegen.rs b/src/compiler/codegen.rs index 74a059ba..b7f0b9ce 100644 --- a/src/compiler/codegen.rs +++ b/src/compiler/codegen.rs @@ -1104,6 +1104,96 @@ impl Compiler { self.compile_stmts(stmts)?; self.compile_expr(expr)?; } + Expr::ResultConstruct { ok, payload, .. } => { + self.assembler.call(BuiltinFunction::MapNew.call_index(), 0); + self.assembler + .push_const(Value::string(super::result::RESULT_TAG_KEY)); + self.assembler.push_const(Value::Bool(*ok)); + self.assembler.call(BuiltinFunction::Set.call_index(), 3); + self.assembler + .push_const(Value::string(super::result::RESULT_PAYLOAD_KEY)); + self.compile_expr(payload)?; + self.assembler.call(BuiltinFunction::Set.call_index(), 3); + } + Expr::ResultQuery { value, ok, .. } => { + self.compile_expr(value)?; + self.assembler + .push_const(Value::string(super::result::RESULT_TAG_KEY)); + self.assembler.call(BuiltinFunction::Get.call_index(), 2); + if !*ok { + self.assembler.not(); + } + } + Expr::ResultMatch { + value, + value_slot, + ok_binding, + ok_expr, + err_binding, + err_expr, + .. + } => { + self.compile_expr(value)?; + self.emit_stloc(*value_slot)?; + self.emit_copy_ldloc(*value_slot)?; + self.assembler + .push_const(Value::string(super::result::RESULT_TAG_KEY)); + self.assembler.call(BuiltinFunction::Get.call_index(), 2); + let err_label = self.fresh_label("result_err"); + let end_label = self.fresh_label("result_end"); + self.record_unary_operand_type(ValueType::Bool); + self.assembler.brfalse_label(&err_label); + self.compile_result_binding(*value_slot, *ok_binding)?; + self.compile_expr(ok_expr)?; + self.assembler.br_label(&end_label); + self.assembler + .label(&err_label) + .map_err(CompileError::Assembler)?; + self.compile_result_binding(*value_slot, *err_binding)?; + self.compile_expr(err_expr)?; + self.assembler + .label(&end_label) + .map_err(CompileError::Assembler)?; + } + } + Ok(()) + } + + fn compile_result_binding( + &mut self, + root: LocalSlot, + binding: Option, + ) -> Result<(), CompileError> { + self.emit_copy_ldloc(root)?; + self.assembler + .push_const(Value::string(super::result::RESULT_PAYLOAD_KEY)); + self.assembler.call(BuiltinFunction::Has.call_index(), 2); + let missing = self.fresh_label("result_payload_missing"); + let done = self.fresh_label("result_payload_done"); + self.record_unary_operand_type(ValueType::Bool); + self.assembler.brfalse_label(&missing); + self.emit_copy_ldloc(root)?; + self.assembler + .push_const(Value::string(super::result::RESULT_PAYLOAD_KEY)); + self.assembler.call(BuiltinFunction::Get.call_index(), 2); + self.emit_copy_ldloc(root)?; + self.assembler + .push_const(Value::string(super::result::RESULT_PAYLOAD_KEY)); + self.assembler.push_const(Value::Null); + self.assembler.call(BuiltinFunction::Set.call_index(), 3); + self.emit_stloc(root)?; + self.assembler.br_label(&done); + self.assembler + .label(&missing) + .map_err(CompileError::Assembler)?; + self.assembler.push_const(Value::Null); + self.assembler + .label(&done) + .map_err(CompileError::Assembler)?; + if let Some(slot) = binding { + self.emit_stloc(slot)?; + } else { + self.assembler.pop(); } Ok(()) } @@ -2340,6 +2430,10 @@ fn substitute_type_schema( .collect(), result: Box::new(substitute_type_schema(result, bindings)), }, + TypeSchema::Result { ok, err } => TypeSchema::Result { + ok: Box::new(substitute_type_schema(ok, bindings)), + err: Box::new(substitute_type_schema(err, bindings)), + }, _ => schema.clone(), } } diff --git a/src/compiler/frontends/mod.rs b/src/compiler/frontends/mod.rs index ee65b2ae..fc110447 100644 --- a/src/compiler/frontends/mod.rs +++ b/src/compiler/frontends/mod.rs @@ -264,6 +264,10 @@ fn collect_named_host_types( } collect_named_host_types(result, catalog, visited, pending) } + HostTypeSchema::Result { ok, err } => { + collect_named_host_types(ok, catalog, visited, pending)?; + collect_named_host_types(err, catalog, visited, pending) + } HostTypeSchema::Unknown | HostTypeSchema::Null | HostTypeSchema::Int diff --git a/src/compiler/frontends/rustscript.rs b/src/compiler/frontends/rustscript.rs index fe448e5e..55d00d0c 100644 --- a/src/compiler/frontends/rustscript.rs +++ b/src/compiler/frontends/rustscript.rs @@ -24,6 +24,10 @@ impl ParserDialect for RustScriptDialect { fn allow_for_in_loop(&self) -> bool { true } + + fn allow_result_syntax(&self) -> bool { + true + } } static RUSTSCRIPT_DIALECT: RustScriptDialect = RustScriptDialect; diff --git a/src/compiler/host_call_resolve.rs b/src/compiler/host_call_resolve.rs index d8880c62..cdd682da 100644 --- a/src/compiler/host_call_resolve.rs +++ b/src/compiler/host_call_resolve.rs @@ -903,6 +903,9 @@ fn tf_schema_label(schema: &TypeSchema) -> String { } TypeSchema::GenericParam(name) => name.clone(), TypeSchema::Resource(key) => format!("resource<{key}>"), + TypeSchema::Result { ok, err } => { + format!("Result<{}, {}>", tf_schema_label(ok), tf_schema_label(err)) + } } } @@ -971,6 +974,9 @@ fn schema_label(schema: &crate::host_api::HostTypeSchema) -> String { } HostTypeSchema::Resource(key) => format!("resource<{key}>"), HostTypeSchema::Named { name, .. } => name.clone(), + HostTypeSchema::Result { ok, err } => { + format!("Result<{}, {}>", schema_label(ok), schema_label(err)) + } } } @@ -1025,6 +1031,16 @@ fn expand_actual_named( }, result: Box::new(expand_actual_named(result, expected_result)), }, + ( + TypeSchema::Result { ok, err }, + HostTypeSchema::Result { + ok: expected_ok, + err: expected_err, + }, + ) => TypeSchema::Result { + ok: Box::new(expand_actual_named(ok, expected_ok)), + err: Box::new(expand_actual_named(err, expected_err)), + }, _ => actual.clone(), } } diff --git a/src/compiler/host_conversion.rs b/src/compiler/host_conversion.rs index c6d82f47..7609b292 100644 --- a/src/compiler/host_conversion.rs +++ b/src/compiler/host_conversion.rs @@ -61,6 +61,10 @@ impl HostTypeSchema { }, HostTypeSchema::Resource(key) => TypeSchema::Resource(key.clone()), HostTypeSchema::Named { name, .. } => TypeSchema::Named(name.clone(), Vec::new()), + HostTypeSchema::Result { ok, err } => TypeSchema::Result { + ok: Box::new(ok.to_compiler_schema()), + err: Box::new(err.to_compiler_schema()), + }, } } @@ -83,6 +87,10 @@ impl HostTypeSchema { params: params.iter().map(Self::to_compiler_object_schema).collect(), result: Box::new(result.to_compiler_object_schema()), }, + HostTypeSchema::Result { ok, err } => TypeSchema::Result { + ok: Box::new(ok.to_compiler_object_schema()), + err: Box::new(err.to_compiler_object_schema()), + }, other => other.to_compiler_schema(), } } @@ -156,6 +164,10 @@ pub(crate) fn to_host_schema(schema: &TypeSchema) -> HostTypeSchema { result: Box::new(to_host_schema(result)), }, TypeSchema::Resource(key) => HostTypeSchema::Resource(key.clone()), + TypeSchema::Result { ok, err } => HostTypeSchema::Result { + ok: Box::new(to_host_schema(ok)), + err: Box::new(to_host_schema(err)), + }, } } diff --git a/src/compiler/ir.rs b/src/compiler/ir.rs index 9ad97dfe..dd26afc2 100644 --- a/src/compiler/ir.rs +++ b/src/compiler/ir.rs @@ -55,6 +55,13 @@ pub enum TypeSchema { /// resource can never be mistaken for structural data (object/map) or a /// generic instantiation. Resource(ResourceTypeKey), + /// Guest `Result`. Runtime values are maps with `tag: bool` and the + /// active `payload`; this variant is not a [`TypeSchema::Named`] or + /// [`TypeSchema::Map`]. + Result { + ok: Box, + err: Box, + }, } impl Hash for TypeSchema { @@ -96,6 +103,10 @@ impl Hash for TypeSchema { result.hash(state); } TypeSchema::Resource(key) => key.hash(state), + TypeSchema::Result { ok, err } => { + ok.hash(state); + err.hash(state); + } } } } @@ -132,7 +143,10 @@ impl TypeSchema { TypeSchema::String => ValueType::String, TypeSchema::Bytes => ValueType::Bytes, TypeSchema::Optional(inner) => inner.coarse_value_type(), - TypeSchema::Named(_, _) | TypeSchema::Map(_) | TypeSchema::Object(_) => ValueType::Map, + TypeSchema::Named(_, _) + | TypeSchema::Map(_) + | TypeSchema::Object(_) + | TypeSchema::Result { .. } => ValueType::Map, // Semantic (nominal) lowering: a resource is opaque and is *not* // surfaced as an integral token here, so inferred schemas and // diagnostics never present a resource as `int`. The physical ABI @@ -214,6 +228,7 @@ impl TypeSchema { TypeSchema::Callable { params, result } => { params.iter().any(|param| param.contains_resource()) || result.contains_resource() } + TypeSchema::Result { ok, err } => ok.contains_resource() || err.contains_resource(), TypeSchema::Unknown | TypeSchema::Null | TypeSchema::Int @@ -273,6 +288,10 @@ impl TypeSchema { .collect(), result: Box::new(substitute(result, bindings)), }, + TypeSchema::Result { ok, err } => TypeSchema::Result { + ok: Box::new(substitute(ok, bindings)), + err: Box::new(substitute(err, bindings)), + }, _ => schema.clone(), } } @@ -312,6 +331,9 @@ impl TypeSchema { .any(|param| visit(param, structs, active, reentries)) || visit(result, structs, active, reentries) } + TypeSchema::Result { ok, err } => { + visit(ok, structs, active, reentries) || visit(err, structs, active, reentries) + } TypeSchema::Named(name, args) => { if args .iter() @@ -663,9 +685,56 @@ pub enum Expr { stmts: Vec, expr: Box, }, + /// `Result::Ok(payload)` or `Result::Err(payload)`. `ok` selects the active + /// branch. `result_schema` is filled by the Result resolver; it is never a + /// license to treat an unresolved constructor as `Unknown`. + ResultConstruct { + ok: bool, + payload: Box, + result_schema: TypeSchema, + span: Span, + semantic_id: Option, + }, + /// Exhaustive two-arm Result match. Bindings are `None` for wildcard arms. + ResultMatch { + value: Box, + value_slot: LocalSlot, + ok_binding: Option, + ok_expr: Box, + err_binding: Option, + err_expr: Box, + scrutinee_schema: TypeSchema, + span: Span, + semantic_id: Option, + }, + /// `is_ok` / `is_err`. Reads the tag only; does not consume the value. + ResultQuery { + value: Box, + ok: bool, + span: Span, + semantic_id: Option, + }, } impl Expr { + pub(crate) fn visit_result_children(&self, mut visit: impl FnMut(&Expr)) { + match self { + Expr::ResultConstruct { payload, .. } => visit(payload), + Expr::ResultQuery { value, .. } => visit(value), + Expr::ResultMatch { + value, + ok_expr, + err_expr, + .. + } => { + visit(value); + visit(ok_expr); + visit(err_expr); + } + _ => {} + } + } + /// The exact per-call host-call catalog resolution carried by this node, /// if it is a catalog-resolved [`Expr::Call`]. /// @@ -722,6 +791,9 @@ pub enum Stmt { ClosureLet { line: u32, closure: ClosureExpr, + /// Callable schema preserved for frontends that emit this statement + /// directly. Parser output uses [`Expr::Closure`] under a typed let. + expected_schema: Option, }, FuncDecl { name: String, @@ -1388,6 +1460,13 @@ fn collect_resolved_calls_in_expr( collect_resolved_calls_in_expr(value, by_id, typed, slot_schemas); collect_resolved_calls_in_expr(fallback, by_id, typed, slot_schemas); } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. }) => { + other.visit_result_children(|child| { + collect_resolved_calls_in_expr(child, by_id, typed, slot_schemas); + }); + } _ => {} } } diff --git a/src/compiler/lifetime/availability.rs b/src/compiler/lifetime/availability.rs index c9c67ae8..748656ac 100644 --- a/src/compiler/lifetime/availability.rs +++ b/src/compiler/lifetime/availability.rs @@ -595,7 +595,7 @@ impl AvailabilityAnalyzer { out, )) } - Stmt::ClosureLet { line, closure } => { + Stmt::ClosureLet { line, closure, .. } => { let mut out = state.clone(); if out.reachable { self.analyze_closure(closure, &out, *line)?; @@ -1098,6 +1098,39 @@ impl AvailabilityAnalyzer { } Ok(out) } + Expr::ResultConstruct { payload, .. } => { + let mut out = self.analyze_expr(payload, state, line)?; + self.mark_owned_value_reads_moved(payload, &mut out); + Ok(out) + } + Expr::ResultQuery { value, .. } => self.analyze_expr_to_owned(value, state, line), + Expr::ResultMatch { + value, + value_slot, + ok_binding, + ok_expr, + err_binding, + err_expr, + .. + } => { + let mut value_state = self.analyze_expr(value, state, line)?; + self.mark_owned_value_reads_moved(value, &mut value_state); + self.mark_available(&mut value_state, *value_slot, line)?; + let mut ok_in = value_state.clone(); + if let Some(slot) = ok_binding { + self.mark_available(&mut ok_in, *slot, line)?; + } + let ok_state = self.analyze_expr(ok_expr, &ok_in, line)?; + let mut err_in = value_state; + if let Some(slot) = err_binding { + self.mark_available(&mut err_in, *slot, line)?; + } + let err_state = self.analyze_expr(err_expr, &err_in, line)?; + let mut out = self.merge_states(ok_state, err_state); + self.mark_owned_value_reads_moved(ok_expr, &mut out); + self.mark_owned_value_reads_moved(err_expr, &mut out); + Ok(out) + } Expr::ToOwned(inner) => { // `.copy()` on a resource-containing value would duplicate the // underlying handle; the core has no generic resource copy, so @@ -1480,6 +1513,13 @@ impl AvailabilityAnalyzer { .any(|(_, arm_expr)| self.expr_contains_owned_local(arm_expr)) || self.expr_contains_owned_local(default) } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. }) => { + let mut found = false; + other.visit_result_children(|child| found |= self.expr_contains_owned_local(child)); + found + } Expr::Block { stmts, expr } => { stmts .iter() @@ -1893,6 +1933,53 @@ impl AvailabilityAnalyzer { default: Box::new(self.rewrite_expr_ownership_inner(default, false, state)?), }) } + Expr::ResultConstruct { + ok, + payload, + result_schema, + span, + semantic_id, + } => Ok(Expr::ResultConstruct { + ok: *ok, + payload: Box::new(self.rewrite_expr_ownership_inner(payload, false, state)?), + result_schema: result_schema.clone(), + span: *span, + semantic_id: *semantic_id, + }), + Expr::ResultQuery { + value, + ok, + span, + semantic_id, + } => Ok(Expr::ResultQuery { + // Tag reads must not detach a resource-bearing carrier. The + // call-arg flag keeps an owned local as `Var` instead of `MoveVar`. + value: Box::new(self.rewrite_expr_ownership_inner(value, true, state)?), + ok: *ok, + span: *span, + semantic_id: *semantic_id, + }), + Expr::ResultMatch { + value, + value_slot, + ok_binding, + ok_expr, + err_binding, + err_expr, + scrutinee_schema, + span, + semantic_id, + } => Ok(Expr::ResultMatch { + value: Box::new(self.rewrite_expr_ownership_inner(value, false, state)?), + value_slot: *value_slot, + ok_binding: *ok_binding, + ok_expr: Box::new(self.rewrite_expr_ownership_inner(ok_expr, false, state)?), + err_binding: *err_binding, + err_expr: Box::new(self.rewrite_expr_ownership_inner(err_expr, false, state)?), + scrutinee_schema: scrutinee_schema.clone(), + span: *span, + semantic_id: *semantic_id, + }), Expr::Block { .. } => self.rewrite_expr_for_ownership_with_state(expr, state, 0), Expr::Null | Expr::Int(_) diff --git a/src/compiler/lifetime/availability/captures.rs b/src/compiler/lifetime/availability/captures.rs index f5759e48..618e268f 100644 --- a/src/compiler/lifetime/availability/captures.rs +++ b/src/compiler/lifetime/availability/captures.rs @@ -622,6 +622,13 @@ impl AvailabilityAnalyzer { } self.capture_mode_for_expr(default, captured_slot, context, implicit, scan); } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. }) => { + other.visit_result_children(|child| { + self.capture_mode_for_expr(child, captured_slot, context, implicit, scan); + }); + } Expr::Block { stmts, expr } => { self.capture_mode_for_stmts(stmts, captured_slot, context, implicit, scan); self.capture_mode_for_expr(expr, captured_slot, context, implicit, scan); diff --git a/src/compiler/lifetime/availability/consumption.rs b/src/compiler/lifetime/availability/consumption.rs index 3d1dbf74..033321c2 100644 --- a/src/compiler/lifetime/availability/consumption.rs +++ b/src/compiler/lifetime/availability/consumption.rs @@ -249,6 +249,13 @@ pub(super) fn expr_uses_slot(expr: &Expr, slot: LocalSlot) -> bool { .any(|(_, arm_expr)| expr_uses_slot(arm_expr, slot)) || expr_uses_slot(default, slot) } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. }) => { + let mut used = false; + other.visit_result_children(|child| used |= expr_uses_slot(child, slot)); + used + } Expr::Block { stmts, expr } => { stmts.iter().any(|stmt| stmt_uses_slot(stmt, slot)) || expr_uses_slot(expr, slot) } @@ -581,6 +588,18 @@ pub(super) fn collect_consumed_positions_from_expr( out, ); } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. }) => { + other.visit_result_children(|child| { + collect_consumed_positions_from_expr( + child, + function_impl, + known_consumed_positions, + out, + ); + }); + } Expr::Block { stmts, expr } => { for nested in stmts { collect_consumed_positions_from_stmt( diff --git a/src/compiler/lifetime/liveness.rs b/src/compiler/lifetime/liveness.rs index 741f26a0..f3b55ce0 100644 --- a/src/compiler/lifetime/liveness.rs +++ b/src/compiler/lifetime/liveness.rs @@ -241,7 +241,11 @@ impl LivenessRewriter { }], ) } - Stmt::ClosureLet { line, closure } => { + Stmt::ClosureLet { + line, + closure, + expected_schema, + } => { let mut live_before = live_after.clone(); let mut defs = Vec::with_capacity(closure.capture_copies.len()); for (source_slot, captured_slot) in &closure.capture_copies { @@ -256,6 +260,7 @@ impl LivenessRewriter { Stmt::ClosureLet { line: *line, closure: closure.clone(), + expected_schema: expected_schema.clone(), }, live_before, defs, @@ -670,6 +675,13 @@ impl LivenessRewriter { } self.add_expr_uses_impl(default, live, conservative); } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. }) => { + other.visit_result_children(|child| { + self.add_expr_uses_impl(child, live, conservative) + }); + } Expr::Block { stmts, expr } => { let live_out = if conservative { self.uses_expr(expr) @@ -998,7 +1010,21 @@ impl LocalSlotAllocator { } } } - Stmt::Let { expr, .. } | Stmt::Assign { expr, .. } | Stmt::Expr { expr, .. } => { + Stmt::Let { index, expr, .. } | Stmt::Assign { index, expr, .. } => { + // A closure binding that passes Some(binding) into prototype + // creation must not share a physical slot with a logically + // distinct capture source. Same-binding recursion stays + // allowed because add_edge ignores a self edge. + if let Expr::Closure(closure) = expr { + for (source_slot, _) in &closure.capture_copies { + if *source_slot != *index { + self.add_edge(*index as usize, *source_slot as usize); + } + } + } + self.collect_expr_constraints(expr, live_before, protected_slots)?; + } + Stmt::Expr { expr, .. } => { self.collect_expr_constraints(expr, live_before, protected_slots)?; } Stmt::ClosureLet { closure, .. } => { @@ -1228,6 +1254,32 @@ impl LocalSlotAllocator { } self.collect_expr_constraints(default, &live_during, protected_slots)?; } + Expr::ResultConstruct { payload, .. } => { + self.collect_expr_constraints(payload, &live_during, protected_slots)?; + } + Expr::ResultQuery { value, .. } => { + self.collect_expr_constraints(value, &live_during, protected_slots)?; + } + Expr::ResultMatch { + value, + value_slot, + ok_binding, + ok_expr, + err_binding, + err_expr, + .. + } => { + self.add_slot_live_edges(*value_slot, &live_during); + if let Some(slot) = *ok_binding { + self.add_slot_live_edges(slot, &live_during); + } + if let Some(slot) = *err_binding { + self.add_slot_live_edges(slot, &live_during); + } + self.collect_expr_constraints(value, &live_during, protected_slots)?; + self.collect_expr_constraints(ok_expr, &live_during, protected_slots)?; + self.collect_expr_constraints(err_expr, &live_during, protected_slots)?; + } Expr::Block { stmts, expr } => { self.collect_expr_constraints(expr, &live_during, protected_slots)?; let mut block_live_out = live_during.clone(); @@ -1483,6 +1535,30 @@ impl LocalSlotAllocator { } self.collect_expr_footprint(default, set, stack); } + Expr::ResultConstruct { payload, .. } => { + self.collect_expr_footprint(payload, set, stack) + } + Expr::ResultQuery { value, .. } => self.collect_expr_footprint(value, set, stack), + Expr::ResultMatch { + value, + value_slot, + ok_binding, + ok_expr, + err_binding, + err_expr, + .. + } => { + self.mark_set_slot(set, *value_slot); + if let Some(slot) = *ok_binding { + self.mark_set_slot(set, slot); + } + if let Some(slot) = *err_binding { + self.mark_set_slot(set, slot); + } + self.collect_expr_footprint(value, set, stack); + self.collect_expr_footprint(ok_expr, set, stack); + self.collect_expr_footprint(err_expr, set, stack); + } Expr::Block { stmts, expr } => { for nested in stmts { self.collect_stmt_footprint(nested, set, stack); @@ -1837,6 +1913,13 @@ fn collect_persistent_closure_sources_from_expr(expr: &Expr, slots: &mut BTreeSe } collect_persistent_closure_sources_from_expr(default, slots); } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. }) => { + other.visit_result_children(|child| { + collect_persistent_closure_sources_from_expr(child, slots) + }); + } Expr::Block { stmts, expr } => { collect_persistent_closure_sources_from_stmts(stmts, slots); collect_persistent_closure_sources_from_expr(expr, slots); @@ -2027,6 +2110,28 @@ fn remap_expr_slots(expr: &mut Expr, mapping: &[LocalSlot]) -> Result<(), ParseE } remap_expr_slots(default, mapping)?; } + Expr::ResultConstruct { payload, .. } => remap_expr_slots(payload, mapping)?, + Expr::ResultQuery { value, .. } => remap_expr_slots(value, mapping)?, + Expr::ResultMatch { + value, + value_slot, + ok_binding, + ok_expr, + err_binding, + err_expr, + .. + } => { + *value_slot = remap_slot(*value_slot, mapping)?; + if let Some(slot) = ok_binding { + *slot = remap_slot(*slot, mapping)?; + } + if let Some(slot) = err_binding { + *slot = remap_slot(*slot, mapping)?; + } + remap_expr_slots(value, mapping)?; + remap_expr_slots(ok_expr, mapping)?; + remap_expr_slots(err_expr, mapping)?; + } Expr::Block { stmts, expr } => { for nested in stmts { remap_stmt_slots(nested, mapping)?; @@ -2216,3 +2321,110 @@ mod loop_owned_drop_tests { ); } } + +#[cfg(test)] +mod closure_binding_interference_tests { + use super::LocalSlotAllocator; + use crate::compiler::ir::{AssignmentKind, ClosureExpr, Expr, FrontendIr, Stmt}; + use std::collections::HashMap; + + fn ir(stmts: Vec, locals: usize) -> FrontendIr { + FrontendIr { + stmts, + locals, + local_bindings: Vec::new(), + struct_schemas: HashMap::new(), + unknown_type_spans: Vec::new(), + functions: Vec::new(), + function_impls: HashMap::new(), + stmt_sources: Vec::new(), + function_sources: HashMap::new(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), + host_api_metadata: None, + semantic_index: None, + parsed_semantic_index: None, + catalog_visibility: None, + lexer_tokens: Vec::new(), + } + } + + fn closure_capturing(source: u16, target: u16) -> Expr { + Expr::Closure(ClosureExpr { + param_slots: Vec::new(), + capture_copies: vec![(source, target)], + body: Box::new(Expr::Var(target)), + }) + } + + #[test] + fn distinct_let_and_assign_bindings_do_not_reuse_capture_sources() { + let source = 0u16; + let binding = 1u16; + let target = 2u16; + let assigned = 3u16; + let assigned_target = 4u16; + let stmts = vec![ + Stmt::Let { + index: source, + declared_schema: None, + expr: Expr::Int(3), + line: 1, + }, + Stmt::Let { + index: binding, + declared_schema: None, + expr: closure_capturing(source, target), + line: 2, + }, + Stmt::Assign { + kind: AssignmentKind::Set, + index: assigned, + expr: closure_capturing(source, assigned_target), + line: 3, + }, + Stmt::Expr { + expr: Expr::LocalCall(binding, Vec::new(), Vec::new(), None), + line: 4, + }, + ]; + let allocation = LocalSlotAllocator::new(5, &[], &HashMap::new()) + .allocate(ir(stmts, 5), &[]) + .expect("distinct capture binding should allocate"); + assert_ne!( + allocation.mapping[source as usize], allocation.mapping[binding as usize], + "let binding must not reuse a distinct capture source: {:?}", + allocation.mapping + ); + assert_ne!( + allocation.mapping[source as usize], allocation.mapping[assigned as usize], + "assign binding must not reuse a distinct capture source: {:?}", + allocation.mapping + ); + } + + #[test] + fn same_binding_recursive_capture_stays_allowed() { + let binding = 0u16; + let target = 1u16; + let stmts = vec![Stmt::Let { + index: binding, + declared_schema: None, + expr: closure_capturing(binding, target), + line: 1, + }]; + let allocation = LocalSlotAllocator::new(2, &[], &HashMap::new()) + .allocate(ir(stmts, 2), &[]) + .expect("genuine self capture must remain allocatable"); + assert_eq!( + allocation.mapping[binding as usize], allocation.mapping[binding as usize], + "same logical self binding maps to one physical slot" + ); + assert!( + (allocation.mapping[binding as usize] as usize) < allocation.ir.locals + || allocation.mapping[binding as usize] == binding, + "self binding keeps a real slot: {:?}", + allocation.mapping + ); + } +} diff --git a/src/compiler/linker.rs b/src/compiler/linker.rs index 25aa3bd9..f77b40c2 100644 --- a/src/compiler/linker.rs +++ b/src/compiler/linker.rs @@ -187,6 +187,10 @@ pub(super) fn remap_type_schema_names(schema: &mut TypeSchema, names: &HashMap { + remap_type_schema_names(ok, names); + remap_type_schema_names(err, names); + } TypeSchema::Unknown | TypeSchema::Null | TypeSchema::Int @@ -215,7 +219,16 @@ fn remap_statement_type_names(statement: &mut Stmt, names: &HashMap { remap_expression_type_names(expr, names); } - Stmt::ClosureLet { closure, .. } => remap_expression_type_names(&mut closure.body, names), + Stmt::ClosureLet { + expected_schema, + closure, + .. + } => { + if let Some(schema) = expected_schema { + remap_type_schema_names(schema, names); + } + remap_expression_type_names(&mut closure.body, names); + } Stmt::IfElse { condition, then_branch, @@ -334,6 +347,27 @@ fn remap_expression_type_names(expression: &mut Expr, names: &HashMap { + remap_type_schema_names(result_schema, names); + remap_expression_type_names(payload, names); + } + Expr::ResultQuery { value, .. } => remap_expression_type_names(value, names), + Expr::ResultMatch { + value, + ok_expr, + err_expr, + scrutinee_schema, + .. + } => { + remap_type_schema_names(scrutinee_schema, names); + remap_expression_type_names(value, names); + remap_expression_type_names(ok_expr, names); + remap_expression_type_names(err_expr, names); + } Expr::Block { stmts, expr } => { for statement in stmts { remap_statement_type_names(statement, names); @@ -1477,6 +1511,72 @@ fn remap_expr_indices( )?; } } + Expr::ResultConstruct { + payload, + semantic_id, + .. + } => { + rebase_semantic_id(semantic_id, node_offset); + remap_expr_indices( + payload, + local_base, + node_offset, + function_map, + flat_index_by_symbol, + )?; + } + Expr::ResultQuery { + value, semantic_id, .. + } => { + rebase_semantic_id(semantic_id, node_offset); + remap_expr_indices( + value, + local_base, + node_offset, + function_map, + flat_index_by_symbol, + )?; + } + Expr::ResultMatch { + value, + value_slot, + ok_binding, + ok_expr, + err_binding, + err_expr, + semantic_id, + .. + } => { + *value_slot = remap_local_index(*value_slot, local_base)?; + if let Some(slot) = ok_binding { + *slot = remap_local_index(*slot, local_base)?; + } + if let Some(slot) = err_binding { + *slot = remap_local_index(*slot, local_base)?; + } + rebase_semantic_id(semantic_id, node_offset); + remap_expr_indices( + value, + local_base, + node_offset, + function_map, + flat_index_by_symbol, + )?; + remap_expr_indices( + ok_expr, + local_base, + node_offset, + function_map, + flat_index_by_symbol, + )?; + remap_expr_indices( + err_expr, + local_base, + node_offset, + function_map, + flat_index_by_symbol, + )?; + } Expr::Add(lhs, rhs) | Expr::Sub(lhs, rhs) | Expr::Mul(lhs, rhs) @@ -3491,4 +3591,108 @@ mod linker_provenance_merge_tests { assert!(merged.parsed_semantic_index.is_none()); assert!(merged.catalog_visibility.is_none()); } + + #[test] + fn closure_let_expected_schema_uses_canonical_guest_name() { + use super::super::ir::{ClosureExpr, Expr, StructDeclOrigin}; + let canonical_box = "__guest_type_m0_s0".to_string(); + let canonical_inner = "__guest_type_m0_s1".to_string(); + let names = HashMap::from([ + ("Box".to_string(), canonical_box.clone()), + ("Inner".to_string(), canonical_inner.clone()), + ]); + let mut parsed = FrontendIr { + stmts: vec![Stmt::ClosureLet { + line: 1, + expected_schema: Some(TypeSchema::Callable { + params: vec![TypeSchema::Named( + "Inner".to_string(), + vec![TypeSchema::GenericParam("T".to_string())], + )], + result: Box::new(TypeSchema::Result { + ok: Box::new(TypeSchema::Named( + "Box".to_string(), + vec![TypeSchema::Named("Inner".to_string(), Vec::new())], + )), + err: Box::new(TypeSchema::String), + }), + }), + closure: ClosureExpr { + param_slots: Vec::new(), + capture_copies: vec![(1, 2)], + body: Box::new(Expr::Int(1)), + }, + }], + locals: 0, + local_bindings: Vec::new(), + struct_schemas: HashMap::from([ + ( + "Box".to_string(), + StructDecl { + name: "Box".to_string(), + type_params: vec!["T".to_string()], + body_schema: TypeSchema::Object(HashMap::from([( + "value".to_string(), + TypeSchema::Named("Inner".to_string(), Vec::new()), + )])), + origin: StructDeclOrigin::Guest, + }, + ), + ( + "Inner".to_string(), + StructDecl { + name: "Inner".to_string(), + type_params: Vec::new(), + body_schema: TypeSchema::Object(HashMap::new()), + origin: StructDeclOrigin::Guest, + }, + ), + ]), + unknown_type_spans: Vec::new(), + functions: Vec::new(), + function_impls: HashMap::new(), + stmt_sources: Vec::new(), + function_sources: HashMap::new(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), + host_api_metadata: None, + semantic_index: None, + parsed_semantic_index: None, + catalog_visibility: None, + lexer_tokens: Vec::new(), + }; + remap_guest_type_schemas(&mut parsed, &names); + assert!(parsed.struct_schemas.contains_key(&canonical_box)); + assert!(parsed.struct_schemas.contains_key(&canonical_inner)); + assert!(!parsed.struct_schemas.contains_key("Box")); + let Stmt::ClosureLet { + expected_schema, + closure, + .. + } = &parsed.stmts[0] + else { + panic!("ClosureLet should remain"); + }; + assert_eq!(closure.capture_copies, vec![(1, 2)]); + let Some(TypeSchema::Callable { params, result }) = expected_schema else { + panic!("expected callable schema"); + }; + assert_eq!( + params, + &vec![TypeSchema::Named( + canonical_inner.clone(), + vec![TypeSchema::GenericParam("T".to_string())] + )] + ); + assert_eq!( + result.as_ref(), + &TypeSchema::Result { + ok: Box::new(TypeSchema::Named( + canonical_box, + vec![TypeSchema::Named(canonical_inner, Vec::new())], + )), + err: Box::new(TypeSchema::String), + } + ); + } } diff --git a/src/compiler/materialization.rs b/src/compiler/materialization.rs index 3ee7bc5c..0a828e4d 100644 --- a/src/compiler/materialization.rs +++ b/src/compiler/materialization.rs @@ -488,6 +488,9 @@ impl Classifier { } self.expr(frame, default); } + other @ (Expr::ResultConstruct { .. } | Expr::ResultQuery { .. } | Expr::ResultMatch { .. }) => { + other.visit_result_children(|child| self.expr(frame, child)); + } Expr::Block { stmts, expr } => { for stmt in stmts { self.stmt(frame, stmt); diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 4b67b041..1aa63de7 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -23,6 +23,7 @@ mod materialization; mod modules; mod parser; mod pipeline; +mod result; mod semantic_model; mod source_loader; pub mod source_map; diff --git a/src/compiler/parser/expressions.rs b/src/compiler/parser/expressions.rs index 42c7c1c1..600ec744 100644 --- a/src/compiler/parser/expressions.rs +++ b/src/compiler/parser/expressions.rs @@ -3,6 +3,7 @@ use super::*; type MatchBinding = Option<(String, Span, LocalSlot)>; type ParsedMatchPattern = (Option, MatchBinding); type ParsedMatchConstructor = Option<(MatchPattern, MatchBinding)>; +type ResultConstructorPattern = (bool, Option<(String, Span, LocalSlot)>); impl Parser { pub(super) fn parse_expr(&mut self) -> Result { @@ -359,6 +360,10 @@ impl Parser { .get(self.pos.saturating_sub(1)) .map(|t| t.span) .unwrap_or_else(|| self.current_span()); + if self.dialect.allow_result_syntax() && name == "Result" && self.check_path_separator() + { + return self.parse_result_constructor(name_span); + } if self.dialect.allow_dotted_call() && let Some(expr) = self.try_parse_js_dotted_call(&name, name_span)? { @@ -828,6 +833,96 @@ impl Parser { Ok(expr) } + pub(super) fn parse_result_constructor(&mut self, start: Span) -> Result { + if !self.match_path_separator() { + return Err(ParseError { + span: Some(start), + code: None, + line: self.current_line(), + message: "expected '::' after Result".to_string(), + }); + } + let ctor = self.expect_ident("expected Result constructor 'Ok' or 'Err'")?; + let ok = match ctor.as_str() { + "Ok" => true, + "Err" => false, + other => { + return Err(ParseError { + span: Some(start), + code: None, + line: self.current_line(), + message: format!("unknown Result constructor '{other}'"), + }); + } + }; + self.expect(&TokenKind::LParen, "expected '(' after Result constructor")?; + let payload = self.parse_expr()?; + self.expect( + &TokenKind::RParen, + "expected ')' after Result constructor payload", + )?; + let hi = self + .tokens + .get(self.pos.saturating_sub(1)) + .map(|token| token.span.hi) + .unwrap_or(start.hi); + Ok(Expr::ResultConstruct { + ok, + payload: Box::new(payload), + result_schema: TypeSchema::Unknown, + span: Span::new(start.source_id, start.lo, hi), + semantic_id: None, + }) + } + + fn peek_result_constructor_pattern(&self) -> bool { + self.dialect.allow_result_syntax() + && self.check_ident_literal("Result") + && self.check_kind_at(self.pos + 1, &TokenKind::Colon) + && self.check_kind_at(self.pos + 2, &TokenKind::Colon) + } + + fn parse_result_constructor_pattern(&mut self) -> Result { + let start = self.current_span(); + let name = self.expect_ident("expected Result")?; + if name != "Result" || !self.match_path_separator() { + return Err(ParseError { + span: Some(start), + code: None, + line: self.current_line(), + message: "expected Result::Ok or Result::Err match pattern".to_string(), + }); + } + let ctor = self.expect_ident("expected Result constructor in match pattern")?; + let ok = match ctor.as_str() { + "Ok" => true, + "Err" => false, + other => { + return Err(ParseError { + span: Some(start), + code: None, + line: self.current_line(), + message: format!("unknown Result match constructor '{other}'"), + }); + } + }; + self.expect(&TokenKind::LParen, "expected '(' in Result match pattern")?; + let binding = if self.match_ident_literal("_") { + None + } else { + let (binding_name, binding_span) = + self.expect_ident_with_span("expected binding name in Result match pattern")?; + let slot = self.allocate_hidden_local()?; + self.set_local_slot_mutable(slot, false); + Some((binding_name, binding_span, slot)) + }; + self.expect( + &TokenKind::RParen, + "expected ')' after Result match pattern", + )?; + Ok((ok, binding)) + } + pub(super) fn parse_match_expr(&mut self) -> Result { let value = self.parse_expr()?; self.expect(&TokenKind::LBrace, "expected '{' after match value")?; @@ -836,6 +931,9 @@ impl Parser { let result_slot = self.allocate_hidden_local()?; let mut arms = Vec::<(MatchPattern, Expr)>::new(); let mut default: Option = None; + let mut result_ok: Option<(Option, Expr)> = None; + let mut result_err: Option<(Option, Expr)> = None; + let mut saw_result_arm = false; while !self.check(&TokenKind::RBrace) { if self.check(&TokenKind::Eof) { @@ -848,55 +946,90 @@ impl Parser { } let pattern_token_line = self.current_line(); - let (pattern, arm_binding) = self.parse_match_pattern()?; - self.expect(&TokenKind::FatArrow, "expected '=>' in match arm")?; - let arm_expr = if let Some((name, ident_span, slot)) = arm_binding { - let mut scope = HashMap::new(); - scope.insert(name.clone(), slot); - self.closure_scopes.push(scope); - let arm_open = self.current_span(); - let arm_result = self.with_scope(arm_open, |parser| { - // Record the match pattern binding inside the arm body - // scope, with the exact identifier token span. - parser.record_local_decl(ident_span, ident_span, slot, name.clone()); - parser.parse_expr() - }); - if pattern - .as_ref() - .and_then(MatchPattern::binding_slot) - .is_some() - { + if self.peek_result_constructor_pattern() { + let (ok, binding) = self.parse_result_constructor_pattern()?; + self.expect(&TokenKind::FatArrow, "expected '=>' in match arm")?; + let arm_expr = if let Some((name, ident_span, slot)) = binding.clone() { + let mut scope = HashMap::new(); + scope.insert(name.clone(), slot); + self.closure_scopes.push(scope); + let arm_open = self.current_span(); + let arm_result = self.with_scope(arm_open, |parser| { + parser.record_local_decl(ident_span, ident_span, slot, name); + parser.parse_expr() + }); self.closure_scopes.pop(); + arm_result? + } else { + let arm_open = self.current_span(); + self.with_scope(arm_open, |parser| parser.parse_expr())? + }; + saw_result_arm = true; + let slot = binding.map(|(_, _, slot)| slot); + let target = if ok { &mut result_ok } else { &mut result_err }; + if target.is_some() { + return Err(ParseError { + span: None, + code: None, + line: pattern_token_line, + message: format!( + "duplicate Result::{} match arm", + if ok { "Ok" } else { "Err" } + ), + }); } - arm_result? + *target = Some((slot, arm_expr)); } else { - let arm_open = self.current_span(); - self.with_scope(arm_open, |parser| parser.parse_expr())? - }; + let (pattern, arm_binding) = self.parse_match_pattern()?; + self.expect(&TokenKind::FatArrow, "expected '=>' in match arm")?; + let arm_expr = if let Some((name, ident_span, slot)) = arm_binding { + let mut scope = HashMap::new(); + scope.insert(name.clone(), slot); + self.closure_scopes.push(scope); + let arm_open = self.current_span(); + let arm_result = self.with_scope(arm_open, |parser| { + // Record the match pattern binding inside the arm body + // scope, with the exact identifier token span. + parser.record_local_decl(ident_span, ident_span, slot, name.clone()); + parser.parse_expr() + }); + if pattern + .as_ref() + .and_then(MatchPattern::binding_slot) + .is_some() + { + self.closure_scopes.pop(); + } + arm_result? + } else { + let arm_open = self.current_span(); + self.with_scope(arm_open, |parser| parser.parse_expr())? + }; - match pattern { - Some(pattern) => { - if default.is_some() { - return Err(ParseError { - span: None, - code: None, - line: pattern_token_line, - message: "non-wildcard match arm cannot appear after '_' arm" - .to_string(), - }); + match pattern { + Some(pattern) => { + if default.is_some() { + return Err(ParseError { + span: None, + code: None, + line: pattern_token_line, + message: "non-wildcard match arm cannot appear after '_' arm" + .to_string(), + }); + } + arms.push((pattern, arm_expr)); } - arms.push((pattern, arm_expr)); - } - None => { - if default.is_some() { - return Err(ParseError { - span: None, - code: None, - line: pattern_token_line, - message: "duplicate '_' match arm".to_string(), - }); + None => { + if default.is_some() { + return Err(ParseError { + span: None, + code: None, + line: pattern_token_line, + message: "duplicate '_' match arm".to_string(), + }); + } + default = Some(arm_expr); } - default = Some(arm_expr); } } @@ -917,6 +1050,67 @@ impl Parser { } self.expect(&TokenKind::RBrace, "expected '}' after match expression")?; + if saw_result_arm { + if !arms.is_empty() { + return Err(ParseError { + span: None, + code: None, + line: self.current_line(), + message: "Result match cannot mix constructor arms with value patterns" + .to_string(), + }); + } + let (ok_binding, ok_expr) = if let Some((slot, expr)) = result_ok { + if result_err.is_none() && default.is_some() { + (slot, expr) + } else if result_err.is_some() && default.is_some() { + return Err(ParseError { + span: None, + code: None, + line: self.current_line(), + message: + "Result match with both constructor arms cannot also have a wildcard" + .to_string(), + }); + } else { + (slot, expr) + } + } else if let Some(expr) = default.clone() { + (None, expr) + } else { + return Err(ParseError { + span: None, + code: None, + line: self.current_line(), + message: "Result match is missing Result::Ok arm".to_string(), + }); + }; + let (err_binding, err_expr) = if let Some((slot, expr)) = result_err { + (slot, expr) + } else if let Some(expr) = default { + (None, expr) + } else { + return Err(ParseError { + span: None, + code: None, + line: self.current_line(), + message: "Result match is missing Result::Err arm".to_string(), + }); + }; + let span = self.current_span(); + return Ok(Expr::ResultMatch { + value: Box::new(value), + value_slot, + ok_binding, + ok_expr: Box::new(ok_expr), + err_binding, + err_expr: Box::new(err_expr), + scrutinee_schema: TypeSchema::Unknown, + span, + semantic_id: None, + }); + } + let default = default.ok_or_else(|| ParseError { span: None, code: None, @@ -1150,6 +1344,23 @@ impl Parser { expr_span, "unwrap_or".to_string(), )?; + } else if self.dialect.allow_result_syntax() + && (member == "is_ok" || member == "is_err") + && self.check(&TokenKind::LParen) + { + self.expect(&TokenKind::LParen, "expected '(' after Result query")?; + self.expect(&TokenKind::RParen, "Result query does not take arguments")?; + let expr_span = Span::new( + chain_start.source_id, + chain_start.lo, + self.tokens[self.pos - 1].span.hi, + ); + expr = Expr::ResultQuery { + value: Box::new(expr), + ok: member == "is_ok", + span: expr_span, + semantic_id: None, + }; } else if member == "length" { let expr_span = Span::new(chain_start.source_id, chain_start.lo, member_span.hi); @@ -2272,6 +2483,10 @@ impl Parser { .collect(), result: Box::new(Self::substitute_contextual_schema(result, bindings)), }, + TypeSchema::Result { ok, err } => TypeSchema::Result { + ok: Box::new(Self::substitute_contextual_schema(ok, bindings)), + err: Box::new(Self::substitute_contextual_schema(err, bindings)), + }, _ => schema.clone(), } } diff --git a/src/compiler/parser/mod.rs b/src/compiler/parser/mod.rs index ea792b26..252339e0 100644 --- a/src/compiler/parser/mod.rs +++ b/src/compiler/parser/mod.rs @@ -102,6 +102,10 @@ pub trait ParserDialect { fn allow_for_in_loop(&self) -> bool { false } + + fn allow_result_syntax(&self) -> bool { + false + } } pub(super) fn lint_trailing_function_return_semicolons( diff --git a/src/compiler/parser/statements.rs b/src/compiler/parser/statements.rs index 87e4291d..ad3b9523 100644 --- a/src/compiler/parser/statements.rs +++ b/src/compiler/parser/statements.rs @@ -527,6 +527,15 @@ impl Parser { .get(self.pos.saturating_sub(1)) .map(|token| token.span) .unwrap_or_else(|| self.current_span()); + if self.dialect.allow_result_syntax() && name == "Result" { + return Err(ParseError { + span: Some(name_span), + code: None, + line: self.current_line(), + message: "Result is a built-in generic type and cannot be declared as a struct" + .to_string(), + }); + } let type_params = self.parse_type_params("struct", &name)?; self.push_active_type_params(&type_params); self.expect(&TokenKind::LBrace, "expected '{' after struct name")?; @@ -995,6 +1004,17 @@ impl Parser { TypeSchema::Map(Box::new(TypeSchema::Unknown)) } } + "Result" if self.dialect.allow_result_syntax() => { + self.expect(&TokenKind::Less, "expected '<' after Result")?; + let ok = self.parse_declared_type_schema()?; + self.expect(&TokenKind::Comma, "expected ',' in Result")?; + let err = self.parse_declared_type_schema()?; + self.expect(&TokenKind::Greater, "expected '>' after Result")?; + TypeSchema::Result { + ok: Box::new(ok), + err: Box::new(err), + } + } "resource" => { self.expect(&TokenKind::Less, "expected '<' after resource type")?; let mut key = self.expect_ident("expected resource type key")?; diff --git a/src/compiler/pipeline.rs b/src/compiler/pipeline.rs index 122b17ea..496cb427 100644 --- a/src/compiler/pipeline.rs +++ b/src/compiler/pipeline.rs @@ -186,8 +186,16 @@ fn prepare_typed_units_with_entry( let runtime_availability = repl .map(|(locals, moved)| build_entry_local_availability(&runtime, locals, moved)) .unwrap_or_default(); - let (runtime, error) = + let (mut runtime, error) = typing::legalize_for_admission(runtime, TypingMode::DynamicHints, &runtime_types); + if error.is_none() { + super::result::resolve_result_types(&mut runtime, &runtime_types).map_err(|error| { + SourcePathError::SourceWithMap { + error: SourceError::Compile(error), + sources: sources.clone(), + } + })?; + } if !behavior.recover_host_errors && let Some(error) = error { @@ -351,8 +359,15 @@ fn prepare_owned_typed_ir( } // Legalization and inference do not impose the root's language policy on // dependencies. Admission below uses each producer's own descriptor. - let (legalized, host_error) = + let (mut legalized, host_error) = typing::legalize_for_admission(parsed, TypingMode::DynamicHints, entry.types); + // Host-call resolutions and merged module formals exist only after + // legalize. Fill Result schemas from those owners before ownership. + // A failed host resolution keeps its own diagnostic. + if host_error.is_none() { + super::result::resolve_result_types(&mut legalized, entry.types) + .map_err(SourceError::Compile)?; + } parsed = legalized; let type_info = typing::infer_types(&parsed, TypingMode::DynamicHints, entry.types); let mut analysis_errors = Vec::new(); @@ -584,7 +599,7 @@ fn record_stmt_local_debug_ranges(stmt: &Stmt, ranges: &mut HashMap { + Stmt::ClosureLet { line, closure, .. } => { for (source_slot, captured_slot) in &closure.capture_copies { note_local_use(ranges, *source_slot, *line); note_local_use(ranges, *captured_slot, *line); @@ -754,6 +769,12 @@ fn record_expr_local_debug_ranges( } record_expr_local_debug_ranges(default, line, ranges); } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. }) => { + other + .visit_result_children(|child| record_expr_local_debug_ranges(child, line, ranges)); + } Expr::Block { stmts, expr } => { for stmt in stmts { record_stmt_local_debug_ranges(stmt, ranges); @@ -886,6 +907,9 @@ fn compile_parsed_output_with_capture_output( reject_strict_unknown_annotations(&parsed).map_err(SourceError::Parse)?; } let local_debug_ranges = collect_named_local_debug_ranges(&parsed); + let mut parsed = parsed; + super::result::resolve_result_types(&mut parsed, entry_local_types) + .map_err(SourceError::Compile)?; let parsed = typing::legalize_builtins_and_bind_types(parsed, typing_mode, entry_local_types) .map_err(SourceError::Compile)?; typing::validate_if_else_type_consistency(&parsed, typing_mode, entry_local_types) @@ -1396,6 +1420,7 @@ fn schema_is_fully_known(schema: &TypeSchema) -> bool { TypeSchema::Callable { params, result } => { params.iter().all(schema_is_fully_known) && schema_is_fully_known(result) } + TypeSchema::Result { ok, err } => schema_is_fully_known(ok) && schema_is_fully_known(err), } } diff --git a/src/compiler/result.rs b/src/compiler/result.rs new file mode 100644 index 00000000..9f65b0f8 --- /dev/null +++ b/src/compiler/result.rs @@ -0,0 +1,730 @@ +//! Guest `Result` resolution. +//! +//! Runtime values are ordinary maps with `tag: bool` and the active +//! `payload`. A missing payload is null only when the active branch schema +//! allows null. This pass fills constructor schemas from annotations and +//! rejects non-exhaustive or mistyped Result matches before bytecode emission. + +use std::collections::HashMap; + +use super::CompileError; +use super::ir::{ + AssignmentKind, ClosureExpr, Expr, FrontendIr, FunctionDecl, FunctionImpl, LocalSlot, Stmt, + TypeSchema, +}; +use super::source_map::Span; + +pub(crate) const RESULT_TAG_KEY: &str = "tag"; +pub(crate) const RESULT_PAYLOAD_KEY: &str = "payload"; + +struct Env { + slots: HashMap, + functions: HashMap, + function_list: Vec, + function_impls: HashMap, + struct_schemas: HashMap, +} + +pub(crate) fn resolve_result_types( + ir: &mut FrontendIr, + entry_local_types: &[super::typing::EntryLocalType], +) -> Result<(), CompileError> { + let function_list = ir.functions.clone(); + let functions = function_list + .iter() + .map(|function| (function.index, function.clone())) + .collect(); + let mut slots = HashMap::new(); + for entry_local in entry_local_types { + if let Some(schema) = super::typing::logical_entry_schema(entry_local) { + slots.insert(entry_local.slot, schema); + } + } + let mut env = Env { + slots, + functions, + function_list, + function_impls: ir.function_impls.clone(), + struct_schemas: ir.struct_schemas.clone(), + }; + resolve_stmts(&mut ir.stmts, None, &mut env)?; + let mut impls = std::mem::take(&mut ir.function_impls); + for (index, function_impl) in impls.iter_mut() { + resolve_function(*index, function_impl, &mut env)?; + } + ir.function_impls = impls; + Ok(()) +} + +fn resolve_function( + index: u16, + function_impl: &mut FunctionImpl, + env: &mut Env, +) -> Result<(), CompileError> { + let saved = env.slots.clone(); + if let Some(function) = env.functions.get(&index).cloned() { + for (slot, schema) in function_impl + .param_slots + .iter() + .zip(function.arg_schemas.iter()) + { + if let Some(schema) = schema { + env.slots.insert(*slot, schema.clone()); + } + } + resolve_stmts( + &mut function_impl.body_stmts, + function.return_schema.clone(), + env, + )?; + resolve_expr( + &mut function_impl.body_expr, + function.return_schema.as_ref(), + env, + )?; + } else { + resolve_stmts(&mut function_impl.body_stmts, None, env)?; + resolve_expr(&mut function_impl.body_expr, None, env)?; + } + env.slots = saved; + Ok(()) +} + +fn resolve_stmts( + stmts: &mut [Stmt], + expected_tail: Option, + env: &mut Env, +) -> Result<(), CompileError> { + for stmt in stmts { + resolve_stmt(stmt, expected_tail.as_ref(), env)?; + } + Ok(()) +} + +fn resolve_stmt( + stmt: &mut Stmt, + expected_tail: Option<&TypeSchema>, + env: &mut Env, +) -> Result<(), CompileError> { + match stmt { + Stmt::Noop { .. } | Stmt::FuncDecl { .. } | Stmt::Break { .. } | Stmt::Continue { .. } => { + Ok(()) + } + Stmt::Let { + index, + declared_schema, + expr, + .. + } => { + resolve_expr(expr, declared_schema.as_ref(), env)?; + if let Some(schema) = declared_schema + .clone() + .or_else(|| infer_schema(expr, env)) + .or_else(|| { + super::typing::infer_value_schema( + &env.function_list, + &env.function_impls, + &env.struct_schemas, + expr, + &env.slots, + ) + }) + .filter(|schema| !matches!(schema, TypeSchema::Unknown)) + { + env.slots.insert(*index, schema); + } + Ok(()) + } + Stmt::Assign { + kind, + index, + expr, + line, + } => { + let expected = env.slots.get(index).cloned(); + if !matches!(kind, AssignmentKind::Set) { + let _ = (expected_tail, line); + } + resolve_expr(expr, expected.as_ref(), env)?; + if let Some(schema) = infer_schema(expr, env) { + env.slots.insert(*index, schema); + } + Ok(()) + } + Stmt::ClosureLet { + closure, + expected_schema, + .. + } => resolve_closure(closure, expected_schema.as_ref(), env), + Stmt::Expr { expr, .. } => resolve_expr(expr, None, env), + Stmt::IfElse { + condition, + then_branch, + else_branch, + .. + } => { + resolve_expr(condition, Some(&TypeSchema::Bool), env)?; + resolve_stmts(then_branch, expected_tail.cloned(), env)?; + resolve_stmts(else_branch, expected_tail.cloned(), env) + } + Stmt::For { + init, + condition, + post, + body, + .. + } => { + resolve_stmt(init, None, env)?; + resolve_expr(condition, Some(&TypeSchema::Bool), env)?; + resolve_stmt(post, None, env)?; + resolve_stmts(body, None, env) + } + Stmt::While { + condition, body, .. + } => { + resolve_expr(condition, Some(&TypeSchema::Bool), env)?; + resolve_stmts(body, None, env) + } + Stmt::Drop { index, .. } => { + env.slots.remove(index); + Ok(()) + } + } +} + +fn resolve_expr( + expr: &mut Expr, + expected: Option<&TypeSchema>, + env: &mut Env, +) -> Result<(), CompileError> { + match expr { + Expr::ResultConstruct { + ok, + payload, + result_schema, + span, + .. + } => resolve_construct(*ok, payload, result_schema, *span, expected, env), + Expr::ResultMatch { + value, + ok_binding, + ok_expr, + err_binding, + err_expr, + scrutinee_schema, + span, + .. + } => resolve_match( + value, + *ok_binding, + ok_expr, + *err_binding, + err_expr, + scrutinee_schema, + *span, + expected, + env, + ), + Expr::ResultQuery { value, span, .. } => { + resolve_expr(value, None, env)?; + require_result_schema( + authoritative_schema(value, env).as_ref(), + *span, + "is_ok/is_err", + ) + } + Expr::Block { stmts, expr } => { + resolve_stmts(stmts, expected.cloned(), env)?; + resolve_expr(expr, expected, env) + } + Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + resolve_expr(condition, Some(&TypeSchema::Bool), env)?; + resolve_expr(then_expr, expected, env)?; + resolve_expr(else_expr, expected, env) + } + Expr::Match { + value, + arms, + default, + .. + } => { + resolve_expr(value, None, env)?; + for (_, arm) in arms { + resolve_expr(arm, expected, env)?; + } + resolve_expr(default, expected, env) + } + Expr::OptionalGet { container, key, .. } => { + resolve_expr(container, None, env)?; + resolve_expr(key, None, env) + } + Expr::OptionUnwrapOr { + value, fallback, .. + } => { + resolve_expr(value, None, env)?; + resolve_expr(fallback, expected, env) + } + Expr::Call(index, type_args, args, _, _) => { + let expected_args = super::typing::argument_schemas_for_call( + &env.function_list, + &env.function_impls, + &env.struct_schemas, + *index, + type_args, + ) + .or_else(|| { + super::typing::expected_container_argument_schemas( + &env.function_list, + &env.function_impls, + &env.struct_schemas, + *index, + args, + expected, + ) + }); + for (offset, arg) in args.iter_mut().enumerate() { + let expected_arg = expected_args + .as_ref() + .and_then(|schemas| schemas.get(offset)) + .and_then(|schema| schema.as_ref()); + resolve_expr(arg, expected_arg, env)?; + } + Ok(()) + } + Expr::LocalCall(slot, _, args, _) => { + let params = env.slots.get(slot).and_then(|schema| match schema { + TypeSchema::Callable { params, .. } => Some(params.clone()), + _ => None, + }); + for (offset, arg) in args.iter_mut().enumerate() { + let expected_arg = params.as_ref().and_then(|schemas| schemas.get(offset)); + resolve_expr(arg, expected_arg, env)?; + } + Ok(()) + } + Expr::ModuleCall(symbol, type_args, args, _) => { + let expected_args = env + .function_list + .iter() + .find(|function| function.symbol.as_ref() == Some(symbol)) + .and_then(|function| { + super::typing::argument_schemas_for_call( + &env.function_list, + &env.function_impls, + &env.struct_schemas, + function.index, + type_args, + ) + }); + for (offset, arg) in args.iter_mut().enumerate() { + let expected_arg = expected_args + .as_ref() + .and_then(|schemas| schemas.get(offset)) + .and_then(|schema| schema.as_ref()); + resolve_expr(arg, expected_arg, env)?; + } + Ok(()) + } + Expr::Closure(closure) => resolve_closure(closure, expected, env), + Expr::ClosureCall(closure, args) => { + resolve_closure(closure, expected, env)?; + for arg in args.iter_mut() { + resolve_expr(arg, None, env)?; + } + Ok(()) + } + Expr::Add(lhs, rhs) + | Expr::Sub(lhs, rhs) + | Expr::Mul(lhs, rhs) + | Expr::Div(lhs, rhs) + | Expr::Mod(lhs, rhs) + | Expr::And(lhs, rhs) + | Expr::Or(lhs, rhs) + | Expr::Eq(lhs, rhs) + | Expr::Lt(lhs, rhs) + | Expr::Gt(lhs, rhs) => { + resolve_expr(lhs, None, env)?; + resolve_expr(rhs, None, env) + } + Expr::Neg(inner) + | Expr::Not(inner) + | Expr::ToOwned(inner) + | Expr::Borrow(inner) + | Expr::BorrowMut(inner) => resolve_expr(inner, None, env), + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::String(_) + | Expr::Bytes(_) + | Expr::FunctionRef(_, _) + | Expr::ModuleFunctionRef(_, _) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Var(_) + | Expr::MoveVar(_) + | Expr::MoveField { .. } + | Expr::MoveIndex { .. } => Ok(()), + } +} + +fn resolve_construct( + ok: bool, + payload: &mut Expr, + result_schema: &mut TypeSchema, + span: Span, + expected: Option<&TypeSchema>, + env: &mut Env, +) -> Result<(), CompileError> { + let explicit = match result_schema { + TypeSchema::Result { .. } => Some(result_schema.clone()), + _ => None, + }; + let contextual = match (expected, explicit.as_ref()) { + (Some(expected_schema @ TypeSchema::Result { .. }), Some(explicit_schema)) => { + if !super::typing::result_branch_schemas_compatible( + &env.function_list, + &env.function_impls, + &env.struct_schemas, + expected_schema, + explicit_schema, + ) { + return Err(result_error( + span, + format!( + "Result::{} explicit schema does not match the expected Result schema", + ctor_name(ok) + ), + )); + } + Some(expected_schema.clone()) + } + (Some(expected_schema @ TypeSchema::Result { .. }), None) => Some(expected_schema.clone()), + (None, Some(explicit_schema)) => Some(explicit_schema.clone()), + (Some(other), _) => { + return Err(result_error( + span, + format!("Result::{} is not a value of type {other:?}", ctor_name(ok)), + )); + } + (None, None) => None, + }; + let Some(expected_schema) = contextual else { + return Err(result_error( + span, + format!( + "Result::{} needs an explicit Result annotation for the inactive branch", + ctor_name(ok) + ), + )); + }; + let branch_expected = active_branch(&expected_schema, ok); + resolve_expr(payload, branch_expected, env)?; + let Some(active) = active_branch(&expected_schema, ok) else { + return Err(result_error( + span, + "internal Result schema lost its active branch".to_string(), + )); + }; + if let Err(detail) = super::typing::check_result_payload( + &env.function_list, + &env.function_impls, + &env.struct_schemas, + payload, + active, + &env.slots, + ) { + return Err(result_error( + span, + format!("Result::{} payload: {detail}", ctor_name(ok)), + )); + } + *result_schema = expected_schema; + Ok(()) +} + +// Each Result arm keeps its binding and expression explicit. The remaining +// arguments are the scrutinee, its schema, the span, the expected type, and env. +#[allow(clippy::too_many_arguments)] +fn resolve_match( + value: &mut Expr, + ok_binding: Option, + ok_expr: &mut Expr, + err_binding: Option, + err_expr: &mut Expr, + scrutinee_schema: &mut TypeSchema, + span: Span, + expected: Option<&TypeSchema>, + env: &mut Env, +) -> Result<(), CompileError> { + resolve_expr(value, None, env)?; + let schema = authoritative_schema(value, env) + .ok_or_else(|| result_error(span, "match scrutinee must be a Result".to_string()))?; + let TypeSchema::Result { ok, err } = &schema else { + return Err(result_error( + span, + format!("match scrutinee has type {schema:?}, expected Result"), + )); + }; + *scrutinee_schema = schema.clone(); + let saved = env.slots.clone(); + if let Some(slot) = ok_binding { + env.slots.insert(slot, ok.as_ref().clone()); + } + resolve_expr(ok_expr, expected, env)?; + env.slots = saved.clone(); + if let Some(slot) = err_binding { + env.slots.insert(slot, err.as_ref().clone()); + } + resolve_expr(err_expr, expected, env)?; + env.slots = saved; + Ok(()) +} + +fn authoritative_schema(expr: &Expr, env: &Env) -> Option { + infer_schema(expr, env) + .or_else(|| { + super::typing::infer_value_schema( + &env.function_list, + &env.function_impls, + &env.struct_schemas, + expr, + &env.slots, + ) + }) + .filter(|schema| !matches!(schema, TypeSchema::Unknown)) +} + +fn resolve_closure( + closure: &mut ClosureExpr, + expected: Option<&TypeSchema>, + env: &mut Env, +) -> Result<(), CompileError> { + let (param_schemas, expected_return) = match expected { + Some(TypeSchema::Callable { params, result }) => { + (Some(params.as_slice()), Some(result.as_ref())) + } + other => (None, other), + }; + let nested = super::typing::contextual_callable_slot_schemas( + &env.function_list, + &env.function_impls, + &env.struct_schemas, + &env.slots, + &closure.param_slots, + param_schemas, + &closure.capture_copies, + ); + let saved = std::mem::replace(&mut env.slots, nested); + let resolved = resolve_expr(&mut closure.body, expected_return, env); + env.slots = saved; + resolved +} + +fn require_result_schema( + schema: Option<&TypeSchema>, + span: Span, + site: &str, +) -> Result<(), CompileError> { + match schema { + Some(TypeSchema::Result { .. }) => Ok(()), + Some(other) => Err(result_error( + span, + format!("{site} requires Result, found {other:?}"), + )), + None => Err(result_error( + span, + format!("{site} requires a Result value"), + )), + } +} + +pub(crate) fn active_branch(schema: &TypeSchema, ok: bool) -> Option<&TypeSchema> { + match schema { + TypeSchema::Result { ok: ok_ty, err } => { + Some(if ok { ok_ty.as_ref() } else { err.as_ref() }) + } + _ => None, + } +} + +fn infer_schema(expr: &Expr, env: &Env) -> Option { + match expr { + Expr::Null => Some(TypeSchema::Null), + Expr::Int(_) => Some(TypeSchema::Int), + Expr::Float(_) => Some(TypeSchema::Float), + Expr::Bool(_) | Expr::ResultQuery { .. } => Some(TypeSchema::Bool), + Expr::String(_) => Some(TypeSchema::String), + Expr::Bytes(_) => Some(TypeSchema::Bytes), + Expr::Var(slot) | Expr::MoveVar(slot) => env.slots.get(slot).cloned(), + Expr::ResultConstruct { result_schema, .. } => Some(result_schema.clone()), + Expr::Add(lhs, rhs) | Expr::Sub(lhs, rhs) | Expr::Mul(lhs, rhs) => { + match (infer_schema(lhs, env), infer_schema(rhs, env)) { + (Some(TypeSchema::Int), Some(TypeSchema::Int)) => Some(TypeSchema::Int), + (Some(TypeSchema::Float), Some(TypeSchema::Float | TypeSchema::Int)) + | (Some(TypeSchema::Int), Some(TypeSchema::Float)) => Some(TypeSchema::Float), + _ => None, + } + } + Expr::Block { expr, .. } => infer_schema(expr, env), + Expr::IfElse { + then_expr, + else_expr, + .. + } => { + let then_schema = infer_schema(then_expr, env)?; + let else_schema = infer_schema(else_expr, env)?; + if then_schema == else_schema { + Some(then_schema) + } else { + None + } + } + _ => None, + } +} + +fn ctor_name(ok: bool) -> &'static str { + if ok { "Ok" } else { "Err" } +} + +fn result_error(span: Span, detail: String) -> CompileError { + CompileError::StrictTypingRequired { + line: None, + source_name: None, + detail, + span: Some(span), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ValueType; + use crate::compiler::{CompileError, ModuleId, SymbolId}; + + fn result_int_string() -> TypeSchema { + TypeSchema::Result { + ok: Box::new(TypeSchema::Int), + err: Box::new(TypeSchema::String), + } + } + + fn ir_with(stmts: Vec, functions: Vec) -> FrontendIr { + FrontendIr { + stmts, + locals: 0, + local_bindings: Vec::new(), + struct_schemas: HashMap::new(), + unknown_type_spans: Vec::new(), + functions, + function_impls: HashMap::new(), + stmt_sources: Vec::new(), + function_sources: HashMap::new(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), + host_api_metadata: None, + semantic_index: None, + parsed_semantic_index: None, + catalog_visibility: None, + lexer_tokens: Vec::new(), + } + } + + fn constructor(ok: bool, payload: Expr) -> Expr { + Expr::ResultConstruct { + ok, + payload: Box::new(payload), + result_schema: TypeSchema::Unknown, + span: Span::new(0, 0, 1), + semantic_id: None, + } + } + + #[test] + fn module_call_formal_fills_result_constructor_before_merge() { + let symbol = SymbolId { + module: ModuleId(1), + index: 0, + }; + let mut ir = ir_with( + vec![Stmt::Expr { + expr: Expr::ModuleCall( + symbol, + Vec::new(), + vec![constructor(true, Expr::Int(6))], + None, + ), + line: 1, + }], + vec![FunctionDecl { + name: "take".to_string(), + arity: 1, + index: 0, + args: vec!["value".to_string()], + arg_schemas: vec![Some(result_int_string())], + return_schema: Some(TypeSchema::Int), + type_params: Vec::new(), + exported: true, + return_type: ValueType::Int, + symbol: Some(symbol), + }], + ); + resolve_result_types(&mut ir, &[]) + .expect("formal Result schema should fill the constructor"); + let Stmt::Expr { + expr: Expr::ModuleCall(_, _, args, _), + .. + } = &ir.stmts[0] + else { + panic!("module call should remain until merge"); + }; + let Expr::ResultConstruct { result_schema, .. } = &args[0] else { + panic!("argument should stay a constructor"); + }; + assert!(matches!(result_schema, TypeSchema::Result { .. })); + } + + #[test] + fn module_call_wrong_payload_is_rejected_from_formal() { + let symbol = SymbolId { + module: ModuleId(1), + index: 0, + }; + let mut ir = ir_with( + vec![Stmt::Expr { + expr: Expr::ModuleCall( + symbol, + Vec::new(), + vec![constructor(true, Expr::String("no".to_string()))], + None, + ), + line: 1, + }], + vec![FunctionDecl { + name: "take".to_string(), + arity: 1, + index: 0, + args: vec!["value".to_string()], + arg_schemas: vec![Some(result_int_string())], + return_schema: Some(TypeSchema::Int), + type_params: Vec::new(), + exported: true, + return_type: ValueType::Int, + symbol: Some(symbol), + }], + ); + let error = resolve_result_types(&mut ir, &[]).expect_err("wrong payload must be rejected"); + let CompileError::StrictTypingRequired { detail, .. } = error else { + panic!("unexpected error {error:?}"); + }; + assert!( + (detail.contains("schema") || detail.contains("assigned") || detail.contains("string")) + && !detail.contains("inactive branch"), + "{detail}" + ); + } +} diff --git a/src/compiler/semantic_model.rs b/src/compiler/semantic_model.rs index e486927d..2a29dd6b 100644 --- a/src/compiler/semantic_model.rs +++ b/src/compiler/semantic_model.rs @@ -642,6 +642,10 @@ impl SemanticModel { result: Box::new(self.compiler_schema_to_host_schema(result)), }, TypeSchema::Resource(key) => HostTypeSchema::Resource(key.clone()), + TypeSchema::Result { ok, err } => HostTypeSchema::Result { + ok: Box::new(self.compiler_schema_to_host_schema(ok)), + err: Box::new(self.compiler_schema_to_host_schema(err)), + }, TypeSchema::Named(name, _type_args) => self .catalog .struct_named(name) @@ -1673,6 +1677,7 @@ impl std::fmt::Display for TypeSchema { write!(f, "fn({}) -> {result}", params_str.join(", ")) } TypeSchema::Resource(key) => write!(f, "resource<{key}>"), + TypeSchema::Result { ok, err } => write!(f, "Result<{ok}, {err}>"), } } } diff --git a/src/compiler/source_loader/graph.rs b/src/compiler/source_loader/graph.rs index 226770d0..334e7a2a 100644 --- a/src/compiler/source_loader/graph.rs +++ b/src/compiler/source_loader/graph.rs @@ -467,6 +467,10 @@ fn collect_named_schema_names(schema: &TypeSchema, names: &mut HashSet) collect_named_schema_names(schema, names); } } + TypeSchema::Result { ok, err } => { + collect_named_schema_names(ok, names); + collect_named_schema_names(err, names); + } TypeSchema::Callable { params, result } => { for schema in params { collect_named_schema_names(schema, names); @@ -1871,6 +1875,22 @@ fn resolve_expr_imported_calls( | Expr::BorrowMut(inner) => { resolve_expr_imported_calls(ctx, inner, line, parsed_semantic_index.as_deref_mut())?; } + Expr::ResultConstruct { payload, .. } => { + resolve_expr_imported_calls(ctx, payload, line, parsed_semantic_index.as_deref_mut())?; + } + Expr::ResultQuery { value, .. } => { + resolve_expr_imported_calls(ctx, value, line, parsed_semantic_index.as_deref_mut())?; + } + Expr::ResultMatch { + value, + ok_expr, + err_expr, + .. + } => { + resolve_expr_imported_calls(ctx, value, line, parsed_semantic_index.as_deref_mut())?; + resolve_expr_imported_calls(ctx, ok_expr, line, parsed_semantic_index.as_deref_mut())?; + resolve_expr_imported_calls(ctx, err_expr, line, parsed_semantic_index.as_deref_mut())?; + } Expr::IfElse { condition, then_expr, diff --git a/src/compiler/typing.rs b/src/compiler/typing.rs index a6644fbe..e1dfce14 100644 --- a/src/compiler/typing.rs +++ b/src/compiler/typing.rs @@ -161,6 +161,33 @@ pub(super) struct EntryLocalType { pub(super) optional: bool, } +/// Whole logical type for a shared entry view. +/// +/// The optional bit and a single `Optional` wrapper are the same fact. +/// Unknown is not wrapped or inserted: it is not a nullable value. +pub(super) fn logical_entry_schema(entry: &EntryLocalType) -> Option { + let schema = entry.schema.clone()?; + project_logical_schema(schema, entry.optional) +} + +pub(super) fn project_logical_schema(schema: TypeSchema, optional: bool) -> Option { + if matches!(schema, TypeSchema::Unknown) { + return None; + } + let (inner, schema_optional) = schema.split_optional(); + if matches!(inner, TypeSchema::Unknown) { + return None; + } + let optional = optional || schema_optional; + Some( + if optional && !matches!(inner, TypeSchema::Optional(_) | TypeSchema::Null) { + TypeSchema::Optional(Box::new(inner)) + } else { + inner + }, + ) +} + fn seed_entry_local_state(state: &mut LocalTypeState, entry_local_types: &[EntryLocalType]) { for entry_local in entry_local_types { let (schema, schema_optional) = entry_local @@ -909,6 +936,239 @@ pub(crate) fn build_host_import_signatures( helpers::build_host_import_signatures(functions, function_impls) } +struct ResultTyping<'a> { + function_names: HashMap, + function_decls: HashMap, + host_import_return_types: HashMap, + host_import_signatures: HashMap, + function_impls: &'a HashMap, + struct_schemas: &'a HashMap, +} + +impl<'a> ResultTyping<'a> { + fn new( + functions: &[FunctionDecl], + function_impls: &'a HashMap, + struct_schemas: &'a HashMap, + ) -> Self { + Self { + function_names: build_function_names(functions), + function_decls: build_function_decl_map(functions), + host_import_return_types: build_host_import_return_types(functions, function_impls), + host_import_signatures: build_host_import_signatures(functions, function_impls), + function_impls, + struct_schemas, + } + } + + fn context(&self) -> TypeContext<'_> { + TypeContext::new( + self.function_impls, + &self.function_decls, + self.struct_schemas, + &self.function_names, + &self.host_import_return_types, + &self.host_import_signatures, + TypingMode::Strict, + None, + ) + } +} + +fn result_local_state(slots: &HashMap) -> LocalTypeState { + let mut state = LocalTypeState::default(); + for (slot, schema) in slots { + state.set_with_schema_origin( + *slot, + bound_type_from_schema(schema), + Some(schema.clone()), + true, + ); + } + state +} + +fn inferred_value_schema( + typing: &mut ResultTyping<'_>, + expr: &Expr, + slots: &HashMap, +) -> Option { + let state = result_local_state(slots); + let mut context = typing.context(); + context + .infer_expr_schema(expr, &state) + .or_else(|| scalar_schema_from_bound_type(infer_expr_type(expr, &state))) + .filter(|schema| !matches!(schema, TypeSchema::Unknown)) +} + +/// Infer and check a Result payload with the typing owner's schema rules. +/// Numeric compatibility applies only at this selected-payload position. +/// `Unknown` and unbound generic parameters are not success. +pub(crate) fn check_result_payload( + functions: &[FunctionDecl], + function_impls: &HashMap, + struct_schemas: &HashMap, + payload: &Expr, + expected: &TypeSchema, + slots: &HashMap, +) -> Result<(), String> { + let mut typing = ResultTyping::new(functions, function_impls, struct_schemas); + let Some(actual) = inferred_value_schema(&mut typing, payload, slots) else { + return Err(format!( + "cannot check Result payload against {}", + render_schema_label(expected) + )); + }; + let mut context = typing.context(); + context.result_payload_strict = true; + if let Some(detail) = + helpers::find_declared_schema_mismatch(expected, &actual, &mut context, String::new()) + { + return Err(detail); + } + Ok(()) +} + +pub(crate) fn contextual_callable_slot_schemas( + functions: &[FunctionDecl], + function_impls: &HashMap, + struct_schemas: &HashMap, + caller_slots: &HashMap, + param_slots: &[LocalSlot], + param_schemas: Option<&[TypeSchema]>, + capture_copies: &[(LocalSlot, LocalSlot)], +) -> HashMap { + let caller = result_local_state(caller_slots); + let typing = ResultTyping::new(functions, function_impls, struct_schemas); + let mut context = typing.context(); + let declared = param_schemas.map(|schemas| { + schemas + .iter() + .map(|schema| (!matches!(schema, TypeSchema::Unknown)).then(|| schema.clone())) + .collect::>() + }); + let Some(nested) = context.build_callable_state( + param_slots, + declared.as_deref(), + capture_copies, + None, + &caller, + ) else { + return HashMap::new(); + }; + let mut slots = HashMap::new(); + for (_, captured) in capture_copies { + if let Some(schema) = exported_logical_schema(&nested, *captured) { + slots.insert(*captured, schema); + } + } + for slot in param_slots { + if let Some(schema) = exported_logical_schema(&nested, *slot) { + slots.insert(*slot, schema); + } + } + slots +} + +fn exported_logical_schema(state: &LocalTypeState, slot: LocalSlot) -> Option { + let schema = state.schema(slot).cloned()?; + project_logical_schema(schema, state.is_optional(slot)) +} + +pub(crate) fn infer_value_schema( + functions: &[FunctionDecl], + function_impls: &HashMap, + struct_schemas: &HashMap, + expr: &Expr, + slots: &HashMap, +) -> Option { + let mut typing = ResultTyping::new(functions, function_impls, struct_schemas); + inferred_value_schema(&mut typing, expr, slots) +} + +pub(crate) fn argument_schemas_for_call( + functions: &[FunctionDecl], + function_impls: &HashMap, + struct_schemas: &HashMap, + index: u16, + type_args: &[TypeSchema], +) -> Option>> { + if crate::builtins::BuiltinFunction::from_call_index(index).is_some() { + return None; + } + let typing = ResultTyping::new(functions, function_impls, struct_schemas); + let mut context = typing.context(); + context.resolve_function_arg_schemas(index, type_args) +} + +pub(crate) fn expected_container_argument_schemas( + functions: &[FunctionDecl], + function_impls: &HashMap, + struct_schemas: &HashMap, + index: u16, + args: &[Expr], + expected: Option<&TypeSchema>, +) -> Option>> { + let builtin = crate::builtins::BuiltinFunction::from_call_index(index)?; + let expected = expected?; + match builtin { + crate::builtins::BuiltinFunction::ArrayPush if args.len() == 2 => { + expected.array_prefix_and_rest()?; + let element = expected + .array_item_schema_at(0) + .or_else(|| expected.collapsed_array_item_schema()) + .filter(|schema| !matches!(schema, TypeSchema::Unknown)); + Some(vec![Some(expected.clone()), element]) + } + crate::builtins::BuiltinFunction::Set if args.len() == 3 => { + let value = expected_named_or_container_field_schema( + functions, + function_impls, + struct_schemas, + expected, + &args[1], + ); + Some(vec![Some(expected.clone()), None, value]) + } + _ => None, + } +} + +fn expected_named_or_container_field_schema( + functions: &[FunctionDecl], + function_impls: &HashMap, + struct_schemas: &HashMap, + expected: &TypeSchema, + key: &Expr, +) -> Option { + let Expr::String(name) = key else { + return None; + }; + let schema = match expected { + TypeSchema::Named(struct_name, type_args) => { + let typing = ResultTyping::new(functions, function_impls, struct_schemas); + let mut context = typing.context(); + context::source_named_struct_field_schema(struct_name, type_args, name, &mut context)? + } + TypeSchema::Object(fields) => fields.get(name).cloned()?, + TypeSchema::Map(inner) => inner.as_ref().clone(), + _ => return None, + }; + (!matches!(schema, TypeSchema::Unknown)).then_some(schema) +} + +pub(crate) fn result_branch_schemas_compatible( + functions: &[FunctionDecl], + function_impls: &HashMap, + struct_schemas: &HashMap, + expected: &TypeSchema, + actual: &TypeSchema, +) -> bool { + let typing = ResultTyping::new(functions, function_impls, struct_schemas); + let context = typing.context(); + context.substitute_schema_generics(expected) == context.substitute_schema_generics(actual) +} + #[cfg(test)] mod catalog_call_resolution_tests { use std::sync::Arc; diff --git a/src/compiler/typing/collect.rs b/src/compiler/typing/collect.rs index 8234f350..808e2ebc 100644 --- a/src/compiler/typing/collect.rs +++ b/src/compiler/typing/collect.rs @@ -384,6 +384,18 @@ fn collect_root_expr( | Expr::ModuleFunctionRef(..) | Expr::UnresolvedFunctionRef { .. } | Expr::Closure(_) => {} + Expr::ResultConstruct { payload, .. } => operand(payload, false), + Expr::ResultQuery { value, .. } => operand(value, false), + Expr::ResultMatch { + value, + ok_expr, + err_expr, + .. + } => { + operand(value, false); + operand(ok_expr, true); + operand(err_expr, true); + } Expr::IfElse { .. } | Expr::Match { .. } | Expr::Block { .. } => unreachable!(), } root_expr_value(&inferred_expr, &operand_state, context) @@ -1186,6 +1198,124 @@ fn collect_expr_types( ); let _ = context.infer_expr_type(expr, state); } + Expr::ResultConstruct { payload, .. } => { + collect_expr_types( + payload, + state, + local_types, + local_schemas, + local_schema_labels, + callable_slots, + optional_slots, + context, + ); + let _ = context.infer_expr_type(expr, state); + } + Expr::ResultQuery { value, .. } => { + collect_expr_types( + value, + state, + local_types, + local_schemas, + local_schema_labels, + callable_slots, + optional_slots, + context, + ); + let _ = context.infer_expr_type(expr, state); + } + Expr::ResultMatch { + value, + value_slot, + ok_binding, + ok_expr, + err_binding, + err_expr, + scrutinee_schema, + .. + } => { + collect_expr_types( + value, + state, + local_types, + local_schemas, + local_schema_labels, + callable_slots, + optional_slots, + context, + ); + let value_ty = context.infer_expr_type(value, state); + let mut nested = state.clone(); + bind_expr_result_to_slot( + &mut nested, + *value_slot, + None, + value, + state, + value_ty, + context, + ); + record_local_type(local_types, *value_slot, nested.get(*value_slot)); + record_local_schema(local_schemas, *value_slot, nested.schema(*value_slot)); + record_local_schema_label(local_schema_labels, *value_slot, nested.schema(*value_slot)); + let (ok_schema, err_schema) = match scrutinee_schema { + TypeSchema::Result { ok, err } => (ok.as_ref().clone(), err.as_ref().clone()), + _ => (TypeSchema::Unknown, TypeSchema::Unknown), + }; + let mut ok_state = nested.clone(); + if let Some(slot) = *ok_binding { + ok_state.set_with_optional_schema_origin( + slot, + context.bound_type_for_schema(&ok_schema), + Some(ok_schema.clone()), + true, + matches!(ok_schema, TypeSchema::Optional(_)), + ); + record_local_type(local_types, slot, ok_state.get(slot)); + record_local_schema(local_schemas, slot, ok_state.schema(slot)); + record_local_schema_label(local_schema_labels, slot, ok_state.schema(slot)); + if ok_state.is_optional(slot) { + record_optional_slot(optional_slots, slot); + } + } + let mut err_state = nested; + if let Some(slot) = *err_binding { + err_state.set_with_optional_schema_origin( + slot, + context.bound_type_for_schema(&err_schema), + Some(err_schema.clone()), + true, + matches!(err_schema, TypeSchema::Optional(_)), + ); + record_local_type(local_types, slot, err_state.get(slot)); + record_local_schema(local_schemas, slot, err_state.schema(slot)); + record_local_schema_label(local_schema_labels, slot, err_state.schema(slot)); + if err_state.is_optional(slot) { + record_optional_slot(optional_slots, slot); + } + } + collect_expr_types( + ok_expr, + &ok_state, + local_types, + local_schemas, + local_schema_labels, + callable_slots, + optional_slots, + context, + ); + collect_expr_types( + err_expr, + &err_state, + local_types, + local_schemas, + local_schema_labels, + callable_slots, + optional_slots, + context, + ); + let _ = context.infer_expr_type(expr, state); + } Expr::IfElse { condition, then_expr, diff --git a/src/compiler/typing/context.rs b/src/compiler/typing/context.rs index 2bb4844c..3472f242 100644 --- a/src/compiler/typing/context.rs +++ b/src/compiler/typing/context.rs @@ -84,6 +84,9 @@ fn schema_mentions_generic_param(schema: &TypeSchema, name: &str) -> bool { } TypeSchema::Map(value) => schema_mentions_generic_param(value, name), TypeSchema::Optional(inner) => schema_mentions_generic_param(inner, name), + TypeSchema::Result { ok, err } => { + schema_mentions_generic_param(ok, name) || schema_mentions_generic_param(err, name) + } TypeSchema::Object(fields) => fields .values() .any(|value| schema_mentions_generic_param(value, name)), @@ -124,6 +127,9 @@ pub(super) struct TypeContext<'a> { /// diagnostics. `None` for hand-built test IRs and plugin frontends that /// carry no parser index. parsed: Option<&'a crate::compiler::ir::ParsedSemanticIndex>, + /// Result payload checks reuse this context but must not treat + /// `GenericParam` or `Unknown` as compatible with every concrete schema. + pub(super) result_payload_strict: bool, } struct CallableBody<'a> { @@ -170,6 +176,7 @@ impl<'a> TypeContext<'a> { active_observed_returns: Vec::new(), active_optional_returns: Vec::new(), parsed, + result_payload_strict: false, } } @@ -294,6 +301,10 @@ impl<'a> TypeContext<'a> { .collect(), result: Box::new(self.substitute_schema_generics(result)), }, + TypeSchema::Result { ok, err } => TypeSchema::Result { + ok: Box::new(self.substitute_schema_generics(ok)), + err: Box::new(self.substitute_schema_generics(err)), + }, _ => schema.clone(), } } @@ -467,6 +478,19 @@ impl<'a> TypeContext<'a> { self.resolve_schema_with_seen_tripped(inner, seen, reentries); (TypeSchema::Optional(Box::new(resolved)), trip) } + TypeSchema::Result { ok, err } => { + let (resolved_ok, ok_trip) = + self.resolve_schema_with_seen_tripped(ok, seen, reentries); + let (resolved_err, err_trip) = + self.resolve_schema_with_seen_tripped(err, seen, reentries); + ( + TypeSchema::Result { + ok: Box::new(resolved_ok), + err: Box::new(resolved_err), + }, + ok_trip.or(err_trip), + ) + } TypeSchema::Object(fields) => { let mut resolved_fields = HashMap::with_capacity(fields.len()); let mut innermost_trip = None; @@ -1307,6 +1331,47 @@ impl<'a> TypeContext<'a> { self.apply_stmts(stmts, &mut nested); self.infer_expr_schema(expr, &nested) } + Expr::ResultQuery { .. } => Some(TypeSchema::Bool), + Expr::ResultConstruct { result_schema, .. } => match result_schema { + TypeSchema::Unknown => None, + other => Some(self.substitute_schema_generics(other)), + }, + Expr::ResultMatch { + ok_binding, + ok_expr, + err_binding, + err_expr, + scrutinee_schema, + .. + } => { + let TypeSchema::Result { ok, err } = scrutinee_schema else { + return None; + }; + let mut ok_state = state.clone(); + if let Some(slot) = *ok_binding { + ok_state.set_with_schema_origin( + slot, + self.bound_type_for_schema(ok), + Some(ok.as_ref().clone()), + true, + ); + } + let mut err_state = state.clone(); + if let Some(slot) = *err_binding { + err_state.set_with_schema_origin( + slot, + self.bound_type_for_schema(err), + Some(err.as_ref().clone()), + true, + ); + } + let ok_schema = self.infer_expr_schema(ok_expr, &ok_state); + let err_schema = self.infer_expr_schema(err_expr, &err_state); + match (ok_schema, err_schema) { + (Some(lhs), Some(rhs)) if lhs == rhs => Some(lhs), + _ => None, + } + } _ => None, } } @@ -1434,6 +1499,56 @@ impl<'a> TypeContext<'a> { BoundType::Unknown } } + Expr::ResultConstruct { payload, .. } => { + let _ = self.infer_expr_type(payload, state); + BoundType::Map + } + Expr::ResultQuery { value, .. } => { + let _ = self.infer_expr_type(value, state); + BoundType::Bool + } + Expr::ResultMatch { + value, + ok_binding, + ok_expr, + err_binding, + err_expr, + scrutinee_schema, + .. + } => { + let _ = self.infer_expr_type(value, state); + let (ok_schema, err_schema) = match scrutinee_schema { + TypeSchema::Result { ok, err } => (ok.as_ref().clone(), err.as_ref().clone()), + _ => (TypeSchema::Unknown, TypeSchema::Unknown), + }; + let mut ok_state = state.clone(); + if let Some(slot) = *ok_binding { + ok_state.set_with_optional_schema_origin( + slot, + self.bound_type_for_schema(&ok_schema), + Some(ok_schema), + true, + false, + ); + } + let mut err_state = state.clone(); + if let Some(slot) = *err_binding { + err_state.set_with_optional_schema_origin( + slot, + self.bound_type_for_schema(&err_schema), + Some(err_schema), + true, + false, + ); + } + let ok_ty = self.infer_expr_type(ok_expr, &ok_state); + let err_ty = self.infer_expr_type(err_expr, &err_state); + if ok_ty == err_ty { + ok_ty + } else { + BoundType::Unknown + } + } Expr::Block { stmts, expr } => { let mut nested = state.clone(); self.apply_stmts(stmts, &mut nested); @@ -2861,7 +2976,7 @@ pub(crate) fn bound_type_from_schema(schema: &TypeSchema) -> BoundType { TypeSchema::Array(_) | TypeSchema::ArrayTuple(_) | TypeSchema::ArrayTupleRest { .. } => { BoundType::Array } - TypeSchema::Map(_) | TypeSchema::Object(_) => BoundType::Map, + TypeSchema::Map(_) | TypeSchema::Object(_) | TypeSchema::Result { .. } => BoundType::Map, // Resources are opaque (nominal) values: they are never reduced to the // `Map` bound or to an integral token in semantic inference. TypeSchema::Resource(_) => BoundType::Unknown, @@ -3061,7 +3176,7 @@ pub(super) fn infer_access_schema( } } -fn source_named_struct_field_schema( +pub(super) fn source_named_struct_field_schema( struct_name: &str, type_args: &[TypeSchema], field_name: &str, @@ -3115,7 +3230,7 @@ pub(super) fn schema_label(schema: &TypeSchema) -> &'static str { TypeSchema::Array(_) | TypeSchema::ArrayTuple(_) | TypeSchema::ArrayTupleRest { .. } => { "array" } - TypeSchema::Map(_) | TypeSchema::Object(_) => "map", + TypeSchema::Map(_) | TypeSchema::Object(_) | TypeSchema::Result { .. } => "map", TypeSchema::Resource(_) => "resource", } } @@ -3175,6 +3290,11 @@ pub(crate) fn render_schema_label(schema: &TypeSchema) -> String { format!("[{}]", parts.join(", ")) } TypeSchema::Map(value) => format!("map<{}>", render_schema_label(value)), + TypeSchema::Result { ok, err } => format!( + "Result<{}, {}>", + render_schema_label(ok), + render_schema_label(err) + ), // Resources render as their nominal key (`resource`), never as // their physical integer ABI token or a structural `map<...>` shape. TypeSchema::Resource(key) => format!("resource<{key}>"), diff --git a/src/compiler/typing/helpers.rs b/src/compiler/typing/helpers.rs index 28a85939..614f405c 100644 --- a/src/compiler/typing/helpers.rs +++ b/src/compiler/typing/helpers.rs @@ -286,6 +286,9 @@ fn schema_contains_unresolved(schema: &TypeSchema) -> bool { TypeSchema::Callable { params, result } => { params.iter().any(schema_contains_unresolved) || schema_contains_unresolved(result) } + TypeSchema::Result { ok, err } => { + schema_contains_unresolved(ok) || schema_contains_unresolved(err) + } TypeSchema::Null | TypeSchema::Int | TypeSchema::Float @@ -1263,6 +1266,17 @@ fn find_declared_schema_mismatch_with_recursion( }; match (&expected, &actual) { + (_, TypeSchema::Unknown) | (TypeSchema::Unknown, _) if context.result_payload_strict => { + Some("cannot check Result payload against an unknown schema".to_string()) + } + (_, TypeSchema::Null) + if context.result_payload_strict && !matches!(expected, TypeSchema::Null) => + { + Some(format!( + "Result payload null is not compatible with {}", + schema_type_label(&expected) + )) + } (_, TypeSchema::Unknown | TypeSchema::Null) | (TypeSchema::Unknown, _) => None, (TypeSchema::Int, TypeSchema::Int) | (TypeSchema::Number, TypeSchema::Int) @@ -1293,7 +1307,18 @@ fn find_declared_schema_mismatch_with_recursion( allow_partial_object, ) } - (TypeSchema::GenericParam(_), _) | (_, TypeSchema::GenericParam(_)) => None, + (TypeSchema::GenericParam(_), _) | (_, TypeSchema::GenericParam(_)) => { + if context.result_payload_strict && expected != actual { + Some(format!( + "{} is declared as schema type '{}' but was assigned {}", + schema_path_label(&path), + schema_type_label(&expected), + schema_type_label(&actual) + )) + } else { + None + } + } ( TypeSchema::Callable { params: expected_params, @@ -1415,6 +1440,31 @@ fn find_declared_schema_mismatch_with_recursion( } None } + ( + TypeSchema::Result { + ok: expected_ok, + err: expected_err, + }, + TypeSchema::Result { + ok: actual_ok, + err: actual_err, + }, + ) => { + let expected_ok = context.substitute_schema_generics(expected_ok); + let actual_ok = context.substitute_schema_generics(actual_ok); + let expected_err = context.substitute_schema_generics(expected_err); + let actual_err = context.substitute_schema_generics(actual_err); + if expected_ok == actual_ok && expected_err == actual_err { + None + } else { + Some(format!( + "{} is declared as schema type '{}' but was assigned {}", + schema_path_label(&path), + schema_type_label(&expected), + schema_type_label(&actual) + )) + } + } _ => Some(format!( "{} is declared as schema type '{}' but was assigned {}", schema_path_label(&path), @@ -1880,6 +1930,15 @@ pub(super) fn expr_contains_param_add(expr: &Expr, param_slots: &[LocalSlot]) -> .any(|(_, arm_expr)| expr_contains_param_add(arm_expr, param_slots)) || expr_contains_param_add(default, param_slots) } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. }) => { + let mut found = false; + other.visit_result_children(|child| { + found |= expr_contains_param_add(child, param_slots) + }); + found + } Expr::Block { stmts, expr } => function_body_contains_param_add(param_slots, stmts, expr), } } @@ -1952,6 +2011,13 @@ pub(super) fn expr_uses_param(expr: &Expr, param_slots: &[LocalSlot]) -> bool { .any(|(_, arm_expr)| expr_uses_param(arm_expr, param_slots)) || expr_uses_param(default, param_slots) } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. }) => { + let mut found = false; + other.visit_result_children(|child| found |= expr_uses_param(child, param_slots)); + found + } Expr::Block { stmts, expr } => { stmts.iter().any(|stmt| stmt_uses_param(stmt, param_slots)) || expr_uses_param(expr, param_slots) @@ -2148,6 +2214,56 @@ pub(super) fn legalize_expr( BoundType::Unknown } } + Expr::ResultConstruct { payload, .. } => { + let _ = legalize_expr(payload, state, context, site, host_resolution); + BoundType::Map + } + Expr::ResultQuery { value, .. } => { + let _ = legalize_expr(value, state, context, site, host_resolution); + BoundType::Bool + } + Expr::ResultMatch { + value, + ok_binding, + ok_expr, + err_binding, + err_expr, + scrutinee_schema, + .. + } => { + let _ = legalize_expr(value, state, context, site, host_resolution); + let (ok_schema, err_schema) = match scrutinee_schema { + TypeSchema::Result { ok, err } => ((**ok).clone(), (**err).clone()), + _ => (TypeSchema::Unknown, TypeSchema::Unknown), + }; + let mut ok_state = state.clone(); + if let Some(slot) = *ok_binding { + ok_state.set_with_optional_schema_origin( + slot, + context.bound_type_for_schema(&ok_schema), + Some(ok_schema.clone()), + true, + matches!(ok_schema, TypeSchema::Optional(_)), + ); + } + let mut err_state = state.clone(); + if let Some(slot) = *err_binding { + err_state.set_with_optional_schema_origin( + slot, + context.bound_type_for_schema(&err_schema), + Some(err_schema.clone()), + true, + matches!(err_schema, TypeSchema::Optional(_)), + ); + } + let ok_ty = legalize_expr(ok_expr, &ok_state, context, site, host_resolution); + let err_ty = legalize_expr(err_expr, &err_state, context, site, host_resolution); + if ok_ty == err_ty { + ok_ty + } else { + BoundType::Unknown + } + } Expr::Block { stmts, expr } => { let mut nested = state.clone(); legalize_stmts( diff --git a/src/compiler/typing/validate.rs b/src/compiler/typing/validate.rs index 96c505c6..5ab0c3cb 100644 --- a/src/compiler/typing/validate.rs +++ b/src/compiler/typing/validate.rs @@ -453,6 +453,10 @@ fn validate_json_schema_with_seen( TypeSchema::Bytes => Err(format!( "{path} uses bytes, which json::encode does not support" )), + TypeSchema::Result { ok, err } => { + validate_json_schema_with_seen(ok, context, path, seen, reentries)?; + validate_json_schema_with_seen(err, context, path, seen, reentries) + } TypeSchema::Resource(key) => Err(format!( "{path} is resource '{key}', which json::encode does not support" )), @@ -751,6 +755,16 @@ pub(super) fn validate_expr( | Expr::ModuleCall(..) | Expr::LocalCall(..) | Expr::Closure(_) => { + if let Expr::LocalCall(slot, _, _, _) = expr { + ensure_expr_not_optional( + &Expr::Var(*slot), + state, + line_context, + source_name, + context, + "callable call", + )?; + } validate_expr_children( expr, state, @@ -1039,6 +1053,87 @@ pub(super) fn validate_expr( merge_bound_types(arm_type, default_ty) } } + Expr::ResultConstruct { payload, .. } => { + let _ = validate_expr( + payload, + state, + line_context, + source_name, + context, + strict_function_add_types, + )?; + BoundType::Map + } + Expr::ResultQuery { value, .. } => { + let _ = validate_expr( + value, + state, + line_context, + source_name, + context, + strict_function_add_types, + )?; + BoundType::Bool + } + Expr::ResultMatch { + value, + ok_binding, + ok_expr, + err_binding, + err_expr, + scrutinee_schema, + .. + } => { + let _ = validate_expr( + value, + state, + line_context, + source_name, + context, + strict_function_add_types, + )?; + let (ok_schema, err_schema) = match scrutinee_schema { + TypeSchema::Result { ok, err } => ((**ok).clone(), (**err).clone()), + _ => (TypeSchema::Unknown, TypeSchema::Unknown), + }; + let mut ok_state = state.clone(); + if let Some(slot) = ok_binding { + ok_state.set_with_optional_schema_origin( + *slot, + context.bound_type_for_schema(&ok_schema), + Some(ok_schema.clone()), + true, + matches!(ok_schema, TypeSchema::Optional(_)), + ); + } + let mut err_state = state.clone(); + if let Some(slot) = err_binding { + err_state.set_with_optional_schema_origin( + *slot, + context.bound_type_for_schema(&err_schema), + Some(err_schema.clone()), + true, + matches!(err_schema, TypeSchema::Optional(_)), + ); + } + let ok_ty = validate_expr( + ok_expr, + &ok_state, + line_context, + source_name, + context, + strict_function_add_types, + )?; + let err_ty = validate_expr( + err_expr, + &err_state, + line_context, + source_name, + context, + strict_function_add_types, + )?; + merge_bound_types(ok_ty, err_ty) + } Expr::Block { stmts, expr } => { let mut nested = state.clone(); validate_stmts( diff --git a/src/host_api.rs b/src/host_api.rs index 66e88311..45126648 100644 --- a/src/host_api.rs +++ b/src/host_api.rs @@ -935,6 +935,11 @@ pub enum HostTypeSchema { name: String, fields: Vec, }, + /// Guest `Result`. Distinct from [`Self::Named`] and [`Self::Map`]. + Result { + ok: Box, + err: Box, + }, } impl HostTypeSchema { @@ -1026,6 +1031,13 @@ impl Serialize for HostTypeSchema { state.serialize_field("fields", fields)?; state.end() } + Self::Result { ok, err } => { + let mut state = + serializer.serialize_struct_variant("HostTypeSchema", 14, "Result", 2)?; + state.serialize_field("ok", ok)?; + state.serialize_field("err", err)?; + state.end() + } } } } @@ -1046,6 +1058,7 @@ enum HostTypeSchemaVariant { Callable, Resource, Named, + Result, } struct HostTypeSchemaSeed<'a> { @@ -1078,7 +1091,7 @@ impl<'de> DeserializeSeed<'de> for HostTypeSchemaSeed<'_> { "HostTypeSchema", &[ "Unknown", "Null", "Int", "Float", "Number", "Bool", "String", "Bytes", "Array", - "Map", "Optional", "Callable", "Resource", "Named", + "Map", "Optional", "Callable", "Resource", "Named", "Result", ], HostTypeSchemaVisitor { budget: self.budget, @@ -1161,6 +1174,12 @@ impl<'de> Visitor<'de> for HostTypeSchemaVisitor<'_> { depth: self.depth, }) .map(|(name, fields)| HostTypeSchema::Named { name, fields }), + HostTypeSchemaVariant::Result => access + .newtype_variant_seed(ResultSchemaSeed { + budget: self.budget, + depth: self.depth, + }) + .map(|(ok, err)| HostTypeSchema::Result { ok, err }), } } } @@ -1191,6 +1210,91 @@ impl<'de> Deserialize<'de> for HostTypeSchema { } } +struct ResultSchemaSeed<'a> { + budget: &'a mut ComplexityBudget, + depth: usize, +} + +impl<'de> DeserializeSeed<'de> for ResultSchemaSeed<'_> { + type Value = (Box, Box); + + fn deserialize(self, deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let child_depth = next_schema_depth::(self.depth)?; + deserializer.deserialize_struct( + "HostTypeSchema::Result", + &["ok", "err"], + ResultSchemaVisitor { + budget: self.budget, + child_depth, + }, + ) + } +} + +struct ResultSchemaVisitor<'a> { + budget: &'a mut ComplexityBudget, + child_depth: usize, +} + +#[derive(Deserialize)] +#[serde(field_identifier, rename_all = "snake_case")] +enum ResultSchemaField { + Ok, + Err, +} + +impl<'de> Visitor<'de> for ResultSchemaVisitor<'_> { + type Value = (Box, Box); + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a Result schema object") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + bounded_map_size_hint(map.size_hint(), "result schema", 2)?; + let mut entries = 0; + let mut ok = None; + let mut err = None; + loop { + let Some(field) = map.next_key::()? else { + break; + }; + bounded_map_entry(&mut entries, "result schema", 2)?; + match field { + ResultSchemaField::Ok => { + if ok.is_some() { + return Err(de::Error::duplicate_field("ok")); + } + ok = Some(Box::new(map.next_value_seed(HostTypeSchemaSeed { + budget: self.budget, + depth: self.child_depth, + property: false, + })?)); + } + ResultSchemaField::Err => { + if err.is_some() { + return Err(de::Error::duplicate_field("err")); + } + err = Some(Box::new(map.next_value_seed(HostTypeSchemaSeed { + budget: self.budget, + depth: self.child_depth, + property: false, + })?)); + } + } + } + let ok = ok.ok_or_else(|| de::Error::missing_field("ok"))?; + let err = err.ok_or_else(|| de::Error::missing_field("err"))?; + Ok((ok, err)) + } +} + struct CallableSchemaSeed<'a> { budget: &'a mut ComplexityBudget, depth: usize, @@ -1602,6 +1706,7 @@ impl fmt::Display for HostTypeSchema { } Self::Resource(key) => write!(f, "resource<{key}>"), Self::Named { name, .. } => write!(f, "{name}"), + Self::Result { ok, err } => write!(f, "Result<{ok}, {err}>"), } } } @@ -2947,6 +3052,21 @@ where pending.push((&field.ty, child_depth)); } } + HostTypeSchema::Result { ok, err } => { + let child_depth = + depth + .checked_add(1) + .ok_or(HostSchemaValidationError::IntegerOverflow { + field: "schema depth", + })?; + pending.try_reserve(2).map_err(|_| { + HostSchemaValidationError::AllocationFailed { + field: "schema traversal", + } + })?; + pending.push((err, child_depth)); + pending.push((ok, child_depth)); + } HostTypeSchema::Unknown | HostTypeSchema::Null | HostTypeSchema::Int @@ -4136,6 +4256,16 @@ fn try_push_type( push_len_str(bytes, name)?; push_named_fields(bytes, fields)?; } + HostTypeSchema::Result { ok, err } => { + push_tag(bytes, b'R'); + pending.try_reserve(2).map_err(|_| { + HostSchemaValidationError::AllocationFailed { + field: "schema encoding", + } + })?; + pending.push(err); + pending.push(ok); + } } } Ok(()) @@ -4277,6 +4407,10 @@ fn validate_named_struct_refs( } validate_named_struct_refs(function, result, structs) } + HostTypeSchema::Result { ok, err } => { + validate_named_struct_refs(function, ok, structs)?; + validate_named_struct_refs(function, err, structs) + } _ => Ok(()), } } diff --git a/src/vm/aot/compile.rs b/src/vm/aot/compile.rs index 2c9d603f..739674e7 100644 --- a/src/vm/aot/compile.rs +++ b/src/vm/aot/compile.rs @@ -1191,6 +1191,14 @@ fn lower_aot_ssa_inst( value: values[input], }, )?, + AotSsaInstKind::TaggedToBool { input } => aot_lower_tagged_to_bool( + b, + ctx, + AotTaggedValueOp { + ip: inst.ip, + value: values[input], + }, + )?, AotSsaInstKind::TaggedNumberToFloat { input } => aot_lower_tagged_number_to_float( b, ctx, @@ -1910,6 +1918,51 @@ fn aot_load_checked_heap_ptr( Ok(b.block_params(cont)[0]) } +#[cfg(feature = "cranelift-jit")] +fn aot_lower_tagged_to_bool( + b: &mut FunctionBuilder, + ctx: AotLowerCtx<'_>, + operand: AotTaggedValueOp, +) -> Result { + let AotLowerCtx { + vm_ptr, + exit_block, + pointer_type, + layout, + offsets, + .. + } = ctx; + let AotTaggedValueOp { + ip, + value: tagged_value, + } = operand; + let ok = b.create_block(); + let fail = b.create_block(); + let cont = b.create_block(); + b.append_block_param(cont, types::I8); + + let tag = ssa_load_tag_i32(b, layout.value, tagged_value); + let is_bool = b + .ins() + .icmp_imm(IntCC::Equal, tag, i64::from(layout.value.bool_tag)); + b.ins().brif(is_bool, ok, &[], fail, &[]); + + b.switch_to_block(ok); + let value = b.ins().load( + types::I8, + MemFlags::new(), + tagged_value, + layout.value.bool_payload_offset, + ); + b.ins().jump(cont, &[BlockArg::Value(value)]); + + b.switch_to_block(fail); + aot_emit_error_status(b, vm_ptr, exit_block, pointer_type, offsets, ip)?; + + b.switch_to_block(cont); + Ok(b.block_params(cont)[0]) +} + #[cfg(feature = "cranelift-jit")] fn aot_lower_tagged_to_int( b: &mut FunctionBuilder, diff --git a/src/vm/aot/ssa.rs b/src/vm/aot/ssa.rs index 9209b8e3..17f04ac5 100644 --- a/src/vm/aot/ssa.rs +++ b/src/vm/aot/ssa.rs @@ -248,6 +248,9 @@ pub(crate) enum AotSsaInstKind { TaggedToInt { input: AotSsaValueId, }, + TaggedToBool { + input: AotSsaValueId, + }, TaggedNumberToFloat { input: AotSsaValueId, }, @@ -380,6 +383,7 @@ impl AotSsaInstKind { | Self::FloatCmpGt { lhs, rhs } => vec![*lhs, *rhs], Self::BoolNot { input } | Self::TaggedToInt { input } + | Self::TaggedToBool { input } | Self::TaggedNumberToFloat { input } | Self::IntToFloat { input } | Self::IntNeg { input } @@ -1533,10 +1537,22 @@ impl<'a> Builder<'a> { target_ip, fallthrough_ip, } => { - let condition = frame.pop(block.terminal_ip.unwrap_or(block.end_ip), "brfalse")?; + let ip = block.terminal_ip.unwrap_or(block.end_ip); + let mut condition = frame.pop(ip, "brfalse")?; + if condition.value.repr == AotSsaValueRepr::Tagged + && operand_types_at(self.program, ip).0 == ValueType::Bool + { + condition.value = emitter.emit( + ip, + AotSsaInstKind::TaggedToBool { + input: condition.value.id, + }, + AotSsaValueRepr::Bool, + ); + } if condition.value.repr != AotSsaValueRepr::Bool { return Err(AotSsaBuildError::NonBoolBranchCondition { - ip: block.terminal_ip.unwrap_or(block.end_ip), + ip, repr: condition.value.repr, }); } diff --git a/src/vm/host.rs b/src/vm/host.rs index 984ed576..64686ad2 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -395,6 +395,10 @@ fn host_schema_contains_resource(schema: &HostTypeSchema) -> Result { + pending.push((err, depth + 1)); + pending.push((ok, depth + 1)); + } HostTypeSchema::Named { fields, .. } => { for field in fields { pending.push((&field.ty, depth + 1)); @@ -2421,6 +2425,21 @@ fn validate_host_value( } Ok(()) } + HostTypeSchema::Result { ok, err } => { + let (is_ok, payload) = super::split_result_carrier(value)?; + let active = if is_ok { ok.as_ref() } else { err.as_ref() }; + match payload { + Some(payload) => validate_host_value(payload, active, program, resources), + None if matches!( + active, + HostTypeSchema::Null | HostTypeSchema::Optional(_) | HostTypeSchema::Unknown + ) => + { + validate_host_value(&Value::Null, active, program, resources) + } + None => Err(VmError::TypeMismatch("result")), + } + } } } diff --git a/src/vm/jit/recorder.rs b/src/vm/jit/recorder.rs index d0e07157..aa4ea53d 100644 --- a/src/vm/jit/recorder.rs +++ b/src/vm/jit/recorder.rs @@ -464,9 +464,10 @@ fn inline_schema_guard_type(schema: &TypeSchema) -> Option> { TypeSchema::Bool => Some(Some(ValueType::Bool)), TypeSchema::String => Some(Some(ValueType::String)), TypeSchema::Bytes => Some(Some(ValueType::Bytes)), - TypeSchema::Named(_, _) | TypeSchema::Map(_) | TypeSchema::Object(_) => { - Some(Some(ValueType::Map)) - } + TypeSchema::Named(_, _) + | TypeSchema::Map(_) + | TypeSchema::Object(_) + | TypeSchema::Result { .. } => Some(Some(ValueType::Map)), TypeSchema::Array(_) | TypeSchema::ArrayTuple(_) | TypeSchema::ArrayTupleRest { .. } => { Some(Some(ValueType::Array)) } diff --git a/src/vm/mod.rs b/src/vm/mod.rs index 15d06a4e..e143aead 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -600,10 +600,44 @@ fn substitute_named_struct_schema( .collect(), result: Box::new(substitute_named_struct_schema(result, bindings)), }, + TypeSchema::Result { ok, err } => TypeSchema::Result { + ok: Box::new(substitute_named_struct_schema(ok, bindings)), + err: Box::new(substitute_named_struct_schema(err, bindings)), + }, _ => schema.clone(), } } +pub(crate) fn split_result_carrier(value: &Value) -> VmResult<(bool, Option<&Value>)> { + let Value::Map(entries) = value else { + return Err(VmError::TypeMismatch("result")); + }; + if entries.len() > 2 { + return Err(VmError::TypeMismatch("result")); + } + for (key, _) in entries.iter() { + let Value::String(name) = key else { + return Err(VmError::TypeMismatch("result")); + }; + if name.as_str() != "tag" && name.as_str() != "payload" { + return Err(VmError::TypeMismatch("result")); + } + } + let Some(Value::Bool(ok)) = entries.get(&Value::string("tag")) else { + return Err(VmError::TypeMismatch("result")); + }; + Ok((*ok, entries.get(&Value::string("payload")))) +} + +fn result_schema_accepts_missing_payload(schema: &crate::compiler::TypeSchema) -> bool { + matches!( + schema, + crate::compiler::TypeSchema::Null + | crate::compiler::TypeSchema::Optional(_) + | crate::compiler::TypeSchema::Unknown + ) +} + fn validate_value_against_type_schema( value: &Value, schema: &crate::compiler::TypeSchema, @@ -888,6 +922,33 @@ fn validate_value_against_type_schema_walk( .validate_resource_type_key(handle, key) .map_err(|error| VmError::HostError(error.to_string())) } + TypeSchema::Result { ok, err } => { + let (is_ok, payload) = split_result_carrier(value)?; + let active = if is_ok { ok.as_ref() } else { err.as_ref() }; + match payload { + Some(payload) => validate_value_against_type_schema_walk( + payload, + active, + resources, + validate_scalars, + named_struct_schemas, + depth, + nodes, + ), + None if result_schema_accepts_missing_payload(active) => { + validate_value_against_type_schema_walk( + &Value::Null, + active, + resources, + validate_scalars, + named_struct_schemas, + depth, + nodes, + ) + } + None => Err(VmError::TypeMismatch("result")), + } + } } } @@ -950,6 +1011,16 @@ fn schema_contains_resource_walk( match schema { TypeSchema::Resource(_) => true, + TypeSchema::Result { ok, err } => { + schema_contains_resource_walk(ok, named_struct_schemas, depth + 1, active, nodes) + || schema_contains_resource_walk( + err, + named_struct_schemas, + depth + 1, + active, + nodes, + ) + } TypeSchema::Optional(inner) | TypeSchema::Array(inner) | TypeSchema::Map(inner) => { schema_contains_resource_walk(inner, named_struct_schemas, depth + 1, active, nodes) } @@ -1080,6 +1151,11 @@ fn hash_type_schema(schema: &crate::compiler::TypeSchema, state: &mut impl Hashe } hash_type_schema(result, state); } + TypeSchema::Result { ok, err } => { + 18u8.hash(state); + hash_type_schema(ok, state); + hash_type_schema(err, state); + } TypeSchema::Resource(key) => { 17u8.hash(state); key.hash(state); diff --git a/src/vmbc.rs b/src/vmbc.rs index e58c5172..384761ad 100644 --- a/src/vmbc.rs +++ b/src/vmbc.rs @@ -18,8 +18,6 @@ use crate::host_api::{ use crate::vm::{HostImport, OpCode, Program, Value}; const MAGIC: [u8; 4] = *b"VMBC"; -const VERSION_V11: u16 = 11; -const VERSION_V12: u16 = 12; const VERSION_V13: u16 = 13; const FLAGS: u16 = 0; const MAX_WIRE_PAYLOAD_BYTES: usize = 64 * 1024 * 1024; @@ -360,11 +358,9 @@ pub fn decode_program(bytes: &[u8]) -> Result { } let version = cursor.read_u16()?; - let has_host_import_schemas = match version { - VERSION_V11 => false, - VERSION_V12 | VERSION_V13 => true, - _ => return Err(WireError::UnsupportedVersion(version)), - }; + if version != VERSION_V13 { + return Err(WireError::UnsupportedVersion(version)); + } let flags = cursor.read_u16()?; if flags != FLAGS { @@ -382,12 +378,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { let mut code = Vec::new(); reserve_vec(&mut code, "code", code_bytes.len())?; code.extend_from_slice(code_bytes); - if version == VERSION_V11 && code.contains(&(OpCode::CallScript as u8)) { - // CallScript was added in V12. Keep the legacy version branch based - // on the version discriminant, independent of the import count. - return Err(WireError::UnsupportedVersion(VERSION_V11)); - } - let import_count = cursor.read_count("imports", if has_host_import_schemas { 7 } else { 6 })?; + let import_count = cursor.read_count("imports", 7)?; let mut imports = Vec::new(); reserve_vec(&mut imports, "imports", import_count)?; let mut host_import_schemas = Vec::new(); @@ -396,29 +387,21 @@ pub fn decode_program(bytes: &[u8]) -> Result { // the shared host-schema budget as each element is decoded. for _ in 0..import_count { let import = HostImport { - name: if has_host_import_schemas { - cursor.read_bounded_string("host import name", MAX_HOST_FUNCTION_NAME_LEN)? - } else { - cursor.read_string()? - }, + name: cursor.read_bounded_string("host import name", MAX_HOST_FUNCTION_NAME_LEN)?, arity: cursor.read_u8()?, return_type: read_value_type(cursor.read_u8()?)?, }; - if has_host_import_schemas { - let schema = read_optional_host_import_schema(&mut cursor)?; - if let Some(schema) = schema.as_ref() - && (schema.name != import.name || schema.arity() != import.arity as usize) - { - return Err(WireError::HostSchemaImportMismatch); - } - host_import_schemas.push(schema); + let schema = read_optional_host_import_schema(&mut cursor)?; + if let Some(schema) = schema.as_ref() + && (schema.name != import.name || schema.arity() != import.arity as usize) + { + return Err(WireError::HostSchemaImportMismatch); } + host_import_schemas.push(schema); imports.push(import); } - if has_host_import_schemas { - crate::host_api::validate_optional_host_import_schemas(&host_import_schemas) - .map_err(|error| WireError::InvalidHostSchemaComplexity(error.to_string()))?; - } + crate::host_api::validate_optional_host_import_schemas(&host_import_schemas) + .map_err(|error| WireError::InvalidHostSchemaComplexity(error.to_string()))?; let type_map = read_type_map(&mut cursor)?; let debug = read_debug_info(&mut cursor)?; let ( @@ -428,11 +411,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { root_callable_bindings, exported_callables, ) = read_callable_metadata(&mut cursor)?; - let named_struct_decls = if version >= VERSION_V13 { - read_named_struct_decls(&mut cursor)? - } else { - HashMap::new() - }; + let named_struct_decls = read_named_struct_decls(&mut cursor)?; if !cursor.is_eof() { return Err(WireError::TrailingBytes); @@ -1692,6 +1671,11 @@ fn write_host_type_schema( write_host_type_schema(&field.ty, out, next_host_schema_depth(depth)?)?; } } + HostTypeSchema::Result { ok, err } => { + out.push(14); + write_host_type_schema(ok, out, next_host_schema_depth(depth)?)?; + write_host_type_schema(err, out, next_host_schema_depth(depth)?)?; + } } Ok(()) } @@ -1775,6 +1759,16 @@ fn read_host_type_schema( } Ok(HostTypeSchema::Named { name, fields }) } + 14 => Ok(HostTypeSchema::Result { + ok: Box::new(read_host_type_schema( + cursor, + next_host_schema_depth(depth)?, + )?), + err: Box::new(read_host_type_schema( + cursor, + next_host_schema_depth(depth)?, + )?), + }), other => Err(WireError::InvalidHostSchemaTag(other)), } } @@ -1857,6 +1851,11 @@ fn write_schema(schema: &TypeSchema, out: &mut Vec) -> Result<(), WireError> out.push(17); write_string("schema resource key", key.as_str(), out)?; } + TypeSchema::Result { ok, err } => { + out.push(18); + write_schema(ok, out)?; + write_schema(err, out)?; + } } Ok(()) } @@ -1947,6 +1946,10 @@ fn read_schema(cursor: &mut Cursor<'_>, depth: usize) -> Result Ok(TypeSchema::Result { + ok: Box::new(read_schema(cursor, nested_depth)?), + err: Box::new(read_schema(cursor, nested_depth)?), + }), other => Err(WireError::InvalidValueType(other)), } } diff --git a/tests/compiler/frontend_plugin_tests.rs b/tests/compiler/frontend_plugin_tests.rs index 57ba3499..d01e6a98 100644 --- a/tests/compiler/frontend_plugin_tests.rs +++ b/tests/compiler/frontend_plugin_tests.rs @@ -314,3 +314,624 @@ fn descriptor_typing_policy_rejects_unknown_schema_only_for_strict_plugins() { assert_eq!(vm.run().unwrap(), VmStatus::Halted); assert_eq!(vm.stack(), &[Value::Int(44)]); } + +struct ResultIrPlugin; + +static RESULT_IR: ResultIrPlugin = ResultIrPlugin; + +fn result_schema() -> vm::compiler::TypeSchema { + vm::compiler::TypeSchema::Result { + ok: Box::new(vm::compiler::TypeSchema::Int), + err: Box::new(vm::compiler::TypeSchema::Int), + } +} + +fn result_ir() -> FrontendIr { + let span = vm::Span::new(0, 0, 1); + FrontendIr { + stmts: vec![ + Stmt::Let { + index: 0, + declared_schema: Some(result_schema()), + expr: Expr::ResultConstruct { + ok: true, + payload: Box::new(Expr::Int(7)), + result_schema: result_schema(), + span, + semantic_id: None, + }, + line: 1, + }, + Stmt::Expr { + expr: Expr::ResultMatch { + value: Box::new(Expr::MoveVar(0)), + value_slot: 1, + ok_binding: Some(2), + ok_expr: Box::new(Expr::Var(2)), + err_binding: Some(3), + err_expr: Box::new(Expr::Int(-1)), + scrutinee_schema: result_schema(), + span, + semantic_id: None, + }, + line: 2, + }, + ], + locals: 4, + local_bindings: Vec::new(), + struct_schemas: Default::default(), + unknown_type_spans: Vec::new(), + functions: Vec::new(), + function_impls: Default::default(), + stmt_sources: Vec::new(), + function_sources: Default::default(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), + host_api_metadata: None, + semantic_index: None, + parsed_semantic_index: None, + catalog_visibility: None, + lexer_tokens: Vec::new(), + } +} + +impl SourcePlugin for ResultIrPlugin { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("result-ir"), + extensions: &["result-ir"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + _source: &str, + _context: &SourceFrontendContext<'_>, + ) -> Result { + Ok(result_ir()) + } + + fn parse_module_imports( + &self, + _source: &str, + _path: &Path, + _context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + Ok(Vec::new()) + } +} + +#[test] +fn synthetic_frontend_ir_result_match_returns_ok_payload() { + let path = plugin_source_path("result-ir"); + let compiled = compile_source_file_with_options( + &path, + CompileSourceFileOptions::new().with_source_plugin(&RESULT_IR), + ) + .expect("synthetic Result IR should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!( + vm.run().expect("synthetic Result IR should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(7)]); +} + +struct ContextualResultIrPlugin; + +static CONTEXTUAL_RESULT_IR: ContextualResultIrPlugin = ContextualResultIrPlugin; + +fn contextual_result_ir() -> FrontendIr { + let span = vm::Span::new(0, 0, 1); + let result = vm::TypeSchema::Result { + ok: Box::new(vm::TypeSchema::Int), + err: Box::new(vm::TypeSchema::String), + }; + FrontendIr { + stmts: vec![ + Stmt::ClosureLet { + line: 1, + expected_schema: Some(vm::TypeSchema::Callable { + params: Vec::new(), + result: Box::new(result.clone()), + }), + closure: vm::ClosureExpr { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body: Box::new(Expr::ResultConstruct { + ok: false, + payload: Box::new(Expr::String("e".to_string())), + result_schema: vm::TypeSchema::Unknown, + span, + semantic_id: None, + }), + }, + }, + Stmt::Let { + index: 0, + declared_schema: Some(vm::TypeSchema::Callable { + params: Vec::new(), + result: Box::new(result.clone()), + }), + expr: Expr::Closure(vm::ClosureExpr { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body: Box::new(Expr::ResultConstruct { + ok: true, + payload: Box::new(Expr::Int(1)), + result_schema: vm::TypeSchema::Unknown, + span, + semantic_id: None, + }), + }), + line: 1, + }, + Stmt::Let { + index: 4, + declared_schema: Some(vm::TypeSchema::Callable { + params: Vec::new(), + result: Box::new(result.clone()), + }), + expr: Expr::Closure(vm::ClosureExpr { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body: Box::new(Expr::ResultConstruct { + ok: false, + payload: Box::new(Expr::String("e".to_string())), + result_schema: vm::TypeSchema::Unknown, + span, + semantic_id: None, + }), + }), + line: 1, + }, + Stmt::Expr { + expr: Expr::ResultMatch { + value: Box::new(Expr::LocalCall(0, Vec::new(), Vec::new(), None)), + value_slot: 1, + ok_binding: Some(2), + ok_expr: Box::new(Expr::Var(2)), + err_binding: Some(3), + err_expr: Box::new(Expr::Int(-1)), + scrutinee_schema: result.clone(), + span, + semantic_id: None, + }, + line: 2, + }, + Stmt::Expr { + expr: Expr::ResultMatch { + value: Box::new(Expr::LocalCall(4, Vec::new(), Vec::new(), None)), + value_slot: 5, + ok_binding: Some(6), + ok_expr: Box::new(Expr::Var(6)), + err_binding: Some(7), + err_expr: Box::new(Expr::Int(-1)), + scrutinee_schema: result, + span, + semantic_id: None, + }, + line: 3, + }, + ], + locals: 8, + local_bindings: Vec::new(), + struct_schemas: Default::default(), + unknown_type_spans: Vec::new(), + functions: Vec::new(), + function_impls: Default::default(), + stmt_sources: Vec::new(), + function_sources: Default::default(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), + host_api_metadata: None, + semantic_index: None, + parsed_semantic_index: None, + catalog_visibility: None, + lexer_tokens: Vec::new(), + } +} + +impl SourcePlugin for ContextualResultIrPlugin { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("contextual-result-ir"), + extensions: &["contextual-result-ir"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + _source: &str, + _context: &SourceFrontendContext<'_>, + ) -> Result { + Ok(contextual_result_ir()) + } + + fn parse_module_imports( + &self, + _source: &str, + _path: &Path, + _context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + Ok(Vec::new()) + } +} + +#[test] +fn synthetic_frontend_ir_closure_result_uses_expected_callable_schema() { + let path = plugin_source_path("contextual-result-ir"); + let compiled = compile_source_file_with_options( + &path, + CompileSourceFileOptions::new().with_source_plugin(&CONTEXTUAL_RESULT_IR), + ) + .expect("typed closure Result IR should compile from expected schema"); + let mut vm = Vm::new(compiled.program); + assert_eq!( + vm.run().expect("typed closure Result IR should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(1), Value::Int(-1)]); +} + +struct CapturedResultIrPlugin; +static CAPTURED_RESULT_IR: CapturedResultIrPlugin = CapturedResultIrPlugin; + +fn captured_result_ir() -> FrontendIr { + let span = vm::Span::new(0, 0, 1); + let result = vm::TypeSchema::Result { + ok: Box::new(vm::TypeSchema::Int), + err: Box::new(vm::TypeSchema::String), + }; + let callable = vm::TypeSchema::Callable { + params: Vec::new(), + result: Box::new(result.clone()), + }; + let source_slot = 0; + let captured_slot = 1; + let closure = || vm::ClosureExpr { + param_slots: Vec::new(), + capture_copies: vec![(source_slot, captured_slot)], + body: Box::new(Expr::ResultConstruct { + ok: true, + payload: Box::new(Expr::Var(captured_slot)), + result_schema: vm::TypeSchema::Unknown, + span, + semantic_id: None, + }), + }; + FrontendIr { + stmts: vec![ + Stmt::Let { + index: source_slot, + declared_schema: Some(vm::TypeSchema::Int), + expr: Expr::Int(7), + line: 1, + }, + Stmt::ClosureLet { + line: 1, + expected_schema: Some(callable.clone()), + closure: closure(), + }, + Stmt::Let { + index: 2, + declared_schema: Some(callable), + expr: Expr::Closure(closure()), + line: 2, + }, + Stmt::Expr { + expr: Expr::ResultMatch { + value: Box::new(Expr::LocalCall(2, Vec::new(), Vec::new(), None)), + value_slot: 3, + ok_binding: Some(4), + ok_expr: Box::new(Expr::Var(4)), + err_binding: Some(5), + err_expr: Box::new(Expr::Int(-1)), + scrutinee_schema: result, + span, + semantic_id: None, + }, + line: 3, + }, + ], + locals: 6, + local_bindings: Vec::new(), + struct_schemas: Default::default(), + unknown_type_spans: Vec::new(), + functions: Vec::new(), + function_impls: Default::default(), + stmt_sources: Vec::new(), + function_sources: Default::default(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), + host_api_metadata: None, + semantic_index: None, + parsed_semantic_index: None, + catalog_visibility: None, + lexer_tokens: Vec::new(), + } +} + +impl SourcePlugin for CapturedResultIrPlugin { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("captured-result-ir"), + extensions: &["captured-result-ir"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + _source: &str, + _context: &SourceFrontendContext<'_>, + ) -> Result { + Ok(captured_result_ir()) + } + + fn parse_module_imports( + &self, + _source: &str, + _path: &Path, + _context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + Ok(Vec::new()) + } +} + +#[test] +fn contextual_captured_result_ir_executes_distinct_capture_slots() { + let ir = captured_result_ir(); + let copies = match &ir.stmts[2] { + Stmt::Let { + expr: Expr::Closure(closure), + .. + } => closure.capture_copies.clone(), + other => panic!("executable closure missing: {other:?}"), + }; + assert_eq!(copies, vec![(0, 1)]); + assert_ne!(copies[0].0, copies[0].1); + let path = plugin_source_path("captured-result-ir"); + let compiled = compile_source_file_with_options( + &path, + CompileSourceFileOptions::new().with_source_plugin(&CAPTURED_RESULT_IR), + ) + .expect("captured Result IR should compile"); + let mut vm = Vm::new(compiled.program); + assert_eq!( + vm.run().expect("captured Result IR should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(7)]); +} + +struct GuestBoxClosurePlugin; +static GUEST_BOX_CLOSURE: GuestBoxClosurePlugin = GuestBoxClosurePlugin; + +fn builtin_call(builtin: vm::BuiltinFunction, args: Vec) -> Expr { + Expr::Call(builtin.call_index(), Vec::new(), args, None, None) +} + +fn unknown_ok(payload: Expr) -> Expr { + Expr::ResultConstruct { + ok: true, + payload: Box::new(payload), + result_schema: vm::TypeSchema::Unknown, + span: vm::Span::new(0, 0, 1), + semantic_id: None, + } +} + +fn guest_box_body(field: &str, payload: Expr) -> Expr { + unknown_ok(builtin_call( + vm::BuiltinFunction::Set, + vec![ + builtin_call(vm::BuiltinFunction::MapNew, Vec::new()), + Expr::String(field.to_string()), + unknown_ok(payload), + ], + )) +} + +fn guest_box_ir(field: &str, payload: Expr) -> FrontendIr { + let span = vm::Span::new(0, 0, 1); + let inner = vm::TypeSchema::Result { + ok: Box::new(vm::TypeSchema::Int), + err: Box::new(vm::TypeSchema::String), + }; + let named = vm::TypeSchema::Named("Box".to_string(), Vec::new()); + let outer = vm::TypeSchema::Result { + ok: Box::new(named.clone()), + err: Box::new(vm::TypeSchema::String), + }; + let callable = vm::TypeSchema::Callable { + params: Vec::new(), + result: Box::new(outer.clone()), + }; + let body = guest_box_body(field, payload); + FrontendIr { + stmts: vec![ + Stmt::ClosureLet { + line: 1, + expected_schema: Some(callable.clone()), + closure: vm::ClosureExpr { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body: Box::new(body.clone()), + }, + }, + Stmt::Let { + index: 0, + declared_schema: Some(callable), + expr: Expr::Closure(vm::ClosureExpr { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body: Box::new(body), + }), + line: 2, + }, + Stmt::Expr { + expr: Expr::ResultMatch { + value: Box::new(Expr::LocalCall(0, Vec::new(), Vec::new(), None)), + value_slot: 1, + ok_binding: Some(2), + ok_expr: Box::new(Expr::ResultMatch { + value: Box::new(builtin_call( + vm::BuiltinFunction::Get, + vec![Expr::Var(2), Expr::String("value".to_string())], + )), + value_slot: 3, + ok_binding: Some(4), + ok_expr: Box::new(Expr::Var(4)), + err_binding: Some(5), + err_expr: Box::new(Expr::Int(-1)), + scrutinee_schema: inner, + span, + semantic_id: None, + }), + err_binding: Some(6), + err_expr: Box::new(Expr::Int(-2)), + scrutinee_schema: outer, + span, + semantic_id: None, + }, + line: 3, + }, + ], + locals: 7, + local_bindings: Vec::new(), + struct_schemas: std::iter::once(( + "Box".to_string(), + vm::compiler::ir::StructDecl { + name: "Box".to_string(), + type_params: Vec::new(), + body_schema: vm::TypeSchema::Object( + std::iter::once(("value".to_string(), { + vm::TypeSchema::Result { + ok: Box::new(vm::TypeSchema::Int), + err: Box::new(vm::TypeSchema::String), + } + })) + .collect(), + ), + origin: vm::compiler::StructDeclOrigin::Guest, + }, + )) + .collect(), + unknown_type_spans: Vec::new(), + functions: Vec::new(), + function_impls: Default::default(), + stmt_sources: Vec::new(), + function_sources: Default::default(), + use_declarations: Vec::new(), + implicit_extern_names: Vec::new(), + host_api_metadata: None, + semantic_index: None, + parsed_semantic_index: None, + catalog_visibility: None, + lexer_tokens: Vec::new(), + } +} + +impl SourcePlugin for GuestBoxClosurePlugin { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("guest-box-closure"), + extensions: &["guest-box"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + source: &str, + _context: &SourceFrontendContext<'_>, + ) -> Result { + if source.contains("wrong-field") { + return Ok(guest_box_ir("other", Expr::Int(1))); + } + if source.contains("wrong-payload") { + return Ok(guest_box_ir("value", Expr::String("no".to_string()))); + } + Ok(guest_box_ir("value", Expr::Int(1))) + } + + fn parse_module_imports( + &self, + _source: &str, + _path: &Path, + _context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + Ok(Vec::new()) + } +} + +fn guest_box_source(marker: &str) -> PluginSourceFixture { + let path = plugin_source_path("guest-box"); + std::fs::write(&path.path, format!("{marker}\n")).expect("guest box source"); + path +} + +#[test] +fn closure_let_guest_named_expected_schema_survives_canonical_remap() { + let path = guest_box_source("ok"); + let compiled = compile_source_file_with_options( + &path, + CompileSourceFileOptions::new().with_source_plugin(&GUEST_BOX_CLOSURE), + ) + .expect("guest Box ClosureLet should compile after canonical rename"); + let decls = compiled.program.named_struct_decls(); + let (name, decl) = decls + .iter() + .find(|(name, _)| name.starts_with("__guest_type_")) + .expect("canonical guest declaration"); + assert_eq!(decl.name, name.as_str()); + assert_ne!(*name, "Box"); + let vm::TypeSchema::Object(fields) = &decl.body_schema else { + panic!("guest body should stay an object: {:?}", decl.body_schema); + }; + match fields.get("value") { + Some(vm::TypeSchema::Result { ok, err }) => { + assert!(matches!(ok.as_ref(), vm::TypeSchema::Int)); + assert!(matches!(err.as_ref(), vm::TypeSchema::String)); + } + other => panic!("nested field schema was {other:?}"), + } + let mut vm = Vm::new(compiled.program); + assert_eq!( + vm.run().expect("guest Box closure should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack(), &[Value::Int(1)]); + + for (marker, label) in [("wrong-field", "field"), ("wrong-payload", "payload")] { + let path = guest_box_source(marker); + let error = match compile_source_file_with_options( + &path, + CompileSourceFileOptions::new().with_source_plugin(&GUEST_BOX_CLOSURE), + ) { + Err(error) => error, + Ok(_) => panic!("{label} must reject"), + }; + let text = error.to_string(); + let rejected = text.contains("schema") + || text.contains("field") + || text.contains("string") + || text.contains("assigned") + || text.contains("annotation") + || text.contains("inactive"); + assert!(rejected, "{label} diagnostic was {text}"); + if label == "payload" { + assert!( + text.contains("schema") || text.contains("string") || text.contains("assigned"), + "payload diagnostic was {text}" + ); + } + } +} diff --git a/tests/pr24_resource_tests.rs b/tests/pr24_resource_tests.rs index 65d4c980..058a0c0b 100644 --- a/tests/pr24_resource_tests.rs +++ b/tests/pr24_resource_tests.rs @@ -459,7 +459,9 @@ fn compiled_expression_block_detaches_taken_resource_once() { #[test] fn closure_capture_executes_take_owned_resource_once() { - let _guard = CLOSURE_TEST_LOCK.lock().expect("closure test lock"); + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); CLOSURE_CLOSES.store(0, Ordering::SeqCst); let options = CompileSourceFileOptions::new().with_host_api_catalog(closure_catalog()); let source = r#" @@ -488,7 +490,9 @@ fn closure_capture_executes_take_owned_resource_once() { #[test] fn named_function_capture_executes_take_owned_resource_once() { - let _guard = CLOSURE_TEST_LOCK.lock().expect("closure test lock"); + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); CLOSURE_CLOSES.store(0, Ordering::SeqCst); let options = CompileSourceFileOptions::new().with_host_api_catalog(closure_catalog()); let source = r#" @@ -603,3 +607,399 @@ fn expression_block_rejects_later_resource_use() { "unexpected compiler error: {rendered}" ); } + +fn result_resource_catalog() -> Arc { + let mut builder = HostApiBuilder::new(); + let resource_key = key("pr24.closure"); + builder.resource(ResourceTypeSchema::new( + resource_key.clone(), + "closure resource", + )); + let result_ok = HostTypeSchema::Result { + ok: Box::new(HostTypeSchema::Resource(resource_key.clone())), + err: Box::new(HostTypeSchema::Int), + }; + let result_err = HostTypeSchema::Result { + ok: Box::new(HostTypeSchema::Int), + err: Box::new(HostTypeSchema::Resource(resource_key.clone())), + }; + builder.function(HostFunctionSchema::with_return( + "test::make_ok_result", + Vec::new(), + result_ok.clone(), + )); + builder.function(HostFunctionSchema::with_return( + "test::make_err_result", + Vec::new(), + result_err, + )); + builder.function(HostFunctionSchema::with_return( + "test::make_extra_result", + Vec::new(), + result_ok.clone(), + )); + builder.function(HostFunctionSchema::with_return( + "test::make_wrong_key_result", + Vec::new(), + result_ok.clone(), + )); + builder.function(HostFunctionSchema::with_return( + "test::make_wrong_gen_result", + Vec::new(), + result_ok.clone(), + )); + builder.function(HostFunctionSchema::with_return( + "test::make_wrong_arena_result", + Vec::new(), + result_ok.clone(), + )); + builder.function(HostFunctionSchema::with_return( + "test::make_bad_tag_result", + Vec::new(), + result_ok.clone(), + )); + builder.function(HostFunctionSchema::with_return( + "test::make_bad_payload_result", + Vec::new(), + result_ok, + )); + builder.function(HostFunctionSchema::with_return( + "test::close_closure", + vec![HostParamSchema::with_passing( + "resource", + HostTypeSchema::Resource(resource_key), + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Null, + )); + Arc::new( + builder + .build() + .expect("result resource catalog should build"), + ) +} + +fn result_map(ok: bool, payload: Value, extra: bool) -> Value { + let mut entries = vec![ + (Value::string("tag"), Value::Bool(ok)), + (Value::string("payload"), payload), + ]; + if extra { + entries.push((Value::string("extra"), Value::Int(1))); + } + Value::map(entries) +} + +struct MakeOkResult; +impl HostFunction for MakeOkResult { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> VmResult { + let token = vm + .host_context() + .push_resource(ClosureResource) + .map_err(|error| VmError::HostError(error.to_string()))?; + Ok(CallOutcome::Return(CallReturn::one(result_map( + true, + Value::Int(token.handle().raw() as i64), + false, + )))) + } +} + +struct MakeErrResult; +impl HostFunction for MakeErrResult { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> VmResult { + let token = vm + .host_context() + .push_resource(ClosureResource) + .map_err(|error| VmError::HostError(error.to_string()))?; + Ok(CallOutcome::Return(CallReturn::one(result_map( + false, + Value::Int(token.handle().raw() as i64), + false, + )))) + } +} + +struct MakeExtraResult; +impl HostFunction for MakeExtraResult { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> VmResult { + let token = vm + .host_context() + .push_resource(ClosureResource) + .map_err(|error| VmError::HostError(error.to_string()))?; + Ok(CallOutcome::Return(CallReturn::one(result_map( + true, + Value::Int(token.handle().raw() as i64), + true, + )))) + } +} + +struct MakeWrongKeyResult; +impl HostFunction for MakeWrongKeyResult { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> VmResult { + let token = vm + .host_context() + .push_resource(BlockResource) + .map_err(|error| VmError::HostError(error.to_string()))?; + Ok(CallOutcome::Return(CallReturn::one(result_map( + true, + Value::Int(token.handle().raw() as i64), + false, + )))) + } +} + +struct MakeWrongArenaResult; +impl HostFunction for MakeWrongArenaResult { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> VmResult { + let token = vm + .host_context() + .push_resource(ClosureResource) + .map_err(|error| VmError::HostError(error.to_string()))?; + // Arena identity occupies bits 43..62 of the public handle encoding. + let wrong_arena = token.handle().raw() ^ (1 << 43); + Ok(CallOutcome::Return(CallReturn::one(result_map( + true, + Value::Int(wrong_arena as i64), + false, + )))) + } +} + +struct MakeWrongGenResult; +impl HostFunction for MakeWrongGenResult { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> VmResult { + let token = vm + .host_context() + .push_resource(ClosureResource) + .map_err(|error| VmError::HostError(error.to_string()))?; + Ok(CallOutcome::Return(CallReturn::one(result_map( + true, + Value::Int(token.handle().raw().wrapping_add(1) as i64), + false, + )))) + } +} + +struct MakeBadTagResult; +impl HostFunction for MakeBadTagResult { + fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> VmResult { + let token = vm + .host_context() + .push_resource(ClosureResource) + .map_err(|error| VmError::HostError(error.to_string()))?; + Ok(CallOutcome::Return(CallReturn::one(Value::map(vec![ + (Value::string("tag"), Value::string("ok")), + ( + Value::string("payload"), + Value::Int(token.handle().raw() as i64), + ), + ])))) + } +} + +struct MakeBadPayloadResult; +impl HostFunction for MakeBadPayloadResult { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(CallReturn::one(result_map( + true, + Value::string("not-a-resource"), + false, + )))) + } +} + +fn bind_result_hosts(vm: &mut Vm) { + vm.bind_function("test::make_ok_result", Box::new(MakeOkResult)); + vm.bind_function("test::make_err_result", Box::new(MakeErrResult)); + vm.bind_function("test::make_extra_result", Box::new(MakeExtraResult)); + vm.bind_function("test::make_wrong_key_result", Box::new(MakeWrongKeyResult)); + vm.bind_function("test::make_wrong_gen_result", Box::new(MakeWrongGenResult)); + vm.bind_function( + "test::make_wrong_arena_result", + Box::new(MakeWrongArenaResult), + ); + vm.bind_function("test::make_bad_tag_result", Box::new(MakeBadTagResult)); + vm.bind_function( + "test::make_bad_payload_result", + Box::new(MakeBadPayloadResult), + ); + vm.bind_function("test::close_closure", Box::new(CloseClosureResource)); +} + +#[test] +fn result_match_transfers_resource_after_nonconsuming_predicate() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + CLOSURE_CLOSES.store(0, Ordering::SeqCst); + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let source = r#" + fn close_one(handle: resource) -> int { + test::close_closure(handle); + 1 + } + let value = test::make_ok_result(); + let flag = value.is_ok(); + let observed = match value { + Result::Ok(handle) => close_one(handle), + Result::Err(e) => 0, + }; + let result = if flag => { observed } else => { 0 }; + result; + "#; + let compiled = + compile_source_with_flavor_and_options(source, vm::SourceFlavor::RustScript, options) + .expect("predicate must not consume the Result resource"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + bind_result_hosts(&mut vm); + assert_eq!( + vm.run().expect("Result resource match should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack().last().cloned(), Some(Value::Int(1))); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); +} + +#[test] +fn result_match_rejects_resource_reuse_and_keeps_err_branch_owned() { + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let reuse = r#" + fn close_twice(handle: resource) -> int { + test::close_closure(handle); + test::close_closure(handle); + 1 + } + let value = test::make_ok_result(); + let observed = match value { + Result::Ok(handle) => close_twice(handle), + Result::Err(e) => 0, + }; + observed; + "#; + let error = match compile_source_with_flavor_and_options( + reuse, + vm::SourceFlavor::RustScript, + options, + ) { + Ok(_) => panic!("consumed Result resource must not be reused"), + Err(error) => error, + }; + assert!(error.to_string().contains("moved"), "{error}"); + + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + CLOSURE_CLOSES.store(0, Ordering::SeqCst); + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let source = r#" + fn close_one(handle: resource) -> int { + test::close_closure(handle); + 1 + } + let value = test::make_err_result(); + let observed = match value { + Result::Ok(v) => 0, + Result::Err(handle) => close_one(handle), + }; + observed; + "#; + let compiled = + compile_source_with_flavor_and_options(source, vm::SourceFlavor::RustScript, options) + .expect("Err resource should transfer through the selected arm"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + bind_result_hosts(&mut vm); + assert_eq!( + vm.run().expect("Err resource match should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack().last().cloned(), Some(Value::Int(1))); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); +} + +#[test] +fn result_host_return_rejects_extra_field_wrong_key_and_wrong_generation() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let source = "test::make_extra_result();"; + let compiled = compile_source_with_flavor_and_options( + source, + vm::SourceFlavor::RustScript, + options.clone(), + ) + .expect("extra-field call should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + bind_result_hosts(&mut vm); + assert!(vm.run().is_err(), "extra Result field must be rejected"); + + BLOCK_CLOSES.store(0, Ordering::SeqCst); + CLOSURE_CLOSES.store(0, Ordering::SeqCst); + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let source = "test::make_wrong_key_result();"; + let compiled = + compile_source_with_flavor_and_options(source, vm::SourceFlavor::RustScript, options) + .expect("wrong-key call should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + bind_result_hosts(&mut vm); + assert!(vm.run().is_err(), "wrong resource key must be rejected"); + drop(vm); + assert_eq!(BLOCK_CLOSES.load(Ordering::SeqCst), 1); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 0); + + CLOSURE_CLOSES.store(0, Ordering::SeqCst); + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let source = "test::make_wrong_gen_result();"; + let compiled = + compile_source_with_flavor_and_options(source, vm::SourceFlavor::RustScript, options) + .expect("wrong-generation call should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + bind_result_hosts(&mut vm); + assert!( + vm.run().is_err(), + "wrong resource generation must be rejected" + ); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + + CLOSURE_CLOSES.store(0, Ordering::SeqCst); + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let source = "test::make_wrong_arena_result();"; + let compiled = + compile_source_with_flavor_and_options(source, vm::SourceFlavor::RustScript, options) + .expect("wrong-arena call should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + bind_result_hosts(&mut vm); + assert!(vm.run().is_err(), "wrong resource arena must be rejected"); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + + CLOSURE_CLOSES.store(0, Ordering::SeqCst); + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let source = "test::make_bad_tag_result();"; + let compiled = + compile_source_with_flavor_and_options(source, vm::SourceFlavor::RustScript, options) + .expect("bad-tag call should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + bind_result_hosts(&mut vm); + assert!(vm.run().is_err(), "non-bool Result tag must be rejected"); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + + CLOSURE_CLOSES.store(0, Ordering::SeqCst); + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let source = "test::make_bad_payload_result();"; + let compiled = + compile_source_with_flavor_and_options(source, vm::SourceFlavor::RustScript, options) + .expect("bad-payload call should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + bind_result_hosts(&mut vm); + assert!( + vm.run().is_err(), + "non-resource Result payload must be rejected" + ); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 0); +} diff --git a/tests/repl_public_api.rs b/tests/repl_public_api.rs index 0e69f619..aeaea0c4 100644 --- a/tests/repl_public_api.rs +++ b/tests/repl_public_api.rs @@ -797,3 +797,299 @@ fn repl_options_preserve_named_host_identity_and_runtime_values_between_snippets ) ); } + +fn result_entry_state(name: &str, schema: TypeSchema, moved: bool) -> ReplLocalState { + ReplLocalState { + binding: ReplLocalBinding { + name: name.into(), + mutable: false, + schema: Some(schema), + optional: false, + }, + moved, + } +} + +fn result_int_string() -> TypeSchema { + TypeSchema::Result { + ok: Box::new(TypeSchema::Int), + err: Box::new(TypeSchema::String), + } +} + +fn expect_repl_ok(compiled: Result, label: &str) { + if let Err(error) = compiled { + panic!("{label} must compile, got {error}"); + } +} + +fn expect_repl_err( + compiled: Result, + label: &str, +) -> String { + match compiled { + Err(error) => format!("{error:?} {error}"), + Ok(_) => panic!("{label} must reject"), + } +} + +#[test] +fn result_entry_schema_compiles_carried_result_query() { + expect_repl_ok( + compile_source_for_repl_with_state( + "r.is_ok();", + &[result_entry_state("r", result_int_string(), false)], + ), + "carried Result entry", + ); +} + +#[test] +fn result_entry_schema_compiles_carried_callable_query() { + let schema = TypeSchema::Callable { + params: Vec::new(), + result: Box::new(result_int_string()), + }; + expect_repl_ok( + compile_source_for_repl_with_state( + "f().is_ok();", + &[result_entry_state("f", schema, false)], + ), + "carried callable Result entry", + ); +} + +#[test] +fn result_entry_schema_rejects_non_result_and_moved_owned() { + let non_result = expect_repl_err( + compile_source_for_repl_with_state( + "n.is_ok();", + &[result_entry_state("n", TypeSchema::Int, false)], + ), + "non-Result entry", + ); + assert!( + non_result.contains("Result"), + "non-Result diagnostic was {non_result}" + ); + + let moved = expect_repl_err( + compile_source_for_repl_with_state( + "message;", + &[result_entry_state("message", TypeSchema::String, true)], + ), + "moved owned entry", + ); + assert!( + moved.contains("moved") || moved.contains("MOVED"), + "moved diagnostic was {moved}" + ); + + let resource = vm::ResourceTypeKey::new("repl.result-entry-resource").unwrap(); + let copy = expect_repl_err( + compile_source_for_repl_with_state( + "let duplicate = owned.copy();", + &[result_entry_state( + "owned", + TypeSchema::Resource(resource), + false, + )], + ), + "resource copy", + ); + assert!( + copy.contains("E_OWNERSHIP_COPY_RESOURCE"), + "resource copy diagnostic was {copy}" + ); +} + +struct EntryShiftPlugin; +static ENTRY_SHIFT_PLUGIN: EntryShiftPlugin = EntryShiftPlugin; +struct EntryShiftGrammar; +impl vm::ParserDialect for EntryShiftGrammar {} +static ENTRY_SHIFT_GRAMMAR: EntryShiftGrammar = EntryShiftGrammar; + +impl vm::SourcePlugin for EntryShiftPlugin { + fn descriptor(&self) -> vm::SourceFrontendDescriptor { + vm::SourceFrontendDescriptor { + id: vm::FrontendId::new("result-entry-shift"), + extensions: &["entry-shift"], + typing_policy: vm::FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + source: &str, + context: &vm::SourceFrontendContext<'_>, + ) -> Result { + vm::parse_source_with_dialect( + source, + &ENTRY_SHIFT_GRAMMAR, + vm::SharedParserOptions { + source_id: context.source_id, + allow_implicit_externs: false, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: false, + }, + ) + } + + fn parse_module_imports( + &self, + source: &str, + _path: &std::path::Path, + _context: &vm::SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + if source.contains("semantic-dependency") { + return Ok(vec![ + vm::ModuleImport::value("ghost.rss", vm::ImportClause::AllPublic, 1).type_only(), + ]); + } + Ok(Vec::new()) + } +} + +#[test] +fn result_entry_schema_runtime_projection_uses_remapped_slots() { + let options = vm::CompileSourceFileOptions::default() + .with_source_plugin(&ENTRY_SHIFT_PLUGIN) + .with_module_override_path("calibration.rss", "calibration.entry-shift") + .with_module_override_source( + "calibration.rss", + "// semantic-dependency\npub fn value() -> int { 1 }\n", + ) + .with_module_override_source( + "ghost.rss", + "let semantic_local: int = 40;\npub fn invisible() -> int { 3 }\n", + ); + let compiled = vm::compile_source_for_repl_with_state_and_options( + "use calibration; r.is_ok();", + &[result_entry_state("r", result_int_string(), false)], + options, + ); + let compiled = compiled.expect("runtime projection must keep the remapped Result entry"); + let locals = compiled + .compiled + .program + .debug + .as_ref() + .expect("debug locals") + .locals + .iter() + .map(|local| local.name.as_str()) + .collect::>(); + assert!( + locals.iter().all(|name| !name.contains("semantic_local")), + "semantic-only locals must not occupy runtime slots: {locals:?}" + ); +} + +fn result_entry_state_optional(name: &str, schema: TypeSchema, optional: bool) -> ReplLocalState { + ReplLocalState { + binding: ReplLocalBinding { + name: name.into(), + mutable: false, + schema: Some(schema), + optional, + }, + moved: false, + } +} + +fn assert_optional_entry_rejected(source: &str, state: ReplLocalState, label: &str) { + let rejected = expect_repl_err(compile_source_for_repl_with_state(source, &[state]), label); + assert!( + rejected.contains("optional") + || rejected.contains("Result") + || rejected.contains("unwrapped"), + "{label} must reject an unrefined optional use, got {rejected}" + ); +} + +#[test] +fn result_entry_schema_rejects_optional_bit_result_query() { + assert_optional_entry_rejected( + "r.is_ok();", + result_entry_state_optional("r", result_int_string(), true), + "optional-bit Result entry", + ); +} + +#[test] +fn result_entry_schema_rejects_optional_wrapper_result_query() { + assert_optional_entry_rejected( + "r.is_ok();", + result_entry_state_optional( + "r", + TypeSchema::Optional(Box::new(result_int_string())), + false, + ), + "optional-wrapper Result entry", + ); +} + +#[test] +fn result_entry_schema_rejects_optional_callable_call() { + let callable = TypeSchema::Callable { + params: Vec::new(), + result: Box::new(result_int_string()), + }; + assert_optional_entry_rejected( + "f().is_ok();", + result_entry_state_optional("f", callable.clone(), true), + "optional-bit callable entry", + ); + assert_optional_entry_rejected( + "f();", + result_entry_state_optional("f", callable.clone(), true), + "optional-bit callable call", + ); + assert_optional_entry_rejected( + "f().is_ok();", + result_entry_state_optional("f", TypeSchema::Optional(Box::new(callable)), false), + "optional-wrapper callable entry", + ); +} + +#[test] +fn result_entry_schema_rejects_captured_optional_result_query() { + assert_optional_entry_rejected( + "let f = || r.is_ok();", + result_entry_state_optional("r", result_int_string(), true), + "captured optional Result entry", + ); +} + +#[test] +fn result_entry_schema_accepts_unwrapped_optional_result() { + let compiled = compile_source_for_repl_with_state( + "let fallback: Result = Result::Ok(1); r.unwrap_or(fallback).is_ok();", + &[result_entry_state_optional("r", result_int_string(), true)], + ); + let compiled = match compiled { + Ok(compiled) => compiled, + Err(error) => panic!("legitimate unwrap_or of optional Result must compile, got {error}"), + }; + #[cfg(feature = "runtime")] + { + let mut machine = vm::Vm::new( + compiled + .compiled + .program + .with_local_count(compiled.compiled.locals), + ); + machine.set_fuel(100_000); + assert_eq!( + machine.run().expect("unwrapped optional Result should run"), + vm::VmStatus::Halted + ); + assert_eq!( + machine.stack().last(), + Some(&vm::Value::Bool(true)), + "unwrap_or fallback Ok must report is_ok" + ); + } +} diff --git a/tests/result_language_tests.rs b/tests/result_language_tests.rs new file mode 100644 index 00000000..4ee1e449 --- /dev/null +++ b/tests/result_language_tests.rs @@ -0,0 +1,763 @@ +#![cfg(feature = "runtime")] + +use vm::{Vm, VmStatus, compile_source}; + +fn eval(source: &str) -> vm::Value { + let compiled = compile_source(source).expect("Result source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_fuel(100_000); + assert_eq!( + vm.run().expect("Result program should run"), + VmStatus::Halted + ); + vm.stack() + .last() + .cloned() + .expect("Result program should leave a value") +} + +#[test] +fn ok_and_err_match_return_distinct_payloads() { + let source = r#" + let a: Result = Result::Ok(7); + let b: Result = Result::Err("bad"); + let ok_flag = a.is_ok(); + let err_flag = b.is_err(); + let value = match a { + Result::Ok(v) => v + 1, + Result::Err(e) => -1, + }; + let ignored = match b { + Result::Ok(v) => v, + Result::Err(e) => -1, + }; + let result = if ok_flag && err_flag => { value } else => { ignored }; + result; + "#; + assert_eq!(eval(source), vm::Value::Int(8)); +} + +fn reject(source: &str) -> String { + match compile_source(source) { + Ok(_) => panic!("Result source should be rejected: {source}"), + Err(error) => error.to_string(), + } +} + +#[test] +fn same_payload_keeps_ok_and_err_distinct() { + let source = r#" + let ok_zero: Result = Result::Ok(0); + let err_zero: Result = Result::Err(0); + let value = match ok_zero { + Result::Ok(v) => v + 3, + Result::Err(e) => e - 3, + }; + let other = match err_zero { + Result::Ok(v) => v + 3, + Result::Err(e) => e - 3, + }; + value - other; + "#; + assert_eq!(eval(source), vm::Value::Int(6)); +} + +#[test] +fn false_and_null_payloads_follow_the_active_branch() { + let source = r#" + let ok_false: Result = Result::Ok(false); + let err_false: Result = Result::Err(false); + let ok_null: Result = Result::Ok(null); + let flag = match ok_false { + Result::Ok(v) => v, + Result::Err(e) => true, + }; + let err_flag = match err_false { + Result::Ok(v) => true, + Result::Err(e) => e, + }; + let missing = match ok_null { + Result::Ok(v) => v, + Result::Err(e) => 9, + }; + let err_null: Result = Result::Err(null); + let result = if flag || err_flag => { -1 } else => { missing }; + let err_observed = match err_null { + Result::Ok(v) => 2, + Result::Err(e) => e, + }; + let observed = if result == null => { err_observed } else => { result }; + observed; + "#; + assert_eq!(eval(source), vm::Value::Null); +} + +#[test] +fn nested_array_and_named_payloads_match_the_active_branch() { + let source = r#" + struct Point { x: int } + let nested: Result, bool> = Result::Ok(Result::Ok(4)); + let numbers: Result, string> = Result::Ok([1, 2]); + let point: Result = Result::Ok({ x: 5 }); + let inner = match nested { + Result::Ok(v) => match v { + Result::Ok(n) => n, + Result::Err(e) => -1, + }, + Result::Err(e) => -2, + }; + let sum = match numbers { + Result::Ok(v) => v[0] + v[1], + Result::Err(e) => -1, + }; + let x = match point { + Result::Ok(v) => v.x, + Result::Err(e) => -1, + }; + inner + sum + x; + "#; + assert_eq!(eval(source), vm::Value::Int(12)); +} + +#[test] +fn result_struct_declaration_and_nonexhaustive_match_are_rejected() { + let reserved = reject("struct Result { value: int }"); + assert!( + reserved.contains("built-in generic type"), + "reserved struct error was {reserved}" + ); + let missing = reject( + "let value: Result = Result::Ok(1); match value { Result::Ok(v) => v };", + ); + assert!( + missing.contains("missing Result::Err"), + "missing arm error was {missing}" + ); + let duplicate = reject( + "let value: Result = Result::Ok(1); match value { Result::Ok(v) => v, Result::Ok(v) => v, Result::Err(e) => -1 };", + ); + assert!( + duplicate.contains("duplicate Result::Ok"), + "duplicate arm error was {duplicate}" + ); + let wildcard = reject( + "let value: Result = Result::Ok(1); match value { Result::Ok(v) => v, Result::Err(e) => -1, _ => 0 };", + ); + assert!( + wildcard.contains("wildcard"), + "wildcard-after-both-arms error was {wildcard}" + ); + let question = reject("fn fail() -> Result { Result::Err(\"x\")? }"); + assert!( + !question.is_empty(), + "question-mark must stay outside R1, got {question}" + ); + compile_source("fn ok_only() -> Result { Result::Ok(1) }") + .expect("Result function without ? should compile"); +} + +#[test] +fn generic_instantiations_keep_distinct_payloads() { + let source = r#" + fn wrap(value: T) -> Result { + Result::Ok(value) + } + let text = wrap::("hi"); + let number = wrap::(9); + let text_ok = match text { + Result::Ok(v) => v == "hi", + Result::Err(e) => false, + }; + let number_value = match number { + Result::Ok(v) => v, + Result::Err(e) => -1, + }; + let result = if text_ok => { number_value } else => { -1 }; + result; + "#; + assert_eq!(eval(source), vm::Value::Int(9)); +} + +#[test] +fn callable_payload_is_invoked_from_the_selected_arm() { + let source = r#" + fn add1(value: int) -> int { value + 1 } + let boxed: Result int, string> = Result::Ok(add1); + let applied = match boxed { + Result::Ok(f) => f(6), + Result::Err(e) => -1, + }; + applied; + "#; + assert_eq!(eval(source), vm::Value::Int(7)); +} + +#[test] +fn result_constructor_requires_result_context_and_matching_payload() { + let missing = reject("Result::Ok(1);"); + assert!( + missing.contains("inactive branch"), + "missing context error was {missing}" + ); + let wrong_type = reject("let value: int = Result::Ok(1);"); + assert!( + wrong_type.contains("is not a value of type"), + "wrong context error was {wrong_type}" + ); + let wrong_payload = reject("let value: Result = Result::Ok(\"no\");"); + assert!( + wrong_payload.contains("schema") || wrong_payload.contains("assigned"), + "wrong payload error was {wrong_payload}" + ); + compile_source("fn typed() -> Result { Result::Ok(1) }") + .expect("return context should supply the inactive branch"); +} + +#[test] +fn match_evaluates_scrutinee_once_and_only_the_selected_arm() { + let source = r#" + fn make(n: int) -> Result { + Result::Ok(n + 1) + } + let mut calls = [0]; + let value = match make(if true => { calls[0] = calls[0].copy() + 1; calls[0].copy() } else => { 0 }) { + Result::Ok(v) => if true => { calls[0] = calls[0].copy() + 10; v } else => { 0 }, + Result::Err(e) => if true => { calls[0] = calls[0].copy() + 100; 0 } else => { 0 }, + }; + value * 1000 + calls[0].copy(); + "#; + assert_eq!(eval(source), vm::Value::Int(2011)); +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn aot_ssa_match_returns_runtime_ok_and_err_payloads() { + fn run_pick(flag: i64, ok_value: i64, err_value: i64) -> (vm::Value, u64) { + let source = r#" + pub fn pick(flag: int, ok_value: int, err_value: int) -> int { + let value: Result = if flag == 1 => { + Result::Ok(ok_value) + } else => { + Result::Err(err_value) + }; + match value { + Result::Ok(v) => v + 1, + Result::Err(e) => 0 - e, + } + } + let callable = pick; + callable(41, 42, 43); + "#; + let compiled = compile_source(source).expect("Result pick should compile"); + let mut program = compiled.program.with_local_count(compiled.locals); + for constant in &mut program.constants { + if let vm::Value::Int(value) = constant { + *constant = match *value { + 41 => vm::Value::Int(flag), + 42 => vm::Value::Int(ok_value), + 43 => vm::Value::Int(err_value), + other => vm::Value::Int(other), + }; + } + } + let mut machine = vm::Vm::new_with_jit_config( + program, + vm::JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + machine.set_fuel(100_000); + machine + .compile_aot() + .expect("Result match should compile to AOT"); + let dump = machine.dump_aot_info(); + assert!(dump.contains("lowering=ssa"), "{dump}"); + assert!(!dump.contains("interpreter-boundary"), "{dump}"); + assert_eq!( + machine.run().expect("AOT Result match should run"), + VmStatus::Halted + ); + let count = machine.aot_exec_count(); + assert!(count > 0, "AOT execution count was {count}; dump {dump}"); + ( + machine + .stack() + .last() + .cloned() + .expect("AOT Result match should return a payload"), + count, + ) + } + + let (ok_payload, ok_count) = run_pick(1, 7, 9); + assert_eq!(ok_payload, vm::Value::Int(8)); + assert!(ok_count > 0); + let (err_payload, err_count) = run_pick(0, 7, 9); + assert_eq!(err_payload, vm::Value::Int(-9)); + assert!(err_count > 0); + + let source = r#" + pub fn pick(flag: int) -> bool { + let value: Result = if flag == 1 => { Result::Ok(false) } else => { Result::Err(1) }; + match value { + Result::Ok(v) => v, + Result::Err(e) => true, + } + } + let callable = pick; + callable(41); + "#; + let compiled = compile_source(source).expect("bool Result should compile"); + let mut program = compiled.program.with_local_count(compiled.locals); + for constant in &mut program.constants { + if let vm::Value::Int(41) = constant { + *constant = vm::Value::Int(1); + } + } + let mut machine = vm::Vm::new_with_jit_config( + program, + vm::JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + machine.set_fuel(100_000); + machine + .compile_aot() + .expect("bool Result should compile to AOT"); + assert_eq!( + machine.run().expect("bool Result should run"), + VmStatus::Halted + ); + assert!(machine.aot_exec_count() > 0); + assert_eq!( + machine.stack().last().cloned(), + Some(vm::Value::Bool(false)) + ); +} + +#[test] +fn direct_call_receiver_queries_both_outcomes_once() { + let source = r#" + fn choose(flag: bool) -> Result { + if flag => { Result::Ok(1) } else => { Result::Err("e") } + } + let mut calls = [0]; + let ok_is_ok = choose(true).is_ok(); + let ok_is_err = choose(true).is_err(); + let err_is_ok = choose(false).is_ok(); + let err_is_err = choose(false).is_err(); + let once = choose(if true => { calls[0] = calls[0].copy() + 1; true } else => { false }).is_ok(); + let local: Result = Result::Ok(2); + let local_ok = local.is_ok(); + let result = if ok_is_ok && err_is_err && once && local_ok => { + if ok_is_err || err_is_ok => { -1 } else => { calls[0].copy() } + } else => { -2 }; + result; + "#; + assert_eq!(eval(source), vm::Value::Int(1)); + let non_result = reject("fn plain(n: int) -> int { n } plain(1).is_ok();"); + assert!( + non_result.contains("Result"), + "non-Result query error was {non_result}" + ); +} + +#[test] +fn nested_result_array_constructors_keep_inactive_branches() { + let source = r#" + let rows: Result>, string> = Result::Ok([Result::Ok(1), Result::Err("e")]); + let outer: Result>, string> = Result::Err("outer"); + let deep: Result, string>>, string> = Result::Ok([Result::Ok(Result::Err(false))]); + let nullable: Result>, string> = Result::Ok([Result::Ok(null)]); + let first = match rows { + Result::Ok(items) => match items[0] { + Result::Ok(n) => n, + Result::Err(e) => -1, + }, + Result::Err(e) => -1, + }; + let second = match rows { + Result::Ok(items) => match items[1] { + Result::Ok(n) => n, + Result::Err(e) => 2, + }, + Result::Err(e) => -1, + }; + let outer_flag = match outer { + Result::Ok(items) => -1, + Result::Err(e) => 4, + }; + let nested_flag = match deep { + Result::Ok(items) => match items[0] { + Result::Ok(inner) => match inner { + Result::Ok(n) => n, + Result::Err(flag) => if flag => { -1 } else => { 8 }, + }, + Result::Err(e) => -1, + }, + Result::Err(e) => -1, + }; + let null_flag = match nullable { + Result::Ok(items) => match items[0] { + Result::Ok(n) => if n == null => { 16 } else => { -1 }, + Result::Err(e) => -1, + }, + Result::Err(e) => -1, + }; + first + second + outer_flag + nested_flag + null_flag; + "#; + assert_eq!(eval(source), vm::Value::Int(31)); + let wrong = reject( + "let value: Result>, string> = Result::Ok([Result::Ok(\"no\")]);", + ); + assert!( + (wrong.contains("schema") || wrong.contains("assigned") || wrong.contains("string")) + && !wrong.contains("inactive branch"), + "wrong nested payload error was {wrong}" + ); +} + +#[test] +fn named_and_generic_result_fields_use_declaration_context() { + let source = r#" + struct Box { value: Result } + struct Holder { item: Result } + let boxed: Result = Result::Ok({ value: Result::Ok(5) }); + let err_boxed: Result = Result::Ok({ value: Result::Err("e") }); + let held: Result, string> = Result::Ok({ item: Result::Err("e") }); + let ok_value = match boxed { + Result::Ok(v) => match v.value { + Result::Ok(n) => n, + Result::Err(e) => -1, + }, + Result::Err(e) => -1, + }; + let err_flag = match err_boxed { + Result::Ok(v) => match v.value { + Result::Ok(n) => n, + Result::Err(e) => 3, + }, + Result::Err(e) => -1, + }; + let generic_flag = match held { + Result::Ok(v) => match v.item { + Result::Ok(n) => n, + Result::Err(e) => 7, + }, + Result::Err(e) => -1, + }; + ok_value + err_flag + generic_flag; + "#; + assert_eq!(eval(source), vm::Value::Int(15)); + let wrong = reject( + "struct Box { value: Result } let value: Result = Result::Ok({ value: Result::Ok(\"no\") });", + ); + assert!( + (wrong.contains("schema") || wrong.contains("assigned") || wrong.contains("string")) + && !wrong.contains("inactive branch"), + "invalid field payload error was {wrong}" + ); + let missing = reject( + "struct Box { value: Result } let value: Result = Result::Ok({});", + ); + assert!( + missing.contains("field") && !missing.contains("inactive branch"), + "missing field error was {missing}" + ); +} + +#[test] +fn typed_closure_result_return_uses_callable_schema() { + let source = r#" + let empty: fn() -> Result = || Result::Ok(1); + let choose: fn(bool) -> Result = |flag| if flag => { Result::Ok(8) } else => { Result::Err("e") }; + let empty_value = match empty() { + Result::Ok(v) => v, + Result::Err(e) => -1, + }; + let ok_value = match choose(true) { + Result::Ok(v) => v, + Result::Err(e) => -1, + }; + let err_flag = match choose(false) { + Result::Ok(v) => v, + Result::Err(e) => 2, + }; + empty_value + ok_value + err_flag; + "#; + assert_eq!(eval(source), vm::Value::Int(11)); +} + +fn eval_file(path: &std::path::Path) -> vm::Value { + let compiled = vm::compile_source_file(path).expect("module Result source should compile"); + let mut machine = vm::Vm::new(compiled.program.with_local_count(compiled.locals)); + machine.set_fuel(100_000); + assert_eq!( + machine.run().expect("module Result program should run"), + VmStatus::Halted + ); + machine + .stack() + .last() + .cloned() + .expect("module Result program should leave a value") +} + +#[test] +fn imported_module_formal_supplies_result_constructor_context() { + let root = std::env::temp_dir().join(format!( + "r1_address01_module_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&root).expect("module root"); + let module_path = root.join("module.rss"); + let main_path = root.join("main.rss"); + std::fs::write( + &module_path, + r#" + pub fn take(value: Result) -> int { + let observed = match value { + Result::Ok(v) => v + 1, + Result::Err(e) => -1, + }; + observed + } + "#, + ) + .expect("module source"); + std::fs::write( + &main_path, + r#" + use module::{take}; + let ok_value = take(Result::Ok(6)); + let err_value = take(Result::Err("e")); + ok_value + err_value; + "#, + ) + .expect("main source"); + let observed = eval_file(&main_path); + let _ = std::fs::remove_dir_all(&root); + assert_eq!(observed, vm::Value::Int(6)); + let wrong_root = std::env::temp_dir().join(format!( + "r1_address01_module_wrong_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&wrong_root).expect("wrong module root"); + std::fs::write( + wrong_root.join("module.rss"), + r#" + pub fn take(value: Result) -> int { + let observed = match value { + Result::Ok(v) => v, + Result::Err(e) => -1, + }; + observed + } + "#, + ) + .expect("wrong module source"); + std::fs::write( + wrong_root.join("main.rss"), + "use module::{take};\ntake(Result::Ok(\"no\"));\n", + ) + .expect("wrong main source"); + let wrong = match vm::compile_source_file(wrong_root.join("main.rss")) { + Ok(_) => { + let _ = std::fs::remove_dir_all(&wrong_root); + panic!("wrong module payload should be rejected"); + } + Err(error) => error.to_string(), + }; + let _ = std::fs::remove_dir_all(&wrong_root); + assert!( + (wrong.contains("schema") || wrong.contains("assigned") || wrong.contains("string")) + && !wrong.contains("inactive branch"), + "wrong module payload error was {wrong}" + ); +} + +#[test] +fn contextual_captured_result_returns_captured_ok() { + let value = eval( + r#" + let n: int = 7; + let f: fn() -> Result = || Result::Ok(n); + match f() { Result::Ok(v) => v, Result::Err(e) => -1 }; + "#, + ); + assert_eq!(value, vm::Value::Int(7)); +} + +#[test] +fn contextual_captured_result_selects_err_payload() { + let value = eval( + r#" + let message: string = "bad"; + let f: fn() -> Result = || Result::Err(message); + match f() { Result::Ok(v) => v, Result::Err(e) => -1 }; + "#, + ); + assert_eq!(value, vm::Value::Int(-1)); +} + +#[test] +fn contextual_captured_result_captured_callable() { + let value = eval( + r#" + let add1: fn(int) -> int = |value| value + 1; + let f: fn() -> Result int, string> = || Result::Ok(add1); + let picked = match f() { + Result::Ok(g) => g(6), + Result::Err(e) => -1, + }; + picked; + "#, + ); + assert_eq!(value, vm::Value::Int(7)); +} + +#[test] +fn contextual_captured_result_rejects_wrong_payload() { + let wrong = match compile_source( + r#" + let message: string = "no"; + let f: fn() -> Result = || Result::Ok(message); + "#, + ) { + Ok(_) => panic!("wrong captured payload must be rejected"), + Err(error) => error.to_string(), + }; + assert!( + (wrong.contains("schema") || wrong.contains("assigned") || wrong.contains("string")) + && !wrong.contains("inactive branch"), + "wrong captured payload error was {wrong}" + ); +} + +#[test] +fn contextual_captured_result_rejects_moved_resource() { + let key = vm::ResourceTypeKey::new("capture.resource").expect("resource key"); + let mut builder = vm::HostApiBuilder::new(); + builder.resource(vm::ResourceTypeSchema::new(key.clone(), "capture resource")); + builder.function(vm::HostFunctionSchema::with_return( + "capture::open", + Vec::new(), + vm::HostTypeSchema::Resource(key), + )); + let catalog = std::sync::Arc::new(builder.build().expect("catalog")); + let wrong = match vm::compile_source_with_flavor_and_options( + "let owned = capture::open();\nlet f = || owned.copy();\n", + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::default().with_host_api_catalog(catalog), + ) { + Ok(_) => panic!("moved/copied resource capture must be rejected"), + Err(error) => format!("{error:?} {error}"), + }; + assert!( + wrong.contains("E_OWNERSHIP_COPY_RESOURCE") || wrong.contains("moved"), + "moved resource diagnostic was {wrong}" + ); +} + +fn observe_nested_closure(source: &str) -> (String, Result) { + let compiled = compile_source(source).expect("nested closure source should compile"); + let prototypes = format!("{:?}", compiled.program.callable_prototypes); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_fuel(100_000); + let runtime = match vm.run() { + Ok(status) => { + if status != VmStatus::Halted { + Err(format!("status {status:?}")) + } else { + vm.stack() + .last() + .cloned() + .ok_or_else(|| "missing stack value".to_string()) + } + } + Err(error) => Err(format!("{error:?}")), + }; + (prototypes, runtime) +} + +fn assert_nonrecursive_nested_returns_three(source: &str, label: &str) { + let (prototypes, runtime) = observe_nested_closure(source); + let self_claimed = prototypes.contains("self_slot: Some"); + assert!( + !self_claimed && matches!(runtime, Ok(vm::Value::Int(3))), + "{label} nonrecursive prototypes must keep self_slot None and return 3; prototypes={prototypes} runtime={runtime:?}" + ); + assert!( + prototypes.contains("capture_slots: [") && prototypes.contains("Callable"), + "{label} must keep an ordinary captured callable schema; prototypes={prototypes}" + ); +} + +#[test] +fn contextual_captured_result_nested_call_returns_three() { + assert_nonrecursive_nested_returns_three( + r#" + let n: int = 3; + let f: fn() -> Result = || Result::Ok(n); + let g: fn() -> Result = || f(); + match g() { Result::Ok(v) => v, Result::Err(e) => -1 }; + "#, + "green-f1 nested call", + ); +} + +#[test] +fn contextual_captured_result_nested_inner_call_returns_three() { + assert_nonrecursive_nested_returns_three( + r#" + let n: int = 3; + let inner: fn() -> int = || n; + let outer: fn() -> Result = || Result::Ok(inner()); + match outer() { Result::Ok(v) => v, Result::Err(e) => -1 }; + "#, + "green-f1b nested inner call", + ); +} + +#[test] +fn contextual_captured_result_nested_returned_callable_returns_three() { + assert_nonrecursive_nested_returns_three( + r#" + let n: int = 3; + let inner: fn() -> int = || n; + let outer: fn() -> Result int, string> = || Result::Ok(inner); + let picked = match outer() { + Result::Ok(f) => f(), + Result::Err(e) => -1, + }; + picked; + "#, + "green-f1c returned callable", + ); +} + +#[test] +fn no_result_nested_inner_outer_returns_three() { + assert_nonrecursive_nested_returns_three( + r#" + let n: int = 3; + let inner: fn() -> int = || n; + let outer = || inner; + let picked = outer(); + picked(); + "#, + "no-Result nested inner/outer", + ); +} diff --git a/tests/wire/wire_tests.rs b/tests/wire/wire_tests.rs index 487a9f39..72f6fe5c 100644 --- a/tests/wire/wire_tests.rs +++ b/tests/wire/wire_tests.rs @@ -9,6 +9,7 @@ use vm::{ compile_source, decode_program, disassemble_vmbc, disassemble_vmbc_with_options, encode_program, infer_local_count, validate_program, }; +use vm::compiler::TypeSchema; #[test] fn wire_roundtrip_preserves_constants_and_code() { @@ -89,9 +90,10 @@ fn wire_v11_legacy_imports_decode_without_schema_metadata() { strip_empty_named_struct_section(&mut legacy); legacy[4..6].copy_from_slice(&11u16.to_le_bytes()); - let decoded = decode_program(&legacy).expect("v11 payload should remain readable"); - assert_eq!(decoded.imports, vec![import]); - assert!(decoded.host_import_schemas().is_empty()); + assert!( + matches!(decode_program(&legacy), Err(WireError::UnsupportedVersion(11))), + "v11 payloads are not a supported reader" + ); } #[test] @@ -101,10 +103,10 @@ fn wire_v11_zero_import_program_decodes_by_version() { strip_empty_named_struct_section(&mut encoded); encoded[4..6].copy_from_slice(&11u16.to_le_bytes()); - let decoded = decode_program(&encoded).expect("schema-less v11 payload should decode"); - assert_eq!(decoded.code, program.code); - assert!(decoded.imports.is_empty()); - assert!(decoded.host_import_schemas().is_empty()); + assert!(matches!( + decode_program(&encoded), + Err(WireError::UnsupportedVersion(11)) + )); } fn strip_empty_named_struct_section(encoded: &mut Vec) { @@ -123,7 +125,7 @@ fn strip_empty_named_struct_section(encoded: &mut Vec) { fn minimal_vmbc_prefix(constant_count: u32, code: &[u8], import_count: u32) -> Vec { let mut bytes = Vec::new(); bytes.extend_from_slice(b"VMBC"); - bytes.extend_from_slice(&12u16.to_le_bytes()); + bytes.extend_from_slice(&13u16.to_le_bytes()); bytes.extend_from_slice(&0u16.to_le_bytes()); bytes.extend_from_slice(&constant_count.to_le_bytes()); bytes.extend_from_slice(&(code.len() as u32).to_le_bytes()); @@ -1074,7 +1076,10 @@ fn call_script_wire_version_is_v13_and_v11_accepts_schema_less_program() { let mut old = encoded; strip_empty_named_struct_section(&mut old); old[4..6].copy_from_slice(&11u16.to_le_bytes()); - decode_program(&old).expect("schema-less v11 program should decode"); + assert!(matches!( + decode_program(&old), + Err(WireError::UnsupportedVersion(11)) + )); } #[test] @@ -1101,13 +1106,16 @@ fn v12_trailing_zero_count_is_not_a_named_struct_table() { let mut encoded = encode_program(&program).expect("v13 encoding should succeed"); strip_empty_named_struct_section(&mut encoded); encoded[4..6].copy_from_slice(&12u16.to_le_bytes()); - decode_program(&encoded).expect("clean v12 without a named-struct section should decode"); + assert!(matches!( + decode_program(&encoded), + Err(WireError::UnsupportedVersion(12)) + )); let mut garbage = encoded; garbage.extend_from_slice(&0u32.to_le_bytes()); assert!( - matches!(decode_program(&garbage), Err(WireError::TrailingBytes)), - "v12 must not treat a 4-byte zero trailer as an empty named-struct table" + matches!(decode_program(&garbage), Err(WireError::UnsupportedVersion(12))), + "v12 is rejected before trailing bytes are interpreted" ); } @@ -1133,3 +1141,62 @@ fn v13_roundtrip_preserves_guest_named_struct_payload() { "VMBC v13 should preserve guest struct decls" ); } + +#[test] +fn result_schema_roundtrip_rejects_non_v13_unknown_tags_and_excess_depth() { + let compiled = compile_source( + "let value: Result = Result::Ok(7); match value { Result::Ok(v) => v, Result::Err(e) => -1 };", + ) + .expect("Result source should compile"); + let encoded = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("Result program should encode"); + assert_eq!(u16::from_le_bytes([encoded[4], encoded[5]]), 13); + let decoded = decode_program(&encoded).expect("v13 Result program should decode"); + let has_result = decoded.type_map.as_ref().is_some_and(|map| { + map.local_schemas + .iter() + .flatten() + .any(|schema| matches!(schema, TypeSchema::Result { .. })) + }); + assert!(has_result, "decoded type map should keep the Result schema"); + + let mut old = encoded.clone(); + old[4..6].copy_from_slice(&11u16.to_le_bytes()); + assert!(matches!( + decode_program(&old), + Err(WireError::UnsupportedVersion(11)) + )); + + let mut unknown = encoded.clone(); + let tag = unknown + .windows(2) + .position(|window| window == [18, 2]) + .expect("encoded Result schema should contain tag 18 then Int"); + unknown[tag + 1] = 99; + assert!(matches!( + decode_program(&unknown), + Err(WireError::InvalidValueType(99)) + )); + + let mut schema = TypeSchema::Int; + for _ in 0..70 { + schema = TypeSchema::Result { + ok: Box::new(schema), + err: Box::new(TypeSchema::Int), + }; + } + let mut deep = Program::new(vec![Value::Int(1)], vec![0x01]); + deep.type_map = Some(TypeMap { + strict_types: true, + local_types: vec![ValueType::Map], + local_schemas: vec![Some(schema)], + callable_slots: vec![false], + optional_slots: vec![false], + operand_types: HashMap::new(), + }); + let deep_bytes = encode_program(&deep).expect("deep Result schema should encode"); + assert!(matches!( + decode_program(&deep_bytes), + Err(WireError::LengthTooLarge("schema nesting depth", _)) + )); +} From fe5827be99b27a96798fabbf748ca6d5854ff411 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 18 Oct 2026 04:19:37 +0800 Subject: [PATCH 2/5] feat(result): add function-local error propagation and ownership cleanup --- pd-vm-nostd/src/error.rs | 6 + pd-vm-nostd/src/vmbc.rs | 454 ++++- pd-vm-nostd/tests/call_script_tests.rs | 284 +++ pd-vm-nostd/tests/embedded_vmbc.rs | 20 +- src/bytecode.rs | 20 + src/compiler/codegen.rs | 127 +- src/compiler/frontends/rustscript.rs | 4 + src/compiler/ir.rs | 26 +- src/compiler/lifetime/availability.rs | 690 +++++-- .../lifetime/availability/captures.rs | 3 +- .../lifetime/availability/consumption.rs | 56 +- src/compiler/lifetime/liveness.rs | 53 +- src/compiler/linker.rs | 28 + src/compiler/materialization.rs | 5 +- src/compiler/mod.rs | 1 + src/compiler/parser/expressions.rs | 31 +- src/compiler/parser/mod.rs | 7 + src/compiler/pipeline.rs | 20 +- src/compiler/result.rs | 177 ++ src/compiler/source_loader/graph.rs | 3 + src/compiler/try_anf.rs | 718 ++++++++ src/compiler/typing/collect.rs | 14 + src/compiler/typing/context.rs | 51 + src/compiler/typing/helpers.rs | 17 +- src/compiler/typing/validate.rs | 18 + src/lib.rs | 3 +- src/vm/aot/artifact.rs | 105 ++ src/vm/aot/ssa.rs | 228 ++- src/vm/mod.rs | 483 ++++- src/vm/native/bridge.rs | 2 +- src/vm/resource/table.rs | 11 + src/vm/tests.rs | 1391 +++++++++++++- src/vmbc.rs | 715 ++++++- tests/compiler/frontend_plugin_tests.rs | 344 ++++ tests/pr24_resource_tests.rs | 1638 ++++++++++++++++- tests/result_try_tests.rs | 1188 ++++++++++++ tests/typed_host_no_dynamic_contract_tests.rs | 4 + tests/wire/wire_tests.rs | 24 +- 38 files changed, 8774 insertions(+), 195 deletions(-) create mode 100644 src/compiler/try_anf.rs create mode 100644 tests/result_try_tests.rs diff --git a/pd-vm-nostd/src/error.rs b/pd-vm-nostd/src/error.rs index c1cd67be..5bd4c74c 100644 --- a/pd-vm-nostd/src/error.rs +++ b/pd-vm-nostd/src/error.rs @@ -136,6 +136,8 @@ pub enum WireError { InvalidUtf8, LengthTooLarge(&'static str, usize), SchemaTooDeep, + InvalidRetCleanup(&'static str), + UnsupportedCapability(&'static str), TrailingBytes, } @@ -181,6 +183,10 @@ impl fmt::Display for WireError { write!(f, "{field} length is too large: {length}") } Self::SchemaTooDeep => f.write_str("VMBC type schema nesting is too deep"), + Self::InvalidRetCleanup(reason) => write!(f, "invalid ret cleanup: {reason}"), + Self::UnsupportedCapability(reason) => { + write!(f, "unsupported capability: {reason}") + } Self::TrailingBytes => f.write_str("trailing bytes after VMBC payload"), } } diff --git a/pd-vm-nostd/src/vmbc.rs b/pd-vm-nostd/src/vmbc.rs index 227ab154..ec1e1c67 100644 --- a/pd-vm-nostd/src/vmbc.rs +++ b/pd-vm-nostd/src/vmbc.rs @@ -1,3 +1,4 @@ +use alloc::boxed::Box; use alloc::string::String; use alloc::vec::Vec; @@ -115,7 +116,14 @@ pub fn decode_program(bytes: &[u8]) -> Result { root_callable_bindings, exported_callables, ) = read_callable_metadata(&mut cursor)?; - skip_named_struct_decls(&mut cursor)?; + let named_decls = read_named_cleanup_decls(&mut cursor)?; + read_ret_cleanups( + &mut cursor, + &code, + &callable_prototypes, + &function_regions, + &named_decls, + )?; if !cursor.is_empty() { return Err(WireError::TrailingBytes); } @@ -296,31 +304,455 @@ fn skip_schema(cursor: &mut Cursor<'_>, depth: usize) -> Result<(), WireError> { } } -fn skip_named_struct_decls(cursor: &mut Cursor<'_>) -> Result<(), WireError> { +fn read_named_cleanup_decls(cursor: &mut Cursor<'_>) -> Result, WireError> { let count = cursor.read_count("named struct decls", 1)?; - let mut seen_names = Vec::new(); - reserve(&mut seen_names, "named struct decls", count)?; + let mut decls: Vec = Vec::new(); + reserve(&mut decls, "named struct decls", count)?; for _ in 0..count { let name = cursor.read_string()?; - if seen_names.iter().any(|existing| existing == &name) { + if decls.iter().any(|existing| existing.name == name) { return Err(WireError::InvalidValueType(0)); } - seen_names.push(name); let param_count = cursor.read_count("named struct type params", 1)?; - let mut seen = Vec::new(); - reserve(&mut seen, "named struct type params", param_count)?; + let mut type_params: Vec = Vec::new(); + reserve(&mut type_params, "named struct type params", param_count)?; for _ in 0..param_count { let param = cursor.read_string()?; - if seen.iter().any(|existing| existing == ¶m) { + if type_params.iter().any(|existing| existing == ¶m) { return Err(WireError::InvalidValueType(0)); } - seen.push(param); + type_params.push(param); } - skip_schema(cursor, 0)?; + let body = read_cleanup_schema(cursor, 0)?; + decls.push(NamedCleanupDecl { + name, + type_params, + body, + }); + } + Ok(decls) +} + +fn read_ret_cleanups( + cursor: &mut Cursor<'_>, + code: &[u8], + prototypes: &[CallablePrototype], + regions: &[FunctionRegion], + decls: &[NamedCleanupDecl], +) -> Result<(), WireError> { + let plan_count = cursor.read_count_with_overhead("ret cleanup plans", 8, 0)?; + if plan_count > code.len() { + return Err(WireError::LengthTooLarge("ret cleanup plans", plan_count)); + } + let ret_sites = if plan_count == 0 { + Vec::new() + } else { + ret_instruction_sites(code)? + }; + let mut previous_site = None; + let mut saw_resource = false; + for _ in 0..plan_count { + let site = cursor.read_u32()?; + if previous_site.is_some_and(|previous| site <= previous) { + return Err(WireError::InvalidRetCleanup( + "cleanup sites are not canonical", + )); + } + previous_site = Some(site); + let frame_locals = cleanup_frame_local_count(prototypes, regions, &ret_sites, site)?; + let local_count = cursor.read_u32()? as usize; + if local_count > frame_locals || local_count > MAX_FRAME_LOCAL_COUNT { + return Err(WireError::LengthTooLarge("ret cleanup locals", local_count)); + } + cursor.validate_count("ret cleanup locals", local_count, 3)?; + cursor.debit_count("ret cleanup locals", local_count)?; + let mut previous_slot = None; + for _ in 0..local_count { + let slot = cursor.read_u16()?; + if previous_slot.is_some_and(|previous| slot <= previous) { + return Err(WireError::InvalidRetCleanup( + "cleanup locals are not canonical", + )); + } + if slot as usize >= frame_locals { + return Err(WireError::InvalidRetCleanup( + "cleanup local is outside the frame", + )); + } + previous_slot = Some(slot); + let schema = read_cleanup_schema(cursor, 0)?; + if !cleanup_schema_owns(&schema, decls, &[], &[], 0, &mut 0, &mut Vec::new())? { + return Err(WireError::InvalidRetCleanup( + "cleanup schema has no owned resource", + )); + } + saw_resource = true; + } + } + if saw_resource { + return Err(WireError::UnsupportedCapability( + "ret cleanup resource plans", + )); } Ok(()) } +fn ret_instruction_sites(code: &[u8]) -> Result, WireError> { + let mut ip = 0usize; + let mut sites = Vec::new(); + while ip < code.len() { + let opcode = OpCode::try_from(code[ip]) + .map_err(|_| WireError::InvalidRetCleanup("cleanup bytecode has an invalid opcode"))?; + if opcode == OpCode::Ret { + let site = u32::try_from(ip).map_err(|_| WireError::LengthTooLarge("code", ip))?; + sites.push(site); + } + let next = ip + .checked_add(1) + .and_then(|value| value.checked_add(opcode.operand_len())) + .ok_or(WireError::LengthTooLarge("code", ip))?; + if next > code.len() { + return Err(WireError::InvalidRetCleanup("truncated instruction")); + } + ip = next; + } + Ok(sites) +} + +fn cleanup_frame_local_count( + prototypes: &[CallablePrototype], + regions: &[FunctionRegion], + ret_sites: &[u32], + site: u32, +) -> Result { + if !ret_sites.contains(&site) { + return Err(WireError::InvalidRetCleanup( + "cleanup site is not a Ret instruction", + )); + } + let mut frame_locals = None; + for region in regions { + if site < region.start_ip || site >= region.end_ip { + continue; + } + let Some(prototype_id) = region.prototype_id else { + continue; + }; + let Some(prototype) = prototypes.get(prototype_id as usize) else { + return Err(WireError::InvalidRetCleanup( + "cleanup frame prototype is missing", + )); + }; + if !matches!(prototype.target, CallableTarget::ScriptFunction(_)) { + return Err(WireError::InvalidRetCleanup( + "cleanup frame is not a script function", + )); + } + if frame_locals.replace(prototype.frame_local_count).is_some() { + return Err(WireError::InvalidRetCleanup( + "cleanup site matches multiple frames", + )); + } + } + frame_locals.ok_or(WireError::InvalidRetCleanup( + "cleanup site is not in a callable frame", + )) +} + +const CLEANUP_SCHEMA_WALK_NODES: usize = 16_384; + +struct NamedCleanupDecl { + name: String, + type_params: Vec, + body: WireSchema, +} + +#[derive(Clone)] +enum WireSchema { + Scalar, + Generic(String), + Named { + name: String, + args: Vec, + }, + Array(Box), + Tuple(Vec), + TupleRest { + prefix: Vec, + rest: Box, + }, + Map(Box), + Object(Vec<(String, WireSchema)>), + Callable { + params: Vec, + result: Box, + }, + Optional(Box), + Resource, + Result { + ok: Box, + err: Box, + }, +} + +fn read_cleanup_schema(cursor: &mut Cursor<'_>, depth: usize) -> Result { + if depth >= MAX_SCHEMA_DEPTH { + return Err(WireError::SchemaTooDeep); + } + cursor.debit_count("schema nodes", 1)?; + let nested = depth.checked_add(1).ok_or(WireError::SchemaTooDeep)?; + Ok(match cursor.read_u8()? { + 0..=7 => WireSchema::Scalar, + 8 => WireSchema::Generic(cursor.read_string()?), + 9 => { + let name = cursor.read_string()?; + let count = cursor.read_count("schema type args", 1)?; + let mut args = Vec::new(); + reserve(&mut args, "schema type args", count)?; + for _ in 0..count { + args.push(read_cleanup_schema(cursor, nested)?); + } + WireSchema::Named { name, args } + } + 10 => WireSchema::Array(Box::new(read_cleanup_schema(cursor, nested)?)), + 11 => { + let count = cursor.read_count("schema tuple items", 1)?; + let mut items = Vec::new(); + reserve(&mut items, "schema tuple items", count)?; + for _ in 0..count { + items.push(read_cleanup_schema(cursor, nested)?); + } + WireSchema::Tuple(items) + } + 12 => { + let count = cursor.read_count_with_overhead("schema tuple prefix", 1, 1)?; + let mut prefix = Vec::new(); + reserve(&mut prefix, "schema tuple prefix", count)?; + for _ in 0..count { + prefix.push(read_cleanup_schema(cursor, nested)?); + } + WireSchema::TupleRest { + prefix, + rest: Box::new(read_cleanup_schema(cursor, nested)?), + } + } + 13 => WireSchema::Map(Box::new(read_cleanup_schema(cursor, nested)?)), + 14 => { + let count = cursor.read_count("schema object fields", 5)?; + let mut fields = Vec::new(); + reserve(&mut fields, "schema object fields", count)?; + for _ in 0..count { + fields.push((cursor.read_string()?, read_cleanup_schema(cursor, nested)?)); + } + WireSchema::Object(fields) + } + 15 => { + let count = cursor.read_count_with_overhead("schema callable params", 1, 1)?; + let mut params = Vec::new(); + reserve(&mut params, "schema callable params", count)?; + for _ in 0..count { + params.push(read_cleanup_schema(cursor, nested)?); + } + WireSchema::Callable { + params, + result: Box::new(read_cleanup_schema(cursor, nested)?), + } + } + 16 => WireSchema::Optional(Box::new(read_cleanup_schema(cursor, nested)?)), + 17 => { + skip_resource_key(cursor)?; + WireSchema::Resource + } + 18 => WireSchema::Result { + ok: Box::new(read_cleanup_schema(cursor, nested)?), + err: Box::new(read_cleanup_schema(cursor, nested)?), + }, + value => return Err(WireError::InvalidValueType(value)), + }) +} + +fn cleanup_schema_owns( + schema: &WireSchema, + decls: &[NamedCleanupDecl], + param_names: &[String], + param_args: &[WireSchema], + depth: usize, + nodes: &mut usize, + active: &mut Vec, +) -> Result { + if depth >= MAX_SCHEMA_DEPTH { + return Err(WireError::SchemaTooDeep); + } + *nodes = nodes + .checked_add(1) + .ok_or(WireError::LengthTooLarge("schema nodes", usize::MAX))?; + if *nodes > CLEANUP_SCHEMA_WALK_NODES { + return Err(WireError::LengthTooLarge("schema nodes", *nodes)); + } + let next = depth + 1; + match schema { + WireSchema::Scalar => Ok(false), + WireSchema::Generic(name) => { + let Some(index) = param_names.iter().position(|param| param == name) else { + return Ok(false); + }; + cleanup_schema_owns(¶m_args[index], decls, &[], &[], next, nodes, active) + } + WireSchema::Resource => Ok(true), + WireSchema::Array(inner) | WireSchema::Map(inner) | WireSchema::Optional(inner) => { + cleanup_schema_owns(inner, decls, param_names, param_args, next, nodes, active) + } + WireSchema::Tuple(items) => { + any_owns(items, decls, param_names, param_args, next, nodes, active) + } + WireSchema::TupleRest { prefix, rest } => { + let mut owned = any_owns(prefix, decls, param_names, param_args, next, nodes, active)?; + owned |= + cleanup_schema_owns(rest, decls, param_names, param_args, next, nodes, active)?; + Ok(owned) + } + WireSchema::Object(fields) => { + let mut owned = false; + for (_, field) in fields { + owned |= cleanup_schema_owns( + field, + decls, + param_names, + param_args, + next, + nodes, + active, + )?; + } + Ok(owned) + } + WireSchema::Callable { params, result } => { + any_owns(params, decls, param_names, param_args, next, nodes, active)?; + cleanup_schema_owns(result, decls, param_names, param_args, next, nodes, active)?; + Ok(false) + } + WireSchema::Result { ok, err } => { + let ok_owned = + cleanup_schema_owns(ok, decls, param_names, param_args, next, nodes, active)?; + let err_owned = + cleanup_schema_owns(err, decls, param_names, param_args, next, nodes, active)?; + Ok(ok_owned || err_owned) + } + WireSchema::Named { name, args } => { + let Some(decl) = decls.iter().find(|decl| decl.name == *name) else { + return Err(WireError::InvalidRetCleanup( + "unresolved named cleanup schema", + )); + }; + if decl.type_params.len() != args.len() { + return Err(WireError::InvalidRetCleanup("named cleanup schema arity")); + } + if active.iter().any(|active_name| active_name == name) { + return Err(WireError::InvalidRetCleanup("named cleanup schema cycle")); + } + let resolved = args + .iter() + .map(|arg| substitute_schema(arg, param_names, param_args)) + .collect::>(); + active.push(name.clone()); + let mut owned = cleanup_schema_owns( + &decl.body, + decls, + &decl.type_params, + &resolved, + next, + nodes, + active, + )?; + owned |= any_owns(&resolved, decls, &[], &[], next, nodes, active)?; + active.pop(); + Ok(owned) + } + } +} + +fn any_owns( + items: &[WireSchema], + decls: &[NamedCleanupDecl], + param_names: &[String], + param_args: &[WireSchema], + depth: usize, + nodes: &mut usize, + active: &mut Vec, +) -> Result { + let mut owned = false; + for item in items { + owned |= cleanup_schema_owns(item, decls, param_names, param_args, depth, nodes, active)?; + } + Ok(owned) +} + +fn substitute_schema( + schema: &WireSchema, + param_names: &[String], + param_args: &[WireSchema], +) -> WireSchema { + match schema { + WireSchema::Generic(name) => param_names + .iter() + .position(|param| param == name) + .map(|index| param_args[index].clone()) + .unwrap_or_else(|| schema.clone()), + WireSchema::Named { name, args } => WireSchema::Named { + name: name.clone(), + args: args + .iter() + .map(|arg| substitute_schema(arg, param_names, param_args)) + .collect(), + }, + WireSchema::Array(inner) => { + WireSchema::Array(Box::new(substitute_schema(inner, param_names, param_args))) + } + WireSchema::Optional(inner) => { + WireSchema::Optional(Box::new(substitute_schema(inner, param_names, param_args))) + } + WireSchema::Map(inner) => { + WireSchema::Map(Box::new(substitute_schema(inner, param_names, param_args))) + } + WireSchema::Tuple(items) => WireSchema::Tuple( + items + .iter() + .map(|item| substitute_schema(item, param_names, param_args)) + .collect(), + ), + WireSchema::TupleRest { prefix, rest } => WireSchema::TupleRest { + prefix: prefix + .iter() + .map(|item| substitute_schema(item, param_names, param_args)) + .collect(), + rest: Box::new(substitute_schema(rest, param_names, param_args)), + }, + WireSchema::Object(fields) => WireSchema::Object( + fields + .iter() + .map(|(name, field)| { + ( + name.clone(), + substitute_schema(field, param_names, param_args), + ) + }) + .collect(), + ), + WireSchema::Callable { params, result } => WireSchema::Callable { + params: params + .iter() + .map(|param| substitute_schema(param, param_names, param_args)) + .collect(), + result: Box::new(substitute_schema(result, param_names, param_args)), + }, + WireSchema::Result { ok, err } => WireSchema::Result { + ok: Box::new(substitute_schema(ok, param_names, param_args)), + err: Box::new(substitute_schema(err, param_names, param_args)), + }, + WireSchema::Scalar | WireSchema::Resource => schema.clone(), + } +} + fn skip_resource_key(cursor: &mut Cursor<'_>) -> Result<(), WireError> { let bytes = cursor.read_blob("schema resource key")?; if bytes.is_empty() || bytes.len() > MAX_RESOURCE_KEY_LEN { diff --git a/pd-vm-nostd/tests/call_script_tests.rs b/pd-vm-nostd/tests/call_script_tests.rs index b342cbe9..de3c3c44 100644 --- a/pd-vm-nostd/tests/call_script_tests.rs +++ b/pd-vm-nostd/tests/call_script_tests.rs @@ -390,3 +390,287 @@ fn call_script_binding_outside_frame_fails_typed() { "expected InvalidFrameState for the out-of-frame binding, got {err:?}" ); } + +#[test] +fn nostd_executes_empty_cleanup_try_ok_and_err_without_source() { + let ok_source = r#" + fn pass() -> Result { Result::Ok(7) } + fn relay() -> Result { + let x: int = pass()?; + Result::Ok(x + 1) + } + let value = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + value; + "#; + let err_source = r#" + fn fail() -> Result { Result::Err(4) } + fn relay() -> Result { + let x: int = fail()?; + Result::Ok(x) + } + let value = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + value; + "#; + for (source, expected) in [(ok_source, 8), (err_source, 4)] { + let compiled = compile_source(source).expect("empty-cleanup try should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("empty-cleanup try should encode"); + let decoded = decode_program(&bytes).expect("no-std should decode an empty cleanup plan"); + let mut vm = EmbeddedVm::new(decoded); + assert_eq!( + vm.run() + .expect("empty-cleanup try should execute without source"), + EmbeddedVmStatus::Halted + ); + assert_eq!(vm.stack(), &[EmbeddedValue::Int(expected)]); + } +} + +#[test] +fn nostd_rejects_nonempty_resource_cleanup_without_executing() { + use std::sync::Arc; + use vm::{ + CompileSourceFileOptions, HostApiBuilder, HostFunctionSchema, HostTypeSchema, + ResourceTypeKey, ResourceTypeSchema, SourceFlavor, compile_source_with_flavor_and_options, + }; + + let key = ResourceTypeKey::new("pr24.closure").expect("resource key"); + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new(key.clone(), "closure resource")); + builder.function(HostFunctionSchema::with_return( + "test::make_local", + Vec::new(), + HostTypeSchema::Resource(key.clone()), + )); + builder.function(HostFunctionSchema::with_return( + "test::make_err_result", + Vec::new(), + HostTypeSchema::Result { + ok: Box::new(HostTypeSchema::Int), + err: Box::new(HostTypeSchema::Resource(key)), + }, + )); + let catalog = Arc::new(builder.build().expect("catalog")); + let source = r#" + fn relay() -> Result> { + let local: resource = test::make_local(); + let value: int = test::make_err_result()?; + Result::Ok(value) + } + let carried = relay(); + carried; + "#; + let compiled = compile_source_with_flavor_and_options( + source, + SourceFlavor::RustScript, + CompileSourceFileOptions::new().with_host_api_catalog(catalog), + ) + .expect("resource try should compile"); + let bytes = encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("resource try should encode"); + let err = decode_program(&bytes).expect_err("no-std must reject a nonempty resource cleanup"); + assert!( + matches!( + err, + WireError::UnsupportedCapability("ret cleanup resource plans") + ), + "expected an explicit capability rejection before execution, got {err:?}" + ); +} + +fn resource_cleanup_bytes() -> Vec { + use std::sync::Arc; + use vm::{ + CompileSourceFileOptions, HostApiBuilder, HostFunctionSchema, HostTypeSchema, + ResourceTypeKey, ResourceTypeSchema, SourceFlavor, compile_source_with_flavor_and_options, + }; + let key = ResourceTypeKey::new("pr24.closure").expect("resource key"); + let mut builder = HostApiBuilder::new(); + builder.resource(ResourceTypeSchema::new(key.clone(), "closure resource")); + builder.function(HostFunctionSchema::with_return( + "test::make_local", + Vec::new(), + HostTypeSchema::Resource(key.clone()), + )); + builder.function(HostFunctionSchema::with_return( + "test::make_err_result", + Vec::new(), + HostTypeSchema::Result { + ok: Box::new(HostTypeSchema::Int), + err: Box::new(HostTypeSchema::Resource(key)), + }, + )); + let catalog = Arc::new(builder.build().expect("catalog")); + let source = r#" + fn relay() -> Result> { + let local: resource = test::make_local(); + let value: int = test::make_err_result()?; + Result::Ok(value) + } + let carried = relay(); + carried; + "#; + let compiled = compile_source_with_flavor_and_options( + source, + SourceFlavor::RustScript, + CompileSourceFileOptions::new().with_host_api_catalog(catalog), + ) + .expect("resource try should compile"); + encode_program(&compiled.program.with_local_count(compiled.locals)).expect("encode") +} + +fn u32_le(bytes: &[u8], at: usize) -> u32 { + u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) +} + +fn replace_cleanup(bytes: &[u8], decls: &[u8], schema: &[u8]) -> Vec { + let key = b"pr24.closure"; + let mut suffix = vec![17u8]; + suffix.extend_from_slice(&(key.len() as u32).to_le_bytes()); + suffix.extend_from_slice(key); + assert!( + bytes.ends_with(&suffix), + "compiled cleanup should end in the resource schema, tail {:02x?}", + &bytes[bytes.len().saturating_sub(48)..] + ); + let schema_at = bytes.len() - suffix.len(); + let slot_at = schema_at - 2; + let locals_at = slot_at - 4; + let site_at = locals_at - 4; + let plans_at = site_at - 4; + let decls_at = plans_at - 4; + assert_eq!( + u32_le(bytes, decls_at), + 0, + "fixture should have no named decls" + ); + assert_eq!(u32_le(bytes, plans_at), 1); + assert_eq!(u32_le(bytes, locals_at), 1); + let site = &bytes[site_at..site_at + 4]; + let slot = &bytes[slot_at..schema_at]; + let mut out = bytes[..decls_at].to_vec(); + out.extend_from_slice(decls); + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(site); + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(slot); + out.extend_from_slice(schema); + out +} + +fn named_decl(name: &str, params: &[&str], body: &[u8]) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&1u32.to_le_bytes()); + out.extend_from_slice(&(name.len() as u32).to_le_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(&(params.len() as u32).to_le_bytes()); + for param in params { + out.extend_from_slice(&(param.len() as u32).to_le_bytes()); + out.extend_from_slice(param.as_bytes()); + } + out.extend_from_slice(body); + out +} + +fn schema_resource() -> Vec { + let key = b"pr24.closure"; + let mut out = vec![17u8]; + out.extend_from_slice(&(key.len() as u32).to_le_bytes()); + out.extend_from_slice(key); + out +} + +fn schema_named(name: &str, args: &[Vec]) -> Vec { + let mut out = vec![9u8]; + out.extend_from_slice(&(name.len() as u32).to_le_bytes()); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(&(args.len() as u32).to_le_bytes()); + for arg in args { + out.extend_from_slice(arg); + } + out +} + +fn schema_map(inner: &[u8]) -> Vec { + let mut out = vec![13u8]; + out.extend_from_slice(inner); + out +} + +fn schema_result(ok: &[u8], err: &[u8]) -> Vec { + let mut out = vec![18u8]; + out.extend_from_slice(ok); + out.extend_from_slice(err); + out +} + +#[test] +fn nostd_named_validity_precedes_nonempty_capability_refusal() { + let base = resource_cleanup_bytes(); + let body = schema_resource(); + let named = replace_cleanup( + &base, + &named_decl("Holder", &[], &body), + &schema_named("Holder", &[]), + ); + assert_eq!( + decode_program(&named), + Err(WireError::UnsupportedCapability( + "ret cleanup resource plans" + )), + "a valid nongeneric resource body is a nonempty plan, not a missing resource" + ); + let map = replace_cleanup(&base, &0u32.to_le_bytes(), &schema_map(&body)); + assert_eq!( + decode_program(&map), + Err(WireError::UnsupportedCapability( + "ret cleanup resource plans" + )) + ); + let unresolved = replace_cleanup( + &base, + &0u32.to_le_bytes(), + &schema_result(&body, &schema_named("Missing", &[])), + ); + assert_eq!( + decode_program(&unresolved), + Err(WireError::InvalidRetCleanup( + "unresolved named cleanup schema" + )) + ); + let unresolved_ok = replace_cleanup( + &base, + &0u32.to_le_bytes(), + &schema_result(&schema_named("Missing", &[]), &body), + ); + assert_eq!( + decode_program(&unresolved_ok), + Err(WireError::InvalidRetCleanup( + "unresolved named cleanup schema" + )) + ); + let arity = replace_cleanup( + &base, + &named_decl("Holder", &["T"], &schema_resource()), + &schema_named("Holder", &[]), + ); + assert_eq!( + decode_program(&arity), + Err(WireError::InvalidRetCleanup("named cleanup schema arity")) + ); + let cycle = replace_cleanup( + &base, + &named_decl("Holder", &[], &schema_named("Holder", &[])), + &schema_named("Holder", &[]), + ); + assert_eq!( + decode_program(&cycle), + Err(WireError::InvalidRetCleanup("named cleanup schema cycle")) + ); +} diff --git a/pd-vm-nostd/tests/embedded_vmbc.rs b/pd-vm-nostd/tests/embedded_vmbc.rs index 183d501c..bd6eb5b4 100644 --- a/pd-vm-nostd/tests/embedded_vmbc.rs +++ b/pd-vm-nostd/tests/embedded_vmbc.rs @@ -152,8 +152,8 @@ fn embedded_decoder_rejects_legacy_v11_without_schema_markers() { vec![OpCode::Ldc as u8, 0, 0, 0, 0, OpCode::Ret as u8], ); let mut bytes = encode_program(&program).expect("legacy fixture should encode"); - assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]); - bytes.truncate(bytes.len() - 4); + assert_eq!(&bytes[bytes.len() - 8..], &[0, 0, 0, 0, 0, 0, 0, 0]); + bytes.truncate(bytes.len() - 8); bytes[4..6].copy_from_slice(&11u16.to_le_bytes()); assert!(matches!( @@ -544,6 +544,8 @@ fn v12_with_named_host_return_schema(schema: &[u8]) -> Vec { bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.extend_from_slice(&0u32.to_le_bytes()); + // Mandatory current-v13 ret-cleanup section: empty plan count after named structs. + bytes.extend_from_slice(&0u32.to_le_bytes()); bytes } @@ -651,8 +653,8 @@ fn embedded_decoder_reads_v13_guest_named_struct_payload() { fn embedded_decoder_rejects_duplicate_named_struct_generic_params() { let mut bytes = encode_program(&Program::new(Vec::new(), vec![OpCode::Ret as u8])) .expect("empty program should encode"); - assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]); - bytes.truncate(bytes.len() - 4); + assert_eq!(&bytes[bytes.len() - 8..], &[0, 0, 0, 0, 0, 0, 0, 0]); + bytes.truncate(bytes.len() - 8); bytes.extend_from_slice(&1u32.to_le_bytes()); append_wire_string(&mut bytes, "Holder"); bytes.extend_from_slice(&2u32.to_le_bytes()); @@ -660,6 +662,7 @@ fn embedded_decoder_rejects_duplicate_named_struct_generic_params() { append_wire_string(&mut bytes, "T"); bytes.push(14); bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); assert_eq!(decode_program(&bytes), Err(WireError::InvalidValueType(0))); } @@ -667,8 +670,8 @@ fn embedded_decoder_rejects_duplicate_named_struct_generic_params() { fn embedded_decoder_rejects_duplicate_named_struct_names() { let mut bytes = encode_program(&Program::new(Vec::new(), vec![OpCode::Ret as u8])) .expect("empty program should encode"); - assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]); - bytes.truncate(bytes.len() - 4); + assert_eq!(&bytes[bytes.len() - 8..], &[0, 0, 0, 0, 0, 0, 0, 0]); + bytes.truncate(bytes.len() - 8); bytes.extend_from_slice(&2u32.to_le_bytes()); append_wire_string(&mut bytes, "Dup"); bytes.extend_from_slice(&0u32.to_le_bytes()); @@ -678,6 +681,7 @@ fn embedded_decoder_rejects_duplicate_named_struct_names() { bytes.extend_from_slice(&0u32.to_le_bytes()); bytes.push(14); bytes.extend_from_slice(&0u32.to_le_bytes()); + bytes.extend_from_slice(&0u32.to_le_bytes()); assert_eq!(decode_program(&bytes), Err(WireError::InvalidValueType(0))); } @@ -685,8 +689,8 @@ fn embedded_decoder_rejects_duplicate_named_struct_names() { fn embedded_decoder_rejects_v12_trailing_zero_named_struct_garbage() { let mut bytes = encode_program(&Program::new(Vec::new(), vec![OpCode::Ret as u8])) .expect("empty program should encode"); - assert_eq!(&bytes[bytes.len() - 4..], &[0, 0, 0, 0]); - bytes.truncate(bytes.len() - 4); + assert_eq!(&bytes[bytes.len() - 8..], &[0, 0, 0, 0, 0, 0, 0, 0]); + bytes.truncate(bytes.len() - 8); bytes[4..6].copy_from_slice(&12u16.to_le_bytes()); assert_eq!( decode_program(&bytes), diff --git a/src/bytecode.rs b/src/bytecode.rs index f5d6e179..492c3b5d 100644 --- a/src/bytecode.rs +++ b/src/bytecode.rs @@ -617,6 +617,20 @@ impl DecodedInstructionData { } } +/// One owned local closed when a specific Err `Ret` runs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RetCleanupLocal { + pub slot: u16, + pub schema: crate::compiler::TypeSchema, +} + +/// Cleanup metadata for one Err-path `Ret` opcode site. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RetCleanupPlan { + pub site: u32, + pub locals: Vec, +} + /// Hard upper bound for a single interpreter frame's local slots. /// /// This is deliberately below the wire-format count ceiling: metadata may be @@ -651,6 +665,9 @@ pub struct Program { pub function_regions: Vec, pub root_callable_bindings: Vec, pub exported_callables: Vec, + /// Err-path `Ret` sites and the owned locals to close before frame drain. + /// Empty for programs that do not use `?`. + pub(crate) ret_cleanups: Vec, #[allow(dead_code)] decoded_instruction_data_cache: Arc>>, operand_type_hints_cache: Arc>>>, @@ -673,6 +690,7 @@ impl Program { function_regions: Vec::new(), root_callable_bindings: Vec::new(), exported_callables: Vec::new(), + ret_cleanups: Vec::new(), decoded_instruction_data_cache: Arc::new(OnceLock::new()), operand_type_hints_cache: Arc::new(OnceLock::new()), } @@ -698,6 +716,7 @@ impl Program { function_regions: Vec::new(), root_callable_bindings: Vec::new(), exported_callables: Vec::new(), + ret_cleanups: Vec::new(), decoded_instruction_data_cache: Arc::new(OnceLock::new()), operand_type_hints_cache: Arc::new(OnceLock::new()), } @@ -724,6 +743,7 @@ impl Program { function_regions: Vec::new(), root_callable_bindings: Vec::new(), exported_callables: Vec::new(), + ret_cleanups: Vec::new(), decoded_instruction_data_cache: Arc::new(OnceLock::new()), operand_type_hints_cache: Arc::new(OnceLock::new()), } diff --git a/src/compiler/codegen.rs b/src/compiler/codegen.rs index b7f0b9ce..4280a3eb 100644 --- a/src/compiler/codegen.rs +++ b/src/compiler/codegen.rs @@ -65,6 +65,11 @@ pub struct Compiler { pending_closures: Vec<(u32, ClosureExpr)>, callable_prototype_bindings: HashMap, closure_param_hints: HashMap)>>, + /// True while emitting a named-function or closure body. `?` may `Ret` + /// only from that lexical frame, never from root code. + in_callable: bool, + /// Err-path `Ret` sites and the owned locals to close before the frame drain. + ret_cleanups: Vec, } struct LoopContext { @@ -123,6 +128,8 @@ impl Compiler { pending_closures: Vec::new(), callable_prototype_bindings: HashMap::new(), closure_param_hints: HashMap::new(), + in_callable: false, + ret_cleanups: Vec::new(), } } @@ -320,6 +327,7 @@ impl Compiler { program.root_callable_bindings = self.root_callable_bindings; program.exported_callables = exported_callables; program.imports = self.host_imports; + program.ret_cleanups = std::mem::take(&mut self.ret_cleanups); program = program.with_optional_host_import_schemas(self.host_import_schemas); let guest_struct_decls = self .struct_schemas @@ -488,6 +496,8 @@ impl Compiler { let callable_snapshot = self.callable_bindings.clone(); let type_snapshot = self.type_state.clone(); let loop_snapshot = std::mem::take(&mut self.loop_stack); + let callable_flag = self.in_callable; + self.in_callable = true; self.seed_frame_type_state_from_type_map(); if let Some(decl) = self.function_decls.get(&function_index) { for (slot, schema) in function_impl.param_slots.iter().zip(&decl.arg_schemas) { @@ -510,6 +520,7 @@ impl Compiler { self.compile_stmts(&function_impl.body_stmts)?; self.compile_expr(&function_impl.body_expr)?; self.assembler.ret(); + self.in_callable = callable_flag; self.loop_stack = loop_snapshot; self.callable_bindings = callable_snapshot; self.type_state = type_snapshot; @@ -540,6 +551,8 @@ impl Compiler { let callable_snapshot = self.callable_bindings.clone(); let type_snapshot = self.type_state.clone(); let loop_snapshot = std::mem::take(&mut self.loop_stack); + let callable_flag = self.in_callable; + self.in_callable = true; self.seed_frame_type_state_from_type_map(); if let Some(hints) = self.closure_param_hints.get(&prototype_id).cloned() { for (slot, (ty, schema)) in closure.param_slots.iter().zip(hints) { @@ -549,6 +562,7 @@ impl Compiler { } self.compile_expr(&closure.body)?; self.assembler.ret(); + self.in_callable = callable_flag; self.loop_stack = loop_snapshot; self.callable_bindings = callable_snapshot; self.type_state = type_snapshot; @@ -1155,6 +1169,14 @@ impl Compiler { .label(&end_label) .map_err(CompileError::Assembler)?; } + Expr::ResultTry { + value, + value_slot, + payload_slot, + exit_cleanup, + span, + .. + } => self.compile_result_try(value, *value_slot, *payload_slot, exit_cleanup, *span)?, } Ok(()) } @@ -1198,6 +1220,83 @@ impl Compiler { Ok(()) } + fn emit_err_ret( + &mut self, + slots: &[(LocalSlot, crate::compiler::TypeSchema)], + ) -> Result<(), CompileError> { + let site = self.assembler.position(); + let mut locals = Vec::new(); + for (slot, stored) in slots { + let schema = if stored.contains_resource_with_named_types(&self.struct_schemas) { + stored.clone() + } else { + self.type_map + .local_schemas + .get(*slot as usize) + .and_then(|schema| schema.clone()) + .filter(|schema| { + schema.contains_resource_with_named_types(&self.struct_schemas) + }) + .ok_or_else(|| CompileError::StrictTypingRequired { + line: None, + source_name: None, + detail: format!("owned local {slot} has no schema for Err cleanup"), + span: None, + })? + }; + locals.push(crate::RetCleanupLocal { + slot: *slot, + schema, + }); + } + if !locals.is_empty() { + self.ret_cleanups + .push(crate::RetCleanupPlan { site, locals }); + } + self.assembler.ret(); + Ok(()) + } + + fn compile_result_try( + &mut self, + value: &Expr, + value_slot: LocalSlot, + payload_slot: LocalSlot, + exit_cleanup: &[(LocalSlot, crate::compiler::TypeSchema)], + span: crate::compiler::source_map::Span, + ) -> Result<(), CompileError> { + if !self.in_callable { + return Err(CompileError::StrictTypingRequired { + line: None, + source_name: None, + detail: "? requires the enclosing function to return Result".to_string(), + span: Some(span), + }); + } + self.compile_expr(value)?; + self.emit_stloc(value_slot)?; + self.emit_copy_ldloc(value_slot)?; + self.assembler + .push_const(Value::string(super::result::RESULT_TAG_KEY)); + self.assembler.call(BuiltinFunction::Get.call_index(), 2); + let err_label = self.fresh_label("result_try_err"); + let ok_label = self.fresh_label("result_try_ok"); + self.record_unary_operand_type(ValueType::Bool); + self.assembler.brfalse_label(&err_label); + self.compile_result_binding(value_slot, Some(payload_slot))?; + self.emit_move_ldloc(payload_slot)?; + self.assembler.br_label(&ok_label); + self.assembler + .label(&err_label) + .map_err(CompileError::Assembler)?; + self.emit_move_ldloc(value_slot)?; + self.emit_err_ret(exit_cleanup)?; + self.assembler + .label(&ok_label) + .map_err(CompileError::Assembler)?; + Ok(()) + } + fn compile_optional_get_expr( &mut self, container: &Expr, @@ -1849,7 +1948,15 @@ impl Compiler { let return_type = self .function_decls .get(&index) - .map(|decl| decl.return_type) + .map(|decl| { + // A coarse Optional payload type is not a definite + // physical return type: native resume must retain Null. + if matches!(decl.return_schema, Some(TypeSchema::Optional(_))) { + ValueType::Unknown + } else { + decl.return_type + } + }) .unwrap_or(ValueType::Unknown); for arg in args { self.compile_scalar_expr(arg)?; @@ -1868,7 +1975,15 @@ impl Compiler { let return_type = self .function_decls .get(&index) - .map(|decl| decl.return_type) + .map(|decl| { + // A coarse Optional payload type is not a definite + // physical return type: native resume must retain Null. + if matches!(decl.return_schema, Some(TypeSchema::Optional(_))) { + ValueType::Unknown + } else { + decl.return_type + } + }) .unwrap_or(ValueType::Unknown); self.emit_copy_ldloc(slot)?; return self.compile_callvalue_args(args, return_type); @@ -2268,7 +2383,13 @@ impl Compiler { .get(slot as usize) .and_then(|schema| schema.as_ref()) { - Some(TypeSchema::Callable { result, .. }) => result.coarse_value_type(), + Some(TypeSchema::Callable { result, .. }) => { + if matches!(result.as_ref(), TypeSchema::Optional(_)) { + ValueType::Unknown + } else { + result.coarse_value_type() + } + } _ => ValueType::Unknown, } } diff --git a/src/compiler/frontends/rustscript.rs b/src/compiler/frontends/rustscript.rs index 55d00d0c..19ff581e 100644 --- a/src/compiler/frontends/rustscript.rs +++ b/src/compiler/frontends/rustscript.rs @@ -28,6 +28,10 @@ impl ParserDialect for RustScriptDialect { fn allow_result_syntax(&self) -> bool { true } + + fn allow_result_try(&self) -> bool { + true + } } static RUSTSCRIPT_DIALECT: RustScriptDialect = RustScriptDialect; diff --git a/src/compiler/ir.rs b/src/compiler/ir.rs index dd26afc2..d84da6d1 100644 --- a/src/compiler/ir.rs +++ b/src/compiler/ir.rs @@ -325,12 +325,7 @@ impl TypeSchema { TypeSchema::Object(fields) => fields .values() .any(|value| visit(value, structs, active, reentries)), - TypeSchema::Callable { params, result } => { - params - .iter() - .any(|param| visit(param, structs, active, reentries)) - || visit(result, structs, active, reentries) - } + TypeSchema::Callable { .. } => false, TypeSchema::Result { ok, err } => { visit(ok, structs, active, reentries) || visit(err, structs, active, reentries) } @@ -714,6 +709,21 @@ pub enum Expr { span: Span, semantic_id: Option, }, + /// Postfix `?`. Evaluates `value` once. Ok continues as `T`; Err returns + /// the active carrier from the lexical function through the existing `Ret`. + ResultTry { + value: Box, + value_slot: LocalSlot, + payload_slot: LocalSlot, + operand_schema: TypeSchema, + return_schema: TypeSchema, + /// Owned locals that are available when this `?` takes the Err path. + /// The schema is the pre-compaction type, so cleanup does not depend on + /// the post-compaction type map having the same index. + exit_cleanup: Vec<(LocalSlot, TypeSchema)>, + span: Span, + semantic_id: Option, + }, } impl Expr { @@ -731,6 +741,7 @@ impl Expr { visit(ok_expr); visit(err_expr); } + Expr::ResultTry { value, .. } => visit(value), _ => {} } } @@ -1462,7 +1473,8 @@ fn collect_resolved_calls_in_expr( } other @ (Expr::ResultConstruct { .. } | Expr::ResultQuery { .. } - | Expr::ResultMatch { .. }) => { + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { other.visit_result_children(|child| { collect_resolved_calls_in_expr(child, by_id, typed, slot_schemas); }); diff --git a/src/compiler/lifetime/availability.rs b/src/compiler/lifetime/availability.rs index 748656ac..bc282c1c 100644 --- a/src/compiler/lifetime/availability.rs +++ b/src/compiler/lifetime/availability.rs @@ -7,7 +7,7 @@ use crate::host_api::HostParamPassing; use super::super::ParseError; use super::super::ir::{ - ClosureExpr, Expr, FrontendIr, FunctionImpl, LocalSlot, ResolvedHostCall, Stmt, + ClosureExpr, Expr, FrontendIr, FunctionImpl, LocalSlot, ResolvedHostCall, Stmt, TypeSchema, }; use super::EntryLocalAvailability; use super::liveness::{LivenessRewriter, LocalSlotAllocator, persistent_capture_slots}; @@ -34,6 +34,10 @@ struct FlowState { moved_definite: HashSet, moved_possible: HashSet, copyable_fields: HashSet, + /// Current-invocation owners. A resource schema bit alone is not ownership: + /// borrowed parameters and persistent captures stay false until an explicit + /// move/detach creates a fresh current-owned local. + current_owned: Vec, } #[derive(Default)] @@ -78,6 +82,7 @@ impl FlowState { moved_definite: HashSet::new(), moved_possible: HashSet::new(), copyable_fields: HashSet::new(), + current_owned: vec![false; local_count], } } @@ -97,6 +102,9 @@ impl FlowState { state.movable_locals[slot] = entry.movable; state.moved_local_definite[slot] = entry.moved; state.moved_local_possible[slot] = entry.moved; + // Entry seeds are caller-owned until this invocation explicitly + // detaches them. Do not infer ownership from the schema bit. + state.current_owned[slot] = false; } state } @@ -174,7 +182,7 @@ pub(super) fn enforce_source_availability( &owned_slots, ); let rewritten = analyzer - .analyze_function_impl(function_impl.clone()) + .analyze_function_impl(*index, function_impl.clone()) .map_err(|error| { attach_owner(error, ir.function_sources.get(index).map(String::as_str)) })?; @@ -350,6 +358,7 @@ impl AvailabilityAnalyzer { fn analyze_function_impl( &self, + function_index: u16, function_impl: FunctionImpl, ) -> Result { let FunctionImpl { @@ -360,18 +369,31 @@ impl AvailabilityAnalyzer { body_expr_line, } = function_impl; let mut state = FlowState::reachable(self.local_count); - for slot in ¶m_slots { + let consumed = self.function_consumed_params.get(&function_index); + for (position, slot) in param_slots.iter().enumerate() { self.mark_available(&mut state, *slot, 1)?; // Resource-typed parameters are move-only inside the body: they // can be returned (moved out), passed by ownership, or borrowed // through exact host-call arguments, but never copied. + // Ownership for exit cleanup follows the existing consumed-param + // contract, not the schema bit. Borrowed params stay caller-owned. if self.is_owned_slot(*slot) { state.copyable_locals[*slot as usize] = false; state.movable_locals[*slot as usize] = true; + if consumed.is_some_and(|positions| positions.contains(&position)) { + state.current_owned[*slot as usize] = true; + } } } for (_, captured_slot) in &capture_copies { self.mark_available(&mut state, *captured_slot, 1)?; + // Persistent environment captures are not this invocation's + // owners, even when the environment was created by Move. + if self.is_owned_slot(*captured_slot) { + state.copyable_locals[*captured_slot as usize] = false; + state.movable_locals[*captured_slot as usize] = true; + state.current_owned[*captured_slot as usize] = false; + } } let (rewritten_body, body_state) = self.analyze_block(&body_stmts, state, true)?; let rewritten_body_expr = self.rewrite_function_return_expr(&body_expr, &body_state)?; @@ -536,6 +558,7 @@ impl AvailabilityAnalyzer { }; self.set_local_copyable_state(&mut out, *index, is_copyable); self.set_local_movable_state(&mut out, *index, is_movable); + self.note_rebind_ownership(&mut out, *index, expr); rewritten_expr = self.rewrite_local_source_move_on_rebind(&mut out, *index, expr); rewritten_expr = self.rewrite_expr_for_ownership_with_state( @@ -579,6 +602,7 @@ impl AvailabilityAnalyzer { }; self.set_local_copyable_state(&mut out, *index, is_copyable); self.set_local_movable_state(&mut out, *index, is_movable); + self.note_rebind_ownership(&mut out, *index, expr); rewritten_expr = self.rewrite_local_source_move_on_rebind(&mut out, *index, expr); rewritten_expr = @@ -639,7 +663,8 @@ impl AvailabilityAnalyzer { else_branch, line, } => { - let cond_state = self.analyze_expr(condition, &state, *line)?; + let (condition, cond_state) = + self.rewrite_expr_and_state(condition, false, &state, *line)?; let (rewritten_then, then_state, rewritten_else, else_state) = if let Some(control) = loop_control { let (rewritten_then, then_state) = self.analyze_block_with_loop_control( @@ -665,7 +690,7 @@ impl AvailabilityAnalyzer { let merged = self.merge_states(then_state, else_state); let rewritten = Stmt::IfElse { - condition: condition.clone(), + condition, then_branch: rewritten_then, else_branch: rewritten_else, line: *line, @@ -755,7 +780,8 @@ impl AvailabilityAnalyzer { } self.next_collection_alias_id.set(alias_seed); - let cond_state = self.analyze_expr(condition, &loop_entry, line)?; + let (condition, cond_state) = + self.rewrite_expr_and_state(condition, false, &loop_entry, line)?; let mut loop_control = LoopControlFlow::default(); let (rewritten_body, _) = self.analyze_block_with_loop_control( body, @@ -770,7 +796,7 @@ impl AvailabilityAnalyzer { }; let rewritten = Stmt::While { - condition: condition.clone(), + condition, body: rewritten_body, line, }; @@ -817,7 +843,8 @@ impl AvailabilityAnalyzer { } self.next_collection_alias_id.set(alias_seed); - let cond_state = self.analyze_expr(condition, &loop_entry, line)?; + let (condition, cond_state) = + self.rewrite_expr_and_state(condition, false, &loop_entry, line)?; let mut loop_control = LoopControlFlow::default(); let (rewritten_body, body_state) = self.analyze_block_with_loop_control( body, @@ -838,7 +865,7 @@ impl AvailabilityAnalyzer { let rewritten = Stmt::For { init: Box::new(rewritten_init), - condition: condition.clone(), + condition, post: Box::new(rewritten_post), body: rewritten_body, line, @@ -907,10 +934,9 @@ impl AvailabilityAnalyzer { let mut value_state = self.analyze_expr(value, state, line)?; self.mark_owned_value_reads_moved(value, &mut value_state); self.mark_available(&mut value_state, *value_slot, line)?; - let then_state = self.analyze_expr(fallback, &value_state, line)?; - let mut out = self.merge_states(then_state, value_state); - self.mark_owned_value_reads_moved(fallback, &mut out); - Ok(out) + let mut fallback_state = self.analyze_expr(fallback, &value_state, line)?; + self.mark_owned_value_reads_moved(fallback, &mut fallback_state); + Ok(self.merge_states(fallback_state, value_state)) } // Resolved module calls (pre-merge only) analyze their arguments; // interprocedural effects apply to the post-merge flat call. @@ -1018,14 +1044,18 @@ impl AvailabilityAnalyzer { | Expr::Mul(lhs, rhs) | Expr::Div(lhs, rhs) | Expr::Mod(lhs, rhs) - | Expr::And(lhs, rhs) - | Expr::Or(lhs, rhs) | Expr::Eq(lhs, rhs) | Expr::Lt(lhs, rhs) | Expr::Gt(lhs, rhs) => { let lhs_state = self.analyze_expr(lhs, state, line)?; self.analyze_expr(rhs, &lhs_state, line) } + Expr::And(lhs, rhs) | Expr::Or(lhs, rhs) => { + let lhs_state = self.analyze_expr(lhs, state, line)?; + let rhs_state = self.analyze_expr(rhs, &lhs_state, line)?; + // The RHS transfers apply only on its executed edge. + Ok(self.merge_states(lhs_state, rhs_state)) + } Expr::Neg(inner) | Expr::Not(inner) => self.analyze_expr(inner, state, line), Expr::Borrow(inner) | Expr::BorrowMut(inner) => { // Outside an exact host-call argument a borrow wrapper is an @@ -1104,6 +1134,14 @@ impl AvailabilityAnalyzer { Ok(out) } Expr::ResultQuery { value, .. } => self.analyze_expr_to_owned(value, state, line), + Expr::ResultTry { + value, value_slot, .. + } => { + let mut out = self.analyze_expr(value, state, line)?; + self.mark_owned_value_reads_moved(value, &mut out); + self.mark_available(&mut out, *value_slot, line)?; + Ok(out) + } Expr::ResultMatch { value, value_slot, @@ -1113,18 +1151,15 @@ impl AvailabilityAnalyzer { err_expr, .. } => { - let mut value_state = self.analyze_expr(value, state, line)?; - self.mark_owned_value_reads_moved(value, &mut value_state); - self.mark_available(&mut value_state, *value_slot, line)?; - let mut ok_in = value_state.clone(); - if let Some(slot) = ok_binding { - self.mark_available(&mut ok_in, *slot, line)?; - } + let (ok_in, err_in) = self.result_match_arm_states( + value, + *value_slot, + *ok_binding, + *err_binding, + state, + line, + )?; let ok_state = self.analyze_expr(ok_expr, &ok_in, line)?; - let mut err_in = value_state; - if let Some(slot) = err_binding { - self.mark_available(&mut err_in, *slot, line)?; - } let err_state = self.analyze_expr(err_expr, &err_in, line)?; let mut out = self.merge_states(ok_state, err_state); self.mark_owned_value_reads_moved(ok_expr, &mut out); @@ -1234,6 +1269,89 @@ impl AvailabilityAnalyzer { self.analyze_expr(inner, state, line) } + /// Whether this slot is a current-invocation owner that Err cleanup may close. + /// The resource schema bit is necessary but not sufficient. + fn is_current_cleanup_owner(&self, slot: LocalSlot, state: &FlowState) -> bool { + self.is_owned_slot(slot) + && state + .current_owned + .get(slot as usize) + .copied() + .unwrap_or(false) + } + + /// A rebind establishes current ownership when it detaches an owned value + /// into this local or materializes a fresh owned result. A bare read of a + /// caller-owned local does not. + fn note_rebind_ownership(&self, state: &mut FlowState, target: LocalSlot, expr: &Expr) { + let slot = target as usize; + if slot >= self.local_count { + return; + } + state.current_owned[slot] = + self.is_owned_slot(target) && self.expr_establishes_current_ownership(expr, state); + } + + fn expr_establishes_current_ownership(&self, expr: &Expr, state: &FlowState) -> bool { + match expr { + Expr::Var(slot) | Expr::MoveVar(slot) => { + state + .current_owned + .get(*slot as usize) + .copied() + .unwrap_or(false) + || self.should_move_local_on_rebind_source(*slot, state) + } + Expr::Block { expr, .. } => self.expr_establishes_current_ownership(expr, state), + Expr::Borrow(_) | Expr::BorrowMut(_) => false, + _ => true, + } + } + + /// Arm entry states shared by analysis and ownership rewrite. Payload + /// bindings receive the same current-ownership fact in both. + fn result_match_arm_states( + &self, + value: &Expr, + value_slot: LocalSlot, + ok_binding: Option, + err_binding: Option, + state: &FlowState, + line: u32, + ) -> Result<(FlowState, FlowState), ParseError> { + let mut value_state = self.analyze_expr(value, state, line)?; + self.mark_owned_value_reads_moved(value, &mut value_state); + self.mark_available(&mut value_state, value_slot, line)?; + let mut ok_in = value_state.clone(); + if let Some(slot) = ok_binding { + self.mark_available(&mut ok_in, slot, line)?; + self.note_result_payload_owner(&mut ok_in, slot, value); + } + let mut err_in = value_state; + if let Some(slot) = err_binding { + self.mark_available(&mut err_in, slot, line)?; + self.note_result_payload_owner(&mut err_in, slot, value); + } + Ok((ok_in, err_in)) + } + + fn note_result_payload_owner( + &self, + state: &mut FlowState, + binding: LocalSlot, + scrutinee: &Expr, + ) { + let index = binding as usize; + if index >= self.local_count { + return; + } + // This binding's post-legalize projection already resolves Named + // bodies. A declaration-blind schema walk would lose non-generic + // resource holders. Runtime ownership still comes from the scrutinee. + state.current_owned[index] = self.is_owned_slot(binding) + && self.expr_establishes_current_ownership(scrutinee, state); + } + /// Whether a logical local slot carries a resource anywhere in its /// post-legalize schema (direct or nested). fn is_owned_slot(&self, index: LocalSlot) -> bool { @@ -1243,6 +1361,49 @@ impl AvailabilityAnalyzer { .unwrap_or(false) } + /// Frame-owned locals that may still be present when `?` takes the Err + /// return. The try's own operand temps are excluded so the returned Err + /// carrier is not closed; legitimate per-edge absence is Optional. + fn err_exit_cleanup_slots( + &self, + state: &FlowState, + exclude: &[LocalSlot], + ) -> Vec<(LocalSlot, TypeSchema)> { + let mut slots = Vec::new(); + if !state.reachable { + return slots; + } + for slot in 0..self.local_count { + let slot = slot as LocalSlot; + if exclude.contains(&slot) || !self.is_current_cleanup_owner(slot, state) { + continue; + } + let index = slot as usize; + if !state.possible.get(index).copied().unwrap_or(false) { + continue; + } + if state + .moved_local_definite + .get(index) + .copied() + .unwrap_or(false) + { + continue; + } + // Whole-slot MoveVar detaches to Null. Preserve provenance across + // the join, permitting absence only on this Err edge when a + // predecessor moved this owner or never initialized a branch-local + // owner. Schema attachment preserves this per-edge Optional view. + let schema = if state.moved_local_possible[index] || !state.definite[index] { + TypeSchema::Optional(Box::new(TypeSchema::Unknown)) + } else { + TypeSchema::Unknown + }; + slots.push((slot, schema)); + } + slots + } + /// Analyzes the arguments of a catalog-resolved host call against its /// exact ordered passing modes. /// @@ -1261,28 +1422,32 @@ impl AvailabilityAnalyzer { ) -> Result { let mut out = state.clone(); for (position, arg) in args.iter().enumerate() { - out = match resolution.passing.get(position).copied() { - Some(HostParamPassing::TakeOwned) => { - self.legalize_take_owned_arg(arg, &out, line)? - } - Some(HostParamPassing::Borrow) | Some(HostParamPassing::BorrowMut) => { - // The parser wraps borrowed arguments in Borrow/BorrowMut; - // unwrap them here into a non-consuming read so the - // generic borrow arm (which rejects resource escapes) - // never sees them. - match arg { - Expr::Borrow(inner) | Expr::BorrowMut(inner) => { - self.analyze_expr_to_owned(inner, &out, line)? - } - other => self.analyze_expr_to_owned(other, &out, line)?, - } - } - _ => self.analyze_expr(arg, &out, line)?, - }; + out = + self.analyze_call_arg(arg, resolution.passing.get(position).copied(), &out, line)?; } Ok(out) } + /// Shared exact passing-mode transfer for analysis and lexical rewrite. + fn analyze_call_arg( + &self, + arg: &Expr, + passing: Option, + state: &FlowState, + line: u32, + ) -> Result { + match passing { + Some(HostParamPassing::TakeOwned) => self.legalize_take_owned_arg(arg, state, line), + Some(HostParamPassing::Borrow | HostParamPassing::BorrowMut) => match arg { + Expr::Borrow(inner) | Expr::BorrowMut(inner) => { + self.analyze_expr_to_owned(inner, state, line) + } + other => self.analyze_expr_to_owned(other, state, line), + }, + _ => self.analyze_expr(arg, state, line), + } + } + /// Flow effect of a `TakeOwned` argument: the source local or literal-key /// field is consumed (marked moved). Fresh values (nested call results, /// literals) flow directly into the argument slot and have no local @@ -1515,7 +1680,8 @@ impl AvailabilityAnalyzer { } other @ (Expr::ResultConstruct { .. } | Expr::ResultQuery { .. } - | Expr::ResultMatch { .. }) => { + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { let mut found = false; other.visit_result_children(|child| found |= self.expr_contains_owned_local(child)); found @@ -1639,11 +1805,10 @@ impl AvailabilityAnalyzer { then_expr, else_expr, } => { - let condition_state = self.analyze_expr(condition, state, line)?; + let (condition, condition_state) = + self.rewrite_expr_and_state(condition, false, state, line)?; Ok(Expr::IfElse { - condition: Box::new( - self.rewrite_expr_for_ownership_with_state(condition, state, line)?, - ), + condition: Box::new(condition), then_expr: Box::new(self.rewrite_expr_for_ownership_with_state( then_expr, &condition_state, @@ -1663,10 +1828,10 @@ impl AvailabilityAnalyzer { arms, default, } => { - let mut value_state = self.analyze_expr(value, state, line)?; + let (rewritten_value, mut value_state) = + self.rewrite_expr_and_state(value, false, state, line)?; + self.mark_owned_value_reads_moved(&rewritten_value, &mut value_state); self.mark_available(&mut value_state, *value_slot, line)?; - let rewritten_value = - self.rewrite_expr_for_ownership_with_state(value, state, line)?; let mut rewritten_arms = Vec::with_capacity(arms.len()); for (pattern, arm_expr) in arms { let mut arm_state = value_state.clone(); @@ -1714,6 +1879,25 @@ impl AvailabilityAnalyzer { self.rewrite_expr_ownership_inner(expr, false, state) } + /// Pair emitted ownership syntax with its continuation facts. Replaying + /// transfer rules is metadata work only; no runtime expression is copied. + /// Use the same alias seed for rewrite and analysis, so nested replay does + /// not spend a second set of alias IDs or change fixed-point convergence. + fn rewrite_expr_and_state( + &self, + expr: &Expr, + in_call_arg: bool, + state: &FlowState, + line: u32, + ) -> Result<(Expr, FlowState), ParseError> { + let seed = self.next_collection_alias_id.get(); + let rewritten = self.rewrite_expr_ownership_inner(expr, in_call_arg, state); + self.next_collection_alias_id.set(seed); + let rewritten = rewritten?; + let out = self.analyze_expr(&rewritten, state, line)?; + Ok((rewritten, out)) + } + fn rewrite_expr_ownership_inner( &self, expr: &Expr, @@ -1723,21 +1907,39 @@ impl AvailabilityAnalyzer { match expr { Expr::Call(index, type_args, args, resolution, source_node_id) => { let mut rewritten_args = Vec::with_capacity(args.len()); - for arg in args { - rewritten_args.push(self.rewrite_expr_ownership_inner(arg, true, state)?); - } - if let Some(resolution) = resolution.as_deref() { - for (position, arg) in rewritten_args.iter_mut().enumerate() { - match resolution.passing.get(position).copied() { - Some(HostParamPassing::TakeOwned) => { - *arg = self.rewrite_take_owned_arg(arg)?; - } - Some(HostParamPassing::Borrow) | Some(HostParamPassing::BorrowMut) => { - *arg = self.rewrite_borrow_arg(arg); - } - _ => {} + let mut arg_state = state.clone(); + for (position, arg) in args.iter().enumerate() { + let seed = self.next_collection_alias_id.get(); + let mut arg = self.rewrite_expr_ownership_inner(arg, true, &arg_state)?; + let passing = resolution + .as_deref() + .and_then(|r| r.passing.get(position)) + .copied(); + match passing { + Some(HostParamPassing::TakeOwned) => { + arg = self.rewrite_take_owned_arg(&arg)? + } + Some(HostParamPassing::Borrow | HostParamPassing::BorrowMut) => { + arg = self.rewrite_borrow_arg(&arg) } + _ => {} } + self.next_collection_alias_id.set(seed); + arg_state = if resolution.is_none() + && position == 0 + && matches!( + BuiltinFunction::from_call_index(*index), + Some(BuiltinFunction::Get | BuiltinFunction::Set) + ) { + // A projection may read an available remaining child + // of a partially moved root; it is not a whole read. + self.analyze_projection_root_expr(&arg, &arg_state, 1)? + } else { + self.analyze_call_arg(&arg, passing, &arg_state, 1)? + }; + rewritten_args.push(arg); + } + if let Some(resolution) = resolution.as_deref() { return Ok(Expr::Call( *index, type_args.clone(), @@ -1793,35 +1995,54 @@ impl AvailabilityAnalyzer { container_slot, key_slot, semantic_id, - } => Ok(Expr::OptionalGet { - field_projection: *field_projection, - container: Box::new(self.rewrite_expr_ownership_inner(container, false, state)?), - key: Box::new(self.rewrite_expr_ownership_inner(key, false, state)?), - container_slot: *container_slot, - key_slot: *key_slot, - semantic_id: *semantic_id, - }), + } => { + let (container, mut container_state) = + self.rewrite_expr_and_state(container, false, state, 1)?; + self.mark_owned_value_reads_moved(&container, &mut container_state); + Ok(Expr::OptionalGet { + field_projection: *field_projection, + container: Box::new(container), + key: Box::new(self.rewrite_expr_ownership_inner( + key, + false, + &container_state, + )?), + container_slot: *container_slot, + key_slot: *key_slot, + semantic_id: *semantic_id, + }) + } Expr::OptionUnwrapOr { value, value_slot, fallback, semantic_id, - } => Ok(Expr::OptionUnwrapOr { - value: Box::new(self.rewrite_expr_ownership_inner(value, false, state)?), - value_slot: *value_slot, - fallback: Box::new(self.rewrite_expr_ownership_inner(fallback, false, state)?), - semantic_id: *semantic_id, - }), + } => { + let (value, mut value_state) = + self.rewrite_expr_and_state(value, false, state, 1)?; + self.mark_owned_value_reads_moved(&value, &mut value_state); + self.mark_available(&mut value_state, *value_slot, 1)?; + Ok(Expr::OptionUnwrapOr { + value: Box::new(value), + value_slot: *value_slot, + fallback: Box::new(self.rewrite_expr_ownership_inner( + fallback, + false, + &value_state, + )?), + semantic_id: *semantic_id, + }) + } Expr::LocalCall(index, type_args, args, semantic_id) => Ok(Expr::LocalCall( *index, type_args.clone(), - self.rewrite_call_args(args, state)?, + self.rewrite_call_args(args, state)?.0, *semantic_id, )), Expr::ModuleCall(index, type_args, args, semantic_id) => Ok(Expr::ModuleCall( *index, type_args.clone(), - self.rewrite_call_args(args, state)?, + self.rewrite_call_args(args, state)?.0, *semantic_id, )), Expr::Closure(closure) => { @@ -1837,7 +2058,8 @@ impl AvailabilityAnalyzer { })) } Expr::ClosureCall(closure, args) => { - let closure_state = self.closure_flow_state(closure, state, 0)?; + let (args, arg_state) = self.rewrite_call_args(args, state)?; + let closure_state = self.closure_flow_state(closure, &arg_state, 0)?; Ok(Expr::ClosureCall( ClosureExpr { param_slots: closure.param_slots.clone(), @@ -1848,7 +2070,7 @@ impl AvailabilityAnalyzer { &closure_state, )?), }, - self.rewrite_call_args(args, state)?, + args, )) } Expr::Add(lhs, rhs) @@ -1861,8 +2083,35 @@ impl AvailabilityAnalyzer { | Expr::Eq(lhs, rhs) | Expr::Lt(lhs, rhs) | Expr::Gt(lhs, rhs) => { - let lhs = self.rewrite_expr_ownership_inner(lhs, false, state)?; - let rhs = self.rewrite_expr_ownership_inner(rhs, false, state)?; + let copy = matches!(expr, Expr::Add(..)); + let observation = matches!( + expr, + Expr::Eq(..) | Expr::Lt(..) | Expr::Gt(..) | Expr::And(..) | Expr::Or(..) + ); + let (lhs, lhs_state) = if copy { + let seed = self.next_collection_alias_id.get(); + let lhs = self.rewrite_expr_ownership_inner(lhs, true, state)?; + self.next_collection_alias_id.set(seed); + // Add's existing projection-copy transfer must not become + // a consuming Get merely because its RHS needs the state. + let out = self.analyze_expr_to_owned(&lhs, state, 1)?; + (lhs, out) + } else { + // Only bare observations use the plain-read context. + // Nested projections and explicit calls keep their own + // consuming transfer rules, matching source analysis. + self.rewrite_expr_and_state( + lhs, + observation && matches!(lhs.as_ref(), Expr::Var(_)), + state, + 1, + )? + }; + let rhs = self.rewrite_expr_ownership_inner( + rhs, + copy || (observation && matches!(rhs.as_ref(), Expr::Var(_))), + &lhs_state, + )?; Ok(match expr { Expr::Add(..) => Expr::Add(Box::new(lhs), Box::new(rhs)), Expr::Sub(..) => Expr::Sub(Box::new(lhs), Box::new(rhs)), @@ -1906,33 +2155,24 @@ impl AvailabilityAnalyzer { condition, then_expr, else_expr, - } => Ok(Expr::IfElse { - condition: Box::new(self.rewrite_expr_ownership_inner(condition, false, state)?), - then_expr: Box::new(self.rewrite_expr_ownership_inner(then_expr, false, state)?), - else_expr: Box::new(self.rewrite_expr_ownership_inner(else_expr, false, state)?), - }), - Expr::Match { - value_slot, - result_slot, - value, - arms, - default, } => { - let mut rewritten_arms = Vec::with_capacity(arms.len()); - for (pattern, arm_expr) in arms { - rewritten_arms.push(( - pattern.clone(), - self.rewrite_expr_ownership_inner(arm_expr, false, state)?, - )); - } - Ok(Expr::Match { - value_slot: *value_slot, - result_slot: *result_slot, - value: Box::new(self.rewrite_expr_ownership_inner(value, false, state)?), - arms: rewritten_arms, - default: Box::new(self.rewrite_expr_ownership_inner(default, false, state)?), + let (condition, condition_state) = + self.rewrite_expr_and_state(condition, false, state, 1)?; + Ok(Expr::IfElse { + condition: Box::new(condition), + then_expr: Box::new(self.rewrite_expr_ownership_inner( + then_expr, + false, + &condition_state, + )?), + else_expr: Box::new(self.rewrite_expr_ownership_inner( + else_expr, + false, + &condition_state, + )?), }) } + Expr::Match { .. } => self.rewrite_expr_for_ownership_with_state(expr, state, 1), Expr::ResultConstruct { ok, payload, @@ -1959,6 +2199,35 @@ impl AvailabilityAnalyzer { span: *span, semantic_id: *semantic_id, }), + Expr::ResultTry { + value, + value_slot, + payload_slot, + operand_schema, + return_schema, + span, + semantic_id, + .. + } => { + let value = self.rewrite_expr_ownership_inner(value, false, state)?; + // Err returns after evaluating and consuming the operand. Replay + // the existing transfer rules on the emitted operand so detached + // sources are excluded while fresh frame-owned locals remain. + // This only derives metadata; the operand still executes once. + let mut operand_state = self.analyze_expr(&value, state, 1)?; + self.mark_owned_value_reads_moved(&value, &mut operand_state); + Ok(Expr::ResultTry { + value: Box::new(value), + value_slot: *value_slot, + payload_slot: *payload_slot, + operand_schema: operand_schema.clone(), + return_schema: return_schema.clone(), + exit_cleanup: self + .err_exit_cleanup_slots(&operand_state, &[*value_slot, *payload_slot]), + span: *span, + semantic_id: *semantic_id, + }) + } Expr::ResultMatch { value, value_slot, @@ -1969,17 +2238,31 @@ impl AvailabilityAnalyzer { scrutinee_schema, span, semantic_id, - } => Ok(Expr::ResultMatch { - value: Box::new(self.rewrite_expr_ownership_inner(value, false, state)?), - value_slot: *value_slot, - ok_binding: *ok_binding, - ok_expr: Box::new(self.rewrite_expr_ownership_inner(ok_expr, false, state)?), - err_binding: *err_binding, - err_expr: Box::new(self.rewrite_expr_ownership_inner(err_expr, false, state)?), - scrutinee_schema: scrutinee_schema.clone(), - span: *span, - semantic_id: *semantic_id, - }), + } => { + let line = 1u32; + let value = self.rewrite_expr_ownership_inner(value, false, state)?; + let (ok_in, err_in) = self.result_match_arm_states( + &value, + *value_slot, + *ok_binding, + *err_binding, + state, + line, + )?; + Ok(Expr::ResultMatch { + value: Box::new(value), + value_slot: *value_slot, + ok_binding: *ok_binding, + ok_expr: Box::new(self.rewrite_expr_ownership_inner(ok_expr, false, &ok_in)?), + err_binding: *err_binding, + err_expr: Box::new( + self.rewrite_expr_ownership_inner(err_expr, false, &err_in)?, + ), + scrutinee_schema: scrutinee_schema.clone(), + span: *span, + semantic_id: *semantic_id, + }) + } Expr::Block { .. } => self.rewrite_expr_for_ownership_with_state(expr, state, 0), Expr::Null | Expr::Int(_) @@ -1993,12 +2276,19 @@ impl AvailabilityAnalyzer { } } - fn rewrite_call_args(&self, args: &[Expr], state: &FlowState) -> Result, ParseError> { + fn rewrite_call_args( + &self, + args: &[Expr], + state: &FlowState, + ) -> Result<(Vec, FlowState), ParseError> { let mut rewritten = Vec::with_capacity(args.len()); + let mut arg_state = state.clone(); for arg in args { - rewritten.push(self.rewrite_expr_ownership_inner(arg, true, state)?); + let (arg, next) = self.rewrite_expr_and_state(arg, true, &arg_state, 1)?; + rewritten.push(arg); + arg_state = next; } - Ok(rewritten) + Ok((rewritten, arg_state)) } /// Rewrites a `TakeOwned` argument: a local becomes `MoveVar`, a literal @@ -2439,6 +2729,7 @@ impl AvailabilityAnalyzer { moved_definite: HashSet::new(), moved_possible: HashSet::new(), copyable_fields: HashSet::new(), + current_owned: vec![false; self.local_count], }, (true, false) => lhs, (false, true) => rhs, @@ -2450,6 +2741,7 @@ impl AvailabilityAnalyzer { let mut collection_aliases = vec![HashSet::new(); self.local_count]; let mut moved_local_definite = vec![false; self.local_count]; let mut moved_local_possible = vec![false; self.local_count]; + let mut current_owned = vec![false; self.local_count]; for idx in 0..self.local_count { definite[idx] = lhs.definite[idx] && rhs.definite[idx]; possible[idx] = lhs.possible[idx] || rhs.possible[idx]; @@ -2463,6 +2755,12 @@ impl AvailabilityAnalyzer { lhs.moved_local_definite[idx] && rhs.moved_local_definite[idx]; moved_local_possible[idx] = lhs.moved_local_possible[idx] || rhs.moved_local_possible[idx]; + // A branch-local owner remains ours when the other edge + // never initialized the slot (Null at runtime). Do not turn + // a real caller-owned/borrowed predecessor into ownership. + current_owned[idx] = (lhs.current_owned[idx] + && (rhs.current_owned[idx] || !rhs.possible[idx])) + || (rhs.current_owned[idx] && !lhs.possible[idx]); } let moved_possible = lhs .moved_possible @@ -2491,6 +2789,7 @@ impl AvailabilityAnalyzer { moved_definite, moved_possible, copyable_fields, + current_owned, } } } @@ -2585,6 +2884,151 @@ mod tests { ) } + #[test] + fn postoperand_join_retains_branch_local_owner_but_not_borrowed_predecessor() { + let analyzer = AvailabilityAnalyzer::new(1, &[], &HashMap::new(), true, &[true]); + let absent = FlowState::reachable(1); + let mut owned = absent.clone(); + analyzer.mark_available(&mut owned, 0, 1).unwrap(); + owned.current_owned[0] = true; + for (lhs, rhs) in [(owned.clone(), absent.clone()), (absent, owned.clone())] { + let merged = analyzer.merge_states(lhs, rhs); + assert!(merged.current_owned[0]); + assert!(!merged.definite[0]); + assert_eq!( + analyzer.err_exit_cleanup_slots(&merged, &[]), + vec![(0, TypeSchema::Optional(Box::new(TypeSchema::Unknown)))] + ); + assert!(analyzer.err_exit_cleanup_slots(&merged, &[0]).is_empty()); + } + let mut borrowed = owned.clone(); + borrowed.current_owned[0] = false; + for (lhs, rhs) in [(owned.clone(), borrowed.clone()), (borrowed, owned)] { + let merged = analyzer.merge_states(lhs, rhs); + assert!(!merged.current_owned[0]); + assert!(analyzer.err_exit_cleanup_slots(&merged, &[]).is_empty()); + } + } + + fn polex_try_expr() -> Expr { + let schema = TypeSchema::Result { + ok: Box::new(TypeSchema::Int), + err: Box::new(TypeSchema::Int), + }; + let span = crate::compiler::source_map::Span { + source_id: 0, + lo: 0, + hi: 1, + }; + Expr::ResultTry { + value: Box::new(Expr::ResultConstruct { + ok: false, + payload: Box::new(Expr::Int(9)), + result_schema: schema.clone(), + span, + semantic_id: None, + }), + value_slot: 2, + payload_slot: 3, + operand_schema: schema.clone(), + return_schema: schema, + exit_cleanup: Vec::new(), + span, + semantic_id: None, + } + } + + #[test] + fn polex_metadata_optional_container_and_exact_host_args_thread_consumption() { + let key = ResourceTypeKey::new("test.resource").unwrap(); + let analyzer = AvailabilityAnalyzer::new( + 6, + &[], + &HashMap::new(), + true, + &[true, true, false, false, false, false], + ); + let mut state = FlowState::reachable(6); + for slot in [0, 1] { + analyzer.mark_available(&mut state, slot, 1).unwrap(); + state.current_owned[slot as usize] = true; + } + let optional = Expr::OptionalGet { + field_projection: false, + container: Box::new(take_call(Expr::Var(0), &key)), + key: Box::new(polex_try_expr()), + container_slot: 4, + key_slot: 5, + semantic_id: None, + }; + let mut resolution = take_resolution(&key); + resolution.passing.push(HostParamPassing::Value); + resolution.params.push(ResolvedHostParam { + name: "next".into(), + schema: TypeSchema::Int, + }); + let call = Expr::Call( + 99, + vec![], + vec![Expr::Var(0), polex_try_expr()], + Some(resolution), + None, + ); + for expr in [optional, call] { + let rewritten = analyzer + .rewrite_expr_ownership_inner(&expr, false, &state) + .unwrap(); + let nested = match &rewritten { + Expr::OptionalGet { key, .. } => key.as_ref(), + Expr::Call(_, _, args, _, _) => &args[1], + _ => unreachable!(), + }; + let Expr::ResultTry { exit_cleanup, .. } = nested else { + panic!("missing Try") + }; + assert_eq!(*exit_cleanup, vec![(1, TypeSchema::Unknown)]); + } + } + + #[test] + fn polex_for_post_cleanup_uses_continue_edge_fresh_owner() { + let key = ResourceTypeKey::new("test.resource").unwrap(); + let analyzer = + AvailabilityAnalyzer::new(4, &[], &HashMap::new(), true, &[true, true, false, false]); + let mut state = FlowState::reachable(4); + analyzer.mark_available(&mut state, 0, 1).unwrap(); + state.current_owned[0] = true; + let init = Stmt::Noop { line: 1 }; + let condition = Expr::Bool(true); + let post = Stmt::Expr { + expr: polex_try_expr(), + line: 1, + }; + let body = vec![resource_binding(1, &key), Stmt::Continue { line: 1 }]; + let parts = ForLoopParts { + init: &init, + condition: &condition, + post: &post, + body: &body, + line: 1, + }; + let (rewritten, _) = analyzer.analyze_for_loop(parts, state, true).unwrap(); + let Stmt::For { post, .. } = rewritten else { + unreachable!() + }; + let Stmt::Expr { + expr: Expr::ResultTry { exit_cleanup, .. }, + .. + } = *post + else { + panic!("missing post Try") + }; + assert_eq!( + exit_cleanup, + vec![(0, TypeSchema::Unknown), (1, TypeSchema::Unknown)] + ); + } + #[test] fn capture_mode_uses_resolved_host_parameter_ownership() { let move_impl = FunctionImpl { diff --git a/src/compiler/lifetime/availability/captures.rs b/src/compiler/lifetime/availability/captures.rs index 618e268f..370f3d48 100644 --- a/src/compiler/lifetime/availability/captures.rs +++ b/src/compiler/lifetime/availability/captures.rs @@ -624,7 +624,8 @@ impl AvailabilityAnalyzer { } other @ (Expr::ResultConstruct { .. } | Expr::ResultQuery { .. } - | Expr::ResultMatch { .. }) => { + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { other.visit_result_children(|child| { self.capture_mode_for_expr(child, captured_slot, context, implicit, scan); }); diff --git a/src/compiler/lifetime/availability/consumption.rs b/src/compiler/lifetime/availability/consumption.rs index 033321c2..5b6dd040 100644 --- a/src/compiler/lifetime/availability/consumption.rs +++ b/src/compiler/lifetime/availability/consumption.rs @@ -1,4 +1,5 @@ use super::*; +use crate::host_api::HostParamPassing; pub(super) fn extract_passthrough_return_slot(function_impl: &FunctionImpl) -> Option { if !function_impl.body_stmts.is_empty() { @@ -60,6 +61,30 @@ pub(super) fn collect_consumed_positions_from_function( out, ); collect_return_rebind_consumed_positions(function_impl, out); + collect_direct_return_consumed_positions(function_impl, out); +} + +pub(super) fn collect_direct_return_consumed_positions( + function_impl: &FunctionImpl, + out: &mut HashSet, +) { + let slots = match &function_impl.body_expr { + Expr::Var(slot) | Expr::MoveVar(slot) => vec![*slot], + Expr::ResultConstruct { payload, .. } => match payload.as_ref() { + Expr::Var(slot) | Expr::MoveVar(slot) => vec![*slot], + _ => Vec::new(), + }, + _ => Vec::new(), + }; + for slot in slots { + if let Some(position) = function_impl + .param_slots + .iter() + .position(|param| *param == slot) + { + out.insert(position); + } + } } pub(super) fn collect_return_rebind_consumed_positions( @@ -251,9 +276,15 @@ pub(super) fn expr_uses_slot(expr: &Expr, slot: LocalSlot) -> bool { } other @ (Expr::ResultConstruct { .. } | Expr::ResultQuery { .. } - | Expr::ResultMatch { .. }) => { + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { let mut used = false; other.visit_result_children(|child| used |= expr_uses_slot(child, slot)); + if let Expr::ResultTry { exit_cleanup, .. } = other { + used |= exit_cleanup + .iter() + .any(|(cleanup_slot, _)| *cleanup_slot == slot); + } used } Expr::Block { stmts, expr } => { @@ -434,7 +465,7 @@ pub(super) fn collect_consumed_positions_from_expr( out, ); } - Expr::Call(index, _, args, _, _) => { + Expr::Call(index, _, args, resolution, _) => { for arg in args { collect_consumed_positions_from_expr( arg, @@ -443,6 +474,24 @@ pub(super) fn collect_consumed_positions_from_expr( out, ); } + if let Some(resolution) = resolution { + for (position, passing) in resolution.passing.iter().enumerate() { + if *passing != HostParamPassing::TakeOwned { + continue; + } + let Some(arg_expr) = args.get(position) else { + continue; + }; + if let Expr::Var(slot) | Expr::MoveVar(slot) = arg_expr + && let Some(param_position) = function_impl + .param_slots + .iter() + .position(|param| param == slot) + { + out.insert(param_position); + } + } + } let Some(consumed_arg_positions) = known_consumed_positions.get(index) else { return; }; @@ -590,7 +639,8 @@ pub(super) fn collect_consumed_positions_from_expr( } other @ (Expr::ResultConstruct { .. } | Expr::ResultQuery { .. } - | Expr::ResultMatch { .. }) => { + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { other.visit_result_children(|child| { collect_consumed_positions_from_expr( child, diff --git a/src/compiler/lifetime/liveness.rs b/src/compiler/lifetime/liveness.rs index f3b55ce0..b43865dc 100644 --- a/src/compiler/lifetime/liveness.rs +++ b/src/compiler/lifetime/liveness.rs @@ -677,10 +677,16 @@ impl LivenessRewriter { } other @ (Expr::ResultConstruct { .. } | Expr::ResultQuery { .. } - | Expr::ResultMatch { .. }) => { + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { other.visit_result_children(|child| { self.add_expr_uses_impl(child, live, conservative) }); + if let Expr::ResultTry { exit_cleanup, .. } = other { + for (slot, _) in exit_cleanup { + self.mark_live(live, *slot); + } + } } Expr::Block { stmts, expr } => { let live_out = if conservative { @@ -1260,6 +1266,20 @@ impl LocalSlotAllocator { Expr::ResultQuery { value, .. } => { self.collect_expr_constraints(value, &live_during, protected_slots)?; } + Expr::ResultTry { + value, + value_slot, + payload_slot, + exit_cleanup, + .. + } => { + self.add_slot_live_edges(*value_slot, &live_during); + self.add_slot_live_edges(*payload_slot, &live_during); + for (slot, _) in exit_cleanup { + self.add_slot_live_edges(*slot, &live_during); + } + self.collect_expr_constraints(value, &live_during, protected_slots)?; + } Expr::ResultMatch { value, value_slot, @@ -1539,6 +1559,20 @@ impl LocalSlotAllocator { self.collect_expr_footprint(payload, set, stack) } Expr::ResultQuery { value, .. } => self.collect_expr_footprint(value, set, stack), + Expr::ResultTry { + value, + value_slot, + payload_slot, + exit_cleanup, + .. + } => { + self.mark_set_slot(set, *value_slot); + self.mark_set_slot(set, *payload_slot); + for (slot, _) in exit_cleanup { + self.mark_set_slot(set, *slot); + } + self.collect_expr_footprint(value, set, stack); + } Expr::ResultMatch { value, value_slot, @@ -1915,7 +1949,8 @@ fn collect_persistent_closure_sources_from_expr(expr: &Expr, slots: &mut BTreeSe } other @ (Expr::ResultConstruct { .. } | Expr::ResultQuery { .. } - | Expr::ResultMatch { .. }) => { + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { other.visit_result_children(|child| { collect_persistent_closure_sources_from_expr(child, slots) }); @@ -2112,6 +2147,20 @@ fn remap_expr_slots(expr: &mut Expr, mapping: &[LocalSlot]) -> Result<(), ParseE } Expr::ResultConstruct { payload, .. } => remap_expr_slots(payload, mapping)?, Expr::ResultQuery { value, .. } => remap_expr_slots(value, mapping)?, + Expr::ResultTry { + value, + value_slot, + payload_slot, + exit_cleanup, + .. + } => { + *value_slot = remap_slot(*value_slot, mapping)?; + *payload_slot = remap_slot(*payload_slot, mapping)?; + for (slot, _) in exit_cleanup.iter_mut() { + *slot = remap_slot(*slot, mapping)?; + } + remap_expr_slots(value, mapping)?; + } Expr::ResultMatch { value, value_slot, diff --git a/src/compiler/linker.rs b/src/compiler/linker.rs index f77b40c2..da726089 100644 --- a/src/compiler/linker.rs +++ b/src/compiler/linker.rs @@ -356,6 +356,16 @@ fn remap_expression_type_names(expression: &mut Expr, names: &HashMap remap_expression_type_names(value, names), + Expr::ResultTry { + value, + operand_schema, + return_schema, + .. + } => { + remap_type_schema_names(operand_schema, names); + remap_type_schema_names(return_schema, names); + remap_expression_type_names(value, names); + } Expr::ResultMatch { value, ok_expr, @@ -1537,6 +1547,24 @@ fn remap_expr_indices( flat_index_by_symbol, )?; } + Expr::ResultTry { + value, + value_slot, + payload_slot, + semantic_id, + .. + } => { + *value_slot = remap_local_index(*value_slot, local_base)?; + *payload_slot = remap_local_index(*payload_slot, local_base)?; + rebase_semantic_id(semantic_id, node_offset); + remap_expr_indices( + value, + local_base, + node_offset, + function_map, + flat_index_by_symbol, + )?; + } Expr::ResultMatch { value, value_slot, diff --git a/src/compiler/materialization.rs b/src/compiler/materialization.rs index 0a828e4d..0c3a2431 100644 --- a/src/compiler/materialization.rs +++ b/src/compiler/materialization.rs @@ -488,7 +488,10 @@ impl Classifier { } self.expr(frame, default); } - other @ (Expr::ResultConstruct { .. } | Expr::ResultQuery { .. } | Expr::ResultMatch { .. }) => { + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { other.visit_result_children(|child| self.expr(frame, child)); } Expr::Block { stmts, expr } => { diff --git a/src/compiler/mod.rs b/src/compiler/mod.rs index 1aa63de7..b5cedde2 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -27,6 +27,7 @@ mod result; mod semantic_model; mod source_loader; pub mod source_map; +mod try_anf; mod typing; #[cfg(test)] diff --git a/src/compiler/parser/expressions.rs b/src/compiler/parser/expressions.rs index 600ec744..1e9eb834 100644 --- a/src/compiler/parser/expressions.rs +++ b/src/compiler/parser/expressions.rs @@ -167,7 +167,9 @@ impl Parser { let inner = self.parse_unary()?; Ok(Expr::Not(Box::new(inner))) } else { - self.parse_primary() + let start = self.current_span(); + let expr = self.parse_primary()?; + self.parse_postfix_access(expr, start) } } @@ -362,7 +364,8 @@ impl Parser { .unwrap_or_else(|| self.current_span()); if self.dialect.allow_result_syntax() && name == "Result" && self.check_path_separator() { - return self.parse_result_constructor(name_span); + let expr = self.parse_result_constructor(name_span)?; + return self.parse_postfix_access(expr, name_span); } if self.dialect.allow_dotted_call() && let Some(expr) = self.try_parse_js_dotted_call(&name, name_span)? @@ -1411,6 +1414,30 @@ impl Parser { continue; } if self.match_kind(&TokenKind::Question) { + if !self.check(&TokenKind::Dot) { + if self.dialect.allow_result_try() { + let question_span = self.tokens[self.pos - 1].span; + let value_slot = self.allocate_hidden_local()?; + let payload_slot = self.allocate_hidden_local()?; + expr = Expr::ResultTry { + value: Box::new(expr), + value_slot, + payload_slot, + operand_schema: TypeSchema::Unknown, + return_schema: TypeSchema::Unknown, + exit_cleanup: Vec::new(), + span: question_span, + semantic_id: None, + }; + continue; + } + return Err(ParseError { + line: self.current_line(), + message: "expected '.' after '?' in optional access".to_string(), + span: Some(self.tokens[self.pos - 1].span), + code: None, + }); + } self.expect(&TokenKind::Dot, "expected '.' after '?' in optional access")?; if self.match_kind(&TokenKind::LBracket) { let bracket_open = self diff --git a/src/compiler/parser/mod.rs b/src/compiler/parser/mod.rs index 252339e0..0e794831 100644 --- a/src/compiler/parser/mod.rs +++ b/src/compiler/parser/mod.rs @@ -106,6 +106,13 @@ pub trait ParserDialect { fn allow_result_syntax(&self) -> bool { false } + + /// Plain postfix `?` as lexical-function Result propagation. + /// + /// Default off. `?.` optional access and type-position `T?` are unaffected. + fn allow_result_try(&self) -> bool { + false + } } pub(super) fn lint_trailing_function_return_semicolons( diff --git a/src/compiler/pipeline.rs b/src/compiler/pipeline.rs index 496cb427..e5f52ac6 100644 --- a/src/compiler/pipeline.rs +++ b/src/compiler/pipeline.rs @@ -368,6 +368,7 @@ fn prepare_owned_typed_ir( super::result::resolve_result_types(&mut legalized, entry.types) .map_err(SourceError::Compile)?; } + super::try_anf::lower_partial_resource_args(&mut legalized).map_err(SourceError::Parse)?; parsed = legalized; let type_info = typing::infer_types(&parsed, TypingMode::DynamicHints, entry.types); let mut analysis_errors = Vec::new(); @@ -439,6 +440,9 @@ fn prepare_owned_typed_ir( } Err(error) => return Err(SourceError::Parse(error)), }; + let mut owned_ir = owned_ir; + let structs = owned_ir.struct_schemas.clone(); + super::try_anf::attach_err_cleanup_schemas(&mut owned_ir, &logical_schemas, &structs); for owner in owners { if let Some(plugin) = owner.plugin { let typed = FrontendTypedIr { @@ -771,7 +775,8 @@ fn record_expr_local_debug_ranges( } other @ (Expr::ResultConstruct { .. } | Expr::ResultQuery { .. } - | Expr::ResultMatch { .. }) => { + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { other .visit_result_children(|child| record_expr_local_debug_ranges(child, line, ranges)); } @@ -910,8 +915,10 @@ fn compile_parsed_output_with_capture_output( let mut parsed = parsed; super::result::resolve_result_types(&mut parsed, entry_local_types) .map_err(SourceError::Compile)?; - let parsed = typing::legalize_builtins_and_bind_types(parsed, typing_mode, entry_local_types) - .map_err(SourceError::Compile)?; + let mut parsed = + typing::legalize_builtins_and_bind_types(parsed, typing_mode, entry_local_types) + .map_err(SourceError::Compile)?; + super::try_anf::lower_partial_resource_args(&mut parsed).map_err(SourceError::Parse)?; typing::validate_if_else_type_consistency(&parsed, typing_mode, entry_local_types) .map_err(SourceError::Compile)?; let pre_lifetime_type_info = typing::infer_types(&parsed, typing_mode, entry_local_types); @@ -936,6 +943,13 @@ fn compile_parsed_output_with_capture_output( &owned_local_slots, ) .map_err(SourceError::Parse)?; + let mut parsed = parsed; + let structs = parsed.struct_schemas.clone(); + super::try_anf::attach_err_cleanup_schemas( + &mut parsed, + &pre_lifetime_type_info.local_schemas, + &structs, + ); compile_owned_ir_with_capture_output( source, parsed, diff --git a/src/compiler/result.rs b/src/compiler/result.rs index 9f65b0f8..11880b29 100644 --- a/src/compiler/result.rs +++ b/src/compiler/result.rs @@ -23,6 +23,9 @@ struct Env { function_list: Vec, function_impls: HashMap, struct_schemas: HashMap, + /// Return schema of the function or closure currently being resolved. + /// `None` at root and inside a callable that does not return `Result`. + lexical_return: Option, } pub(crate) fn resolve_result_types( @@ -46,6 +49,7 @@ pub(crate) fn resolve_result_types( function_list, function_impls: ir.function_impls.clone(), struct_schemas: ir.struct_schemas.clone(), + lexical_return: None, }; resolve_stmts(&mut ir.stmts, None, &mut env)?; let mut impls = std::mem::take(&mut ir.function_impls); @@ -62,7 +66,9 @@ fn resolve_function( env: &mut Env, ) -> Result<(), CompileError> { let saved = env.slots.clone(); + let saved_return = env.lexical_return.clone(); if let Some(function) = env.functions.get(&index).cloned() { + env.lexical_return = function.return_schema.clone(); for (slot, schema) in function_impl .param_slots .iter() @@ -87,6 +93,7 @@ fn resolve_function( resolve_expr(&mut function_impl.body_expr, None, env)?; } env.slots = saved; + env.lexical_return = saved_return; Ok(()) } @@ -233,6 +240,13 @@ fn resolve_expr( "is_ok/is_err", ) } + Expr::ResultTry { + value, + operand_schema, + return_schema, + span, + .. + } => resolve_try(value, operand_schema, return_schema, *span, env), Expr::Block { stmts, expr } => { resolve_stmts(stmts, expected.cloned(), env)?; resolve_expr(expr, expected, env) @@ -484,6 +498,163 @@ fn resolve_match( Ok(()) } +fn resolve_try( + value: &mut Expr, + operand_schema: &mut TypeSchema, + return_schema: &mut TypeSchema, + span: Span, + env: &mut Env, +) -> Result<(), CompileError> { + resolve_expr(value, None, env)?; + let operand = operand_schema_of(value, env).ok_or_else(|| { + result_error( + span, + "? operand must be Result, found an untyped value".to_string(), + ) + })?; + let (operand_ok, operand_err) = match &operand { + TypeSchema::Result { ok, err } => ((**ok).clone(), (**err).clone()), + other => { + return Err(result_error( + span, + format!( + "? operand must be Result, found {}", + render_schema(other) + ), + )); + } + }; + let function_return = env.lexical_return.clone().ok_or_else(|| { + result_error( + span, + "? requires the enclosing function to return Result".to_string(), + ) + })?; + let function_err = match &function_return { + TypeSchema::Result { err, .. } => (**err).clone(), + other => { + return Err(result_error( + span, + format!( + "? requires the enclosing function to return Result, found {}", + render_schema(other) + ), + )); + } + }; + if operand_err != function_err { + return Err(result_error( + span, + format!( + "? error type {} is not {}", + render_schema(&operand_err), + render_schema(&function_err) + ), + )); + } + *operand_schema = TypeSchema::Result { + ok: Box::new(operand_ok), + err: Box::new(operand_err), + }; + *return_schema = function_return; + Ok(()) +} + +fn operand_schema_of(expr: &Expr, env: &Env) -> Option { + if let Some(schema) = authoritative_schema(expr, env) { + return Some(schema); + } + match expr { + Expr::Call(index, type_args, _, resolution, _) => { + if let Some(resolved) = resolution.as_deref() { + return Some(resolved.return_type.clone()); + } + let function = env.functions.get(index)?; + Some(substitute_schema( + function.return_schema.clone()?, + &function.type_params, + type_args, + )) + } + Expr::LocalCall(slot, type_args, _, _) => { + let schema = env.slots.get(slot).cloned()?; + callable_result_schema(&schema, type_args) + } + Expr::ModuleCall(symbol, type_args, _, _) => { + let function = env + .function_list + .iter() + .find(|function| function.symbol == Some(*symbol))?; + Some(substitute_schema( + function.return_schema.clone()?, + &function.type_params, + type_args, + )) + } + _ => None, + } +} + +fn callable_result_schema(schema: &TypeSchema, _type_args: &[TypeSchema]) -> Option { + match schema { + TypeSchema::Callable { result, .. } => Some((**result).clone()), + _ => None, + } +} + +fn substitute_schema(schema: TypeSchema, params: &[String], args: &[TypeSchema]) -> TypeSchema { + if params.is_empty() || args.is_empty() { + return schema; + } + let bindings: Vec<(&str, &TypeSchema)> = params + .iter() + .zip(args.iter()) + .map(|(name, schema)| (name.as_str(), schema)) + .collect(); + substitute_schema_bindings(schema, &bindings) +} + +fn substitute_schema_bindings(schema: TypeSchema, bindings: &[(&str, &TypeSchema)]) -> TypeSchema { + match schema { + TypeSchema::GenericParam(name) => bindings + .iter() + .find(|(param, _)| *param == name) + .map(|(_, replacement)| (*replacement).clone()) + .unwrap_or(TypeSchema::GenericParam(name)), + TypeSchema::Result { ok, err } => TypeSchema::Result { + ok: Box::new(substitute_schema_bindings(*ok, bindings)), + err: Box::new(substitute_schema_bindings(*err, bindings)), + }, + TypeSchema::Optional(inner) => { + TypeSchema::Optional(Box::new(substitute_schema_bindings(*inner, bindings))) + } + TypeSchema::Array(inner) => { + TypeSchema::Array(Box::new(substitute_schema_bindings(*inner, bindings))) + } + TypeSchema::Map(inner) => { + TypeSchema::Map(Box::new(substitute_schema_bindings(*inner, bindings))) + } + TypeSchema::Named(name, args) => TypeSchema::Named( + name, + args.into_iter() + .map(|schema| substitute_schema_bindings(schema, bindings)) + .collect(), + ), + TypeSchema::Callable { params, result } => TypeSchema::Callable { + params: params + .into_iter() + .map(|schema| substitute_schema_bindings(schema, bindings)) + .collect(), + result: Box::new(substitute_schema_bindings(*result, bindings)), + }, + other => other, + } +} + +fn render_schema(schema: &TypeSchema) -> String { + super::typing::render_schema_label(schema) +} + fn authoritative_schema(expr: &Expr, env: &Env) -> Option { infer_schema(expr, env) .or_else(|| { @@ -519,8 +690,14 @@ fn resolve_closure( &closure.capture_copies, ); let saved = std::mem::replace(&mut env.slots, nested); + let saved_return = env.lexical_return.clone(); + env.lexical_return = match expected_return { + Some(schema @ TypeSchema::Result { .. }) => Some(schema.clone()), + _ => None, + }; let resolved = resolve_expr(&mut closure.body, expected_return, env); env.slots = saved; + env.lexical_return = saved_return; resolved } diff --git a/src/compiler/source_loader/graph.rs b/src/compiler/source_loader/graph.rs index 334e7a2a..576c79fd 100644 --- a/src/compiler/source_loader/graph.rs +++ b/src/compiler/source_loader/graph.rs @@ -1881,6 +1881,9 @@ fn resolve_expr_imported_calls( Expr::ResultQuery { value, .. } => { resolve_expr_imported_calls(ctx, value, line, parsed_semantic_index.as_deref_mut())?; } + Expr::ResultTry { value, .. } => { + resolve_expr_imported_calls(ctx, value, line, parsed_semantic_index.as_deref_mut())?; + } Expr::ResultMatch { value, ok_expr, diff --git a/src/compiler/try_anf.rs b/src/compiler/try_anf.rs new file mode 100644 index 00000000..8cae52ab --- /dev/null +++ b/src/compiler/try_anf.rs @@ -0,0 +1,718 @@ +//! Left-to-right ANF for calls that contain `?`. +//! +//! A value read or resource move that precedes a later `?` must already be a +//! local when that `?` runs. Leaving a bare argument in the call postpones the +//! read until after the hoisted `?`, so a later mutation is visible to the +//! earlier operand. Borrow and BorrowMut stay call-scoped passing modes. + +use crate::builtins::BuiltinFunction; + +use super::ParseError; +use super::ir::{Expr, FrontendIr, FunctionImpl, LocalSlot, Stmt, StructDecl, TypeSchema}; + +pub(super) fn attach_err_cleanup_schemas( + ir: &mut FrontendIr, + schemas: &[Option], + structs: &std::collections::HashMap, +) { + for stmt in &mut ir.stmts { + attach_stmt_schema(stmt, schemas, structs); + } + for function in ir.function_impls.values_mut() { + for stmt in &mut function.body_stmts { + attach_stmt_schema(stmt, schemas, structs); + } + attach_expr_schema(&mut function.body_expr, schemas, structs); + } +} + +fn attach_stmt_schema( + stmt: &mut Stmt, + schemas: &[Option], + structs: &std::collections::HashMap, +) { + match stmt { + Stmt::Let { expr, .. } | Stmt::Assign { expr, .. } | Stmt::Expr { expr, .. } => { + attach_expr_schema(expr, schemas, structs); + } + Stmt::ClosureLet { closure, .. } => attach_expr_schema(&mut closure.body, schemas, structs), + Stmt::IfElse { + condition, + then_branch, + else_branch, + .. + } => { + attach_expr_schema(condition, schemas, structs); + for stmt in then_branch.iter_mut().chain(else_branch.iter_mut()) { + attach_stmt_schema(stmt, schemas, structs); + } + } + Stmt::For { + init, + condition, + post, + body, + .. + } => { + attach_stmt_schema(init, schemas, structs); + attach_expr_schema(condition, schemas, structs); + attach_stmt_schema(post, schemas, structs); + for stmt in body { + attach_stmt_schema(stmt, schemas, structs); + } + } + Stmt::While { + condition, body, .. + } => { + attach_expr_schema(condition, schemas, structs); + for stmt in body { + attach_stmt_schema(stmt, schemas, structs); + } + } + Stmt::Noop { .. } + | Stmt::FuncDecl { .. } + | Stmt::Break { .. } + | Stmt::Continue { .. } + | Stmt::Drop { .. } => {} + } +} + +fn attach_expr_schema( + expr: &mut Expr, + schemas: &[Option], + structs: &std::collections::HashMap, +) { + if let Expr::ResultTry { exit_cleanup, .. } = expr { + for (slot, schema) in exit_cleanup.iter_mut() { + if let Some(known) = schemas + .get(*slot as usize) + .and_then(|schema| schema.clone()) + && known.contains_resource_with_named_types(structs) + { + *schema = if matches!(schema, TypeSchema::Optional(inner) if matches!(inner.as_ref(), TypeSchema::Unknown)) + { + // Availability marks legitimate whole-slot absence on a + // conditional-move Err edge; do not widen the variable's + // public logical type or all Resource cleanup leaves. + TypeSchema::Optional(Box::new(known)) + } else { + known + }; + } + } + } + match expr { + Expr::OptionalGet { container, key, .. } => { + attach_expr_schema(container, schemas, structs); + attach_expr_schema(key, schemas, structs); + } + Expr::OptionUnwrapOr { + value, fallback, .. + } => { + attach_expr_schema(value, schemas, structs); + attach_expr_schema(fallback, schemas, structs); + } + Expr::Call(_, _, args, _, _) + | Expr::ModuleCall(_, _, args, _) + | Expr::LocalCall(_, _, args, _) + | Expr::ClosureCall(_, args) => { + for arg in args { + attach_expr_schema(arg, schemas, structs); + } + } + Expr::Closure(closure) => attach_expr_schema(&mut closure.body, schemas, structs), + Expr::Add(lhs, rhs) + | Expr::Sub(lhs, rhs) + | Expr::Mul(lhs, rhs) + | Expr::Div(lhs, rhs) + | Expr::Mod(lhs, rhs) + | Expr::And(lhs, rhs) + | Expr::Or(lhs, rhs) + | Expr::Eq(lhs, rhs) + | Expr::Lt(lhs, rhs) + | Expr::Gt(lhs, rhs) => { + attach_expr_schema(lhs, schemas, structs); + attach_expr_schema(rhs, schemas, structs); + } + Expr::Neg(inner) + | Expr::Not(inner) + | Expr::ToOwned(inner) + | Expr::Borrow(inner) + | Expr::BorrowMut(inner) => { + attach_expr_schema(inner, schemas, structs); + } + Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + attach_expr_schema(condition, schemas, structs); + attach_expr_schema(then_expr, schemas, structs); + attach_expr_schema(else_expr, schemas, structs); + } + Expr::Match { + value, + arms, + default, + .. + } => { + attach_expr_schema(value, schemas, structs); + for (_, arm) in arms { + attach_expr_schema(arm, schemas, structs); + } + attach_expr_schema(default, schemas, structs); + } + Expr::Block { stmts, expr } => { + for stmt in stmts { + attach_stmt_schema(stmt, schemas, structs); + } + attach_expr_schema(expr, schemas, structs); + } + Expr::ResultConstruct { payload, .. } => attach_expr_schema(payload, schemas, structs), + Expr::ResultMatch { + value, + ok_expr, + err_expr, + .. + } => { + attach_expr_schema(value, schemas, structs); + attach_expr_schema(ok_expr, schemas, structs); + attach_expr_schema(err_expr, schemas, structs); + } + Expr::ResultQuery { value, .. } | Expr::ResultTry { value, .. } => { + attach_expr_schema(value, schemas, structs); + } + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::String(_) + | Expr::Bytes(_) + | Expr::FunctionRef(_, _) + | Expr::ModuleFunctionRef(_, _) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Var(_) + | Expr::MoveVar(_) + | Expr::MoveField { .. } + | Expr::MoveIndex { .. } => {} + } +} + +pub(super) fn lower_partial_resource_args(ir: &mut FrontendIr) -> Result<(), ParseError> { + let mut locals = ir.locals; + for stmt in &mut ir.stmts { + rewrite_stmt(stmt, &mut locals)?; + } + for function in ir.function_impls.values_mut() { + rewrite_function(function, &mut locals)?; + } + ir.locals = locals; + Ok(()) +} + +fn rewrite_function(function: &mut FunctionImpl, locals: &mut usize) -> Result<(), ParseError> { + for stmt in &mut function.body_stmts { + rewrite_stmt(stmt, locals)?; + } + rewrite_expr(&mut function.body_expr, locals) +} + +fn rewrite_stmt(stmt: &mut Stmt, locals: &mut usize) -> Result<(), ParseError> { + match stmt { + Stmt::Let { expr, .. } | Stmt::Assign { expr, .. } | Stmt::Expr { expr, .. } => { + rewrite_expr(expr, locals) + } + Stmt::ClosureLet { closure, .. } => rewrite_expr(&mut closure.body, locals), + Stmt::IfElse { + condition, + then_branch, + else_branch, + .. + } => { + rewrite_expr(condition, locals)?; + for stmt in then_branch.iter_mut().chain(else_branch.iter_mut()) { + rewrite_stmt(stmt, locals)?; + } + Ok(()) + } + Stmt::For { + init, + condition, + post, + body, + .. + } => { + rewrite_stmt(init, locals)?; + rewrite_expr(condition, locals)?; + rewrite_stmt(post, locals)?; + for stmt in body { + rewrite_stmt(stmt, locals)?; + } + Ok(()) + } + Stmt::While { + condition, body, .. + } => { + rewrite_expr(condition, locals)?; + for stmt in body { + rewrite_stmt(stmt, locals)?; + } + Ok(()) + } + Stmt::Noop { .. } + | Stmt::FuncDecl { .. } + | Stmt::Break { .. } + | Stmt::Continue { .. } + | Stmt::Drop { .. } => Ok(()), + } +} + +fn rewrite_expr(expr: &mut Expr, locals: &mut usize) -> Result<(), ParseError> { + rewrite_children(expr, locals)?; + if let Some(lowered) = hoist_call(expr, locals)? { + *expr = lowered; + } + Ok(()) +} + +fn rewrite_children(expr: &mut Expr, locals: &mut usize) -> Result<(), ParseError> { + match expr { + Expr::OptionalGet { container, key, .. } => { + rewrite_expr(container, locals)?; + rewrite_expr(key, locals) + } + Expr::OptionUnwrapOr { + value, fallback, .. + } => { + rewrite_expr(value, locals)?; + rewrite_expr(fallback, locals) + } + Expr::Call(_, _, args, _, _) + | Expr::ModuleCall(_, _, args, _) + | Expr::LocalCall(_, _, args, _) + | Expr::ClosureCall(_, args) => { + for arg in args { + rewrite_expr(arg, locals)?; + } + Ok(()) + } + Expr::Closure(closure) => rewrite_expr(&mut closure.body, locals), + Expr::Add(lhs, rhs) + | Expr::Sub(lhs, rhs) + | Expr::Mul(lhs, rhs) + | Expr::Div(lhs, rhs) + | Expr::Mod(lhs, rhs) + | Expr::And(lhs, rhs) + | Expr::Or(lhs, rhs) + | Expr::Eq(lhs, rhs) + | Expr::Lt(lhs, rhs) + | Expr::Gt(lhs, rhs) => { + rewrite_expr(lhs, locals)?; + rewrite_expr(rhs, locals) + } + Expr::Neg(inner) + | Expr::Not(inner) + | Expr::ToOwned(inner) + | Expr::Borrow(inner) + | Expr::BorrowMut(inner) => rewrite_expr(inner, locals), + Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + rewrite_expr(condition, locals)?; + rewrite_expr(then_expr, locals)?; + rewrite_expr(else_expr, locals) + } + Expr::Match { + value, + arms, + default, + .. + } => { + rewrite_expr(value, locals)?; + for (_, arm) in arms { + rewrite_expr(arm, locals)?; + } + rewrite_expr(default, locals) + } + Expr::Block { stmts, expr } => { + for stmt in stmts { + rewrite_stmt(stmt, locals)?; + } + rewrite_expr(expr, locals) + } + Expr::ResultConstruct { payload, .. } => rewrite_expr(payload, locals), + Expr::ResultMatch { + value, + ok_expr, + err_expr, + .. + } => { + rewrite_expr(value, locals)?; + rewrite_expr(ok_expr, locals)?; + rewrite_expr(err_expr, locals) + } + Expr::ResultQuery { value, .. } | Expr::ResultTry { value, .. } => { + rewrite_expr(value, locals) + } + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::String(_) + | Expr::Bytes(_) + | Expr::FunctionRef(_, _) + | Expr::ModuleFunctionRef(_, _) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Var(_) + | Expr::MoveVar(_) + | Expr::MoveField { .. } + | Expr::MoveIndex { .. } => Ok(()), + } +} + +fn hoist_call(expr: &Expr, locals: &mut usize) -> Result, ParseError> { + if call_args(expr).is_none() + || !call_args(expr).is_some_and(|args| args.iter().any(contains_try)) + { + return Ok(None); + } + let mut lets = Vec::new(); + let mut lowered = expr.clone(); + snapshot_callee(&mut lowered, &mut lets, locals)?; + let args = call_args(&lowered) + .expect("callee snapshot preserves the call") + .to_vec(); + let mut final_args = Vec::with_capacity(args.len()); + for (index, arg) in args.iter().enumerate() { + let later_exits = args[index + 1..].iter().any(contains_try); + final_args.push(bind_call_arg(arg, later_exits, &mut lets, locals)?); + } + set_call_args(&mut lowered, final_args); + Ok(Some(Expr::Block { + stmts: lets, + expr: Box::new(lowered), + })) +} + +fn call_args(expr: &Expr) -> Option<&[Expr]> { + match expr { + Expr::Call(_, _, args, _, _) + | Expr::ModuleCall(_, _, args, _) + | Expr::LocalCall(_, _, args, _) + | Expr::ClosureCall(_, args) => Some(args), + _ => None, + } +} + +fn set_call_args(expr: &mut Expr, args: Vec) { + match expr { + Expr::Call(_, _, slot, _, _) + | Expr::ModuleCall(_, _, slot, _) + | Expr::LocalCall(_, _, slot, _) + | Expr::ClosureCall(_, slot) => *slot = args, + _ => {} + } +} + +fn snapshot_callee( + expr: &mut Expr, + lets: &mut Vec, + locals: &mut usize, +) -> Result<(), ParseError> { + match expr { + Expr::LocalCall(slot, _, _, _) => { + let saved = allocate_slot(locals)?; + lets.push(Stmt::Let { + index: saved, + declared_schema: None, + expr: Expr::Var(*slot), + line: 1, + }); + *slot = saved; + } + Expr::ClosureCall(closure, args) => { + let saved = allocate_slot(locals)?; + let closure = closure.clone(); + let args = std::mem::take(args); + lets.push(Stmt::Let { + index: saved, + declared_schema: None, + expr: Expr::Closure(closure), + line: 1, + }); + *expr = Expr::LocalCall(saved, Vec::new(), args, None); + } + _ => {} + } + Ok(()) +} + +fn bind_call_arg( + arg: &Expr, + later_exits: bool, + lets: &mut Vec, + locals: &mut usize, +) -> Result { + if let Some(preserved) = preserved_borrow(arg, lets, locals)? { + return Ok(preserved); + } + if is_bare_local(arg) && !later_exits { + return Ok(arg.clone()); + } + let slot = allocate_slot(locals)?; + let moved = matches!(arg, Expr::MoveVar(_)); + let schema = if moved { None } else { owned_schema(arg) }; + lets.push(Stmt::Let { + index: slot, + declared_schema: schema.clone(), + expr: arg.clone(), + line: 1, + }); + Ok(if moved || schema.is_some() { + Expr::MoveVar(slot) + } else { + Expr::Var(slot) + }) +} + +fn preserved_borrow( + arg: &Expr, + lets: &mut Vec, + locals: &mut usize, +) -> Result, ParseError> { + let (mutable, inner) = match arg { + Expr::Borrow(inner) => (false, inner.as_ref()), + Expr::BorrowMut(inner) => (true, inner.as_ref()), + _ => return Ok(None), + }; + if is_stable_borrow_receiver(inner) { + return Ok(Some(arg.clone())); + } + let slot = allocate_slot(locals)?; + lets.push(Stmt::Let { + index: slot, + declared_schema: None, + expr: inner.clone(), + line: 1, + }); + let receiver = Box::new(Expr::Var(slot)); + Ok(Some(if mutable { + Expr::BorrowMut(receiver) + } else { + Expr::Borrow(receiver) + })) +} + +fn is_stable_borrow_receiver(expr: &Expr) -> bool { + matches!( + expr, + Expr::Var(_) | Expr::MoveVar(_) | Expr::MoveField { .. } | Expr::MoveIndex { .. } + ) +} + +fn is_bare_local(expr: &Expr) -> bool { + matches!(expr, Expr::Var(_) | Expr::MoveVar(_)) +} + +fn contains_try(expr: &Expr) -> bool { + match expr { + Expr::ResultTry { .. } => true, + Expr::OptionalGet { container, key, .. } => contains_try(container) || contains_try(key), + Expr::OptionUnwrapOr { + value, fallback, .. + } => contains_try(value) || contains_try(fallback), + Expr::Call(_, _, args, _, _) + | Expr::ModuleCall(_, _, args, _) + | Expr::LocalCall(_, _, args, _) + | Expr::ClosureCall(_, args) => args.iter().any(contains_try), + Expr::Closure(closure) => contains_try(&closure.body), + Expr::Add(lhs, rhs) + | Expr::Sub(lhs, rhs) + | Expr::Mul(lhs, rhs) + | Expr::Div(lhs, rhs) + | Expr::Mod(lhs, rhs) + | Expr::And(lhs, rhs) + | Expr::Or(lhs, rhs) + | Expr::Eq(lhs, rhs) + | Expr::Lt(lhs, rhs) + | Expr::Gt(lhs, rhs) => contains_try(lhs) || contains_try(rhs), + Expr::Neg(inner) + | Expr::Not(inner) + | Expr::ToOwned(inner) + | Expr::Borrow(inner) + | Expr::BorrowMut(inner) => contains_try(inner), + Expr::IfElse { + condition, + then_expr, + else_expr, + } => contains_try(condition) || contains_try(then_expr) || contains_try(else_expr), + Expr::Match { + value, + arms, + default, + .. + } => { + contains_try(value) + || arms.iter().any(|(_, arm)| contains_try(arm)) + || contains_try(default) + } + Expr::Block { stmts, expr } => stmts.iter().any(stmt_contains_try) || contains_try(expr), + Expr::ResultConstruct { payload, .. } => contains_try(payload), + Expr::ResultMatch { + value, + ok_expr, + err_expr, + .. + } => contains_try(value) || contains_try(ok_expr) || contains_try(err_expr), + Expr::ResultQuery { value, .. } => contains_try(value), + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::String(_) + | Expr::Bytes(_) + | Expr::FunctionRef(_, _) + | Expr::ModuleFunctionRef(_, _) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Var(_) + | Expr::MoveVar(_) + | Expr::MoveField { .. } + | Expr::MoveIndex { .. } => false, + } +} + +fn stmt_contains_try(stmt: &Stmt) -> bool { + match stmt { + Stmt::Let { expr, .. } | Stmt::Assign { expr, .. } | Stmt::Expr { expr, .. } => { + contains_try(expr) + } + Stmt::ClosureLet { closure, .. } => contains_try(&closure.body), + Stmt::IfElse { + condition, + then_branch, + else_branch, + .. + } => { + contains_try(condition) + || then_branch.iter().any(stmt_contains_try) + || else_branch.iter().any(stmt_contains_try) + } + Stmt::For { + init, + condition, + post, + body, + .. + } => { + stmt_contains_try(init) + || contains_try(condition) + || stmt_contains_try(post) + || body.iter().any(stmt_contains_try) + } + Stmt::While { + condition, body, .. + } => contains_try(condition) || body.iter().any(stmt_contains_try), + Stmt::Noop { .. } + | Stmt::FuncDecl { .. } + | Stmt::Break { .. } + | Stmt::Continue { .. } + | Stmt::Drop { .. } => false, + } +} + +fn owned_schema(expr: &Expr) -> Option { + match expr { + Expr::Call(index, _, args, resolved, _) => { + if let Some(resolved) = resolved + && resolved.return_type.contains_resource() + { + return Some(resolved.return_type.clone()); + } + if *index == BuiltinFunction::ArrayNew.call_index() { + let inner = args.iter().find_map(owned_schema)?; + return Some(TypeSchema::Array(Box::new(inner))); + } + None + } + Expr::Block { expr, .. } => owned_schema(expr), + _ => None, + } +} + +fn allocate_slot(locals: &mut usize) -> Result { + let slot = u16::try_from(*locals).map_err(|_| { + ParseError::new("too many locals while binding a resource argument before `?`".to_string()) + })?; + *locals += 1; + Ok(slot) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn codegen(ir: &FrontendIr) -> crate::Program { + let mut compiler = super::super::Compiler::new(); + compiler.set_root_local_count(ir.locals); + compiler.set_function_impls(ir.function_impls.clone()); + compiler.set_function_decls( + ir.functions + .iter() + .cloned() + .map(|decl| (decl.index, decl)) + .collect(), + ); + compiler.set_struct_schemas(ir.struct_schemas.clone()); + compiler.set_type_inference(super::super::typing::infer_types( + ir, + super::super::TypingMode::DynamicHints, + &[], + )); + compiler + .compile_program(&ir.stmts) + .expect("NoResult codegen") + } + + #[test] + fn no_result_controls_add_no_try_locals_instructions_or_runtime_allocation_work() { + for source in [ + "let x: int = 4; x + 1;", + "fn inc(n: int) -> int { n + 1 } inc(4);", + "fn inc(n: int) -> int { n + 1 } let f: fn(int) -> int = inc; f(4);", + "let xs = [4, 5]; xs[0] + xs[1];", + ] { + let model = crate::analyze_source(source).expect("NoResult admission"); + let mut ir = model.ir().clone(); + assert!(!ir.stmts.iter().any(stmt_contains_try)); + let before_ir = format!("{ir:?}"); + let before = codegen(&ir); + let locals = ir.locals; + lower_partial_resource_args(&mut ir).unwrap(); + let structs = ir.struct_schemas.clone(); + attach_err_cleanup_schemas(&mut ir, &vec![None; locals], &structs); + assert_eq!(ir.locals, locals, "no hidden Try locals: {source}"); + assert_eq!( + format!("{ir:?}"), + before_ir, + "no inserted Map/call/branch nodes: {source}" + ); + let after = codegen(&ir); + assert_eq!( + after.code, before.code, + "no added instruction or allocation work: {source}" + ); + assert_eq!(after.constants, before.constants); + assert_eq!(after.local_count, before.local_count); + assert!(after.ret_cleanups.is_empty()); + assert!( + !after + .constants + .iter() + .any(|value| value == &crate::Value::string("tag") + || value == &crate::Value::string("payload")) + ); + } + } +} diff --git a/src/compiler/typing/collect.rs b/src/compiler/typing/collect.rs index 808e2ebc..3312bec1 100644 --- a/src/compiler/typing/collect.rs +++ b/src/compiler/typing/collect.rs @@ -386,6 +386,7 @@ fn collect_root_expr( | Expr::Closure(_) => {} Expr::ResultConstruct { payload, .. } => operand(payload, false), Expr::ResultQuery { value, .. } => operand(value, false), + Expr::ResultTry { value, .. } => operand(value, false), Expr::ResultMatch { value, ok_expr, @@ -1224,6 +1225,19 @@ fn collect_expr_types( ); let _ = context.infer_expr_type(expr, state); } + Expr::ResultTry { value, .. } => { + collect_expr_types( + value, + state, + local_types, + local_schemas, + local_schema_labels, + callable_slots, + optional_slots, + context, + ); + let _ = context.infer_expr_type(expr, state); + } Expr::ResultMatch { value, value_slot, diff --git a/src/compiler/typing/context.rs b/src/compiler/typing/context.rs index 3472f242..8a6d67ff 100644 --- a/src/compiler/typing/context.rs +++ b/src/compiler/typing/context.rs @@ -567,6 +567,27 @@ impl<'a> TypeContext<'a> { } } + pub(super) fn selected_result_success_schema( + &mut self, + operand: &Expr, + operand_schema: &TypeSchema, + state: &LocalTypeState, + ) -> Option { + let schema = if matches!(operand_schema, TypeSchema::Result { .. }) { + operand_schema.clone() + } else { + self.infer_expr_schema(operand, state)? + }; + // Optional is not a try operand. Unknown has no success authority. + if schema.is_optional() || matches!(schema, TypeSchema::Unknown) { + return None; + } + match schema { + TypeSchema::Result { ok, .. } => Some(*ok), + _ => None, + } + } + pub(super) fn bound_type_for_schema(&mut self, schema: &TypeSchema) -> BoundType { bound_type_from_schema(&self.resolve_schema(schema)) } @@ -782,6 +803,13 @@ impl<'a> TypeContext<'a> { self.apply_stmts(stmts, &mut nested); self.expr_is_optional(expr, &nested) } + Expr::ResultTry { + value, + operand_schema, + .. + } => self + .selected_result_success_schema(value, operand_schema, state) + .is_some_and(|schema| schema.is_optional()), _ => false, } } @@ -853,6 +881,7 @@ impl<'a> TypeContext<'a> { Expr::ToOwned(inner) | Expr::Borrow(inner) | Expr::BorrowMut(inner) => { self.infer_optional_expr_inner_type(inner, state) } + Expr::ResultTry { .. } => self.infer_expr_type(expr, state), _ => self.infer_expr_type(expr, state), } } @@ -884,6 +913,13 @@ impl<'a> TypeContext<'a> { Expr::ToOwned(inner) | Expr::Borrow(inner) | Expr::BorrowMut(inner) => { self.infer_optional_expr_inner_schema(inner, state) } + Expr::ResultTry { + value, + operand_schema, + .. + } => self + .selected_result_success_schema(value, operand_schema, state) + .map(|schema| schema.clone_inner_if_optional()), _ => self.infer_expr_schema(expr, state), } } @@ -1332,6 +1368,11 @@ impl<'a> TypeContext<'a> { self.infer_expr_schema(expr, &nested) } Expr::ResultQuery { .. } => Some(TypeSchema::Bool), + Expr::ResultTry { + value, + operand_schema, + .. + } => self.selected_result_success_schema(value, operand_schema, state), Expr::ResultConstruct { result_schema, .. } => match result_schema { TypeSchema::Unknown => None, other => Some(self.substitute_schema_generics(other)), @@ -1507,6 +1548,16 @@ impl<'a> TypeContext<'a> { let _ = self.infer_expr_type(value, state); BoundType::Bool } + Expr::ResultTry { + value, + operand_schema, + .. + } => { + let _ = self.infer_expr_type(value, state); + self.selected_result_success_schema(value, operand_schema, state) + .map(|schema| self.bound_type_for_schema(&schema)) + .unwrap_or(BoundType::Unknown) + } Expr::ResultMatch { value, ok_binding, diff --git a/src/compiler/typing/helpers.rs b/src/compiler/typing/helpers.rs index 614f405c..38e0e98a 100644 --- a/src/compiler/typing/helpers.rs +++ b/src/compiler/typing/helpers.rs @@ -1932,7 +1932,8 @@ pub(super) fn expr_contains_param_add(expr: &Expr, param_slots: &[LocalSlot]) -> } other @ (Expr::ResultConstruct { .. } | Expr::ResultQuery { .. } - | Expr::ResultMatch { .. }) => { + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { let mut found = false; other.visit_result_children(|child| { found |= expr_contains_param_add(child, param_slots) @@ -2013,7 +2014,8 @@ pub(super) fn expr_uses_param(expr: &Expr, param_slots: &[LocalSlot]) -> bool { } other @ (Expr::ResultConstruct { .. } | Expr::ResultQuery { .. } - | Expr::ResultMatch { .. }) => { + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { let mut found = false; other.visit_result_children(|child| found |= expr_uses_param(child, param_slots)); found @@ -2222,6 +2224,17 @@ pub(super) fn legalize_expr( let _ = legalize_expr(value, state, context, site, host_resolution); BoundType::Bool } + Expr::ResultTry { + value, + operand_schema, + .. + } => { + let _ = legalize_expr(value, state, context, site, host_resolution); + context + .selected_result_success_schema(value, operand_schema, state) + .map(|schema| context.bound_type_for_schema(&schema)) + .unwrap_or(BoundType::Unknown) + } Expr::ResultMatch { value, ok_binding, diff --git a/src/compiler/typing/validate.rs b/src/compiler/typing/validate.rs index 5ab0c3cb..cfeb5675 100644 --- a/src/compiler/typing/validate.rs +++ b/src/compiler/typing/validate.rs @@ -1075,6 +1075,24 @@ pub(super) fn validate_expr( )?; BoundType::Bool } + Expr::ResultTry { + value, + operand_schema, + .. + } => { + let _ = validate_expr( + value, + state, + line_context, + source_name, + context, + strict_function_add_types, + )?; + match operand_schema { + TypeSchema::Result { ok, .. } => context.bound_type_for_schema(ok), + _ => BoundType::Unknown, + } + } Expr::ResultMatch { value, ok_binding, diff --git a/src/lib.rs b/src/lib.rs index aca9b61d..65a93512 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,7 +84,8 @@ pub use builtins::{ pub use bytecode::{ CallableEnvironment, CallableKind, CallablePrototype, CallableTarget, CallableValue, CaptureBindingMode, ExportedCallable, FunctionRegion, HostImport, MAX_FRAME_LOCAL_COUNT, - OpCode, Program, RootCallableBinding, ScriptFunction, TypeMap, Value, ValueType, VmMap, + OpCode, Program, RetCleanupLocal, RetCleanupPlan, RootCallableBinding, ScriptFunction, TypeMap, + Value, ValueType, VmMap, }; pub use host_api::{ FunctionNameError, HostApiBuilder, HostApiCatalog, HostApiCatalogError, HostApiFingerprint, diff --git a/src/vm/aot/artifact.rs b/src/vm/aot/artifact.rs index b08302e6..f6a6858d 100644 --- a/src/vm/aot/artifact.rs +++ b/src/vm/aot/artifact.rs @@ -1289,4 +1289,109 @@ mod tests { standalone.dump_aot_info() ); } + + #[cfg(feature = "cranelift-jit")] + #[test] + fn artifact_load_rejects_cleanup_only_metadata_change() { + let compiled = crate::compile_source("fn id(x: int) -> int { x }\nid(1);") + .expect("cleanup binding fixture should compile"); + let program = compiled.program.with_local_count(compiled.locals); + let site = script_ret_site(&program); + let mut with_cleanup = program.clone(); + with_cleanup + .ret_cleanups + .push(crate::bytecode::RetCleanupPlan { + site, + locals: vec![crate::bytecode::RetCleanupLocal { + slot: 0, + schema: crate::compiler::TypeSchema::Resource( + crate::host_api::ResourceTypeKey::new("pr24.closure").unwrap(), + ), + }], + }); + let mut other_key = with_cleanup.clone(); + other_key.ret_cleanups[0].locals[0].schema = crate::compiler::TypeSchema::Resource( + crate::host_api::ResourceTypeKey::new("pr24.blocked").unwrap(), + ); + let original_program = + crate::vmbc::encode_program(&with_cleanup).expect("cleanup program encodes"); + let other_program = + crate::vmbc::encode_program(&other_key).expect("same-length key encodes"); + assert_eq!(original_program.len(), other_program.len()); + assert_ne!(original_program, other_program); + + let mut vm = Vm::new(with_cleanup.clone()); + vm.compile_aot() + .expect("cleanup fixture should compile to aot"); + let bytes = vm.encode_aot_artifact().expect("artifact encodes"); + let path = std::env::temp_dir().join("r2-a02-cleanup-only-artifact.bin"); + vm.save_aot_artifact_to_file(&path).expect("artifact saves"); + + let mut mismatched = Vm::new(other_key.clone()); + let incompatible = mismatched + .load_aot_artifact_from_file(&path) + .expect_err("cleanup-only program mismatch must reject"); + assert!( + matches!( + incompatible, + AotArtifactError::IncompatibleProgramHash { .. } + ), + "{incompatible:?}" + ); + assert!(!mismatched.has_aot_program()); + + let pos = bytes + .windows(original_program.len()) + .position(|window| window == original_program) + .expect("artifact embeds the program bytes"); + let mut tampered = bytes.clone(); + tampered[pos..pos + other_program.len()].copy_from_slice(&other_program); + let mut intact = Vm::new(with_cleanup.clone()); + let hash_error = intact + .load_aot_artifact(&tampered) + .expect_err("tampered cleanup bytes must fail the embedded hash"); + assert!( + matches!( + hash_error, + AotArtifactError::EmbeddedProgramHashMismatch { .. } + ), + "{hash_error:?}" + ); + assert!(!intact.has_aot_program()); + assert_eq!(intact.aot_exec_count(), 0); + + let mut positive = Vm::new(with_cleanup); + positive + .load_aot_artifact_from_file(&path) + .expect("matching cleanup metadata must load"); + assert!(positive.has_aot_program()); + let _ = std::fs::remove_file(&path); + } + + fn script_ret_site(program: &Program) -> u32 { + let mut ip = 0usize; + while ip < program.code.len() { + let opcode = crate::vm::OpCode::try_from(program.code[ip]).expect("opcode"); + if opcode == crate::vm::OpCode::Ret { + let site = u32::try_from(ip).unwrap(); + let in_frame = program.function_regions.iter().any(|region| { + site >= region.start_ip + && site < region.end_ip + && region.prototype_id.is_some_and(|id| { + program + .callable_prototypes + .get(id as usize) + .is_some_and(|prototype| prototype.frame_local_count > 0) + }) + }); + if in_frame { + return site; + } + } + ip = ip + .checked_add(1 + opcode.operand_len()) + .expect("instruction length"); + } + panic!("fixture has no script Ret inside a nonempty frame"); + } } diff --git a/src/vm/aot/ssa.rs b/src/vm/aot/ssa.rs index 17f04ac5..0a9be3a0 100644 --- a/src/vm/aot/ssa.rs +++ b/src/vm/aot/ssa.rs @@ -1410,7 +1410,13 @@ fn function_region_entry_shape( }; for (slot, schema) in prototype.parameter_slots.iter().zip(params.iter()) { if let Some(repr) = shape.locals.get_mut(*slot as usize) { - *repr = value_type_repr(schema.coarse_value_type()); + // Optional's coarse payload type cannot describe a nullable + // native entry value. Keep the tag across the frame boundary. + *repr = if matches!(schema, crate::compiler::TypeSchema::Optional(_)) { + AotSsaValueRepr::Tagged + } else { + value_type_repr(schema.coarse_value_type()) + }; } } shape @@ -2862,11 +2868,24 @@ fn call_result_reprs( .map(value_type_repr) }) .unwrap_or(AotSsaValueRepr::Tagged), - AotCallDispatch::HostImport => program - .imports - .get(call.index as usize) - .map(|import| value_type_repr(import.return_type)) - .unwrap_or(AotSsaValueRepr::Tagged), + AotCallDispatch::HostImport => { + let nullable = program + .host_import_schemas() + .get(call.index as usize) + .and_then(Option::as_ref) + .is_some_and(|schema| { + matches!(schema.return_type, crate::HostTypeSchema::Optional(_)) + }); + if nullable { + AotSsaValueRepr::Tagged + } else { + program + .imports + .get(call.index as usize) + .map(|import| value_type_repr(import.return_type)) + .unwrap_or(AotSsaValueRepr::Tagged) + } + } }; vec![repr; result_count] } @@ -2940,6 +2959,203 @@ mod tests { use super::*; use crate::{BytecodeBuilder, HostImport, Value}; + #[test] + fn aotnull_exact_err_fallback_has_tagged_resume_and_native_execution() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct Fallback(Arc); + impl crate::HostFunction for Fallback { + fn call( + &mut self, + _vm: &mut crate::Vm, + _args: &[Value], + ) -> crate::VmResult { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(crate::CallOutcome::Return(crate::CallReturn::one( + Value::map(vec![ + (Value::string("tag"), Value::Bool(false)), + (Value::string("payload"), Value::Int(9)), + ]), + ))) + } + } + + let mut catalog = crate::HostApiBuilder::new(); + catalog.function(crate::HostFunctionSchema::with_return( + "test::make_err_int", + Vec::new(), + crate::HostTypeSchema::Result { + ok: Box::new(crate::HostTypeSchema::Int), + err: Box::new(crate::HostTypeSchema::Int), + }, + )); + let compiled = crate::compile_source_with_flavor_and_options( + "fn absent() -> int? { null } fn relay() -> Result { let x = absent().unwrap_or(test::make_err_int()?); Result::Ok(0) } let carried = relay(); carried;", + crate::SourceFlavor::RustScript, + crate::CompileSourceFileOptions::new() + .with_host_api_catalog(Arc::new(catalog.build().unwrap())), + ) + .unwrap(); + let program = compiled.program.with_local_count(compiled.locals); + let ssa = build_aot_ssa(&program).unwrap(); + // The exact relay's CallScript at ip24 resumes with a nullable value. + assert_eq!( + ssa.checkpoint_for_ip(30).unwrap().stack, + vec![AotSsaValueRepr::Tagged] + ); + assert!(!ssa.text().contains("InterpreterBoundary")); + println!("AOTNULL exact Err9 SSA\n{}", ssa.text()); + let calls = Arc::new(AtomicUsize::new(0)); + let mut vm = crate::Vm::new(program); + vm.set_fuel(100_000); + vm.bind_function("test::make_err_int", Box::new(Fallback(calls.clone()))); + vm.compile_aot().unwrap(); + assert_eq!(vm.run().unwrap(), crate::VmStatus::Halted); + assert_eq!( + vm.stack().last(), + Some(&Value::map(vec![ + (Value::string("tag"), Value::Bool(false)), + (Value::string("payload"), Value::Int(9)), + ])) + ); + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(vm.aot_exec_count() > 0); + assert!(vm.execution_scope().is_active()); + println!( + "AOTNULL exact Err9 fallback_calls={}\n{}", + calls.load(Ordering::SeqCst), + vm.dump_aot_info() + ); + } + + #[test] + fn aotnull_optional_call_representation_control() { + let mut failures = Vec::new(); + for expression in ["0", "7", "null"] { + let source = format!( + "fn absent() -> int? {{ {expression} }} let carried = absent().unwrap_or(41); carried;" + ); + let compiled = crate::compile_source(&source).unwrap(); + let program = compiled.program.with_local_count(compiled.locals); + println!( + "AOTNULL source={source}\n{}\nTYPEMAP={:#?}", + crate::disassemble_program(&program), + program.type_map + ); + let ssa = build_aot_ssa(&program).unwrap(); + assert_eq!( + ssa.checkpoint_for_ip(6).unwrap().stack, + vec![AotSsaValueRepr::Tagged] + ); + assert!(!ssa.text().contains("InterpreterBoundary")); + println!( + "SSA={}\nIR={:#?}", + ssa.text(), + super::super::ir::lower_program(&program).unwrap() + ); + for backend in 0..3 { + let program = if backend == 1 { + crate::decode_program(&crate::encode_program(&program).unwrap()).unwrap() + } else { + program.clone() + }; + let mut vm = crate::Vm::new(program); + vm.set_fuel(100_000); + if backend == 2 { + vm.compile_aot().unwrap(); + } + assert_eq!(vm.run().unwrap(), crate::VmStatus::Halted); + println!( + "AOTNULL expression={expression} backend={backend} stack={:?}\n{}", + vm.stack(), + vm.dump_aot_info() + ); + let expected = Value::Int(if expression == "null" { + 41 + } else { + expression.parse().unwrap() + }); + if vm.stack().last() != Some(&expected) { + failures.push(format!("{expression} backend={backend}: {:?}", vm.stack())); + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); + } + + #[test] + fn aotnull_nullable_parameter_alias_and_local_controls() { + for expression in ["0", "7", "null"] { + for source in [ + format!("fn relay(x:int?) -> int {{ x.unwrap_or(41) }} relay({expression});"), + format!( + "fn absent() -> int? {{ {expression} }} let f = absent; f().unwrap_or(41);" + ), + format!("let x:int? = {expression}; x.unwrap_or(41);"), + ] { + let compiled = crate::compile_source(&source).unwrap(); + let program = compiled.program.with_local_count(compiled.locals); + println!( + "AOTNULL source={source}\n{}\n{}", + crate::disassemble_program(&program), + build_aot_ssa(&program).unwrap().text() + ); + let mut vm = crate::Vm::new(program); + vm.set_fuel(100_000); + vm.compile_aot().unwrap(); + assert_eq!(vm.run().unwrap(), crate::VmStatus::Halted); + println!("stack={:?}\n{}", vm.stack(), vm.dump_aot_info()); + assert_eq!( + vm.stack().last(), + Some(&Value::Int(if expression == "null" { + 41 + } else { + expression.parse().unwrap() + })) + ); + } + } + } + + #[test] + fn aotnull_imported_nested_optional_returns() { + let root = std::env::temp_dir().join(format!("aotnull-import-{}", std::process::id())); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("nullable.rss"), "fn pick(flag:bool) -> int? { if flag => { null } else => { 0 } } pub fn relay(flag:bool) -> int? { pick(flag) }").unwrap(); + for (flag, expected) in [(true, 41), (false, 0)] { + let main = root.join("main.rss"); + std::fs::write( + &main, + format!("use self::nullable as imported; imported::relay({flag}).unwrap_or(41);"), + ) + .unwrap(); + let compiled = crate::compile_source_file(&main).unwrap(); + let program = compiled.program.with_local_count(compiled.locals); + let ssa = build_aot_ssa(&program).unwrap(); + assert!(!ssa.text().contains("InterpreterBoundary")); + println!("AOTNULL imported flag={flag}\n{}", ssa.text()); + for backend in 0..3 { + let program = if backend == 1 { + crate::decode_program(&crate::encode_program(&program).unwrap()).unwrap() + } else { + program.clone() + }; + let mut vm = crate::Vm::new(program); + vm.set_fuel(100_000); + if backend == 2 { + vm.compile_aot().unwrap(); + } + assert_eq!(vm.run().unwrap(), crate::VmStatus::Halted); + assert_eq!(vm.stack().last(), Some(&Value::Int(expected))); + if backend == 2 { + assert!(vm.aot_exec_count() > 0); + } + } + } + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn aot_ssa_number_relations_retain_dynamic_operands_without_hints() { for (body, expected) in [ diff --git a/src/vm/mod.rs b/src/vm/mod.rs index e143aead..e0f26ad1 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -463,6 +463,15 @@ fn compute_program_cache_key(program: &Program) -> u64 { decl.type_params.hash(&mut hasher); hash_type_schema(&decl.body_schema, &mut hasher); } + program.ret_cleanups.len().hash(&mut hasher); + for plan in &program.ret_cleanups { + plan.site.hash(&mut hasher); + plan.locals.len().hash(&mut hasher); + for local in &plan.locals { + local.slot.hash(&mut hasher); + hash_type_schema(&local.schema, &mut hasher); + } + } hasher.finish() } @@ -2191,7 +2200,142 @@ impl Vm { } } - fn complete_active_frame(&mut self) -> VmResult { + fn close_ret_owned_resources(&mut self, ret_site: usize) -> VmResult<()> { + let Some(plan) = self + .program + .ret_cleanups + .iter() + .find(|plan| plan.site as usize == ret_site) + .cloned() + else { + return Ok(()); + }; + if !ret_cleanup_site_is_boundary(&self.program.code, ret_site) { + return Err(VmError::InvalidFrameState( + "cleanup site is not a Ret instruction", + )); + } + let frame = self + .instance + .execution_frames + .last() + .ok_or(VmError::InvalidFrameState( + "cleanup frame is missing before resource close", + ))?; + for local in &plan.locals { + if local.slot as usize >= frame.local_count { + return Err(VmError::InvalidFrameState( + "cleanup local is outside the active frame", + )); + } + let absolute = frame + .local_base + .checked_add(local.slot as usize) + .ok_or(VmError::InvalidFrameState("cleanup local frame overflow"))?; + if self.instance.capture_cells.contains_key(&absolute) + || self.instance.shared_capture_slots.contains(&absolute) + { + return Err(VmError::InvalidFrameState( + "cleanup plan targets a persistent capture", + )); + } + } + let return_schema = frame.prototype_id.and_then(|prototype_id| { + self.program + .callable_prototypes + .get(prototype_id as usize) + .and_then(|prototype| prototype.schema.as_ref()) + .and_then(|schema| match schema { + crate::compiler::TypeSchema::Callable { result, .. } => { + Some(result.as_ref().clone()) + } + _ => None, + }) + }); + let return_value = if self.instance.stack.len() > frame.operand_stack_base { + self.instance.stack.last().cloned() + } else { + None + }; + let lookup = NamedStructLookup { + host: &self.host.named_struct_schemas, + guest: &self.program.named_struct_decls, + }; + let mut cleanup_nodes = 0usize; + let mut protected = HashSet::new(); + if let (Some(schema), Some(value)) = (return_schema, return_value) { + let mut returned = Vec::new(); + collect_ret_resource_handles( + &value, + &schema, + lookup, + &mut cleanup_nodes, + &mut returned, + )?; + for (raw, _) in returned { + protected.insert(raw); + } + } + let mut pending = Vec::new(); + for local in &plan.locals { + let index = u8::try_from(local.slot).map_err(|_| VmError::InvalidLocal(0))?; + let value = self.load_local_value(index)?; + collect_ret_resource_handles( + &value, + &local.schema, + lookup, + &mut cleanup_nodes, + &mut pending, + )?; + } + let mut seen = HashSet::new(); + let mut validated = Vec::with_capacity(pending.len()); + for (raw, key) in pending { + if !seen.insert(raw) { + return Err(VmError::InvalidFrameState( + "cleanup plan has duplicate resource ownership", + )); + } + if protected.contains(&raw) { + return Err(VmError::InvalidFrameState( + "cleanup plan overlaps the returned resource graph", + )); + } + let handle = crate::ResourceHandle::from_raw(raw) + .map_err(|error| VmError::ExecutionScope(ExecutionScopeError::Resource(error)))?; + validated.push((handle, key)); + } + { + let table = self.execution_scope().resources(); + for (handle, key) in &validated { + table + .validate_resource_type_key(*handle, key) + .map_err(|error| { + VmError::ExecutionScope(ExecutionScopeError::Resource(error)) + })?; + } + } + let mut first_error = None; + for (handle, key) in validated { + if let Err(error) = self + .execution_scope() + .resources_mut() + .close_erased_open_slot(handle, &key, crate::ResourceCloseReason::Parent) + && first_error.is_none() + { + first_error = Some(VmError::ExecutionScope(ExecutionScopeError::Resource( + error, + ))); + } + } + if let Some(error) = first_error { + return Err(error); + } + Ok(()) + } + + fn complete_active_frame(&mut self, ret_site: usize) -> VmResult { + self.close_ret_owned_resources(ret_site)?; let frame = self .instance .execution_frames @@ -3255,7 +3399,10 @@ impl Vm { ) -> VmResult { match opcode { x if x == OpCode::Nop as u8 => {} - x if x == OpCode::Ret as u8 => return self.complete_active_frame(), + x if x == OpCode::Ret as u8 => { + let ret_site = self.instance.ip - 1; + return self.complete_active_frame(ret_site); + } x if x == OpCode::Ldc as u8 => { let opcode_ip = self.instance.ip - 1; let value = if let Some(value) = self.decoded_ldc_value_at(opcode_ip).cloned() { @@ -3562,7 +3709,8 @@ impl Vm { self.charge_interrupt_tick()?; } self.instance.ip = self.instance.ip.saturating_add(1); - return self.complete_active_frame(); + let ret_site = self.instance.ip - 1; + return self.complete_active_frame(ret_site); } } HostCallExecOutcome::Halted => return Ok(ExecOutcome::Halted), @@ -4068,3 +4216,332 @@ impl Vm { .collect() } } + +fn ret_cleanup_site_is_boundary(code: &[u8], ret_site: usize) -> bool { + let mut ip = 0usize; + while ip < code.len() { + let Ok(opcode) = crate::OpCode::try_from(code[ip]) else { + return false; + }; + if ip == ret_site { + return opcode == crate::OpCode::Ret; + } + let Some(next) = ip + .checked_add(1) + .and_then(|value| value.checked_add(opcode.operand_len())) + else { + return false; + }; + if next > code.len() { + return false; + } + ip = next; + } + false +} + +const RET_CLEANUP_MAX_HANDLES: usize = resource::handle::DEFAULT_MAX_RESOURCES; + +fn collect_ret_resource_handles<'a>( + value: &Value, + schema: &crate::compiler::TypeSchema, + lookup: NamedStructLookup<'a>, + nodes: &'a mut usize, + out: &'a mut Vec<(u64, crate::ResourceTypeKey)>, +) -> VmResult<()> { + let mut walk = RetCleanupWalk { + lookup, + nodes, + seen_containers: HashSet::new(), + seen_handles: HashSet::new(), + active_named: HashSet::new(), + out, + }; + walk.collect(value, schema, 0) +} + +struct RetCleanupWalk<'a> { + lookup: NamedStructLookup<'a>, + nodes: &'a mut usize, + seen_containers: HashSet<(usize, crate::compiler::TypeSchema)>, + seen_handles: HashSet, + active_named: HashSet, + out: &'a mut Vec<(u64, crate::ResourceTypeKey)>, +} + +impl RetCleanupWalk<'_> { + fn collect( + &mut self, + value: &Value, + schema: &crate::compiler::TypeSchema, + depth: usize, + ) -> VmResult<()> { + if !self.schema_owns(schema, depth)? { + return Ok(()); + } + self.walk(value, schema, depth) + } + + fn charge(&mut self, depth: usize) -> VmResult<()> { + if depth >= crate::host_api::MAX_HOST_SCHEMA_DEPTH { + return Err(VmError::InvalidFrameState( + "cleanup walk exceeded schema depth", + )); + } + *self.nodes = self.nodes.checked_add(1).ok_or(VmError::InvalidFrameState( + "cleanup walk node budget overflow", + ))?; + if *self.nodes > crate::host_api::MAX_HOST_SCHEMA_NODES { + return Err(VmError::InvalidFrameState( + "cleanup walk exceeded node budget", + )); + } + Ok(()) + } + + fn schema_owns( + &mut self, + schema: &crate::compiler::TypeSchema, + depth: usize, + ) -> VmResult { + use crate::compiler::TypeSchema; + self.charge(depth)?; + let next = depth + 1; + match schema { + TypeSchema::Resource(_) => Ok(true), + TypeSchema::Optional(inner) | TypeSchema::Array(inner) | TypeSchema::Map(inner) => { + self.schema_owns(inner, next) + } + TypeSchema::ArrayTuple(items) => { + let mut owned = false; + for item in items { + owned |= self.schema_owns(item, next)?; + } + Ok(owned) + } + TypeSchema::ArrayTupleRest { prefix, rest } => { + let mut owned = self.schema_owns(rest, next)?; + for item in prefix { + owned |= self.schema_owns(item, next)?; + } + Ok(owned) + } + TypeSchema::Object(fields) => { + let mut owned = false; + for field in fields.values() { + owned |= self.schema_owns(field, next)?; + } + Ok(owned) + } + TypeSchema::Result { ok, err } => { + let ok_owned = self.schema_owns(ok, next)?; + let err_owned = self.schema_owns(err, next)?; + Ok(ok_owned || err_owned) + } + TypeSchema::Callable { params, result } => { + for param in params { + self.schema_owns(param, next)?; + } + self.schema_owns(result, next)?; + Ok(false) + } + TypeSchema::Named(name, args) => { + if !self.active_named.insert(name.clone()) { + return Err(VmError::InvalidFrameState("cleanup named schema is cyclic")); + } + let resolved = self.lookup.body(name, args); + let owned = match resolved { + Some((body, _)) => self.schema_owns(&body, next)?, + None => { + self.active_named.remove(name); + return Err(VmError::InvalidFrameState( + "cleanup named schema is unresolved or has the wrong arity", + )); + } + }; + self.active_named.remove(name); + Ok(owned) + } + TypeSchema::Unknown + | TypeSchema::Null + | TypeSchema::Int + | TypeSchema::Float + | TypeSchema::Number + | TypeSchema::Bool + | TypeSchema::String + | TypeSchema::Bytes + | TypeSchema::GenericParam(_) => Ok(false), + } + } + + fn note_handle(&mut self, raw: u64, key: &crate::ResourceTypeKey) -> VmResult<()> { + if self.out.len() >= RET_CLEANUP_MAX_HANDLES { + return Err(VmError::InvalidFrameState( + "cleanup walk exceeded resource budget", + )); + } + if !self.seen_handles.insert(raw) { + return Err(VmError::InvalidFrameState( + "cleanup plan has duplicate resource ownership", + )); + } + self.out.push((raw, key.clone())); + Ok(()) + } + + fn walk( + &mut self, + value: &Value, + schema: &crate::compiler::TypeSchema, + depth: usize, + ) -> VmResult<()> { + use crate::compiler::TypeSchema; + if let Some(ptr) = container_identity(value) + && !self.seen_containers.insert((ptr, schema.clone())) + { + return Ok(()); + } + self.charge(depth)?; + let next = depth + 1; + match schema { + TypeSchema::Resource(key) => match value { + Value::Int(raw) if *raw > 0 => self.note_handle(*raw as u64, key), + other => Err(VmError::HostError(format!( + "Err cleanup expected a resource handle, got {other:?}" + ))), + }, + TypeSchema::Optional(inner) => { + if matches!(value, Value::Null) { + return Ok(()); + } + self.walk(value, inner, next) + } + TypeSchema::Array(inner) => { + let Value::Array(items) = value else { + return Err(VmError::HostError( + "Err cleanup expected an array owning resources".to_string(), + )); + }; + for item in items.iter() { + self.walk(item, inner, next)?; + } + Ok(()) + } + TypeSchema::ArrayTuple(items_schema) => { + let Value::Array(items) = value else { + return Err(VmError::HostError( + "Err cleanup expected a tuple owning resources".to_string(), + )); + }; + if items.len() != items_schema.len() { + return Err(VmError::InvalidFrameState( + "cleanup tuple length does not match its schema", + )); + } + for (item, item_schema) in items.iter().zip(items_schema.iter()) { + self.walk(item, item_schema, next)?; + } + Ok(()) + } + TypeSchema::ArrayTupleRest { prefix, rest } => { + let Value::Array(items) = value else { + return Err(VmError::HostError( + "Err cleanup expected a tuple owning resources".to_string(), + )); + }; + if items.len() < prefix.len() { + return Err(VmError::InvalidFrameState( + "cleanup tuple is shorter than its prefix", + )); + } + for (item, item_schema) in items.iter().zip(prefix.iter()) { + self.walk(item, item_schema, next)?; + } + for item in items.iter().skip(prefix.len()) { + self.walk(item, rest, next)?; + } + Ok(()) + } + TypeSchema::Map(inner) => { + let Value::Map(map) = value else { + return Err(VmError::HostError( + "Err cleanup expected a map owning resources".to_string(), + )); + }; + for (_, item) in map.iter() { + self.walk(item, inner, next)?; + } + Ok(()) + } + TypeSchema::Object(fields) => { + let Value::Map(map) = value else { + return Err(VmError::HostError( + "Err cleanup expected an object owning resources".to_string(), + )); + }; + let mut names = fields.keys().collect::>(); + names.sort_unstable(); + for name in names { + let Some(field) = map.get(&Value::string(name)) else { + return Err(VmError::InvalidFrameState( + "cleanup object is missing a declared field", + )); + }; + self.walk(field, &fields[name], next)?; + } + Ok(()) + } + TypeSchema::Named(name, args) => { + if !self.active_named.insert(name.clone()) { + return Err(VmError::InvalidFrameState("cleanup named schema is cyclic")); + } + let Some((body, _)) = self.lookup.body(name, args) else { + self.active_named.remove(name); + return Err(VmError::InvalidFrameState( + "cleanup named schema is unresolved or has the wrong arity", + )); + }; + let result = self.walk(value, &body, next); + self.active_named.remove(name); + result + } + TypeSchema::Result { ok, err } => { + let Value::Map(map) = value else { + return Err(VmError::HostError( + "Err cleanup expected a Result map".to_string(), + )); + }; + let tag = map.get(&Value::string("tag")).cloned(); + let payload = map + .get(&Value::string("payload")) + .cloned() + .unwrap_or(Value::Null); + match tag { + Some(Value::Bool(true)) => self.walk(&payload, ok, next), + Some(Value::Bool(false)) => self.walk(&payload, err, next), + _ => Err(VmError::HostError( + "Err cleanup Result is missing a bool tag".to_string(), + )), + } + } + TypeSchema::Callable { .. } + | TypeSchema::Unknown + | TypeSchema::Null + | TypeSchema::Int + | TypeSchema::Float + | TypeSchema::Number + | TypeSchema::Bool + | TypeSchema::String + | TypeSchema::Bytes + | TypeSchema::GenericParam(_) => Ok(()), + } + } +} + +fn container_identity(value: &Value) -> Option { + match value { + Value::Array(items) => Some(Arc::as_ptr(items) as usize), + Value::Map(map) => Some(Arc::as_ptr(map) as usize), + _ => None, + } +} diff --git a/src/vm/native/bridge.rs b/src/vm/native/bridge.rs index 6907db5a..0b297f44 100644 --- a/src/vm/native/bridge.rs +++ b/src/vm/native/bridge.rs @@ -1138,7 +1138,7 @@ fn native_leave_frame(vm: &mut Vm, ret_ip: i64, inherited_state: *mut u8) -> VmR let ret_ip = usize::try_from(ret_ip) .map_err(|_| VmError::InvalidFrameState("native ret ip out of range"))?; vm.jump_to(ret_ip)?; - let status = match vm.complete_active_frame()? { + let status = match vm.complete_active_frame(ret_ip)? { ExecOutcome::Continue => STATUS_LINKED_CONTINUE, ExecOutcome::Halted => STATUS_HALTED, ExecOutcome::Yielded => STATUS_YIELDED, diff --git a/src/vm/resource/table.rs b/src/vm/resource/table.rs index 878de376..210b69eb 100644 --- a/src/vm/resource/table.rs +++ b/src/vm/resource/table.rs @@ -740,6 +740,17 @@ impl ResourceTable { *self.slots[slot_index].state.get_mut() = state; } + pub(crate) fn close_erased_open_slot( + &mut self, + handle: ResourceHandle, + expected: &ResourceTypeKey, + reason: ResourceCloseReason, + ) -> ResourceResult { + self.validate_resource_type_key(handle, expected)?; + let slot_index = self.resolve_index(handle)?; + self.close_open_slot(slot_index, handle, reason) + } + fn close_open_slot( &mut self, slot_index: usize, diff --git a/src/vm/tests.rs b/src/vm/tests.rs index 05775ba2..6a36054a 100644 --- a/src/vm/tests.rs +++ b/src/vm/tests.rs @@ -3,6 +3,7 @@ mod callable_identity; use super::*; use crate::BuiltinFunction; use crate::bytecode::TypeMap; +use crate::compiler::{StructDecl, TypeSchema}; use crate::host_api::{ HostApiBuilder, HostFunctionSchema, HostImportSchema, HostTypeSchema, ResourceTypeKey, ResourceTypeSchema, @@ -10,7 +11,7 @@ use crate::host_api::{ use crate::vm::host::WaitingHostOpSource; use crate::vm::operation::driver::{HostOperation, OperationSpec}; use crate::vm::operation::{OperationCancelReason, OperationResult}; -use crate::{BytecodeBuilder, decode_program, encode_program}; +use crate::{BytecodeBuilder, RetCleanupLocal, RetCleanupPlan, decode_program, encode_program}; use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; @@ -57,6 +58,30 @@ fn program_cache_key_includes_full_host_import_schema() { ); } +#[test] +fn program_cache_key_changes_when_only_ret_cleanup_schema_changes() { + let key = ResourceTypeKey::new("ret.cleanup.a").expect("key"); + let other = ResourceTypeKey::new("ret.cleanup.b").expect("key"); + let mut program = Program::new(Vec::new(), vec![OpCode::Ret as u8]).with_local_count(1); + program.ret_cleanups = vec![RetCleanupPlan { + site: 0, + locals: vec![RetCleanupLocal { + slot: 0, + schema: crate::compiler::TypeSchema::Resource(key), + }], + }]; + let first = compute_program_cache_key(&program); + program.ret_cleanups[0].locals[0].schema = crate::compiler::TypeSchema::Resource(other); + let second = compute_program_cache_key(&program); + program.ret_cleanups[0].site = 1; + let moved_site = compute_program_cache_key(&program); + assert_ne!(first, second, "cleanup schema is part of program identity"); + assert_ne!( + second, moved_site, + "cleanup site is part of program identity" + ); +} + #[test] fn legacy_and_marker_only_schema_absence_share_cache_identity() { let import = HostImport { @@ -4461,3 +4486,1367 @@ mod callable_resource_schema_tests { assert_eq!(vm.stack(), &[point_value(8, 9)]); } } + +#[test] +fn sol_try_waiting_operand_reset_cancels_once_without_running_guest_err_arm() { + struct Wait; + impl crate::HostFunction for Wait { + fn call(&mut self, vm: &mut Vm, _: &[Value]) -> VmResult { + vm.submit_host_future(empty_host_future()) + } + } + let mut catalog = crate::HostApiBuilder::new(); + catalog.function(crate::HostFunctionSchema::with_return( + "test::wait", + Vec::new(), + crate::HostTypeSchema::Result { + ok: Box::new(crate::HostTypeSchema::Int), + err: Box::new(crate::HostTypeSchema::String), + }, + )); + let compiled = crate::compile_source_with_flavor_and_options( + r#" + fn relay() -> Result { let n = test::wait()?; Result::Ok(n) } + let caught = match relay() { Result::Ok(_) => 1, Result::Err(_) => 2, }; caught; + "#, + crate::SourceFlavor::RustScript, + crate::CompileSourceFileOptions::new() + .with_host_api_catalog(Arc::new(catalog.build().unwrap())), + ) + .expect("waiting Try admission"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_fuel(100_000); + let cleanups = Arc::new(Mutex::new(Vec::new())); + struct Bridge { + future: Option, + requests: Arc>>, + terminals: Arc, + } + impl HostAsyncBridge for Bridge { + fn submit_op(&mut self, _: HostOpId, future: HostFuture) -> VmResult<()> { + self.future = Some(future); + Ok(()) + } + fn poll_op(&mut self, _: HostOpId, _: &mut Context<'_>) -> Poll> { + Poll::Pending + } + fn request_cancel_op( + &mut self, + op: HostOpId, + reason: OperationCancelReason, + ) -> VmResult<()> { + self.requests.lock().unwrap().push((op, reason)); + self.future.take(); + Ok(()) + } + fn poll_cancel_op(&mut self, _: HostOpId, _: &mut Context<'_>) -> Poll> { + if self.future.is_none() { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + fn cleanup_op(&mut self, _: HostOpId, terminal: HostAsyncOpTerminal) -> VmResult<()> { + assert_eq!(terminal, HostAsyncOpTerminal::Cancelled); + self.terminals.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + } + let terminals = Arc::new(AtomicU64::new(0)); + vm.set_async_bridge(Box::new(Bridge { + future: None, + requests: cleanups.clone(), + terminals: terminals.clone(), + })) + .unwrap(); + vm.bind_function("test::wait", Box::new(Wait)); + let VmStatus::Waiting(op) = vm.run().expect("operand must suspend") else { + panic!("expected actual host Pending") + }; + assert_eq!(vm.host.submitted_host_ops.len(), 1); + assert!(!vm.stack().contains(&Value::Int(2))); + vm.reset_for_reuse() + .expect("cancel pending operand on reset"); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + vm.poll_reset_for_reuse(&mut cx), + Poll::Ready(Ok(())) + )); + assert_eq!( + *cleanups.lock().unwrap(), + vec![(op, OperationCancelReason::VmReset)] + ); + assert!(vm.host.submitted_host_ops.is_empty()); + assert!(vm.stack().is_empty()); + assert_eq!(vm.execution_scope().operations().len(), 0); + drop(vm); + assert_eq!(cleanups.lock().unwrap().len(), 1); + assert_eq!(terminals.load(Ordering::SeqCst), 1); +} + +#[derive(Default)] +struct SolLifecycleStats { + begins: [std::sync::atomic::AtomicUsize; 3], + polls: [std::sync::atomic::AtomicUsize; 3], + handles: Mutex>, + fail_first: AtomicBool, + pending: AtomicBool, + ready: AtomicBool, +} + +struct SolLifecycleResource { + id: usize, + stats: Arc, +} +impl crate::resource::HostResource for SolLifecycleResource { + fn resource_type_key() -> Option { + Some(cleanup_key("ret.cleanup.a")) + } + fn begin_close( + &mut self, + _: crate::ResourceCloseReason, + ) -> crate::resource::error::ResourceResult { + self.stats.begins[self.id].fetch_add(1, Ordering::SeqCst); + if self.id == 0 && self.stats.fail_first.load(Ordering::SeqCst) { + return Err(crate::resource::error::ResourceError::new( + crate::resource::error::ResourceErrorCode::ResourceCleanupFailed, + "sol.first", + "first close failed", + )); + } + Ok( + if self.id < 2 && self.stats.pending.load(Ordering::SeqCst) { + crate::resource::CloseProgress::Pending + } else { + crate::resource::CloseProgress::Ready + }, + ) + } + fn poll_close( + &mut self, + _: &mut Context<'_>, + ) -> Poll> { + self.stats.polls[self.id].fetch_add(1, Ordering::SeqCst); + if self.stats.ready.load(Ordering::SeqCst) { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } +} + +const SOL_LIFECYCLE_SOURCE: &str = r#" + fn relay() -> Result> { + let first: resource = test::open(0); + let second: resource = test::open(1); + let n = test::fail()?; + test::consume(first); + test::consume(second); + Result::Ok(n) + } + let carried = relay(); + carried; +"#; + +fn sol_lifecycle_vm(source: &str, stats: &Arc) -> Vm { + struct Factory { + stats: Arc, + fail: bool, + } + impl crate::HostFunction for Factory { + fn call(&mut self, vm: &mut Vm, args: &[Value]) -> VmResult { + let id = if self.fail { + 2 + } else { + match args[0] { + Value::Int(id) => id as usize, + _ => unreachable!(), + } + }; + let resource = vm + .execution_scope() + .push_resource(SolLifecycleResource { + id, + stats: self.stats.clone(), + }) + .map_err(VmError::ExecutionScope)?; + let raw = resource.handle().raw(); + self.stats.handles.lock().unwrap().push((id, raw)); + let value = if self.fail { + let mut carrier = crate::VmMap::new(); + carrier.insert(Value::string("tag"), Value::Bool(false)); + carrier.insert(Value::string("payload"), Value::Int(raw as i64)); + Value::Map(carrier.into()) + } else { + Value::Int(raw as i64) + }; + Ok(crate::CallOutcome::Return(crate::CallReturn::one(value))) + } + } + let key = cleanup_key("ret.cleanup.a"); + let mut catalog = crate::HostApiBuilder::new(); + catalog.resource(crate::ResourceTypeSchema::new( + key.clone(), + "Sol lifecycle probe", + )); + catalog.function(crate::HostFunctionSchema::with_return( + "test::open", + vec![crate::HostParamSchema::value( + "id", + crate::HostTypeSchema::Int, + )], + crate::HostTypeSchema::Resource(key.clone()), + )); + catalog.function(crate::HostFunctionSchema::with_return( + "test::consume", + vec![crate::HostParamSchema::with_passing( + "value", + crate::HostTypeSchema::Resource(key.clone()), + crate::HostParamPassing::TakeOwned, + )], + crate::HostTypeSchema::Null, + )); + catalog.function(crate::HostFunctionSchema::with_return( + "test::fail", + Vec::new(), + crate::HostTypeSchema::Result { + ok: Box::new(crate::HostTypeSchema::Int), + err: Box::new(crate::HostTypeSchema::Resource(key)), + }, + )); + let compiled = crate::compile_source_with_flavor_and_options( + source, + crate::SourceFlavor::RustScript, + crate::CompileSourceFileOptions::new() + .with_host_api_catalog(Arc::new(catalog.build().unwrap())), + ) + .expect("lifecycle source admission"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_fuel(100_000); + vm.bind_function( + "test::open", + Box::new(Factory { + stats: stats.clone(), + fail: false, + }), + ); + vm.bind_function( + "test::fail", + Box::new(Factory { + stats: stats.clone(), + fail: true, + }), + ); + struct Consume; + impl crate::HostFunction for Consume { + fn call(&mut self, _: &mut Vm, _: &[Value]) -> VmResult { + panic!("Ok-only consumer must be skipped on these Err paths") + } + } + vm.bind_function("test::consume", Box::new(Consume)); + vm +} + +#[test] +fn sol_try_pending_cleanup_retains_handles_until_real_poll_then_reset() { + let stats = Arc::new(SolLifecycleStats::default()); + stats.pending.store(true, Ordering::SeqCst); + let mut vm = sol_lifecycle_vm(SOL_LIFECYCLE_SOURCE, &stats); + assert_eq!(vm.run().expect("Pending Err return"), VmStatus::Halted); + assert_eq!(vm.execution_scope().resources().len(), 3); + assert!( + vm.execution_scope().is_active(), + "ordinary Err does not close the scope" + ); + let handles = stats.handles.lock().unwrap().clone(); + let mut cx = Context::from_waker(Waker::noop()); + for &(id, raw) in &handles { + assert_eq!(stats.begins[id].load(Ordering::SeqCst), usize::from(id < 2)); + if id < 2 { + let handle = crate::ResourceHandle::from_raw(raw).unwrap(); + assert!( + vm.execution_scope() + .resources() + .typed::(handle) + .is_err(), + "Closing resource is not Open" + ); + assert!(matches!( + vm.execution_scope().resources_mut().poll_close( + crate::resource::Resource::::from_handle(handle), + &mut cx + ), + Poll::Pending + )); + assert_eq!(stats.begins[id].load(Ordering::SeqCst), 1); + assert_eq!(stats.polls[id].load(Ordering::SeqCst), 1); + } + } + stats.ready.store(true, Ordering::SeqCst); + for &(id, raw) in &handles { + if id < 2 { + let handle = crate::ResourceHandle::from_raw(raw).unwrap(); + assert!(matches!( + vm.execution_scope().resources_mut().poll_close( + crate::resource::Resource::::from_handle(handle), + &mut cx + ), + Poll::Ready(Ok(())) + )); + assert!( + vm.execution_scope() + .resources() + .typed::(handle) + .is_err() + ); + assert_eq!(stats.begins[id].load(Ordering::SeqCst), 1); + assert_eq!(stats.polls[id].load(Ordering::SeqCst), 2); + } + } + assert_eq!( + vm.execution_scope().resources().len(), + 1, + "returned E remains caller-owned" + ); + vm.reset_for_reuse().expect("reset start"); + assert!(matches!( + vm.poll_reset_for_reuse(&mut cx), + Poll::Ready(Ok(())) + )); + assert_eq!(vm.execution_scope().resources().len(), 0); + for &(id, raw) in &handles { + assert_eq!(stats.begins[id].load(Ordering::SeqCst), 1); + assert!( + vm.execution_scope() + .resources() + .typed::(crate::ResourceHandle::from_raw(raw).unwrap()) + .is_err(), + "old generation/arena must be stale after reset" + ); + } + drop(vm); + for count in &stats.begins { + assert_eq!(count.load(Ordering::SeqCst), 1); + } +} + +#[test] +fn sol_try_close_failure_is_fatal_attempts_sibling_and_preserves_err_until_abort() { + let stats = Arc::new(SolLifecycleStats::default()); + stats.fail_first.store(true, Ordering::SeqCst); + let mut vm = sol_lifecycle_vm(SOL_LIFECYCLE_SOURCE, &stats); + let error = vm.run().expect_err("cleanup failure must be fatal VmError"); + assert!( + matches!(error, VmError::ExecutionScope(ExecutionScopeError::Resource(ref e)) if e.operation() == "sol.first"), + "{error:?}" + ); + assert_eq!(stats.begins[0].load(Ordering::SeqCst), 1); + assert_eq!(stats.begins[1].load(Ordering::SeqCst), 1); + assert_eq!(stats.begins[2].load(Ordering::SeqCst), 0); + assert_eq!(vm.execution_scope().resources().len(), 2); + assert!(vm.execution_scope().is_active()); + stats.fail_first.store(false, Ordering::SeqCst); + vm.execution_scope() + .begin_close(crate::ResourceCloseReason::Requested) + .expect("explicit abort after observing fatal failure"); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + vm.execution_scope().poll_close(&mut cx), + Poll::Ready(_) + )); + assert!(vm.execution_scope().is_quiescent()); + assert!(vm.execution_scope().terminal().is_some()); + assert_eq!( + vm.execution_scope().failed_count(), + 0, + "failed begin_close was retried only by explicit abort" + ); + assert_eq!(vm.execution_scope().resources().len(), 0); + assert_eq!(stats.begins[0].load(Ordering::SeqCst), 2); + assert_eq!(stats.begins[1].load(Ordering::SeqCst), 1); + assert_eq!(stats.begins[2].load(Ordering::SeqCst), 1); + for &(_, raw) in stats.handles.lock().unwrap().iter() { + assert!( + vm.execution_scope() + .resources() + .typed::(crate::ResourceHandle::from_raw(raw).unwrap()) + .is_err() + ); + } + drop(vm); + assert_eq!(stats.begins[0].load(Ordering::SeqCst), 2); + assert_eq!(stats.begins[1].load(Ordering::SeqCst), 1); + assert_eq!(stats.begins[2].load(Ordering::SeqCst), 1); +} + +const SOL_TRACE_RESOURCE_SOURCE: &str = r#" + fn relay() -> Result> { + let first: resource = test::open(0); + let second: resource = test::open(1); + let mut carrier: Result> = Result::Ok(1); + let mut i = 0; + let mut total = 0; + while i < 100 { + let n = carrier?; + total = total + n; + i = i + 1; + if i == 80 { carrier = test::fail(); } else { carrier = Result::Ok(1); } + } + test::consume(first); + test::consume(second); + Result::Ok(total) + } + relay(); + "#; + +#[test] +fn sol_try_trace_guard_exit_uses_interpreter_ret_cleanup_without_scope_abort() { + let stats = Arc::new(SolLifecycleStats::default()); + let mut vm = sol_lifecycle_vm(SOL_TRACE_RESOURCE_SOURCE, &stats); + vm.set_jit_config(crate::JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + let outcome = vm.run(); + let snapshot = vm.jit_snapshot(); + println!( + "native={} outcome={outcome:?}\n{}", + vm.jit_native_exec_count(), + vm.dump_jit_info() + ); + assert_eq!( + outcome.expect("Trace resource Try execution"), + VmStatus::Halted + ); + assert!(vm.jit_native_exec_count() > 0); + // Resource carrier detachment exits at the move-helper call before Try's + // tag read. This proves Trace-enabled/interpreter-Ret parity, not native + // Try execution (the scalar-E test below retains the real tag SSA guard). + assert!(snapshot.traces.iter().any( + |trace| trace.executions > 0 && trace.op_names().last().is_some_and(|op| op == "call") + )); + assert!( + snapshot + .traces + .iter() + .all(|trace| trace.executions == 0 || !trace.ssa_text().contains("String(\"tag\")")) + ); + assert_eq!(stats.begins[0].load(Ordering::SeqCst), 1); + assert_eq!(stats.begins[1].load(Ordering::SeqCst), 1); + assert_eq!(stats.begins[2].load(Ordering::SeqCst), 0); + assert_eq!(vm.execution_scope().resources().len(), 1); + assert!(vm.execution_scope().is_active()); + let raw = stats + .handles + .lock() + .unwrap() + .iter() + .find(|(id, _)| *id == 2) + .unwrap() + .1; + let Value::Map(carrier) = vm.stack().last().expect("Err carrier") else { + panic!("Result carrier") + }; + assert_eq!( + carrier.get(&Value::string("tag")), + Some(&Value::Bool(false)) + ); + assert_eq!( + carrier.get(&Value::string("payload")), + Some(&Value::Int(raw as i64)) + ); + assert!( + vm.execution_scope() + .resources() + .typed::(crate::ResourceHandle::from_raw(raw).unwrap()) + .is_ok() + ); + drop(vm); + assert_eq!(stats.begins[2].load(Ordering::SeqCst), 1); +} + +#[test] +fn sol_try_scalar_err_trace_guard_closes_local_resources_before_drop() { + let stats = Arc::new(SolLifecycleStats::default()); + let source = SOL_TRACE_RESOURCE_SOURCE + .replace("Result>", "Result") + .replace("carrier = test::fail();", "carrier = Result::Err(total);"); + let mut vm = sol_lifecycle_vm(&source, &stats); + vm.set_jit_config(crate::JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + assert_eq!( + vm.run().expect("scalar Err Trace with owned locals"), + VmStatus::Halted + ); + println!("{}", vm.dump_jit_info()); + assert!(vm.jit_native_exec_count() > 0); + assert!( + vm.jit_snapshot() + .traces + .iter() + .any(|trace| trace.executions > 0 + && trace.op_names().iter().any(|op| op == "map_get") + && trace.ssa_text().contains("String(\"tag\")")) + ); + let Value::Map(carrier) = vm.stack().last().unwrap() else { + panic!("Result carrier") + }; + assert_eq!( + carrier.get(&Value::string("tag")), + Some(&Value::Bool(false)) + ); + assert_eq!( + carrier.get(&Value::string("payload")), + Some(&Value::Int(80)) + ); + assert_eq!(stats.begins[0].load(Ordering::SeqCst), 1); + assert_eq!(stats.begins[1].load(Ordering::SeqCst), 1); + assert_eq!(vm.execution_scope().resources().len(), 0); + assert!(vm.execution_scope().is_active()); + drop(vm); + assert_eq!(stats.begins[0].load(Ordering::SeqCst), 1); + assert_eq!(stats.begins[1].load(Ordering::SeqCst), 1); +} + +#[test] +fn sol_try_resource_err_operand_carrier_excludes_moved_source_in_interpreter() { + let stats = Arc::new(SolLifecycleStats::default()); + let mut vm = sol_lifecycle_vm(SOL_TRACE_RESOURCE_SOURCE, &stats); + vm.set_jit_config(crate::JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }); + assert_eq!( + vm.run().expect("resource Err operand under interpreter"), + VmStatus::Halted + ); + assert_eq!(stats.begins[0].load(Ordering::SeqCst), 1); + assert_eq!(stats.begins[1].load(Ordering::SeqCst), 1); + assert_eq!(stats.begins[2].load(Ordering::SeqCst), 0); + assert_eq!(vm.jit_native_exec_count(), 0); + sol_assert_returned_err_owner(&mut vm, &stats); + drop(vm); + assert_eq!(stats.begins[0].load(Ordering::SeqCst), 1); + assert_eq!(stats.begins[1].load(Ordering::SeqCst), 1); + assert_eq!(stats.begins[2].load(Ordering::SeqCst), 1); +} + +fn sol_assert_returned_err_owner(vm: &mut Vm, stats: &SolLifecycleStats) { + assert!(vm.execution_scope().is_active()); + assert_eq!(vm.execution_scope().resources().len(), 1); + let raw = stats + .handles + .lock() + .unwrap() + .iter() + .find(|(id, _)| *id == 2) + .unwrap() + .1; + let Value::Map(carrier) = vm.stack().last().expect("returned Err") else { + panic!("Result carrier") + }; + assert_eq!( + carrier.get(&Value::string("tag")), + Some(&Value::Bool(false)) + ); + assert_eq!( + carrier.get(&Value::string("payload")), + Some(&Value::Int(raw as i64)) + ); + assert!( + vm.execution_scope() + .resources() + .typed::(crate::ResourceHandle::from_raw(raw).unwrap()) + .is_ok() + ); +} + +#[test] +fn sol_try_postoperand_fresh_block_owners_close_before_return() { + let source = r#" + fn relay() -> Result> { + let n = (if true => { + let first: resource = test::open(0); + let second: resource = test::open(1); + let carrier: Result> = test::fail(); + carrier + } else => { test::fail() })?; + Result::Ok(n) + } + relay(); + "#; + for flag in [true, false] { + let stats = Arc::new(SolLifecycleStats::default()); + let source = source.replace("if true", &format!("if {flag}")); + let mut vm = sol_lifecycle_vm(&source, &stats); + assert_eq!(vm.run().expect("fresh operand owners"), VmStatus::Halted); + assert_eq!( + stats.handles.lock().unwrap().len(), + if flag { 3 } else { 1 }, + "operand evaluated once" + ); + let closes = usize::from(flag); + assert_eq!(stats.begins[0].load(Ordering::SeqCst), closes); + assert_eq!(stats.begins[1].load(Ordering::SeqCst), closes); + assert_eq!(stats.begins[2].load(Ordering::SeqCst), 0); + sol_assert_returned_err_owner(&mut vm, &stats); + drop(vm); + assert_eq!(stats.begins[0].load(Ordering::SeqCst), closes); + assert_eq!(stats.begins[1].load(Ordering::SeqCst), closes); + assert_eq!(stats.begins[2].load(Ordering::SeqCst), 1); + } +} + +#[test] +fn sol_try_resource_quota_is_fatal_before_operand_returns_guest_result() { + let stats = Arc::new(SolLifecycleStats::default()); + let source = SOL_LIFECYCLE_SOURCE + .replace("let second: resource = test::open(1);", "") + .replace("test::consume(second);", ""); + let mut vm = sol_lifecycle_vm(&source, &stats); + *vm.execution_scope().resources_mut() = crate::resource::ResourceTable::with_limit(1).unwrap(); + let error = vm.run().expect_err("resource quota is not guest Err"); + assert!( + matches!( + error, + VmError::ExecutionScope(ExecutionScopeError::Resource(_)) + ), + "{error:?}" + ); + assert_eq!(stats.handles.lock().unwrap().len(), 1); + assert_eq!(stats.begins[0].load(Ordering::SeqCst), 0); + assert_eq!(vm.execution_scope().resources().len(), 1); + vm.reset_for_reuse().unwrap(); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + vm.poll_reset_for_reuse(&mut cx), + Poll::Ready(Ok(())) + )); + assert_eq!(stats.begins[0].load(Ordering::SeqCst), 1); + assert_eq!(vm.execution_scope().resources().len(), 0); +} + +fn cleanup_key(name: &str) -> ResourceTypeKey { + ResourceTypeKey::new(name).expect("cleanup resource key") +} + +fn resource_schema(key: &ResourceTypeKey) -> crate::compiler::TypeSchema { + crate::compiler::TypeSchema::Resource(key.clone()) +} + +fn cleanup_program(local_count: usize, locals: Vec) -> Program { + let mut program = + Program::new(Vec::new(), vec![OpCode::Ret as u8]).with_local_count(local_count); + program.ret_cleanups = vec![RetCleanupPlan { site: 0, locals }]; + program +} + +struct CloseProbe { + begins: Arc, + fail: bool, + pending: bool, + ready: Arc, +} + +fn probe(begins: &Arc, fail: bool, pending: bool) -> CloseProbe { + CloseProbe { + begins: Arc::clone(begins), + fail, + pending, + ready: Arc::new(AtomicBool::new(false)), + } +} + +macro_rules! keyed_probe { + ($name:ident, $key:literal) => { + struct $name(CloseProbe); + impl crate::vm::resource::HostResource for $name { + fn resource_type_key() -> Option { + Some(cleanup_key($key)) + } + fn begin_close( + &mut self, + reason: crate::ResourceCloseReason, + ) -> crate::vm::resource::error::ResourceResult + { + self.0.begin_close(reason) + } + fn poll_close( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + self.0.poll_close(cx) + } + } + }; +} + +keyed_probe!(CleanupA, "ret.cleanup.a"); +keyed_probe!(CleanupB, "ret.cleanup.b"); + +impl CloseProbe { + fn begin_close( + &mut self, + _reason: crate::ResourceCloseReason, + ) -> crate::vm::resource::error::ResourceResult { + self.begins.fetch_add(1, Ordering::SeqCst); + if self.fail { + return Err(crate::vm::resource::error::ResourceError::new( + crate::vm::resource::error::ResourceErrorCode::ResourceCleanupFailed, + "ret.cleanup", + "begin_close failed", + )); + } + if self.pending { + Ok(crate::vm::resource::CloseProgress::Pending) + } else { + Ok(crate::vm::resource::CloseProgress::Ready) + } + } + + fn poll_close( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + if self.ready.load(Ordering::SeqCst) { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } +} + +fn push_probe(vm: &mut Vm, value: T) -> u64 { + vm.execution_scope() + .push_resource(value) + .expect("probe resource") + .handle() + .raw() +} + +fn still_open(vm: &mut Vm, raw: u64, key: &ResourceTypeKey) -> bool { + let handle = crate::ResourceHandle::from_raw(raw).expect("handle"); + vm.execution_scope() + .resources() + .validate_resource_type_key(handle, key) + .is_ok() +} + +#[test] +fn ret_cleanup_rejects_stale_second_handle_with_zero_closes() { + let key = cleanup_key("ret.cleanup.a"); + let begins = Arc::new(AtomicU64::new(0)); + let program = cleanup_program( + 2, + vec![ + RetCleanupLocal { + slot: 0, + schema: resource_schema(&key), + }, + RetCleanupLocal { + slot: 1, + schema: resource_schema(&key), + }, + ], + ); + let mut vm = Vm::new(program); + let live = push_probe(&mut vm, CleanupA(probe(&begins, false, false))); + let live_handle = crate::ResourceHandle::from_raw(live).expect("live handle"); + let stale = crate::ResourceHandle::encode( + live_handle.arena_id(), + live_handle.slot_index().expect("slot"), + live_handle.generation().saturating_add(1), + ) + .expect("stale generation") + .raw(); + begins.store(0, Ordering::SeqCst); + vm.instance.locals[0] = Value::Int(live as i64); + vm.instance.locals[1] = Value::Int(stale as i64); + let error = vm + .close_ret_owned_resources(0) + .expect_err("stale handle must reject the plan"); + assert!(matches!( + error, + VmError::ExecutionScope(ExecutionScopeError::Resource(ref resource)) + if resource.code() == crate::vm::resource::error::ResourceErrorCode::ResourceStale + )); + assert_eq!(begins.load(Ordering::SeqCst), 0); + assert!(still_open(&mut vm, live, &key)); +} + +#[test] +fn ret_cleanup_rejects_wrong_key_and_wrong_arena_with_zero_closes() { + let key_a = cleanup_key("ret.cleanup.a"); + let begins = Arc::new(AtomicU64::new(0)); + let program = cleanup_program( + 2, + vec![ + RetCleanupLocal { + slot: 0, + schema: resource_schema(&key_a), + }, + RetCleanupLocal { + slot: 1, + schema: resource_schema(&key_a), + }, + ], + ); + let mut vm = Vm::new(program); + let live = push_probe(&mut vm, CleanupA(probe(&begins, false, false))); + let other = push_probe(&mut vm, CleanupB(probe(&begins, false, false))); + begins.store(0, Ordering::SeqCst); + vm.instance.locals[0] = Value::Int(live as i64); + vm.instance.locals[1] = Value::Int(other as i64); + let error = vm + .close_ret_owned_resources(0) + .expect_err("wrong key must reject before close"); + assert!(matches!( + error, + VmError::ExecutionScope(ExecutionScopeError::Resource(ref resource)) + if resource.code() + == crate::vm::resource::error::ResourceErrorCode::ResourceTypeKeyMismatch + )); + assert_eq!(begins.load(Ordering::SeqCst), 0); + assert!(still_open(&mut vm, live, &key_a)); + + let live_handle = crate::ResourceHandle::from_raw(live).expect("live handle"); + let foreign_arena = if live_handle.arena_id() == 1 { 2 } else { 1 }; + let foreign = crate::ResourceHandle::encode(foreign_arena, 0, 1).expect("foreign handle"); + vm.instance.locals[1] = Value::Int(foreign.raw() as i64); + let error = vm + .close_ret_owned_resources(0) + .expect_err("wrong arena must reject before close"); + assert!(matches!( + error, + VmError::ExecutionScope(ExecutionScopeError::Resource(ref resource)) + if resource.code() + == crate::vm::resource::error::ResourceErrorCode::ResourceHandleWrongTable + )); + assert_eq!(begins.load(Ordering::SeqCst), 0); + assert!(still_open(&mut vm, live, &key_a)); +} + +#[test] +fn ret_cleanup_rejects_duplicate_and_return_alias_with_zero_closes() { + let key = cleanup_key("ret.cleanup.a"); + let begins = Arc::new(AtomicU64::new(0)); + let mut program = cleanup_program( + 2, + vec![ + RetCleanupLocal { + slot: 0, + schema: resource_schema(&key), + }, + RetCleanupLocal { + slot: 1, + schema: resource_schema(&key), + }, + ], + ); + program = program.with_callable_metadata( + Vec::new(), + vec![crate::CallablePrototype { + kind: crate::CallableKind::FunctionItem, + target: crate::CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 2, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: Some(crate::compiler::TypeSchema::Callable { + params: Vec::new(), + result: Box::new(crate::compiler::TypeSchema::Result { + ok: Box::new(crate::compiler::TypeSchema::Int), + err: Box::new(resource_schema(&key)), + }), + }), + }], + Vec::new(), + Vec::new(), + ); + program.ret_cleanups = vec![RetCleanupPlan { + site: 0, + locals: vec![ + RetCleanupLocal { + slot: 0, + schema: resource_schema(&key), + }, + RetCleanupLocal { + slot: 1, + schema: resource_schema(&key), + }, + ], + }]; + let mut vm = Vm::new(program); + let raw = push_probe(&mut vm, CleanupA(probe(&begins, false, false))); + begins.store(0, Ordering::SeqCst); + vm.instance.locals[0] = Value::Int(raw as i64); + vm.instance.locals[1] = Value::Int(raw as i64); + let error = vm + .close_ret_owned_resources(0) + .expect_err("duplicate ownership must reject"); + assert!(matches!( + error, + VmError::InvalidFrameState("cleanup plan has duplicate resource ownership") + )); + assert_eq!(begins.load(Ordering::SeqCst), 0); + assert!(still_open(&mut vm, raw, &key)); +} + +#[test] +fn ret_cleanup_rejects_return_alias_with_zero_closes() { + let key = cleanup_key("ret.cleanup.a"); + let begins = Arc::new(AtomicU64::new(0)); + let mut program = cleanup_program( + 1, + vec![RetCleanupLocal { + slot: 0, + schema: resource_schema(&key), + }], + ); + program = program.with_callable_metadata( + Vec::new(), + vec![crate::CallablePrototype { + kind: crate::CallableKind::FunctionItem, + target: crate::CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count: 1, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: Some(crate::compiler::TypeSchema::Callable { + params: Vec::new(), + result: Box::new(crate::compiler::TypeSchema::Result { + ok: Box::new(crate::compiler::TypeSchema::Int), + err: Box::new(resource_schema(&key)), + }), + }), + }], + Vec::new(), + Vec::new(), + ); + program.ret_cleanups = vec![RetCleanupPlan { + site: 0, + locals: vec![RetCleanupLocal { + slot: 0, + schema: resource_schema(&key), + }], + }]; + let mut vm = Vm::new(program); + let raw = push_probe(&mut vm, CleanupA(probe(&begins, false, false))); + begins.store(0, Ordering::SeqCst); + vm.instance.locals[0] = Value::Int(raw as i64); + vm.instance.execution_frames[0].prototype_id = Some(0); + vm.instance.stack.push(Value::map(vec![ + (Value::string("tag"), Value::Bool(false)), + (Value::string("payload"), Value::Int(raw as i64)), + ])); + let error = vm + .close_ret_owned_resources(0) + .expect_err("returned resource must stay protected"); + assert!(matches!( + error, + VmError::InvalidFrameState("cleanup plan overlaps the returned resource graph") + )); + assert_eq!(begins.load(Ordering::SeqCst), 0); + assert!(still_open(&mut vm, raw, &key)); +} + +#[test] +fn ret_cleanup_rejects_capture_slot_before_close() { + let key = cleanup_key("ret.cleanup.a"); + let begins = Arc::new(AtomicU64::new(0)); + let program = cleanup_program( + 1, + vec![RetCleanupLocal { + slot: 0, + schema: resource_schema(&key), + }], + ); + let mut vm = Vm::new(program); + let raw = push_probe(&mut vm, CleanupA(probe(&begins, false, false))); + begins.store(0, Ordering::SeqCst); + vm.instance.locals[0] = Value::Int(raw as i64); + vm.instance.shared_capture_slots.insert(0); + let error = vm + .close_ret_owned_resources(0) + .expect_err("capture slot must be excluded"); + assert!(matches!( + error, + VmError::InvalidFrameState("cleanup plan targets a persistent capture") + )); + assert_eq!(begins.load(Ordering::SeqCst), 0); + assert!(still_open(&mut vm, raw, &key)); +} + +#[test] +fn ret_cleanup_first_close_failure_still_closes_later_resource() { + let key = cleanup_key("ret.cleanup.a"); + let first_begins = Arc::new(AtomicU64::new(0)); + let second_begins = Arc::new(AtomicU64::new(0)); + let program = cleanup_program( + 2, + vec![ + RetCleanupLocal { + slot: 0, + schema: resource_schema(&key), + }, + RetCleanupLocal { + slot: 1, + schema: resource_schema(&key), + }, + ], + ); + let mut vm = Vm::new(program); + let failed = push_probe(&mut vm, CleanupA(probe(&first_begins, true, false))); + let later = push_probe(&mut vm, CleanupA(probe(&second_begins, false, false))); + vm.instance.locals[0] = Value::Int(failed as i64); + vm.instance.locals[1] = Value::Int(later as i64); + let error = vm + .close_ret_owned_resources(0) + .expect_err("first close failure is fatal"); + assert!(matches!( + error, + VmError::ExecutionScope(ExecutionScopeError::Resource(ref resource)) + if resource.code() + == crate::vm::resource::error::ResourceErrorCode::ResourceCleanupFailed + )); + assert_eq!(first_begins.load(Ordering::SeqCst), 1); + assert_eq!(second_begins.load(Ordering::SeqCst), 1); + assert!(still_open(&mut vm, failed, &key)); + assert!(!still_open(&mut vm, later, &key)); +} + +#[test] +fn ret_cleanup_pending_begins_once_and_completes_on_real_poll() { + let key = cleanup_key("ret.cleanup.a"); + let begins = Arc::new(AtomicU64::new(0)); + let ready = Arc::new(AtomicBool::new(false)); + let program = cleanup_program( + 1, + vec![RetCleanupLocal { + slot: 0, + schema: resource_schema(&key), + }], + ); + let mut vm = Vm::new(program); + let raw = push_probe( + &mut vm, + CleanupA(CloseProbe { + begins: Arc::clone(&begins), + fail: false, + pending: true, + ready: Arc::clone(&ready), + }), + ); + vm.instance.locals[0] = Value::Int(raw as i64); + vm.close_ret_owned_resources(0) + .expect("pending close is not a guest error or synchronous wait"); + assert_eq!(begins.load(Ordering::SeqCst), 1); + let handle = crate::ResourceHandle::from_raw(raw).expect("handle"); + let token = crate::vm::resource::Resource::::from_handle(handle); + let mut cx = Context::from_waker(Waker::noop()); + assert!(matches!( + vm.execution_scope() + .resources_mut() + .poll_close(token, &mut cx), + Poll::Pending + )); + assert_eq!(begins.load(Ordering::SeqCst), 1); + ready.store(true, Ordering::SeqCst); + let token = crate::vm::resource::Resource::::from_handle(handle); + assert!(matches!( + vm.execution_scope() + .resources_mut() + .poll_close(token, &mut cx), + Poll::Ready(Ok(())) + )); + assert_eq!(begins.load(Ordering::SeqCst), 1); +} + +#[test] +fn ret_cleanup_shared_array_dag_closes_once_and_deep_schema_closes_nothing() { + let key = cleanup_key("ret.cleanup.a"); + let begins = Arc::new(AtomicU64::new(0)); + let child_schema = crate::compiler::TypeSchema::Array(Box::new(resource_schema(&key))); + let program = cleanup_program( + 1, + vec![RetCleanupLocal { + slot: 0, + schema: crate::compiler::TypeSchema::Array(Box::new(child_schema)), + }], + ); + let mut vm = Vm::new(program); + let raw = push_probe(&mut vm, CleanupA(probe(&begins, false, false))); + let child = Value::array(vec![Value::Int(raw as i64)]); + vm.instance.locals[0] = Value::array(vec![child.clone(), child]); + vm.close_ret_owned_resources(0) + .expect("shared array dag must terminate"); + assert_eq!(begins.load(Ordering::SeqCst), 1); + + let mut deep = resource_schema(&key); + for _ in 0..crate::host_api::MAX_HOST_SCHEMA_DEPTH { + deep = crate::compiler::TypeSchema::Array(Box::new(deep)); + } + let begins = Arc::new(AtomicU64::new(0)); + let program = cleanup_program( + 1, + vec![RetCleanupLocal { + slot: 0, + schema: deep, + }], + ); + let mut vm = Vm::new(program); + let raw = push_probe(&mut vm, CleanupA(probe(&begins, false, false))); + vm.instance.locals[0] = Value::Int(raw as i64); + let error = vm + .close_ret_owned_resources(0) + .expect_err("schema deeper than the budget must fail before close"); + assert!(matches!( + error, + VmError::InvalidFrameState("cleanup walk exceeded schema depth") + )); + assert_eq!(begins.load(Ordering::SeqCst), 0); + assert!(still_open(&mut vm, raw, &key)); +} + +fn guest_decl(name: &str, params: &[&str], body: TypeSchema) -> (String, StructDecl) { + ( + name.to_string(), + StructDecl { + name: name.to_string(), + type_params: params.iter().map(|param| (*param).to_string()).collect(), + body_schema: body, + origin: crate::compiler::StructDeclOrigin::Guest, + }, + ) +} + +fn close_schema( + schema: TypeSchema, + build: impl FnOnce(u64) -> Value, + decls: Vec<(String, StructDecl)>, +) -> (usize, Result<(), String>, bool) { + let key = cleanup_key("ret.cleanup.a"); + let begins = Arc::new(AtomicU64::new(0)); + let mut program = cleanup_program(1, vec![RetCleanupLocal { slot: 0, schema }]); + program.named_struct_decls.extend(decls); + let mut vm = Vm::new(program); + let raw = push_probe(&mut vm, CleanupA(probe(&begins, false, false))); + vm.instance.locals[0] = build(raw); + let result = vm + .close_ret_owned_resources(0) + .map_err(|error| error.to_string()); + let open = still_open(&mut vm, raw, &key); + (begins.load(Ordering::SeqCst) as usize, result, open) +} + +#[test] +fn ret_cleanup_rejects_null_for_required_resource_and_result_with_zero_closes() { + let owned = resource_schema(&cleanup_key("ret.cleanup.a")); + for schema in [ + owned.clone(), + TypeSchema::Result { + ok: Box::new(TypeSchema::Int), + err: Box::new(owned), + }, + ] { + let (closes, result, open) = close_schema(schema, |_| Value::Null, Vec::new()); + assert!(result.is_err(), "required cleanup schema must reject Null"); + assert_eq!(closes, 0); + assert!(open, "strict preflight must not close any resource"); + } +} + +#[test] +fn ret_cleanup_walks_map_object_named_tuple_and_active_result() { + let key = cleanup_key("ret.cleanup.a"); + let owned = resource_schema(&key); + let (closes, result, _) = close_schema( + TypeSchema::Map(Box::new(owned.clone())), + |raw| { + let handle = Value::Int(raw as i64); + Value::map(vec![(handle.clone(), handle)]) + }, + Vec::new(), + ); + assert_eq!(result, Ok(()), "map values are owned resources"); + assert_eq!(closes, 1, "map keys must not be closed as handles"); + + let mut fields = HashMap::new(); + fields.insert("handle".to_string(), owned.clone()); + fields.insert("note".to_string(), TypeSchema::Int); + let (closes, result, _) = close_schema( + TypeSchema::Object(fields), + |raw| { + let handle = Value::Int(raw as i64); + Value::map(vec![ + (Value::string("handle"), handle.clone()), + (Value::string("note"), Value::Int(4)), + (Value::string("decoy"), handle), + ]) + }, + Vec::new(), + ); + assert_eq!(result, Ok(())); + assert_eq!(closes, 1, "only declared object fields are owned"); + + let (closes, result, _) = close_schema( + TypeSchema::Named("Holder".to_string(), Vec::new()), + |raw| Value::Int(raw as i64), + vec![guest_decl("Holder", &[], owned.clone())], + ); + assert_eq!(result, Ok(()), "nongeneric named body must be recognized"); + assert_eq!(closes, 1); + + let (closes, result, _) = close_schema( + TypeSchema::ArrayTupleRest { + prefix: vec![owned.clone(), TypeSchema::Int], + rest: Box::new(TypeSchema::Int), + }, + |raw| { + Value::array(vec![ + Value::Int(raw as i64), + Value::Int(7), + Value::Int(raw as i64), + ]) + }, + Vec::new(), + ); + assert_eq!(result, Ok(())); + assert_eq!( + closes, 1, + "tuple rest must not treat trailing scalars as handles" + ); + + let (closes, result, open) = close_schema( + TypeSchema::ArrayTupleRest { + prefix: vec![TypeSchema::Int], + rest: Box::new(owned.clone()), + }, + |raw| Value::array(vec![Value::Int(9), Value::Int(raw as i64)]), + Vec::new(), + ); + assert_eq!(result, Ok(()), "tuple rest handles close"); + assert_eq!(closes, 1); + assert!(!open, "rest handle must close"); + + let (closes, result, _) = close_schema( + TypeSchema::Optional(Box::new(owned.clone())), + |_| Value::Null, + Vec::new(), + ); + assert_eq!(result, Ok(())); + assert_eq!(closes, 0, "optional null is absence"); + + let (closes, result, open) = close_schema( + TypeSchema::Result { + ok: Box::new(owned.clone()), + err: Box::new(TypeSchema::Int), + }, + |raw| { + Value::map(vec![ + (Value::string("tag"), Value::Bool(false)), + (Value::string("payload"), Value::Int(raw as i64)), + ]) + }, + Vec::new(), + ); + assert_eq!(result, Ok(())); + assert_eq!( + closes, 0, + "inactive resource schema must not close an int payload" + ); + assert!(open, "inactive branch must leave the handle open"); + + let (closes, result, _) = close_schema( + TypeSchema::Result { + ok: Box::new(TypeSchema::Int), + err: Box::new(owned.clone()), + }, + |raw| { + Value::map(vec![ + (Value::string("tag"), Value::Bool(false)), + (Value::string("payload"), Value::Int(raw as i64)), + ]) + }, + Vec::new(), + ); + assert_eq!(result, Ok(())); + assert_eq!(closes, 1); + + let (_, result, open) = close_schema( + TypeSchema::Result { + ok: Box::new(owned), + err: Box::new(TypeSchema::Named("Missing".to_string(), Vec::new())), + }, + |raw| { + Value::map(vec![ + (Value::string("tag"), Value::Bool(true)), + (Value::string("payload"), Value::Int(raw as i64)), + ]) + }, + Vec::new(), + ); + let message = result.expect_err("inactive unresolved named must fail"); + assert!( + message.contains("unresolved") || message.contains("wrong arity"), + "{message}" + ); + assert!( + open, + "malformed inactive branch must not close the active handle" + ); +} +fn nested_array_schema(depth: usize) -> TypeSchema { + let mut schema = TypeSchema::Int; + for _ in 0..depth { + schema = TypeSchema::Array(Box::new(schema)); + } + schema +} + +#[test] +fn ret_cleanup_rejects_duplicate_owned_aliases_and_bounds_resource_free_dags() { + let key = cleanup_key("ret.cleanup.a"); + let owned = resource_schema(&key); + let (closes, result, open) = close_schema( + TypeSchema::Array(Box::new(owned.clone())), + |raw| Value::array(vec![Value::Int(raw as i64), Value::Int(raw as i64)]), + Vec::new(), + ); + let message = result.expect_err("duplicate owners reject"); + assert!(message.contains("duplicate"), "{message}"); + assert_eq!(closes, 0); + assert!(open); + + let (closes, result, _) = close_schema( + TypeSchema::ArrayTuple(vec![owned.clone(), nested_array_schema(20)]), + |raw| { + let mut noise_schema_value = Value::Int(0); + for _ in 0..20 { + noise_schema_value = + Value::array(vec![noise_schema_value.clone(), noise_schema_value]); + } + Value::array(vec![Value::Int(raw as i64), noise_schema_value]) + }, + Vec::new(), + ); + assert_eq!(result, Ok(()), "shared resource-free containers must dedup"); + assert_eq!(closes, 1); + + let (closes, result, open) = close_schema(owned, |_| Value::Null, Vec::new()); + let message = result.expect_err("null is not a resource handle"); + assert!(message.contains("resource handle"), "{message}"); + assert_eq!(closes, 0); + assert!(open); +} diff --git a/src/vmbc.rs b/src/vmbc.rs index 384761ad..9c65074d 100644 --- a/src/vmbc.rs +++ b/src/vmbc.rs @@ -5,7 +5,8 @@ use std::hash::Hash; use crate::builtins::BuiltinFunction; use crate::bytecode::{ CallableKind, CallablePrototype, CallableTarget, CaptureBindingMode, ExportedCallable, - FunctionRegion, MAX_FRAME_LOCAL_COUNT, RootCallableBinding, ScriptFunction, TypeMap, ValueType, + FunctionRegion, MAX_FRAME_LOCAL_COUNT, RetCleanupLocal, RetCleanupPlan, RootCallableBinding, + ScriptFunction, TypeMap, ValueType, }; use crate::compiler::ir::{StructDecl, StructDeclOrigin, TypeSchema}; use crate::debug_info::{ArgInfo, DebugFunction, DebugInfo, LineInfo, LocalInfo}; @@ -49,6 +50,7 @@ pub enum WireError { CodeTooLong(usize), UnsupportedConstantType(&'static str), LengthTooLarge(&'static str, usize), + InvalidRetCleanup(&'static str), TrailingBytes, } @@ -94,6 +96,9 @@ impl std::fmt::Display for WireError { WireError::LengthTooLarge(field, len) => { write!(f, "{field} length too large: {len}") } + WireError::InvalidRetCleanup(reason) => { + write!(f, "invalid ret cleanup: {reason}") + } WireError::TrailingBytes => write!(f, "trailing bytes after program payload"), } } @@ -301,6 +306,354 @@ fn read_constant(cursor: &mut Cursor<'_>, depth: usize) -> Result, program: &Program) -> Result<(), WireError> { + validate_ret_cleanups(program)?; + let mut plans = program.ret_cleanups.clone(); + for plan in &mut plans { + plan.locals.sort_by_key(|local| local.slot); + } + plans.sort_by_key(|plan| plan.site); + write_u32_count("ret cleanup plans", plans.len(), out)?; + for plan in &plans { + out.extend_from_slice(&plan.site.to_le_bytes()); + write_u32_count("ret cleanup locals", plan.locals.len(), out)?; + for local in &plan.locals { + out.extend_from_slice(&local.slot.to_le_bytes()); + write_schema(&local.schema, out)?; + } + } + Ok(()) +} + +fn read_ret_cleanups( + cursor: &mut Cursor<'_>, + code: &[u8], + prototypes: &[CallablePrototype], + regions: &[FunctionRegion], + decls: &HashMap, +) -> Result, WireError> { + let plan_count = cursor.read_count_with_overhead("ret cleanup plans", 8, 0)?; + if plan_count > code.len() { + return Err(WireError::LengthTooLarge("ret cleanup plans", plan_count)); + } + let ret_sites = if plan_count == 0 { + HashSet::new() + } else { + ret_instruction_sites(code)? + }; + let mut plans = Vec::new(); + reserve_vec(&mut plans, "ret cleanup plans", plan_count)?; + let mut previous_site = None; + for _ in 0..plan_count { + let site = cursor.read_u32()?; + if previous_site.is_some_and(|previous| site <= previous) { + return Err(WireError::InvalidRetCleanup( + "cleanup sites are not canonical", + )); + } + previous_site = Some(site); + let frame_locals = cleanup_frame_local_count(prototypes, regions, &ret_sites, site)?; + let local_count = cursor.read_u32()? as usize; + if local_count > frame_locals || local_count > MAX_FRAME_LOCAL_COUNT { + return Err(WireError::LengthTooLarge("ret cleanup locals", local_count)); + } + cursor.validate_count("ret cleanup locals", local_count, 3)?; + cursor.debit_count("ret cleanup locals", local_count)?; + let mut locals = Vec::new(); + reserve_vec(&mut locals, "ret cleanup locals", local_count)?; + let mut previous_slot = None; + for _ in 0..local_count { + let slot = cursor.read_u16()?; + if previous_slot.is_some_and(|previous| slot <= previous) { + return Err(WireError::InvalidRetCleanup( + "cleanup locals are not canonical", + )); + } + if slot as usize >= frame_locals { + return Err(WireError::InvalidRetCleanup( + "cleanup local is outside the frame", + )); + } + previous_slot = Some(slot); + let schema = read_schema(cursor, 0)?; + require_owned_cleanup_schema(&schema, decls)?; + locals.push(RetCleanupLocal { slot, schema }); + } + plans.push(RetCleanupPlan { site, locals }); + } + Ok(plans) +} + +fn validate_ret_cleanups(program: &Program) -> Result<(), WireError> { + if program.ret_cleanups.is_empty() { + return Ok(()); + } + let ret_sites = ret_instruction_sites(&program.code)?; + let mut seen_sites = HashSet::new(); + for plan in &program.ret_cleanups { + if !seen_sites.insert(plan.site) { + return Err(WireError::InvalidRetCleanup("duplicate cleanup site")); + } + let frame_locals = cleanup_frame_local_count( + &program.callable_prototypes, + &program.function_regions, + &ret_sites, + plan.site, + )?; + let mut seen_slots = HashSet::new(); + for local in &plan.locals { + if !seen_slots.insert(local.slot) { + return Err(WireError::InvalidRetCleanup("duplicate cleanup local")); + } + if local.slot as usize >= frame_locals { + return Err(WireError::InvalidRetCleanup( + "cleanup local is outside the frame", + )); + } + require_owned_cleanup_schema(&local.schema, &program.named_struct_decls)?; + } + } + Ok(()) +} + +fn ret_instruction_sites(code: &[u8]) -> Result, WireError> { + let mut ip = 0usize; + let mut sites = HashSet::new(); + while ip < code.len() { + let opcode = OpCode::try_from(code[ip]) + .map_err(|_| WireError::InvalidRetCleanup("cleanup bytecode has an invalid opcode"))?; + if opcode == OpCode::Ret { + let site = u32::try_from(ip).map_err(|_| WireError::LengthTooLarge("code", ip))?; + sites.insert(site); + } + let next = ip + .checked_add(1) + .and_then(|value| value.checked_add(opcode.operand_len())) + .ok_or(WireError::LengthTooLarge("code", ip))?; + if next > code.len() { + return Err(WireError::InvalidRetCleanup("truncated instruction")); + } + ip = next; + } + Ok(sites) +} + +fn cleanup_frame_local_count( + prototypes: &[CallablePrototype], + regions: &[FunctionRegion], + ret_sites: &HashSet, + site: u32, +) -> Result { + if !ret_sites.contains(&site) { + return Err(WireError::InvalidRetCleanup( + "cleanup site is not a Ret instruction", + )); + } + let mut frame_locals = None; + for region in regions { + if site < region.start_ip || site >= region.end_ip { + continue; + } + let Some(prototype_id) = region.prototype_id else { + continue; + }; + let Some(prototype) = prototypes.get(prototype_id as usize) else { + return Err(WireError::InvalidRetCleanup( + "cleanup frame prototype is missing", + )); + }; + if !matches!(prototype.target, CallableTarget::ScriptFunction(_)) { + return Err(WireError::InvalidRetCleanup( + "cleanup frame is not a script function", + )); + } + if frame_locals.replace(prototype.frame_local_count).is_some() { + return Err(WireError::InvalidRetCleanup( + "cleanup site matches multiple frames", + )); + } + } + frame_locals.ok_or(WireError::InvalidRetCleanup( + "cleanup site is not in a callable frame", + )) +} + +fn require_owned_cleanup_schema( + schema: &TypeSchema, + decls: &HashMap, +) -> Result<(), WireError> { + let mut nodes = 0usize; + if cleanup_schema_owns_resource(schema, decls, 0, &mut nodes, &mut HashSet::new())? { + Ok(()) + } else { + Err(WireError::InvalidRetCleanup( + "cleanup schema has no owned resource", + )) + } +} + +fn cleanup_schema_owns_resource( + schema: &TypeSchema, + decls: &HashMap, + depth: usize, + nodes: &mut usize, + active: &mut HashSet, +) -> Result { + if depth >= MAX_SCHEMA_DEPTH { + return Err(WireError::LengthTooLarge("schema nesting depth", depth)); + } + *nodes = nodes + .checked_add(1) + .ok_or(WireError::LengthTooLarge("schema nodes", *nodes))?; + if *nodes > MAX_HOST_SCHEMA_NODES { + return Err(WireError::LengthTooLarge("schema nodes", *nodes)); + } + let next = depth + 1; + match schema { + TypeSchema::Resource(_) => Ok(true), + TypeSchema::Optional(inner) | TypeSchema::Array(inner) => { + cleanup_schema_owns_resource(inner, decls, next, nodes, active) + } + TypeSchema::ArrayTuple(items) => { + let mut owned = false; + for item in items { + owned |= cleanup_schema_owns_resource(item, decls, next, nodes, active)?; + } + Ok(owned) + } + TypeSchema::ArrayTupleRest { prefix, rest } => { + let mut owned = cleanup_schema_owns_resource(rest, decls, next, nodes, active)?; + for item in prefix { + owned |= cleanup_schema_owns_resource(item, decls, next, nodes, active)?; + } + Ok(owned) + } + TypeSchema::Result { ok, err } => { + let ok_owned = cleanup_schema_owns_resource(ok, decls, next, nodes, active)?; + let err_owned = cleanup_schema_owns_resource(err, decls, next, nodes, active)?; + Ok(ok_owned || err_owned) + } + TypeSchema::Map(value) => cleanup_schema_owns_resource(value, decls, next, nodes, active), + TypeSchema::Object(fields) => { + let mut owned = false; + let mut fields = fields.iter().collect::>(); + fields.sort_unstable_by(|(lhs, _), (rhs, _)| lhs.cmp(rhs)); + for (_, field) in fields { + owned |= cleanup_schema_owns_resource(field, decls, next, nodes, active)?; + } + Ok(owned) + } + TypeSchema::Callable { params, result } => { + for param in params { + cleanup_schema_owns_resource(param, decls, next, nodes, active)?; + } + cleanup_schema_owns_resource(result, decls, next, nodes, active)?; + Ok(false) + } + TypeSchema::Named(name, args) => { + let Some(decl) = decls.get(name) else { + return Err(WireError::InvalidRetCleanup( + "unresolved named cleanup schema", + )); + }; + if decl.type_params.len() != args.len() { + return Err(WireError::InvalidRetCleanup("named cleanup schema arity")); + } + if !active.insert(name.clone()) { + return Err(WireError::InvalidRetCleanup("named cleanup schema cycle")); + } + let substituted = substitute_cleanup_schema(&decl.body_schema, &decl.type_params, args); + let mut owned = cleanup_schema_owns_resource(&substituted, decls, next, nodes, active)?; + for arg in args { + owned |= cleanup_schema_owns_resource(arg, decls, next, nodes, active)?; + } + active.remove(name); + Ok(owned) + } + TypeSchema::Unknown + | TypeSchema::Null + | TypeSchema::Int + | TypeSchema::Float + | TypeSchema::Number + | TypeSchema::Bool + | TypeSchema::String + | TypeSchema::Bytes + | TypeSchema::GenericParam(_) => Ok(false), + } +} + +fn substitute_cleanup_schema( + schema: &TypeSchema, + params: &[String], + args: &[TypeSchema], +) -> TypeSchema { + match schema { + TypeSchema::GenericParam(name) => params + .iter() + .position(|param| param == name) + .and_then(|index| args.get(index).cloned()) + .unwrap_or_else(|| schema.clone()), + TypeSchema::Optional(inner) => { + TypeSchema::Optional(Box::new(substitute_cleanup_schema(inner, params, args))) + } + TypeSchema::Array(inner) => { + TypeSchema::Array(Box::new(substitute_cleanup_schema(inner, params, args))) + } + TypeSchema::Map(inner) => { + TypeSchema::Map(Box::new(substitute_cleanup_schema(inner, params, args))) + } + TypeSchema::ArrayTuple(items) => TypeSchema::ArrayTuple( + items + .iter() + .map(|item| substitute_cleanup_schema(item, params, args)) + .collect(), + ), + TypeSchema::ArrayTupleRest { prefix, rest } => TypeSchema::ArrayTupleRest { + prefix: prefix + .iter() + .map(|item| substitute_cleanup_schema(item, params, args)) + .collect(), + rest: Box::new(substitute_cleanup_schema(rest, params, args)), + }, + TypeSchema::Object(fields) => TypeSchema::Object( + fields + .iter() + .map(|(name, value)| (name.clone(), substitute_cleanup_schema(value, params, args))) + .collect(), + ), + TypeSchema::Callable { + params: callable_params, + result, + } => TypeSchema::Callable { + params: callable_params + .iter() + .map(|param| substitute_cleanup_schema(param, params, args)) + .collect(), + result: Box::new(substitute_cleanup_schema(result, params, args)), + }, + TypeSchema::Named(name, type_args) => TypeSchema::Named( + name.clone(), + type_args + .iter() + .map(|arg| substitute_cleanup_schema(arg, params, args)) + .collect(), + ), + TypeSchema::Result { ok, err } => TypeSchema::Result { + ok: Box::new(substitute_cleanup_schema(ok, params, args)), + err: Box::new(substitute_cleanup_schema(err, params, args)), + }, + TypeSchema::Unknown + | TypeSchema::Null + | TypeSchema::Int + | TypeSchema::Float + | TypeSchema::Number + | TypeSchema::Bool + | TypeSchema::String + | TypeSchema::Bytes + | TypeSchema::Resource(_) => schema.clone(), + } +} + pub fn encode_program(program: &Program) -> Result, WireError> { let mut out = Vec::new(); out.extend_from_slice(&MAGIC); @@ -342,6 +695,7 @@ pub fn encode_program(program: &Program) -> Result, WireError> { write_debug_info(&mut out, program.debug.as_ref())?; write_callable_metadata(&mut out, program)?; write_named_struct_decls(&mut out, program)?; + write_ret_cleanups(&mut out, program)?; Ok(out) } @@ -412,6 +766,13 @@ pub fn decode_program(bytes: &[u8]) -> Result { exported_callables, ) = read_callable_metadata(&mut cursor)?; let named_struct_decls = read_named_struct_decls(&mut cursor)?; + let ret_cleanups = read_ret_cleanups( + &mut cursor, + &code, + &callable_prototypes, + &function_regions, + &named_struct_decls, + )?; if !cursor.is_eof() { return Err(WireError::TrailingBytes); @@ -426,6 +787,7 @@ pub fn decode_program(bytes: &[u8]) -> Result { program.root_callable_bindings = root_callable_bindings; program.exported_callables = exported_callables; program.named_struct_decls = named_struct_decls; + program.ret_cleanups = ret_cleanups; let type_map_local_count = program .type_map .as_ref() @@ -2279,3 +2641,354 @@ mod budget_tests { ); } } + +#[cfg(test)] +mod ret_cleanup_wire_tests { + use super::*; + use crate::compiler::ir::StructDeclOrigin; + + fn resource_schema() -> TypeSchema { + TypeSchema::Resource(ResourceTypeKey::new("pr24.closure").expect("resource key")) + } + + fn prototype(frame_local_count: usize) -> CallablePrototype { + CallablePrototype { + kind: CallableKind::FunctionItem, + target: CallableTarget::ScriptFunction(0), + arity: 0, + frame_local_count, + parameter_slots: Vec::new(), + capture_source_slots: Vec::new(), + capture_slots: Vec::new(), + capture_modes: Vec::new(), + self_slot: None, + schema: None, + } + } + + fn program_with_ret_at(code: Vec, frame_local_count: usize) -> Program { + let end = code.len() as u32; + Program::new(Vec::new(), code).with_callable_metadata( + vec![ScriptFunction { + entry_ip: 0, + end_ip: end, + }], + vec![prototype(frame_local_count)], + vec![FunctionRegion { + start_ip: 0, + end_ip: end, + prototype_id: Some(0), + }], + Vec::new(), + ) + } + + fn ldc_ret_program() -> Program { + program_with_ret_at( + vec![ + OpCode::Ldc as u8, + OpCode::Ret as u8, + 0, + 0, + 0, + OpCode::Ret as u8, + ], + 2, + ) + } + + #[test] + fn private_cleanup_plan_roundtrips_in_canonical_order() { + let mut program = ldc_ret_program(); + program.ret_cleanups = vec![RetCleanupPlan { + site: 5, + locals: vec![ + RetCleanupLocal { + slot: 1, + schema: resource_schema(), + }, + RetCleanupLocal { + slot: 0, + schema: TypeSchema::Optional(Box::new(resource_schema())), + }, + ], + }]; + let bytes = encode_program(&program).expect("canonical cleanup should encode"); + let decoded = decode_program(&bytes).expect("canonical cleanup should decode"); + assert_eq!( + decoded.ret_cleanups, + vec![RetCleanupPlan { + site: 5, + locals: vec![ + RetCleanupLocal { + slot: 0, + schema: TypeSchema::Optional(Box::new(resource_schema())), + }, + RetCleanupLocal { + slot: 1, + schema: resource_schema(), + }, + ], + }] + ); + } + + #[test] + fn named_cleanup_schema_roundtrips_without_renaming() { + let mut program = ldc_ret_program(); + program.named_struct_decls.insert( + "Holder".to_string(), + StructDecl { + name: "Holder".to_string(), + type_params: vec!["T".to_string()], + body_schema: TypeSchema::GenericParam("T".to_string()), + origin: StructDeclOrigin::Guest, + }, + ); + program.ret_cleanups = vec![RetCleanupPlan { + site: 5, + locals: vec![RetCleanupLocal { + slot: 0, + schema: TypeSchema::Named("Holder".to_string(), vec![resource_schema()]), + }], + }]; + let decoded = + decode_program(&encode_program(&program).expect("named cleanup should encode")) + .expect("named cleanup should decode"); + assert_eq!(decoded.ret_cleanups, program.ret_cleanups); + } + + #[test] + fn operand_byte_equal_to_ret_is_not_a_cleanup_site() { + let mut program = ldc_ret_program(); + program.ret_cleanups = vec![RetCleanupPlan { + site: 5, + locals: vec![RetCleanupLocal { + slot: 0, + schema: resource_schema(), + }], + }]; + let mut bytes = encode_program(&program).expect("valid site should encode"); + let key = b"pr24.closure"; + let schema_len = 1 + 4 + key.len(); + let site_offset = bytes.len() - schema_len - 2 - 4 - 4; + bytes[site_offset..site_offset + 4].copy_from_slice(&1u32.to_le_bytes()); + assert!(matches!( + decode_program(&bytes), + Err(WireError::InvalidRetCleanup( + "cleanup site is not a Ret instruction" + )) + )); + } + + #[test] + fn malformed_cleanup_metadata_is_rejected_before_any_program_exists() { + let mut program = ldc_ret_program(); + program.ret_cleanups = vec![ + RetCleanupPlan { + site: 5, + locals: vec![RetCleanupLocal { + slot: 0, + schema: resource_schema(), + }], + }, + RetCleanupPlan { + site: 5, + locals: vec![RetCleanupLocal { + slot: 1, + schema: resource_schema(), + }], + }, + ]; + assert!(matches!( + encode_program(&program), + Err(WireError::InvalidRetCleanup("duplicate cleanup site")) + )); + + program.ret_cleanups.truncate(1); + program.ret_cleanups[0].locals[0].slot = 9; + assert!(matches!( + encode_program(&program), + Err(WireError::InvalidRetCleanup( + "cleanup local is outside the frame" + )) + )); + + let mut missing = encode_program(&Program::new(Vec::new(), vec![OpCode::Ret as u8])) + .expect("zero-plan program should encode"); + missing.truncate(missing.len() - 4); + assert!(matches!( + decode_program(&missing), + Err(WireError::UnexpectedEof) + )); + + let mut truncated = encode_program(&{ + let mut valid = ldc_ret_program(); + valid.ret_cleanups = vec![RetCleanupPlan { + site: 5, + locals: vec![RetCleanupLocal { + slot: 0, + schema: resource_schema(), + }], + }]; + valid + }) + .expect("plan should encode"); + truncated.pop(); + assert!(matches!( + decode_program(&truncated), + Err(WireError::UnexpectedEof) + )); + + let mut overflow = encode_program(&Program::new(Vec::new(), vec![OpCode::Ret as u8])) + .expect("zero-plan program should encode"); + let count_at = overflow.len() - 4; + overflow[count_at..].copy_from_slice(&1_000_001u32.to_le_bytes()); + assert!(matches!( + decode_program(&overflow), + Err(WireError::LengthTooLarge("ret cleanup plans", _)) + )); + + let mut deep = encode_program(&{ + let mut valid = ldc_ret_program(); + valid.ret_cleanups = vec![RetCleanupPlan { + site: 5, + locals: vec![RetCleanupLocal { + slot: 0, + schema: resource_schema(), + }], + }]; + valid + }) + .expect("plan should encode"); + let key = b"pr24.closure"; + let schema_len = 1 + 4 + key.len(); + deep.truncate(deep.len() - schema_len); + for _ in 0..64 { + deep.push(16); + } + deep.push(17); + deep.extend_from_slice(&(key.len() as u32).to_le_bytes()); + deep.extend_from_slice(key); + assert!(matches!( + decode_program(&deep), + Err(WireError::LengthTooLarge("schema nesting depth", _)) + )); + + let mut bad_key = encode_program(&{ + let mut valid = ldc_ret_program(); + valid.ret_cleanups = vec![RetCleanupPlan { + site: 5, + locals: vec![RetCleanupLocal { + slot: 0, + schema: resource_schema(), + }], + }]; + valid + }) + .expect("plan should encode"); + let key_at = bad_key.len() - key.len(); + bad_key[key_at] = b'P'; + assert!(matches!( + decode_program(&bad_key), + Err(WireError::InvalidResourceKey(_)) + )); + + let mut version = encode_program(&Program::new(Vec::new(), vec![OpCode::Ret as u8])) + .expect("zero-plan program should encode"); + version[4..6].copy_from_slice(&12u16.to_le_bytes()); + assert!(matches!( + decode_program(&version), + Err(WireError::UnsupportedVersion(12)) + )); + } + + fn cleanup_program(schema: TypeSchema) -> Program { + let mut program = ldc_ret_program(); + program.ret_cleanups = vec![RetCleanupPlan { + site: 5, + locals: vec![RetCleanupLocal { slot: 0, schema }], + }]; + program + } + + #[test] + fn named_cleanup_validity_precedes_capability_in_both_result_branches() { + let mut owned_body = cleanup_program(TypeSchema::Named("Holder".to_string(), Vec::new())); + owned_body.named_struct_decls.insert( + "Holder".to_string(), + StructDecl { + name: "Holder".to_string(), + type_params: Vec::new(), + body_schema: resource_schema(), + origin: StructDeclOrigin::Guest, + }, + ); + let decoded = + decode_program(&encode_program(&owned_body).expect("named body should encode")) + .expect("named body should decode"); + assert_eq!( + decoded.ret_cleanups[0].locals[0].schema, + TypeSchema::Named("Holder".to_string(), Vec::new()) + ); + + for (label, schema) in [ + ( + "err", + TypeSchema::Result { + ok: Box::new(resource_schema()), + err: Box::new(TypeSchema::Named("Missing".to_string(), Vec::new())), + }, + ), + ( + "ok", + TypeSchema::Result { + ok: Box::new(TypeSchema::Named("Missing".to_string(), Vec::new())), + err: Box::new(resource_schema()), + }, + ), + ] { + let program = cleanup_program(schema); + assert_eq!( + encode_program(&program), + Err(WireError::InvalidRetCleanup( + "unresolved named cleanup schema" + )), + "{label} branch must be validated before a resource plan is accepted" + ); + } + + let mut arity = cleanup_program(TypeSchema::Named( + "Holder".to_string(), + vec![TypeSchema::Int], + )); + arity.named_struct_decls.insert( + "Holder".to_string(), + StructDecl { + name: "Holder".to_string(), + type_params: Vec::new(), + body_schema: resource_schema(), + origin: StructDeclOrigin::Guest, + }, + ); + assert_eq!( + encode_program(&arity), + Err(WireError::InvalidRetCleanup("named cleanup schema arity")) + ); + + let mut cycle = cleanup_program(TypeSchema::Named("Holder".to_string(), Vec::new())); + cycle.named_struct_decls.insert( + "Holder".to_string(), + StructDecl { + name: "Holder".to_string(), + type_params: Vec::new(), + body_schema: TypeSchema::Named("Holder".to_string(), Vec::new()), + origin: StructDeclOrigin::Guest, + }, + ); + assert_eq!( + encode_program(&cycle), + Err(WireError::InvalidRetCleanup("named cleanup schema cycle")) + ); + } +} diff --git a/tests/compiler/frontend_plugin_tests.rs b/tests/compiler/frontend_plugin_tests.rs index d01e6a98..2c905e1c 100644 --- a/tests/compiler/frontend_plugin_tests.rs +++ b/tests/compiler/frontend_plugin_tests.rs @@ -419,6 +419,350 @@ fn synthetic_frontend_ir_result_match_returns_ok_payload() { assert_eq!(vm.stack(), &[Value::Int(7)]); } +// This producer deliberately does not invoke the RustScript parser or analyzer. +struct IndependentTryIrPlugin; +static INDEPENDENT_TRY_IR: IndependentTryIrPlugin = IndependentTryIrPlugin; + +impl SourcePlugin for IndependentTryIrPlugin { + fn descriptor(&self) -> SourceFrontendDescriptor { + SourceFrontendDescriptor { + id: FrontendId::new("independent-try-ir"), + extensions: &["try-ir"], + typing_policy: FrontendTypingPolicy::Strict, + supports_ownership_checking: false, + } + } + + fn parse_source( + &self, + source: &str, + context: &SourceFrontendContext<'_>, + ) -> Result { + use vm::compiler::ir::{FunctionDecl, FunctionImpl}; + let span = vm::Span::new(0, 0, 1); + let result = vm::TypeSchema::Result { + ok: Box::new(vm::TypeSchema::Int), + err: Box::new(vm::TypeSchema::String), + }; + let leaf_callable = vm::TypeSchema::Callable { + params: Vec::new(), + result: Box::new(vm::TypeSchema::Int), + }; + let optional_callable = vm::TypeSchema::Optional(Box::new(leaf_callable.clone())); + let is_callable = source.starts_with("callable-"); + let operand = if is_callable { + vm::TypeSchema::Result { + ok: Box::new(optional_callable.clone()), + err: Box::new(vm::TypeSchema::String), + } + } else if source == "wrong-error" { + vm::TypeSchema::Result { + ok: Box::new(vm::TypeSchema::Int), + err: Box::new(vm::TypeSchema::Int), + } + } else { + result.clone() + }; + let make_result = |ok, payload, schema| Expr::ResultConstruct { + ok, + payload: Box::new(payload), + result_schema: schema, + span, + semantic_id: None, + }; + let callable = vm::TypeSchema::Callable { + params: Vec::new(), + result: Box::new(result.clone()), + }; + let mut ir = PLUG_A.ir(); + ir.locals = 10; + ir.stmts = vec![ + Stmt::FuncDecl { + name: "relay".into(), + index: 0, + arity: 0, + args: Vec::new(), + exported: false, + has_impl: true, + line: 1, + }, + Stmt::Let { + index: 1, + declared_schema: Some(callable), + expr: Expr::FunctionRef(0, Vec::new()), + line: 1, + }, + Stmt::Expr { + expr: Expr::ResultMatch { + value: Box::new(Expr::LocalCall(1, Vec::new(), Vec::new(), None)), + value_slot: 2, + ok_binding: Some(3), + ok_expr: Box::new(Expr::Var(3)), + err_binding: Some(4), + err_expr: Box::new(Expr::Var(4)), + scrutinee_schema: result.clone(), + span, + semantic_id: None, + }, + line: 1, + }, + ]; + ir.functions = vec![FunctionDecl { + name: "relay".into(), + arity: 0, + index: 0, + args: Vec::new(), + arg_schemas: Vec::new(), + return_schema: Some(result.clone()), + type_params: Vec::new(), + exported: false, + return_type: vm::ValueType::Map, + symbol: None, + }]; + if is_callable { + ir.functions.push(FunctionDecl { + name: "one".into(), + arity: 0, + index: 1, + args: Vec::new(), + arg_schemas: Vec::new(), + return_schema: Some(vm::TypeSchema::Int), + type_params: Vec::new(), + exported: false, + return_type: vm::ValueType::Int, + symbol: None, + }); + ir.function_impls.insert( + 1, + FunctionImpl { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body_stmts: Vec::new(), + body_expr: Expr::Int(1), + body_expr_line: 1, + }, + ); + } + let mark = context.install_host_function(&mut ir, "test::mark", 1)?; + let set_log = |n| Expr::Call(mark, Vec::new(), vec![Expr::Int(n)], None, None); + let ok = source == "ok" || is_callable; + let payload = if is_callable { + if source.ends_with("-null") { + Expr::Null + } else { + Expr::FunctionRef(1, Vec::new()) + } + } else if ok { + Expr::Int(7) + } else if source == "wrong-error" { + Expr::Int(9) + } else { + Expr::String("bad".into()) + }; + ir.function_impls.insert( + 0, + FunctionImpl { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body_stmts: vec![ + Stmt::Expr { + expr: set_log(1), + line: 1, + }, + Stmt::Let { + index: 6, + declared_schema: Some(if is_callable { + optional_callable + } else { + vm::TypeSchema::Int + }), + expr: Expr::ResultTry { + value: Box::new(make_result(ok, payload, operand.clone())), + value_slot: 7, + payload_slot: 8, + operand_schema: operand, + return_schema: result.clone(), + exit_cleanup: Vec::new(), + span, + semantic_id: None, + }, + line: 1, + }, + Stmt::Expr { + expr: set_log(2), + line: 1, + }, + ], + body_expr: make_result( + true, + if is_callable { + Expr::LocalCall( + if source == "callable-direct" { 6 } else { 9 }, + Vec::new(), + Vec::new(), + None, + ) + } else { + Expr::Var(6) + }, + result, + ), + body_expr_line: 1, + }, + ); + if source == "callable-direct" { + let body = ir.function_impls.get_mut(&0).unwrap(); + body.body_stmts.push(Stmt::Expr { + expr: Expr::LocalCall(6, Vec::new(), Vec::new(), None), + line: 1, + }); + body.body_expr = make_result( + true, + Expr::Int(1), + vm::TypeSchema::Result { + ok: Box::new(vm::TypeSchema::Int), + err: Box::new(vm::TypeSchema::String), + }, + ); + } + if is_callable && source != "callable-direct" { + ir.function_impls + .get_mut(&0) + .unwrap() + .body_stmts + .push(Stmt::Let { + index: 9, + declared_schema: Some(leaf_callable), + expr: Expr::OptionUnwrapOr { + value: Box::new(Expr::Var(6)), + value_slot: 5, + fallback: Box::new(Expr::Closure(vm::ClosureExpr { + param_slots: Vec::new(), + capture_copies: Vec::new(), + body: Box::new(Expr::Int(9)), + })), + semantic_id: None, + }, + line: 1, + }); + } + Ok(ir) + } + + fn parse_module_imports( + &self, + _source: &str, + _path: &Path, + _context: &SourceFrontendContext<'_>, + ) -> Result, vm::SourcePathError> { + Ok(Vec::new()) + } +} + +fn independent_try_options() -> CompileSourceFileOptions { + let mut catalog = vm::HostApiBuilder::new(); + catalog.function(vm::HostFunctionSchema::with_return( + "test::mark", + vec![vm::HostParamSchema::value("n", vm::HostTypeSchema::Int)], + vm::HostTypeSchema::Int, + )); + CompileSourceFileOptions::new() + .with_source_plugin(&INDEPENDENT_TRY_IR) + .with_host_api_catalog(std::sync::Arc::new(catalog.build().unwrap())) +} + +#[test] +fn independent_typed_try_ir_public_compile_runs_ok_err_and_effects() { + struct Mark(std::sync::Arc>>); + impl vm::HostFunction for Mark { + fn call(&mut self, _: &mut Vm, args: &[Value]) -> vm::VmResult { + self.0.lock().unwrap().push(args[0].clone()); + Ok(vm::CallOutcome::Return(vm::CallReturn::one( + args[0].clone(), + ))) + } + } + for (source, payload, effects) in [ + ("ok", Value::Int(7), vec![Value::Int(1), Value::Int(2)]), + ("err", Value::string("bad"), vec![Value::Int(1)]), + ] { + let compiled = compile_source_with_flavor_and_options( + source, + SourceFlavor::Plugin(FrontendId::new("independent-try-ir")), + independent_try_options(), + ) + .unwrap_or_else(|error| panic!("independent {source} IR admission: {error:?}")); + let log = std::sync::Arc::new(Mutex::new(Vec::new())); + let mut machine = Vm::new(compiled.program.with_local_count(compiled.locals)); + machine.bind_function("test::mark", Box::new(Mark(log.clone()))); + machine.set_fuel(100_000); + assert_eq!( + machine.run().expect("independent IR runtime"), + VmStatus::Halted + ); + assert_eq!(machine.stack(), &[payload]); + assert_eq!(*log.lock().unwrap(), effects); + } +} + +#[test] +fn independent_typed_try_ir_rejects_wrong_error_schema() { + let error = compile_source_with_flavor_and_options( + "wrong-error", + SourceFlavor::Plugin(FrontendId::new("independent-try-ir")), + independent_try_options(), + ) + .err() + .expect("wrong E must reject through public strict admission"); + assert!(error.to_string().contains("error type"), "{error:?}"); +} + +#[test] +fn independent_optional_callable_try_rejects_unrefined_invocation() { + let error = compile_source_with_flavor_and_options( + "callable-direct", + SourceFlavor::Plugin(FrontendId::new("independent-try-ir")), + independent_try_options(), + ) + .err() + .expect("OptionalCallable must not be directly invoked"); + assert!( + error.to_string().contains("optional") || error.to_string().contains("null"), + "{error:?}" + ); +} + +#[test] +fn independent_optional_callable_try_unwrap_executes_null_and_nonnull() { + struct Mark; + impl vm::HostFunction for Mark { + fn call(&mut self, _: &mut Vm, args: &[Value]) -> vm::VmResult { + Ok(vm::CallOutcome::Return(vm::CallReturn::one( + args[0].clone(), + ))) + } + } + for source in ["callable-unwrap-null", "callable-unwrap-nonnull"] { + let compiled = compile_source_with_flavor_and_options( + source, + SourceFlavor::Plugin(FrontendId::new("independent-try-ir")), + independent_try_options(), + ) + .unwrap_or_else(|error| panic!("{source} admission: {error:?}")); + let mut machine = Vm::new(compiled.program.with_local_count(compiled.locals)); + machine.set_fuel(100_000); + machine.bind_function("test::mark", Box::new(Mark)); + assert_eq!( + machine.run().expect("unwrapped callable runtime"), + VmStatus::Halted + ); + assert_eq!( + machine.stack(), + &[Value::Int(if source.ends_with("-null") { 9 } else { 1 })] + ); + } +} + struct ContextualResultIrPlugin; static CONTEXTUAL_RESULT_IR: ContextualResultIrPlugin = ContextualResultIrPlugin; diff --git a/tests/pr24_resource_tests.rs b/tests/pr24_resource_tests.rs index 058a0c0b..93c3e485 100644 --- a/tests/pr24_resource_tests.rs +++ b/tests/pr24_resource_tests.rs @@ -1,6 +1,6 @@ #![cfg(feature = "runtime")] -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use vm::compiler::{HostCallResolver, TypeSchema}; @@ -304,6 +304,16 @@ impl HostFunction for CloseBlockResource { static CLOSURE_CLOSES: AtomicUsize = AtomicUsize::new(0); #[cfg(feature = "runtime")] static CLOSURE_TEST_LOCK: Mutex<()> = Mutex::new(()); +#[cfg(feature = "runtime")] +static LAST_LOCAL_RAW: AtomicU64 = AtomicU64::new(0); +#[cfg(feature = "runtime")] +static LAST_ERR_RAW: AtomicU64 = AtomicU64::new(0); +#[cfg(feature = "runtime")] +static USE_OWNED_CALLS: AtomicUsize = AtomicUsize::new(0); +#[cfg(feature = "runtime")] +static LATER_ARG_CALLS: AtomicUsize = AtomicUsize::new(0); +static BORROW_BOUNDARY_CALLS: AtomicUsize = AtomicUsize::new(0); +static AOTNULL_ERR_CALLS: AtomicUsize = AtomicUsize::new(0); #[derive(Debug)] struct ClosureResource; @@ -330,9 +340,9 @@ impl HostFunction for MakeClosureResource { .host_context() .push_resource(ClosureResource) .map_err(|error| VmError::HostError(error.to_string()))?; - Ok(CallOutcome::Return(CallReturn::one(Value::Int( - token.handle().raw() as i64, - )))) + let raw = token.handle().raw(); + LAST_LOCAL_RAW.store(raw, Ordering::SeqCst); + Ok(CallOutcome::Return(CallReturn::one(Value::Int(raw as i64)))) } } @@ -615,6 +625,7 @@ fn result_resource_catalog() -> Arc { resource_key.clone(), "closure resource", )); + builder.resource(ResourceTypeSchema::new(key("pr24.block"), "block resource")); let result_ok = HostTypeSchema::Result { ok: Box::new(HostTypeSchema::Resource(resource_key.clone())), err: Box::new(HostTypeSchema::Int), @@ -663,15 +674,93 @@ fn result_resource_catalog() -> Arc { Vec::new(), result_ok, )); + builder.function(HostFunctionSchema::with_return( + "test::make_local", + Vec::new(), + HostTypeSchema::Resource(resource_key.clone()), + )); builder.function(HostFunctionSchema::with_return( "test::close_closure", vec![HostParamSchema::with_passing( "resource", - HostTypeSchema::Resource(resource_key), + HostTypeSchema::Resource(resource_key.clone()), HostParamPassing::TakeOwned, )], HostTypeSchema::Null, )); + builder.function(HostFunctionSchema::with_return( + "test::use_owned", + vec![ + HostParamSchema::with_passing( + "resource", + HostTypeSchema::Resource(resource_key.clone()), + HostParamPassing::TakeOwned, + ), + HostParamSchema::value("mid", HostTypeSchema::Int), + HostParamSchema::value("later", HostTypeSchema::Int), + ], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "test::use_owned_array", + vec![ + HostParamSchema::with_passing( + "items", + HostTypeSchema::Array(Box::new(HostTypeSchema::Resource(resource_key.clone()))), + HostParamPassing::TakeOwned, + ), + HostParamSchema::value("later", HostTypeSchema::Int), + ], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "test::later_arg", + Vec::new(), + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "test::touch_borrow", + vec![HostParamSchema::with_passing( + "resource", + HostTypeSchema::Resource(resource_key.clone()), + HostParamPassing::Borrow, + )], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "test::touch_borrow_mut", + vec![ + HostParamSchema::with_passing( + "resource", + HostTypeSchema::Resource(resource_key.clone()), + HostParamPassing::BorrowMut, + ), + HostParamSchema::value("later", HostTypeSchema::Int), + ], + HostTypeSchema::Int, + )); + builder.function(HostFunctionSchema::with_return( + "test::make_block", + Vec::new(), + HostTypeSchema::Resource(key("pr24.block")), + )); + builder.function(HostFunctionSchema::with_return( + "test::take_null", + vec![HostParamSchema::with_passing( + "resource", + HostTypeSchema::Resource(resource_key), + HostParamPassing::TakeOwned, + )], + HostTypeSchema::Optional(Box::new(HostTypeSchema::Int)), + )); + builder.function(HostFunctionSchema::with_return( + "test::make_err_int", + Vec::new(), + HostTypeSchema::Result { + ok: Box::new(HostTypeSchema::Int), + err: Box::new(HostTypeSchema::Int), + }, + )); Arc::new( builder .build() @@ -697,24 +786,31 @@ impl HostFunction for MakeOkResult { .host_context() .push_resource(ClosureResource) .map_err(|error| VmError::HostError(error.to_string()))?; + let raw = token.handle().raw(); + LAST_LOCAL_RAW.store(raw, Ordering::SeqCst); Ok(CallOutcome::Return(CallReturn::one(result_map( true, - Value::Int(token.handle().raw() as i64), + Value::Int(raw as i64), false, )))) } } +static POLEX_EVENTS: Mutex> = Mutex::new(Vec::new()); + struct MakeErrResult; impl HostFunction for MakeErrResult { fn call(&mut self, vm: &mut Vm, _args: &[Value]) -> VmResult { + POLEX_EVENTS.lock().unwrap().push("err"); let token = vm .host_context() .push_resource(ClosureResource) .map_err(|error| VmError::HostError(error.to_string()))?; + let raw = token.handle().raw(); + LAST_ERR_RAW.store(raw, Ordering::SeqCst); Ok(CallOutcome::Return(CallReturn::one(result_map( false, - Value::Int(token.handle().raw() as i64), + Value::Int(raw as i64), false, )))) } @@ -810,6 +906,66 @@ impl HostFunction for MakeBadPayloadResult { } } +struct UseOwnedResource; +impl HostFunction for UseOwnedResource { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + USE_OWNED_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(CallOutcome::Return(CallReturn::one(Value::Int(1)))) + } +} + +struct UseOwnedArray; +impl HostFunction for UseOwnedArray { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + USE_OWNED_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(CallOutcome::Return(CallReturn::one(Value::Int(1)))) + } +} + +struct LaterArg; +impl HostFunction for LaterArg { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + LATER_ARG_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(CallOutcome::Return(CallReturn::one(Value::Int(1)))) + } +} + +struct TouchBorrow; +impl HostFunction for TouchBorrow { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + Ok(CallOutcome::Return(CallReturn::one(Value::Int(1)))) + } +} + +struct TouchBorrowMut; +impl HostFunction for TouchBorrowMut { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + BORROW_BOUNDARY_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(CallOutcome::Return(CallReturn::one(Value::Int(1)))) + } +} + +struct TakeNull; +impl HostFunction for TakeNull { + fn call(&mut self, vm: &mut Vm, args: &[Value]) -> VmResult { + POLEX_EVENTS.lock().unwrap().push("close"); + CloseClosureResource.call(vm, args)?; + Ok(CallOutcome::Return(CallReturn::one(Value::Null))) + } +} + +struct MakeErrInt; +impl HostFunction for MakeErrInt { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + AOTNULL_ERR_CALLS.fetch_add(1, Ordering::SeqCst); + Ok(CallOutcome::Return(CallReturn::one(result_map( + false, + Value::Int(9), + false, + )))) + } +} + fn bind_result_hosts(vm: &mut Vm) { vm.bind_function("test::make_ok_result", Box::new(MakeOkResult)); vm.bind_function("test::make_err_result", Box::new(MakeErrResult)); @@ -825,7 +981,16 @@ fn bind_result_hosts(vm: &mut Vm) { "test::make_bad_payload_result", Box::new(MakeBadPayloadResult), ); + vm.bind_function("test::make_local", Box::new(MakeClosureResource)); vm.bind_function("test::close_closure", Box::new(CloseClosureResource)); + vm.bind_function("test::use_owned", Box::new(UseOwnedResource)); + vm.bind_function("test::use_owned_array", Box::new(UseOwnedArray)); + vm.bind_function("test::later_arg", Box::new(LaterArg)); + vm.bind_function("test::touch_borrow", Box::new(TouchBorrow)); + vm.bind_function("test::touch_borrow_mut", Box::new(TouchBorrowMut)); + vm.bind_function("test::make_block", Box::new(MakeBlockResource)); + vm.bind_function("test::make_err_int", Box::new(MakeErrInt)); + vm.bind_function("test::take_null", Box::new(TakeNull)); } #[test] @@ -1003,3 +1168,1462 @@ fn result_host_return_rejects_extra_field_wrong_key_and_wrong_generation() { drop(vm); assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 0); } + +#[test] +fn result_try_cleans_local_resource_and_returns_err_resource_once() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + CLOSURE_CLOSES.store(0, Ordering::SeqCst); + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let source = r#" + fn close_err(handle: resource) -> int { + test::close_closure(handle); + 1 + } + fn relay() -> Result> { + let local: resource = test::make_local(); + let value: int = test::make_err_result()?; + Result::Ok(value) + } + let moved = match relay() { + Result::Ok(v) => v, + Result::Err(handle) => close_err(handle), + }; + moved; + "#; + let compiled = + compile_source_with_flavor_and_options(source, vm::SourceFlavor::RustScript, options) + .expect("Err resource propagation should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + bind_result_hosts(&mut vm); + assert_eq!( + vm.run().expect("Err resource propagation should run"), + VmStatus::Halted + ); + assert_eq!(vm.stack().last().cloned(), Some(Value::Int(1))); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 2); +} + +// PO-LEX uses the public RSS compiler and real resource table. All observations +// are made before VM teardown, while the original Err payload remains live. +fn assert_polex_err(body: &str, closure_closes: usize, block_closes: usize) { + assert_polex_err_backends(body, closure_closes, block_closes, &[0, 1, 2]); +} + +fn assert_polex_err_backends( + body: &str, + closure_closes: usize, + block_closes: usize, + backends: &[u8], +) { + let source = format!( + "fn relay() -> Result> {{ + let owned: resource = test::make_local(); + let sibling: resource = test::make_block(); + {body} + Result::Ok(0) + }} + let carried = relay(); carried;" + ); + for &backend in backends { + eprintln!("POLEX backend={backend} body={body}"); + reset_exit_observers(); + let mut vm = run_result_source_backend(&source, backend); + let err_raw = err_payload_raw(vm.stack().last().expect("guest Err")) as u64; + assert_eq!(err_raw, LAST_ERR_RAW.load(Ordering::SeqCst)); + assert!(closure_is_open(&mut vm, err_raw)); + assert!(vm.execution_scope().is_active()); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), closure_closes); + assert_eq!(BLOCK_CLOSES.load(Ordering::SeqCst), block_closes); + assert_eq!(vm.execution_scope().resources().len(), 1); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), closure_closes + 1); + assert_eq!(BLOCK_CLOSES.load(Ordering::SeqCst), block_closes); + } +} + +#[test] +fn observation_statement_if_keeps_owner_for_explicit_close() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let source = "fn relay() -> int { let owned: resource = test::make_local(); let mut observed = 0; if owned == null { observed = 99; } else { observed = 1; } test::close_closure(owned); observed } relay();"; + for backend in [0, 1] { + reset_exit_observers(); + let mut vm = run_result_source_backend(source, backend); + assert_eq!(vm.stack().last(), Some(&Value::Int(1))); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + assert!(!closure_is_open( + &mut vm, + LAST_LOCAL_RAW.load(Ordering::SeqCst) + )); + assert!(vm.execution_scope().resources().is_empty()); + assert!(vm.execution_scope().is_active()); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + } +} + +#[test] +fn observation_while_keeps_owner_across_multiple_condition_checks() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let source = "fn relay() -> int { let owned: resource = test::make_local(); let mut n = 0; while owned != null && n < 2 { n = n + 1; } test::close_closure(owned); n } relay();"; + for backend in [0, 1] { + reset_exit_observers(); + let mut vm = run_result_source_backend(source, backend); + assert_eq!(vm.stack().last(), Some(&Value::Int(2))); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + assert!(!closure_is_open( + &mut vm, + LAST_LOCAL_RAW.load(Ordering::SeqCst) + )); + assert!(vm.execution_scope().resources().is_empty()); + assert!(vm.execution_scope().is_active()); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + } +} + +#[test] +fn observation_expression_if_and_nested_logical_keep_err_cleanup_owner() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for body in [ + "let observed = if owned == null => { 99 } else => { 1 }; let v = test::make_err_result()?;", + "if (owned != null && true) || owned == null { test::later_arg(); } let v = test::make_err_result()?;", + "for i in 0..(if owned != null => { 2 } else => { 0 }) { test::later_arg(); } let v = test::make_err_result()?;", + ] { + // Resource-vs-Null Ceq remains outside the admitted AOT instruction set. + assert_polex_err_backends(body, 1, 1, &[0, 1]); + } +} + +#[test] +fn polex_reached_and_rhs_uses_post_lhs_owner_state() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + reset_exit_observers(); + assert_polex_err( + "let flag = test::close_closure(owned) == null && test::make_err_result()? == 9;", + 1, + 1, + ); +} + +#[test] +fn polex_statement_if_condition_closes_sibling_before_err() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + reset_exit_observers(); + assert_polex_err("if test::make_err_result()? == 9 { 1; } else { 0; }", 1, 1); +} + +#[test] +fn polex_while_condition_closes_sibling_before_err() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + reset_exit_observers(); + assert_polex_err("while test::make_err_result()? == 9 { break; }", 1, 1); +} + +#[test] +fn polex_for_condition_closes_sibling_before_err() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + reset_exit_observers(); + assert_polex_err("for i in 0..test::make_err_result()? { break; }", 1, 1); +} + +macro_rules! polex_position_test { + ($name:ident, $body:literal) => { + #[test] + fn $name() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + reset_exit_observers(); + assert_polex_err($body, 1, 1); + } + }; +} + +polex_position_test!( + polex_reached_or_rhs, + "let flag = !(test::close_closure(owned) == null) || test::make_err_result()? == 9;" +); +polex_position_test!( + polex_eager_add_rhs, + "fn take(h: resource) -> int { test::close_closure(h); 3 } let x = take(owned) + test::make_err_result()?;" +); +polex_position_test!( + polex_nested_expression_if, + "let x = 1 + (if test::close_closure(owned) == null => { test::make_err_result()? } else => { 0 });" +); +#[test] +fn polex_unwrap_fallback() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + assert_polex_err_backends( + "let x = test::take_null(owned).unwrap_or(test::make_err_result()?);", + 1, + 1, + &[0, 1, 2], + ); + assert_eq!(*POLEX_EVENTS.lock().unwrap(), vec!["close", "err"]); +} + +#[test] +fn polex_aot_optional_null_baseline_probe() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + reset_exit_observers(); + let mut vm = run_result_source_backend( + "fn absent() -> int? { null } fn relay() -> Result { let x = absent().unwrap_or(test::make_err_int()?); Result::Ok(0) } let carried = relay(); carried;", + 2, + ); + assert_eq!(err_payload_raw(vm.stack().last().unwrap()), 9); + assert!(vm.execution_scope().is_active()); + assert_eq!(AOTNULL_ERR_CALLS.load(Ordering::SeqCst), 1); + assert!(vm.aot_exec_count() > 0); + assert!(vm.dump_aot_info().contains("lowering=ssa")); + println!( + "AOTNULL exact scalar fallback_calls=1\n{}\n{}", + vm::disassemble_program(vm.program()), + vm.dump_aot_info() + ); +} + +#[test] +fn aotnull_exact_scalar_interpreter_and_decoded_control() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for backend in [0, 1] { + reset_exit_observers(); + let mut vm = run_result_source_backend( + "fn absent() -> int? { null } fn relay() -> Result { let x = absent().unwrap_or(test::make_err_int()?); Result::Ok(0) } let carried = relay(); carried;", + backend, + ); + assert_eq!(err_payload_raw(vm.stack().last().unwrap()), 9); + assert!(vm.execution_scope().is_active()); + } +} + +#[test] +fn aotnull_nullable_payloads_and_fallback_effects() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for (ty, expression, fallback, expected) in [ + ("int", "null", "test::later_arg()", Value::Int(1)), + ("int", "0", "test::later_arg()", Value::Int(0)), + ("int", "7", "test::later_arg()", Value::Int(7)), + ("bool", "null", "test::later_arg() == 1", Value::Bool(true)), + ( + "bool", + "false", + "test::later_arg() == 1", + Value::Bool(false), + ), + ("bool", "true", "test::later_arg() == 1", Value::Bool(true)), + ( + "float", + "null", + "if test::later_arg() == 1 => { 1.5 } else => { 2.5 }", + Value::Float(1.5), + ), + ( + "float", + "0.0", + "if test::later_arg() == 1 => { 1.5 } else => { 2.5 }", + Value::Float(0.0), + ), + ( + "float", + "2.5", + "if test::later_arg() == 1 => { 1.5 } else => { 2.5 }", + Value::Float(2.5), + ), + ] { + for backend in [0, 1, 2] { + for source in [ + format!("fn pick() -> {ty}? {{ {expression} }} pick().unwrap_or({fallback});"), + format!( + "fn relay(x:{ty}?) -> {ty} {{ x.unwrap_or({fallback}) }} relay({expression});" + ), + format!( + "fn pick() -> {ty}? {{ {expression} }} let f = pick; f().unwrap_or({fallback});" + ), + format!( + "let f: fn() -> {ty}? = || if {} => {{ null }} else => {{ {} }}; let value: {ty}? = f(); value.unwrap_or({fallback});", + expression == "null", + if expression == "null" { + match ty { + "int" => "0", + "bool" => "false", + "float" => "0.0", + _ => unreachable!(), + } + } else { + expression + } + ), + ] { + reset_exit_observers(); + let vm = run_result_source_backend(&source, backend); + assert_eq!( + vm.stack().last(), + Some(&expected), + "backend={backend} {source}" + ); + assert_eq!( + LATER_ARG_CALLS.load(Ordering::SeqCst), + usize::from(expression == "null"), + "backend={backend} {source}" + ); + if backend == 2 { + assert!(vm.dump_aot_info().contains("lowering=ssa")); + } + } + } + } +} + +#[test] +fn aotnull_fatal_fallback_is_not_guest_err() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + struct FatalFallback; + impl HostFunction for FatalFallback { + fn call(&mut self, _vm: &mut Vm, _args: &[Value]) -> VmResult { + AOTNULL_ERR_CALLS.fetch_add(1, Ordering::SeqCst); + Err(vm::VmError::HostError("aotnull fatal fallback".into())) + } + } + for expression in ["null", "0", "7"] { + for backend in [0, 1, 2] { + reset_exit_observers(); + let source = format!( + "fn pick() -> int? {{ {expression} }} fn relay() -> Result {{ let x = pick().unwrap_or(test::make_err_int()?); Result::Ok(x) }} relay();" + ); + let options = + CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let compiled = compile_source_with_flavor_and_options( + &source, + vm::SourceFlavor::RustScript, + options, + ) + .unwrap(); + let program = compiled.program.with_local_count(compiled.locals); + let program = if backend == 1 { + vm::decode_program(&vm::encode_program(&program).unwrap()).unwrap() + } else { + program + }; + let mut vm = Vm::new(program); + vm.set_fuel(100_000); + bind_result_hosts(&mut vm); + vm.bind_function("test::make_err_int", Box::new(FatalFallback)); + if backend == 2 { + vm.compile_aot().unwrap(); + } + let outcome = vm.run(); + if expression == "null" { + assert!( + matches!(outcome, Err(vm::VmError::HostError(ref message)) if message == "aotnull fatal fallback"), + "{outcome:?}" + ); + assert_eq!(AOTNULL_ERR_CALLS.load(Ordering::SeqCst), 1); + } else { + assert_eq!(outcome.unwrap(), VmStatus::Halted); + assert_eq!( + vm.stack().last(), + Some(&result_map( + true, + Value::Int(expression.parse().unwrap()), + false + )) + ); + assert_eq!(AOTNULL_ERR_CALLS.load(Ordering::SeqCst), 0); + } + if backend == 2 { + assert!(vm.aot_exec_count() > 0); + } + } + } +} + +#[test] +fn aotnull_loop_jit_and_aot_fallback_effect_parity() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let source = "fn pick(i:int) -> int? { if i < 50 => { null } else => { 0 } } fn relay() -> int { let mut total = 0; for i in 0..100 { total = total + pick(i).unwrap_or(test::later_arg()); } total } relay();"; + for backend in [0, 1, 2, 4] { + reset_exit_observers(); + let vm = run_result_source_backend(source, backend); + assert_eq!(vm.stack().last(), Some(&Value::Int(50))); + assert_eq!(LATER_ARG_CALLS.load(Ordering::SeqCst), 50); + if backend == 4 { + let dump = vm.dump_jit_info(); + println!( + "AOTNULL trace\n{}", + dump.lines() + .filter(|line| !line.trim_start().starts_with("code:")) + .collect::>() + .join("\n") + ); + assert!(vm.jit_native_exec_count() > 0); + assert!(vm.jit_snapshot().traces.iter().any(|trace| { + let ssa = trace.ssa_text(); + trace.executions > 0 + && ssa.contains("type_of") + && ssa.contains("String(\"null\")") + && ssa.contains("branch_bool") + })); + } + } +} + +#[test] +fn polex_optional_index() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + // AOT does not support this Optional index's Get(String) instruction. + assert_polex_err_backends( + "let arr: array? = [1]; let x = arr?.[(if test::close_closure(owned) == null => { test::make_err_result()? } else => { 0 })];", + 1, + 1, + &[0, 1], + ); +} + +polex_position_test!( + polex_for_init_try, + "for i in test::make_err_result()? .. 2 { break; }" +); +polex_position_test!( + polex_expression_if_condition, + "let x = if test::make_err_result()? == 9 => { 1 } else => { 0 };" +); +#[test] +fn polex_nested_match() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + // Optional-pattern Ceq is outside AOT's admitted instruction set. + assert_polex_err_backends( + "let x = 1 + (match test::take_null(owned) { Some(v) => v, _ => test::make_err_result()?, });", + 1, + 1, + &[0, 1], + ); +} +polex_position_test!( + polex_nested_result_match, + "fn take(h: resource) -> Result { test::close_closure(h); Result::Err(3) } let x = 1 + (match take(owned) { Result::Ok(v) => v, Result::Err(e) => test::make_err_result()?, });" +); +polex_position_test!( + polex_aggregate_sibling, + "fn take(h: resource) -> int { test::close_closure(h); 3 } let x = [take(owned), test::make_err_result()?];" +); +polex_position_test!( + polex_call_sibling, + "fn take(h: resource) -> int { test::close_closure(h); 3 } fn sum(a:int,b:int)->int { a+b } let x = sum(take(owned), test::make_err_result()?);" +); + +#[test] +fn polex_eager_binary_runs_once_in_left_to_right_order() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + assert_polex_err( + "fn take(h: resource) -> int { test::close_closure(h); 3 } let x = take(owned) - test::make_err_result()?;", + 1, + 1, + ); + assert_eq!(*POLEX_EVENTS.lock().unwrap(), vec!["close", "err"]); +} + +#[test] +fn polex_reached_and_skipped_try_both_operators() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for op in ["&&", "||"] { + for flag in [false, true] { + assert_polex_err( + &format!( + "let x = ((test::close_closure(owned) == null) == {flag}) {op} (test::make_err_result()? == 9); test::later_arg(); let v = test::make_err_result()?;" + ), + 1, + 1, + ); + let reached = if op == "&&" { flag } else { !flag }; + assert_eq!( + LATER_ARG_CALLS.load(Ordering::SeqCst), + usize::from(!reached) + ); + assert_eq!(*POLEX_EVENTS.lock().unwrap(), vec!["close", "err"]); + } + } +} + +#[test] +fn polex_finite_loop_break_continue_and_post_controls() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for body in [ + "while true { let closed = test::close_closure(owned); break; } let v = test::make_err_result()?;", + "while false { let closed = test::close_closure(owned); break; } let v = test::make_err_result()?;", + "for i in 0..2 { let closed = test::close_closure(owned); break; } let v = test::make_err_result()?;", + "for i in 0..0 { let closed = test::close_closure(owned); break; } let v = test::make_err_result()?;", + "for i in 0..2 { if i == 0 { continue; } let v = test::make_err_result()?; }", + ] { + assert_polex_err(body, 1, 1); + } +} + +#[test] +fn polex_optional_fallback_skipped_owner_is_not_definitely_moved() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for flag in [false, true] { + assert_polex_err( + &format!( + "let maybe: int? = if {flag} => {{ 1 }} else => {{ null }}; let x = maybe.unwrap_or(if test::close_closure(owned) == null => {{ 2 }} else => {{ 3 }}); let v = test::make_err_result()?;" + ), + 1, + 1, + ); + let expected = if flag { + vec!["err"] + } else { + vec!["close", "err"] + }; + assert_eq!(*POLEX_EVENTS.lock().unwrap(), expected); + } +} + +#[test] +fn polex_short_circuit_skipped_rhs_retains_owner_for_later_try() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for op in ["&&", "||"] { + for flag in [false, true] { + reset_exit_observers(); + assert_polex_err( + &format!( + "let flag = {flag} {op} (test::close_closure(owned) == null); let value = test::make_err_result()?;" + ), + 1, + 1, + ); + let reached = if op == "&&" { flag } else { !flag }; + let expected = if reached { + vec!["close", "err"] + } else { + vec!["err"] + }; + assert_eq!(*POLEX_EVENTS.lock().unwrap(), expected); + } + } +} + +#[test] +fn polex_loop_backedge_fresh_owner_is_in_condition_cleanup() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for body in [ + "let mut i = 0; while if i == 0 => { true } else => { test::make_err_result()? == 9 } { let inner: resource = test::make_local(); i = i + 1; continue; }", + "let mut n = 0; for i in 0..(if n == 0 => { 2 } else => { test::make_err_result()? }) { let inner: resource = test::make_local(); n = n + 1; continue; }", + ] { + reset_exit_observers(); + assert_polex_err(body, 2, 1); + } +} + +#[test] +fn polex_scalar_error_conditions_close_before_vm_drop() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for body in [ + "let x = test::close_closure(owned) == null && test::make_err_int()? == 9;", + "if test::make_err_int()? == 9 { 1; } else { 0; }", + "while test::make_err_int()? == 9 { break; }", + "for i in 0..test::make_err_int()? { break; }", + ] { + let source = format!( + "fn relay() -> Result {{ let owned: resource = test::make_local(); let sibling: resource = test::make_block(); {body} Result::Ok(0) }} let carried = relay(); carried;" + ); + for backend in 0..3 { + reset_exit_observers(); + let mut vm = run_result_source_backend(&source, backend); + assert_eq!(err_payload_raw(vm.stack().last().unwrap()), 9); + assert!(vm.execution_scope().is_active()); + assert_eq!(vm.execution_scope().resources().len(), 0); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + assert_eq!(BLOCK_CLOSES.load(Ordering::SeqCst), 1); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + assert_eq!(BLOCK_CLOSES.load(Ordering::SeqCst), 1); + } + } +} + +fn reset_exit_observers() { + AOTNULL_ERR_CALLS.store(0, Ordering::SeqCst); + POLEX_EVENTS.lock().unwrap().clear(); + CLOSURE_CLOSES.store(0, Ordering::SeqCst); + BLOCK_CLOSES.store(0, Ordering::SeqCst); + LAST_LOCAL_RAW.store(0, Ordering::SeqCst); + LAST_ERR_RAW.store(0, Ordering::SeqCst); + USE_OWNED_CALLS.store(0, Ordering::SeqCst); + LATER_ARG_CALLS.store(0, Ordering::SeqCst); + BORROW_BOUNDARY_CALLS.store(0, Ordering::SeqCst); +} + +fn closure_is_open(vm: &mut Vm, raw: u64) -> bool { + let Ok(handle) = ResourceHandle::from_raw(raw) else { + return false; + }; + vm.execution_scope() + .resources() + .typed::(handle) + .is_ok() +} + +fn err_payload_raw(value: &Value) -> i64 { + let Value::Map(map) = value else { + panic!("expected returned Result map before VM drop, got {value:?}"); + }; + assert_eq!( + map.get(&Value::string("tag")), + Some(&Value::Bool(false)), + "Err exit must return the original Err carrier, got {value:?}" + ); + match map.get(&Value::string("payload")) { + Some(Value::Int(raw)) => *raw, + other => panic!("expected resource Err payload, got {other:?}"), + } +} + +fn assert_local_closed_and_err_open_before_drop(vm: &mut Vm, local_raw: u64, err_raw: u64) { + let local_open = closure_is_open(vm, local_raw); + let err_open = closure_is_open(vm, err_raw); + let closes = CLOSURE_CLOSES.load(Ordering::SeqCst); + let live = vm.execution_scope().resources().len(); + assert!( + !local_open && err_open && closes == 1, + "before VM drop the owned local must be closed once and the returned Err resource must still be open and usable: closes={closes} local_open={local_open} err_open={err_open} live={live} local={local_raw} err={err_raw}" + ); + let err_handle = ResourceHandle::from_raw(err_raw).expect("returned Err handle"); + vm.execution_scope() + .close_resource::(err_handle, ResourceCloseReason::Requested) + .expect("caller should close the still-open Err resource once"); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 2); + assert!(!closure_is_open(vm, err_raw)); +} + +fn run_result_source(source: &str) -> Vm { + run_result_source_backend(source, 3) +} + +fn run_result_source_backend(source: &str, backend: u8) -> Vm { + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let compiled = + compile_source_with_flavor_and_options(source, vm::SourceFlavor::RustScript, options) + .unwrap_or_else(|error| { + panic!("resource try source should compile: {error:?}\n{source}") + }); + let program = compiled.program.with_local_count(compiled.locals); + let program = if backend == 1 { + vm::decode_program(&vm::encode_program(&program).unwrap()).unwrap() + } else { + program + }; + let mut vm = if backend == 4 { + Vm::new_with_jit_config( + program, + vm::JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ) + } else { + Vm::new(program) + }; + vm.set_fuel(100_000); + bind_result_hosts(&mut vm); + if backend < 3 { + // The legacy fixture's Null host returns zero values. These tests bind + // its explicit Null value so a let consumes exactly one value and the + // AOT branch shapes match the host signature. + struct ExplicitClose; + impl HostFunction for ExplicitClose { + fn call(&mut self, vm: &mut Vm, args: &[Value]) -> VmResult { + POLEX_EVENTS.lock().unwrap().push("close"); + CloseClosureResource.call(vm, args)?; + Ok(CallOutcome::Return(CallReturn::one(Value::Null))) + } + } + vm.bind_function("test::close_closure", Box::new(ExplicitClose)); + } + if backend == 2 { + vm.compile_aot().expect("A03 AOT compile"); + } + assert_eq!( + vm.run().unwrap_or_else(|error| panic!( + "resource try should run (backend={backend}): {error:?}" + )), + VmStatus::Halted + ); + if backend == 2 { + assert!(vm.aot_exec_count() > 0); + } + vm +} + +#[test] +fn result_try_closes_live_local_before_vm_drop_and_keeps_err_open() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result> { + let local: resource = test::make_local(); + let value: int = test::make_err_result()?; + test::close_closure(local); + Result::Ok(value) + } + let carried = relay(); + carried; + "#; + let mut vm = run_result_source(source); + let err_raw = err_payload_raw(vm.stack().last().expect("returned Result")); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert_eq!(err_raw as u64, LAST_ERR_RAW.load(Ordering::SeqCst)); + assert_ne!(local_raw, 0); + assert_local_closed_and_err_open_before_drop(&mut vm, local_raw, err_raw as u64); + drop(vm); + assert_eq!( + CLOSURE_CLOSES.load(Ordering::SeqCst), + 2, + "VM drop must not close the already closed local or Err resource again" + ); +} + +#[test] +fn result_try_decoded_program_closes_live_local_before_vm_drop() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result> { + let local: resource = test::make_local(); + let value: int = test::make_err_result()?; + test::close_closure(local); + Result::Ok(value) + } + let carried = relay(); + carried; + "#; + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let compiled = + compile_source_with_flavor_and_options(source, vm::SourceFlavor::RustScript, options) + .expect("resource try source should compile"); + let bytes = vm::encode_program(&compiled.program.with_local_count(compiled.locals)) + .expect("compiled resource try program should encode"); + let decoded = vm::decode_program(&bytes).expect("compiled resource try program should decode"); + let mut vm = Vm::new(decoded.with_local_count(compiled.locals)); + vm.set_fuel(100_000); + bind_result_hosts(&mut vm); + assert_eq!( + vm.run().unwrap_or_else(|error| { + panic!("decoded resource try should run on a fresh VM: {error:?}") + }), + VmStatus::Halted + ); + let err_raw = err_payload_raw(vm.stack().last().expect("returned Result")); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert_eq!(err_raw as u64, LAST_ERR_RAW.load(Ordering::SeqCst)); + assert_ne!(local_raw, 0); + assert_local_closed_and_err_open_before_drop(&mut vm, local_raw, err_raw as u64); + drop(vm); + assert_eq!( + CLOSURE_CLOSES.load(Ordering::SeqCst), + 2, + "VM drop must not close the already closed local or Err resource again" + ); +} + +#[test] +fn result_try_aot_artifact_closes_live_local_before_vm_drop() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result> { + let local: resource = test::make_local(); + let value: int = test::make_err_result()?; + test::close_closure(local); + Result::Ok(value) + } + let carried = relay(); + carried; + "#; + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let compiled = + compile_source_with_flavor_and_options(source, vm::SourceFlavor::RustScript, options) + .expect("resource try source should compile"); + let jit = vm::JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }; + let mut producer = Vm::new_with_jit_config( + compiled.program.with_local_count(compiled.locals), + jit.clone(), + ); + producer.set_fuel(100_000); + bind_result_hosts(&mut producer); + let artifact = producer + .encode_aot_artifact() + .expect("AOT artifact should embed the current v13 cleanup section"); + drop(producer); + reset_exit_observers(); + let mut vm = Vm::new_from_aot_artifact_with_jit_config(&artifact, jit) + .expect("fresh VM should load the artifact program and SSA code"); + vm.set_fuel(100_000); + bind_result_hosts(&mut vm); + assert_eq!( + vm.run().unwrap_or_else(|error| { + panic!("artifact AOT resource try should run on a fresh VM: {error:?}") + }), + VmStatus::Halted + ); + assert!( + vm.aot_exec_count() > 0, + "SSA return must execute, not only the interpreter" + ); + let err_raw = err_payload_raw(vm.stack().last().expect("returned Result")); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert_eq!(err_raw as u64, LAST_ERR_RAW.load(Ordering::SeqCst)); + assert_ne!(local_raw, 0); + assert_local_closed_and_err_open_before_drop(&mut vm, local_raw, err_raw as u64); + drop(vm); + assert_eq!( + CLOSURE_CLOSES.load(Ordering::SeqCst), + 2, + "artifact VM drop must not close the already closed local or Err resource again" + ); +} + +#[test] +fn result_try_postoperand_named_ok_carrier_transfers_resource_once() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result { + let carrier = test::make_ok_result(); + let handle: resource = carrier?; + test::close_closure(handle); + Result::Ok(7) + } + let observed = match relay() { Result::Ok(n) => n, Result::Err(e) => e, }; + observed; + "#; + let mut vm = run_result_source(source); + assert_eq!(vm.stack().last(), Some(&Value::Int(7))); + assert_eq!(vm.execution_scope().resources().len(), 0); + assert!(vm.execution_scope().is_active()); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); +} + +#[test] +fn result_try_postoperand_nested_call_carrier_keeps_only_returned_err() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + reset_exit_observers(); + let source = r#" + fn pass(value: Result>) -> Result> { value } + fn relay() -> Result> { + let local: resource = test::make_local(); + let carrier = test::make_err_result(); + let value = pass(pass(carrier))?; + test::close_closure(local); + Result::Ok(value) + } + relay(); + "#; + let mut vm = run_result_source(source); + let err_raw = err_payload_raw(vm.stack().last().unwrap()) as u64; + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert_local_closed_and_err_open_before_drop(&mut vm, local_raw, err_raw); + assert!(vm.execution_scope().is_active()); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 2); +} + +#[test] +fn result_try_postoperand_named_err_in_partial_owned_argument_closes_temp() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result> { + let carrier = test::make_err_result(); + let value = test::use_owned(test::make_local(), carrier?, test::later_arg()); + Result::Ok(value) + } + relay(); + "#; + let mut vm = run_result_source(source); + assert_eq!(USE_OWNED_CALLS.load(Ordering::SeqCst), 0); + assert_eq!(LATER_ARG_CALLS.load(Ordering::SeqCst), 0); + let err_raw = err_payload_raw(vm.stack().last().unwrap()) as u64; + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert_local_closed_and_err_open_before_drop(&mut vm, local_raw, err_raw); + assert!(vm.execution_scope().is_active()); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 2); +} + +#[test] +fn result_try_partial_owned_argument_closes_before_vm_drop() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result> { + let value = test::use_owned(test::make_local(), test::make_err_result()?, test::later_arg()); + Result::Ok(value) + } + let carried = relay(); + carried; + "#; + let mut vm = run_result_source(source); + assert_eq!( + USE_OWNED_CALLS.load(Ordering::SeqCst), + 0, + "callee must not run" + ); + assert_eq!( + LATER_ARG_CALLS.load(Ordering::SeqCst), + 0, + "argument after ? must not run" + ); + let err_raw = err_payload_raw(vm.stack().last().expect("returned Result")); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert_local_closed_and_err_open_before_drop(&mut vm, local_raw, err_raw as u64); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 2); +} + +#[test] +fn result_try_partial_owned_array_closes_before_vm_drop() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result> { + let value = test::use_owned_array([test::make_local()], test::make_err_result()?); + Result::Ok(value) + } + let carried = relay(); + carried; + "#; + let mut vm = run_result_source(source); + assert_eq!(USE_OWNED_CALLS.load(Ordering::SeqCst), 0); + let err_raw = err_payload_raw(vm.stack().last().expect("returned Result")); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert_local_closed_and_err_open_before_drop(&mut vm, local_raw, err_raw as u64); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 2); +} + +#[test] +fn result_try_ok_resource_continues_and_closes_once_before_vm_drop() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result { + let handle: resource = test::make_ok_result()?; + test::close_closure(handle); + Result::Ok(1) + } + let flag = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + flag; + "#; + let mut vm = run_result_source(source); + assert_eq!(vm.stack().last().cloned(), Some(Value::Int(1))); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + assert_eq!(vm.execution_scope().resources().len(), 0); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert_ne!(local_raw, 0); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); +} + +#[test] +fn result_try_rejects_reuse_of_moved_ok_resource() { + let source = r#" + fn relay() -> Result { + let handle: resource = test::make_ok_result()?; + test::close_closure(handle); + test::close_closure(handle); + Result::Ok(1) + } + relay(); + "#; + let options = CompileSourceFileOptions::new().with_host_api_catalog(result_resource_catalog()); + let error = + match compile_source_with_flavor_and_options(source, vm::SourceFlavor::RustScript, options) + { + Err(error) => error, + Ok(_) => panic!("moved Ok resource must not be reused"), + }; + let text = error.to_string(); + assert!( + text.contains("moved") || text.contains("use"), + "reuse after move should be rejected, got {error:?}" + ); +} + +#[test] +fn result_try_does_not_close_borrowed_caller_resource() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay(borrowed: resource) -> Result { + let probe = test::touch_borrow(borrowed); + let value: int = test::make_err_int()?; + Result::Ok(probe + value) + } + let owned: resource = test::make_local(); + let carried = relay(owned); + test::close_closure(owned); + carried; + "#; + let mut vm = run_result_source(source); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + assert_eq!(vm.execution_scope().resources().len(), 0); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); +} + +#[test] +fn result_try_keeps_borrowed_param_usable_when_ok_path_uses_it_after_try() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay(borrowed: resource) -> Result { + let value: int = test::make_err_int()?; + let probe = test::touch_borrow(borrowed); + Result::Ok(probe + value) + } + let owned: resource = test::make_local(); + let carried = relay(owned); + let still = test::touch_borrow(owned); + test::close_closure(owned); + still; + carried; + "#; + let mut vm = run_result_source(source); + assert_eq!(err_payload_raw(vm.stack().last().expect("Err result")), 9); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + assert_eq!(vm.execution_scope().resources().len(), 0); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); +} + +#[test] +fn result_try_does_not_close_persistent_capture_used_after_try() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + let owned: resource = test::make_local(); + fn relay() -> Result { + let value: int = test::make_err_int()?; + let probe = test::touch_borrow(owned); + if false => { + test::close_closure(owned); + 0 + } else => { + 0 + }; + Result::Ok(probe + value) + } + let first = relay(); + let second = relay(); + first; + "#; + let mut vm = run_result_source(source); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert_ne!(local_raw, 0); + assert!( + closure_is_open(&mut vm, local_raw), + "persistent capture must stay open across Err returns" + ); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 0); + assert_eq!(err_payload_raw(vm.stack().last().expect("first Err")), 9); + drop(vm); +} + +#[test] +fn result_try_match_owned_payload_is_closed_on_later_err() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result { + let value = test::make_ok_result(); + match value { + Result::Ok(handle) => if true => { + let n: int = test::make_err_int()?; + Result::Ok(n) + } else => { + Result::Ok(0) + }, + Result::Err(code) => Result::Err(code), + } + } + let carried = relay(); + carried; + "#; + let mut vm = run_result_source(source); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert_ne!(local_raw, 0); + assert!( + !closure_is_open(&mut vm, local_raw), + "match payload must close before VM drop" + ); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); +} + +#[test] +fn result_try_closes_consumed_param_still_held_on_err() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay(owned: resource) -> Result { + let value: int = test::make_err_int()?; + test::close_closure(owned); + Result::Ok(value) + } + let owned: resource = test::make_local(); + let carried = relay(owned); + carried; + "#; + let mut vm = run_result_source(source); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert_ne!(local_raw, 0); + assert!( + !closure_is_open(&mut vm, local_raw), + "consumed param must close on Err" + ); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); +} + +#[test] +fn result_try_closes_owned_local_after_borrow_with_no_later_ordinary_use() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result { + let first: resource = test::make_local(); + let probe = test::touch_borrow(first); + let scalar_a = probe + 1; + let scalar_b = scalar_a + 2; + let scalar_c = scalar_b + 3; + let second: resource = test::make_local(); + let value: int = test::make_err_int()?; + Result::Ok(scalar_c + value) + } + let carried = relay(); + carried; + "#; + let mut vm = run_result_source(source); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert_ne!(local_raw, 0); + assert!( + !closure_is_open(&mut vm, local_raw), + "cleanup-only local must close on Err before VM drop" + ); + assert_eq!( + CLOSURE_CLOSES.load(Ordering::SeqCst), + 2, + "both owned resources must close on Err, not only the last allocated slot" + ); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 2); +} + +#[test] +fn result_try_snapshots_bare_read_before_later_ok_mutation() { + let source = r#" + fn relay() -> Result { + let mut x = 1; + fn bump() -> Result { + x = 11; + Result::Ok(0) + } + fn keep(first: int, second: int) -> Result { + Result::Ok(first) + } + keep(x, bump()?) + } + let carried = relay(); + carried; + "#; + let vm = run_result_source(source); + let value = vm.stack().last().expect("returned Result"); + let Value::Map(map) = value else { + panic!("expected Result map, got {value:?}"); + }; + assert_eq!(map.get(&Value::string("tag")), Some(&Value::Bool(true))); + assert_eq!( + map.get(&Value::string("payload")), + Some(&Value::Int(1)), + "bare read of x must be snapshotted before bump()? mutates it, got {value:?}" + ); +} + +#[test] +fn local_call_snapshots_callee_and_closes_resource_before_later_try() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result> { + let callee = |resource, later| if true => { + test::use_owned(resource, 0, later) + } else => { 0 }; + let value = callee(test::make_local(), test::make_err_result()?); + Result::Ok(value) + } + let carried = relay(); + carried; + "#; + let mut vm = run_result_source(source); + assert_eq!(USE_OWNED_CALLS.load(Ordering::SeqCst), 0); + assert_eq!( + err_payload_raw(vm.stack().last().expect("Result")), + LAST_ERR_RAW.load(Ordering::SeqCst) as i64 + ); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert!(!closure_is_open(&mut vm, local_raw)); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); +} + +#[test] +fn local_call_uses_snapshotted_callee_after_reassignment() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result { + let mut callee = |later| if true => { 1 } else => { 0 }; + let value = callee(if true => { + callee = |later| if true => { 100 } else => { 0 }; + 0 + } else => { 0 }); + Result::Ok(value) + } + let carried = relay(); + carried; + "#; + let vm = run_result_source(source); + let value = vm.stack().last().expect("Result"); + let Value::Map(map) = value else { + panic!("expected Result map, got {value:?}"); + }; + assert_eq!( + map.get(&Value::string("payload")), + Some(&Value::Int(1)), + "callee must be snapshotted before the later try reassigns it, got {value:?}" + ); +} + +#[test] +fn borrow_and_borrow_mut_stay_passing_across_later_try() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result { + let mut owned: resource = test::make_local(); + let borrowed = test::touch_borrow(owned); + let mutated = test::touch_borrow_mut(&mut owned, test::make_err_int()?); + Result::Ok(borrowed + mutated) + } + let carried = relay(); + carried; + "#; + let mut vm = run_result_source(source); + assert_eq!(BORROW_BOUNDARY_CALLS.load(Ordering::SeqCst), 0); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert!(!closure_is_open(&mut vm, local_raw)); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); +} + +#[test] +fn result_try_conditional_take_join_closes_only_present_owners_for_both_flags() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + for (flag, backend) in [false, true] + .into_iter() + .flat_map(|flag| (0..3).map(move |backend| (flag, backend))) + { + reset_exit_observers(); + let source = format!( + r#" + fn relay(flag: bool) -> Result> {{ + let owned: resource = test::make_local(); + let sibling: resource = test::make_local(); + if flag {{ let closed = test::close_closure(owned); }} + let n = test::make_err_result()?; + test::close_closure(sibling); + Result::Ok(n) + }} + relay({flag}); + "# + ); + let mut machine = run_result_source_backend(&source, backend); + let err = err_payload_raw(machine.stack().last().unwrap()) as u64; + assert_eq!( + CLOSURE_CLOSES.load(Ordering::SeqCst), + 2, + "both logical owners close exactly once: flag={flag}" + ); + assert_eq!(machine.execution_scope().resources().len(), 1); + assert!(closure_is_open(&mut machine, err)); + machine + .execution_scope() + .close_resource::( + ResourceHandle::from_raw(err).unwrap(), + ResourceCloseReason::Requested, + ) + .unwrap(); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 3); + assert_eq!(machine.execution_scope().resources().len(), 0); + drop(machine); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 3); + } +} + +#[test] +fn result_try_conditional_take_reassignment_restores_present_owner() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + for (flag, backend) in [false, true] + .into_iter() + .flat_map(|flag| (0..3).map(move |backend| (flag, backend))) + { + reset_exit_observers(); + let source = format!( + r#" + fn relay(flag: bool) -> Result {{ + let mut owned: resource = test::make_local(); + if flag {{ let closed = test::close_closure(owned); owned = test::make_local(); }} + let n = test::make_err_int()?; + test::close_closure(owned); + Result::Ok(n) + }} + relay({flag}); + "# + ); + let mut machine = run_result_source_backend(&source, backend); + assert_eq!(err_payload_raw(machine.stack().last().unwrap()), 9); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1 + usize::from(flag)); + assert_eq!(machine.execution_scope().resources().len(), 0); + drop(machine); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1 + usize::from(flag)); + } +} + +#[test] +fn result_try_named_match_payload_body_is_owned_in_both_result_arms() { + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + for (ok, backend) in [false, true] + .into_iter() + .flat_map(|ok| (0..3).map(move |backend| (ok, backend))) + { + reset_exit_observers(); + let (ty, carrier, arm) = if ok { + ( + "Result", + "Result::Ok(holder)", + "Result::Ok(payload)", + ) + } else { + ( + "Result", + "Result::Err(holder)", + "Result::Err(payload)", + ) + }; + let other = if ok { + "Result::Err(_)" + } else { + "Result::Ok(_)" + }; + let source = format!( + r#" + struct Holder {{ value: resource }} + fn relay() -> Result {{ + let holder: Holder = {{ value: test::make_local() }}; + let carried: {ty} = {carrier}; + match carried {{ + {arm} => if true => {{ + let n = test::make_err_int()?; + Result::Ok(n) + }} else => {{ Result::Ok(0) }}, + {other} => Result::Ok(0), + }} + }} + relay(); + "# + ); + let mut machine = run_result_source_backend(&source, backend); + assert_eq!(err_payload_raw(machine.stack().last().unwrap()), 9); + assert_eq!( + CLOSURE_CLOSES.load(Ordering::SeqCst), + 1, + "non-generic Named payload closes before drop: ok={ok}" + ); + assert_eq!(machine.execution_scope().resources().len(), 0); + drop(machine); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + } +} + +#[test] +fn distinct_resource_keys_scalar_and_closure_close_only_owned_resources() { + let _guard = CLOSURE_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + reset_exit_observers(); + let source = r#" + fn relay() -> Result { + let owned: resource = test::make_local(); + let block: resource = test::make_block(); + let probe = test::touch_borrow(owned); + let closer = || if true => { 1 } else => { 0 }; + let scalar = probe + closer(); + let value = test::make_err_int()?; + Result::Ok(scalar + value) + } + let carried = relay(); + carried; + "#; + let mut vm = run_result_source(source); + let local_raw = LAST_LOCAL_RAW.load(Ordering::SeqCst); + assert!(!closure_is_open(&mut vm, local_raw)); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + assert_eq!(BLOCK_CLOSES.load(Ordering::SeqCst), 1); + drop(vm); + assert_eq!(CLOSURE_CLOSES.load(Ordering::SeqCst), 1); + assert_eq!(BLOCK_CLOSES.load(Ordering::SeqCst), 1); +} diff --git a/tests/result_try_tests.rs b/tests/result_try_tests.rs new file mode 100644 index 00000000..7451afbc --- /dev/null +++ b/tests/result_try_tests.rs @@ -0,0 +1,1188 @@ +#![cfg(feature = "runtime")] + +use vm::{Vm, VmStatus, compile_source}; + +fn eval(source: &str) -> vm::Value { + let compiled = compile_source(source).expect("Result try source should compile"); + let mut vm = Vm::new(compiled.program.with_local_count(compiled.locals)); + vm.set_fuel(100_000); + assert_eq!( + vm.run().expect("Result try program should run"), + VmStatus::Halted + ); + vm.stack() + .last() + .cloned() + .expect("Result try program should leave a value") +} + +#[test] +fn err_propagation_returns_original_error_and_skips_later_effect() { + let source = r#" + let mut calls = [0]; + fn fail() -> Result { + Result::Err("bad") + } + fn relay() -> Result { + let x: int = fail()?; + calls[0] = calls[0].copy() + 1; + Result::Ok("unreachable") + } + let text = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + let flag = if text == "bad" && calls[0] == 0 => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(source), vm::Value::Int(1)); +} + +fn reject(source: &str) -> vm::SourceError { + match compile_source(source) { + Err(error) => error, + Ok(_) => panic!("Result try source should be rejected: {source}"), + } +} + +fn detail(error: &vm::SourceError) -> String { + error.to_string() +} + +fn assert_question_span(source: &str, error: &vm::SourceError) { + let vm::SourceError::Compile(vm::CompileError::StrictTypingRequired { span, .. }) = error + else { + panic!("expected a spanned try diagnostic, got {error:?}"); + }; + let span = span.expect("try diagnostic should keep the ? span"); + let lo = span.lo as usize; + let hi = span.hi as usize; + assert_eq!( + hi, + lo + 1, + "try diagnostic should cover only '?', got {span:?}" + ); + assert_eq!(&source[lo..hi], "?", "diagnostic span should point at '?'"); +} + +#[test] +fn ok_propagation_changes_success_type_and_keeps_later_effect() { + let source = r#" + let mut calls = [0]; + fn pass() -> Result { + Result::Ok(7) + } + fn relay() -> Result { + let x: int = pass()?; + calls[0] = calls[0].copy() + x; + Result::Ok("kept") + } + let text = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + let flag = if text == "kept" && calls[0] == 7 => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(source), vm::Value::Int(1)); +} + +#[test] +fn try_diagnostics_reject_root_non_result_wrong_error_and_keep_question_span() { + let root = "let value: Result = Result::Err(\"bad\");\nlet x: int = value?;\nx;"; + let root_error = reject(root); + assert!( + detail(&root_error).contains("enclosing function"), + "{root_error:?}" + ); + assert_question_span(root, &root_error); + + let plain = "fn plain() -> int {\n let value: Result = Result::Err(\"bad\");\n let x: int = value?;\n x\n}\nplain;"; + let plain_error = reject(plain); + assert!( + detail(&plain_error).contains("enclosing function"), + "{plain_error:?}" + ); + assert_question_span(plain, &plain_error); + + let operand = "fn relay() -> Result {\n let x: int = 1?;\n Result::Ok(\"no\")\n}\nrelay;"; + let operand_error = reject(operand); + assert!( + detail(&operand_error).contains("operand must be Result"), + "{operand_error:?}" + ); + assert_question_span(operand, &operand_error); + + let wrong = "fn fail() -> Result { Result::Err(1) }\nfn relay() -> Result {\n let x: int = fail()?;\n Result::Ok(\"no\")\n}\nrelay;"; + let wrong_error = reject(wrong); + assert!( + detail(&wrong_error).contains("error type"), + "{wrong_error:?}" + ); + assert_question_span(wrong, &wrong_error); +} + +#[test] +fn optional_access_and_optional_type_remain_distinct_from_try() { + let source = r#" + struct Age { first: int } + struct User { age: Age } + let user: User = { age: { first: 41 } }; + let age = user?.age; + let first = age?.first; + first; + "#; + assert_eq!(eval(source), vm::Value::Int(41)); + let optional_type = r#" + let maybe: int? = 1; + maybe.unwrap_or(0); + "#; + assert_eq!(eval(optional_type), vm::Value::Int(1)); +} + +#[test] +fn default_dialect_rejects_plain_try_and_keeps_optional_access() { + struct DefaultDialect; + impl vm::ParserDialect for DefaultDialect {} + static DIALECT: DefaultDialect = DefaultDialect; + let options = vm::SharedParserOptions { + source_id: 0, + allow_implicit_externs: false, + allow_implicit_semicolons: false, + enforce_mutable_bindings: true, + import_scan_mode: false, + }; + let rejected = vm::parse_source_with_dialect("1?", &DIALECT, options) + .expect_err("default dialect must reject plain ?"); + assert!( + rejected.message.contains("expected '.' after '?'"), + "{rejected:?}" + ); +} + +#[test] +fn closure_try_exits_only_the_closure() { + let source = r#" + let mut outer = [0]; + fn fail() -> Result { + Result::Err("bad") + } + fn relay() -> Result { + let inner: fn() -> Result = || Result::Ok(fail()?); + let text = match inner() { + Result::Ok(v) => "ok", + Result::Err(e) => e, + }; + outer[0] = outer[0].copy() + 1; + Result::Ok(text) + } + let text = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + let flag = if text == "bad" && outer[0] == 1 => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(source), vm::Value::Int(1)); +} + +#[test] +fn call_arguments_stop_after_err_and_run_in_order_on_ok() { + let err_source = r#" + let mut log = [0]; + fn tick(n: int) -> int { + log[0] = log[0].copy() * 10 + n; + n + } + fn fail() -> Result { + Result::Err("bad") + } + fn f(a: int, mid: int, b: int) -> int { + log[0] = log[0].copy() * 10 + 4; + a + mid + b + } + fn relay() -> Result { + let value: int = f(tick(1), fail()?, tick(3)); + Result::Ok(value) + } + let text = match relay() { + Result::Ok(v) => "ok", + Result::Err(e) => e, + }; + let flag = if text == "bad" && log[0] == 1 => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(err_source), vm::Value::Int(1)); + + let ok_source = r#" + let mut log = [0]; + fn tick(n: int) -> int { + log[0] = log[0].copy() * 10 + n; + n + } + fn pass() -> Result { + Result::Ok(2) + } + fn f(a: int, mid: int, b: int) -> int { + log[0] = log[0].copy() * 10 + 4; + a + mid + b + } + fn relay() -> Result { + let value: int = f(tick(1), pass()?, tick(3)); + Result::Ok(value) + } + let value = match relay() { + Result::Ok(v) => v, + Result::Err(e) => -1, + }; + let flag = if value == 6 && log[0] == 134 => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(ok_source), vm::Value::Int(1)); +} + +#[test] +fn err_stops_unexecuted_siblings_in_arithmetic_if_and_loop() { + let source = r#" + let mut log = [0]; + fn tick(n: int) -> int { + log[0] = log[0].copy() * 10 + n; + n + } + fn fail() -> Result { + Result::Err("bad") + } + fn relay() -> Result { + let sum: int = tick(1) + fail()? + tick(2); + let picked = if false => { tick(3) } else => { sum }; + while false { + tick(4); + } + Result::Ok(picked) + } + let text = match relay() { + Result::Ok(v) => "ok", + Result::Err(e) => e, + }; + let flag = if text == "bad" && log[0] == 1 => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(source), vm::Value::Int(1)); +} + +#[test] +fn generic_try_substitutes_error_type() { + let source = r#" + fn fail(err: E) -> Result { + Result::Err(err) + } + fn relay() -> Result { + let x: int = fail::("bad")?; + Result::Ok("no") + } + let text = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + let flag = if text == "bad" => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(source), vm::Value::Int(1)); +} + +#[test] +fn typed_frontend_ir_records_result_try_schemas() { + let source = r#" + fn fail() -> Result { + Result::Err("bad") + } + fn relay() -> Result { + let x: int = fail()?; + Result::Ok("no") + } + relay; + "#; + let model = vm::analyze_source(source).expect("typed analysis should accept try"); + let mut found = false; + for function in model.ir().function_impls.values() { + for stmt in &function.body_stmts { + if let vm::Stmt::Let { + expr: + vm::Expr::ResultTry { + operand_schema, + return_schema, + .. + }, + .. + } = stmt + { + assert!(matches!( + operand_schema, + vm::TypeSchema::Result { ok, err } + if matches!(ok.as_ref(), vm::TypeSchema::Int) + && matches!(err.as_ref(), vm::TypeSchema::String) + )); + assert!(matches!( + return_schema, + vm::TypeSchema::Result { ok, err } + if matches!(ok.as_ref(), vm::TypeSchema::String) + && matches!(err.as_ref(), vm::TypeSchema::String) + )); + found = true; + } + } + } + assert!(found, "typed FrontendIr should contain ResultTry"); +} + +fn eval_aot(source: &str) -> vm::Value { + let compiled = compile_source(source).expect("AOT Result try source should compile"); + let mut machine = vm::Vm::new_with_jit_config( + compiled.program.with_local_count(compiled.locals), + vm::JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + machine.set_fuel(100_000); + machine + .compile_aot() + .expect("Result try should compile to AOT"); + let dump = machine.dump_aot_info(); + assert!(dump.contains("lowering=ssa"), "{dump}"); + assert_eq!( + machine.run().expect("AOT Result try should run"), + VmStatus::Halted + ); + assert!(machine.aot_exec_count() > 0, "{dump}"); + machine + .stack() + .last() + .cloned() + .expect("AOT Result try should leave a value") +} + +#[test] +fn aot_executes_ok_and_err_try_payloads() { + let err = r#" + fn fail() -> Result { Result::Err("bad") } + fn relay() -> Result { + let x: int = fail()?; + Result::Ok("no") + } + let text = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + let flag = if text == "bad" => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval_aot(err), vm::Value::Int(1)); + let ok = r#" + fn pass() -> Result { Result::Ok(4) } + fn relay() -> Result { + let x: int = pass()?; + Result::Ok("kept") + } + let text = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + let flag = if text == "kept" => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval_aot(ok), vm::Value::Int(1)); +} + +#[test] +fn nested_recursive_and_aliased_calls_exit_only_the_lexical_callee() { + let nested = r#" + fn outer() -> Result { + fn inner() -> Result { + Result::Err("bad") + } + fn relay() -> Result { + let x: int = inner()?; + Result::Ok("no") + } + let text = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + Result::Ok(text) + } + let text = match outer() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + let flag = if text == "bad" => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(nested), vm::Value::Int(1)); + + let recursive = r#" + fn walk(n: int) -> Result { + if n == 0 => { Result::Err("bad") } else => { Result::Ok(walk(n - 1)?) } + } + fn relay() -> Result { + let x: int = walk(2)?; + Result::Ok("no") + } + let mut after = [0]; + let text = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + after[0] = 1; + let flag = if text == "bad" && after[0] == 1 => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(recursive), vm::Value::Int(1)); + + let aliased = r#" + fn fail() -> Result { Result::Err("bad") } + fn relay() -> Result { + let alias: fn() -> Result = fail; + let x: int = alias()?; + Result::Ok("no") + } + let text = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + let flag = if text == "bad" => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(aliased), vm::Value::Int(1)); +} + +#[test] +fn err_stops_match_array_and_short_circuit_siblings() { + let match_source = r#" + let mut log = [0]; + fn tick(n: int) -> int { + log[0] = log[0].copy() * 10 + n; + n + } + fn fail() -> Result { Result::Err("bad") } + fn relay() -> Result { + let picked = match fail()? { + 0 => tick(2), + _ => tick(3), + }; + Result::Ok(picked) + } + let text = match relay() { + Result::Ok(v) => "ok", + Result::Err(e) => e, + }; + let flag = if text == "bad" && log[0] == 0 => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(match_source), vm::Value::Int(1)); + let array_source = r#" + let mut log = [0]; + fn tick(n: int) -> int { + log[0] = log[0].copy() * 10 + n; + n + } + fn fail() -> Result { Result::Err("bad") } + fn relay() -> Result { + let values = [tick(1), fail()?, tick(2)]; + Result::Ok(values[0]) + } + let text = match relay() { + Result::Ok(v) => "ok", + Result::Err(e) => e, + }; + let flag = if text == "bad" && log[0] == 1 => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(array_source), vm::Value::Int(1)); + let and_source = r#" + let mut log = [0]; + fn tick(n: int) -> int { + log[0] = log[0].copy() * 10 + n; + n + } + fn fail() -> Result { Result::Err("bad") } + fn relay() -> Result { + let flag = if tick(1) == 1 && fail()? == 1 && tick(2) == 2 => { 1 } else => { 0 }; + Result::Ok(flag) + } + let text = match relay() { + Result::Ok(v) => "ok", + Result::Err(e) => e, + }; + let flag = if text == "bad" && log[0] == 1 => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(and_source), vm::Value::Int(1)); +} + +#[test] +fn try_inside_executed_if_arm_and_loop_exits_the_function() { + let if_source = r#" + let mut log = [0]; + fn tick(n: int) -> int { + log[0] = log[0].copy() * 10 + n; + n + } + fn fail() -> Result { Result::Err("bad") } + fn relay() -> Result { + if true { + let x: int = fail()?; + tick(x); + } else { + tick(9); + } + tick(8); + Result::Ok("no") + } + let text = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + let flag = if text == "bad" && log[0] == 0 => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(if_source), vm::Value::Int(1)); + let loop_source = r#" + let mut log = [0]; + fn tick(n: int) -> int { + log[0] = log[0].copy() * 10 + n; + n + } + fn fail() -> Result { Result::Err("bad") } + fn relay() -> Result { + let mut i = 0; + while i < 3 { + i = i + 1; + let x: int = fail()?; + tick(x); + } + tick(8); + Result::Ok("no") + } + let text = match relay() { + Result::Ok(v) => v, + Result::Err(e) => e, + }; + let flag = if text == "bad" && log[0] == 0 => { 1 } else => { 0 }; + flag; + "#; + assert_eq!(eval(loop_source), vm::Value::Int(1)); +} + +#[test] +fn optional_success_payload_is_not_a_definite_scalar() { + let arithmetic = r#" + fn maybe() -> Result { Result::Ok(null) } + fn relay() -> Result { + Result::Ok(maybe()? + 1) + } + relay(); + "#; + let error = reject(arithmetic); + let text = detail(&error); + assert!( + text.contains("optional") || text.contains("null"), + "selected optional payload must not be used as a definite int, got {error:?}" + ); +} + +#[test] +fn optional_success_payload_unwrap_keeps_null_and_nonnull() { + let nonnull = r#" + fn maybe() -> Result { Result::Ok(4) } + fn relay() -> Result { + let inner: int? = maybe()?; + Result::Ok(inner.unwrap_or(0) + 1) + } + let value = match relay() { + Result::Ok(v) => v, + Result::Err(_) => 0, + }; + value; + "#; + assert_eq!(eval(nonnull), vm::Value::Int(5)); + let null_ok = r#" + fn maybe() -> Result { Result::Ok(null) } + fn relay() -> Result { + let inner: int? = maybe()?; + Result::Ok(inner.unwrap_or(9)) + } + let value = match relay() { + Result::Ok(v) => v, + Result::Err(_) => 0, + }; + value; + "#; + assert_eq!(eval(null_ok), vm::Value::Int(9)); +} + +#[test] +fn optional_result_operand_is_rejected_at_try_boundary() { + let source = r#" + fn relay(value: Result?) -> Result { + Result::Ok(value?) + } + relay; + "#; + let error = reject(source); + assert!( + detail(&error).contains("operand must be Result"), + "{error:?}" + ); + assert_question_span(source, &error); +} + +#[test] +fn optional_try_payload_cannot_flow_to_required_let_or_argument() { + let head = + "fn maybe() -> Result { Result::Ok(null) }\nfn take(n: int) -> int { n }\n"; + for body in [ + "let required: int = maybe()?; Result::Ok(required)", + "Result::Ok(take(maybe()?))", + "let selected = maybe()?; let required: int = selected; Result::Ok(required)", + "let selected = maybe()?; Result::Ok(take(selected))", + "let selected = maybe()?; let callback: fn() -> int = || selected; Result::Ok(callback())", + "let selected = maybe()?; fn captured() -> int { take(selected) } Result::Ok(captured())", + ] { + let source = format!("{head} fn relay() -> Result {{ {body} }} relay();"); + let error = reject(&source); + let text = detail(&error); + assert!( + text.contains("optional") + || text.contains("null") + || text.contains("annotation") + || text.contains("schema"), + "{body}: {error:?}" + ); + } +} + +#[test] +fn optional_try_refinement_keeps_both_payload_states() { + for (payload, expected) in [("null", 9), ("4", 5)] { + let source = format!( + r#" + fn maybe() -> Result {{ Result::Ok({payload}) }} + fn relay() -> Result {{ + let selected = maybe()?; + let n = if selected != null => {{ selected + 1 }} else => {{ 9 }}; + Result::Ok(n) + }} + let n = match relay() {{ Result::Ok(v) => v, Result::Err(_) => 0, }}; + n; + "# + ); + assert_eq!(eval(&source), vm::Value::Int(expected)); + } +} + +#[test] +fn trace_jit_try_in_hot_region_preserves_payload_and_order() { + let mut region_failures = Vec::new(); + for fail_at in [1000, 80] { + let source = format!( + r#" + fn relay() -> Result {{ + let mut i = 0; + let mut total = 0; + let mut carrier: Result = Result::Ok(1); + while i < 100 {{ + let value = carrier?; + total = total + value; + i = i + 1; + if i == {fail_at} {{ carrier = Result::Err(total); }} + }} + Result::Ok(total) + }} + relay(); + "# + ); + let compiled = compile_source(&source).expect("Trace Try source admission"); + let mut machine = Vm::new_with_jit_config( + compiled.program.with_local_count(compiled.locals), + vm::JitConfig { + enabled: true, + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + machine.set_fuel(1_000_000); + assert_eq!( + machine.run().expect("Trace Try execution"), + VmStatus::Halted + ); + let expected = if fail_at == 80 { 80 } else { 100 }; + let vm::Value::Map(result) = machine.stack().last().expect("Result carrier") else { + panic!("missing carrier") + }; + assert_eq!( + result.get(&vm::Value::string("tag")), + Some(&vm::Value::Bool(fail_at != 80)) + ); + assert_eq!( + result.get(&vm::Value::string("payload")), + Some(&vm::Value::Int(expected)) + ); + let snapshot = machine.jit_snapshot(); + let dump = machine.dump_jit_info(); + println!( + "fail_at={fail_at} native={}\n{dump}", + machine.jit_native_exec_count() + ); + assert!( + machine.jit_native_exec_count() > 0, + "Try loop did not execute natively: {dump}" + ); + // A BranchExit region is sufficient: the Try tag/payload reads and + // guard execute natively; destructive payload extraction and Err Ret + // can resume in the interpreter. Do not claim a direct native Ret. + if !snapshot.traces.iter().any(|trace| { + trace.executions > 0 + && trace.op_names().iter().any(|op| op == "map_get") + && trace.ssa_text().contains("String(\"tag\")") + && trace.ssa_text().contains("String(\"payload\")") + && trace.ssa_text().contains("branch_bool") + }) { + region_failures.push(format!( + "fail_at={fail_at}: executing Try tag/payload/guard region missing; attempts={:?}", + snapshot.attempts + )); + } + } + assert!(region_failures.is_empty(), "{}", region_failures.join("\n")); +} + +#[test] +fn optional_entry_parameter_stays_optional_across_try_selection() { + let negative = r#" + fn relay(value: int?) -> Result { + let carrier: Result = Result::Ok(value); + let required: int = carrier?; + Result::Ok(required) + } + relay(null); + "#; + let error = reject(negative); + assert!( + detail(&error).contains("optional") || detail(&error).contains("annotation"), + "{error:?}" + ); + for (payload, expected) in [("null", 9), ("4", 5)] { + let source = format!( + r#" + fn relay(value: int?) -> Result {{ + let carrier: Result = Result::Ok(value); + let selected = carrier?; + let n = if selected != null => {{ selected + 1 }} else => {{ 9 }}; + Result::Ok(n) + }} + let n = match relay({payload}) {{ Result::Ok(v) => v, Result::Err(_) => 0, }}; + n; + "# + ); + assert_eq!(eval(&source), vm::Value::Int(expected)); + } +} + +struct TryModuleFixture(std::path::PathBuf); +impl TryModuleFixture { + fn new() -> Self { + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let id = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let root = std::env::temp_dir().join(format!("r2-sol-import-{}-{id}", std::process::id())); + std::fs::create_dir(&root).expect("portable module fixture"); + Self(root) + } + fn write(&self, name: &str, source: &str) -> std::path::PathBuf { + let path = self.0.join(name); + std::fs::write(&path, source).expect("module source"); + path + } +} +impl Drop for TryModuleFixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn actual_import_try_remaps_generic_named_error_and_rejects_distinct_identity() { + let fixture = TryModuleFixture::new(); + let module = r#" + struct Failure { value: T } + struct Other { value: T } + fn fail(error: E) -> Result { Result::Err(error) } + pub fn relay() -> Result> { + let padding = 31; + let error: Failure = { value: "import-error" }; + let n = fail::>(error)?; + Result::Ok(n + padding) + } + "#; + fixture.write("relay.rss", module); + let entry = fixture.write( + "main.rss", + r#" + use self::relay as imported; + let padding = [4, 5, 6]; + fn before(n: int) -> int { n + 1 } + let keep = before(padding[0]); + let text = match imported::relay() { Result::Ok(_) => "wrong", Result::Err(e) => e.value, }; + let correct = if text == "import-error" && keep == 5 => { 1 } else => { 0 }; + correct; + "#, + ); + let compiled = vm::compile_source_file(&entry).expect("entry imports function containing Try"); + assert!(compiled.locals > 0); + assert!( + compiled + .program + .named_struct_decls() + .keys() + .any(|name| name.starts_with("__guest_type_")) + ); + let mut machine = Vm::new(compiled.program.with_local_count(compiled.locals)); + machine.set_fuel(100_000); + assert_eq!( + machine.run().expect("imported Try runtime"), + VmStatus::Halted + ); + assert_eq!(machine.stack().last(), Some(&vm::Value::Int(1))); + fixture.write( + "relay.rss", + &module.replace( + "pub fn relay() -> Result>", + "pub fn relay() -> Result>", + ), + ); + let error = vm::compile_source_file(&entry) + .err() + .expect("same-shape distinct Named E must reject"); + assert!(error.to_string().contains("error type"), "{error:?}"); +} + +#[test] +fn actual_import_try_selects_int_and_string_host_signatures_and_payload_order() { + use std::sync::{Arc, Mutex}; + struct Pick(Arc>>); + impl vm::HostFunction for Pick { + fn call(&mut self, _: &mut Vm, args: &[vm::Value]) -> vm::VmResult { + self.0.lock().unwrap().push(args[0].clone()); + Ok(vm::CallOutcome::Return(vm::CallReturn::one( + vm::Value::Int(1), + ))) + } + } + let mut builder = vm::HostApiBuilder::new(); + for schema in [vm::HostTypeSchema::Int, vm::HostTypeSchema::String] { + builder.function(vm::HostFunctionSchema::with_return( + "test::pick", + vec![vm::HostParamSchema::value("v", schema)], + vm::HostTypeSchema::Int, + )); + } + let catalog = Arc::new(builder.build().unwrap()); + let options = vm::CompileSourceFileOptions::new().with_host_api_catalog(catalog); + let fixture = TryModuleFixture::new(); + for (ty, first_payload, second_payload, expected, host_ty) in [ + ( + "int", + "7", + "8", + vec![vm::Value::Int(7), vm::Value::Int(8)], + vm::HostTypeSchema::Int, + ), + ( + "string", + "\"seven\"", + "\"eight\"", + vec![vm::Value::string("seven"), vm::Value::string("eight")], + vm::HostTypeSchema::String, + ), + ] { + fixture.write( + "relay.rss", + &format!( + r#" + fn pass(value: {ty}) -> Result<{ty}, string> {{ Result::Ok(value) }} + pub fn relay() -> Result {{ + let first = test::pick(pass({first_payload})?); + let second = test::pick(pass({second_payload})?); + Result::Ok(first + second) + }} + "# + ), + ); + let entry = fixture.write("main.rss", "use self::relay as imported; let pad = [1, 2]; let carried = imported::relay(); carried;"); + let compiled = vm::compile_source_file_with_options(&entry, options.clone()) + .expect("imported overloaded Try admission"); + let imports: Vec<_> = compiled + .program + .host_import_schemas() + .iter() + .flatten() + .filter(|s| s.name == "test::pick") + .collect(); + assert!(!imports.is_empty()); + assert!( + imports.iter().all(|s| s.params[0].schema == host_ty), + "{imports:?}" + ); + let calls = Arc::new(Mutex::new(Vec::new())); + let mut machine = Vm::new(compiled.program.with_local_count(compiled.locals)); + machine.set_fuel(100_000); + machine.bind_function("test::pick", Box::new(Pick(calls.clone()))); + assert_eq!( + machine.run().expect("imported selected overload runtime"), + VmStatus::Halted + ); + assert_eq!(*calls.lock().unwrap(), expected, "host payload order: {ty}"); + let vm::Value::Map(result) = machine.stack().last().unwrap() else { + panic!("missing Result") + }; + assert_eq!( + result.get(&vm::Value::string("payload")), + Some(&vm::Value::Int(2)) + ); + } + fixture.write("relay.rss", "fn pass() -> Result { Result::Ok(true) } pub fn relay() -> Result { Result::Ok(test::pick(pass()?)) }"); + let error = vm::compile_source_file_with_options(fixture.0.join("main.rss"), options) + .err() + .expect("bool has no overload"); + assert!(error.to_string().contains("pick"), "{error:?}"); +} + +#[test] +fn fatal_host_operand_vmerror_does_not_become_guest_err() { + struct Fatal; + impl vm::HostFunction for Fatal { + fn call(&mut self, _: &mut Vm, _: &[vm::Value]) -> vm::VmResult { + Err(vm::VmError::HostError("sol-fatal-operand".into())) + } + } + let mut builder = vm::HostApiBuilder::new(); + builder.function(vm::HostFunctionSchema::with_return( + "test::fatal", + Vec::new(), + vm::HostTypeSchema::Result { + ok: Box::new(vm::HostTypeSchema::Int), + err: Box::new(vm::HostTypeSchema::String), + }, + )); + let compiled = vm::compile_source_with_flavor_and_options( + r#" + fn relay() -> Result { let n = test::fatal()?; Result::Ok(n) } + let caught = match relay() { Result::Ok(_) => 1, Result::Err(_) => 2, }; caught; + "#, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::new() + .with_host_api_catalog(std::sync::Arc::new(builder.build().unwrap())), + ) + .expect("fatal operand source admission"); + let mut machine = Vm::new(compiled.program.with_local_count(compiled.locals)); + machine.set_fuel(100_000); + machine.bind_function("test::fatal", Box::new(Fatal)); + let error = machine + .run() + .expect_err("host VmError must escape the guest Result protocol"); + assert!( + matches!(error, vm::VmError::HostError(ref s) if s == "sol-fatal-operand"), + "{error:?}" + ); + assert!( + !machine.stack().contains(&vm::Value::Int(2)), + "guest Err arm must not execute" + ); +} + +#[test] +fn fuel_yields_inside_try_operand_then_resumes_to_real_guest_err() { + let compiled = compile_source( + r#" + fn work() -> Result { + let mut i = 0; while i < 100 { i = i + 1; } + Result::Err("finished") + } + fn relay() -> Result { let n = work()?; Result::Ok(n) } + let caught = match relay() { Result::Ok(_) => "wrong", Result::Err(e) => e, }; caught; + "#, + ) + .expect("fuel source admission"); + let mut machine = Vm::new_with_jit_config( + compiled.program.with_local_count(compiled.locals), + vm::JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + machine.set_fuel(1); + assert_eq!( + machine.run().expect("fuel suspension is not guest Err"), + VmStatus::Yielded + ); + assert_eq!(machine.last_yield_reason(), Some(vm::VmYieldReason::Fuel)); + let mut halted = false; + let mut yielded_inside_operand = false; + for _ in 0..1024 { + machine.recharge_fuel(32).unwrap(); + match machine.run().expect("resumed operand") { + VmStatus::Halted => { + halted = true; + break; + } + VmStatus::Yielded => { + assert_eq!(machine.last_yield_reason(), Some(vm::VmYieldReason::Fuel)); + yielded_inside_operand |= machine.call_depth() >= 2; + } + status => panic!("unexpected status {status:?}"), + } + } + assert!(halted, "bounded fuel driver must finish"); + assert!( + yielded_inside_operand, + "fuel must yield inside work() called by relay's Try operand" + ); + assert_eq!(machine.stack().last(), Some(&vm::Value::string("finished"))); +} + +#[test] +fn call_depth_failure_inside_try_operand_is_not_guest_err() { + let compiled = compile_source( + r#" + fn recurse(n: int) -> Result { Result::Ok(recurse(n + 1)? + 1) } + fn relay() -> Result { Result::Ok(recurse(0)?) } + let caught = match relay() { Result::Ok(_) => 1, Result::Err(_) => 2, }; caught; + "#, + ) + .expect("call depth source admission"); + let mut machine = Vm::new_with_jit_config( + compiled.program.with_local_count(compiled.locals), + vm::JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + machine.set_fuel(100_000); + machine.set_max_script_call_depth(4).unwrap(); + let error = machine.run().expect_err("call depth must be fatal"); + assert!( + matches!(error, vm::VmError::CallStackOverflow { limit: 4 }), + "{error:?}" + ); + assert!(!machine.stack().contains(&vm::Value::Int(2))); +} + +#[test] +fn host_overload_sees_selected_try_success_type() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + static PICK_INT: AtomicUsize = AtomicUsize::new(0); + static PICK_STRING: AtomicUsize = AtomicUsize::new(0); + struct Pick; + impl vm::HostFunction for Pick { + fn call(&mut self, _vm: &mut vm::Vm, args: &[vm::Value]) -> vm::VmResult { + match &args[0] { + vm::Value::Int(value) => { + PICK_INT.fetch_add(1, Ordering::SeqCst); + Ok(vm::CallOutcome::Return(vm::CallReturn::one( + vm::Value::Int(value + 1), + ))) + } + vm::Value::String(_) => { + PICK_STRING.fetch_add(1, Ordering::SeqCst); + Ok(vm::CallOutcome::Return(vm::CallReturn::one( + vm::Value::Int(2), + ))) + } + other => panic!("pick saw {other:?}"), + } + } + } + let mut builder = vm::HostApiBuilder::new(); + builder.function(vm::HostFunctionSchema::with_return( + "test::pick", + vec![vm::HostParamSchema::value("value", vm::HostTypeSchema::Int)], + vm::HostTypeSchema::Int, + )); + builder.function(vm::HostFunctionSchema::with_return( + "test::pick", + vec![vm::HostParamSchema::value( + "value", + vm::HostTypeSchema::String, + )], + vm::HostTypeSchema::Int, + )); + let catalog = Arc::new(builder.build().expect("pick catalog")); + let options = vm::CompileSourceFileOptions::new().with_host_api_catalog(Arc::clone(&catalog)); + let int_source = r#" + fn pass() -> Result { Result::Ok(7) } + fn relay() -> Result { + Result::Ok(test::pick(pass()?)) + } + let value = match relay() { + Result::Ok(v) => v, + Result::Err(_) => 0, + }; + value; + "#; + PICK_INT.store(0, Ordering::SeqCst); + let compiled = vm::compile_source_with_flavor_and_options( + int_source, + vm::SourceFlavor::RustScript, + options.clone(), + ) + .unwrap_or_else(|error| panic!("int overload through ? should resolve, got {error:?}")); + let selected = compiled + .program + .host_import_schemas() + .iter() + .flatten() + .find(|schema| schema.name == "test::pick") + .expect("selected pick import"); + assert!( + matches!( + selected.params.first().map(|param| ¶m.schema), + Some(vm::HostTypeSchema::Int) + ), + "int success type must select the int overload, got {selected:?}" + ); + let mut machine = vm::Vm::new(compiled.program.with_local_count(compiled.locals)); + machine.set_fuel(100_000); + machine.bind_function("test::pick", Box::new(Pick)); + assert_eq!( + machine.run().expect("int pick should run"), + vm::VmStatus::Halted + ); + assert_eq!(machine.stack().last().cloned(), Some(vm::Value::Int(8))); + assert_eq!(PICK_INT.load(Ordering::SeqCst), 1); + assert_eq!(PICK_STRING.load(Ordering::SeqCst), 0); + + let mismatch = r#" + fn pass() -> Result { Result::Ok(true) } + fn relay() -> Result { + Result::Ok(test::pick(pass()?)) + } + relay(); + "#; + let rejected = match vm::compile_source_with_flavor_and_options( + mismatch, + vm::SourceFlavor::RustScript, + options, + ) { + Err(error) => error, + Ok(_) => panic!("bool is not a pick overload"), + }; + let text = rejected.to_string(); + assert!( + text.contains("pick") || text.contains("overload") || text.contains("argument"), + "{rejected:?}" + ); +} diff --git a/tests/typed_host_no_dynamic_contract_tests.rs b/tests/typed_host_no_dynamic_contract_tests.rs index f5928b27..122e4614 100644 --- a/tests/typed_host_no_dynamic_contract_tests.rs +++ b/tests/typed_host_no_dynamic_contract_tests.rs @@ -43,6 +43,10 @@ fn visit(path: &str, schema: &HostTypeSchema, seen: &mut BTreeSet, polic } HostTypeSchema::Array(inner) => visit(&format!("{path}[]"), inner, seen, policy), HostTypeSchema::Optional(inner) => visit(&format!("{path}?"), inner, seen, policy), + HostTypeSchema::Result { ok, err } => { + visit(&format!("{path}.ok"), ok, seen, policy); + visit(&format!("{path}.err"), err, seen, policy); + } HostTypeSchema::Named { name, fields } => { if !seen.insert(name.clone()) { return; diff --git a/tests/wire/wire_tests.rs b/tests/wire/wire_tests.rs index 72f6fe5c..60a0ea38 100644 --- a/tests/wire/wire_tests.rs +++ b/tests/wire/wire_tests.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; +use vm::compiler::TypeSchema; use vm::{ ArgInfo, Assembler, BuiltinFunction, BytecodeBuilder, CallableKind, CallablePrototype, CallableTarget, DebugFunction, DebugInfo, DisassembleOptions, HostApiBuilder, @@ -9,7 +10,6 @@ use vm::{ compile_source, decode_program, disassemble_vmbc, disassemble_vmbc_with_options, encode_program, infer_local_count, validate_program, }; -use vm::compiler::TypeSchema; #[test] fn wire_roundtrip_preserves_constants_and_code() { @@ -91,7 +91,10 @@ fn wire_v11_legacy_imports_decode_without_schema_metadata() { legacy[4..6].copy_from_slice(&11u16.to_le_bytes()); assert!( - matches!(decode_program(&legacy), Err(WireError::UnsupportedVersion(11))), + matches!( + decode_program(&legacy), + Err(WireError::UnsupportedVersion(11)) + ), "v11 payloads are not a supported reader" ); } @@ -111,15 +114,15 @@ fn wire_v11_zero_import_program_decodes_by_version() { fn strip_empty_named_struct_section(encoded: &mut Vec) { assert!( - encoded.len() >= 4, - "encoded VMBC is too short to contain a named-struct section" + encoded.len() >= 8, + "encoded VMBC is too short to contain named-struct and ret-cleanup sections" ); assert_eq!( - &encoded[encoded.len() - 4..], - &[0, 0, 0, 0], - "expected an empty named-struct count trailer on current encode" + &encoded[encoded.len() - 8..], + &[0, 0, 0, 0, 0, 0, 0, 0], + "expected empty named-struct and ret-cleanup count trailers on current encode" ); - encoded.truncate(encoded.len() - 4); + encoded.truncate(encoded.len() - 8); } fn minimal_vmbc_prefix(constant_count: u32, code: &[u8], import_count: u32) -> Vec { @@ -1114,7 +1117,10 @@ fn v12_trailing_zero_count_is_not_a_named_struct_table() { let mut garbage = encoded; garbage.extend_from_slice(&0u32.to_le_bytes()); assert!( - matches!(decode_program(&garbage), Err(WireError::UnsupportedVersion(12))), + matches!( + decode_program(&garbage), + Err(WireError::UnsupportedVersion(12)) + ), "v12 is rejected before trailing bytes are interpreted" ); } From f37008acbcfc3521711e785dc4b62e2a73295276 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 18 Oct 2026 04:52:25 +0800 Subject: [PATCH 3/5] feat(result): add typed host bindings and owned callback SDK --- build.rs | 191 ++++- crates/pd-host-schema/src/lib.rs | 173 +++- crates/pd-host-schema/tests/guest_result.rs | 65 ++ pd-host-function/src/guest_result.rs | 134 +++ pd-host-function/src/lib.rs | 140 +++- .../tests/ui/fail/guest_result_arity.rs | 10 + .../tests/ui/fail/guest_result_arity.stderr | 5 + .../ui/fail/guest_result_borrowed_err.rs | 10 + .../ui/fail/guest_result_borrowed_err.stderr | 5 + .../tests/ui/fail/guest_result_borrowed_ok.rs | 10 + .../ui/fail/guest_result_borrowed_ok.stderr | 5 + .../tests/ui/fail/guest_result_dynamic.rs | 10 + .../tests/ui/fail/guest_result_dynamic.stderr | 5 + .../fail/guest_result_owned_borrow_decode.rs | 5 + .../guest_result_owned_borrow_decode.stderr | 19 + .../tests/ui/pass/valid_guest_result.rs | 29 + src/builtins/runtime/mod.rs | 4 +- src/builtins/runtime/typed.rs | 122 ++- src/compiler/host_call_resolve.rs | 71 ++ src/lib.rs | 8 +- src/result.rs | 148 ++++ src/vm/host.rs | 86 +- src/vm/host_extension.rs | 136 ++- src/vm/instance.rs | 5 + src/vm/map_iter.rs | 10 + src/vm/mod.rs | 28 +- src/vm/store.rs | 477 ++++++++++- src/vm/store/publication.rs | 449 ++++++++++ src/vm/store/publication/quarantine_tests.rs | 306 +++++++ src/vm/store/publication/tests.rs | 249 ++++++ .../fixtures/result_sdk_resource_signature.rs | 42 + tests/fixtures/result_sdk_signature.rs | 33 + tests/result_host_tests.rs | 368 ++++++++ tests/result_sdk_callback_tests.rs | 158 ++++ tests/result_sdk_resource_tests.rs | 784 ++++++++++++++++++ tests/result_sdk_type_tests.rs | 9 + tests/support/result_sdk_abort.rs | 360 ++++++++ tests/support/result_sdk_success.rs | 262 ++++++ 38 files changed, 4881 insertions(+), 50 deletions(-) create mode 100644 crates/pd-host-schema/tests/guest_result.rs create mode 100644 pd-host-function/src/guest_result.rs create mode 100644 pd-host-function/tests/ui/fail/guest_result_arity.rs create mode 100644 pd-host-function/tests/ui/fail/guest_result_arity.stderr create mode 100644 pd-host-function/tests/ui/fail/guest_result_borrowed_err.rs create mode 100644 pd-host-function/tests/ui/fail/guest_result_borrowed_err.stderr create mode 100644 pd-host-function/tests/ui/fail/guest_result_borrowed_ok.rs create mode 100644 pd-host-function/tests/ui/fail/guest_result_borrowed_ok.stderr create mode 100644 pd-host-function/tests/ui/fail/guest_result_dynamic.rs create mode 100644 pd-host-function/tests/ui/fail/guest_result_dynamic.stderr create mode 100644 pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.rs create mode 100644 pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.stderr create mode 100644 pd-host-function/tests/ui/pass/valid_guest_result.rs create mode 100644 src/result.rs create mode 100644 src/vm/store/publication.rs create mode 100644 src/vm/store/publication/quarantine_tests.rs create mode 100644 src/vm/store/publication/tests.rs create mode 100644 tests/fixtures/result_sdk_resource_signature.rs create mode 100644 tests/fixtures/result_sdk_signature.rs create mode 100644 tests/result_host_tests.rs create mode 100644 tests/result_sdk_callback_tests.rs create mode 100644 tests/result_sdk_resource_tests.rs create mode 100644 tests/result_sdk_type_tests.rs create mode 100644 tests/support/result_sdk_abort.rs create mode 100644 tests/support/result_sdk_success.rs diff --git a/build.rs b/build.rs index b67036f4..97bd4401 100644 --- a/build.rs +++ b/build.rs @@ -172,6 +172,7 @@ fn main() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("missing manifest dir")); let out_dir = PathBuf::from(env::var("OUT_DIR").expect("missing OUT_DIR")); + emit_guest_result_schema_fixture(&manifest_dir, &out_dir); let namespace_manifest = manifest_dir .join("src") @@ -338,7 +339,7 @@ fn parse_sources( } pub(crate) fn classify_host_binding(function: &ItemFn) -> HostBindingKind { - if function.sig.asyncness.is_some() { + if function.sig.asyncness.is_some() || guest_result_uses_vm(function) { return HostBindingKind::StaticStack; } if function.sig.inputs.iter().any(|input| match input { @@ -380,6 +381,21 @@ pub(crate) fn classify_host_binding(function: &ItemFn) -> HostBindingKind { HostBindingKind::StaticArgs } +fn guest_result_uses_vm(function: &ItemFn) -> bool { + let input = function.sig.inputs.iter().any(|arg| match arg { + FnArg::Typed(arg) => pd_host_schema::guest_result_shape(&arg.ty) + .expect("valid guest union") + .is_some_and(|shape| !shape.resources().is_empty()), + _ => false, + }); + let output = normalized_return_type(&function.sig.output); + let output = sole_type_argument(&output, "VmResult").unwrap_or(output); + input + || pd_host_schema::guest_result_shape(&output) + .expect("valid guest union") + .is_some_and(|shape| !shape.resources().is_empty()) +} + pub(crate) fn infer_host_execution(function: &ItemFn) -> HostExecutionKind { if function.sig.asyncness.is_some() { return HostExecutionKind::MaySuspend; @@ -2007,7 +2023,7 @@ fn host_wrapper_adapter_name(callable: &CallableDecl) -> String { } fn generated_wrapper_decl(function: &ItemFn) -> WrapperDecl { - let mut needs_vm = function.sig.asyncness.is_some(); + let mut needs_vm = function.sig.asyncness.is_some() || guest_result_uses_vm(function); for input in &function.sig.inputs { let FnArg::Typed(pat_type) = input else { panic!("methods are not supported in #[pd_host_function] declarations"); @@ -2139,6 +2155,177 @@ fn callable_docs(name: &str, attrs: &[Attribute]) -> String { docs } +/// Emits the typed host-schema fixture with the real build-script parser and +/// emitter. The integration test compiles this output and the proc-macro +/// descriptor from the same declaration, then compares their full structures. +fn emit_guest_result_schema_fixture(manifest: &Path, out: &Path) { + for (input, output) in [ + ("result_sdk_signature.rs", "result_sdk_build_schemas.rs"), + ( + "result_sdk_resource_signature.rs", + "result_sdk_resource_build_schemas.rs", + ), + ] { + let fixture = manifest.join("tests/fixtures").join(input); + println!("cargo:rerun-if-changed={}", fixture.display()); + let source = fs::read_to_string(&fixture).expect("typed guest schema fixture"); + let parsed = syn::parse_file(&source).expect("typed guest schema fixture syntax"); + let mut generated = String::new(); + for item in parsed.items { + if let Item::Fn(function) = item { + generated.push_str(&render_guest_descriptor_model(&function)); + } + } + fs::write(out.join(output), generated).expect("write typed guest schema fixture"); + } +} + +fn rust_schema_type(ty: &Type) -> String { + match ty { + Type::Path(path) => { + path.path + .segments + .iter() + .map(|segment| { + let mut name = segment.ident.to_string(); + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + let args = args.args.iter().map(|arg| match arg { + syn::GenericArgument::Type(ty) => rust_schema_type(ty), + _ => panic!("only concrete type arguments are supported in named schema paths"), + }).collect::>(); + name.push('<'); + name.push_str(&args.join(", ")); + name.push('>'); + } + name + }) + .collect::>() + .join("::") + } + Type::Tuple(tuple) if tuple.elems.is_empty() => "()".into(), + _ => panic!("unsupported named/resource schema path"), + } +} + +fn guest_schema_expr(shape: &pd_host_schema::GuestTypeShape<'_>) -> String { + use pd_host_schema::GuestTypeShape as S; + match shape { + S::Result(ok, err) => format!( + "vm::HostTypeSchema::Result {{ ok: Box::new({}), err: Box::new({}) }}", + guest_schema_expr(ok), + guest_schema_expr(err) + ), + S::Optional(inner) => format!( + "vm::HostTypeSchema::Optional(Box::new({}))", + guest_schema_expr(inner) + ), + S::Array(inner) => format!( + "vm::HostTypeSchema::Array(Box::new({}))", + guest_schema_expr(inner) + ), + S::Resource(spec) => format!( + "vm::HostTypeSchema::Resource({})", + guest_resource_key_expr(spec) + ), + S::Named(ty) => format!( + "<{} as vm::HostNamedStruct>::host_type_schema()", + rust_schema_type(ty) + ), + S::Scalar(label) => format!( + "vm::HostTypeSchema::{}", + match *label { + "int" => "Int", + "float" => "Float", + "bool" => "Bool", + "string" => "String", + "bytes" => "Bytes", + "null" => "Null", + _ => unreachable!("shared typed scalar"), + } + ), + } +} + +fn guest_resource_key_expr(spec: &pd_host_schema::ResourceSpec) -> String { + let key = match &spec.key { + Some(key) => format!("{key:?}"), + None => format!( + "<{} as vm::HostResourceType>::KEY", + rust_schema_type(&spec.inner) + ), + }; + format!("vm::ResourceTypeKey::new({key}).expect(\"validated resource type key\")") +} + +pub(crate) fn render_guest_descriptor_model(function: &ItemFn) -> String { + use pd_host_schema::ResourceMode; + let mut params = Vec::new(); + let mut effects = Vec::new(); + for arg in &function.sig.inputs { + let FnArg::Typed(arg) = arg else { + panic!("host method"); + }; + if is_vm_context_type(&arg.ty) || pd_host_schema::state_spec(&arg.ty).is_some() { + continue; + } + let Pat::Ident(name) = arg.pat.as_ref() else { + panic!("host parameter pattern"); + }; + let shape = pd_host_schema::guest_type_shape(&arg.ty).expect("fully typed host parameter"); + let mode = shape.resource_mode().expect("unambiguous guest passing"); + let passing = match mode { + Some(ResourceMode::Borrow) => "Borrow", + Some(ResourceMode::BorrowMut) => "BorrowMut", + Some(ResourceMode::TakeOwned) => "TakeOwned", + _ => "Value", + }; + params.push(format!( + "vm::HostParamSchema::with_passing({:?}, {}, vm::HostParamPassing::{passing})", + name.ident.to_string(), + guest_schema_expr(&shape) + )); + for resource in shape.resources() { + let ctor = match resource.mode { + ResourceMode::Borrow => "borrow", + ResourceMode::BorrowMut => "borrow_mut", + ResourceMode::TakeOwned => "take_owned", + ResourceMode::Value => unreachable!(), + }; + effects.push(format!( + "vm::HostEffect::GuestResource(vm::ResourceEffect::{ctor}({}))", + guest_resource_key_expr(resource) + )); + } + } + let output = normalized_return_type(&function.sig.output); + let output = sole_type_argument(&output, "VmResult").unwrap_or(output); + let shape = pd_host_schema::guest_type_shape(&output).expect("fully typed host return"); + assert!( + pd_host_schema::borrowed_resource_return(&output).is_none(), + "borrowed resource return" + ); + for resource in shape.resources() { + effects.push(format!( + "vm::HostEffect::GuestResource(vm::ResourceEffect::create({}))", + guest_resource_key_expr(resource) + )); + } + let binding = match classify_host_binding(function) { + HostBindingKind::StaticStack => "StaticStack", + HostBindingKind::StaticArgs => "StaticArgs", + HostBindingKind::StaticNonYieldingArgs => "StaticNonYieldingArgs", + }; + format!( + "pub fn result_sdk_build_{}() -> (vm::HostFunctionSchema, Vec, vm::HostBindingKind) {{ (vm::HostFunctionSchema::with_return({:?}, vec![{}], {}).with_description({:?}), vec![{}], vm::HostBindingKind::{binding}) }}\n", + function.sig.ident, + pd_host_function_name(&function.attrs).expect("host name"), + params.join(","), + guest_schema_expr(&shape), + doc_string(&function.attrs), + effects.join(",") + ) +} + fn return_type_label(output: &ReturnType) -> String { match output { ReturnType::Default => "null".to_string(), diff --git a/crates/pd-host-schema/src/lib.rs b/crates/pd-host-schema/src/lib.rs index 3d5f9fa5..5149decb 100644 --- a/crates/pd-host-schema/src/lib.rs +++ b/crates/pd-host-schema/src/lib.rs @@ -567,6 +567,7 @@ pub fn type_label(ty: &Type) -> Result { "ResourceOwned" => Err( "ResourceOwned is input-only; it cannot be used as a return schema".to_string(), ), + "GuestResult" => Ok(guest_type_shape(ty)?.label()), "VmCallable" => callable_type_label(segment), "Unknown" | "UnknownValue" => Ok("unknown".to_string()), "CallOutcome" => Ok("unknown".to_string()), @@ -608,6 +609,162 @@ fn parameter_type_label_inner(ty: &Type) -> Result<(String, bool), String> { Ok((type_label(ty)?, optional)) } +/// Explicit typed shape within a guest union. Both generators use this tree; +/// no branch is inferred from Rust's standard Result or from a runtime map. +#[derive(Clone, Debug)] +pub enum GuestTypeShape<'a> { + Result(Box, Box), + Optional(Box), + Array(Box), + Resource(Box), + Scalar(&'static str), + Named(&'a Type), +} + +impl GuestTypeShape<'_> { + pub fn label(&self) -> String { + match self { + Self::Result(ok, err) => format!("Result<{}, {}>", ok.label(), err.label()), + Self::Optional(inner) => format!("{} | null", inner.label()), + Self::Array(inner) => format!("array<{}>", inner.label()), + Self::Resource(_) => "resource".into(), + Self::Scalar(label) => (*label).into(), + Self::Named(ty) => path_last_ident(ty).expect("named path"), + } + } + + /// Resource leaves in declaration order, including the unselected branch. + pub fn resources(&self) -> Vec<&ResourceSpec> { + match self { + Self::Result(ok, err) => { + let mut out = ok.resources(); + out.extend(err.resources()); + out + } + Self::Optional(inner) | Self::Array(inner) => inner.resources(), + Self::Resource(spec) => vec![spec], + _ => Vec::new(), + } + } + + /// One parameter has one passing mode. Mixed branch access is ambiguous. + pub fn resource_mode(&self) -> Result, String> { + let leaves = self.resources(); + let Some(first) = leaves.first() else { + return Ok(None); + }; + if leaves.iter().any(|leaf| leaf.mode != first.mode) { + return Err("GuestResult resource branches must use the same passing mode".into()); + } + Ok(Some(first.mode)) + } +} + +/// Parses a fully typed guest payload. Dynamic map/unknown payload signatures +/// are rejected; named leaves retain their real Rust type for HostNamedStruct. +pub fn guest_type_shape(ty: &Type) -> Result, String> { + fn walk(ty: &Type, depth: usize) -> Result, String> { + if depth > 64 { + return Err("GuestResult schema exceeds nesting limit".into()); + } + let ty = unwrap_surface(ty); + if let Some((ok, err)) = guest_result_arguments(ty)? { + return Ok(GuestTypeShape::Result( + Box::new(walk(ok, depth + 1)?), + Box::new(walk(err, depth + 1)?), + )); + } + if let Some(spec) = resource_spec(ty, &[])? { + return Ok(GuestTypeShape::Resource(Box::new(spec))); + } + if let Type::Path(path) = ty { + let segment = path.path.segments.last().ok_or("empty type path")?; + let ident = segment.ident.to_string(); + if ident == "Option" || ident == "Vec" { + let argument = one_type_argument(segment, &ident)?; + if ident == "Vec" && path_last_ident(argument).as_deref() == Some("u8") { + return Ok(GuestTypeShape::Scalar("bytes")); + } + let inner = walk(argument, depth + 1)?; + if ident == "Vec" && !inner.resources().is_empty() { + return Err("resource arrays are not supported by the host handle ABI".into()); + } + return Ok(if ident == "Option" { + GuestTypeShape::Optional(Box::new(inner)) + } else { + GuestTypeShape::Array(Box::new(inner)) + }); + } + if matches!( + ident.as_str(), + "Result" | "VmResult" | "HostCallResult" | "HostFutureOutput" + ) { + return Err( + "transport results cannot be nested in GuestResult; use GuestResult" + .into(), + ); + } + } + match type_label(ty).as_deref() { + Ok("int") => Ok(GuestTypeShape::Scalar("int")), + Ok("float") => Ok(GuestTypeShape::Scalar("float")), + Ok("bool") => Ok(GuestTypeShape::Scalar("bool")), + Ok("string") => Ok(GuestTypeShape::Scalar("string")), + Ok("bytes") => Ok(GuestTypeShape::Scalar("bytes")), + Ok("null") => Ok(GuestTypeShape::Scalar("null")), + Ok(_) => Err("GuestResult payload must have a fully typed public schema".into()), + Err(_) if matches!(ty, Type::Path(_)) => Ok(GuestTypeShape::Named(ty)), + Err(message) => Err(message.clone()), + } + } + walk(ty, 0) +} + +/// Returns a typed shape only for the explicit guest union and its supported +/// outer value containers. Transport wrappers are stripped by the return owner. +pub fn guest_result_shape(ty: &Type) -> Result>, String> { + fn contains(ty: &Type) -> bool { + match unwrap_surface(ty) { + Type::Path(path) => path.path.segments.last().is_some_and(|segment| { + segment.ident == "GuestResult" + || matches!(segment.ident.to_string().as_str(), "Option" | "Vec") + && one_type_argument(segment, &segment.ident.to_string()) + .is_ok_and(contains) + }), + _ => false, + } + } + if contains(ty) { + guest_type_shape(ty).map(Some) + } else { + Ok(None) + } +} + +/// Recognizes the explicit guest union and validates its exact type arity. +/// Rust `Result` and the outer VM transport are deliberately not matched. +pub fn guest_result_arguments(ty: &Type) -> Result, String> { + let Type::Path(path) = unwrap_surface(ty) else { + return Ok(None); + }; + let Some(segment) = path.path.segments.last() else { + return Ok(None); + }; + if segment.ident != "GuestResult" { + return Ok(None); + } + let PathArguments::AngleBracketed(args) = &segment.arguments else { + return Err("GuestResult requires exactly two type arguments".into()); + }; + let mut args = args.args.iter(); + match (args.next(), args.next(), args.next()) { + (Some(GenericArgument::Type(ok)), Some(GenericArgument::Type(err)), None) => { + Ok(Some((ok, err))) + } + _ => Err("GuestResult requires exactly two type arguments".into()), + } +} + fn one_type_argument<'a>(segment: &'a syn::PathSegment, name: &str) -> Result<&'a Type, String> { let PathArguments::AngleBracketed(args) = &segment.arguments else { return Err(format!("{name} requires one generic argument")); @@ -705,13 +862,11 @@ pub struct BorrowedResourceReturn { pub wrappers: Vec, } -/// Finds a borrowed resource wrapper nested inside supported return wrappers. +/// Finds a borrowed resource wrapper nested in a return type's generic syntax. /// -/// `VmResult`, `Result`, `Option`, `HostCallResult`, and -/// `HostFutureOutput` are transparent for this validation. The traversal is -/// deliberately independent of the final schema label so a borrowed resource -/// cannot be hidden behind a wrapper that would otherwise be flattened by the -/// host-function parser. +/// Transport, guest-union, optional/array, and generic named wrappers are all +/// transparent for this validation. The traversal is independent of the final +/// schema label, so a generic named payload cannot hide a resource borrow. pub fn borrowed_resource_return(ty: &Type) -> Option { let ty = unwrap_return_surface(ty); if let Some(kind) = resource_return_kind(ty) { @@ -731,12 +886,6 @@ pub fn borrowed_resource_return(ty: &Type) -> Option { }; let segment = path.path.segments.last()?; let wrapper = segment.ident.to_string(); - if !matches!( - wrapper.as_str(), - "VmResult" | "Result" | "Option" | "HostCallResult" | "HostFutureOutput" - ) { - return None; - } let PathArguments::AngleBracketed(args) = &segment.arguments else { return None; }; diff --git a/crates/pd-host-schema/tests/guest_result.rs b/crates/pd-host-schema/tests/guest_result.rs new file mode 100644 index 00000000..bd41ac8f --- /dev/null +++ b/crates/pd-host-schema/tests/guest_result.rs @@ -0,0 +1,65 @@ +use pd_host_schema::{ + ResourceMode, borrowed_resource_return, guest_result_arguments, guest_type_shape, type_label, +}; +use syn::{Type, parse_quote}; +#[test] +fn exact_guest_union_arity_and_transport_identity() { + for ty in [ + parse_quote!(GuestResult), + parse_quote!(GuestResult), + parse_quote!(GuestResult), + parse_quote!(GuestResult<'static, i64>), + ] { + assert!(guest_result_arguments(&ty).is_err()); + } + for ty in [ + parse_quote!(Result), + parse_quote!(VmResult), + ] { + assert!(guest_result_arguments(&ty).unwrap().is_none()); + } + assert_eq!( + type_label(&parse_quote!(VmResult>)).unwrap(), + "Result" + ); +} +#[test] +fn both_resource_branches_are_observed_in_declaration_order() { + let ty: Type = + parse_quote!(GuestResult, Option>>); + let shape = guest_type_shape(&ty).unwrap(); + assert_eq!(shape.resources().len(), 2); + assert_eq!( + shape.resource_mode().unwrap(), + Some(ResourceMode::TakeOwned) + ); + assert_eq!(shape.label(), "Result"); + assert!( + guest_type_shape(&parse_quote!( + GuestResult, ResourceRef<'static, Failure>> + )) + .unwrap() + .resource_mode() + .is_err() + ); +} +#[test] +fn borrowed_return_visitor_checks_both_branches_and_containers() { + for ty in [ + parse_quote!(GuestResult, i64>), + parse_quote!(GuestResult>>>), + parse_quote!(GuestResult>, i64>), + ] { + assert!(borrowed_resource_return(&ty).is_some()); + } +} +#[test] +fn typed_payloads_reject_dynamic_and_nested_transport() { + for ty in [ + parse_quote!(GuestResult), + parse_quote!(GuestResult), + parse_quote!(GuestResult>), + ] { + assert!(guest_type_shape(&ty).is_err()); + } +} diff --git a/pd-host-function/src/guest_result.rs b/pd-host-function/src/guest_result.rs new file mode 100644 index 00000000..d73f257a --- /dev/null +++ b/pd-host-function/src/guest_result.rs @@ -0,0 +1,134 @@ +//! Expansion for the explicit guest union, using the shared syntax tree. +use pd_host_schema::{GuestTypeShape, ResourceMode}; +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Error, Type}; + +pub(super) fn shape(ty: &Type) -> Result>, Error> { + pd_host_schema::guest_result_shape(ty).map_err(|message| Error::new_spanned(ty, message)) +} + +pub(super) fn uses_vm(item: &syn::ItemFn) -> bool { + item.sig.inputs.iter().any(|arg| match arg { + syn::FnArg::Typed(arg) => shape(&arg.ty) + .ok() + .flatten() + .is_some_and(|s| !s.resources().is_empty()), + _ => false, + }) || match &item.sig.output { + syn::ReturnType::Type(_, ty) => shape(super::unwrap_transparent_return(ty)) + .ok() + .flatten() + .is_some_and(|s| !s.resources().is_empty()), + _ => false, + } +} + +pub(super) fn schema(shape: &GuestTypeShape<'_>) -> Result { + let sdk = super::sdk_path(); + Ok(match shape { + GuestTypeShape::Result(ok, err) => { + let ok = schema(ok)?; + let err = schema(err)?; + quote!(#sdk::HostTypeSchema::Result { ok: Box::new(#ok), err: Box::new(#err) }) + } + GuestTypeShape::Optional(inner) => { + let inner = schema(inner)?; + quote!(#sdk::HostTypeSchema::Optional(Box::new(#inner))) + } + GuestTypeShape::Array(inner) => { + let inner = schema(inner)?; + quote!(#sdk::HostTypeSchema::Array(Box::new(#inner))) + } + GuestTypeShape::Resource(spec) => { + let key = super::resource_key_tokens(spec); + quote!(#sdk::HostTypeSchema::Resource(#key)) + } + GuestTypeShape::Scalar(label) => { + super::host_type_schema_from_label(label, &syn::parse_quote!(()))? + } + GuestTypeShape::Named(ty) => quote!(<#ty as #sdk::HostNamedStruct>::host_type_schema()), + }) +} + +pub(super) fn decode(shape: &GuestTypeShape<'_>, value: TokenStream) -> TokenStream { + let sdk = super::sdk_path(); + match shape { + GuestTypeShape::Result(ok, err) => { + let ok = decode(ok, quote!(__pd_payload)); + let err = decode(err, quote!(__pd_payload)); + quote!({ let (__pd_tag, __pd_payload) = #sdk::result::payload(#value)?; + if __pd_tag { #sdk::GuestResult::Ok(#ok) } else { #sdk::GuestResult::Err(#err) } }) + } + GuestTypeShape::Optional(inner) => { + let inner = decode(inner, quote!(__pd_optional)); + quote!({ let __pd_optional = #value; if matches!(__pd_optional, #sdk::Value::Null) { None } else { Some(#inner) } }) + } + GuestTypeShape::Array(inner) => { + let inner = decode(inner, quote!(__pd_element)); + quote!({ let #sdk::Value::Array(__pd_array) = #value else { return Err(#sdk::VmError::TypeMismatch("array Result payload")); }; + let mut __pd_decoded = Vec::with_capacity(__pd_array.len()); + for __pd_element in __pd_array.iter() { __pd_decoded.push(#inner); } __pd_decoded }) + } + GuestTypeShape::Resource(spec) => { + let inner = &spec.inner; + let key = super::resource_key_tokens(spec); + let method = match spec.mode { + ResourceMode::Borrow => quote!(borrow_resource_with_key), + ResourceMode::BorrowMut => quote!(borrow_resource_mut_with_key), + _ => quote!(take_resource_with_key), + }; + let extracted = quote!(__pd_guest_context.#method::<#inner>(#sdk::result::resource_handle(#value)?, &#key).map_err(|error| #sdk::VmError::HostError(error.to_string()))?); + if spec.mode == ResourceMode::TakeOwned { + quote!(#sdk::ResourceOwned::new(#extracted)) + } else { + extracted + } + } + _ => quote!(#sdk::FromVmValue::from_vm_value(#value, "Result payload")?), + } +} + +pub(super) fn encode( + shape: &GuestTypeShape<'_>, + value: TokenStream, + root_schema: &TokenStream, +) -> TokenStream { + fn walk( + shape: &GuestTypeShape<'_>, + value: TokenStream, + root: &TokenStream, + wrap: &dyn Fn(TokenStream) -> TokenStream, + ) -> TokenStream { + let sdk = super::sdk_path(); + match shape { + GuestTypeShape::Result(ok, err) => { + let ok = walk(ok, quote!(__pd_selected), root, &|v| { + wrap(quote!(#sdk::result::carrier(true, #v))) + }); + let err = walk(err, quote!(__pd_selected), root, &|v| { + wrap(quote!(#sdk::result::carrier(false, #v))) + }); + quote!(match #value { #sdk::GuestResult::Ok(__pd_selected) => #ok, #sdk::GuestResult::Err(__pd_selected) => #err }) + } + GuestTypeShape::Optional(inner) => { + let some = walk(inner, quote!(__pd_selected), root, wrap); + let none = wrap(quote!(#sdk::Value::Null)); + quote!(match #value { Some(__pd_selected) => #some, None => #none }) + } + GuestTypeShape::Array(inner) => { + let element = walk(inner, quote!(__pd_element), root, &|v| v); + wrap( + quote!(#sdk::Value::array(#value.into_iter().map(|__pd_element| #element).collect())), + ) + } + GuestTypeShape::Resource(spec) => { + let inner = &spec.inner; + let wrapped = wrap(quote!(__pd_token)); + quote!(#sdk::result::publish_owned::<#inner, _>(vm, #value, &#root, |__pd_token| Ok(#wrapped))?) + } + _ => wrap(quote!(#sdk::IntoVmValue::into_vm_value(#value))), + } + } + walk(shape, value, root_schema, &|v| v) +} diff --git a/pd-host-function/src/lib.rs b/pd-host-function/src/lib.rs index ef26c054..87324440 100644 --- a/pd-host-function/src/lib.rs +++ b/pd-host-function/src/lib.rs @@ -1,3 +1,5 @@ +mod guest_result; + use proc_macro::TokenStream; use quote::quote; use syn::{ @@ -86,6 +88,13 @@ fn expand_pd_host_function( validate_sync_vm_state_conflict(&item, &state_params)?; validate_state_resource_combination(&state_params, &resource_params)?; validate_return_type(&item.sig.output, has_named_struct_attr(&item.attrs))?; + if (is_async || return_is_host_future_output(&item.sig.output)) && guest_result::uses_vm(&item) + { + return Err(Error::new_spanned( + &item.sig, + "GuestResult owned resources cannot cross async/future publication; use a synchronous live-VM adapter", + )); + } if contract.is_some() && has_named_struct_attr(&item.attrs) { return Err(Error::new_spanned( &item.sig.ident, @@ -526,7 +535,10 @@ fn generate_vm_wrapper( FnArg::Typed(pat_type) => is_vm_context_type(&pat_type.ty), FnArg::Receiver(_) => false, }); - let needs_vm = has_vm || !resource_params.is_empty() || !state_params.is_empty(); + let needs_vm = has_vm + || !resource_params.is_empty() + || !state_params.is_empty() + || guest_result::uses_vm(item); if needs_vm { wrapper_params.push(quote!(vm: &mut super::super::Vm)); if has_vm { @@ -621,6 +633,28 @@ fn generate_vm_wrapper( proc_macro2::Span::call_site(), ); let index = syn::Index::from(arg_index); + if let Some(shape) = guest_result::shape(ty)? { + let decoded = guest_result::decode( + &shape, + quote!(args.get(#index).ok_or_else(|| #sdk::VmError::HostError(format!("missing {}", #label)))?), + ); + let mode = shape + .resource_mode() + .map_err(|message| Error::new_spanned(ty, message))?; + let context = mode.map(|mode| { + if mode == ResourceMode::Borrow { + quote!(let __pd_guest_context = vm.host_context();) + } else { + quote!(let mut __pd_guest_context = vm.host_context();) + } + }); + let extract = quote! { #context let #ident: #ty = #decoded; }; + imm_extract_stmts.push(extract.clone()); + mut_extract_stmts.push(extract); + call_args.push(quote!(#ident)); + arg_index += 1; + continue; + } imm_extract_stmts.push(quote! { let #ident = super::borrow_arg::<#ty>(args, #index, #label)?; }); @@ -636,12 +670,27 @@ fn generate_vm_wrapper( arg_index += 1; } - let wrapper_output = wrapper_output_type(&item.sig.output)?; - let call_expr = if return_is_vm_result(&item.sig.output) { + let mut wrapper_output = wrapper_output_type(&item.sig.output)?; + let mut call_expr = if return_is_vm_result(&item.sig.output) { quote!(#impl_name(#(#call_args),*)) } else { quote!(Ok(#impl_name(#(#call_args),*))) }; + if let ReturnType::Type(_, ty) = &item.sig.output + && let Some(shape) = guest_result::shape(unwrap_transparent_return(ty))? + { + let schema = guest_result::schema(&shape)?; + let encoded = guest_result::encode(&shape, quote!(__pd_result), &schema); + let validation = (!shape.resources().is_empty()) + .then(|| quote!(vm.validate_guest_result_value(&__pd_value, &#schema)?;)); + wrapper_output = quote!(#sdk::VmResult<#sdk::Value>); + let invoke = if return_is_vm_result(&item.sig.output) { + quote!(#impl_name(#(#call_args),*)?) + } else { + quote!(#impl_name(#(#call_args),*)) + }; + call_expr = quote! { let __pd_result = #invoke; let __pd_value = #encoded; #validation Ok(__pd_value) }; + } Ok(quote! { #[allow(dead_code)] @@ -1051,6 +1100,28 @@ fn generate_host_function_descriptor( }); continue; } + if let Some(shape) = guest_result::shape(&pat_type.ty)? { + let schema = guest_result::schema(&shape)?; + let mode = shape + .resource_mode() + .map_err(|message| Error::new_spanned(&pat_type.ty, message))?; + let passing = match mode { + Some(ResourceMode::Borrow) => quote!(#sdk::HostParamPassing::Borrow), + Some(ResourceMode::BorrowMut) => quote!(#sdk::HostParamPassing::BorrowMut), + Some(ResourceMode::TakeOwned) => quote!(#sdk::HostParamPassing::TakeOwned), + _ => quote!(#sdk::HostParamPassing::Value), + }; + param_tokens + .push(quote!(#sdk::HostParamSchema::with_passing(#param_name, #schema, #passing))); + for spec in shape.resources() { + let key = resource_key_tokens(spec); + if let Some(effect) = resource_effect_tokens(spec.mode, &key) { + effect_tokens.push(effect); + } + resource_meta_tokens.push(resource_meta_tokens_for(spec)); + } + continue; + } if let Some((_, spec)) = resource_params.iter().find(|(name, _)| name == ¶m_name) { let key_expr = resource_key_tokens(spec); let passing = match spec.mode { @@ -1314,7 +1385,12 @@ fn classify_generated_binding( ) -> GeneratedBinding { let is_async = item.sig.asyncness.is_some(); let has_vm = item.sig.inputs.iter().any(is_vm_context_param); - if is_async || has_vm || !resource_params.is_empty() || !state_params.is_empty() { + if is_async + || has_vm + || !resource_params.is_empty() + || !state_params.is_empty() + || guest_result::uses_vm(item) + { return GeneratedBinding::Stack; } match &item.sig.output { @@ -1462,6 +1538,22 @@ fn return_schema_tokens( )); }; let surface = unwrap_transparent_return(ty); + if let Some(shape) = guest_result::shape(surface)? { + let schema = guest_result::schema(&shape)?; + let mut effects = Vec::new(); + let mut metas = Vec::new(); + for spec in shape.resources() { + let key = resource_key_tokens(spec); + effects + .push(quote!(#sdk::HostEffect::GuestResource(#sdk::ResourceEffect::create(#key)))); + metas.push(resource_meta_tokens_for(spec)); + } + return Ok(( + schema, + (!effects.is_empty()).then(|| quote!(#(#effects),*)), + (!metas.is_empty()).then(|| quote!(#(#metas),*)), + )); + } if resource_return_kind(surface) == Some(ResourceReturnKind::Owned) { let inner = last_generic_type(surface).ok_or_else(|| { Error::new_spanned(surface, "Resource return must have a type argument") @@ -1535,6 +1627,9 @@ fn host_type_schema_tokens( named_struct: bool, ) -> Result { let sdk = sdk_path(); + if let Some(shape) = guest_result::shape(ty)? { + return guest_result::schema(&shape); + } if named_struct { return Ok(quote!(<#ty as #sdk::host_extension::HostNamedStruct>::host_type_schema())); } @@ -1610,6 +1705,19 @@ mod tests { use super::{expand_pd_host_function, type_label}; use syn::{ItemFn, Meta, Token, Type, parse_quote, punctuated::Punctuated}; + #[test] + fn guest_union_owned_resource_return_is_accepted_without_infallible_handle_conversion() { + let attr: Punctuated = parse_quote!(name = "test::result_owned"); + let item: ItemFn = parse_quote! { + /// Publishes only the selected resource. + fn result_owned() -> VmResult, ResourceOwned>> { todo!() } + }; + let expanded = expand_pd_host_function(attr, item).unwrap().to_string(); + assert!(expanded.contains("publish_owned")); + assert!(expanded.contains("HostTypeSchema :: Result")); + assert!(expanded.contains("ResourceEffect :: create")); + } + #[test] fn accepts_host_call_result_from_the_function_signature() { let attr: Punctuated = parse_quote!(name = "test::suspend"); @@ -2011,6 +2119,30 @@ mod tests { assert!(error.to_string().contains("Value")); } + #[test] + fn guest_result_owned_future_publication_has_explicit_diagnostic() { + let items: Vec = vec![ + parse_quote! { + /// An async owned guest payload needs a live VM publisher. + async fn pending() -> VmResult, i64>> { todo!() } + }, + parse_quote! { + /// A legacy future output cannot bypass owned publication. + fn legacy() -> VmResult>>> { todo!() } + }, + ]; + for item in items { + let error = expand_pd_host_function(parse_quote!(name = "test::pending"), item) + .expect_err("GuestResult owned future publication must be rejected"); + assert!( + error + .to_string() + .contains("GuestResult owned resources cannot cross async/future publication"), + "{error}" + ); + } + } + #[test] fn async_borrow_resource_rejected() { let attr: Punctuated = parse_quote!(name = "test::async_borrow"); diff --git a/pd-host-function/tests/ui/fail/guest_result_arity.rs b/pd-host-function/tests/ui/fail/guest_result_arity.rs new file mode 100644 index 00000000..17caba6a --- /dev/null +++ b/pd-host-function/tests/ui/fail/guest_result_arity.rs @@ -0,0 +1,10 @@ +#![allow(dead_code, unused_imports)] +use pd_host_function::pd_host_function; +use vm::{GuestResult, ResourceMut, ResourceRef, Value, VmResult}; +struct Counter; +/// Invalid typed guest return. +#[pd_host_function(name = "test::invalid")] +fn invalid() -> VmResult> { + todo!() +} +fn main() {} diff --git a/pd-host-function/tests/ui/fail/guest_result_arity.stderr b/pd-host-function/tests/ui/fail/guest_result_arity.stderr new file mode 100644 index 00000000..04d6d7ef --- /dev/null +++ b/pd-host-function/tests/ui/fail/guest_result_arity.stderr @@ -0,0 +1,5 @@ +error: GuestResult requires exactly two type arguments + --> tests/ui/fail/guest_result_arity.rs:7:17 + | +7 | fn invalid() -> VmResult> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/guest_result_borrowed_err.rs b/pd-host-function/tests/ui/fail/guest_result_borrowed_err.rs new file mode 100644 index 00000000..747404db --- /dev/null +++ b/pd-host-function/tests/ui/fail/guest_result_borrowed_err.rs @@ -0,0 +1,10 @@ +#![allow(dead_code, unused_imports)] +use pd_host_function::pd_host_function; +use vm::{GuestResult, ResourceMut, ResourceRef, Value, VmResult}; +struct Counter; +/// Invalid typed guest return. +#[pd_host_function(name = "test::invalid")] +fn invalid() -> VmResult>>>> { + todo!() +} +fn main() {} diff --git a/pd-host-function/tests/ui/fail/guest_result_borrowed_err.stderr b/pd-host-function/tests/ui/fail/guest_result_borrowed_err.stderr new file mode 100644 index 00000000..a32b4bea --- /dev/null +++ b/pd-host-function/tests/ui/fail/guest_result_borrowed_err.stderr @@ -0,0 +1,5 @@ +error: ResourceMut cannot appear in a host function return nested inside `VmResult` -> `GuestResult` -> `Option` -> `Vec`; resource borrows cannot cross the host boundary + --> tests/ui/fail/guest_result_borrowed_err.rs:7:17 + | +7 | fn invalid() -> VmResult>>>> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/guest_result_borrowed_ok.rs b/pd-host-function/tests/ui/fail/guest_result_borrowed_ok.rs new file mode 100644 index 00000000..6d2e2f2c --- /dev/null +++ b/pd-host-function/tests/ui/fail/guest_result_borrowed_ok.rs @@ -0,0 +1,10 @@ +#![allow(dead_code, unused_imports)] +use pd_host_function::pd_host_function; +use vm::{GuestResult, ResourceMut, ResourceRef, Value, VmResult}; +struct Counter; +/// Invalid typed guest return. +#[pd_host_function(name = "test::invalid")] +fn invalid() -> VmResult, i64>> { + todo!() +} +fn main() {} diff --git a/pd-host-function/tests/ui/fail/guest_result_borrowed_ok.stderr b/pd-host-function/tests/ui/fail/guest_result_borrowed_ok.stderr new file mode 100644 index 00000000..a2da13a9 --- /dev/null +++ b/pd-host-function/tests/ui/fail/guest_result_borrowed_ok.stderr @@ -0,0 +1,5 @@ +error: ResourceRef cannot appear in a host function return nested inside `VmResult` -> `GuestResult`; resource borrows cannot cross the host boundary + --> tests/ui/fail/guest_result_borrowed_ok.rs:7:17 + | +7 | fn invalid() -> VmResult, i64>> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/guest_result_dynamic.rs b/pd-host-function/tests/ui/fail/guest_result_dynamic.rs new file mode 100644 index 00000000..82da9962 --- /dev/null +++ b/pd-host-function/tests/ui/fail/guest_result_dynamic.rs @@ -0,0 +1,10 @@ +#![allow(dead_code, unused_imports)] +use pd_host_function::pd_host_function; +use vm::{GuestResult, ResourceMut, ResourceRef, Value, VmResult}; +struct Counter; +/// Invalid typed guest return. +#[pd_host_function(name = "test::invalid")] +fn invalid() -> VmResult> { + todo!() +} +fn main() {} diff --git a/pd-host-function/tests/ui/fail/guest_result_dynamic.stderr b/pd-host-function/tests/ui/fail/guest_result_dynamic.stderr new file mode 100644 index 00000000..363000d7 --- /dev/null +++ b/pd-host-function/tests/ui/fail/guest_result_dynamic.stderr @@ -0,0 +1,5 @@ +error: GuestResult payload must have a fully typed public schema + --> tests/ui/fail/guest_result_dynamic.rs:7:17 + | +7 | fn invalid() -> VmResult> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.rs b/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.rs new file mode 100644 index 00000000..c9f6c6f2 --- /dev/null +++ b/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.rs @@ -0,0 +1,5 @@ +use vm::{FromVmValue, GuestResult, ResourceOwned, Value}; +struct Counter; +fn main() { + let _ = GuestResult::, i64>::from_vm_value(&Value::Null, "owned"); +} diff --git a/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.stderr b/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.stderr new file mode 100644 index 00000000..2d116b5e --- /dev/null +++ b/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.stderr @@ -0,0 +1,19 @@ +error[E0599]: the variant or associated item `from_vm_value` exists for enum `GuestResult, i64>`, but its trait bounds were not satisfied + --> tests/ui/fail/guest_result_owned_borrow_decode.rs:4:57 + | +4 | let _ = GuestResult::, i64>::from_vm_value(&Value::Null, "owned"); + | ^^^^^^^^^^^^^ variant or associated item cannot be called on `GuestResult, i64>` due to unsatisfied trait bounds + | + ::: $WORKSPACE/src/result.rs + | + | pub enum GuestResult { + | -------------------------- doesn't satisfy `_: FromVmValue<'_>` + | + ::: $WORKSPACE/src/vm/resource/handle.rs + | + | pub struct ResourceOwned(T); + | --------------------------- doesn't satisfy `ResourceOwned: FromVmValue<'_>` + | + = note: the following trait bounds were not satisfied: + `ResourceOwned: FromVmValue<'_>` + which is required by `GuestResult, i64>: FromVmValue<'_>` diff --git a/pd-host-function/tests/ui/pass/valid_guest_result.rs b/pd-host-function/tests/ui/pass/valid_guest_result.rs new file mode 100644 index 00000000..5c5a7350 --- /dev/null +++ b/pd-host-function/tests/ui/pass/valid_guest_result.rs @@ -0,0 +1,29 @@ +#![allow(dead_code, unused_imports)] +use pd_host_function::pd_host_function; +use vm::{GuestResult, Value, VmResult}; +mod generated_parent { + pub mod functions { + use super::super::*; + /// Echo a typed guest union without confusing its error with VmError. + #[pd_host_function(name = "test::guest_result")] + fn echo(value: GuestResult) -> VmResult> { + Ok(value) + } + } +} +fn main() { + let descriptor = generated_parent::functions::echo_descriptor(); + assert!(matches!( + descriptor.schema.return_type, + vm::HostTypeSchema::Result { .. } + )); + for value in [ + vm::result::carrier(true, Value::Int(0)), + vm::result::carrier(false, Value::string("error")), + ] { + assert_eq!( + generated_parent::functions::echo(std::slice::from_ref(&value)).unwrap(), + value + ); + } +} diff --git a/src/builtins/runtime/mod.rs b/src/builtins/runtime/mod.rs index 2ab70d48..dc047c42 100644 --- a/src/builtins/runtime/mod.rs +++ b/src/builtins/runtime/mod.rs @@ -118,8 +118,8 @@ pub use typed::HostCallResult; use typed::{AnyValue, IntoBuiltinCallOutcome, NumberValue, UnknownValue, VmArray, VmBytes, VmMap}; #[allow(unused_imports)] pub use typed::{ - BorrowVmValue, FromVmValue, IntoHostCallOutcome, TakeVmValue, arg, borrow_arg, return_none, - return_one, take_arg, + BorrowVmValue, FromVmValue, IntoHostCallOutcome, IntoVmValue, TakeVmValue, arg, borrow_arg, + return_none, return_one, take_arg, }; pub(crate) enum BuiltinCallOutcome { diff --git a/src/builtins/runtime/typed.rs b/src/builtins/runtime/typed.rs index 25082021..692853fb 100644 --- a/src/builtins/runtime/typed.rs +++ b/src/builtins/runtime/typed.rs @@ -365,6 +365,124 @@ where } } +macro_rules! take_scalar { + ($($ty:ty),* $(,)?) => {$ ( + impl TakeVmValue for $ty { + fn take_vm_value(slot: &mut Value, label: &str) -> VmResult { + let result = Self::from_vm_value(slot, label)?; + *slot = Value::Null; + Ok(result) + } + } + )*}; +} +take_scalar!(i64, bool, f64, String, u32, usize); + +impl FromVmValue<'_> for () { + fn from_vm_value(value: &Value, _label: &str) -> VmResult { + if matches!(value, Value::Null) { + Ok(()) + } else { + Err(VmError::TypeMismatch("null")) + } + } +} +take_scalar!(()); + +impl FromVmValue<'_> for Vec { + fn from_vm_value(value: &Value, _label: &str) -> VmResult { + match value { + Value::Bytes(bytes) => Ok(bytes.as_ref().clone()), + _ => Err(VmError::TypeMismatch("bytes")), + } + } +} + +impl TakeVmValue for Vec { + fn take_vm_value(slot: &mut Value, _label: &str) -> VmResult { + if !matches!(slot, Value::Bytes(_)) { + return Err(VmError::TypeMismatch("bytes")); + } + let Value::Bytes(bytes) = std::mem::replace(slot, Value::Null) else { + unreachable!() + }; + Ok(std::sync::Arc::unwrap_or_clone(bytes)) + } +} + +impl<'a, T: FromVmValue<'a>> FromVmValue<'a> for Vec { + fn from_vm_value(value: &'a Value, label: &str) -> VmResult { + let Value::Array(values) = value else { + return Err(VmError::TypeMismatch("array")); + }; + values + .iter() + .map(|value| T::from_vm_value(value, label)) + .collect() + } +} + +impl TakeVmValue for Vec { + fn take_vm_value(slot: &mut Value, label: &str) -> VmResult { + if !matches!(slot, Value::Array(_)) { + return Err(VmError::TypeMismatch("array")); + } + let Value::Array(values) = std::mem::replace(slot, Value::Null) else { + unreachable!() + }; + let mut values = std::sync::Arc::unwrap_or_clone(values); + values + .iter_mut() + .map(|value| T::take_vm_value(value, label)) + .collect() + } +} + +impl<'a, T, E> FromVmValue<'a> for crate::GuestResult +where + T: FromVmValue<'a>, + E: FromVmValue<'a>, +{ + fn from_vm_value(value: &'a Value, label: &str) -> VmResult { + let (tag, payload) = crate::result::payload(value)?; + if tag { + T::from_vm_value(payload, label).map(Self::Ok) + } else { + E::from_vm_value(payload, label).map(Self::Err) + } + } +} + +impl TakeVmValue for crate::GuestResult +where + T: TakeVmValue, + E: TakeVmValue, +{ + fn take_vm_value(slot: &mut Value, label: &str) -> VmResult { + let (tag, _) = crate::result::payload(slot)?; + let Value::Map(mut entries) = std::mem::replace(slot, Value::Null) else { + unreachable!() + }; + let mut payload = std::sync::Arc::make_mut(&mut entries) + .remove(&Value::string("payload")) + .unwrap_or(Value::Null); + if tag { + T::take_vm_value(&mut payload, label).map(Self::Ok) + } else { + E::take_vm_value(&mut payload, label).map(Self::Err) + } + } +} + +impl IntoVmValue for crate::GuestResult { + fn into_vm_value(self) -> Value { + match self { + Self::Ok(value) => crate::result::carrier(true, value.into_vm_value()), + Self::Err(error) => crate::result::carrier(false, error.into_vm_value()), + } + } +} + pub trait IntoVmValue { fn into_vm_value(self) -> Value; } @@ -452,9 +570,9 @@ where } } -impl IntoVmValue for Vec { +impl IntoVmValue for Vec { fn into_vm_value(self) -> Value { - Value::array(self) + Value::array(self.into_iter().map(IntoVmValue::into_vm_value).collect()) } } diff --git a/src/compiler/host_call_resolve.rs b/src/compiler/host_call_resolve.rs index cdd682da..0192b1c7 100644 --- a/src/compiler/host_call_resolve.rs +++ b/src/compiler/host_call_resolve.rs @@ -612,6 +612,19 @@ fn score_pair(expected: &TypeSchema, actual: &TypeSchema) -> MatchScore { } match (expected, actual) { + ( + Result { + ok: e_ok, + err: e_err, + }, + Result { + ok: a_ok, + err: a_err, + }, + ) => MatchScore::default() + .plus_exact() + .combined(score_pair(e_ok, a_ok)) + .combined(score_pair(e_err, a_err)), (Optional(e), Optional(a)) => MatchScore::default() .plus_exact() .combined(score_pair(e, a)), @@ -1131,6 +1144,64 @@ mod tests { b.build().expect("valid catalog") } + #[test] + fn result_pair_scores_both_branches_with_existing_rules() { + fn result(ok: Ts, err: Ts) -> Ts { + Ts::Result { + ok: Box::new(ok), + err: Box::new(err), + } + } + let expected = result(Ts::Array(Box::new(Ts::Int)), Ts::Resource(io_file())); + let exact = score_pair(&expected, &expected); + assert_eq!( + exact, + MatchScore { + exact_structural: 4, + ..MatchScore::default() + } + ); + let deferred = score_pair( + &expected, + &result(Ts::Array(Box::new(Ts::Unknown)), Ts::Resource(io_file())), + ); + assert_eq!(deferred.deferred, 1); + assert_eq!(deferred.mismatches, 0); + assert_eq!( + score_pair( + &expected, + &result(Ts::Array(Box::new(Ts::Number)), Ts::Resource(io_file())) + ) + .numeric_compat, + 1 + ); + assert_eq!( + score_pair( + &expected, + &result(Ts::Array(Box::new(Ts::Int)), Ts::Resource(sqlite_conn())) + ) + .mismatches, + 1 + ); + assert_eq!( + score_pair( + &expected, + &result(Ts::Resource(io_file()), Ts::Array(Box::new(Ts::Int))) + ) + .mismatches, + 2 + ); + assert_eq!( + score_pair( + &result(Ts::GenericParam("T".into()), Ts::String), + &result(Ts::GenericParam("E".into()), Ts::String) + ) + .mismatches, + 1 + ); + assert_eq!(score_pair(&expected, &Ts::Int).mismatches, 1); + } + #[test] fn resolves_io_open_and_infers_file_return() { let catalog = concrete_catalog(); diff --git a/src/lib.rs b/src/lib.rs index aca9b61d..e392c83a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,10 @@ pub mod debug_info; #[cfg(feature = "runtime")] pub mod debugger; pub mod host_api; +pub mod result; +pub use result::GuestResult; +#[cfg(feature = "runtime")] +pub use result::{ScriptValueContext, VmScriptArgs, VmScriptValue}; #[cfg(feature = "runtime")] pub mod jit { pub use crate::vm::jit::{ @@ -62,8 +66,8 @@ pub use builtins::runtime::http::{ }; #[cfg(feature = "runtime")] pub use builtins::runtime::{ - BorrowVmValue, FromVmValue, HostCallResult, IntoHostCallOutcome, TakeVmValue, arg, borrow_arg, - return_one, take_arg, + BorrowVmValue, FromVmValue, HostCallResult, IntoHostCallOutcome, IntoVmValue, TakeVmValue, arg, + borrow_arg, return_one, take_arg, }; #[cfg(feature = "runtime")] pub use builtins::runtime::{ diff --git a/src/result.rs b/src/result.rs new file mode 100644 index 00000000..65b1280d --- /dev/null +++ b/src/result.rs @@ -0,0 +1,148 @@ +//! Typed guest success/failure values, separate from the VM transport channel. + +/// A guest-language `Result`. +/// +/// `VmResult>` keeps fatal VM errors in the outer result. +/// Only the selected variant is encoded; payload truthiness never selects it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum GuestResult { + Ok(T), + Err(E), +} + +impl From> for GuestResult { + fn from(value: Result) -> Self { + match value { + Ok(value) => Self::Ok(value), + Err(error) => Self::Err(error), + } + } +} + +impl From> for Result { + fn from(value: GuestResult) -> Self { + match value { + GuestResult::Ok(value) => Ok(value), + GuestResult::Err(error) => Err(error), + } + } +} + +#[cfg(feature = "runtime")] +#[doc(hidden)] +pub fn payload(value: &crate::Value) -> crate::VmResult<(bool, &crate::Value)> { + use crate::{Value, VmError}; + let Value::Map(entries) = value else { + return Err(VmError::TypeMismatch("Result")); + }; + if entries.iter().any(|(key, _)| !matches!(key, Value::String(key) if key.as_str() == "tag" || key.as_str() == "payload")) { + return Err(VmError::TypeMismatch("canonical Result fields")); + } + let Some(Value::Bool(tag)) = entries.get(&Value::string("tag")) else { + return Err(VmError::TypeMismatch("Result Boolean tag")); + }; + Ok(( + *tag, + entries + .get(&Value::string("payload")) + .unwrap_or(&Value::Null), + )) +} + +#[cfg(feature = "runtime")] +#[doc(hidden)] +pub fn resource_handle(value: &crate::Value) -> crate::VmResult { + let crate::Value::Int(raw) = value else { + return Err(crate::VmError::TypeMismatch("resource handle")); + }; + let raw = u64::try_from(*raw) + .map_err(|_| crate::VmError::TypeMismatch("positive resource handle"))?; + crate::ResourceHandle::from_raw(raw) + .map_err(|error| crate::VmError::HostError(error.to_string())) +} + +/// Context-aware publication used by generated owned Result returns. +/// Validation or assembly failure closes the unpublished resource through the +/// existing execution-scope lifecycle, preserving the primary transport error. +#[cfg(feature = "runtime")] +#[doc(hidden)] +pub fn publish_owned( + vm: &mut crate::Vm, + value: crate::ResourceOwned, + schema: &crate::HostTypeSchema, + assemble: F, +) -> crate::VmResult +where + F: FnOnce(crate::Value) -> crate::VmResult, +{ + schema + .validate() + .map_err(|error| crate::VmError::HostError(error.to_string()))?; + let key = crate::ResourceTypeKey::new(T::KEY) + .map_err(|error| crate::VmError::HostError(error.to_string()))?; + vm.host_context() + .validate_resource_type_key::(&key) + .map_err(|error| crate::VmError::HostError(error.to_string()))?; + let token = vm + .host_context() + .insert_resource(value.into_inner()) + .map_err(|error| crate::VmError::HostError(error.to_string()))?; + let handle = token.handle(); + let result = assemble(crate::Value::Int(handle.raw() as i64)).and_then(|value| { + vm.validate_guest_result_value(&value, schema)?; + Ok(value) + }); + if let Err(primary) = &result { + vm.host_context() + .close_resource::(handle, crate::ResourceCloseReason::Requested) + .map_err(|error| { + crate::VmError::HostError(format!( + "Result publication rollback failed: {error}; primary: {primary}" + )) + })?; + } + result +} + +#[cfg(feature = "runtime")] +#[doc(hidden)] +pub fn carrier(tag: bool, payload: crate::Value) -> crate::Value { + crate::Value::map(vec![ + (crate::Value::string("tag"), crate::Value::Bool(tag)), + (crate::Value::string("payload"), payload), + ]) +} + +/// Fallible live-VM codec for typed callback payloads, including owned resources. +/// The ordinary context-free callback conversion traits remain independent. +#[cfg(feature = "runtime")] +pub trait VmScriptValue: Sized { + fn schema() -> crate::VmResult; + fn into_value(self, context: &mut ScriptValueContext<'_>) -> crate::VmResult; + fn from_value(vm: &mut crate::Vm, value: crate::Value) -> crate::VmResult; +} + +/// Typed callback arguments converted only after Store/callable admission. +#[cfg(feature = "runtime")] +pub trait VmScriptArgs: Sized { + fn schemas() -> crate::VmResult>; + fn into_values( + self, + context: &mut ScriptValueContext<'_>, + ) -> crate::VmResult>; +} + +/// A callback's resource publications. The invocation retains its typed journal +/// through execution so an abort can reclaim only abandoned publications. +#[cfg(feature = "runtime")] +pub struct ScriptValueContext<'a> { + pub(crate) vm: &'a mut crate::Vm, + pub(crate) pending: Vec, +} + +#[cfg(feature = "runtime")] +pub(crate) struct PendingScriptResource { + pub(crate) handle: crate::ResourceHandle, + pub(crate) key: &'static str, + pub(crate) close: fn(&mut crate::Vm, crate::ResourceHandle) -> crate::VmResult<()>, +} diff --git a/src/vm/host.rs b/src/vm/host.rs index 64686ad2..4b91ddb9 100644 --- a/src/vm/host.rs +++ b/src/vm/host.rs @@ -435,6 +435,46 @@ fn addressable_host_resource_key(schema: &HostTypeSchema) -> Option<(ResourceTyp } } +/// Precisely supported union resource paths: Result/Optional nodes leading to +/// a direct resource or a resource-free leaf. Other aggregates remain refused. +fn addressable_result_resources(schema: &HostTypeSchema) -> bool { + fn branch(schema: &HostTypeSchema, depth: u8) -> bool { + if depth > MAX_REGISTRATION_SCHEMA_DEPTH { + return false; + } + match schema { + HostTypeSchema::Result { ok, err } => branch(ok, depth + 1) && branch(err, depth + 1), + HostTypeSchema::Optional(inner) => branch(inner, depth + 1), + HostTypeSchema::Resource(_) => true, + _ => host_schema_contains_resource(schema) == Ok(false), + } + } + match schema { + HostTypeSchema::Result { .. } => branch(schema, 0), + HostTypeSchema::Optional(inner) => addressable_result_resources(inner), + _ => false, + } +} + +fn active_result_resource<'a>( + schema: &HostTypeSchema, + value: &'a Value, +) -> VmResult> { + match schema { + HostTypeSchema::Result { ok, err } => { + let (tag, payload) = crate::result::payload(value)?; + active_result_resource(if tag { ok } else { err }, payload) + } + HostTypeSchema::Optional(_) if matches!(value, Value::Null) => Ok(None), + HostTypeSchema::Optional(inner) => active_result_resource(inner, value), + HostTypeSchema::Resource(key) => Ok(Some((key.clone(), value))), + _ if !host_schema_contains_resource(schema).map_err(VmError::HostError)? => Ok(None), + _ => Err(VmError::HostError( + "Result resource branch is not addressable".into(), + )), + } +} + /// Registration-time passing-mode contract of one catalog schema. /// /// `owned_value_transfer` is set only by @@ -480,7 +520,10 @@ fn validate_registration_passing( param.name, param.schema, )); } - if owned_value_transfer && addressable_host_resource_key(¶m.schema).is_none() { + if owned_value_transfer + && addressable_host_resource_key(¶m.schema).is_none() + && !addressable_result_resources(¶m.schema) + { return Err(format!( "resource-passing parameter '{}' schema {:#?} is not directly addressable by the \ handle ABI", @@ -493,6 +536,7 @@ fn validate_registration_passing( if return_has_resource && owned_value_transfer && addressable_host_resource_key(&schema.return_type).is_none() + && !addressable_result_resources(&schema.return_type) { return Err(format!( "exact return schema {:#?} contains a resource nested inside an aggregate; only \ @@ -513,7 +557,8 @@ fn validate_registration_passing( fn schema_requires_guard(schema: &HostImportSchema) -> bool { schema.params.iter().any(|param| { param.passing != HostParamPassing::Value - && addressable_host_resource_key(¶m.schema).is_some() + && (addressable_host_resource_key(¶m.schema).is_some() + || addressable_result_resources(¶m.schema)) }) } @@ -644,6 +689,22 @@ impl ExactHostCallContract { param.name, param.schema, ))); } + if addressable_result_resources(¶m.schema) { + let value = args.get(index).ok_or_else(|| { + invalid_resource_handle_error(format!( + "exact host call is missing argument at index {index}" + )) + })?; + if let Some((key, value)) = active_result_resource(¶m.schema, value)? { + specs.push(ExactResourceSpec { + arg_index: index, + handle: resource_handle_argument(value)?, + key, + mode, + }); + } + continue; + } let Some((key, optional)) = addressable_host_resource_key(¶m.schema) else { return Err(VmError::HostError(format!( "resource-passing parameter '{}' schema {:#?} is not directly addressable by \ @@ -2059,6 +2120,27 @@ fn validate_coarse_host_value(value: &Value, expected: ValueType) -> VmResult<() Err(VmError::TypeMismatch(expected)) } +impl Vm { + /// Validates a generated typed guest value against this VM's live catalog + /// and resource arena before the adapter publishes it. + #[doc(hidden)] + pub fn validate_guest_result_value( + &self, + value: &Value, + schema: &HostTypeSchema, + ) -> VmResult<()> { + schema + .validate() + .map_err(|error| VmError::HostError(error.to_string()))?; + validate_host_value( + value, + schema, + self.program(), + self.host.execution_scope.resources(), + ) + } +} + pub(crate) fn validate_host_call_return( values: &CallReturn, expected: Option, diff --git a/src/vm/host_extension.rs b/src/vm/host_extension.rs index 6f824f67..f1cd5336 100644 --- a/src/vm/host_extension.rs +++ b/src/vm/host_extension.rs @@ -705,24 +705,41 @@ fn collect_schema_resource_keys(schema: &HostTypeSchema, seen: &mut Vec Vec { - let mut expected = Vec::new(); - for param in &schema.params { - if let HostTypeSchema::Resource(key) = ¶m.ty { - match param.passing { - HostParamPassing::Borrow => expected.push(ResourceEffect::borrow(key.clone())), - HostParamPassing::BorrowMut => { - expected.push(ResourceEffect::borrow_mut(key.clone())) - } - HostParamPassing::TakeOwned => { - expected.push(ResourceEffect::take_owned(key.clone())) - } - HostParamPassing::Value => {} + // A Result statically declares both alternatives, including repeated keys. + // Container and callable mentions do not confer an active payload effect. + fn visit( + ty: &HostTypeSchema, + effect: &dyn Fn(ResourceTypeKey) -> Option, + out: &mut Vec, + ) { + match ty { + HostTypeSchema::Resource(key) => out.extend(effect(key.clone())), + HostTypeSchema::Result { ok, err } => { + visit(ok, effect, out); + visit(err, effect, out); } + HostTypeSchema::Optional(inner) => visit(inner, effect, out), + _ => {} } } - if let HostTypeSchema::Resource(key) = &schema.return_type { - expected.push(ResourceEffect::create(key.clone())); + let mut expected = Vec::new(); + for param in &schema.params { + visit( + ¶m.ty, + &|key| match param.passing { + HostParamPassing::Borrow => Some(ResourceEffect::borrow(key)), + HostParamPassing::BorrowMut => Some(ResourceEffect::borrow_mut(key)), + HostParamPassing::TakeOwned => Some(ResourceEffect::take_owned(key)), + HostParamPassing::Value => None, + }, + &mut expected, + ); } + visit( + &schema.return_type, + &|key| Some(ResourceEffect::create(key)), + &mut expected, + ); expected } @@ -770,6 +787,10 @@ fn collect_named_structs_from_type( collect_named_structs_from_type(&field.ty, seen, builder)?; } } + HostTypeSchema::Result { ok, err } => { + collect_named_structs_from_type(ok, seen, builder)?; + collect_named_structs_from_type(err, seen, builder)?; + } HostTypeSchema::Array(inner) | HostTypeSchema::Map(inner) | HostTypeSchema::Optional(inner) => { @@ -1491,6 +1512,93 @@ mod tests { register_catalog_function(registry, catalog, name, schema, || Box::new(Noop)) } + #[test] + fn result_resource_effects_preserve_multiplicity_modes_and_callable_boundary() { + use crate::host_api::HostParamPassing; + let key = ResourceTypeKey::new("test.result").unwrap(); + let resource = HostTypeSchema::Resource(key.clone()); + let result = HostTypeSchema::Result { + ok: Box::new(resource.clone()), + err: Box::new(HostTypeSchema::Optional(Box::new(resource.clone()))), + }; + for (passing, effect) in [ + ( + HostParamPassing::Borrow, + ResourceEffect::borrow(key.clone()), + ), + ( + HostParamPassing::BorrowMut, + ResourceEffect::borrow_mut(key.clone()), + ), + ( + HostParamPassing::TakeOwned, + ResourceEffect::take_owned(key.clone()), + ), + ] { + let schema = HostFunctionSchema::with_return( + "test::result", + vec![HostParamSchema::with_passing( + "value", + result.clone(), + passing, + )], + result.clone(), + ); + assert_eq!( + expected_guest_resource_effects(&schema), + vec![ + effect.clone(), + effect, + ResourceEffect::create(key.clone()), + ResourceEffect::create(key.clone()) + ] + ); + } + let schema = HostFunctionSchema::with_return( + "test::result", + vec![HostParamSchema::value("value", result.clone())], + HostTypeSchema::Callable { + params: vec![result], + result: Box::new(resource.clone()), + }, + ); + assert!(expected_guest_resource_effects(&schema).is_empty()); + for unsupported in [ + HostTypeSchema::Array(Box::new(resource.clone())), + HostTypeSchema::Map(Box::new(resource)), + ] { + let schema = HostFunctionSchema::with_return("test::result", vec![], unsupported); + assert!(expected_guest_resource_effects(&schema).is_empty()); + } + } + + #[test] + fn result_named_collector_preserves_duplicate_body_conflicts() { + use crate::host_api::HostStructField; + let named = HostTypeSchema::Named { + name: "NestedResult".into(), + fields: vec![HostStructField::new("value", HostTypeSchema::Int)], + }; + let mut seen = Vec::new(); + let mut builder = HostApiBuilder::new(); + let mut schema = HostTypeSchema::Result { + ok: Box::new(named.clone()), + err: Box::new(named), + }; + collect_named_structs_from_type(&schema, &mut seen, &mut builder).unwrap(); + assert_eq!(seen.len(), 1); + if let HostTypeSchema::Result { err, .. } = &mut schema { + *err = Box::new(HostTypeSchema::Named { + name: "NestedResult".into(), + fields: vec![HostStructField::new("value", HostTypeSchema::String)], + }); + } + assert!(matches!( + collect_named_structs_from_type(&schema, &mut seen, &mut builder), + Err(HostApiCatalogError::StructFieldMismatch { .. }) + )); + } + #[test] fn catalog_registration_accepts_the_selected_full_schema() { let catalog = catalog_with_function("demo::selected"); diff --git a/src/vm/instance.rs b/src/vm/instance.rs index a5dd193a..f5852778 100644 --- a/src/vm/instance.rs +++ b/src/vm/instance.rs @@ -79,6 +79,9 @@ pub(crate) struct Instance { pub(crate) run_depth: usize, pub(crate) max_script_call_depth: usize, pub(crate) host_return: Option, + // Sticky across interpreter reset while the old scope is still closing. + // Only the facade's completed scope replacement clears this quarantine. + pub(crate) callback_publication_unresolved: bool, pub(crate) queued_callables: VecDeque, pub(crate) completed_callable_results: VecDeque, pub(crate) owned_callables: Vec>, @@ -122,6 +125,7 @@ impl Instance { run_depth: 0, max_script_call_depth: DEFAULT_MAX_SCRIPT_CALL_DEPTH, host_return: None, + callback_publication_unresolved: false, queued_callables: VecDeque::new(), completed_callable_results: VecDeque::new(), owned_callables: Vec::new(), @@ -193,6 +197,7 @@ impl Instance { || self.active_operand_stack_base_cache != 0 || self.call_depth != 0 || self.host_return.is_some() + || self.callback_publication_unresolved || !self.queued_callables.is_empty() || !self.completed_callable_results.is_empty() || self.draining_queued_callables diff --git a/src/vm/map_iter.rs b/src/vm/map_iter.rs index c956dec9..f24d60cf 100644 --- a/src/vm/map_iter.rs +++ b/src/vm/map_iter.rs @@ -18,6 +18,16 @@ pub(crate) struct MapIteratorState { } impl MapIteratorState { + /// Non-owning projection for the SDK's pre-abort ownership inspection. + /// Includes the iterator's owner and separately retained current values. + pub(super) fn publication_roots(&self) -> (&SharedMap, Option<&Value>, Option<&Value>) { + ( + self.iter.borrow_owner(), + self.current_key.as_ref(), + self.current_value.as_ref(), + ) + } + pub(crate) fn new(map: SharedMap) -> Self { Self { iter: OwnedMapIter::new(map, |map| map.iter()), diff --git a/src/vm/mod.rs b/src/vm/mod.rs index e143aead..80127981 100644 --- a/src/vm/mod.rs +++ b/src/vm/mod.rs @@ -1390,6 +1390,9 @@ impl Vm { callable_maintenance::after_registration(entries, &mut self.host.module_state_store); }); self.engine.reset_runtime_state(&self.program); + if !self.host.scope_reset_pending { + self.instance.callback_publication_unresolved = false; + } Ok(()) } @@ -1419,18 +1422,34 @@ impl Vm { /// Callers that maintain a VM pool can use this method with their own /// waker instead of releasing the VM while its old scope is Closing. pub fn poll_reset_for_reuse(&mut self, cx: &mut Context<'_>) -> Poll> { + let was_pending = self.host.scope_reset_pending; match self.host.poll_reset_execution_scope(cx) { Poll::Pending => Poll::Pending, - Poll::Ready(Ok(())) => Poll::Ready(Ok(())), + Poll::Ready(Ok(())) => { + if was_pending && !self.host.scope_reset_pending { + self.instance.callback_publication_unresolved = false; + } + Poll::Ready(Ok(())) + } Poll::Ready(Err(error)) => Poll::Ready(Err(error)), } } + fn ensure_callback_publication_resolved(&self) -> VmResult<()> { + if self.instance.callback_publication_unresolved { + return Err(VmError::InvalidFrameState( + "callback publication ownership unresolved; explicit shutdown/reset required", + )); + } + Ok(()) + } + fn ensure_scope_ready(&mut self) -> VmResult<()> { validate_frame_allocation_limits(&self.program)?; if self.instance.shutdown { return Err(VmError::InvalidFrameState("vm is shut down")); } + self.ensure_callback_publication_resolved()?; if let Some(error) = self.host.scope_reset_error().cloned() { return Err(VmError::ExecutionScope(error)); } @@ -3781,6 +3800,7 @@ impl Vm { } pub fn drain_callable_queue(&mut self) -> VmResult> { + self.ensure_callback_publication_resolved()?; if self.instance.draining_queued_callables { return Err(VmError::InvalidFrameState( "callable queue is already being drained", @@ -4040,8 +4060,12 @@ impl Vm { /// Takes the next result from the callback completion queue. If no queued /// callback result exists, this also retains the legacy fallback to the - /// current host return slot. + /// current host return slot. Unresolved callback ownership retains both + /// queues until explicit scope cleanup; no result can be taken then. pub fn take_callable_result(&mut self) -> Option { + if self.instance.callback_publication_unresolved { + return None; + } self.instance .completed_callable_results .pop_front() diff --git a/src/vm/store.rs b/src/vm/store.rs index 7e9c6579..f6d99646 100644 --- a/src/vm/store.rs +++ b/src/vm/store.rs @@ -4,10 +4,14 @@ use std::sync::atomic::{AtomicBool, Ordering}; use crate::Value; use crate::compiler::TypeSchema; +use crate::host_api::HostTypeSchema; +use crate::result::{PendingScriptResource, ScriptValueContext, VmScriptArgs, VmScriptValue}; use super::execution_scope::ExecutionScopeError; use super::{EpochCheckpoint, EpochHandle, FuelCheckpoint, Vm, VmError, VmResult, VmStatus}; +mod publication; + /// Lightweight Wasmtime-style store wrapper for VM state and host context data. pub struct Store { vm: Vm, @@ -76,11 +80,7 @@ impl Clone for ScriptCallback { } } -impl ScriptCallback -where - Args: ScriptArgs, - Ret: ScriptResult, -{ +impl ScriptCallback { pub fn schema(&self) -> Option<&TypeSchema> { self.schema.as_ref() } @@ -92,7 +92,9 @@ where pub fn unsubscribe(&self) { self.subscription.store(false, Ordering::Release); } +} +impl ScriptCallback { pub fn call(&self, store: &mut Store, args: Args) -> VmResult { let invocation = self.prepare(args)?; store.validate_invocation(&invocation)?; @@ -131,6 +133,320 @@ where } } +impl ScriptValueContext<'_> { + /// Inserts one owned value through the normal typed publication authority. + /// Conversion/admission failure rolls this insertion back; after admission + /// the journal reclaims only abandoned owners when invocation completes. + pub fn publish_owned( + &mut self, + value: crate::ResourceOwned, + ) -> VmResult { + let value = crate::result::publish_owned( + self.vm, + value, + & as VmScriptValue>::schema()?, + Ok, + )?; + let handle = crate::result::resource_handle(&value)?; + self.pending.push(PendingScriptResource { + handle, + key: T::KEY, + close: close_unpublished::, + }); + Ok(value) + } + + fn rollback(&mut self, primary: VmError) -> VmError { + let mut failures = Vec::new(); + for pending in self.pending.drain(..).rev() { + if let Err(error) = (pending.close)(self.vm, pending.handle) { + failures.push(error.to_string()); + } + } + if failures.is_empty() { + primary + } else { + VmError::HostError(format!( + "{primary}; callback publication rollback: {}", + failures.join("; ") + )) + } + } +} + +fn close_unpublished( + vm: &mut Vm, + handle: crate::ResourceHandle, +) -> VmResult<()> { + // Frame admission can reject after the normal abort protocol retired the + // entry. Never reinterpret a stale handle as a new generation to close. + let key = crate::ResourceTypeKey::new(T::KEY) + .map_err(|error| VmError::HostError(error.to_string()))?; + if vm + .host_context() + .typed_resource_with_key::(handle, &key) + .is_ok() + { + vm.host_context() + .close_resource::(handle, crate::ResourceCloseReason::Requested) + .map_err(|error| VmError::HostError(error.to_string()))?; + } + Ok(()) +} + +impl VmScriptValue for crate::ResourceOwned { + fn schema() -> VmResult { + crate::ResourceTypeKey::new(T::KEY) + .map(HostTypeSchema::Resource) + .map_err(|error| VmError::HostError(error.to_string())) + } + fn into_value(self, context: &mut ScriptValueContext<'_>) -> VmResult { + context.publish_owned(self) + } + fn from_value(vm: &mut Vm, value: Value) -> VmResult { + let key = crate::ResourceTypeKey::new(T::KEY) + .map_err(|error| VmError::HostError(error.to_string()))?; + vm.host_context() + .take_resource_with_key::(crate::result::resource_handle(&value)?, &key) + .map(crate::ResourceOwned::new) + .map_err(|error| VmError::HostError(error.to_string())) + } +} + +macro_rules! impl_vm_script_scalar { + ($ty:ty, $schema:ident) => { + impl VmScriptValue for $ty { + fn schema() -> VmResult { + Ok(HostTypeSchema::$schema) + } + fn into_value(self, _: &mut ScriptValueContext<'_>) -> VmResult { + Ok(crate::IntoVmValue::into_vm_value(self)) + } + fn from_value(_: &mut Vm, value: Value) -> VmResult { + crate::FromVmValue::from_vm_value(&value, "callback result") + } + } + }; +} +impl_vm_script_scalar!(i64, Int); +impl_vm_script_scalar!(bool, Bool); +impl_vm_script_scalar!(String, String); +impl_vm_script_scalar!((), Null); + +impl VmScriptValue for crate::GuestResult { + fn schema() -> VmResult { + Ok(HostTypeSchema::Result { + ok: Box::new(T::schema()?), + err: Box::new(E::schema()?), + }) + } + fn into_value(self, context: &mut ScriptValueContext<'_>) -> VmResult { + match self { + Self::Ok(value) => Ok(crate::result::carrier(true, value.into_value(context)?)), + Self::Err(value) => Ok(crate::result::carrier(false, value.into_value(context)?)), + } + } + fn from_value(vm: &mut Vm, value: Value) -> VmResult { + vm.validate_guest_result_value(&value, &Self::schema()?)?; + let (tag, payload) = crate::result::payload(&value)?; + if tag { + T::from_value(vm, payload.clone()).map(Self::Ok) + } else { + E::from_value(vm, payload.clone()).map(Self::Err) + } + } +} + +impl VmScriptValue for Option { + fn schema() -> VmResult { + Ok(HostTypeSchema::Optional(Box::new(T::schema()?))) + } + fn into_value(self, context: &mut ScriptValueContext<'_>) -> VmResult { + self.map_or(Ok(Value::Null), |value| value.into_value(context)) + } + fn from_value(vm: &mut Vm, value: Value) -> VmResult { + if matches!(value, Value::Null) { + Ok(None) + } else { + T::from_value(vm, value).map(Some) + } + } +} + +impl VmScriptArgs for () { + fn schemas() -> VmResult> { + Ok(Vec::new()) + } + fn into_values(self, _: &mut ScriptValueContext<'_>) -> VmResult> { + Ok(Vec::new()) + } +} +macro_rules! impl_vm_script_args { + ($($name:ident),+) => { + impl<$($name: VmScriptValue),+> VmScriptArgs for ($($name,)+) { + fn schemas() -> VmResult> { Ok(vec![$($name::schema()?),+]) } + #[allow(non_snake_case)] + fn into_values(self, context: &mut ScriptValueContext<'_>) -> VmResult> { + let ($($name,)+) = self; + Ok(vec![$($name.into_value(context)?),+]) + } + } + }; +} +impl_vm_script_args!(A); +impl_vm_script_args!(A, B); +impl_vm_script_args!(A, B, C); +impl_vm_script_args!(A, B, C, D); + +impl ScriptCallback { + /// Synchronously invokes an RSS callback using live-VM owned conversions. + /// Context-free prepare/start/queued callbacks retain their existing API. + /// A native handler retaining an owned resource in opaque Rust state must + /// take it through the typed resource API (or a `ResourceOwned` parameter). + /// Copying an encoded integer token alone does not transfer ownership. + /// If bounded ownership inspection fails, execution roots are retained and + /// the VM requires explicit shutdown/reset; no successful abort is claimed. + pub fn call_with_vm(&self, store: &mut Store, args: Args) -> VmResult { + let invocation = QueuedScriptInvocation { + registry: Arc::clone(&self.registry), + subscription: Arc::clone(&self.subscription), + callable: self.callable.clone(), + args: Vec::new(), + }; + store.validate_invocation(&invocation)?; + store.vm.ensure_scope_ready()?; + // Reuse the ordinary callback's prototype/identity/schema authority. + store.vm_script_callback::(self.callable.clone())?; + if !store.vm.instance.execution_frames.is_empty() { + return Err(VmError::InvalidFrameState( + "host invocation requires a halted VM", + )); + } + let mut context = ScriptValueContext { + vm: &mut store.vm, + pending: Vec::new(), + }; + let values = match args.into_values(&mut context).and_then(|values| { + let schemas = Args::schemas()?; + if values.len() != schemas.len() { + return Err(VmError::TypeMismatch("callback argument count")); + } + for (value, schema) in values.iter().zip(&schemas) { + context.vm.validate_guest_result_value(value, schema)?; + } + Ok(values) + }) { + Ok(values) => values, + Err(error) => return Err(context.rollback(error)), + }; + let argc = match u8::try_from(values.len()) { + Ok(argc) => argc, + Err(_) => { + return Err(context.rollback(VmError::InvalidFrameState("too many arguments"))); + } + }; + let stack_base = context.vm.instance.stack.len(); + let frame_count = context.vm.instance.execution_frames.len(); + let local_base = context.vm.instance.locals.len(); + context.vm.instance.stack.push(self.callable.clone()); + context.vm.instance.stack.extend(values); + context.vm.instance.host_return = None; + // This is the same frame-entry seam as Vm::start_callable. The narrow + // RSS-only hook separates rejection from execution without guessing + // from errors or closing guest-owned entries after execution starts. + if let Err(error) = context.vm.execute_call_value(argc, None) { + context.vm.abort_host_invocation(stack_base, frame_count); + return Err(context.rollback(error)); + } + if let Some(frame) = context.vm.instance.execution_frames.last_mut() { + frame.continuation = super::FrameContinuation::ReturnToHost; + } + let result = match context.vm.run_internal(None, false) { + Ok(VmStatus::Halted) => { + if !context.pending.is_empty() { + // Keep the actual return rooted until classification is + // complete: it and surviving captures retain their owners. + let abandoned = publication::abandoned( + context.vm, + &context.pending, + stack_base, + local_base, + frame_count, + ) + .map_err(|projection| { + context.vm.instance.callback_publication_unresolved = true; + VmError::HostError(format!( + "callback publication ownership unresolved (execution roots retained; explicit shutdown/reset required): {projection}" + )) + })?; + for pending in context.pending.drain(..).rev() { + if abandoned.contains(&pending.handle) { + // A close failure stays tracked in the existing + // resource table; preserve success and try siblings. + let _ = (pending.close)(context.vm, pending.handle); + } + } + } + context + .vm + .instance + .host_return + .take() + .ok_or(VmError::InvalidFrameState( + "host invocation completed without a result", + )) + } + Ok(VmStatus::Yielded) => Err(VmError::InvalidFrameState("host invocation yielded")), + Ok(VmStatus::Waiting(_)) => { + Err(VmError::InvalidFrameState("host invocation is waiting")) + } + Err(error) => Err(error), + }; + let value = match result { + Ok(value) => value, + Err(error) => { + if context.pending.is_empty() { + context.vm.abort_host_invocation(stack_base, frame_count); + return Err(error); + } + let abandoned = match publication::abandoned( + context.vm, + &context.pending, + stack_base, + local_base, + frame_count, + ) { + Ok(abandoned) => abandoned, + Err(projection) => { + // Preserve the actual topology when ownership cannot + // be proven. Do not drain roots or claim reclamation. + context.vm.instance.callback_publication_unresolved = true; + return Err(VmError::HostError(format!( + "{error}; callback publication ownership unresolved (execution roots retained; explicit shutdown/reset required): {projection}" + ))); + } + }; + // Project before draining roots, then let the ordinary abort + // cancel waiting operations before beginning resource close. + // Pending closes remain in the existing table for real polls. + context.vm.abort_host_invocation(stack_base, frame_count); + for pending in context.pending.drain(..).rev() { + if abandoned.contains(&pending.handle) { + // Keep the primary transport error and still try + // eligible siblings after a close failure. + let _ = (pending.close)(context.vm, pending.handle); + } + } + return Err(error); + } + }; + context + .vm + .validate_guest_result_value(&value, &Ret::schema()?)?; + Ret::from_value(context.vm, value) + } +} + impl ScriptArgs for () { const ARITY: Option = Some(0); @@ -276,6 +592,95 @@ impl ScriptResult for bool { } } +impl IntoScriptValue for crate::GuestResult { + fn into_script_value(self) -> Value { + match self { + Self::Ok(value) => crate::result::carrier(true, value.into_script_value()), + Self::Err(error) => crate::result::carrier(false, error.into_script_value()), + } + } + + fn schema() -> Option { + Some(TypeSchema::Result { + ok: Box::new(T::schema()?), + err: Box::new(E::schema()?), + }) + } +} + +impl ScriptResult for crate::GuestResult { + fn from_value(value: Value) -> VmResult { + let (tag, payload) = crate::result::payload(&value)?; + if tag { + T::from_value(payload.clone()).map(Self::Ok) + } else { + E::from_value(payload.clone()).map(Self::Err) + } + } + + fn schema() -> Option { + Some(TypeSchema::Result { + ok: Box::new(T::schema()?), + err: Box::new(E::schema()?), + }) + } +} + +impl ScriptResult for String { + fn from_value(value: Value) -> VmResult { + match value { + Value::String(text) => Ok(text.to_string()), + _ => Err(VmError::TypeMismatch("string callback result")), + } + } + fn schema() -> Option { + Some(TypeSchema::String) + } +} + +impl IntoScriptValue for Option { + fn into_script_value(self) -> Value { + self.map_or(Value::Null, T::into_script_value) + } + fn schema() -> Option { + Some(TypeSchema::Optional(Box::new(T::schema()?))) + } +} + +impl ScriptResult for Option { + fn from_value(value: Value) -> VmResult { + if matches!(value, Value::Null) { + Ok(None) + } else { + T::from_value(value).map(Some) + } + } + fn schema() -> Option { + Some(TypeSchema::Optional(Box::new(T::schema()?))) + } +} + +impl IntoScriptValue for Vec { + fn into_script_value(self) -> Value { + Value::array(self.into_iter().map(T::into_script_value).collect()) + } + fn schema() -> Option { + Some(TypeSchema::Array(Box::new(T::schema()?))) + } +} + +impl ScriptResult for Vec { + fn from_value(value: Value) -> VmResult { + let Value::Array(values) = value else { + return Err(VmError::TypeMismatch("array callback result")); + }; + values.iter().cloned().map(T::from_value).collect() + } + fn schema() -> Option { + Some(TypeSchema::Array(Box::new(T::schema()?))) + } +} + impl Store { pub fn new(mut vm: Vm, data: T) -> Self { let callback_registry = Arc::new(AtomicBool::new(true)); @@ -339,6 +744,16 @@ impl Store { Args: ScriptArgs, Ret: ScriptResult, { + self.build_script_callback(callable, Args::ARITY, Args::schemas(), Ret::schema()) + } + + fn build_script_callback( + &mut self, + callable: Value, + arity: Option, + argument_schemas: Option>, + result_schema: Option, + ) -> VmResult> { if let Some(error) = self.vm.scope_reset_error().cloned() { return Err(VmError::ExecutionScope(error)); } @@ -362,7 +777,7 @@ impl Store { .callable_prototypes .get(value.prototype_id as usize) .ok_or(VmError::InvalidCallablePrototype(value.prototype_id))?; - if let Some(arity) = Args::ARITY + if let Some(arity) = arity && arity != usize::from(prototype.arity) { return Err(VmError::CallableArityMismatch { @@ -372,7 +787,7 @@ impl Store { }); } if let Some(TypeSchema::Callable { params, result }) = prototype.schema.as_ref() { - if let Some(args) = Args::schemas() + if let Some(args) = argument_schemas && (args.len() != params.len() || !args .iter() @@ -381,7 +796,7 @@ impl Store { { return Err(VmError::TypeMismatch("script callback argument schema")); } - if let Some(actual) = Ret::schema() + if let Some(actual) = result_schema && !callback_schema_accepts(result, &actual) { return Err(VmError::TypeMismatch("script callback result schema")); @@ -396,6 +811,52 @@ impl Store { }) } + pub fn vm_script_callback_by_name( + &mut self, + name: &str, + ) -> VmResult> { + let callable = self.resolve_exported_callable(name)?; + self.vm_script_callback(callable) + } + + pub fn vm_script_callback( + &mut self, + callable: Value, + ) -> VmResult> { + let args = Args::schemas()?; + for schema in &args { + schema + .validate() + .map_err(|error| VmError::HostError(error.to_string()))?; + } + let ret = Ret::schema()?; + ret.validate() + .map_err(|error| VmError::HostError(error.to_string()))?; + let callback = self.build_script_callback( + callable, + Some(args.len()), + Some( + args.iter() + .map(HostTypeSchema::to_compiler_schema) + .collect(), + ), + Some(ret.to_compiler_schema()), + )?; + let Value::Callable(value) = &callback.callable else { + return Err(VmError::InvalidCallable); + }; + let prototype = &self.vm.program().callable_prototypes[value.prototype_id as usize]; + if !matches!(prototype.target, super::CallableTarget::ScriptFunction(_)) { + return Err(VmError::InvalidFrameState( + "VM-aware callbacks require an RSS callable", + )); + } + if !matches!(prototype.schema, Some(TypeSchema::Callable { .. })) { + return Err(VmError::TypeMismatch("typed script callback schema")); + } + Ok(callback) + } + pub fn enqueue_callback(&mut self, invocation: QueuedScriptInvocation) -> VmResult<()> { self.validate_invocation(&invocation)?; self.vm.queue_callable_with_subscription( diff --git a/src/vm/store/publication.rs b/src/vm/store/publication.rs new file mode 100644 index 00000000..9265c509 --- /dev/null +++ b/src/vm/store/publication.rs @@ -0,0 +1,449 @@ +//! Invocation-local ownership projection; never a resource authority or registry. +use std::collections::HashMap; +use std::sync::Arc; + +use crate::bytecode::{CallableEnvironment, SharedCaptureCell}; +use crate::compiler::TypeSchema; +use crate::result::PendingScriptResource; +use crate::{ResourceHandle, Value, Vm, VmError, VmResult}; + +const MAX_NODES: usize = 65_536; +const MAX_DEPTH: usize = 64; +const DISCARDED: u8 = 1; +const SURVIVING: u8 = 2; + +#[cfg(test)] +mod tests; +#[cfg(test)] +mod quarantine_tests; + +#[derive(Clone)] +enum Hold { + Value(Value), + Environment(Arc, u32), + Cell(SharedCaptureCell), +} +impl Hold { + fn identity(&self) -> Option<(u8, usize)> { + Some(match self { + Self::Value(Value::Array(v)) => (0, Arc::as_ptr(v) as usize), + Self::Value(Value::Map(v)) => (1, Arc::as_ptr(v) as usize), + Self::Value(Value::Callable(v)) => (2, Arc::as_ptr(v) as usize), + Self::Environment(v, _) => (3, Arc::as_ptr(v) as usize), + Self::Cell(v) => (4, Arc::as_ptr(v) as usize), + _ => return None, + }) + } + fn strong_count(&self) -> usize { + match self { + Self::Value(Value::Array(v)) => Arc::strong_count(v), + Self::Value(Value::Map(v)) => Arc::strong_count(v), + Self::Value(Value::Callable(v)) => Arc::strong_count(v), + Self::Environment(v, _) => Arc::strong_count(v), + Self::Cell(v) => Arc::strong_count(v), + _ => 0, + } + } +} +struct Node { + hold: Hold, + incoming: usize, + roots: u8, + children: Vec, + handle: Option, +} +struct Graph<'a> { + vm: &'a Vm, + journal: &'a [PendingScriptResource], + nodes: Vec, + identities: HashMap<(u8, usize), usize>, + edges: usize, +} +impl Graph<'_> { + fn add( + &mut self, + hold: Hold, + schema: Option<&TypeSchema>, + incoming: bool, + roots: u8, + depth: usize, + ) -> VmResult { + self.edges += 1; + if self.edges > MAX_NODES || depth > MAX_DEPTH { + return Err(VmError::InvalidFrameState("callback ownership graph limit")); + } + let identity = hold.identity(); + if let Some(index) = identity.and_then(|id| self.identities.get(&id).copied()) { + let node = &mut self.nodes[index]; + node.incoming += usize::from(incoming); + node.roots |= roots; + return Ok(index); + } + let mut owned_schema = schema.cloned(); + for _ in 0..MAX_DEPTH { + owned_schema = match owned_schema { + Some(TypeSchema::Optional(inner)) => Some(*inner), + Some(TypeSchema::Named(ref name, ref args)) => super::super::NamedStructLookup { + host: &self.vm.host.named_struct_schemas, + guest: &self.vm.program.named_struct_decls, + } + .body(name, args) + .map(|(body, _)| body), + other => { + owned_schema = other; + break; + } + }; + } + let schema = owned_schema.as_ref(); + let handle = match &hold { + Hold::Value(Value::Int(raw)) => self + .journal + .iter() + .find(|entry| { + // Compare only this invocation's typed publications. An Int + // schema is data, even when its bits equal a journaled handle. + entry.handle.raw() == *raw as u64 + && match schema { + Some(TypeSchema::Resource(key)) => key.as_str() == entry.key, + None | Some(TypeSchema::Unknown | TypeSchema::GenericParam(_)) => true, + _ => false, + } + }) + .map(|entry| entry.handle), + _ => None, + }; + let index = self.nodes.len(); + self.nodes.push(Node { + hold, + incoming: usize::from(incoming), + roots, + children: Vec::new(), + handle, + }); + if let Some(id) = identity { + self.identities.insert(id, index); + } + // Snapshot holds are counted separately from actual graph edges. These + // temporary expansion clones are gone before external-root detection. + let held = self.nodes[index].hold.clone(); + let mut children = Vec::new(); + match held { + Hold::Value(Value::Array(values)) => { + for (ordinal, value) in values.iter().enumerate() { + let child_schema = match schema { + Some(TypeSchema::Array(inner)) => Some(inner.as_ref()), + Some(TypeSchema::ArrayTuple(items)) => items.get(ordinal), + Some(TypeSchema::ArrayTupleRest { prefix, rest }) => { + Some(prefix.get(ordinal).unwrap_or(rest)) + } + _ => None, + }; + children.push(self.add( + Hold::Value(value.clone()), + child_schema, + true, + 0, + depth + 1, + )?); + } + } + Hold::Value(Value::Map(values)) => { + for (key, value) in values.iter() { + let child_schema = match (schema, key) { + (Some(TypeSchema::Result { ok, err }), Value::String(name)) + if name.as_str() == "payload" => + { + match values.get(&Value::string("tag")) { + Some(Value::Bool(true)) => Some(ok.as_ref()), + Some(Value::Bool(false)) => Some(err.as_ref()), + _ => None, + } + } + (Some(TypeSchema::Object(fields)), Value::String(name)) => { + fields.get(name.as_str()) + } + (Some(TypeSchema::Map(inner)), _) => Some(inner.as_ref()), + _ => None, + }; + children.push(self.add(Hold::Value(key.clone()), None, true, 0, depth + 1)?); + children.push(self.add( + Hold::Value(value.clone()), + child_schema, + true, + 0, + depth + 1, + )?); + } + } + Hold::Value(Value::Callable(callable)) => { + if let Some(env) = &callable.env { + children.push(self.add( + Hold::Environment(env.clone(), callable.prototype_id), + None, + true, + 0, + depth + 1, + )?); + } + } + Hold::Environment(env, prototype_id) => { + let cells = env + .cells + .lock() + .map_err(|_| VmError::InvalidFrameState("callback capture lock is poisoned"))?; + for (ordinal, cell) in cells.iter().enumerate() { + let schema = self + .vm + .program + .callable_prototypes + .get(prototype_id as usize) + .and_then(|prototype| prototype.capture_source_slots.get(ordinal)) + .and_then(|slot| { + self.vm + .program + .type_map + .as_ref()? + .local_schemas + .get(usize::from(*slot)) + }) + .and_then(Option::as_ref); + children.push(self.add( + Hold::Cell(cell.clone()), + schema, + true, + 0, + depth + 1, + )?); + } + } + Hold::Cell(cell) => { + let value = cell + .lock() + .map_err(|_| VmError::InvalidFrameState("callback capture lock is poisoned"))?; + children.push(self.add(Hold::Value(value.clone()), schema, true, 0, depth + 1)?); + } + _ => {} + } + self.nodes[index].children = children; + Ok(index) + } + fn value(&mut self, value: &Value, schema: Option<&TypeSchema>, roots: u8) -> VmResult<()> { + self.add(Hold::Value(value.clone()), schema, true, roots, 0) + .map(|_| ()) + } + fn abandoned(mut self) -> Vec { + let mut pending = Vec::new(); + for (index, node) in self.nodes.iter_mut().enumerate() { + // A live Arc outside the projected VM graph is a surviving owner: + // e.g. a host-retained callback, container, environment or cell. + // Weak identity records are observations, never owning roots. + if node.hold.strong_count() > node.incoming + 1 { + node.roots |= SURVIVING; + } + if node.roots != 0 { + pending.push((index, node.roots)); + } + } + let mut seen = vec![0u8; self.nodes.len()]; + while let Some((index, roots)) = pending.pop() { + let new = roots & !seen[index]; + if new == 0 { + continue; + } + seen[index] |= new; + pending.extend(self.nodes[index].children.iter().map(|child| (*child, new))); + } + let protected = self + .nodes + .iter() + .zip(seen) + .filter_map(|(node, roots)| (roots & SURVIVING != 0).then_some(node.handle).flatten()) + .collect::>(); + // The retained journal is itself the invocation's typed owning root. + // A carrier already dropped inside the failed body is abandoned too; + // absence from the disposable Values must not erase that ownership. + self.journal + .iter() + .filter_map(|entry| (!protected.contains(&entry.handle)).then_some(entry.handle)) + .collect() + } +} + +/// Reuse the admitted callable's authoritative schema for deferred arguments. +fn parameters<'a>(vm: &'a Vm, value: &Value) -> Option<&'a [TypeSchema]> { + let Value::Callable(callable) = value else { + return None; + }; + match vm + .program + .callable_prototypes + .get(callable.prototype_id as usize)? + .schema + .as_ref()? + { + TypeSchema::Callable { params, .. } => Some(params), + _ => None, + } +} + +/// Project the exact roots *before* ordinary abort drains them. Only journaled +/// handles can be returned, and their monomorphized close revalidates the live +/// type/key/arena/generation afterward. Opaque native state must take a resource +/// to acquire Rust ownership; retaining a naked encoded integer is not a take. +/// Shared heap values retained by native state are detected by actual Arc edges. +pub(super) fn abandoned( + vm: &Vm, + journal: &[PendingScriptResource], + stack_base: usize, + local_base: usize, + frame_count: usize, +) -> VmResult> { + let mut graph = Graph { + vm, + journal, + nodes: Vec::new(), + identities: HashMap::new(), + edges: 0, + }; + let local_base = vm + .instance + .execution_frames + .get(frame_count) + .map_or(local_base, |frame| local_base.min(frame.local_base)); + for (index, value) in vm.instance.locals.iter().enumerate() { + let schema = if index < local_base { + vm.program + .type_map + .as_ref() + .and_then(|map| map.local_schemas.get(index)) + .and_then(Option::as_ref) + } else { + // Parameter schemas are authoritative; ordinary temporaries and + // operands have no frame-relative schema metadata in this ABI. + vm.instance.execution_frames.iter().rev().find_map(|frame| { + let prototype = vm + .program + .callable_prototypes + .get(frame.prototype_id? as usize)?; + let ordinal = prototype + .parameter_slots + .iter() + .position(|slot| frame.local_base + usize::from(*slot) == index)?; + match prototype.schema.as_ref()? { + TypeSchema::Callable { params, .. } => params.get(ordinal), + _ => None, + } + }) + }; + graph.value( + value, + schema, + if index < local_base { + SURVIVING + } else { + DISCARDED + }, + )?; + } + for (index, value) in vm.instance.stack.iter().enumerate() { + graph.value( + value, + None, + if index < stack_base { + SURVIVING + } else { + DISCARDED + }, + )?; + } + for value in vm + .program + .constants + .iter() + .chain(vm.instance.host_return.iter()) + .chain(vm.instance.completed_callable_results.iter()) + { + graph.value(value, None, SURVIVING)?; + } + for queued in &vm.instance.queued_callables { + graph.value(&queued.callable, None, SURVIVING)?; + let schemas = parameters(vm, &queued.callable); + for (index, value) in queued.args.iter().enumerate() { + graph.value( + value, + schemas.and_then(|schemas| schemas.get(index)), + SURVIVING, + )?; + } + } + if let Some(state) = &vm.instance.invocation { + use super::super::invocation::InvocationPhase; + if let InvocationPhase::EventPending(value) | InvocationPhase::CompletePending(value) = + &state.phase + { + graph.value(value, None, SURVIVING)?; + } + } + if let Some(stream) = &vm.instance.host_stream { + graph.value(&stream.callback, None, SURVIVING)?; + if let Some(value) = &stream.open_callback { + graph.value(value, None, SURVIVING)?; + } + let callback = match stream.item_callback { + super::super::async_host::HostStreamCallback::Event => Some(&stream.callback), + super::super::async_host::HostStreamCallback::Open => stream.open_callback.as_ref(), + }; + let schemas = callback.and_then(|value| parameters(vm, value)); + for (index, value) in stream.item.iter().flatten().enumerate() { + graph.value( + value, + schemas.and_then(|schemas| schemas.get(index)), + SURVIVING, + )?; + } + } + // Include externally held callbacks even if no VM Value points at them. + // Upgrade adds only an observation hold, not an incoming ownership edge. + for weak in &vm.instance.owned_callables { + if let Some(callable) = weak.upgrade() { + graph.add(Hold::Value(Value::Callable(callable)), None, false, 0, 0)?; + } + } + for (index, cell) in &vm.instance.capture_cells { + graph.add( + Hold::Cell(cell.clone()), + None, + true, + if *index < local_base { + SURVIVING + } else { + DISCARDED + }, + 0, + )?; + } + let retained_iterators = vm + .instance + .execution_frames + .iter() + .take(frame_count) + .filter(|frame| frame.prototype_id.is_some()) + .count() + .saturating_add(1); + for (depth, iterators) in vm.instance.map_iterators.iter().enumerate() { + for iterator in iterators.iter().flatten() { + let roots = if depth < retained_iterators { + SURVIVING + } else { + DISCARDED + }; + let (map, key, value) = iterator.publication_roots(); + graph.value(&Value::Map(map.clone()), None, roots)?; + for value in key.into_iter().chain(value) { + graph.value(value, None, roots)?; + } + } + } + Ok(graph.abandoned()) +} diff --git a/src/vm/store/publication/quarantine_tests.rs b/src/vm/store/publication/quarantine_tests.rs new file mode 100644 index 00000000..75d4e85b --- /dev/null +++ b/src/vm/store/publication/quarantine_tests.rs @@ -0,0 +1,306 @@ +use super::*; +use crate::HostResourceType; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + +#[derive(Debug, Default)] +struct CloseState { + begins: AtomicUsize, + polls: AtomicUsize, + drops: AtomicUsize, + ready: AtomicBool, + fail: AtomicBool, +} +#[derive(Debug)] +struct Item(Arc); +impl Drop for Item { + fn drop(&mut self) { + self.0.drops.fetch_add(1, Ordering::SeqCst); + } +} +impl crate::resource::HostResource for Item { + fn resource_type_key() -> Option { + Some(crate::ResourceTypeKey::new(Self::KEY).unwrap()) + } + fn begin_close( + &mut self, + _: crate::ResourceCloseReason, + ) -> crate::resource::ResourceResult { + self.0.begins.fetch_add(1, Ordering::SeqCst); + if self.0.fail.load(Ordering::SeqCst) { + return Err(crate::resource::ResourceError::new( + crate::resource::ResourceErrorCode::ResourceCleanupFailed, + "sdk_quarantine_test", + "intentional close failure", + )); + } + Ok(if self.0.ready.load(Ordering::SeqCst) { + crate::resource::CloseProgress::Ready + } else { + crate::resource::CloseProgress::Pending + }) + } + fn poll_close( + &mut self, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.0.polls.fetch_add(1, Ordering::SeqCst); + if self.0.ready.load(Ordering::SeqCst) { + std::task::Poll::Ready(Ok(())) + } else { + std::task::Poll::Pending + } + } +} +impl crate::HostResourceType for Item { + const KEY: &'static str = "sdk.quarantine_item"; + const DESCRIPTION: &'static str = "quarantine lifecycle fixture"; +} +fn constant_store(success: bool) -> crate::Store { + let source = if success { + "pub fn invoke(value: resource) -> resource { value } pub fn ordinary(value: int) -> int { value + 1 }" + } else { + "pub fn invoke(value: resource) -> resource { let n = 1 / 0; value } pub fn ordinary(value: int) -> int { value + 1 }" + }; + let compiled = crate::compile_source_for_repl(source).unwrap(); + let mut program = compiled.program.with_local_count(compiled.locals); + let mut deep = Value::Null; + for _ in 0..MAX_DEPTH + 1 { + deep = Value::array(vec![deep]); + } + program.constants.push(deep); + let mut store = crate::Store::from_vm(Vm::new(program)); + store.run().unwrap(); + store +} +fn invoke( + store: &mut crate::Store, + state: &Arc, +) -> VmResult> { + store + .vm_script_callback_by_name::<(crate::ResourceOwned,), crate::ResourceOwned>( + "invoke", + )? + .call_with_vm(store, (crate::ResourceOwned::new(Item(state.clone())),)) +} +#[test] +fn callback_publication_success_projection_refusal_preserves_host_return_and_blocks_public_run() { + let mut store = constant_store(true); + let queued = store + .script_callback_by_name::<(i64,), i64>("ordinary") + .unwrap(); + store + .enqueue_callback(queued.prepare((7,)).unwrap()) + .unwrap(); + let state = Arc::new(CloseState::default()); + let error = invoke(&mut store, &state).unwrap_err(); + assert!( + error.to_string().contains("ownership unresolved"), + "{error}" + ); + assert!(store.vm.instance.execution_frames.is_empty()); + let returned = store + .vm + .instance + .host_return + .clone() + .expect("actual return must stay rooted"); + assert_eq!(store.vm.host_context().resource_count(), 1); + assert_eq!(state.begins.load(Ordering::SeqCst), 0); + let run = store.run(); + let direct_run = store.vm_mut().run(); + let second = invoke(&mut store, &state); + eprintln!( + "callback_projection_success unit_constant_fixture=true run={run:?} direct_run={direct_run:?} second={second:?} live={} host_return_preserved={}", + store.vm.host_context().resource_count(), + store.vm.instance.host_return.as_ref() == Some(&returned) + ); + assert!( + run.is_err(), + "Store::run must reject unresolved successful return" + ); + assert!(direct_run.is_err()); + assert!(second.is_err()); + assert_eq!(store.vm.instance.host_return.as_ref(), Some(&returned)); + assert!(store.resume().is_err()); + assert!(store.vm_mut().drain_callable_queue().is_err()); + assert_eq!(store.vm().queued_callable_count(), 1); + assert!(queued.call(&mut store, (7,)).is_err()); + assert!(queued.start(&mut store, (7,)).is_err()); + assert!( + store.vm_mut().take_callable_result().is_none(), + "public result extraction must not drain an unclassified return" + ); + assert_eq!(store.vm.instance.host_return.as_ref(), Some(&returned)); + assert_eq!(store.vm.host_context().resource_count(), 1); + state.ready.store(true, Ordering::SeqCst); + store.vm_mut().shutdown(); + assert_eq!(store.vm.host_context().resource_count(), 0); + assert_eq!(state.begins.load(Ordering::SeqCst), 1); + assert!(store.run().is_err(), "shutdown remains terminal"); +} +#[test] +fn callback_publication_source_created_depth_limit_blocks_public_continuation() { + let source = "pub fn invoke(value: resource) -> resource { let mut graph: array = []; let mut n = 0; while n < 65 { graph = [graph]; n = n + 1; } let bad = 1 / 0; graph; value }"; + let compiled = crate::compile_source_for_repl(source).unwrap(); + let mut store = + crate::Store::from_vm(Vm::new(compiled.program.with_local_count(compiled.locals))); + store.vm_mut().set_fuel(100_000); + store.run().unwrap(); + let state = Arc::new(CloseState::default()); + let error = invoke(&mut store, &state).unwrap_err(); + assert!( + error.to_string().contains("ownership unresolved"), + "{error}" + ); + let frames = store.vm.instance.execution_frames.len(); + let locals = store.vm.instance.locals.clone(); + let run = store.run(); + let second = invoke(&mut store, &state); + eprintln!( + "callback_projection_source_graph admitted=true error={error} run={run:?} second={second:?} frames_before={frames} frames_after={} live={}", + store.vm.instance.execution_frames.len(), + store.vm.host_context().resource_count() + ); + assert!( + run.as_ref() + .is_err_and(|error| error.to_string().contains("ownership unresolved")), + "source-created graph must reject at ownership admission, got {run:?}" + ); + assert_eq!(store.vm.instance.execution_frames.len(), frames); + assert_eq!(store.vm.instance.locals, locals); + assert!(second.is_err()); + assert_eq!(store.vm.host_context().resource_count(), 1); + state.ready.store(true, Ordering::SeqCst); + store.reset_for_reuse().unwrap(); + assert_eq!(store.vm.host_context().resource_count(), 0); + store.run().unwrap(); +} + +#[test] +fn callback_publication_pending_reset_keeps_quarantine_until_real_replacement() { + for success in [true, false] { + let mut store = constant_store(success); + let state = Arc::new(CloseState::default()); + assert!( + invoke(&mut store, &state) + .unwrap_err() + .to_string() + .contains("ownership unresolved") + ); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert!(matches!( + store.poll_reset_for_reuse(&mut cx), + std::task::Poll::Ready(Ok(())) + )); + assert!( + store.vm.instance.callback_publication_unresolved, + "a poll without reset is not replacement" + ); + assert!(store.run().is_err()); + store.reset_for_reuse().unwrap(); + assert!(store.vm().scope_reset_pending()); + assert!( + store.vm.instance.callback_publication_unresolved, + "interpreter reset cannot clear quarantine" + ); + assert!(!store.is_reusable()); + assert_eq!(state.begins.load(Ordering::SeqCst), 1); + assert_eq!(store.vm.host_context().resource_count(), 1); + assert!(store.poll_reset_for_reuse(&mut cx).is_pending()); + assert!(store.vm.instance.callback_publication_unresolved); + assert!(store.run().is_err()); + assert!(store.vm_mut().run().is_err()); + state.ready.store(true, Ordering::SeqCst); + assert!(matches!( + store.poll_reset_for_reuse(&mut cx), + std::task::Poll::Ready(Ok(())) + )); + assert!(!store.vm.instance.callback_publication_unresolved); + assert!(!store.vm().scope_reset_pending()); + assert!(store.is_reusable()); + assert_eq!(state.begins.load(Ordering::SeqCst), 1); + assert!(state.polls.load(Ordering::SeqCst) >= 2); + assert_eq!(state.drops.load(Ordering::SeqCst), 1); + assert_eq!(store.vm.host_context().resource_count(), 0); + store.run().unwrap(); + let ordinary = store + .vm_script_callback_by_name::<(i64,), i64>("ordinary") + .unwrap(); + assert_eq!(ordinary.call_with_vm(&mut store, (7,)).unwrap(), 8); + eprintln!( + "callback_quarantine_reset success={success} pending_preserved=true terminal_replacement=true close_begin=1 drops=1 ordinary_return=8" + ); + } +} + +#[test] +fn callback_publication_failed_cleanup_never_clears_quarantine() { + let mut store = constant_store(true); + let state = Arc::new(CloseState::default()); + assert!(invoke(&mut store, &state).is_err()); + state.fail.store(true, Ordering::SeqCst); + let reset = store.reset_for_reuse().unwrap_err(); + assert!(store.vm.instance.callback_publication_unresolved); + assert!(!store.is_reusable()); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert!(matches!( + store.poll_reset_for_reuse(&mut cx), + std::task::Poll::Ready(Err(_)) + )); + assert!(store.vm.instance.callback_publication_unresolved); + assert!(store.run().is_err()); + assert!(invoke(&mut store, &state).is_err()); + assert_eq!(state.begins.load(Ordering::SeqCst), 1); + assert!(store.reset_for_reuse().is_err()); + assert!(store.vm.instance.callback_publication_unresolved); + eprintln!( + "callback_quarantine_failed_cleanup reset={reset} quarantine_retained=true close_begin=1" + ); +} + +#[test] +fn callback_publication_pending_shutdown_stays_terminal_until_completed_reset() { + let mut store = constant_store(true); + let state = Arc::new(CloseState::default()); + assert!(invoke(&mut store, &state).is_err()); + store.vm_mut().shutdown(); + assert_eq!(store.vm.host_context().resource_count(), 1); + assert!(store.vm.instance.callback_publication_unresolved); + assert!(!store.is_reusable()); + assert!(store.run().is_err()); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert!( + store + .vm_mut() + .execution_scope() + .poll_close(&mut cx) + .is_pending() + ); + assert_eq!(state.begins.load(Ordering::SeqCst), 1); + state.ready.store(true, Ordering::SeqCst); + assert!( + store + .vm_mut() + .execution_scope() + .poll_close(&mut cx) + .is_ready() + ); + assert_eq!(state.begins.load(Ordering::SeqCst), 1); + assert_eq!(state.drops.load(Ordering::SeqCst), 1); + assert_eq!(store.vm.host_context().resource_count(), 0); + assert!( + store.vm.instance.callback_publication_unresolved, + "a closed terminal scope is not a reusable replacement" + ); + assert!(store.run().is_err()); + store.reset_for_reuse().unwrap(); + assert!(!store.vm.instance.callback_publication_unresolved); + store.run().unwrap(); + let ordinary = store + .vm_script_callback_by_name::<(i64,), i64>("ordinary") + .unwrap(); + assert_eq!(ordinary.call_with_vm(&mut store, (10,)).unwrap(), 11); + eprintln!( + "callback_quarantine_shutdown pending_retained=true actual_poll_live=0 reset_replacement=true ordinary_return=11 close_begin=1" + ); +} diff --git a/src/vm/store/publication/tests.rs b/src/vm/store/publication/tests.rs new file mode 100644 index 00000000..127bbd4a --- /dev/null +++ b/src/vm/store/publication/tests.rs @@ -0,0 +1,249 @@ +use super::*; +use crate::{HostResourceType, ResourceCloseReason, ResourceTypeKey}; + +#[derive(Debug)] +struct Item; +impl crate::resource::HostResource for Item { + fn resource_type_key() -> Option { + Some(ResourceTypeKey::new(Self::KEY).unwrap()) + } +} +impl HostResourceType for Item { + const KEY: &'static str = "sdk.abort_item"; + const DESCRIPTION: &'static str = "ownership projection test"; +} +fn fixture() -> (Vm, Vec, ResourceHandle) { + let mut vm = Vm::new(crate::Program::new( + Vec::new(), + vec![crate::OpCode::Ret as u8], + )); + let handle = vm.host_context().insert_resource(Item).unwrap().handle(); + let journal = vec![PendingScriptResource { + handle, + key: Item::KEY, + close: super::super::close_unpublished::, + }]; + (vm, journal, handle) +} +#[test] +fn callback_publication_shared_container_external_owner_and_typed_int_coincidence() { + let (mut vm, journal, handle) = fixture(); + let value = crate::result::carrier(true, Value::Int(handle.raw() as i64)); + vm.instance + .stack + .push(Value::array(vec![value.clone(), value.clone()])); + assert!( + abandoned(&vm, &journal, 0, 0, 0).unwrap().is_empty(), + "host-retained carrier is a real surviving Arc root" + ); + drop(value); + vm.instance.locals.push(Value::Int(handle.raw() as i64)); + let mut program = (*vm.program).clone(); + program.type_map = Some(crate::TypeMap { + local_schemas: vec![Some(TypeSchema::Int)], + ..Default::default() + }); + vm.program = Arc::new(program); + assert_eq!( + abandoned(&vm, &journal, 0, 1, 0).unwrap(), + vec![handle], + "typed Int data is not a resource owner" + ); + (journal[0].close)(&mut vm, handle).unwrap(); + assert_eq!(vm.host_context().resource_count(), 0); +} +#[test] +fn callback_publication_cycle_has_no_false_external_owner() { + let (mut vm, journal, handle) = fixture(); + let cell = Arc::new(std::sync::Mutex::new(Value::Null)); + let weak_cell = Arc::downgrade(&cell); + let env = Arc::new(CallableEnvironment { + cells: std::sync::Mutex::new(vec![cell.clone()]), + }); + let callable = Arc::new(crate::CallableValue { + prototype_id: 0, + kind: crate::CallableKind::Closure, + env: Some(env), + }); + *cell.lock().unwrap() = Value::array(vec![ + Value::Int(handle.raw() as i64), + Value::Callable(callable.clone()), + ]); + vm.instance.owned_callables.push(Arc::downgrade(&callable)); + vm.instance.stack.push(Value::Callable(callable)); + drop(cell); + assert_eq!(abandoned(&vm, &journal, 0, 0, 0).unwrap(), vec![handle]); + *weak_cell.upgrade().unwrap().lock().unwrap() = Value::Null; + (journal[0].close)(&mut vm, handle).unwrap(); + assert_eq!(vm.host_context().resource_count(), 0); +} +#[test] +fn callback_publication_surviving_queue_host_return_and_iterator_roots() { + let (mut vm, journal, handle) = fixture(); + let encoded = Value::Int(handle.raw() as i64); + vm.instance.stack.push(encoded.clone()); + vm.instance.host_return = Some(encoded.clone()); + assert!(abandoned(&vm, &journal, 0, 0, 0).unwrap().is_empty()); + vm.instance.host_return = None; + vm.instance + .queued_callables + .push_back(crate::vm::instance::QueuedCallable { + callable: Value::Null, + args: vec![encoded.clone()], + subscription: None, + }); + assert!(abandoned(&vm, &journal, 0, 0, 0).unwrap().is_empty()); + vm.instance.queued_callables.clear(); + let map = Value::map(vec![(Value::string("resource"), encoded)]); + let Value::Map(map) = map else { unreachable!() }; + vm.instance + .map_iterators + .push(vec![Some(crate::vm::map_iter::MapIteratorState::new(map))]); + assert!(abandoned(&vm, &journal, 0, 0, 0).unwrap().is_empty()); + vm.instance.map_iterators.clear(); + assert_eq!(abandoned(&vm, &journal, 0, 0, 0).unwrap(), vec![handle]); + vm.host_context() + .close_resource::(handle, ResourceCloseReason::Requested) + .unwrap(); +} +#[test] +fn callback_publication_projection_failure_retains_actual_execution_roots() { + let compiled = crate::compile_source_for_repl("pub fn fail(value: resource) -> resource { let n = 1 / 0; value }").unwrap(); + let mut program = compiled.program.with_local_count(compiled.locals); + let mut deep = Value::Null; + for _ in 0..MAX_DEPTH + 1 { + deep = Value::array(vec![deep]); + } + program.constants.push(deep); + let mut store = crate::Store::from_vm(Vm::new(program)); + store.run().unwrap(); + let callback = store + .vm_script_callback_by_name::<(crate::ResourceOwned,), crate::ResourceOwned>( + "fail", + ) + .unwrap(); + let error = callback + .call_with_vm(&mut store, (crate::ResourceOwned::new(Item),)) + .unwrap_err(); + assert!( + error.to_string().contains("ownership unresolved"), + "{error}" + ); + assert_eq!(store.vm.instance.execution_frames.len(), 1); + assert_eq!(store.vm.host_context().resource_count(), 1); + assert!( + store + .vm + .instance + .locals + .iter() + .any(|value| matches!(value, Value::Int(_))) + ); + let before_locals = store.vm.instance.locals.clone(); + let before_ip = store.vm.instance.ip; + let run = store.run(); + let frames_after_run = store.vm.instance.execution_frames.len(); + let host_return_after_run = store.vm.instance.host_return.clone(); + let direct_run = store.vm_mut().run(); + let second = callback.call_with_vm(&mut store, (crate::ResourceOwned::new(Item),)); + eprintln!( + "callback_projection_public_run unit_constant_fixture=true run={run:?} direct_run={direct_run:?} frames_after_run={frames_after_run} host_return_after_run={host_return_after_run:?} second={second:?} live={}", + store.vm.host_context().resource_count() + ); + assert!( + run.is_err(), + "Store::run must not resume unresolved ownership" + ); + assert!( + direct_run.is_err(), + "Vm::run must not resume unresolved ownership" + ); + assert_eq!(frames_after_run, 1); + assert_eq!(host_return_after_run, None); + assert_eq!(store.vm.instance.locals, before_locals); + assert_eq!(store.vm.instance.ip, before_ip); + assert!( + second.is_err(), + "no second publication into unresolved execution" + ); + assert_eq!(store.vm.host_context().resource_count(), 1); + store + .vm + .execution_scope() + .begin_close(ResourceCloseReason::Requested) + .unwrap(); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert!(store.vm.execution_scope().poll_close(&mut cx).is_ready()); + assert_eq!(store.vm.host_context().resource_count(), 0); + store.vm.abort_host_invocation(0, 0); + eprintln!( + "callback_projection_limit execution_roots_retained=1 publications_before_reset=1 publications_after_scope_poll=0 second_publication_rejected=true" + ); +} + +#[test] +fn callback_publication_typed_queued_int_is_data_not_an_owned_resource() { + let compiled = crate::compile_source_for_repl("pub fn numeric(value: int) -> int { value } pub fn owned(value: resource) -> resource { value }").unwrap(); + let mut store = + crate::Store::from_vm(Vm::new(compiled.program.with_local_count(compiled.locals))); + store.run().unwrap(); + let numeric = store + .script_callback_by_name::<(i64,), i64>("numeric") + .unwrap(); + let (journal, handle) = { + let mut context = crate::ScriptValueContext { + vm: &mut store.vm, + pending: Vec::new(), + }; + let encoded = context + .publish_owned(crate::ResourceOwned::new(Item)) + .unwrap(); + let handle = crate::result::resource_handle(&encoded).unwrap(); + context.vm.instance.stack.push(encoded); + (context.pending, handle) + }; + store + .enqueue_callback(numeric.prepare((handle.raw() as i64,)).unwrap()) + .unwrap(); + assert_eq!( + abandoned(&store.vm, &journal, 0, store.vm.instance.locals.len(), 0).unwrap(), + vec![handle], + "the admitted queued parameter schema is Int, not Resource" + ); + store.vm.instance.queued_callables.clear(); + let owned = store + .script_callback_by_name::, Value>("owned") + .unwrap(); + store + .enqueue_callback( + owned + .prepare(vec![Value::Int(handle.raw() as i64)]) + .unwrap(), + ) + .unwrap(); + assert!( + abandoned(&store.vm, &journal, 0, store.vm.instance.locals.len(), 0) + .unwrap() + .is_empty(), + "the admitted Resource parameter is a surviving owner" + ); + store.vm.instance.queued_callables.clear(); + (journal[0].close)(&mut store.vm, handle).unwrap(); + assert_eq!(store.vm.host_context().resource_count(), 0); +} + +#[test] +fn callback_publication_graph_limit_is_explicit_without_resource_close() { + let (mut vm, journal, handle) = fixture(); + let mut value = Value::Int(handle.raw() as i64); + for _ in 0..MAX_DEPTH + 1 { + value = Value::array(vec![value]); + } + vm.instance.stack.push(value); + let error = abandoned(&vm, &journal, 0, 0, 0).unwrap_err(); + assert!(error.to_string().contains("ownership graph limit")); + assert_eq!(vm.host_context().resource_count(), 1); + vm.host_context() + .close_resource::(handle, ResourceCloseReason::Requested) + .unwrap(); +} diff --git a/tests/fixtures/result_sdk_resource_signature.rs b/tests/fixtures/result_sdk_resource_signature.rs new file mode 100644 index 00000000..60435989 --- /dev/null +++ b/tests/fixtures/result_sdk_resource_signature.rs @@ -0,0 +1,42 @@ +/// Publishes only the selected owned resource. +#[pd_host_function(name = "result_resource::make")] +fn make(ok: bool) -> VmResult, ResourceOwned>> { + Ok(if ok { + CREATED_OK.fetch_add(1, Ordering::SeqCst); + GuestResult::Ok(ResourceOwned::new(Success(17))) + } else { + CREATED_ERR.fetch_add(1, Ordering::SeqCst); + GuestResult::Err(ResourceOwned::new(Failure(29))) + }) +} +/// Borrows the active resource without transferring it. +#[pd_host_function(name = "result_resource::borrow")] +fn borrow(value: GuestResult, vm::ResourceRef<'_, Failure>>) -> i64 { + match value { + GuestResult::Ok(v) => v.get().0, + GuestResult::Err(v) => v.get().0, + } +} +/// Mutates the active resource without transferring it. +#[pd_host_function(name = "result_resource::bump")] +fn bump(value: GuestResult, vm::ResourceMut<'_, Failure>>) -> i64 { + match value { + GuestResult::Ok(mut v) => { + v.get().0 += 1; + v.get().0 + } + GuestResult::Err(mut v) => { + v.get().0 += 1; + v.get().0 + } + } +} +/// Consumes precisely the active resource. +#[pd_host_function(name = "result_resource::take")] +fn take(value: GuestResult, ResourceOwned>) -> i64 { + HANDLER_CALLS.fetch_add(1, Ordering::SeqCst); + match value { + GuestResult::Ok(v) => v.into_inner().0, + GuestResult::Err(v) => v.into_inner().0, + } +} diff --git a/tests/fixtures/result_sdk_signature.rs b/tests/fixtures/result_sdk_signature.rs new file mode 100644 index 00000000..0467b4cc --- /dev/null +++ b/tests/fixtures/result_sdk_signature.rs @@ -0,0 +1,33 @@ +/// Echo a nullable typed array guest union. +#[pd_host_function(name = "result_sdk::fixture")] +fn fixture( + value: GuestResult>, String>, +) -> VmResult>, String>> { + Ok(value) +} + +/// Returns a concrete generic named payload in the guest error branch. +#[pd_host_function(name = "result_sdk::named")] +fn named(ok: bool) -> VmResult>> { + Ok(if ok { + GuestResult::Ok(7) + } else { + GuestResult::Err(SdkFailure { code: 29 }) + }) +} + +/// Observes the selected optional array without losing its guest schema. +#[pd_host_function(name = "result_sdk::inspect")] +fn inspect(vm: &mut Vm, value: GuestResult>, String>) -> i64 { + let calls = vm + .host_context() + .module_state::() + .copied() + .unwrap_or(0); + vm.host_context().set_module_state(calls + 1); + match value { + GuestResult::Ok(Some(values)) => values.into_iter().sum(), + GuestResult::Ok(None) => -1, + GuestResult::Err(_) => -2, + } +} diff --git a/tests/result_host_tests.rs b/tests/result_host_tests.rs new file mode 100644 index 00000000..9649bd87 --- /dev/null +++ b/tests/result_host_tests.rs @@ -0,0 +1,368 @@ +#![cfg(feature = "runtime")] + +use pd_host_function::pd_host_function; +use std::sync::Arc; +use vm::{GuestResult, Value, Vm, VmError, VmResult, VmStatus}; + +#[derive(Clone, Debug, PartialEq)] +struct SdkFailure { + code: T, +} +impl vm::HostNamedStruct for SdkFailure { + const NAME: &'static str = "SdkFailureInt"; + fn host_struct_fields() -> Vec { + vec![vm::HostStructField::new("code", vm::HostTypeSchema::Int)] + } +} +impl vm::IntoVmValue for SdkFailure { + fn into_vm_value(self) -> Value { + Value::map(vec![(Value::string("code"), self.code.into_vm_value())]) + } +} + +mod generated { + pub use vm::borrow_arg; + pub mod functions { + use super::super::*; + include!("fixtures/result_sdk_signature.rs"); + + /// Returns a guest success, guest failure, or fatal transport error. + #[pd_host_function(name = "result_sdk::tri")] + fn tri(vm: &mut Vm, mode: i64) -> VmResult> { + let calls = vm + .host_context() + .module_state::() + .copied() + .unwrap_or(0); + vm.host_context().set_module_state(calls + 1); + match mode { + 0 => Ok(GuestResult::Ok(7)), + 1 => Ok(GuestResult::Err("guest failure".into())), + _ => Err(VmError::HostError("transport failure".into())), + } + } + } +} + +include!(concat!(env!("OUT_DIR"), "/result_sdk_build_schemas.rs")); + +#[test] +fn actual_build_fixture_and_proc_macro_have_identical_typed_descriptors() { + for (macro_descriptor, (schema, effects, binding)) in [ + ( + generated::functions::fixture_descriptor(), + result_sdk_build_fixture(), + ), + ( + generated::functions::named_descriptor(), + result_sdk_build_named(), + ), + ( + generated::functions::inspect_descriptor(), + result_sdk_build_inspect(), + ), + ] { + assert_eq!(schema, macro_descriptor.schema); + assert_eq!(effects, macro_descriptor.effects); + assert_eq!(binding, macro_descriptor.binding.kind); + } + assert!(matches!( + generated::functions::fixture_descriptor().schema.params[0].ty, + vm::HostTypeSchema::Result { .. } + )); +} + +#[test] +fn generated_named_generic_result_catalog_declares_payload() { + let descriptors = [generated::functions::named_descriptor()]; + let catalog = vm::HostFunctionDescriptor::collect_catalog(&descriptors).unwrap(); + assert!(catalog.struct_named("SdkFailureInt").is_some()); + for (branch, expected) in [("true", 7), ("false", 29)] { + let source = format!( + "use result_sdk; match result_sdk::named({branch}) {{ Result::Ok(v) => v, Result::Err(e) => e.code, }};" + ); + let compiled = vm::compile_source_with_flavor_and_options( + &source, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::default() + .with_host_api_catalog(Arc::new(catalog.clone())), + ) + .unwrap(); + let mut registry = vm::HostFunctionRegistry::empty(); + registry.set_capability_profile(vm::CapabilityProfile::deny_all()); + vm::HostModuleDescriptor::install_descriptors(&mut registry, &descriptors).unwrap(); + registry.authorize_registered_builtin_import("result_sdk::named"); + let mut machine = Vm::new(compiled.program.with_local_count(compiled.locals)); + registry.bind_vm_cached(&mut machine).unwrap(); + machine.set_fuel(100_000); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack().last(), Some(&Value::Int(expected))); + } +} + +#[test] +fn typed_array_optional_generated_roundtrip() { + for (value, expected) in [ + ("Result::Ok(null)", -1), + ("Result::Ok([3, 4])", 7), + ("Result::Err(\"typed error\")", -2), + ] { + let descriptors = vec![ + generated::functions::fixture_descriptor(), + generated::functions::inspect_descriptor(), + ]; + let catalog = Arc::new(vm::HostFunctionDescriptor::collect_catalog(&descriptors).unwrap()); + let source = format!( + "use result_sdk; let input: Result = {value}; let value: Result = result_sdk::fixture(input); result_sdk::inspect(value);" + ); + let compiled = vm::compile_source_with_flavor_and_options( + &source, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::default().with_host_api_catalog(catalog), + ) + .unwrap(); + let mut registry = vm::HostFunctionRegistry::empty(); + registry.set_capability_profile(vm::CapabilityProfile::deny_all()); + vm::HostModuleDescriptor::install_descriptors(&mut registry, &descriptors).unwrap(); + for descriptor in &descriptors { + registry.authorize_registered_builtin_import(&descriptor.schema.name); + } + let mut machine = Vm::new(compiled.program.with_local_count(compiled.locals)); + registry.bind_vm_cached(&mut machine).unwrap(); + machine.set_fuel(100_000); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack().last(), Some(&Value::Int(expected))); + } +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn native_guest_err_and_outer_vm_error_remain_separate() { + for (mode, guest_value) in [(0, Some(7)), (1, Some(-9)), (2, None)] { + let mut machine = machine(&format!( + "use result_sdk; fn run(mode: int) -> int {{ match result_sdk::tri(mode) {{ Result::Ok(v) => v, Result::Err(e) => -9, }} }} let f = run; f({mode});" + )); + machine.compile_aot().unwrap(); + let dump = machine.dump_aot_info(); + assert!(dump.contains("lowering=ssa"), "{dump}"); + assert!(!dump.contains("interpreter-boundary"), "{dump}"); + eprintln!("{dump}"); + match guest_value { + Some(value) => { + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack().last(), Some(&Value::Int(value))); + } + None => assert!( + machine + .run() + .unwrap_err() + .to_string() + .contains("transport failure") + ), + } + let count = machine.aot_exec_count(); + assert!(count > 0, "{dump}"); + assert_eq!(machine.host_context().module_state::(), Some(&1)); + eprintln!( + "typed Result native mode={mode} payload={guest_value:?} lowering=ssa executions={count} host_calls=1" + ); + } +} + +#[test] +fn generated_catalog_runtime_rejects_malformed_carriers_before_handler() { + let descriptor = generated::functions::inspect_descriptor(); + let descriptors = std::slice::from_ref(&descriptor); + let catalog = vm::HostFunctionDescriptor::collect_catalog(descriptors).unwrap(); + for value in [ + Value::Null, + Value::map(vec![ + (Value::string("tag"), Value::Int(1)), + (Value::string("payload"), Value::array(vec![])), + ]), + Value::map(vec![(Value::string("payload"), Value::array(vec![]))]), + vm::result::carrier(false, Value::Null), + vm::result::carrier(true, Value::array(vec![Value::Bool(false)])), + Value::map(vec![ + (Value::string("tag"), Value::Bool(true)), + (Value::string("extra"), Value::Int(1)), + ]), + ] { + let import = vm::catalog_import_schemas(&catalog, "result_sdk::inspect").remove(0); + let mut code = vm::BytecodeBuilder::new(); + code.ldc(0); + code.call(0, 1); + code.ret(); + let program = vm::Program::with_imports_and_debug( + vec![value], + code.finish(), + vec![vm::HostImport { + name: "result_sdk::inspect".into(), + arity: 1, + return_type: vm::ValueType::Int, + }], + None, + ) + .with_host_import_schemas(vec![import]) + .unwrap(); + let mut machine = Vm::new(program); + let mut registry = vm::HostFunctionRegistry::empty(); + registry.set_capability_profile(vm::CapabilityProfile::deny_all()); + vm::HostModuleDescriptor::install_descriptors(&mut registry, descriptors).unwrap(); + registry.authorize_registered_builtin_import("result_sdk::inspect"); + registry.bind_vm_cached(&mut machine).unwrap(); + machine.set_fuel(100_000); + assert!(machine.run().is_err()); + assert_eq!(machine.host_context().module_state::(), None); + assert_eq!(machine.host_context().resource_count(), 0); + } +} + +#[test] +fn generated_catalog_without_adapter_binding_is_rejected() { + let descriptors = [generated::functions::tri_descriptor()]; + let catalog = Arc::new(vm::HostFunctionDescriptor::collect_catalog(&descriptors).unwrap()); + let compiled = vm::compile_source_with_flavor_and_options( + "use result_sdk; result_sdk::tri(0);", + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::default().with_host_api_catalog(catalog), + ) + .unwrap(); + let mut machine = Vm::new(compiled.program.with_local_count(compiled.locals)); + let mut registry = vm::HostFunctionRegistry::empty(); + registry.set_capability_profile(vm::CapabilityProfile::deny_all()); + assert!(registry.bind_vm_cached(&mut machine).is_err()); + assert_eq!(machine.host_context().module_state::(), None); +} + +fn machine(source: &str) -> Vm { + let descriptors = vec![generated::functions::tri_descriptor()]; + let catalog = Arc::new(vm::HostFunctionDescriptor::collect_catalog(&descriptors).unwrap()); + let compiled = vm::compile_source_with_flavor_and_options( + source, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::default().with_host_api_catalog(catalog), + ) + .unwrap(); + let mut registry = vm::HostFunctionRegistry::empty(); + registry.set_capability_profile(vm::CapabilityProfile::deny_all()); + vm::HostModuleDescriptor::install_descriptors(&mut registry, &descriptors).unwrap(); + for descriptor in &descriptors { + registry.authorize_registered_builtin_import(&descriptor.schema.name); + } + let mut machine = Vm::new(compiled.program.with_local_count(compiled.locals)); + registry.bind_vm_cached(&mut machine).unwrap(); + machine.set_fuel(100_000); + machine +} + +#[test] +fn outer_ok_guest_ok_runs_through_generated_restricted_catalog() { + let mut vm = machine( + "use result_sdk; match result_sdk::tri(0) { Result::Ok(v) => v, Result::Err(e) => -1, };", + ); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + assert_eq!(vm.stack().last(), Some(&Value::Int(7))); +} + +#[test] +fn outer_ok_guest_err_is_guest_data() { + let mut vm = machine( + "use result_sdk; match result_sdk::tri(1) { Result::Ok(v) => \"wrong\", Result::Err(e) => e, };", + ); + assert_eq!(vm.run().unwrap(), VmStatus::Halted); + assert_eq!(vm.stack().last(), Some(&Value::string("guest failure"))); +} + +#[test] +fn outer_vm_error_remains_fatal() { + let mut vm = machine("use result_sdk; result_sdk::tri(2);"); + assert!( + vm.run() + .unwrap_err() + .to_string() + .contains("transport failure") + ); +} + +#[test] +fn typed_conversions_preserve_branch_null_false_zero_and_reject_noncanonical() { + use vm::{FromVmValue, IntoVmValue, TakeVmValue}; + for value in [ + GuestResult::, Option>::Ok(None), + GuestResult::Err(None), + GuestResult::Ok(Some(false)), + GuestResult::Err(Some(false)), + ] { + let encoded = value.into_vm_value(); + assert_eq!( + GuestResult::, Option>::from_vm_value(&encoded, "result").unwrap(), + value + ); + } + let bytes = GuestResult::, ()>::Ok(vec![0, 255]); + let mut encoded = bytes.clone().into_vm_value(); + assert_eq!( + GuestResult::, ()>::from_vm_value(&encoded, "bytes").unwrap(), + bytes + ); + assert_eq!( + GuestResult::, ()>::take_vm_value(&mut encoded, "bytes").unwrap(), + bytes + ); + let array = GuestResult::, String>::Ok(vec![0, 7]); + let encoded = array.clone().into_vm_value(); + assert_eq!( + GuestResult::, String>::from_vm_value(&encoded, "array").unwrap(), + array + ); + let mut value = GuestResult::::Ok(0).into_vm_value(); + assert_eq!( + GuestResult::::take_vm_value(&mut value, "result").unwrap(), + GuestResult::Ok(0) + ); + assert_eq!(value, Value::Null); + for value in [ + Value::map(vec![ + (Value::string("tag"), Value::Int(1)), + (Value::string("payload"), Value::Int(0)), + ]), + Value::map(vec![(Value::string("tag"), Value::Bool(true))]), + Value::map(vec![ + (Value::string("tag"), Value::Bool(true)), + (Value::string("payload"), Value::Int(0)), + (Value::string("extra"), Value::Int(1)), + ]), + ] { + assert!(GuestResult::::from_vm_value(&value, "result").is_err()); + } +} + +#[test] +fn callback_guest_result_both_variants_and_store_identity() { + let compiled = + vm::compile_source("pub fn echo(r: Result) -> Result { r }") + .unwrap(); + let mut store = vm::Store::new( + Vm::new(compiled.program.with_local_count(compiled.locals)), + (), + ); + store.vm_mut().set_fuel(100_000); + assert_eq!(store.run().unwrap(), VmStatus::Halted); + let callback = store + .script_callback_by_name::<(GuestResult,), GuestResult>("echo") + .unwrap(); + for value in [ + GuestResult::Ok(0), + GuestResult::Err("typed error".to_string()), + ] { + assert_eq!(callback.call(&mut store, (value.clone(),)).unwrap(), value); + } + let mut other = vm::Store::new( + Vm::new(vm::Program::new(Vec::new(), vec![vm::OpCode::Ret as u8])), + (), + ); + assert!(callback.call(&mut other, (GuestResult::Ok(1),)).is_err()); + store.reset_for_reuse().unwrap(); + assert!(callback.call(&mut store, (GuestResult::Ok(1),)).is_err()); +} diff --git a/tests/result_sdk_callback_tests.rs b/tests/result_sdk_callback_tests.rs new file mode 100644 index 00000000..630b4eaa --- /dev/null +++ b/tests/result_sdk_callback_tests.rs @@ -0,0 +1,158 @@ +#![cfg(feature = "runtime")] +use vm::{ + GuestResult, IntoScriptValue, ScriptResult, TypeSchema, Value, Vm, VmError, VmResult, VmStatus, +}; + +#[derive(Clone, Debug, PartialEq)] +struct Packet(T); +impl IntoScriptValue for Packet { + fn into_script_value(self) -> Value { + Value::map(vec![(Value::string("value"), self.0.into_script_value())]) + } + fn schema() -> Option { + Some(TypeSchema::Named("Packet".into(), vec![T::schema()?])) + } +} +impl ScriptResult for Packet { + fn from_value(value: Value) -> VmResult { + let Value::Map(values) = value else { + return Err(VmError::TypeMismatch("Packet")); + }; + T::from_value( + values + .get(&Value::string("value")) + .cloned() + .unwrap_or(Value::Null), + ) + .map(Self) + } + fn schema() -> Option { + Some(TypeSchema::Named("Packet".into(), vec![T::schema()?])) + } +} + +#[test] +fn generic_named_optional_arrays_callback_both_variants() { + let compiled = vm::compile_source("struct Packet { value: T } pub fn echo(value: Result, string>) -> Result, string> { value }").unwrap(); + let mut store = vm::Store::from_vm(Vm::new(compiled.program.with_local_count(compiled.locals))); + store.vm_mut().set_fuel(100_000); + assert_eq!(store.run().unwrap(), VmStatus::Halted); + type R = GuestResult>>, String>; + let callback = store.script_callback_by_name::<(R,), R>("echo").unwrap(); + for value in [ + GuestResult::Ok(Packet(Some(vec![0, 7]))), + GuestResult::Ok(Packet(None)), + GuestResult::Err("error".into()), + ] { + assert_eq!(callback.call(&mut store, (value.clone(),)).unwrap(), value); + } + assert!( + store + .script_callback_by_name::<(GuestResult,), R>("echo") + .is_err() + ); + assert!( + store + .script_callback_by_name::<(R,), GuestResult>("echo") + .is_err() + ); + callback.unsubscribe(); + assert!( + callback + .call(&mut store, (GuestResult::Err("error".into()),)) + .is_err() + ); +} + +#[test] +fn ordinary_result_callback_prepare_queue_start_and_vm_aware_scalar_controls() { + type R = GuestResult; + let compiled = vm::compile_source( + "pub fn echo(value: Result) -> Result { value }", + ) + .unwrap(); + let new_store = || { + let mut store = vm::Store::from_vm(Vm::new( + compiled.program.clone().with_local_count(compiled.locals), + )); + store.vm_mut().set_fuel(100_000); + store.run().unwrap(); + store + }; + let mut store = new_store(); + let callback = store.script_callback_by_name::<(R,), R>("echo").unwrap(); + let queued = callback.prepare((GuestResult::Ok(0),)).unwrap(); + store.enqueue_callback(queued).unwrap(); + let results = store.drain_callbacks().unwrap(); + assert_eq!(results.len(), 1); + assert_eq!( + ::from_value(results[0].clone()).unwrap(), + GuestResult::Ok(0) + ); + assert_eq!( + callback + .start(&mut store, (GuestResult::Err("queued error".into()),)) + .unwrap(), + VmStatus::Halted + ); + assert_eq!( + store.take_callback_result::().unwrap(), + Some(GuestResult::Err("queued error".into())) + ); + let vm_callback = store.vm_script_callback_by_name::<(R,), R>("echo").unwrap(); + assert_eq!( + vm_callback + .call_with_vm(&mut store, (GuestResult::Ok(7),)) + .unwrap(), + GuestResult::Ok(7) + ); + let mut other = new_store(); + let queued = callback.prepare((GuestResult::Ok(2),)).unwrap(); + assert!(other.enqueue_callback(queued).is_err()); + store.reset_for_reuse().unwrap(); + assert!(callback.prepare((GuestResult::Ok(2),)).is_err()); + assert!( + vm_callback + .call_with_vm(&mut store, (GuestResult::Ok(2),)) + .is_err() + ); +} + +#[test] +fn malformed_result_callback_arguments_rejected_without_poisoning_store() { + let compiled = vm::compile_source( + "pub fn echo(value: Result) -> Result { value }", + ) + .unwrap(); + let mut store = vm::Store::from_vm(Vm::new(compiled.program.with_local_count(compiled.locals))); + store.vm_mut().set_fuel(100_000); + store.run().unwrap(); + let callback = store + .script_callback_by_name::<(Value,), GuestResult>("echo") + .unwrap(); + for value in [ + Value::Null, + Value::map(vec![ + (Value::string("tag"), Value::Int(1)), + (Value::string("payload"), Value::Int(0)), + ]), + Value::map(vec![(Value::string("tag"), Value::Bool(true))]), + Value::map(vec![ + (Value::string("tag"), Value::Bool(true)), + (Value::string("payload"), Value::Bool(false)), + ]), + Value::map(vec![ + (Value::string("tag"), Value::Bool(true)), + (Value::string("payload"), Value::Int(0)), + (Value::string("extra"), Value::Bool(true)), + ]), + ] { + assert!(callback.call(&mut store, (value,)).is_err()); + } + assert_eq!( + callback + .call(&mut store, (vm::result::carrier(true, Value::Int(0)),)) + .unwrap(), + GuestResult::Ok(0) + ); +} diff --git a/tests/result_sdk_resource_tests.rs b/tests/result_sdk_resource_tests.rs new file mode 100644 index 00000000..437ab707 --- /dev/null +++ b/tests/result_sdk_resource_tests.rs @@ -0,0 +1,784 @@ +#![cfg(feature = "runtime")] + +use pd_host_function::pd_host_function; +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use vm::resource::{CloseProgress, HostResource, ResourceCloseReason, ResourceResult}; +use vm::{ + GuestResult, HostResourceType, ResourceOwned, ResourceTypeKey, Value, Vm, VmResult, VmStatus, +}; +static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +#[path = "support/result_sdk_abort.rs"] +mod abort; +#[path = "support/result_sdk_success.rs"] +mod success; +static CLOSED: AtomicUsize = AtomicUsize::new(0); +static CREATED_OK: AtomicUsize = AtomicUsize::new(0); +static CREATED_ERR: AtomicUsize = AtomicUsize::new(0); +static HANDLER_CALLS: AtomicUsize = AtomicUsize::new(0); +static OBSERVED_RESOURCE_COUNT: AtomicUsize = AtomicUsize::new(0); +static OBSERVATIONS: AtomicUsize = AtomicUsize::new(0); +static CONVERSIONS: AtomicUsize = AtomicUsize::new(0); +static DROPPED_OK: AtomicUsize = AtomicUsize::new(0); +static DROPPED_ERR: AtomicUsize = AtomicUsize::new(0); +static PENDING_READY: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +static CLOSE_POLLS: AtomicUsize = AtomicUsize::new(0); +#[derive(Debug)] +struct Success(i64); +#[derive(Debug)] +struct Failure(i64); +impl Drop for Success { + fn drop(&mut self) { + DROPPED_OK.fetch_add(1, Ordering::SeqCst); + } +} +impl Drop for Failure { + fn drop(&mut self) { + DROPPED_ERR.fetch_add(1, Ordering::SeqCst); + } +} +macro_rules! resource_type { + ($ty:ident, $key:literal) => { + impl HostResource for $ty { + fn resource_type_key() -> Option { + Some(ResourceTypeKey::new($key).unwrap()) + } + fn begin_close(&mut self, _: ResourceCloseReason) -> ResourceResult { + CLOSED.fetch_add(1, Ordering::SeqCst); + match self.0 { + -101 => Ok(CloseProgress::Pending), + -102 => Err(vm::resource::ResourceError::new( + vm::resource::ResourceErrorCode::ResourceCleanupFailed, + "sdk_abort_test", + "intentional close failure", + )), + _ => Ok(CloseProgress::Ready), + } + } + fn poll_close( + &mut self, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + CLOSE_POLLS.fetch_add(1, Ordering::SeqCst); + if PENDING_READY.load(Ordering::SeqCst) { + std::task::Poll::Ready(Ok(())) + } else { + std::task::Poll::Pending + } + } + } + impl HostResourceType for $ty { + const KEY: &'static str = $key; + const DESCRIPTION: &'static str = $key; + } + }; +} +resource_type!(Success, "result.success"); +resource_type!(Failure, "result.failure"); +mod generated { + pub use vm::borrow_arg; + pub mod functions { + use super::super::*; + include!("fixtures/result_sdk_resource_signature.rs"); + /// Observes publication before the RSS callback returns ownership. + #[pd_host_function(name = "result_resource::observe")] + fn observe(vm: &mut Vm) -> i64 { + let count = vm.host_context().resource_count(); + OBSERVED_RESOURCE_COUNT.store(count, Ordering::SeqCst); + OBSERVATIONS.fetch_add(1, Ordering::SeqCst); + count as i64 + } + /// Keeps fatal callback transport separate from the guest union. + #[pd_host_function(name = "result_resource::fatal")] + fn fatal() -> VmResult { + Err(vm::VmError::HostError("callback transport failure".into())) + } + /// Takes ownership into persistent opaque Rust module state. + #[pd_host_function(name = "result_resource::retain")] + fn retain( + vm: &mut Vm, + value: GuestResult, ResourceOwned>, + ) -> i64 { + vm.host_context().set_module_state(value); + 1 + } + /// Takes the fixture's initial capture without a guest MoveLocal detach. + #[pd_host_function(name = "result_resource::retire_initial")] + fn retire_initial(vm: &mut Vm) -> VmResult { + super::super::abort::retire_initial(vm) + } + } +} +include!(concat!( + env!("OUT_DIR"), + "/result_sdk_resource_build_schemas.rs" +)); + +#[test] +fn actual_resource_build_fixture_matches_generated_schema_passing_and_effects() { + for (descriptor, (schema, effects, binding)) in [ + ( + generated::functions::make_descriptor(), + result_sdk_build_make(), + ), + ( + generated::functions::take_descriptor(), + result_sdk_build_take(), + ), + ( + generated::functions::borrow_descriptor(), + result_sdk_build_borrow(), + ), + ( + generated::functions::bump_descriptor(), + result_sdk_build_bump(), + ), + ] { + assert_eq!(schema, descriptor.schema); + assert_eq!(effects, descriptor.effects); + assert_eq!(binding, descriptor.binding.kind); + } +} + +fn descriptors() -> Vec { + vec![ + generated::functions::make_descriptor(), + generated::functions::take_descriptor(), + generated::functions::observe_descriptor(), + generated::functions::fatal_descriptor(), + generated::functions::borrow_descriptor(), + generated::functions::retain_descriptor(), + generated::functions::retire_initial_descriptor(), + abort::suspend_descriptor(), + ] +} +fn machine(source: &str) -> Vm { + let descriptors = descriptors(); + let catalog = Arc::new(vm::HostFunctionDescriptor::collect_catalog(&descriptors).unwrap()); + let compiled = vm::compile_source_with_flavor_and_options( + source, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::default().with_host_api_catalog(catalog), + ) + .unwrap(); + let mut registry = vm::HostFunctionRegistry::empty(); + registry.set_capability_profile(vm::CapabilityProfile::deny_all()); + vm::HostModuleDescriptor::install_descriptors(&mut registry, &descriptors).unwrap(); + for descriptor in &descriptors { + registry.authorize_registered_builtin_import(&descriptor.schema.name); + } + let mut machine = Vm::new(compiled.program.with_local_count(compiled.locals)); + registry.bind_vm_cached(&mut machine).unwrap(); + machine.set_fuel(100_000); + machine +} +fn empty_vm() -> Vm { + Vm::new(vm::Program::new(Vec::new(), vec![vm::OpCode::Ret as u8])) +} + +type OwnedResult = GuestResult, ResourceOwned>; +fn callback_store() -> vm::Store { + let mut store = vm::Store::from_vm(machine( + r#"use result_resource; + pub fn echo(value: Result, resource>) -> Result, resource> { result_resource::observe(); value } + pub fn pair(value: Result, resource>, flag: int) -> Result, resource> { result_resource::observe(); value } + pub fn replace(value: Result, resource>) -> Result, resource> { result_resource::take(value); result_resource::make(false) } + pub fn fail(value: Result, resource>) -> Result, resource> { result_resource::take(value); result_resource::fatal(); result_resource::make(true) } + pub fn fail_before_move(value: Result, resource>) -> Result, resource> { result_resource::observe(); result_resource::fatal(); value } + pub fn retain_before_fatal(value: Result, resource>) -> Result, resource> { result_resource::retain(value); result_resource::fatal(); result_resource::make(true) } + pub fn fuel_before_move(value: Result, resource>) -> Result, resource> { result_resource::observe(); let mut n = 0; while n < 1000 { n = n + 1; } value } + pub fn suspend_before_move(value: Result, resource>, mode: int) -> Result, resource> { result_resource::observe(); result_resource::suspend(mode); value } + pub fn fail_pair(first: Result, resource>, second: Result, resource>) -> Result, resource> { result_resource::observe(); result_resource::fatal(); result_resource::borrow(second); first } + pub fn return_first(first: Result, resource>, second: Result, resource>) -> Result, resource> { result_resource::observe(); first } + pub fn fresh_return(value: Result, resource>, flag: bool) -> Result, resource> { result_resource::observe(); result_resource::make(flag) } + pub fn return_first_three(first: Result, resource>, second: Result, resource>, third: Result, resource>) -> Result, resource> { result_resource::observe(); first } + pub fn retain_success(value: Result, resource>) -> int { result_resource::retain(value) } + pub fn nullable(value: Result?, resource>) -> Result?, resource> { result_resource::observe(); value } + "#, + )); + store.run().unwrap(); + store +} + +#[test] +fn vm_aware_owned_callback_both_branches_publish_and_take() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let mut store = callback_store(); + let callback = store + .vm_script_callback_by_name::<(OwnedResult,), OwnedResult>("echo") + .unwrap(); + let closed = CLOSED.load(Ordering::SeqCst); + let dropped_ok = DROPPED_OK.load(Ordering::SeqCst); + let dropped_err = DROPPED_ERR.load(Ordering::SeqCst); + for branch in [true, false] { + let value = if branch { + GuestResult::Ok(ResourceOwned::new(Success(61))) + } else { + GuestResult::Err(ResourceOwned::new(Failure(73))) + }; + let returned = callback.call_with_vm(&mut store, (value,)).unwrap(); + assert_eq!(OBSERVED_RESOURCE_COUNT.load(Ordering::SeqCst), 1); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + match returned { + GuestResult::Ok(value) => { + assert!(branch); + let mut value = value.into_inner(); + assert_eq!(value.0, 61); + value.begin_close(ResourceCloseReason::Requested).unwrap(); + } + GuestResult::Err(value) => { + assert!(!branch); + let mut value = value.into_inner(); + assert_eq!(value.0, 73); + value.begin_close(ResourceCloseReason::Requested).unwrap(); + } + } + } + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 2); + assert_eq!(DROPPED_OK.load(Ordering::SeqCst), dropped_ok + 1); + assert_eq!(DROPPED_ERR.load(Ordering::SeqCst), dropped_err + 1); +} + +struct Watched(OwnedResult); +impl vm::VmScriptValue for Watched { + fn schema() -> VmResult { + ::schema() + } + fn into_value(self, context: &mut vm::ScriptValueContext<'_>) -> VmResult { + CONVERSIONS.fetch_add(1, Ordering::SeqCst); + vm::VmScriptValue::into_value(self.0, context) + } + fn from_value(vm: &mut Vm, value: Value) -> VmResult { + ::from_value(vm, value).map(Self) + } +} +struct LateFailure(bool); +impl vm::VmScriptValue for LateFailure { + fn schema() -> VmResult { + Ok(vm::HostTypeSchema::Int) + } + fn into_value(self, context: &mut vm::ScriptValueContext<'_>) -> VmResult { + context.publish_owned(ResourceOwned::new(Success(88)))?; + if self.0 { + Err(vm::VmError::HostError( + "later argument conversion rejected".into(), + )) + } else { + Ok(Value::Bool(false)) + } + } + fn from_value(_: &mut Vm, _: Value) -> VmResult { + Err(vm::VmError::TypeMismatch("test input only")) + } +} + +#[test] +fn vm_aware_callbacks_reject_foreign_unsubscribed_reset_before_conversion() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let mut store = callback_store(); + let mut other = callback_store(); + let callback = store + .vm_script_callback_by_name::<(Watched,), OwnedResult>("echo") + .unwrap(); + let value = || (Watched(GuestResult::Ok(ResourceOwned::new(Success(5)))),); + let conversions = CONVERSIONS.load(Ordering::SeqCst); + let closed = CLOSED.load(Ordering::SeqCst); + assert!(callback.call_with_vm(&mut other, value()).is_err()); + assert_eq!(CONVERSIONS.load(Ordering::SeqCst), conversions); + assert_eq!(other.vm_mut().host_context().resource_count(), 0); + let cloned = callback.clone(); + cloned.unsubscribe(); + assert!(!callback.is_subscribed()); + assert!(callback.call_with_vm(&mut store, value()).is_err()); + let callback = store + .vm_script_callback_by_name::<(Watched,), OwnedResult>("echo") + .unwrap(); + store.reset_for_reuse().unwrap(); + assert!(callback.call_with_vm(&mut store, value()).is_err()); + assert_eq!(CONVERSIONS.load(Ordering::SeqCst), conversions); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); +} + +#[test] +fn vm_aware_callback_rolls_back_every_unpublished_argument_only() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let mut store = callback_store(); + let existing = store + .vm_mut() + .host_context() + .insert_resource(Failure(99)) + .unwrap() + .handle(); + let callback = store + .vm_script_callback_by_name::<(OwnedResult, LateFailure), OwnedResult>("pair") + .unwrap(); + for conversion_failure in [true, false] { + let closed = CLOSED.load(Ordering::SeqCst); + let observations = OBSERVATIONS.load(Ordering::SeqCst); + assert!( + callback + .call_with_vm( + &mut store, + ( + GuestResult::Ok(ResourceOwned::new(Success(5))), + LateFailure(conversion_failure) + ) + ) + .is_err() + ); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 2); + assert_eq!(OBSERVATIONS.load(Ordering::SeqCst), observations); + assert_eq!(store.vm_mut().host_context().resource_count(), 1); + assert_eq!( + store + .vm_mut() + .host_context() + .borrow_resource::(existing) + .unwrap() + .get() + .0, + 99 + ); + } + store + .vm_mut() + .host_context() + .close_resource::(existing, ResourceCloseReason::Requested) + .unwrap(); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); +} + +#[test] +fn vm_aware_post_admission_fatal_before_move_reclaims_only_abandoned() { + assert_post_admission_abandoned("fail_before_move", false); +} + +#[test] +fn vm_aware_post_admission_body_fuel_before_move_reclaims_only_abandoned() { + assert_post_admission_abandoned("fuel_before_move", true); +} + +fn assert_post_admission_abandoned(name: &str, fuel: bool) { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for branch in [true, false] { + let mut store = callback_store(); + let existing = store + .vm_mut() + .host_context() + .insert_resource(Failure(99)) + .unwrap() + .handle(); + let callback = store + .vm_script_callback_by_name::<(OwnedResult,), OwnedResult>(name) + .unwrap(); + if fuel { + store.vm_mut().set_fuel_check_interval(1).unwrap(); + store.vm_mut().set_fuel(30); + } + let closed = CLOSED.load(Ordering::SeqCst); + let dropped_ok = DROPPED_OK.load(Ordering::SeqCst); + let dropped_err = DROPPED_ERR.load(Ordering::SeqCst); + let observations = OBSERVATIONS.load(Ordering::SeqCst); + let value = if branch { + GuestResult::Ok(ResourceOwned::new(Success(61))) + } else { + GuestResult::Err(ResourceOwned::new(Failure(73))) + }; + let error = callback.call_with_vm(&mut store, (value,)).unwrap_err(); + assert!( + error.to_string().contains(if fuel { + "host invocation yielded" + } else { + "callback transport failure" + }), + "{error}" + ); + assert_eq!( + OBSERVATIONS.load(Ordering::SeqCst), + observations + 1, + "body must execute beyond admission" + ); + assert_eq!(OBSERVED_RESOURCE_COUNT.load(Ordering::SeqCst), 2); + assert_eq!( + store + .vm_mut() + .host_context() + .borrow_resource::(existing) + .unwrap() + .get() + .0, + 99 + ); + eprintln!( + "post_admission name={name} branch={branch} live={} close_delta={} drop_ok_delta={} drop_err_delta={}", + store.vm_mut().host_context().resource_count(), + CLOSED.load(Ordering::SeqCst) - closed, + DROPPED_OK.load(Ordering::SeqCst) - dropped_ok, + DROPPED_ERR.load(Ordering::SeqCst) - dropped_err + ); + assert_eq!( + store.vm_mut().host_context().resource_count(), + 1, + "published owner abandoned before VM destruction" + ); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 1); + assert_eq!( + DROPPED_OK.load(Ordering::SeqCst), + dropped_ok + usize::from(branch) + ); + assert_eq!( + DROPPED_ERR.load(Ordering::SeqCst), + dropped_err + usize::from(!branch) + ); + store + .vm_mut() + .host_context() + .close_resource::(existing, ResourceCloseReason::Requested) + .unwrap(); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + } +} + +#[test] +fn vm_aware_callback_frame_admission_rejection_rolls_back() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let mut store = callback_store(); + let callback = store + .vm_script_callback_by_name::<(OwnedResult,), OwnedResult>("echo") + .unwrap(); + store.vm_mut().set_fuel_check_interval(1).unwrap(); + store.vm_mut().set_fuel(0); + let closed = CLOSED.load(Ordering::SeqCst); + let observations = OBSERVATIONS.load(Ordering::SeqCst); + assert!( + callback + .call_with_vm( + &mut store, + (GuestResult::Ok(ResourceOwned::new(Success(5))),) + ) + .is_err() + ); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 1); + assert_eq!(OBSERVATIONS.load(Ordering::SeqCst), observations); +} + +#[test] +fn vm_aware_owned_callback_take_then_new_return_does_not_close_consumed_argument() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let mut store = callback_store(); + let callback = store + .vm_script_callback_by_name::<(OwnedResult,), OwnedResult>("replace") + .unwrap(); + let closed = CLOSED.load(Ordering::SeqCst); + let calls = HANDLER_CALLS.load(Ordering::SeqCst); + let returned = callback + .call_with_vm( + &mut store, + (GuestResult::Ok(ResourceOwned::new(Success(5))),), + ) + .unwrap(); + assert!(matches!(returned, GuestResult::Err(ref value) if value.as_ref().0 == 29)); + assert_eq!(HANDLER_CALLS.load(Ordering::SeqCst), calls + 1); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); +} + +#[test] +fn vm_aware_owned_callback_fatal_transport_after_take_is_not_guest_err() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let mut store = callback_store(); + let callback = store + .vm_script_callback_by_name::<(OwnedResult,), OwnedResult>("fail") + .unwrap(); + let closed = CLOSED.load(Ordering::SeqCst); + let calls = HANDLER_CALLS.load(Ordering::SeqCst); + let created = CREATED_OK.load(Ordering::SeqCst); + let error = callback + .call_with_vm( + &mut store, + (GuestResult::Ok(ResourceOwned::new(Success(5))),), + ) + .unwrap_err(); + assert!(error.to_string().contains("callback transport failure")); + assert_eq!(HANDLER_CALLS.load(Ordering::SeqCst), calls + 1); + assert_eq!(CREATED_OK.load(Ordering::SeqCst), created); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); +} + +#[test] +fn vm_aware_nullable_owned_callback_allocates_only_present_selected_payload() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + type Nullable = GuestResult>, ResourceOwned>; + let mut store = callback_store(); + let callback = store + .vm_script_callback_by_name::<(Nullable,), Nullable>("nullable") + .unwrap(); + let returned = callback + .call_with_vm(&mut store, (GuestResult::Ok(None),)) + .unwrap(); + assert!(matches!(returned, GuestResult::Ok(None))); + assert_eq!(OBSERVED_RESOURCE_COUNT.load(Ordering::SeqCst), 0); + for value in [ + GuestResult::Ok(Some(ResourceOwned::new(Success(81)))), + GuestResult::Err(ResourceOwned::new(Failure(93))), + ] { + let returned = callback.call_with_vm(&mut store, (value,)).unwrap(); + assert!( + matches!(returned, GuestResult::Ok(Some(ref resource)) if resource.as_ref().0 == 81) + || matches!(returned, GuestResult::Err(ref resource) if resource.as_ref().0 == 93) + ); + assert_eq!(OBSERVED_RESOURCE_COUNT.load(Ordering::SeqCst), 1); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + } +} + +struct EncodedResult(Value); +impl vm::VmScriptValue for EncodedResult { + fn schema() -> VmResult { + ::schema() + } + fn into_value(self, _: &mut vm::ScriptValueContext<'_>) -> VmResult { + Ok(self.0) + } + fn from_value(_: &mut Vm, _: Value) -> VmResult { + Err(vm::VmError::TypeMismatch("test input only")) + } +} + +#[test] +fn vm_aware_callback_rejects_wrong_key_arena_stale_and_malformed_before_effects() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for tag in [true, false] { + let mut store = callback_store(); + let mut foreign = empty_vm(); + let callback = store + .vm_script_callback_by_name::<(EncodedResult,), OwnedResult>("echo") + .unwrap(); + let value = generated::functions::make(store.vm_mut(), &[Value::Bool(tag)]).unwrap(); + let foreign_value = generated::functions::make(&mut foreign, &[Value::Bool(tag)]).unwrap(); + let (_, payload) = vm::result::payload(&value).unwrap(); + let wrong = vm::result::carrier(!tag, payload.clone()); + let malformed = Value::map(vec![ + (Value::string("tag"), Value::Int(1)), + (Value::string("payload"), payload.clone()), + ]); + let observations = OBSERVATIONS.load(Ordering::SeqCst); + for denied in [wrong, foreign_value.clone(), malformed] { + assert!( + callback + .call_with_vm(&mut store, (EncodedResult(denied),)) + .is_err() + ); + assert_eq!(OBSERVATIONS.load(Ordering::SeqCst), observations); + assert_eq!(store.vm_mut().host_context().resource_count(), 1); + assert_eq!(foreign.host_context().resource_count(), 1); + } + callback + .call_with_vm(&mut store, (EncodedResult(value.clone()),)) + .unwrap(); + let replacement = generated::functions::make(store.vm_mut(), &[Value::Bool(tag)]).unwrap(); + let observations = OBSERVATIONS.load(Ordering::SeqCst); + assert!( + callback + .call_with_vm(&mut store, (EncodedResult(value),)) + .is_err() + ); + assert_eq!(OBSERVATIONS.load(Ordering::SeqCst), observations); + assert_eq!(store.vm_mut().host_context().resource_count(), 1); + callback + .call_with_vm(&mut store, (EncodedResult(replacement),)) + .unwrap(); + ::from_value(&mut foreign, foreign_value).unwrap(); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + assert_eq!(foreign.host_context().resource_count(), 0); + } +} + +#[test] +fn vm_aware_result_decode_validates_active_key_arena_and_generation() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for tag in [true, false] { + let mut machine = empty_vm(); + let value = generated::functions::make(&mut machine, &[Value::Bool(tag)]).unwrap(); + let (_, payload) = vm::result::payload(&value).unwrap(); + let wrong = vm::result::carrier(!tag, payload.clone()); + let mut foreign = empty_vm(); + assert!( + ::from_value(&mut foreign, value.clone()).is_err() + ); + assert!(::from_value(&mut machine, wrong).is_err()); + assert_eq!(machine.host_context().resource_count(), 1); + let decoded = + ::from_value(&mut machine, value.clone()).unwrap(); + assert!(matches!(decoded, GuestResult::Ok(_)) == tag); + assert_eq!(machine.host_context().resource_count(), 0); + let replacement = generated::functions::make(&mut machine, &[Value::Bool(tag)]).unwrap(); + assert!(::from_value(&mut machine, value).is_err()); + assert_eq!(machine.host_context().resource_count(), 1); + ::from_value(&mut machine, replacement).unwrap(); + assert_eq!(machine.host_context().resource_count(), 0); + } +} + +#[test] +fn generated_owned_conversion_checks_active_key_arena_generation_before_handler() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for (tag, expected) in [(true, 17), (false, 29)] { + let mut machine = empty_vm(); + let value = generated::functions::make(&mut machine, &[Value::Bool(tag)]).unwrap(); + assert_eq!(machine.host_context().resource_count(), 1); + let calls = HANDLER_CALLS.load(Ordering::SeqCst); + let mut foreign = empty_vm(); + assert!(generated::functions::take(&mut foreign, std::slice::from_ref(&value)).is_err()); + assert_eq!(foreign.host_context().resource_count(), 0); + assert_eq!(machine.host_context().resource_count(), 1); + let (_, payload) = vm::result::payload(&value).unwrap(); + let wrong_key = vm::result::carrier(!tag, payload.clone()); + assert!(generated::functions::take(&mut machine, &[wrong_key]).is_err()); + assert_eq!(HANDLER_CALLS.load(Ordering::SeqCst), calls); + assert_eq!(machine.host_context().resource_count(), 1); + assert_eq!( + generated::functions::take(&mut machine, std::slice::from_ref(&value)).unwrap(), + expected + ); + assert_eq!(machine.host_context().resource_count(), 0); + assert!(generated::functions::take(&mut machine, std::slice::from_ref(&value)).is_err()); + let replacement = generated::functions::make(&mut machine, &[Value::Bool(tag)]).unwrap(); + assert!(generated::functions::take(&mut machine, &[value]).is_err()); + assert_eq!(machine.host_context().resource_count(), 1); + assert_eq!( + generated::functions::take(&mut machine, &[replacement]).unwrap(), + expected + ); + assert_eq!(machine.host_context().resource_count(), 0); + } +} + +#[test] +fn owned_publication_rolls_back_after_insertion_before_publication() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let mut machine = empty_vm(); + let schema = generated::functions::make_descriptor().schema.return_type; + let closed = CLOSED.load(Ordering::SeqCst); + let error = vm::result::publish_owned( + &mut machine, + ResourceOwned::new(Success(41)), + &schema, + |_| { + Err(vm::VmError::HostError( + "assembly denied after insertion".into(), + )) + }, + ) + .unwrap_err(); + assert!( + error + .to_string() + .contains("assembly denied after insertion") + ); + assert_eq!(machine.host_context().resource_count(), 0); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 1); + let error = vm::result::publish_owned( + &mut machine, + ResourceOwned::new(Success(42)), + &schema, + |token| Ok(vm::result::carrier(false, token)), + ) + .unwrap_err(); + assert!(error.to_string().contains("resource")); + assert_eq!(machine.host_context().resource_count(), 0); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 2); +} + +#[test] +fn generated_result_borrows_preserve_resource_until_take() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for (tag, expected) in [(true, 17), (false, 29)] { + let mut machine = empty_vm(); + let value = generated::functions::make(&mut machine, &[Value::Bool(tag)]).unwrap(); + assert_eq!( + generated::functions::borrow(&mut machine, std::slice::from_ref(&value)).unwrap(), + expected + ); + assert_eq!(machine.host_context().resource_count(), 1); + assert_eq!( + generated::functions::bump(&mut machine, std::slice::from_ref(&value)).unwrap(), + expected + 1 + ); + assert_eq!(machine.host_context().resource_count(), 1); + assert_eq!( + generated::functions::take(&mut machine, &[value]).unwrap(), + expected + 1 + ); + assert_eq!(machine.host_context().resource_count(), 0); + } +} + +#[test] +fn owned_publication_overbudget_schema_is_rejected_before_insertion() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let mut machine = empty_vm(); + let mut schema = generated::functions::make_descriptor().schema.return_type; + for _ in 0..vm::MAX_HOST_SCHEMA_DEPTH { + schema = vm::HostTypeSchema::Optional(Box::new(schema)); + } + assert!( + vm::result::publish_owned( + &mut machine, + ResourceOwned::new(Success(43)), + &schema, + |token| Ok(vm::result::carrier(true, token)) + ) + .is_err() + ); + assert_eq!(machine.host_context().resource_count(), 0); +} + +#[test] +fn owned_publication_quota_failure_preserves_existing_resources() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let mut machine = empty_vm(); + let limit = vm::resource::handle::DEFAULT_MAX_RESOURCES; + for _ in 0..limit { + machine.host_context().insert_resource(Success(1)).unwrap(); + } + let error = generated::functions::make(&mut machine, &[Value::Bool(false)]).unwrap_err(); + assert!(error.to_string().contains("capacity"), "{error}"); + assert_eq!(machine.host_context().resource_count(), limit); + machine + .execution_scope() + .begin_close(ResourceCloseReason::Requested) + .unwrap(); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert!(machine.execution_scope().poll_close(&mut cx).is_ready()); + assert_eq!(machine.host_context().resource_count(), 0); +} + +#[test] +fn generated_owned_result_both_branches_publish_then_take_exactly_once() { + let _lock = LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + CREATED_OK.store(0, Ordering::SeqCst); + CREATED_ERR.store(0, Ordering::SeqCst); + for (branch, expected) in [("true", 17), ("false", 29)] { + let mut machine = machine(&format!( + "use result_resource; result_resource::take(result_resource::make({branch}));" + )); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack().last(), Some(&Value::Int(expected))); + assert_eq!(machine.host_context().resource_count(), 0); + } + assert_eq!(CREATED_OK.load(Ordering::SeqCst), 1); + assert_eq!(CREATED_ERR.load(Ordering::SeqCst), 1); + let descriptors = descriptors(); + assert_eq!( + descriptors[1].schema.params[0].passing, + vm::HostParamPassing::TakeOwned + ); + assert_eq!(descriptors[0].effects.len(), 2); + assert_eq!(descriptors[1].effects.len(), 2); +} diff --git a/tests/result_sdk_type_tests.rs b/tests/result_sdk_type_tests.rs new file mode 100644 index 00000000..3a446bee --- /dev/null +++ b/tests/result_sdk_type_tests.rs @@ -0,0 +1,9 @@ +use vm::GuestResult; + +#[test] +fn public_wrapper_is_available_without_runtime() { + let ok: GuestResult = std::result::Result::Ok(7).into(); + assert_eq!(ok, GuestResult::Ok(7)); + let err: std::result::Result = GuestResult::Err("guest error").into(); + assert_eq!(err, Err("guest error")); +} diff --git a/tests/support/result_sdk_abort.rs b/tests/support/result_sdk_abort.rs new file mode 100644 index 00000000..2aaa4d6e --- /dev/null +++ b/tests/support/result_sdk_abort.rs @@ -0,0 +1,360 @@ +use super::*; +use std::task::{Context, Poll}; + +static SUBMITTED: AtomicUsize = AtomicUsize::new(0); +static SUSPENDED: AtomicUsize = AtomicUsize::new(0); +static CANCELLED: AtomicUsize = AtomicUsize::new(0); +static CLEANED: AtomicUsize = AtomicUsize::new(0); +static FUTURE_DROPPED: AtomicUsize = AtomicUsize::new(0); +static ACK: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); +pub(super) fn retire_initial(vm: &mut Vm) -> VmResult { + // The host owns full mutable VM authority. Take the actual old capture via + // its typed key/generation; do not detach the guest capture cell or consume + // the newly published callback parameter. Its payload is always 191/192. + for value in vm.locals().to_vec() { + let Ok((true, payload)) = vm::result::payload(&value) else { + continue; + }; + let Ok(handle) = vm::result::resource_handle(payload) else { + continue; + }; + let initial = vm + .host_context() + .borrow_resource_with_key::( + handle, + &ResourceTypeKey::new(Success::KEY).unwrap(), + ) + .is_ok_and(|value| value.get().0 == 17); + if initial { + let owned = vm + .host_context() + .take_resource_with_key::( + handle, + &ResourceTypeKey::new(Success::KEY).unwrap(), + ) + .unwrap(); + drop(owned); + return Ok(1); + } + } + Err(vm::VmError::HostError("initial capture not found".into())) +} +struct PendingFuture; +impl std::future::Future for PendingFuture { + type Output = VmResult; + fn poll(self: std::pin::Pin<&mut Self>, _: &mut Context<'_>) -> Poll { + Poll::Pending + } +} +impl Drop for PendingFuture { + fn drop(&mut self) { + FUTURE_DROPPED.fetch_add(1, Ordering::SeqCst); + } +} +struct Bridge(Option); +impl vm::HostAsyncBridge for Bridge { + fn submit_op(&mut self, _: vm::HostOpId, future: vm::HostFuture) -> VmResult<()> { + assert!(self.0.replace(future).is_none()); + SUBMITTED.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn poll_op(&mut self, _: vm::HostOpId, _: &mut Context<'_>) -> Poll> { + panic!("submitted future only") + } + fn poll_submitted_op( + &mut self, + _: vm::HostOpId, + cx: &mut Context<'_>, + ) -> Poll> { + self.0.as_mut().unwrap().as_mut().poll(cx) + } + fn request_cancel_op( + &mut self, + _: vm::HostOpId, + _: vm::operation::OperationCancelReason, + ) -> VmResult<()> { + CANCELLED.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn poll_cancel_op(&mut self, _: vm::HostOpId, _: &mut Context<'_>) -> Poll> { + if ACK.load(Ordering::SeqCst) { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + fn cleanup_op(&mut self, _: vm::HostOpId, _: vm::HostAsyncOpTerminal) -> VmResult<()> { + drop(self.0.take().unwrap()); + CLEANED.fetch_add(1, Ordering::SeqCst); + Ok(()) + } +} +fn suspend(vm: &mut Vm, args: &[Value]) -> VmResult { + SUSPENDED.fetch_add(1, Ordering::SeqCst); + if args == [Value::Int(0)] { + Ok(vm::CallOutcome::Yield) + } else { + vm.submit_host_future(Box::pin(PendingFuture)) + } +} +pub(super) fn suspend_descriptor() -> vm::HostFunctionDescriptor { + vm::HostFunctionDescriptor { + schema: vm::HostFunctionSchema::with_return( + "result_resource::suspend", + vec![vm::HostParamSchema::value("mode", vm::HostTypeSchema::Int)], + vm::HostTypeSchema::Int, + ), + binding: vm::HostBindingDescriptor { + kind: vm::HostBindingKind::StaticStack, + }, + effects: Vec::new(), + adapter: vm::HostAdapterDescriptor::StaticStack(suspend), + resource_types: Vec::new(), + } +} +#[test] +fn vm_aware_post_admission_yield_and_real_wait_cancellation_before_move() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for mode in [0, 1] { + for branch in [true, false] { + let mut store = callback_store(); + store + .vm_mut() + .set_async_bridge(Box::new(Bridge(None))) + .unwrap(); + ACK.store(false, Ordering::SeqCst); + let callback = store + .vm_script_callback_by_name::<(OwnedResult, i64), OwnedResult>( + "suspend_before_move", + ) + .unwrap(); + let closed = CLOSED.load(Ordering::SeqCst); + let cancelled = CANCELLED.load(Ordering::SeqCst); + let cleaned = CLEANED.load(Ordering::SeqCst); + let dropped = FUTURE_DROPPED.load(Ordering::SeqCst); + let suspended = SUSPENDED.load(Ordering::SeqCst); + let submitted = SUBMITTED.load(Ordering::SeqCst); + let value = if branch { + GuestResult::Ok(ResourceOwned::new(Success(91))) + } else { + GuestResult::Err(ResourceOwned::new(Failure(92))) + }; + let error = callback + .call_with_vm(&mut store, (value, mode)) + .unwrap_err(); + assert!( + error.to_string().contains(if mode == 0 { + "host invocation yielded" + } else { + "host invocation is waiting" + }), + "{error}" + ); + assert_eq!(SUSPENDED.load(Ordering::SeqCst), suspended + 1); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 1); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + assert_eq!(store.vm().call_depth(), 0); + assert_eq!( + SUBMITTED.load(Ordering::SeqCst), + submitted + usize::from(mode == 1) + ); + assert_eq!( + CANCELLED.load(Ordering::SeqCst), + cancelled + usize::from(mode == 1) + ); + if mode == 1 { + let mut cx = Context::from_waker(std::task::Waker::noop()); + assert!(store.vm_mut().poll_waiting_host_op(&mut cx).is_pending()); + assert!( + !store.vm().is_reusable(), + "cancellation acknowledgement is still pending" + ); + assert_eq!(CLEANED.load(Ordering::SeqCst), cleaned); + assert_eq!(FUTURE_DROPPED.load(Ordering::SeqCst), dropped); + ACK.store(true, Ordering::SeqCst); + assert!(matches!( + store.vm_mut().poll_waiting_host_op(&mut cx), + Poll::Ready(Ok(())) + )); + assert_eq!(CLEANED.load(Ordering::SeqCst), cleaned + 1); + assert_eq!(FUTURE_DROPPED.load(Ordering::SeqCst), dropped + 1); + assert_eq!(CANCELLED.load(Ordering::SeqCst), cancelled + 1); + } + eprintln!( + "abort_suspend mode={mode} branch={branch} close=1 live=0 cancel_delta={} future_drop_delta={}", + CANCELLED.load(Ordering::SeqCst) - cancelled, + FUTURE_DROPPED.load(Ordering::SeqCst) - dropped + ); + } + } +} +#[test] +fn vm_aware_post_admission_close_failure_attempts_pending_sibling_and_preserves_primary() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let mut store = callback_store(); + let callback = store + .vm_script_callback_by_name::<(OwnedResult, OwnedResult), OwnedResult>("fail_pair") + .unwrap(); + PENDING_READY.store(false, Ordering::SeqCst); + let closed = CLOSED.load(Ordering::SeqCst); + let polls = CLOSE_POLLS.load(Ordering::SeqCst); + let dropped_ok = DROPPED_OK.load(Ordering::SeqCst); + let dropped_err = DROPPED_ERR.load(Ordering::SeqCst); + let error = callback + .call_with_vm( + &mut store, + ( + GuestResult::Ok(ResourceOwned::new(Success(-101))), + GuestResult::Err(ResourceOwned::new(Failure(-102))), + ), + ) + .unwrap_err(); + assert!( + matches!(&error, vm::VmError::HostError(message) if message == "callback transport failure"), + "{error}" + ); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 2); + assert_eq!(DROPPED_OK.load(Ordering::SeqCst), dropped_ok); + assert_eq!(DROPPED_ERR.load(Ordering::SeqCst), dropped_err); + assert_eq!(store.vm_mut().host_context().resource_count(), 2); + assert_eq!(CLOSE_POLLS.load(Ordering::SeqCst), polls); + store + .vm_mut() + .execution_scope() + .begin_close(ResourceCloseReason::Requested) + .unwrap(); + let mut cx = Context::from_waker(std::task::Waker::noop()); + assert!( + store + .vm_mut() + .execution_scope() + .poll_close(&mut cx) + .is_pending() + ); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 3); + assert!(!store.vm_mut().host_context().is_scope_quiescent()); + assert_eq!(store.vm_mut().host_context().resource_count(), 1); + PENDING_READY.store(true, Ordering::SeqCst); + assert!( + store + .vm_mut() + .execution_scope() + .poll_close(&mut cx) + .is_ready() + ); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + assert_eq!(DROPPED_OK.load(Ordering::SeqCst), dropped_ok + 1); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 3); + assert_eq!(DROPPED_ERR.load(Ordering::SeqCst), dropped_err + 1); + eprintln!( + "abort_close_error primary_preserved=true initial_sibling_begin=2 failed_open_plus_pending=2 shutdown_retry=1 pending_live=1 terminal_live=0 poll_delta={}", + CLOSE_POLLS.load(Ordering::SeqCst) - polls + ); +} +#[test] +fn vm_aware_native_opaque_state_owns_taken_resource_after_fatal() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for branch in [true, false] { + let mut store = callback_store(); + let callback = store + .vm_script_callback_by_name::<(OwnedResult,), OwnedResult>("retain_before_fatal") + .unwrap(); + let closed = CLOSED.load(Ordering::SeqCst); + let dropped_ok = DROPPED_OK.load(Ordering::SeqCst); + let dropped_err = DROPPED_ERR.load(Ordering::SeqCst); + let value = if branch { + GuestResult::Ok(ResourceOwned::new(Success(211))) + } else { + GuestResult::Err(ResourceOwned::new(Failure(212))) + }; + let error = callback.call_with_vm(&mut store, (value,)).unwrap_err(); + assert!(error.to_string().contains("callback transport failure")); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed); + assert_eq!(DROPPED_OK.load(Ordering::SeqCst), dropped_ok); + assert_eq!(DROPPED_ERR.load(Ordering::SeqCst), dropped_err); + let retained = store + .vm_mut() + .host_context() + .take_module_state::() + .unwrap(); + match &retained { + GuestResult::Ok(value) => assert_eq!(value.as_ref().0, 211), + GuestResult::Err(value) => assert_eq!(value.as_ref().0, 212), + } + drop(retained); + assert_eq!( + DROPPED_OK.load(Ordering::SeqCst), + dropped_ok + usize::from(branch) + ); + assert_eq!( + DROPPED_ERR.load(Ordering::SeqCst), + dropped_err + usize::from(!branch) + ); + } +} + +#[test] +fn vm_aware_transfer_to_surviving_root_capture_before_fatal_stays_usable() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for branch in [true, false] { + let mut store = vm::Store::from_vm(machine( + r#"use result_resource; + pub fn factory() { + let mut saved = result_resource::make(true); + fn callback(value: Result, resource>, action: int) -> int { + if action == 1 => { + result_resource::retire_initial(); + saved = value; result_resource::fatal(); result_resource::borrow(saved) + } else => { + result_resource::take(value); + if action == 0 => { result_resource::borrow(saved) } else => { result_resource::take(saved) } + } + } + callback + } + "#, + )); + store.run().unwrap(); + let factory = store.vm_mut().resolve_exported_callable("factory").unwrap(); + let callable = store.vm_mut().invoke_callable(factory, &[]).unwrap(); + let callback = store + .vm_script_callback::<(OwnedResult, i64), i64>(callable) + .unwrap(); + let alias = callback.clone(); + let closed = CLOSED.load(Ordering::SeqCst); + let value = if branch { + GuestResult::Ok(ResourceOwned::new(Success(191))) + } else { + GuestResult::Err(ResourceOwned::new(Failure(192))) + }; + let error = callback.call_with_vm(&mut store, (value, 1)).unwrap_err(); + assert!(error.to_string().contains("callback transport failure")); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed); + assert_eq!(store.vm_mut().host_context().resource_count(), 1); + assert_eq!( + alias + .call_with_vm( + &mut store, + (GuestResult::Ok(ResourceOwned::new(Success(901))), 0) + ) + .unwrap(), + if branch { 191 } else { 192 } + ); + assert_eq!( + callback + .call_with_vm( + &mut store, + (GuestResult::Ok(ResourceOwned::new(Success(902))), -1) + ) + .unwrap(), + if branch { 191 } else { 192 } + ); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed); + eprintln!( + "abort_surviving_capture branch={branch} after_fatal_live=1 borrowed={} final_live=0 close_delta=0", + if branch { 191 } else { 192 } + ); + } +} diff --git a/tests/support/result_sdk_success.rs b/tests/support/result_sdk_success.rs new file mode 100644 index 00000000..452ceb77 --- /dev/null +++ b/tests/support/result_sdk_success.rs @@ -0,0 +1,262 @@ +use super::*; + +fn owned(branch: bool, payload: i64) -> OwnedResult { + if branch { + GuestResult::Ok(ResourceOwned::new(Success(payload))) + } else { + GuestResult::Err(ResourceOwned::new(Failure(payload))) + } +} +fn assert_returned(value: &OwnedResult, branch: bool, payload: i64) { + match value { + GuestResult::Ok(value) => { + assert!(branch); + assert_eq!(value.as_ref().0, payload); + } + GuestResult::Err(value) => { + assert!(!branch); + assert_eq!(value.as_ref().0, payload); + } + } +} +#[test] +fn vm_aware_success_reclaims_unused_sibling_without_closing_returned_or_unrelated() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for branch in [true, false] { + let mut store = callback_store(); + let existing = store + .vm_mut() + .host_context() + .insert_resource(Failure(99)) + .unwrap() + .handle(); + let callback = store + .vm_script_callback_by_name::<(OwnedResult, OwnedResult), OwnedResult>("return_first") + .unwrap(); + let closed = CLOSED.load(Ordering::SeqCst); + let dropped_ok = DROPPED_OK.load(Ordering::SeqCst); + let dropped_err = DROPPED_ERR.load(Ordering::SeqCst); + let returned = callback + .call_with_vm(&mut store, (owned(branch, 301), owned(!branch, 302))) + .unwrap(); + assert_returned(&returned, branch, 301); + assert_eq!(OBSERVED_RESOURCE_COUNT.load(Ordering::SeqCst), 3); + assert_eq!( + store + .vm_mut() + .host_context() + .borrow_resource::(existing) + .unwrap() + .get() + .0, + 99 + ); + eprintln!( + "success_sibling branch={branch} live={} close_delta={} drop_ok_delta={} drop_err_delta={} returned_payload=301 unrelated_payload=99", + store.vm_mut().host_context().resource_count(), + CLOSED.load(Ordering::SeqCst) - closed, + DROPPED_OK.load(Ordering::SeqCst) - dropped_ok, + DROPPED_ERR.load(Ordering::SeqCst) - dropped_err + ); + assert_eq!( + store.vm_mut().host_context().resource_count(), + 1, + "unused publication must be reclaimed before VM destruction" + ); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 1); + assert_eq!( + DROPPED_OK.load(Ordering::SeqCst), + dropped_ok + usize::from(!branch) + ); + assert_eq!( + DROPPED_ERR.load(Ordering::SeqCst), + dropped_err + usize::from(branch) + ); + drop(returned); + store + .vm_mut() + .host_context() + .close_resource::(existing, ResourceCloseReason::Requested) + .unwrap(); + } +} +#[test] +fn vm_aware_success_reclaims_ignored_publication_and_returns_fresh_owner() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for branch in [true, false] { + let mut store = callback_store(); + let callback = store + .vm_script_callback_by_name::<(OwnedResult, bool), OwnedResult>("fresh_return") + .unwrap(); + let closed = CLOSED.load(Ordering::SeqCst); + let dropped_ok = DROPPED_OK.load(Ordering::SeqCst); + let dropped_err = DROPPED_ERR.load(Ordering::SeqCst); + let returned = callback + .call_with_vm(&mut store, (owned(branch, 303), !branch)) + .unwrap(); + assert_returned(&returned, !branch, if branch { 29 } else { 17 }); + eprintln!( + "success_fresh branch={branch} live={} close_delta={} drop_ok_delta={} drop_err_delta={}", + store.vm_mut().host_context().resource_count(), + CLOSED.load(Ordering::SeqCst) - closed, + DROPPED_OK.load(Ordering::SeqCst) - dropped_ok, + DROPPED_ERR.load(Ordering::SeqCst) - dropped_err + ); + assert_eq!( + store.vm_mut().host_context().resource_count(), + 0, + "ignored publication must be reclaimed before VM destruction" + ); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 1); + assert_eq!( + DROPPED_OK.load(Ordering::SeqCst), + dropped_ok + usize::from(branch) + ); + assert_eq!( + DROPPED_ERR.load(Ordering::SeqCst), + dropped_err + usize::from(!branch) + ); + drop(returned); + } +} + +#[test] +fn vm_aware_success_close_failure_attempts_pending_sibling_and_preserves_return() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + let mut store = callback_store(); + let callback = store + .vm_script_callback_by_name::<(OwnedResult, OwnedResult, OwnedResult), OwnedResult>( + "return_first_three", + ) + .unwrap(); + PENDING_READY.store(false, Ordering::SeqCst); + let closed = CLOSED.load(Ordering::SeqCst); + let returned = callback + .call_with_vm( + &mut store, + (owned(true, 304), owned(true, -101), owned(false, -102)), + ) + .unwrap(); + assert_returned(&returned, true, 304); + assert_eq!(store.vm_mut().host_context().resource_count(), 2); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 2); + store + .vm_mut() + .execution_scope() + .begin_close(ResourceCloseReason::Requested) + .unwrap(); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + assert!( + store + .vm_mut() + .execution_scope() + .poll_close(&mut cx) + .is_pending() + ); + assert_eq!(store.vm_mut().host_context().resource_count(), 1); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 3); + PENDING_READY.store(true, Ordering::SeqCst); + assert!( + store + .vm_mut() + .execution_scope() + .poll_close(&mut cx) + .is_ready() + ); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed + 3); + assert_returned(&returned, true, 304); + eprintln!( + "success_close_error return_payload=304 initial_sibling_begin=2 failed_open_plus_pending=2 shutdown_retry=1 pending_live=1 terminal_live=0" + ); +} + +#[test] +fn vm_aware_success_native_taken_owner_remains_in_opaque_state() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for branch in [true, false] { + let mut store = callback_store(); + let callback = store + .vm_script_callback_by_name::<(OwnedResult,), i64>("retain_success") + .unwrap(); + let closed = CLOSED.load(Ordering::SeqCst); + let dropped_ok = DROPPED_OK.load(Ordering::SeqCst); + let dropped_err = DROPPED_ERR.load(Ordering::SeqCst); + assert_eq!( + callback + .call_with_vm(&mut store, (owned(branch, 305),)) + .unwrap(), + 1 + ); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed); + assert_eq!(DROPPED_OK.load(Ordering::SeqCst), dropped_ok); + assert_eq!(DROPPED_ERR.load(Ordering::SeqCst), dropped_err); + let retained = store + .vm_mut() + .host_context() + .take_module_state::() + .unwrap(); + assert_returned(&retained, branch, 305); + drop(retained); + eprintln!( + "success_native_take branch={branch} retained_payload=305 table_live=0 close_delta=0" + ); + } +} + +#[test] +fn vm_aware_success_surviving_capture_remains_usable_through_alias() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for branch in [true, false] { + let mut store = vm::Store::from_vm(machine( + r#"use result_resource; + pub fn factory() { + let mut saved = result_resource::make(true); + fn callback(value: Result, resource>, action: int) -> int { + if action == 1 => { + result_resource::retire_initial(); saved = value; result_resource::borrow(saved) + } else => { + result_resource::take(value); + if action == 0 => { result_resource::borrow(saved) } else => { result_resource::take(saved) } + } + } + callback + } + "#, + )); + store.run().unwrap(); + let factory = store.vm_mut().resolve_exported_callable("factory").unwrap(); + let callable = store.vm_mut().invoke_callable(factory, &[]).unwrap(); + let callback = store + .vm_script_callback::<(OwnedResult, i64), i64>(callable) + .unwrap(); + let alias = callback.clone(); + let closed = CLOSED.load(Ordering::SeqCst); + assert_eq!( + callback + .call_with_vm(&mut store, (owned(branch, 306), 1)) + .unwrap(), + 306 + ); + assert_eq!(store.vm_mut().host_context().resource_count(), 1); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed); + assert_eq!( + alias + .call_with_vm(&mut store, (owned(true, 307), 0)) + .unwrap(), + 306 + ); + assert_eq!( + callback + .call_with_vm(&mut store, (owned(true, 308), -1)) + .unwrap(), + 306 + ); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + assert_eq!(CLOSED.load(Ordering::SeqCst), closed); + eprintln!( + "success_capture branch={branch} retained_payload=306 after_return_live=1 final_live=0 close_delta=0" + ); + } +} From 5d65a8553c6a3b697906447024ab2cfba3f48b7c Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 18 Oct 2026 05:18:20 +0800 Subject: [PATCH 4/5] fix(result): complete language service schemas and source diagnostics --- pd-vm-wasm/src/analyzer.rs | 29 ++ pd-vm-wasm/src/lib.rs | 7 + pd-vm-wasm/tests/fixtures/result_consumer.rs | 338 +++++++++++++++++++ src/compiler/diagnostics.rs | 9 + src/compiler/frontends/mod.rs | 337 ++++++++++++++++-- src/compiler/parser/format.rs | 6 +- tests/result_language_service_tests.rs | 169 ++++++++++ 7 files changed, 871 insertions(+), 24 deletions(-) create mode 100644 pd-vm-wasm/tests/fixtures/result_consumer.rs create mode 100644 tests/result_language_service_tests.rs diff --git a/pd-vm-wasm/src/analyzer.rs b/pd-vm-wasm/src/analyzer.rs index 4c671efc..639eb51e 100644 --- a/pd-vm-wasm/src/analyzer.rs +++ b/pd-vm-wasm/src/analyzer.rs @@ -98,6 +98,35 @@ fn lint_compile_result( LintReport { diagnostics } } } + Err(SourcePathError::SourceWithMap { + error: SourceError::Compile(err), + sources, + }) if matches!( + &err, + vm::CompileError::StrictTypingRequired { span: Some(_), .. } + ) => + { + let vm::CompileError::StrictTypingRequired { + span: Some(span), .. + } = &err + else { + unreachable!(); + }; + let lint_span = lint_span_from_source_span(&sources, span.source_id, span.lo, span.hi); + let mut diagnostics = + lint_trailing_function_return_semicolon_diagnostics(source, flavor); + diagnostics.extend(lint_unknown_inferred_local_diagnostics( + source, flavor, path, options, + )); + diagnostics.push(LintDiagnostic { + line: lint_span.as_ref().map_or(0, |span| span.start_line), + severity: LintSeverity::Error, + message: err.diagnostic_message(), + span: lint_span, + rendered: render_compile_error(&sources, &err, true), + }); + LintReport { diagnostics } + } // Milestone 5+ load/merge errors carry the compilation-wide source // map; unwrap it so the branch below renders from the owning source. Err(SourcePathError::SourceWithMap { error, .. }) => lint_compile_result( diff --git a/pd-vm-wasm/src/lib.rs b/pd-vm-wasm/src/lib.rs index 23d5f979..7282f6ff 100644 --- a/pd-vm-wasm/src/lib.rs +++ b/pd-vm-wasm/src/lib.rs @@ -10,6 +10,10 @@ use std::path::Path; #[path = "../tests/fixtures/coherent_typehint.rs"] mod coherent_typehint; +#[cfg(test)] +#[path = "../tests/fixtures/result_consumer.rs"] +mod result_consumer; + use serde::{Deserialize, Serialize}; use vm::{ CompileSourceFileOptions, InferredLocalTypeHint, SourceFlavor, @@ -96,6 +100,9 @@ fn type_schema_to_json(schema: &vm::TypeSchema) -> serde_json::Value { TypeSchema::Bytes => json!({"kind": "bytes"}), TypeSchema::GenericParam(name) => json!({"kind": "generic_param", "name": name}), TypeSchema::Resource(key) => json!({"kind": "resource", "key": key.as_str()}), + TypeSchema::Result { ok, err } => { + json!({"kind": "result", "ok": type_schema_to_json(ok), "err": type_schema_to_json(err)}) + } TypeSchema::Optional(inner) => { json!({"kind": "optional", "inner": type_schema_to_json(inner)}) } diff --git a/pd-vm-wasm/tests/fixtures/result_consumer.rs b/pd-vm-wasm/tests/fixtures/result_consumer.rs new file mode 100644 index 00000000..ee572270 --- /dev/null +++ b/pd-vm-wasm/tests/fixtures/result_consumer.rs @@ -0,0 +1,338 @@ +use super::*; +use serde_json::{Value, json}; + +fn result_schema(ok: Value, err: Value) -> Value { + json!({"kind": "result", "ok": ok, "err": err}) +} + +fn response(bytes: Vec) -> Value { + serde_json::from_slice(&bytes).expect("service response must be JSON") +} + +fn hint<'a>(response: &'a Value, name: &str) -> &'a Value { + assert!(response.get("error").is_none(), "{response}"); + response["hints"] + .as_array() + .unwrap() + .iter() + .find(|hint| hint["name"] == name) + .unwrap_or_else(|| panic!("missing hint {name}: {response}")) +} + +#[test] +fn result_public_basic_hints_keep_ok_err_and_callable_payload_schemas() { + let source = "fn increment(value: int) -> int { value + 1 }\nlet success: Result = Result::Ok(7);\nlet failure: Result = Result::Err(\"bad\");\nlet function: Result int, Result> = Result::Ok(increment);\n"; + let payload = response(local_type_hints_json_response(source, "rss")); + println!("RESULT_OBSERVATION basic-hints={payload}"); + let int = json!({"kind":"int"}); + let basic = result_schema(int.clone(), json!({"kind":"string"})); + assert_eq!(hint(&payload, "success")["schema"], basic); + assert_eq!(hint(&payload, "failure")["schema"], basic); + assert_eq!( + hint(&payload, "function")["schema"], + result_schema( + json!({"kind":"callable", "params":[int], "result":int}), + basic + ) + ); +} + +#[test] +fn result_public_hints_preserve_recursive_types_module_identity_and_arm_bindings() { + let source = "// 中文\r\nuse self::lib as lib;\r\nlet returned = lib::make();\r\nlet matched = match returned { Result::Ok(value) => value.value, Result::Err(error) => 0 };\r\nlet nested: Result<[Result?], Result> = Result::Ok([null]);\r\nlet optional: Result? = null;\r\nfn relay(input: Result) -> Result { let number = input?; Result::Ok(\"done\") }\r\nlet relay_fn = relay;\r\nlet propagated = relay(Result::Err(\"bad\"));\r\n"; + let library = "struct Box { value: T }\npub fn make() -> Result, Box> { Result::Ok({ value: 42 }) }\nlet failure: Result = Result::Err(\"bad\");\n"; + let overrides = json!([{"path":"./lib.rss", "source":library}]).to_string(); + let payload = response(local_type_hints_json_with_context_response( + source, + "rss", + "/neutral/main.rss", + &overrides, + )); + println!("RESULT_OBSERVATION hints={payload}"); + let named = |arg| json!({"kind":"named", "name":hint(&payload, "value")["schema"]["name"], "args":[arg]}); + let int = json!({"kind":"int"}); + let string = json!({"kind":"string"}); + let basic = result_schema(int.clone(), string.clone()); + assert_eq!( + hint(&payload, "returned")["schema"], + result_schema(named(int.clone()), named(string.clone())) + ); + assert_eq!(hint(&payload, "value")["schema"], named(int.clone())); + assert_eq!(hint(&payload, "error")["schema"], named(string.clone())); + assert_eq!(hint(&payload, "matched")["schema"], int); + assert_eq!( + hint(&payload, "nested")["schema"], + result_schema( + json!({"kind":"array", "item":{"kind":"optional", "inner":basic}}), + result_schema(json!({"kind":"optional", "inner":string}), int.clone()) + ) + ); + assert_eq!( + hint(&payload, "optional")["schema"], + json!({"kind":"optional", "inner":basic}) + ); + assert_eq!(hint(&payload, "number")["schema"], int); + let outer = result_schema(string.clone(), string.clone()); + assert_eq!(hint(&payload, "propagated")["schema"], outer); + assert_eq!( + hint(&payload, "relay_fn")["schema"], + json!({"kind":"callable", "params":[basic], "result":outer}) + ); + assert_eq!( + hint(&payload, "returned")["inferred_type"], + "Result, Box>" + ); + assert_eq!(hint(&payload, "value")["inferred_type"], "Box"); + assert_eq!(hint(&payload, "error")["inferred_type"], "Box"); + assert_eq!(hint(&payload, "returned")["declared_line"], 3); + assert_eq!(hint(&payload, "returned")["last_line"], 4); + assert_eq!( + hint(&payload, "propagated")["inferred_type"], + "Result" + ); + for name in [ + "returned", + "value", + "error", + "input", + "nested", + "optional", + "relay_fn", + "propagated", + "number", + ] { + let item = hint(&payload, name); + let span = &item["span"]; + assert_eq!(span["source_id"], 0); + assert_eq!(item["source_name"], "/neutral/main.rss"); + assert_eq!( + &source[span["lo"].as_u64().unwrap() as usize..span["hi"].as_u64().unwrap() as usize], + name + ); + } + let item = hint(&payload, "failure"); + assert_eq!(item["schema"], basic); + assert_eq!(item["span"]["source_id"], 1); + let span = &item["span"]; + assert_eq!( + &library[span["lo"].as_u64().unwrap() as usize..span["hi"].as_u64().unwrap() as usize], + "failure" + ); +} + +#[test] +fn result_public_format_preserves_try_and_is_idempotent() { + let source = "fn fail()->Result{Result::Err(\"bad\")}\nfn relay()->Result{let number=fail()?;Result::Ok(\"done\")}\nlet result=relay();\n"; + let formatted = response(format_source_json_response(source, "rss")); + println!("RESULT_OBSERVATION format={formatted}"); + assert_eq!(formatted["ok"], true); + let text = formatted["formatted"].as_str().unwrap(); + // The shared formatter may wrap a long function body between tokens. + // Whitespace must not change the postfix operator or constructor tokens. + assert!( + text.split_whitespace() + .collect::() + .contains("fail()?"), + "{text}" + ); + assert!(text.contains("Result"), "{text}"); + assert_eq!( + response(format_source_json_response(text, "rss")), + formatted + ); +} + +#[test] +fn result_public_lint_preserves_try_and_typed_diagnostics() { + let source = "fn fail() -> Result {\n Result::Err(\"bad\")\n}\nfn relay() -> Result {\n let number = fail()?;\n Result::Ok(\"done\")\n}\nlet result = relay();\n"; + let formatted = response(format_source_json_response(source, "rss")); + assert_eq!(formatted["ok"], true); + let text = formatted["formatted"].as_str().unwrap(); + let lint = response(lint_source_json_response(text, "rss")); + println!("RESULT_OBSERVATION lint={lint}"); + assert_eq!(lint["diagnostics"], json!([])); + let wrong = "fn fail() -> Result { Result::Err(\"bad\") }\nfn wrong() -> Result {\n let number = fail()?;\n Result::Ok(number)\n}\n"; + let compile_error = vm::compile_source_with_flavor_and_options( + wrong, + SourceFlavor::RustScript, + stdlib::embedded_stdlib_compile_options(), + ) + .err() + .expect("wrong E must fail compilation"); + match &compile_error { + vm::SourcePathError::SourceWithMap { error, sources } => { + println!("RESULT_ERROR_ORIGIN {error:?}"); + for id in 0..3 { + println!( + "RESULT_ERROR_SOURCE {id} {:?}", + sources.file(id).map(|file| (&file.name, file.text.len())) + ); + } + } + other => println!("RESULT_ERROR_ORIGIN {other:?}"), + } + let lint = response(lint_source_json_response(wrong, "rss")); + println!("RESULT_OBSERVATION mismatch={lint}"); + let error = lint["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|d| d["severity"] == "error") + .expect("wrong E must be rejected"); + assert!( + error["message"].as_str().unwrap().contains("string"), + "{lint}" + ); + assert!(error["message"].as_str().unwrap().contains("int"), "{lint}"); + assert_eq!(error["line"], 3); + assert_eq!(error["span"]["start_line"], 3); + assert!( + error["span"]["end_col"].as_u64().unwrap() > error["span"]["start_col"].as_u64().unwrap() + ); + assert!( + error["rendered"].as_str().unwrap().contains("fail()?"), + "{lint}" + ); + let rejected = response(local_type_hints_json_response(wrong, "rss")); + assert_eq!(rejected["hints"], json!([])); + assert!(rejected["error"].is_string()); +} + +#[test] +fn result_public_dependency_lint_keeps_original_owner_and_exact_try_extent() { + let library = "// 変換\r\nfn fail() -> Result { Result::Err(\"bad\") }\r\npub fn wrong() -> Result {\r\n let number = fail()?;\r\n Result::Ok(number)\r\n}\r\n"; + let source = "use self::lib as lib;\nlib::wrong();\n"; + let overrides = json!([{"path":"./lib.rss", "source":library}]).to_string(); + for _ in 0..2 { + let lint = response(lint_source_json_with_context_response( + source, + "rss", + "/neutral/main.rss", + &overrides, + )); + println!("RESULT_OBSERVATION dependency-mismatch={lint}"); + let error = lint["diagnostics"] + .as_array() + .unwrap() + .iter() + .find(|d| d["severity"] == "error") + .expect("wrong E must fail"); + assert!( + error["message"] + .as_str() + .unwrap() + .contains("? error type string is not int") + ); + assert_eq!(error["line"], 4); + assert_eq!(error["span"]["start_line"], 4); + assert_eq!(error["span"]["end_line"], 4); + assert_eq!(error["span"]["start_col"], 24); + assert_eq!(error["span"]["end_col"], 25); + assert!( + error["rendered"] + .as_str() + .unwrap() + .contains("/neutral/lib.rss") + ); + assert!(error["rendered"].as_str().unwrap().contains("fail()?")); + } +} + +#[cfg(feature = "runtime")] +#[test] +fn result_native_runtime_response_json_distinguishes_guest_branches_and_fatal_error() { + for (constructor, expected) in [ + ("Result::Ok(7)", "ok:8"), + ("Result::Err(\"bad\")", "err:bad"), + ] { + let source = format!( + r#" +fn relay(input: Result) -> Result {{ + let number = input?; + print("after-try"); + Result::Ok(number + 1) +}} +let returned = relay({constructor}); +match returned {{ + Result::Ok(value) => print("ok:" + value), + Result::Err(error) => print("err:" + error), +}}; +print("caller-continues"); +"# + ); + let payload = response(run_source_json_response( + &source, + "rss", + Some(r#"{"fuel":100000,"fuelCheckInterval":1}"#), + )); + println!("RESULT_OBSERVATION native-runtime-{constructor}={payload}"); + assert_eq!(payload["ok"], true, "{payload}"); + assert!(payload["error"].is_null(), "{payload}"); + assert_eq!(payload["halted"], true); + assert_eq!(payload["yielded"], false); + assert_eq!(payload["diagnostics"], json!([])); + assert_eq!(payload["fuel"]["enabled"], true); + let expected_output = if constructor.contains("::Ok") { + json!(["after-try", expected, "caller-continues"]) + } else { + json!([expected, "caller-continues"]) + }; + assert_eq!(payload["output"], expected_output); + } + let fatal = response(run_source_json_response( + "assert(false);", + "rss", + Some(r#"{"fuel":100000,"fuelCheckInterval":1}"#), + )); + println!("RESULT_OBSERVATION native-runtime-fatal={fatal}"); + assert_eq!(fatal["ok"], false); + assert!(fatal["error"].is_string()); + assert_eq!(fatal["halted"], true); +} + +#[test] +fn result_schema_json_recurses_through_both_branches_and_containers() { + use vm::TypeSchema as S; + let result = S::Result { + ok: Box::new(S::Int), + err: Box::new(S::String), + }; + let expected = result_schema(json!({"kind":"int"}), json!({"kind":"string"})); + let nested = S::Result { + ok: Box::new(S::Optional(Box::new(S::Array(Box::new(result.clone()))))), + err: Box::new(S::Callable { + params: vec![S::Named("Box".into(), vec![result.clone()])], + result: Box::new(result.clone()), + }), + }; + assert_eq!( + type_schema_to_json(&nested), + result_schema( + json!({"kind":"optional", "inner":{"kind":"array", "item":expected}}), + json!({"kind":"callable", "params":[{"kind":"named", "name":"Box", "args":[expected]}], "result":expected}), + ) + ); + for (schema, expected) in [ + ( + S::Map(Box::new(result.clone())), + json!({"kind":"map", "item":expected}), + ), + ( + S::ArrayTuple(vec![result.clone()]), + json!({"kind":"tuple", "items":[expected]}), + ), + ( + S::ArrayTupleRest { + prefix: vec![result.clone()], + rest: Box::new(result.clone()), + }, + json!({"kind":"tuple_rest", "prefix":[expected], "rest":expected}), + ), + ( + S::Object(std::collections::HashMap::from([("value".into(), result)])), + json!({"kind":"object", "fields":{"value":expected}}), + ), + ] { + assert_eq!(type_schema_to_json(&schema), expected); + } +} diff --git a/src/compiler/diagnostics.rs b/src/compiler/diagnostics.rs index 832b0bb1..d1679a5f 100644 --- a/src/compiler/diagnostics.rs +++ b/src/compiler/diagnostics.rs @@ -19,6 +19,15 @@ pub fn render_source_error(source_map: &SourceMap, err: &ParseError, _styled: bo pub fn render_compile_error(source_map: &SourceMap, err: &CompileError, _styled: bool) -> String { let message = err.diagnostic_message(); + // Result typing diagnostics retain an exact parser span. Its source id is + // authoritative even when no legacy line/source-name fields are present. + if let CompileError::StrictTypingRequired { + span: Some(span), .. + } = err + && let Some(rendered) = render_span_snippet(source_map, *span, &message) + { + return format!("compile error: {}", rendered.trim_end()); + } let source_name = err.source_name(); // Resolve the owning source id: by name when the error names its source, diff --git a/src/compiler/frontends/mod.rs b/src/compiler/frontends/mod.rs index fc110447..3a18518e 100644 --- a/src/compiler/frontends/mod.rs +++ b/src/compiler/frontends/mod.rs @@ -698,15 +698,7 @@ fn parse_lowered_with_mapping( host_catalog, ) { Ok(mut ir) => { - remap_lowered_spans( - ir.parsed_semantic_index.as_mut(), - &mut ir.unknown_type_spans, - &mut ir.lexer_tokens, - &mut ir.use_declarations, - &lowered, - lowered_source_id, - original_source_id, - ); + remap_lowered_spans(&mut ir, &lowered, lowered_source_id, original_source_id); Ok(ir) } Err(mut err) => { @@ -769,10 +761,7 @@ fn parse_lowered_repl_with_mapping( ) { Ok(mut parsed) => { remap_lowered_spans( - parsed.ir.parsed_semantic_index.as_mut(), - &mut parsed.ir.unknown_type_spans, - &mut parsed.ir.lexer_tokens, - &mut parsed.ir.use_declarations, + &mut parsed.ir, &lowered, lowered_source_id, original_source_id, @@ -822,10 +811,7 @@ fn parse_lowered_repl_with_mapping( /// `lowered.byte_mapping`, which is generated during lowering — never from /// searching the source text afterwards. fn remap_lowered_spans( - parsed_index: Option<&mut crate::compiler::ir::ParsedSemanticIndex>, - unknown_type_spans: &mut [Span], - lexer_tokens: &mut [crate::compiler::ir::LexerToken], - use_declarations: &mut [crate::compiler::modules::UseDecl], + ir: &mut FrontendIr, lowered: &LoweredSource, lowered_source_id: u32, original_source_id: u32, @@ -840,7 +826,16 @@ fn remap_lowered_spans( } }; - if let Some(index) = parsed_index { + for stmt in &mut ir.stmts { + remap_result_stmt_spans(stmt, &map); + } + for function in ir.function_impls.values_mut() { + for stmt in &mut function.body_stmts { + remap_result_stmt_spans(stmt, &map); + } + remap_result_expr_spans(&mut function.body_expr, &map); + } + if let Some(index) = ir.parsed_semantic_index.as_mut() { for site in &mut index.call_sites { map(&mut site.callee_span); map(&mut site.expr_span); @@ -865,17 +860,176 @@ fn remap_lowered_spans( map(&mut site.span); } } - for span in unknown_type_spans { + for span in &mut ir.unknown_type_spans { map(span); } - for token in lexer_tokens { + for token in &mut ir.lexer_tokens { map(&mut token.span); } - for decl in use_declarations { + for decl in &mut ir.use_declarations { map(&mut decl.span); } } +// Result nodes carry diagnostic spans outside the semantic index. Walk all +// expression/statement children, including closures and ordinary match defaults, +// at the same frontend boundary; leave schemas and semantic identities intact. +fn remap_result_stmt_spans(stmt: &mut super::ir::Stmt, map: &impl Fn(&mut Span)) { + use super::ir::Stmt; + match stmt { + Stmt::Let { expr, .. } | Stmt::Assign { expr, .. } | Stmt::Expr { expr, .. } => { + remap_result_expr_spans(expr, map); + } + Stmt::ClosureLet { closure, .. } => remap_result_expr_spans(&mut closure.body, map), + Stmt::IfElse { + condition, + then_branch, + else_branch, + .. + } => { + remap_result_expr_spans(condition, map); + for stmt in then_branch.iter_mut().chain(else_branch) { + remap_result_stmt_spans(stmt, map); + } + } + Stmt::For { + init, + condition, + post, + body, + .. + } => { + remap_result_stmt_spans(init, map); + remap_result_expr_spans(condition, map); + remap_result_stmt_spans(post, map); + for stmt in body { + remap_result_stmt_spans(stmt, map); + } + } + Stmt::While { + condition, body, .. + } => { + remap_result_expr_spans(condition, map); + for stmt in body { + remap_result_stmt_spans(stmt, map); + } + } + Stmt::Noop { .. } + | Stmt::FuncDecl { .. } + | Stmt::Break { .. } + | Stmt::Continue { .. } + | Stmt::Drop { .. } => {} + } +} + +fn remap_result_expr_spans(expr: &mut super::ir::Expr, map: &impl Fn(&mut Span)) { + use super::ir::Expr; + match expr { + Expr::ResultConstruct { payload, span, .. } => { + map(span); + remap_result_expr_spans(payload, map); + } + Expr::ResultQuery { value, span, .. } | Expr::ResultTry { value, span, .. } => { + map(span); + remap_result_expr_spans(value, map); + } + Expr::ResultMatch { + value, + ok_expr, + err_expr, + span, + .. + } => { + map(span); + remap_result_expr_spans(value, map); + remap_result_expr_spans(ok_expr, map); + remap_result_expr_spans(err_expr, map); + } + Expr::OptionalGet { container, key, .. } => { + remap_result_expr_spans(container, map); + remap_result_expr_spans(key, map); + } + Expr::OptionUnwrapOr { + value, fallback, .. + } => { + remap_result_expr_spans(value, map); + remap_result_expr_spans(fallback, map); + } + Expr::Call(_, _, args, _, _) + | Expr::ModuleCall(_, _, args, _) + | Expr::LocalCall(_, _, args, _) => { + for arg in args { + remap_result_expr_spans(arg, map); + } + } + Expr::Closure(closure) => remap_result_expr_spans(&mut closure.body, map), + Expr::ClosureCall(closure, args) => { + remap_result_expr_spans(&mut closure.body, map); + for arg in args { + remap_result_expr_spans(arg, map); + } + } + Expr::Add(lhs, rhs) + | Expr::Sub(lhs, rhs) + | Expr::Mul(lhs, rhs) + | Expr::Div(lhs, rhs) + | Expr::Mod(lhs, rhs) + | Expr::And(lhs, rhs) + | Expr::Or(lhs, rhs) + | Expr::Eq(lhs, rhs) + | Expr::Lt(lhs, rhs) + | Expr::Gt(lhs, rhs) => { + remap_result_expr_spans(lhs, map); + remap_result_expr_spans(rhs, map); + } + Expr::Neg(inner) + | Expr::Not(inner) + | Expr::ToOwned(inner) + | Expr::Borrow(inner) + | Expr::BorrowMut(inner) => remap_result_expr_spans(inner, map), + Expr::IfElse { + condition, + then_expr, + else_expr, + } => { + remap_result_expr_spans(condition, map); + remap_result_expr_spans(then_expr, map); + remap_result_expr_spans(else_expr, map); + } + Expr::Match { + value, + arms, + default, + .. + } => { + remap_result_expr_spans(value, map); + for (_, arm) in arms { + remap_result_expr_spans(arm, map); + } + remap_result_expr_spans(default, map); + } + Expr::Block { stmts, expr } => { + for stmt in stmts { + remap_result_stmt_spans(stmt, map); + } + remap_result_expr_spans(expr, map); + } + Expr::Null + | Expr::Int(_) + | Expr::Float(_) + | Expr::Bool(_) + | Expr::String(_) + | Expr::Bytes(_) + | Expr::FunctionRef(..) + | Expr::ModuleFunctionRef(..) + | Expr::UnresolvedFunctionRef { .. } + | Expr::Var(_) + | Expr::MoveVar(_) + | Expr::MoveField { .. } + | Expr::MoveIndex { .. } => {} + } +} + #[cfg(test)] mod host_catalog_frontend_tests { use std::sync::Arc; @@ -1860,6 +2014,147 @@ mod lowered_provenance_remap_tests { .to_string() } + #[test] + fn result_nodes_remap_in_ordinary_and_repl_with_nonidentity_lowering() { + use crate::compiler::ir::{Expr, Stmt}; + + let source = "// 変換\r\nlet value: Result = Result::Ok(7);\r\nlet flag = value.is_ok();\r\nlet picked = match value { Result::Ok(v) => v, Result::Err(e) => 0 };\r\nfn relay(input: Result) -> Result { let number = input?; Result::Ok(number) }\r\n"; + for original_id in [7, 17] { + for repl in [false, true] { + let mut builder = LoweringBuilder::new(source); + builder.insert("// generated prefix\n"); + builder.copy_rest(); + let lowered = builder.finish(); + let ir = if repl { + super::parse_lowered_repl_with_mapping( + source, + lowered, + &[], + (original_id, false), + false, + true, + None, + ) + .expect("REPL Result source parses") + .ir + } else { + parse_lowered_with_mapping( + source, + lowered, + false, + false, + true, + original_id, + false, + None, + ) + .expect("ordinary Result source parses") + }; + let mut spans = Vec::new(); + for stmt in &ir.stmts { + if let Stmt::Let { expr, .. } = stmt { + match expr { + Expr::ResultConstruct { + span, semantic_id, .. + } + | Expr::ResultQuery { + span, semantic_id, .. + } + | Expr::ResultMatch { + span, semantic_id, .. + } => { + spans.push(*span); + assert!( + semantic_id.is_none(), + "parser Result identity is unchanged" + ); + } + _ => {} + } + } + } + let function = ir.function_impls.values().next().unwrap(); + let Stmt::Let { + expr: + Expr::ResultTry { + span, semantic_id, .. + }, + .. + } = &function.body_stmts[0] + else { + panic!("expected try in function statement"); + }; + assert!(semantic_id.is_none()); + spans.push(*span); + let Expr::ResultConstruct { span, .. } = &function.body_expr else { + panic!("expected Result function tail"); + }; + spans.push(*span); + assert_eq!(spans.len(), 5); + let slices: Vec<_> = spans + .iter() + .map(|span| { + assert_eq!(span.source_id, original_id); + span_slice(source, *span) + }) + .collect(); + assert_eq!( + slices, + [ + "Result::Ok(7)", + "value.is_ok()", + ";", + "?", + "Result::Ok(number)" + ] + ); + } + } + } + + #[test] + fn result_remap_preserves_other_source_ids_and_semantic_identity() { + use crate::compiler::ir::{Expr, SemanticNodeId, Stmt}; + + let source = "let value: Result = Result::Ok(7);"; + let lowered = rustscript::lower(source).unwrap(); + let mut ir = + parse_lowered_with_mapping(source, lowered.clone(), false, false, true, 7, false, None) + .unwrap(); + let Stmt::Let { + expr: Expr::ResultConstruct { semantic_id, .. }, + .. + } = &mut ir.stmts[0] + else { + panic!("expected Result constructor"); + }; + *semantic_id = Some(SemanticNodeId(42)); + for source_id in [7, 41] { + let Stmt::Let { + expr: Expr::ResultConstruct { span, .. }, + .. + } = &mut ir.stmts[0] + else { + unreachable!() + }; + span.source_id = source_id; + let expected = *span; + super::remap_lowered_spans(&mut ir, &lowered, 8, 7); + let Stmt::Let { + expr: + Expr::ResultConstruct { + span, semantic_id, .. + }, + .. + } = &ir.stmts[0] + else { + unreachable!() + }; + assert_eq!(*span, expected); + assert_eq!(*semantic_id, Some(SemanticNodeId(42))); + } + } + /// Identity lowering: every provenance span carries the original source /// id and slices the exact original call/local/function/scope text. #[test] diff --git a/src/compiler/parser/format.rs b/src/compiler/parser/format.rs index 2c0ce3f2..82c5e605 100644 --- a/src/compiler/parser/format.rs +++ b/src/compiler/parser/format.rs @@ -180,9 +180,9 @@ impl<'a> SourceFormatter<'a> { if self.pending_code_break && !gap_result.saw_line_break { self.request_newline(1); } - if gap_result.saw_line_break { - self.pending_code_break = false; - } + // The next token consumes this break whether the input supplied + // the newline or we generated it. emit_token may queue a new one. + self.pending_code_break = false; self.emit_token()?; } diff --git a/tests/result_language_service_tests.rs b/tests/result_language_service_tests.rs new file mode 100644 index 00000000..52215603 --- /dev/null +++ b/tests/result_language_service_tests.rs @@ -0,0 +1,169 @@ +#![cfg(feature = "runtime")] + +use vm::{CompileSourceFileOptions, SourceFlavor, SourcePathError}; + +fn assert_origin(source: &str, entry: &str, dependency: bool, expected_slice: &str) { + let path = std::path::Path::new("/result-provenance/main.rss"); + let options = if dependency { + CompileSourceFileOptions::new().with_module_override_source("lib.rss", source) + } else { + CompileSourceFileOptions::new() + }; + let error = vm::compile_source_at_path_with_flavor_and_options( + path, + entry, + SourceFlavor::RustScript, + options, + ) + .err() + .expect("admitted Result typing error must fail compilation"); + let SourcePathError::SourceWithMap { error, sources } = error else { + panic!("expected full source ownership: {error:?}"); + }; + let rendered = match &error { + vm::SourceError::Compile(error) => vm::render_compile_error(&sources, error, true), + other => panic!("expected compile diagnostic: {other:?}"), + }; + let vm::SourceError::Compile(vm::CompileError::StrictTypingRequired { + span: Some(span), + detail, + .. + }) = error + else { + panic!("expected Result typing diagnostic: {error:?}"); + }; + let owner = sources + .file(span.source_id) + .expect("diagnostic source must exist"); + assert_eq!(owner.text, source); + assert!( + owner + .name + .ends_with(if dependency { "/lib.rss" } else { "/main.rss" }) + ); + if dependency { + assert_ne!( + span.source_id, 0, + "dependency must retain its own source id" + ); + assert_eq!(sources.file(0).unwrap().text, entry); + } + assert_eq!(&owner.text[span.lo..span.hi], expected_slice); + assert!(rendered.contains(&owner.name), "{rendered}"); + assert!(rendered.contains(expected_slice), "{rendered}"); + let (line, col) = sources + .line_col_for_offset(span.source_id, span.lo) + .unwrap(); + let prefix = &source[..span.lo]; + assert_eq!( + line, + prefix.bytes().filter(|byte| *byte == b'\n').count() + 1 + ); + assert_eq!(col, prefix.rsplit('\n').next().unwrap().chars().count() + 1); + println!( + "RESULT_SOURCE_ORIGIN id={} name={} line={line} col={col} slice={expected_slice:?} detail={detail}", + span.source_id, owner.name + ); +} + +#[test] +fn result_diagnostic_source_map_owns_standalone_and_imported_try() { + let source = "// 変換\r\nfn fail() -> Result { Result::Err(\"bad\") }\r\npub fn wrong() -> Result {\r\n let number = fail()?;\r\n Result::Ok(number)\r\n}\r\n"; + for _ in 0..2 { + assert_origin(source, source, false, "?"); + assert_origin(source, "use self::lib as lib;\nlib::wrong();\n", true, "?"); + } +} + +#[test] +fn result_diagnostic_source_map_owns_nested_function_and_closure_try() { + for body in [ + "fn inner() -> Result { Result::Ok(fail()?) } inner()", + "let inner: fn() -> Result = || Result::Ok(fail()?); inner()", + ] { + let source = format!( + "// 変換\r\nfn fail() -> Result {{ Result::Err(\"bad\") }}\r\npub fn wrong() -> Result {{ {body} }}\r\n" + ); + assert_origin(&source, &source, false, "?"); + assert_origin(&source, "use self::lib as lib;\nlib::wrong();\n", true, "?"); + } +} + +#[test] +fn result_diagnostic_source_map_owns_constructor_and_match() { + for (source, slice) in [ + ( + "// 変換\r\nlet value: Result = Result::Ok(\"bad\");\r\n", + "Result::Ok(\"bad\")", + ), + // The parser currently assigns ResultMatch the next-token span. + // This regression verifies its exact remapping/ownership, not a new extent. + ( + "// 変換\r\nlet value = match 1 { Result::Ok(v) => v, Result::Err(e) => 0 };\r\n", + ";", + ), + ] { + assert_origin(source, source, false, slice); + assert_origin(source, "use self::lib as lib;\n", true, slice); + } +} + +fn execute(source: &str) -> Vec { + let compiled = vm::compile_source(source).expect("formatter control must type check"); + let mut machine = vm::Vm::new(compiled.program.with_local_count(compiled.locals)); + machine.set_fuel(100_000); + assert_eq!(machine.run().unwrap(), vm::VmStatus::Halted); + machine.stack().to_vec() +} + +fn assert_format(source: &str) -> String { + let before = execute(source); + let once = vm::format_source(source).expect("format succeeds"); + let twice = vm::format_source(&once).expect("second format succeeds"); + assert_eq!(once, twice, "compact-source formatting must be idempotent"); + assert_eq!( + execute(&once), + before, + "format must preserve observable execution" + ); + println!("RESULT_FORMAT_EXAMPLE {once:?}"); + once +} + +#[test] +fn compact_result_format_is_idempotent_and_preserves_execution() { + for constructor in ["Result::Ok(7)", "Result::Err(\"bad\")"] { + let source = format!( + "fn relay(value:Result)->Result{{let n=value?;Result::Ok(n+1)}}let result=relay({constructor});let answer=match result{{Result::Ok(v)=>v,Result::Err(e)=>-1}};answer;" + ); + let formatted = assert_format(&source); + assert!(formatted.contains("value?")); + assert!(formatted.contains("Result")); + assert!(formatted.contains("Result::Ok(n + 1)")); + } +} + +#[test] +fn compact_ordinary_call_block_comment_and_generic_format_controls() { + for source in [ + "fn add(a:int,b:int)->int{a+b}let answer=add(1,2);answer;", + "fn identity(v:T)->T{v}let answer=identity::(3);answer;", + "fn answer()->string{/* keep block */let value=\"literal bytes\\n変換\";value}/* next */answer();", + "fn answer()->int{let value=3;// keep line\nvalue}answer();", + ] { + let formatted = assert_format(source); + for literal in [ + "/* keep block */", + "/* next */", + "// keep line", + "\"literal bytes\\n変換\"", + ] { + if source.contains(literal) { + assert!( + formatted.contains(literal), + "comment/literal bytes must survive" + ); + } + } + } +} From 31973aae3c417826dacedc82a6e5ea19e585bbbb Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 18 Oct 2026 13:43:43 +0800 Subject: [PATCH 5/5] test: make owned GuestResult decode diagnostic portable across Rust versions --- .../fail/guest_result_owned_borrow_decode.rs | 5 ++- .../guest_result_owned_borrow_decode.stderr | 32 +++++++++---------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.rs b/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.rs index c9f6c6f2..55c82b06 100644 --- a/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.rs +++ b/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.rs @@ -1,5 +1,8 @@ use vm::{FromVmValue, GuestResult, ResourceOwned, Value}; struct Counter; fn main() { - let _ = GuestResult::, i64>::from_vm_value(&Value::Null, "owned"); + let _ = , i64> as FromVmValue<'_>>::from_vm_value( + &Value::Null, + "owned", + ); } diff --git a/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.stderr b/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.stderr index 2d116b5e..9d56d558 100644 --- a/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.stderr +++ b/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.stderr @@ -1,19 +1,17 @@ -error[E0599]: the variant or associated item `from_vm_value` exists for enum `GuestResult, i64>`, but its trait bounds were not satisfied - --> tests/ui/fail/guest_result_owned_borrow_decode.rs:4:57 +error[E0277]: the trait bound `ResourceOwned: FromVmValue<'_>` is not satisfied + --> tests/ui/fail/guest_result_owned_borrow_decode.rs:4:14 | -4 | let _ = GuestResult::, i64>::from_vm_value(&Value::Null, "owned"); - | ^^^^^^^^^^^^^ variant or associated item cannot be called on `GuestResult, i64>` due to unsatisfied trait bounds +4 | let _ = , i64> as FromVmValue<'_>>::from_vm_value( + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `FromVmValue<'_>` is not implemented for `ResourceOwned` | - ::: $WORKSPACE/src/result.rs - | - | pub enum GuestResult { - | -------------------------- doesn't satisfy `_: FromVmValue<'_>` - | - ::: $WORKSPACE/src/vm/resource/handle.rs - | - | pub struct ResourceOwned(T); - | --------------------------- doesn't satisfy `ResourceOwned: FromVmValue<'_>` - | - = note: the following trait bounds were not satisfied: - `ResourceOwned: FromVmValue<'_>` - which is required by `GuestResult, i64>: FromVmValue<'_>` + = help: the following other types implement trait `FromVmValue<'a>`: + `&'a VmMap` implements `FromVmValue<'a>` + `&'a [u8]` implements `FromVmValue<'a>` + `&'a [vm::Value]` implements `FromVmValue<'a>` + `&'a str` implements `FromVmValue<'a>` + `&'a vm::Value` implements `FromVmValue<'a>` + `()` implements `FromVmValue<'_>` + `Arc>` implements `FromVmValue<'_>` + `Arc>` implements `FromVmValue<'_>` + and $N others + = note: required for `GuestResult, i64>` to implement `FromVmValue<'_>`