diff --git a/README.md b/README.md index 7c986235..479b4d13 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ The complete language, runtime, and implementation guides live on the [RustScrip - [RSS language](https://rustscript.org/docs/reference/rss/) - [Host functions](https://rustscript.org/docs/reference/host-functions/) - [Host SDK: descriptors, resources, and effects](docs/host-sdk-descriptors.md) +- [Result values, propagation, and ownership](docs/result.md) - [Standard timer host module](docs/standard-timer-host.md) - [Runtime controls and artifacts](https://rustscript.org/docs/reference/runtime-controls/) - [Compiler frontend syntax and feature support](src/compiler/frontends/README.md) 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/docs/result.md b/docs/result.md new file mode 100644 index 00000000..b9779110 --- /dev/null +++ b/docs/result.md @@ -0,0 +1,222 @@ +# Result values and function-local propagation + +`Result` represents a typed success value (`T`) or a typed guest error +(`E`). Both parameters are required. It is a compiler built-in, not an ordinary +map type or an exception mechanism. + +## Construct, inspect, and match + +```rss +// expected: 8 +fn choose(flag: bool, n: int) -> Result { + if flag => { Result::Ok(n) } else => { Result::Err("bad input") } +} +let value: Result = choose(true, 7); +let success = value.is_ok(); +let answer = match value { + Result::Ok(n) => n + 1, + Result::Err(message) => -1, +}; +answer; +``` + +`is_ok()` and `is_err()` inspect the Boolean discriminant without consuming +an owned payload. A match evaluates its input once and runs only the selected +arm. `Ok(false)`, `Err(false)`, `Ok(0)`, `Err(0)`, and nullable payloads remain +distinct; payload truthiness never selects a branch. + +A complete match has one `Result::Ok(binding)` arm and one +`Result::Err(binding)` arm, or a wildcard covering the missing branch. Duplicate, +missing, and unreachable arms are rejected. Nested matches are supported; general +nested patterns and guards are not part of this interface. + +A declaration, function return, or typed argument can supply the inactive +constructor parameter. Bare `Result::Ok(7)` without enough type context is +rejected. There is no implicit wrapping of `T`, default error parameter, error +conversion, or conversion from an arbitrary guest map. `Result` cannot be +redeclared as a user or host type. Parameters are invariant. Result can be nested +inside arrays, optional values, named structs, generic types, and callables. + +## `?` returns from the current function + +```rss +// expected: 9 +fn inner(flag: bool) -> Result { + if flag => { Result::Ok(7) } else => { Result::Err(9) } +} +fn relay(flag: bool) -> Result { + let number = inner(flag)?; + Result::Ok("continued") +} +let answer = match relay(false) { + Result::Ok(text) => 0, + Result::Err(code) => code, +}; +answer; +``` + +The operand is evaluated once. Ok transfers its payload to the surrounding +expression. Err transfers the original error into the enclosing function's +Result and returns through that function's normal return continuation. Later +arguments, statements, and other unexecuted sibling expressions do not run. +A closure's `?` exits that closure, not its caller; recursion and callable aliases +use the same lexical boundary. The enclosing Ok type may differ from the operand +Ok type; the error type must match without implicit conversion. + +Root scripts, non-Result-returning functions, non-Result operands, optional Result +operands, and incompatible error types reject `?`. `T?` remains an optional type, +and `object?.field` remains optional access. Other parser dialects do not gain +this syntax unless they enable the Result dialect capability. There are no +`unwrap`, `map`, `and_then`, generic Try traits, or guest throw/catch/finally here. + +## Ownership and cleanup: explicit normal close, automatic Err cleanup + +A Result containing a resource in either parameter is move-only. Construction +moves the active payload. Match and `?` consume the old container and transfer +only the selected owner. Borrow checks, kind, arena, generation, catalog, and +Program identity remain in force; the discriminant grants no resource authority. + +**Only the Err edge of `?` installs automatic remaining-local cleanup.** The +returned Err payload is first preserved as the returned owner; other remaining +function-local resources are closed by the recorded cleanup plan. Cleanup failure +is a fatal VM failure, not a replacement guest Err. + +**Ordinary Ok continuation retains the existing explicit-close contract.** +Explicitly close or transfer resources on normal execution. A fresh resource +created by a guest host call is not automatically closed merely because the +function later returns Ok. This is not general function-exit RAII. + +VM-aware owned callback *arguments* have a separate publication journal. Abandoned +argument publications are reclaimed on failure or success, while returned owners, +surviving guest captures, and resources transferred by a typed native take are +preserved. A fresh guest-host local outside that argument journal still needs an +explicit normal-path close. Opaque native state must take ownership through the +typed API; storing a numeric handle or an externally held container is not an +ownership declaration. Unresolved publication ownership quarantines execution and +new publications until explicit scope shutdown/reset actually completes. + +The maintained generated-host example in +[`result_sdk_try_integration_tests.rs`](../tests/result_sdk_try_integration_tests.rs) +uses `ResourceOwned`, `ResourceRef<'_, T>`, `HostResourceType`, +`vm_script_callback_by_name`, and `call_with_vm`. It checks resource state **before** +VM teardown and exercises normal explicit close and Err cleanup independently. + +## Guest errors and VM errors are different channels + +In a descriptor-generating extension's `functions` module (with the SDK's +usual parent-module imports; see the [descriptor guide](host-sdk-descriptors.md)): + +```rust +use pd_host_function::pd_host_function; +use vm::{GuestResult, VmError, VmResult}; + +/// Returns a guest value or a fatal transport failure. +#[pd_host_function(name = "example::read")] +fn read(mode: i64) -> VmResult> { + match mode { + 0 => Ok(GuestResult::Ok(7)), + 1 => Ok(GuestResult::Err("missing".into())), + _ => Err(VmError::HostError("transport failed".into())), + } +} +``` + +`GuestResult` is the public Rust data wrapper. Outer `VmResult` retains its +existing meaning. Fuel/epoch suspension, cancellation, pending host work, quotas, +invalid representation, and outer `VmError` never become catchable guest Errs. +Host descriptors and build-script schema generation preserve both parameters and +resource effects. Borrowed-resource returns remain rejected, including nested +GuestResult combinations. Existing HTTP/database business return types are not +implicitly migrated. + +Ordinary context-free callbacks use `IntoScriptValue` / `ScriptResult` and +`script_callback_by_name`. Resource-owning callbacks use `VmScriptValue`, +`VmScriptArgs`, and a live `ScriptValueContext` through the VM-aware callback API. +Use the generated descriptor's exact catalog; host-call admission validates the +selected payload before entering a guest body, and typed host return validation +runs before the guest continues. Callback handles are Store/VM/Program-bound and +are invalidated by replacement/reset as specified by that API. + +## Frontends, wire, and execution + +Plugins can construct `TypeSchema::Result { ok, err }`, +`HostTypeSchema::Result { ok, err }`, and typed `Expr::ResultConstruct`, +`ResultMatch`, and `ResultTry` IR. Preserve the full schemas through typing and +ownership passes; do not substitute Unknown or invent a named-map identity. + +The current VMBC contract is **v13 only**. Std writer/reader and no-std reader use +one current schema layout. Non-v13, unknown schema tags, malformed carriers, and +invalid schema budgets are rejected; there is no legacy reader, adapter, or +version-dependent writer. The physical carrier is the existing Map with a Boolean +`tag` (`true` = Ok) and the active `payload`. Missing payload means Null only if +the selected schema permits it. Missing/non-Boolean tag, extra fields, or a wrong +selected payload fail validation. Raw map construction does not establish guest +Result typing. + +Interpreter, Trace JIT, and AOT use existing instructions and the existing Value +and native ABI. Logical payload type alone does not establish a native scalar +representation: generic Map reads can remain Tagged and require checked unboxing. +Dynamic payload tests cover integer, signed zero/NaN, false, string, optional/null, +nested Result, callables/aliases, and owned resources. Native call/collection +helpers and interpreter continuations may still be involved. In particular, +resource move helpers and an Err cleanup Ret can cross an interpreter boundary; +native tag/payload execution is not a claim that every instruction is native. +Standalone host-root callable execution respects the configured backend. +AOT still rejects some unhinted Tagged arithmetic, including addition in a +captured generic Result closure when scalar operand facts are unavailable. +A forwarding captured-closure vector has separate actual native coverage; its +success does not establish native execution of that addition workload. Existing +optional-index String reads and other unsupported operations retain their own +capability limits. + +AOT artifacts remain bound to the checked Program and schemas. Loading can rebuild +current-process native code; loading is not zero-cost relocation. Reset/reuse, +Program replacement, stale callbacks, and cross-VM rejection retain their lifecycle +contracts. + +No-std executes std-encoded scalar/nested Result and cleanup-free `?` programs. +Resource cleanup plans that the no-std runtime cannot honor return +`UnsupportedCapability`; this is not general no-std resource support. Wasm32 +analyzer exports preserve recursive Result schemas and provenance; runtime builds +execute guest branches through the exported JSON ABI. Native analyzer tests, +wasm32 compilation, and actual wasm ABI execution are separate checks. + +Formatter/type hints preserve constructors, matches, and `?`. ResultMatch +validation diagnostics currently extend to the following token in some cases; +`?` mismatch diagnostics retain their dedicated location. Do not depend on +ResultMatch diagnostics covering only the match expression. + +## Costs and reproducible characterization + +Result is **not zero-cost or allocation-free** in this implementation. Escaping +carriers allocate Maps. Constructor/match lowering adds tag/payload operations and +sometimes temporary locals; `?` adds a branch and function-exit work. Native +helper/checkpoint transitions can cost more than interpreting a small workload. +No speculative scalar replacement is part of this interface. + +`tests/result_cost_tests.rs` is a finite opt-in harness comparing Result with a +manual Boolean-tag/payload `Pair` Map and a clearly separate no-Result integer +control. Direct controls record instruction/branch/local counts, assert that +removing the unused carrier declaration leaves their emitted code and local +counts unchanged, and check that no Map construction was introduced. This is a +same-compiler control, not a historical wire-byte compatibility test. +It measures construction/match, function returns, and looped `?` success +and Err exits with dynamic host inputs. All three use the same integer identity +boundary; the manual error loop uses an explicit Boolean stop condition because +RustScript has no general source return statement. Those control-flow differences +are part of the comparison, not instruction-for-instruction equivalence. + +```sh +cargo test --locked -p pd-vm --test result_cost_tests \ + measured_result_map_direct_costs -- --ignored --exact --test-threads=1 --nocapture +``` + +The harness emits every sample, compilation time, bytecode/native code size, +requested allocation counts/bytes, live-byte deltas, RSS, and real native counters. +It uses three same-VM warmups and four alternating rounds, excludes compilation, +reset and assertions from execution timing, and checks cache inventories after +warmup. The default debug profile and same-machine load make these characterization +numbers, not optimized-release throughput claims. The direct control has no +Result carrier; it still includes ordinary VM/host/call costs. Allocator requested +bytes are not total process memory or allocator overhead. Report medians/ranges +and all samples rather than a single speedup or a universal allocation claim. diff --git a/docs/result_host_example.rs b/docs/result_host_example.rs new file mode 100644 index 00000000..b82c66e3 --- /dev/null +++ b/docs/result_host_example.rs @@ -0,0 +1,12 @@ +use pd_host_function::pd_host_function; +use vm::{GuestResult, VmError, VmResult}; + +/// Returns a guest value or a fatal transport failure. +#[pd_host_function(name = "example::read")] +fn read(mode: i64) -> VmResult> { + match mode { + 0 => Ok(GuestResult::Ok(7)), + 1 => Ok(GuestResult::Err("missing".into())), + _ => Err(VmError::HostError("transport failed".into())), + } +} 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..55c82b06 --- /dev/null +++ b/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.rs @@ -0,0 +1,8 @@ +use vm::{FromVmValue, GuestResult, ResourceOwned, Value}; +struct Counter; +fn main() { + 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 new file mode 100644 index 00000000..9d56d558 --- /dev/null +++ b/pd-host-function/tests/ui/fail/guest_result_owned_borrow_decode.stderr @@ -0,0 +1,17 @@ +error[E0277]: the trait bound `ResourceOwned: FromVmValue<'_>` is not satisfied + --> tests/ui/fail/guest_result_owned_borrow_decode.rs:4:14 + | +4 | let _ = , i64> as FromVmValue<'_>>::from_vm_value( + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `FromVmValue<'_>` is not implemented for `ResourceOwned` + | + = 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<'_>` 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/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/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..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; @@ -8,8 +9,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 +72,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 +91,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 +100,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 +116,14 @@ 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)?; - } + 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); } @@ -243,6 +240,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,35 +296,463 @@ 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)), } } -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); + } + 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; } - skip_schema(cursor, 0)?; + } + 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 28d7ef35..cec21b27 100644 --- a/pd-vm-nostd/tests/call_script_tests.rs +++ b/pd-vm-nostd/tests/call_script_tests.rs @@ -85,6 +85,60 @@ 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), + ), + ( + "let value: Result, string> = Result::Ok(Result::Err(false)); match value { Result::Ok(inner) => match inner { Result::Ok(n) => true, Result::Err(flag) => flag, }, Result::Err(e) => true, };", + EmbeddedValue::Bool(false), + ), + ( + "let value: Result = Result::Err(null); match value { Result::Ok(s) => 9, Result::Err(n) => n, };", + EmbeddedValue::Null, + ), + ]; + 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 std_program = vm::decode_program(&bytes).expect("std current v13 decode"); + assert_eq!(encode_program(&std_program).unwrap(), bytes); + let mut std_vm = vm::Vm::new_with_jit_config( + std_program, + vm::JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + std_vm.set_fuel(100_000); + assert_eq!(std_vm.run().unwrap(), vm::VmStatus::Halted); + let std_expected = match &expected { + EmbeddedValue::Int(n) => vm::Value::Int(*n), + EmbeddedValue::Bool(b) => vm::Value::Bool(*b), + EmbeddedValue::Null => vm::Value::Null, + _ => unreachable!(), + }; + assert_eq!(std_vm.stack().last(), Some(&std_expected)); + 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( @@ -363,3 +417,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 ab6c0c7c..bd6eb5b4 100644 --- a/pd-vm-nostd/tests/embedded_vmbc.rs +++ b/pd-vm-nostd/tests/embedded_vmbc.rs @@ -146,24 +146,26 @@ 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], ); 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()); - 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,9 @@ 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()); + // Mandatory current-v13 ret-cleanup section: empty plan count after named structs. + bytes.extend_from_slice(&0u32.to_le_bytes()); bytes } @@ -648,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()); @@ -657,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))); } @@ -664,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()); @@ -675,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))); } @@ -682,10 +689,16 @@ 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()); - 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/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/builtins/runtime/http/mod.rs b/src/builtins/runtime/http/mod.rs index 114e6074..a2e8d04e 100644 --- a/src/builtins/runtime/http/mod.rs +++ b/src/builtins/runtime/http/mod.rs @@ -11,6 +11,7 @@ use crate::host_api::{ use crate::vm::{HostFunctionRegistry, Vm, VmError, VmResult}; mod config; +mod native_join; pub(super) mod policy; pub(super) mod request; pub(super) mod resources; diff --git a/src/builtins/runtime/http/native_join.rs b/src/builtins/runtime/http/native_join.rs new file mode 100644 index 00000000..a95daee7 --- /dev/null +++ b/src/builtins/runtime/http/native_join.rs @@ -0,0 +1,340 @@ +//! Private, transport-free ownership of an HTTP producer's original native join. +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::task::Waker; + +use futures_util::task::AtomicWaker; + +pub(super) struct NativeJoin { + pub(super) notifier_running: AtomicBool, + pub(super) state: Mutex, + input_ready: Condvar, + pub(super) close_waker: AtomicWaker, + pub(super) quiescence_waker: AtomicWaker, + label: &'static str, + thread_name: &'static str, +} + +#[derive(Default)] +pub(super) struct JoinState { + pub(super) handle: Option>, + notifier: Option>, + pub(super) armed: bool, + joined: Option>, + // Body wakes and noop handoffs must not replace the retained real waiter. + pub(super) stream_waker: Option, +} + +struct NotifierTail(Arc); +impl Drop for NotifierTail { + fn drop(&mut self) { + self.0.notifier_running.store(false, Ordering::Release); + } +} + +/// An admission-owned input: every path either installs the unique original +/// handle or terminalizes workerless input, including unwinding before spawn. +pub(super) struct PrearmedInput(Option>); +impl PrearmedInput { + pub(super) fn install(mut self, handle: std::thread::JoinHandle<()>) { + self.0 + .take() + .expect("prearmed input is owned") + .install(handle); + } +} +impl Drop for PrearmedInput { + fn drop(&mut self) { + if let Some(completion) = self.0.take() { + completion.workerless(); + } + } +} + +impl NativeJoin { + pub(super) fn new(label: &'static str, thread_name: &'static str) -> Self { + Self { + notifier_running: AtomicBool::new(false), + state: Mutex::new(JoinState::default()), + input_ready: Condvar::new(), + close_waker: AtomicWaker::new(), + quiescence_waker: AtomicWaker::new(), + label, + thread_name, + } + } + + pub(super) fn register_stream(&self, waker: &Waker) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.joined.is_none() + && (state.stream_waker.is_none() || !waker.will_wake(Waker::noop())) + { + state.stream_waker = Some(waker.clone()); + } + } + + pub(super) fn wake_stream(&self) { + let waker = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .stream_waker + .clone(); + if let Some(waker) = waker { + waker.wake(); + } + } + + fn publish_joined(&self, result: Result<(), String>) { + let waker = { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if state.joined.is_some() { + return; + } + state.joined = Some(result); + state.stream_waker.take() + }; + self.input_ready.notify_one(); + // Original native join precedes durable publication. Wake only outside + // locks, draining retained task roots before the resource-free tail. + if let Some(waker) = waker { + waker.wake(); + } + self.close_waker.wake(); + self.quiescence_waker.wake(); + } + + pub(super) fn install(&self, handle: std::thread::JoinHandle<()>) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + assert!( + state.handle.is_none() && state.joined.is_none(), + "unique HTTP native input" + ); + state.handle = Some(handle); + drop(state); + self.input_ready.notify_one(); + } + + pub(super) fn prearm(self: &Arc) -> Result { + self.prearm_with(|task| { + std::thread::Builder::new() + .name(self.thread_name.into()) + .spawn(task) + }) + } + + pub(super) fn prearm_with( + self: &Arc, + spawn: impl FnOnce(Box) -> std::io::Result>, + ) -> Result { + self.arm_with(true, spawn)?; + Ok(PrearmedInput(Some(Arc::clone(self)))) + } + + pub(super) fn poll_join(self: &Arc) -> Result { + self.poll_join_with(|task| { + std::thread::Builder::new() + .name(self.thread_name.into()) + .spawn(task) + }) + } + + pub(super) fn poll_join_with( + self: &Arc, + spawn: impl FnOnce(Box) -> std::io::Result>, + ) -> Result { + self.arm_with(false, spawn) + } + + fn arm_with( + self: &Arc, + await_input: bool, + spawn: impl FnOnce(Box) -> std::io::Result>, + ) -> Result { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some(result) = state.joined.clone() { + // Its own Packet is not a readiness gate: only finite infrastructure + // Arc bookkeeping remains after publication, never VM/permit/IO. + if let Some(handle) = state.notifier.take() { + if handle.is_finished() { + let _ = handle.join(); + } else { + drop(handle); + } + } + return result.map(|()| true); + } + if state.armed || (!await_input && state.handle.is_none()) { + return Ok(false); + } + let completion = Arc::clone(self); + // Keep original ownership recoverable if spawn fails. The closure + // cannot take input until the admission/poll lock has been released. + let notifier = spawn(Box::new(move || { + let _tail = NotifierTail(Arc::clone(&completion)); + let handle = { + let mut state = completion + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + while state.handle.is_none() && state.joined.is_none() { + state = completion + .input_ready + .wait(state) + .unwrap_or_else(std::sync::PoisonError::into_inner); + } + state.handle.take() + }; + if let Some(handle) = handle { + let result = handle.join().map_err(|panic| { + if let Some(message) = panic.downcast_ref::<&str>() { + (*message).to_string() + } else if let Some(message) = panic.downcast_ref::() { + message.clone() + } else { + format!("{} worker thread panicked", completion.label) + } + }); + completion.publish_joined(result); + } + })) + .map_err(|error| { + format!( + "failed to start {} join notifier (worker ownership retained): {error}", + self.label + ) + })?; + self.notifier_running.store(true, Ordering::Release); + state.armed = true; + state.notifier = Some(notifier); + Ok(false) + } + + pub(super) fn workerless(&self) { + self.publish_joined(Ok(())); + } + + pub(super) fn is_joined(&self) -> bool { + self.state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .joined + .is_some() + } +} + +#[cfg(test)] +mod tests { + use super::NativeJoin; + use std::sync::{Arc, mpsc}; + use std::time::{Duration, Instant}; + + struct Wake(mpsc::Sender<()>); + impl futures_util::task::ArcWake for Wake { + fn wake_by_ref(this: &Arc) { + let _ = this.0.send(()); + } + } + + #[test] + fn prearmed_original_exit_before_install_and_join_before_registration() { + let completion = Arc::new(NativeJoin::new("HTTP request", "test-http-join")); + let input = completion.prearm().unwrap(); + let handle = std::thread::spawn(|| {}); + let deadline = Instant::now() + Duration::from_secs(2); + while !handle.is_finished() { + assert!(Instant::now() < deadline); + std::thread::yield_now(); + } + let (tx, rx) = mpsc::channel(); + let wake = futures_util::task::waker(Arc::new(Wake(tx))); + completion.close_waker.register(&wake); + input.install(handle); + rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(completion.is_joined()); + // Register strictly after durable join. No missing-wake assumption is + // needed: the consumer's register/recheck immediately sees readiness. + completion.quiescence_waker.register(&wake); + assert!(completion.poll_join().unwrap()); + assert!(completion.poll_join().unwrap()); + assert!(completion.state.lock().unwrap().handle.is_none()); + } + + #[test] + fn prearmed_input_drop_releases_workerless_notifier_without_orphan() { + let completion = Arc::new(NativeJoin::new("HTTP request", "test-http-join")); + let (exit_tx, exit_rx) = mpsc::channel(); + let input = completion + .prearm_with(|task| { + std::thread::Builder::new().spawn(move || { + task(); + exit_tx.send(()).unwrap(); + }) + }) + .unwrap(); + assert!(!completion.is_joined()); + drop(input); + exit_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(completion.poll_join().unwrap()); + assert!( + !completion + .notifier_running + .load(std::sync::atomic::Ordering::Acquire) + ); + assert!(completion.state.lock().unwrap().handle.is_none()); + } + + #[test] + fn joined_readiness_does_not_wait_for_resource_free_notifier_packet_tail() { + let completion = Arc::new(NativeJoin::new("HTTP request", "test-http-join")); + let (published_tx, published_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let (exit_tx, exit_rx) = mpsc::channel(); + let input = completion + .prearm_with(|task| { + std::thread::Builder::new().spawn(move || { + task(); + published_tx.send(()).unwrap(); + release_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + exit_tx.send(()).unwrap(); + }) + }) + .unwrap(); + input.install(std::thread::spawn(|| {})); + published_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(completion.is_joined()); + assert!( + !completion + .state + .lock() + .unwrap() + .notifier + .as_ref() + .unwrap() + .is_finished() + ); + assert!( + completion.poll_join().unwrap(), + "original joined, helper packet still held" + ); + assert_eq!( + Arc::strong_count(&completion), + 1, + "tail retains no completion or producer root" + ); + release_tx.send(()).unwrap(); + exit_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + } +} diff --git a/src/builtins/runtime/http/request.rs b/src/builtins/runtime/http/request.rs index d9e24b11..65a692ec 100644 --- a/src/builtins/runtime/http/request.rs +++ b/src/builtins/runtime/http/request.rs @@ -611,12 +611,8 @@ struct BufferedRequestShared { /// Waker registered by a pending operation poll. `register` is followed by /// a result recheck by the operation driver. waker: AtomicWaker, - /// The worker thread handle, taken during close to join. - join_handle: std::sync::Mutex>>, - /// Waker registered by the close poll when the worker is still running. - close_waker: AtomicWaker, - /// Waker registered by the operation registry while waiting for worker quiescence. - quiescence_waker: AtomicWaker, + /// Transport-free original native join and independent retirement wakes. + native_join: Arc, /// The connection permit, held until the shared state is dropped (after /// the worker exits and the resource is closed). _permit: ConnectionPermit, @@ -639,8 +635,8 @@ impl BufferedRequestShared { self.thread_finished.store(true, Ordering::Release); self.worker_lifecycle .store(WorkerLifecycle::Finished as u8, Ordering::Release); - self.close_waker.wake(); - self.quiescence_waker.wake(); + self.native_join.close_waker.wake(); + self.native_join.quiescence_waker.wake(); } /// Publishes a terminal rollback result for a resource that never had a @@ -659,19 +655,20 @@ impl BufferedRequestShared { { return false; } + self.native_join.workerless(); self.request_stop(); self.publish(result); self.thread_finished.store(true, Ordering::Release); - self.close_waker.wake(); - self.quiescence_waker.wake(); + self.native_join.close_waker.wake(); + self.native_join.quiescence_waker.wake(); true } fn request_stop(&self) { self.cancel.notify_one(); self.waker.wake(); - self.close_waker.wake(); - self.quiescence_waker.wake(); + self.native_join.close_waker.wake(); + self.native_join.quiescence_waker.wake(); } fn publish(&self, result: VmResult) { @@ -682,8 +679,8 @@ impl BufferedRequestShared { // The result mutex write happens-before this release publication. self.done.store(true, Ordering::Release); self.waker.wake(); - self.close_waker.wake(); - self.quiescence_waker.wake(); + self.native_join.close_waker.wake(); + self.native_join.quiescence_waker.wake(); } fn has_result(&self) -> bool { @@ -700,24 +697,11 @@ impl BufferedRequestShared { { return Ok(false); } - let handle = { - let mut guard = self - .join_handle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if guard.as_ref().is_some_and(|handle| !handle.is_finished()) { - return Ok(false); - } - guard.take() - }; - let Some(handle) = handle else { - return Ok(true); - }; - handle.join().map(|_| true).map_err(|panic| { + self.native_join.poll_join().map_err(|message| { ResourceError::new( ResourceErrorCode::ResourceCleanupFailed, "http::request::resource", - worker_panic_message(&panic), + message, ) }) } @@ -726,11 +710,7 @@ impl BufferedRequestShared { self.worker_lifecycle.load(Ordering::Acquire) == WorkerLifecycle::Finished as u8 && self.done.load(Ordering::Acquire) && self.thread_finished.load(Ordering::Acquire) - && self - .join_handle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_none() + && self.native_join.is_joined() } } @@ -745,12 +725,43 @@ fn worker_panic_message(panic: &Box) -> String { } #[cfg(test)] -static FAIL_NEXT_WORKER_SPAWN: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); +type NativeTailHook = Box) + Send>; + +#[cfg(test)] +thread_local! { + // Per-admission hook runs after every existing producer wake, while the + // original native closure still owns its libstd thread packet. + static TEST_NATIVE_TAIL: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + static FAIL_NEXT_NOTIFIER_SPAWN: std::cell::Cell = const { std::cell::Cell::new(false) }; + static TEST_NOTIFIER_EXIT: std::cell::RefCell>> = const { std::cell::RefCell::new(None) }; +} + +fn prearm_native_join( + completion: &Arc, +) -> Result { + #[cfg(test)] + if FAIL_NEXT_NOTIFIER_SPAWN.with(|fail| fail.replace(false)) { + return completion.prearm_with(|_task| { + Err(std::io::Error::other("injected HTTP join notifier failure")) + }); + } + #[cfg(test)] + if let Some(exit) = TEST_NOTIFIER_EXIT.with(|hook| hook.borrow_mut().take()) { + return completion.prearm_with(|task| { + std::thread::Builder::new().spawn(move || { + task(); + let _ = exit.send(()); + }) + }); + } + completion.prearm() +} #[cfg(test)] -static REJECT_NEXT_OPERATION_ADMISSION: std::sync::atomic::AtomicBool = - std::sync::atomic::AtomicBool::new(false); +thread_local! { static FAIL_NEXT_WORKER_SPAWN: std::cell::Cell = const { std::cell::Cell::new(false) }; } + +#[cfg(test)] +thread_local! { static REJECT_NEXT_OPERATION_ADMISSION: std::cell::Cell = const { std::cell::Cell::new(false) }; } #[allow(clippy::result_large_err)] fn start_operation( @@ -758,7 +769,7 @@ fn start_operation( operation: T, ) -> crate::vm::host_context::HostContextResult { #[cfg(test)] - if REJECT_NEXT_OPERATION_ADMISSION.swap(false, Ordering::AcqRel) { + if REJECT_NEXT_OPERATION_ADMISSION.with(|fail| fail.replace(false)) { return Err(crate::vm::host_context::HostContextError::new( "http::operation", "injected operation admission rejection", @@ -773,7 +784,7 @@ where F: FnOnce() + Send + 'static, { #[cfg(test)] - if FAIL_NEXT_WORKER_SPAWN.swap(false, Ordering::AcqRel) { + if FAIL_NEXT_WORKER_SPAWN.with(|fail| fail.replace(false)) { return Err(std::io::Error::other("injected HTTP worker spawn failure")); } std::thread::Builder::new() @@ -830,7 +841,7 @@ impl HostResource for HttpRequestResource { match shared.try_join_finished() { Ok(true) => Poll::Ready(Ok(())), Ok(false) => { - shared.close_waker.register(cx.waker()); + shared.native_join.close_waker.register(cx.waker()); match shared.try_join_finished() { Ok(true) => Poll::Ready(Ok(())), Ok(false) => Poll::Pending, @@ -902,14 +913,20 @@ impl HostOperation for HttpRequestOperation { } fn register_quiescence_waker(&mut self, cx: &Context<'_>) { - self.shared.quiescence_waker.register(cx.waker()); + self.shared + .native_join + .quiescence_waker + .register(cx.waker()); } fn poll_quiescent(&mut self, cx: &mut Context<'_>) -> Poll<()> { if self.shared.is_quiescent() { return Poll::Ready(()); } - self.shared.quiescence_waker.register(cx.waker()); + self.shared + .native_join + .quiescence_waker + .register(cx.waker()); if self.shared.is_quiescent() { Poll::Ready(()) } else { @@ -1056,9 +1073,10 @@ pub(super) fn perform_buffered_request( thread_finished: std::sync::atomic::AtomicBool::new(false), worker_lifecycle: std::sync::atomic::AtomicU8::new(WorkerLifecycle::NotStarted as u8), waker: AtomicWaker::new(), - join_handle: std::sync::Mutex::new(None), - close_waker: AtomicWaker::new(), - quiescence_waker: AtomicWaker::new(), + native_join: Arc::new(super::native_join::NativeJoin::new( + "HTTP request", + "rustscript-http-join", + )), _permit: permit, rollback_finished: std::sync::atomic::AtomicBool::new(false), }); @@ -1160,6 +1178,21 @@ pub(super) fn perform_buffered_request( )); } + // Arming is fallible and must precede the original worker: quiescence's + // Poll<()> channel cannot propagate a later notifier spawn failure. + let native_input = match prearm_native_join(&shared.native_join) { + Ok(input) => input, + Err(message) => { + return Err(rollback_buffered_request( + vm, + resource_handle, + &shared, + Some(op_id), + VmError::HostError(message), + )); + } + }; + // Run the request on a worker thread; the operation driver polls the // shared completion cell. The worker uses tokio::select! to respond // promptly to cancellation even while blocked on network I/O. @@ -1171,6 +1204,8 @@ pub(super) fn perform_buffered_request( headers: request.headers, body: request.body.into_bytes(), }; + #[cfg(test)] + let native_tail = TEST_NATIVE_TAIL.with(|hook| hook.borrow_mut().take()); let join_handle = match spawn_worker("rustscript-http-request", { let worker_shared = Arc::clone(&shared); move || { @@ -1210,6 +1245,10 @@ pub(super) fn perform_buffered_request( worker_shared.publish(value); worker_state.mark_worker_finished(); worker_state.waker.wake(); + #[cfg(test)] + if let Some(native_tail) = native_tail { + native_tail(Arc::clone(&worker_state)); + } } }) { Ok(join_handle) => join_handle, @@ -1226,11 +1265,7 @@ pub(super) fn perform_buffered_request( shared.mark_worker_running(); - // Store the join handle so the resource can join it during close. - *shared - .join_handle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(join_handle); + native_input.install(join_handle); let raw = op_id.raw(); Ok(HostCallResult::Pending(raw)) @@ -1883,9 +1918,10 @@ mod tests { thread_finished: AtomicBool::new(false), worker_lifecycle: AtomicU8::new(WorkerLifecycle::NotStarted as u8), waker: futures_util::task::AtomicWaker::new(), - join_handle: std::sync::Mutex::new(None), - close_waker: futures_util::task::AtomicWaker::new(), - quiescence_waker: futures_util::task::AtomicWaker::new(), + native_join: Arc::new(super::super::native_join::NativeJoin::new( + "HTTP request", + "rustscript-http-join", + )), _permit: permit, rollback_finished: AtomicBool::new(false), }) @@ -2135,7 +2171,7 @@ mod tests { .push_resource(HttpRequestResource::new(std::sync::Arc::clone(&shared))) .expect("request resource"); let primary = VmError::HostError("operation admission rejected".to_string()); - REJECT_NEXT_OPERATION_ADMISSION.store(true, Ordering::Release); + REJECT_NEXT_OPERATION_ADMISSION.with(|fail| fail.set(true)); let admission = start_operation(&mut vm, HttpRequestOperation::new(Arc::clone(&shared))); assert!(admission.is_err()); @@ -2160,9 +2196,383 @@ mod tests { assert!(repeated.to_string().contains("repeated rollback")); } + fn accept_bounded(listener: std::net::TcpListener) -> std::net::TcpStream { + listener.set_nonblocking(true).unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + loop { + match listener.accept() { + Ok((stream, _)) => return stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "fixture accept timeout" + ); + std::thread::yield_now(); + } + Err(error) => panic!("fixture accept failed: {error}"), + } + } + } + + fn real_buffered_native_tail(reset: bool, native_panic: bool) { + use crate::builtins::runtime::http::{HttpHostExt, HttpHostState}; + use crate::vm::resource::{HostResource, ResourceCloseReason}; + use std::io::{Read, Write}; + use std::sync::mpsc; + use std::task::Context; + use std::time::Duration; + + struct Wake(mpsc::Sender<()>); + impl futures_util::task::ArcWake for Wake { + fn wake_by_ref(this: &Arc) { + let _ = this.0.send(()); + } + } + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let next_listener = listener.try_clone().unwrap(); + let (connected_tx, connected_rx) = mpsc::channel(); + let server = std::thread::spawn(move || { + let mut stream = accept_bounded(listener); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + head.push(byte[0]); + assert!(head.len() < 8192); + } + connected_tx.send(()).unwrap(); + if reset { + let mut byte = [0]; + assert_eq!(stream.read(&mut byte).unwrap(), 0); + } else { + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + ) + .unwrap(); + } + }); + let source = format!( + "use http; let req = http::request::new(\"GET\", \"http://{address}/\"); http::client::request(req);" + ); + let mut vm = crate::vm::Vm::new(crate::compile_source(&source).unwrap().program); + vm.configure_http(super::HttpConfig { + allow_private_ips: true, + allowed_schemes: vec!["http".into()], + allowed_hosts: vec!["127.0.0.1".into()], + allowed_ports: vec![address.port()], + ..Default::default() + }) + .unwrap(); + vm.set_http_max_in_flight(1); + crate::vm::HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .unwrap(); + let admission = vm + .host_context() + .module_state::() + .unwrap() + .admission + .clone(); + let (body_tx, body_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + super::TEST_NATIVE_TAIL.with(|hook| { + *hook.borrow_mut() = Some(Box::new(move |shared| { + body_tx.send(shared).unwrap(); + release_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + assert!(!native_panic, "buffered original native panic"); + })) + }); + assert!(matches!(vm.run().unwrap(), crate::vm::VmStatus::Waiting(_))); + connected_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + if reset { + vm.reset_for_reuse().unwrap(); + } + let shared = body_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + server.join().unwrap(); + assert!(shared.done.load(Ordering::Acquire)); + assert!(shared.thread_finished.load(Ordering::Acquire)); + assert!(!shared.is_quiescent(), "native tail is still held"); + assert!(admission.acquire().is_err()); + let mut close = HttpRequestResource::new(shared.clone()); + assert_eq!( + close.begin_close(ResourceCloseReason::Requested).unwrap(), + crate::vm::resource::CloseProgress::Pending + ); + let (op_tx, op_rx) = mpsc::channel(); + let (close_tx, close_rx) = mpsc::channel(); + let op_waker = futures_util::task::waker(Arc::new(Wake(op_tx))); + let close_waker = futures_util::task::waker(Arc::new(Wake(close_tx))); + let mut op_cx = Context::from_waker(&op_waker); + let mut close_cx = Context::from_waker(&close_waker); + let mut poll_vm = |cx: &mut Context<'_>| { + if reset { + vm.poll_reset_for_reuse(cx) + } else { + vm.poll_waiting_host_op(cx) + } + }; + assert!(poll_vm(&mut op_cx).is_pending()); + assert!(close.poll_close(&mut close_cx).is_pending()); + // Every old producer wake and close/cancel wake is consumed before + // releasing only native exit. There is no timer driving either poller. + op_rx.try_iter().for_each(drop); + close_rx.try_iter().for_each(drop); + assert!(poll_vm(&mut op_cx).is_pending()); + assert!(close.poll_close(&mut close_cx).is_pending()); + release_tx.send(()).unwrap(); + let op_wake = op_rx.recv_timeout(Duration::from_secs(2)); + let close_wake = close_rx.recv_timeout(Duration::from_secs(2)); + assert!( + op_wake.is_ok(), + "native exit supplied no automatic buffered retirement wake" + ); + assert!( + close_wake.is_ok(), + "native exit supplied no independent buffered close wake" + ); + if native_panic { + for _ in 0..2 { + let std::task::Poll::Ready(Err(error)) = close.poll_close(&mut close_cx) else { + panic!("durable native cleanup failure required") + }; + assert_eq!( + error.code(), + crate::vm::resource::ResourceErrorCode::ResourceCleanupFailed + ); + assert!(error.to_string().contains("buffered original native panic")); + } + let std::task::Poll::Ready(Err(error)) = poll_vm(&mut op_cx) else { + panic!("actual buffered completion must report native cleanup failure") + }; + assert!(error.to_string().contains("buffered original native panic")); + } else { + assert!(matches!( + close.poll_close(&mut close_cx), + std::task::Poll::Ready(Ok(())) + )); + assert!(matches!( + poll_vm(&mut op_cx), + std::task::Poll::Ready(Ok(())) + )); + } + drop(close); + assert!(shared.is_quiescent()); + assert_eq!(vm.host_context().operation_count(), 0); + if !reset { + if !native_panic { + assert_eq!(vm.run().unwrap(), crate::vm::VmStatus::Halted); + vm.reset_for_reuse().unwrap(); + assert!(matches!( + vm.poll_reset_for_reuse(&mut op_cx), + std::task::Poll::Ready(Ok(())) + )); + } else { + let error = vm + .reset_for_reuse() + .expect_err("scope cleanup preserves native panic"); + assert!(error.to_string().contains("buffered original native panic")); + } + } + assert_eq!(vm.host_context().resource_count(), 0); + assert_eq!( + Arc::strong_count(&shared), + 1, + "retired owners retain no producer roots" + ); + drop(shared); + let next = admission.acquire().expect("next single-permit admission"); + assert!(admission.acquire().is_err()); + drop(next); + if native_panic { + return; + } + assert!(vm.is_reusable()); + let next_server = std::thread::spawn(move || { + let mut stream = accept_bounded(next_listener); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + head.push(byte[0]); + assert!(head.len() < 8192); + } + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .unwrap(); + }); + assert!(matches!(vm.run().unwrap(), crate::vm::VmStatus::Waiting(_))); + super::runtime_block_on(async { + tokio::time::timeout(Duration::from_secs(2), vm.await_waiting_host_op()) + .await + .unwrap() + .unwrap(); + }) + .unwrap(); + assert_eq!(vm.run().unwrap(), crate::vm::VmStatus::Halted); + vm.reset_for_reuse().unwrap(); + super::runtime_block_on(async { + tokio::time::timeout( + Duration::from_secs(2), + std::future::poll_fn(|cx| vm.poll_reset_for_reuse(cx)), + ) + .await + .unwrap() + .unwrap(); + }) + .unwrap(); + next_server.join().unwrap(); + assert_eq!(vm.host_context().resource_count(), 0); + assert_eq!(vm.host_context().operation_count(), 0); + assert!(vm.is_reusable()); + } + + #[test] + fn real_buffered_response_native_exit_wakes_retirement_and_close() { + real_buffered_native_tail(false, false); + } + + #[test] + fn real_buffered_reset_native_exit_wakes_retirement_and_close() { + real_buffered_native_tail(true, false); + } + + #[test] + fn real_buffered_native_panic_is_durable_cleanup_failure() { + real_buffered_native_tail(false, true); + } + + fn real_admission_failure(which: &str) { + use crate::builtins::runtime::http::{HttpHostExt, HttpHostState}; + use std::sync::mpsc; + use std::time::Duration; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let source = format!( + "use http; let req = http::request::new(\"GET\", \"http://{address}/\"); http::client::request(req);" + ); + let mut vm = crate::vm::Vm::new(crate::compile_source(&source).unwrap().program); + vm.configure_http(super::HttpConfig { + allow_private_ips: true, + allowed_schemes: vec!["http".into()], + allowed_hosts: vec!["127.0.0.1".into()], + allowed_ports: vec![address.port()], + ..Default::default() + }) + .unwrap(); + vm.set_http_max_in_flight(1); + crate::vm::HostFunctionRegistry::new() + .bind_vm_cached(&mut vm) + .unwrap(); + let admission = vm + .host_context() + .module_state::() + .unwrap() + .admission + .clone(); + let (exit_tx, exit_rx) = mpsc::channel(); + if which == "worker" { + super::TEST_NOTIFIER_EXIT.with(|hook| *hook.borrow_mut() = Some(exit_tx)); + FAIL_NEXT_WORKER_SPAWN.with(|fail| fail.set(true)); + } else if which == "notifier" { + super::FAIL_NEXT_NOTIFIER_SPAWN.with(|fail| fail.set(true)); + } else { + REJECT_NEXT_OPERATION_ADMISSION.with(|fail| fail.set(true)); + } + let error = vm + .run() + .expect_err("actual admission must fail before network"); + let expected = match which { + "worker" => "injected HTTP worker spawn failure", + "notifier" => "injected HTTP join notifier failure", + _ => "injected operation admission rejection", + }; + assert!(error.to_string().contains(expected), "{error}"); + assert_eq!(vm.host_context().operation_count(), 0); + assert_eq!(vm.host_context().resource_count(), 0); + assert!( + admission.acquire().is_ok(), + "workerless rollback releases permit" + ); + assert!( + matches!(listener.accept(), Err(error) if error.kind() == std::io::ErrorKind::WouldBlock) + ); + if which == "worker" { + exit_rx + .recv_timeout(Duration::from_secs(2)) + .expect("workerless input must release the prearmed helper"); + } + vm.reset_for_reuse().unwrap(); + assert!(vm.is_reusable()); + } + + #[test] + fn real_buffered_prearm_failure_rolls_back_before_worker_and_network() { + real_admission_failure("notifier"); + } + + #[test] + fn real_buffered_worker_spawn_failure_releases_prearmed_helper() { + real_admission_failure("worker"); + } + + #[test] + fn real_buffered_operation_admission_failure_retires_workerless() { + real_admission_failure("operation"); + } + + #[test] + fn buffered_registry_preserves_first_cancel_reason_through_join() { + use crate::vm::operation::{OperationCancelReason, OperationOutcome, OperationSpec}; + use std::task::Context; + let mut vm = empty_vm(); + let shared = buffered_shared(); + let operation = vm + .execution_scope() + .start_operation(OperationSpec::new(HttpRequestOperation::new( + shared.clone(), + ))) + .unwrap(); + assert!( + vm.execution_scope() + .cancel_operation(operation, OperationCancelReason::Deadline) + .unwrap() + ); + assert!( + !vm.execution_scope() + .cancel_operation(operation, OperationCancelReason::VmReset) + .unwrap() + ); + assert!(shared.terminalize_workerless(Err(VmError::HostError("not started".into())))); + assert!(matches!( + vm.execution_scope().poll_operation_quiescence( + operation, + &mut Context::from_waker(std::task::Waker::noop()) + ), + std::task::Poll::Ready(Ok(OperationOutcome::Cancelled( + OperationCancelReason::Deadline + ))) + )); + assert_eq!(vm.host_context().operation_count(), 0); + } + #[test] fn worker_spawn_abstraction_can_inject_a_builder_failure() { - FAIL_NEXT_WORKER_SPAWN.store(true, Ordering::Release); + FAIL_NEXT_WORKER_SPAWN.with(|fail| fail.set(true)); let result = spawn_worker("injected-http-worker", || {}); let error = match result { Ok(handle) => { diff --git a/src/builtins/runtime/http/sse.rs b/src/builtins/runtime/http/sse.rs index 95f15a45..a923b30a 100644 --- a/src/builtins/runtime/http/sse.rs +++ b/src/builtins/runtime/http/sse.rs @@ -4,7 +4,6 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::task::{Context, Poll}; use std::time::{Duration, Instant}; -use futures_util::task::AtomicWaker; use pd_host_function::pd_host_function; use tokio::sync::{Notify, mpsc}; @@ -343,6 +342,8 @@ enum SseWorkerLifecycle { Finished = 2, } +use super::native_join::NativeJoin as SseNativeJoin; + pub(super) struct SseShared { /// Set on close/cancel; the worker stops polling the network. pub(super) stopping: AtomicBool, @@ -356,9 +357,8 @@ pub(super) struct SseShared { /// One acknowledgement is issued by the VM after each callback. The /// acknowledgement arrives. pub(super) item_ack: Notify, - /// Waker registered by a pending stream poll. Channel readiness is handled - /// by `Receiver::poll_recv`; this waker covers stop and terminal state. - pub(super) waker: AtomicWaker, + /// Native completion is independent of resource-bearing shared state. + native_join: Arc, /// Bounded FIFO of published items awaiting the stream driver. /// The worker `send`s with backpressure; the driver `try_recv`s. /// This preserves item ordering and never drops events, unlike a @@ -373,13 +373,6 @@ pub(super) struct SseShared { pub(super) worker_lifecycle: std::sync::atomic::AtomicU8, /// The final result from the worker thread (Ok or error). pub(super) result: std::sync::Mutex>>, - /// The worker thread handle, taken during close to join. - pub(super) join_handle: std::sync::Mutex>>, - /// Waker registered by the close poll when the worker is still running. - pub(super) close_waker: AtomicWaker, - /// Waker registered by the scoped operation while the producer is still - /// running. - pub(super) quiescence_waker: AtomicWaker, /// The permit is owned by shared stream state, so dropping the driver /// cannot release admission while the worker or transport is alive. pub(super) _permit: super::ConnectionPermit, @@ -401,8 +394,8 @@ impl SseShared { self.thread_finished.store(true, Ordering::Release); self.worker_lifecycle .store(SseWorkerLifecycle::Finished as u8, Ordering::Release); - self.close_waker.wake(); - self.quiescence_waker.wake(); + self.native_join.close_waker.wake(); + self.native_join.quiescence_waker.wake(); } fn terminalize_workerless(&self, reason: OperationCancelReason, result: VmResult<()>) -> bool { @@ -418,11 +411,12 @@ impl SseShared { { return false; } + self.native_join.workerless(); self.request_stop(reason); self.publish(result); self.thread_finished.store(true, Ordering::Release); - self.close_waker.wake(); - self.quiescence_waker.wake(); + self.native_join.close_waker.wake(); + self.native_join.quiescence_waker.wake(); true } @@ -437,9 +431,9 @@ impl SseShared { } self.cancel.notify_one(); self.cancel.notify_waiters(); - self.waker.wake(); - self.close_waker.wake(); - self.quiescence_waker.wake(); + self.native_join.wake_stream(); + self.native_join.close_waker.wake(); + self.native_join.quiescence_waker.wake(); } fn publish(&self, result: VmResult<()>) { @@ -449,9 +443,9 @@ impl SseShared { .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(result); // The result mutex write happens-before this release publication. self.done.store(true, Ordering::Release); - self.waker.wake(); - self.close_waker.wake(); - self.quiescence_waker.wake(); + self.native_join.wake_stream(); + self.native_join.close_waker.wake(); + self.native_join.quiescence_waker.wake(); } fn take_result(&self) -> Option> { @@ -465,70 +459,27 @@ impl SseShared { self.worker_lifecycle.load(Ordering::Acquire) == SseWorkerLifecycle::Finished as u8 && self.done.load(Ordering::Acquire) && self.thread_finished.load(Ordering::Acquire) - && self - .join_handle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .is_none() + && self.native_join.is_joined() } fn join_worker(&self) -> Result<(), String> { - if self.worker_lifecycle.load(Ordering::Acquire) != SseWorkerLifecycle::Finished as u8 - || !self.done.load(Ordering::Acquire) - || !self.thread_finished.load(Ordering::Acquire) - { - return Err("SSE worker is still running".to_string()); - } - if self - .join_handle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .as_ref() - .is_some_and(|handle| !handle.is_finished()) - { - return Err("SSE worker thread has not exited".to_string()); + let joined = + self.native_join + .poll_join() + .map_err(|message| match self.cancellation_reason() { + Some(reason) => format!("{message} (cancellation reason: {reason})"), + None => message, + })?; + match joined { + true => Ok(()), + false => Err("SSE worker native join is pending (ownership retained)".into()), } - let handle = self - .join_handle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .take(); - let Some(handle) = handle else { - return Ok(()); - }; - handle.join().map_err(|panic| { - let message = worker_panic_message(&panic); - match self.cancellation_reason() { - Some(reason) => format!("{message} (cancellation reason: {reason})"), - None => message, - } - }) } fn try_join_finished(&self) -> ResourceResult { - if self.worker_lifecycle.load(Ordering::Acquire) != SseWorkerLifecycle::Finished as u8 - || !self.done.load(Ordering::Acquire) - || !self.thread_finished.load(Ordering::Acquire) - { - return Ok(false); - } - let handle = { - let mut guard = self - .join_handle - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - if guard.as_ref().is_some_and(|handle| !handle.is_finished()) { - return Ok(false); - } - guard.take() - }; - let Some(handle) = handle else { - return Ok(true); - }; - handle - .join() - .map(|_| true) - .map_err(|panic| resource_cleanup_error(&worker_panic_message(&panic))) + self.native_join + .poll_join() + .map_err(|message| resource_cleanup_error(&message)) } fn cancellation_reason(&self) -> Option { @@ -843,7 +794,7 @@ impl SseWorker { } sent = sender.send(item) => { sent.map_err(|_| VmError::HostError("SSE stream closed".to_string()))?; - self.shared.waker.wake(); + self.shared.native_join.wake_stream(); if self.shared.stopping.load(Ordering::Acquire) { return Err(VmError::HostError("SSE stream cancelled".to_string())); } @@ -1035,6 +986,7 @@ impl HostStreamDriver for SseStreamDriver { } fn poll_next(&mut self, cx: &mut Context<'_>) -> Poll> { + self.shared.native_join.register_stream(cx.waker()); match self.receiver.poll_recv(cx) { Poll::Ready(Some(SseMessage::Open(status, headers, url))) => { self.status = status; @@ -1085,7 +1037,7 @@ impl HostStreamDriver for SseStreamDriver { "eof" }); } - self.shared.waker.register(cx.waker()); + self.shared.native_join.register_stream(cx.waker()); if self.shared.done.load(Ordering::Acquire) { self.poll_terminal(if self.shared.stopping.load(Ordering::Acquire) { "stopped" @@ -1242,7 +1194,7 @@ impl HostResource for SseStreamResource { match self.shared.try_join_finished() { Ok(true) => Poll::Ready(Ok(())), Ok(false) => { - self.shared.close_waker.register(cx.waker()); + self.shared.native_join.close_waker.register(cx.waker()); match self.shared.try_join_finished() { Ok(true) => Poll::Ready(Ok(())), Ok(false) => Poll::Pending, @@ -1299,16 +1251,20 @@ impl HostOperation for SseScopeOperation { } fn register_quiescence_waker(&mut self, cx: &Context<'_>) { - self.shared.quiescence_waker.register(cx.waker()); + self.shared + .native_join + .quiescence_waker + .register(cx.waker()); } fn poll_quiescent(&mut self, cx: &mut Context<'_>) -> Poll<()> { if self.shared.is_quiescent() { return Poll::Ready(()); } - self.shared.waker.register(cx.waker()); - self.shared.close_waker.register(cx.waker()); - self.shared.quiescence_waker.register(cx.waker()); + self.shared + .native_join + .quiescence_waker + .register(cx.waker()); if self.shared.is_quiescent() { Poll::Ready(()) } else { @@ -1422,15 +1378,12 @@ fn builtin_sse_start( cancel: Notify::new(), cancellation_reason: std::sync::Mutex::new(None), item_ack: Notify::new(), - waker: AtomicWaker::new(), + native_join: Arc::new(SseNativeJoin::new("SSE", "rustscript-sse-join")), items, done: AtomicBool::new(false), thread_finished: AtomicBool::new(false), worker_lifecycle: std::sync::atomic::AtomicU8::new(SseWorkerLifecycle::NotStarted as u8), result: std::sync::Mutex::new(None), - join_handle: std::sync::Mutex::new(None), - close_waker: AtomicWaker::new(), - quiescence_waker: AtomicWaker::new(), _permit: permit, rollback_finished: AtomicBool::new(false), }); @@ -1491,7 +1444,7 @@ fn builtin_sse_start( )))); } worker_shared.mark_worker_finished(); - worker_shared.waker.wake(); + worker_shared.native_join.wake_stream(); } }) { Ok(handle) => handle, @@ -1506,10 +1459,21 @@ fn builtin_sse_start( } }; shared.mark_worker_running(); - *shared - .join_handle + shared + .native_join + .state .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(join_handle); + .unwrap_or_else(std::sync::PoisonError::into_inner) + .handle = Some(join_handle); + if let Err(error) = shared.try_join_finished() { + return Err(rollback_sse_admission( + vm, + &shared, + resource, + Some(scope_operation), + VmError::HostError(error.to_string()), + )); + } let driver = SseStreamDriver { shared: Arc::clone(&shared), @@ -1617,11 +1581,14 @@ pub(super) fn builtin_http_client_sse_timeout( #[cfg(test)] mod tests { - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; use super::{ - FAIL_NEXT_WORKER_SPAWN, REJECT_NEXT_OPERATION_ADMISSION, SseEvent, SseParser, - SseScopeOperation, SseShared, SseStreamResource, SseWorkerLifecycle, + FAIL_NEXT_WORKER_SPAWN, REJECT_NEXT_OPERATION_ADMISSION, SseEvent, SseNativeJoin, + SseParser, SseScopeOperation, SseShared, SseStreamResource, SseWorkerLifecycle, rollback_sse_admission, spawn_worker, start_operation, }; use crate::vm::VmError; @@ -1865,7 +1832,7 @@ mod tests { cancel: tokio::sync::Notify::new(), cancellation_reason: std::sync::Mutex::new(None), item_ack: tokio::sync::Notify::new(), - waker: futures_util::task::AtomicWaker::new(), + native_join: Arc::new(SseNativeJoin::new("SSE", "rustscript-sse-join")), items, done: AtomicBool::new(false), thread_finished: AtomicBool::new(false), @@ -1873,9 +1840,6 @@ mod tests { SseWorkerLifecycle::NotStarted as u8, ), result: std::sync::Mutex::new(None), - join_handle: std::sync::Mutex::new(None), - close_waker: futures_util::task::AtomicWaker::new(), - quiescence_waker: futures_util::task::AtomicWaker::new(), _permit: permit, rollback_finished: AtomicBool::new(false), }); @@ -1916,6 +1880,268 @@ mod tests { assert!(repeated.to_string().contains("repeated rollback")); } + #[test] + fn native_tail_completion_automatically_wakes_both_retirement_pollers() { + use crate::vm::operation::OperationSpec; + use crate::vm::resource::{CloseProgress, ResourceCloseReason}; + use std::sync::{Arc, mpsc}; + use std::task::{Context, Poll}; + use std::time::Duration; + + struct Wake(mpsc::Sender<()>); + impl futures_util::task::ArcWake for Wake { + fn wake_by_ref(this: &Arc) { + let _ = this.0.send(()); + } + } + let admission = crate::builtins::runtime::http::policy::ConnectionAdmission::new(1); + let (items, mut receiver) = tokio::sync::mpsc::channel(1); + let shared = Arc::new(SseShared { + stopping: AtomicBool::new(false), + cancel: tokio::sync::Notify::new(), + cancellation_reason: std::sync::Mutex::new(None), + item_ack: tokio::sync::Notify::new(), + native_join: Arc::new(SseNativeJoin::new("SSE", "rustscript-sse-join")), + items, + done: AtomicBool::new(false), + thread_finished: AtomicBool::new(false), + worker_lifecycle: std::sync::atomic::AtomicU8::new( + SseWorkerLifecycle::NotStarted as u8, + ), + result: std::sync::Mutex::new(None), + _permit: admission.acquire().unwrap(), + rollback_finished: AtomicBool::new(false), + }); + let (stream_tx, stream_rx) = mpsc::channel(); + let stream_waker = futures_util::task::waker(Arc::new(Wake(stream_tx))); + shared.native_join.register_stream(&stream_waker); + let (body_tx, body_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let address = listener.local_addr().unwrap(); + let server = std::thread::spawn(move || { + let deadline = std::time::Instant::now() + Duration::from_secs(2); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + assert!( + std::time::Instant::now() < deadline, + "fixture accept timeout" + ); + std::thread::sleep(Duration::from_millis(1)); + } + Err(error) => panic!("fixture accept: {error}"), + } + }; + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + stream.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + assert!(request.len() < 8192); + } + stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: 0\r\nConnection: close\r\n\r\n").unwrap(); + }); + let worker = Arc::new(super::SseWorker { + config: super::super::HttpConfig { + allow_private_ips: true, + allowed_schemes: vec!["http".into()], + allowed_hosts: vec!["127.0.0.1".into()], + allowed_ports: vec![address.port()], + ..Default::default() + }, + request: super::HttpRequest { + method: hyper::Method::GET, + url: format!("http://{address}/events").parse().unwrap(), + headers: Vec::new(), + body: None, + }, + deadline: std::time::Instant::now() + Duration::from_secs(2), + shared: shared.clone(), + items: Arc::new(super::AtomicUsize::new(0)), + bytes_received: Arc::new(super::AtomicUsize::new(0)), + bytes_sent: Arc::new(super::AtomicUsize::new(0)), + status: std::sync::Mutex::new(None), + headers: std::sync::Mutex::new(None), + url: std::sync::Mutex::new(None), + }); + let producer = shared.clone(); + let handle = std::thread::spawn(move || { + producer.mark_worker_running(); + worker.run(); + producer.mark_worker_finished(); + producer.native_join.wake_stream(); + body_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + }); + shared.item_ack.notify_one(); + body_rx.recv_timeout(Duration::from_secs(2)).unwrap(); + assert!(matches!( + receiver.try_recv(), + Ok(super::SseMessage::Open(200, _, _)) + )); + server.join().unwrap(); + // The body wake was consumed while the VM is in its synchronous + // terminal handoff; no real stream waiter is re-registered afterward. + stream_rx.try_iter().for_each(drop); + assert!( + shared.result.lock().unwrap().as_ref().unwrap().is_ok(), + "real network body completed" + ); + assert!(shared.done.load(Ordering::Acquire)); + assert!(shared.thread_finished.load(Ordering::Acquire)); + assert!( + !handle.is_finished(), + "tail must hold the actual native handle" + ); + shared.native_join.state.lock().unwrap().handle = Some(handle); + let mut vm = crate::vm::Vm::new(crate::vm::Program::new( + Vec::new(), + vec![crate::vm::OpCode::Ret as u8], + )); + let scope = vm.execution_scope(); + let resource = scope + .push_resource(SseStreamResource { + shared: shared.clone(), + }) + .unwrap() + .handle(); + let operation = scope + .start_operation(OperationSpec::new(SseScopeOperation { + shared: shared.clone(), + })) + .unwrap(); + scope.complete_operation(operation).unwrap(); + assert_eq!( + scope + .close_resource::(resource, ResourceCloseReason::Requested) + .unwrap(), + CloseProgress::Pending + ); + let (op_tx, op_rx) = mpsc::channel(); + let (close_tx, close_rx) = mpsc::channel(); + let op_waker = futures_util::task::waker(Arc::new(Wake(op_tx))); + let close_waker = futures_util::task::waker(Arc::new(Wake(close_tx))); + assert!( + scope + .poll_operation_quiescence(operation, &mut Context::from_waker(&op_waker)) + .is_pending() + ); + assert!( + scope + .poll_resource_close::( + resource, + &mut Context::from_waker(&close_waker) + ) + .is_pending() + ); + assert!( + admission.acquire().is_err(), + "permit retained before native join" + ); + assert!(shared.native_join.notifier_running.load(Ordering::Acquire)); + release_tx.send(()).unwrap(); + let op_wake = op_rx.recv_timeout(Duration::from_secs(2)); + let close_wake = close_rx.recv_timeout(Duration::from_secs(2)); + stream_rx + .recv_timeout(Duration::from_secs(2)) + .expect("native join must wake the stream after its earlier body wake was consumed"); + assert!( + op_wake.is_ok(), + "native exit supplied no automatic quiescence wake" + ); + assert!( + close_wake.is_ok(), + "native exit supplied no automatic close wake" + ); + assert!(matches!( + scope.poll_resource_close::( + resource, + &mut Context::from_waker(&close_waker) + ), + Poll::Ready(Ok(())) + )); + assert!(matches!( + scope.poll_operation_quiescence(operation, &mut Context::from_waker(&op_waker)), + Poll::Ready(Ok(_)) + )); + assert_eq!(scope.resources().len(), 0); + assert_eq!(scope.operations().len(), 0); + assert!(shared.is_quiescent()); + assert!( + shared + .native_join + .state + .lock() + .unwrap() + .stream_waker + .is_none() + ); + assert!( + shared.native_join.state.lock().unwrap().handle.is_none(), + "original worker exclusively joined" + ); + assert_eq!( + Arc::strong_count(&shared), + 1, + "retired owners retain no producer roots" + ); + drop(shared); + assert!( + admission.acquire().is_ok(), + "real retirement releases permit" + ); + } + + #[test] + fn native_join_spawn_failure_retains_original_handle_and_panic_result() { + use std::sync::{Arc, mpsc}; + use std::time::Duration; + struct Wake(mpsc::Sender<()>); + impl futures_util::task::ArcWake for Wake { + fn wake_by_ref(this: &Arc) { + let _ = this.0.send(()); + } + } + let completion = Arc::new(SseNativeJoin::new("SSE", "rustscript-sse-join")); + let (release, held) = mpsc::channel(); + completion.state.lock().unwrap().handle = Some(std::thread::spawn(move || { + held.recv().unwrap(); + panic!("original producer panic"); + })); + let error = completion + .poll_join_with(|_task| Err(std::io::Error::other("local injected notifier failure"))) + .unwrap_err(); + assert!(error.contains("ownership retained")); + assert!(completion.state.lock().unwrap().handle.is_some()); + assert!(!completion.state.lock().unwrap().armed); + assert!(!completion.is_joined()); + let (tx, rx) = mpsc::channel(); + let wake = futures_util::task::waker(Arc::new(Wake(tx))); + completion.close_waker.register(&wake); + assert!(!completion.poll_join().unwrap()); + release.send(()).unwrap(); + rx.recv_timeout(Duration::from_secs(2)) + .expect("automatic wake after panicking original joins"); + assert!(completion.is_joined()); + assert!( + completion + .poll_join() + .unwrap_err() + .contains("original producer panic") + ); + assert!(completion.state.lock().unwrap().handle.is_none()); + } + #[test] fn worker_spawn_abstraction_can_inject_a_builder_failure() { FAIL_NEXT_WORKER_SPAWN.store(true, Ordering::Release); 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/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 74a059ba..af9a9ead 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; @@ -1104,10 +1118,186 @@ 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)?; + } + Expr::ResultTry { + value, + value_slot, + payload_slot, + exit_cleanup, + span, + .. + } => self.compile_result_try(value, *value_slot, *payload_slot, exit_cleanup, *span)?, } 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(()) + } + + 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.record_operand_types(ValueType::Map, ValueType::String); + 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, @@ -1759,7 +1949,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)?; @@ -1778,7 +1976,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); @@ -2178,7 +2384,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, } } @@ -2340,6 +2552,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/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 ee65b2ae..debcb356 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 @@ -694,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) => { @@ -765,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, @@ -818,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, @@ -836,11 +826,37 @@ 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); } + index.call_arguments.retain_mut(|site| { + let remap = |span: &mut Span| { + if let Some(mapped) = + lowered + .byte_mapping + .map_span(original_source_id, *span, lowered_source_id) + { + *span = mapped; + true + } else { + false + } + }; + remap(&mut site.callee_span) + && remap(&mut site.expr_span) + && site.arguments.iter_mut().all(|arg| remap(&mut arg.span)) + }); for decl in &mut index.local_decls { map(&mut decl.ident_span); map(&mut decl.stmt_span); @@ -861,17 +877,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; @@ -1856,6 +2031,144 @@ 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: + Expr::ResultConstruct { + span, semantic_id, .. + } + | Expr::ResultQuery { + span, semantic_id, .. + } + | Expr::ResultMatch { + span, semantic_id, .. + }, + .. + } = stmt + { + 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/frontends/rustscript.rs b/src/compiler/frontends/rustscript.rs index fe448e5e..19ff581e 100644 --- a/src/compiler/frontends/rustscript.rs +++ b/src/compiler/frontends/rustscript.rs @@ -24,6 +24,14 @@ impl ParserDialect for RustScriptDialect { fn allow_for_in_loop(&self) -> bool { true } + + fn allow_result_syntax(&self) -> bool { + true + } + + fn allow_result_try(&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..4204ee9b 100644 --- a/src/compiler/host_call_resolve.rs +++ b/src/compiler/host_call_resolve.rs @@ -367,6 +367,36 @@ pub(crate) fn resolve_candidate_slice_with_passing( resolve_candidate_refs(requested_name, &named, args, fingerprint) } +#[derive(Clone, Debug)] +pub(crate) enum ArgumentRejection { + Schema { + argument: usize, + }, + Passing { + argument: usize, + expected: HostParamPassing, + actual: HostParamPassing, + }, +} + +pub(crate) fn resolve_candidate_slice_for_admission( + name: &str, + candidates: &[HostFunctionSchema], + args: &[ActualCallArg<'_>], + fingerprint: HostApiFingerprint, +) -> ( + Result, + Option, +) { + let named = candidates + .iter() + .filter(|candidate| candidate.name == name) + .collect::>(); + let mut reason = None; + let result = resolve_candidate_refs_for_admission(name, &named, args, fingerprint, &mut reason); + (result, reason) +} + /// Shared selection core over an already requested-name-filtered slice. /// /// `named` holds only candidates whose name equals `name`, in slice order. An @@ -384,11 +414,21 @@ pub(crate) fn resolve_candidate_slice_with_passing( /// [`HostCallResolveError::NoMatch`]. Passing intent is a pure viability gate: /// an exact [`Some`] intent that differs from the candidate parameter's mode /// rules that candidate non-viable, while anything else leaves it viable. -fn resolve_candidate_refs<'a, A: ActualCallArgView>( +fn resolve_candidate_refs( + name: &str, + named: &[&HostFunctionSchema], + args: &[A], + fingerprint: HostApiFingerprint, +) -> Result { + resolve_candidate_refs_for_admission(name, named, args, fingerprint, &mut None) +} + +fn resolve_candidate_refs_for_admission<'a, A: ActualCallArgView>( name: &str, named: &[&'a HostFunctionSchema], args: &[A], fingerprint: HostApiFingerprint, + reason: &mut Option, ) -> Result { if named.is_empty() { return Err(HostCallResolveError::UnknownFunction(name.to_string())); @@ -475,6 +515,20 @@ fn resolve_candidate_refs<'a, A: ActualCallArgView>( // No viable candidate: report the best concrete mismatch. let (best_candidate, mismatch) = best_concrete_mismatch(&non_viable, args); + // Retain the canonical argument/reason before rendering any diagnostic. + *reason = mismatch.as_ref().and_then(|mismatch| { + if mismatch.passing { + Some(ArgumentRejection::Passing { + argument: mismatch.index, + expected: best_candidate?.params[mismatch.index].passing, + actual: args[mismatch.index].passing()?, + }) + } else { + Some(ArgumentRejection::Schema { + argument: mismatch.index, + }) + } + }); let suffix = best_candidate .map(|function| format!("; best candidate is `{}`", signature_label(function))) .unwrap_or_default(); @@ -612,6 +666,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)), @@ -903,6 +970,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 +1041,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 +1098,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(), } } @@ -1115,6 +1198,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/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..df3b9815 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(), } } @@ -306,11 +325,9 @@ 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) } TypeSchema::Named(name, args) => { if args @@ -663,9 +680,72 @@ 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, + }, + /// 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 { + 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); + } + Expr::ResultTry { value, .. } => visit(value), + _ => {} + } + } + /// The exact per-call host-call catalog resolution carried by this node, /// if it is a catalog-resolved [`Expr::Call`]. /// @@ -722,6 +802,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 +1471,14 @@ 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 { .. } + | Expr::ResultTry { .. }) => { + other.visit_result_children(|child| { + collect_resolved_calls_in_expr(child, by_id, typed, slot_schemas); + }); + } _ => {} } } @@ -1436,6 +1527,31 @@ pub struct ParsedCallSite { pub is_namespace_call: bool, } +/// Exact source-level shape at one direct argument boundary. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParsedArgumentShape { + Other, + Borrow, + BorrowMut, + Copy, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParsedCallArgument { + pub span: Span, + pub shape: ParsedArgumentShape, + /// Direct wrapper operand only; never inferred from nearby references. + pub local: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParsedCallArguments { + pub call_id: SemanticNodeId, + pub callee_span: Span, + pub expr_span: Span, + pub arguments: Vec, +} + /// A parsed local variable declaration site. #[derive(Clone, Debug)] pub struct LocalDeclSite { @@ -1612,6 +1728,8 @@ pub struct ParsedSemanticIndex { pub type_provenance: Vec, /// All parsed call sites, in allocation order. pub call_sites: Vec, + /// Source-only ordered argument provenance; absent for synthetic calls. + pub call_arguments: Vec, /// All parsed local declarations, in allocation order. pub local_decls: Vec, /// All parsed local variable references, in allocation order. diff --git a/src/compiler/lifetime/availability.rs b/src/compiler/lifetime/availability.rs index c9c67ae8..a0157f55 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 } @@ -117,9 +125,11 @@ pub(super) fn enforce_local_availability( owned_local_slots, None, None, + &[], ) } +#[allow(clippy::too_many_arguments)] // Source policy and admission provenance are independent inputs. pub(super) fn enforce_source_availability( mut ir: FrontendIr, entry_locals: &[EntryLocalAvailability], @@ -128,6 +138,7 @@ pub(super) fn enforce_source_availability( owned_local_slots: &[bool], source_policies: Option<&HashMap>, sources: Option<&super::super::source_map::SourceMap>, + rejections: &[super::super::typing::CanonicalHostRejection], ) -> Result { // Pad the post-legalize ownership metadata to the analyzer's local space. // Availability runs pre-compaction, so the logical slot indices align with @@ -172,9 +183,18 @@ pub(super) fn enforce_source_availability( &initial_impls, policy, &owned_slots, + ) + .with_rejections( + rejections, + ir.parsed_semantic_index.as_ref(), + sources.and_then(|sources| { + ir.function_sources + .get(index) + .and_then(|source| sources.source_id_by_name(source)) + }), ); 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)) })?; @@ -210,6 +230,11 @@ pub(super) fn enforce_source_availability( &rewritten_impls, policy, &owned_slots, + ) + .with_rejections( + rejections, + ir.parsed_semantic_index.as_ref(), + sources.and_then(|sources| source.and_then(|source| sources.source_id_by_name(source))), ); let (stmts, next) = analyzer .analyze_block(&ir.stmts[start..end], state, true) @@ -302,6 +327,7 @@ struct AvailabilityAnalyzer { /// anywhere. Owned slots are move-only and cannot be copied or borrowed /// outside exact host-call arguments. owned_local_slots: Vec, + rejections: Vec, } impl AvailabilityAnalyzer { @@ -345,11 +371,125 @@ impl AvailabilityAnalyzer { next_collection_alias_id: Cell::new(1), enable_local_move_semantics, owned_local_slots: owned, + rejections: Vec::new(), + } + } + + fn with_rejections( + mut self, + rejections: &[super::super::typing::CanonicalHostRejection], + parsed: Option<&super::super::ir::ParsedSemanticIndex>, + source: Option, + ) -> Self { + if let (Some(parsed), Some(source)) = (parsed, source) { + self.rejections = rejections + .iter() + .filter(|rejection| { + let site = &rejection.provenance; + let reason_in_range = match rejection.reason { + Some( + super::super::host_call_resolve::ArgumentRejection::Schema { argument } + | super::super::host_call_resolve::ArgumentRejection::Passing { + argument, + .. + }, + ) => argument < site.arguments.len(), + None => true, + }; + reason_in_range + && site.callee_span.source_id == source + && parsed + .call_arguments + .iter() + .filter(|args| *args == site) + .count() + == 1 + && parsed + .call_sites + .iter() + .filter(|call| { + call.id == site.call_id + && call.callee_span == site.callee_span + && call.expr_span == site.expr_span + }) + .count() + == 1 + }) + .cloned() + .collect(); + } + self + } + + /// Only parser-proven direct wrappers of this recorded failed host call + /// participate. These are admission facts, never runtime passing modes. + fn analyze_rejected_arg( + &self, + id: Option, + position: usize, + arg: &Expr, + state: &FlowState, + line: u32, + ) -> Option> { + use super::super::host_call_resolve::ArgumentRejection; + use super::super::ir::ParsedArgumentShape; + let mut matching = self + .rejections + .iter() + .filter(|rejection| Some(rejection.provenance.call_id) == id); + let rejection = matching.next()?; + if matching.next().is_some() { + return None; + } + let site = rejection.provenance.arguments.get(position)?; + let (shape, inner) = match arg { + Expr::Borrow(inner) => (ParsedArgumentShape::Borrow, inner.as_ref()), + Expr::BorrowMut(inner) => (ParsedArgumentShape::BorrowMut, inner.as_ref()), + Expr::ToOwned(inner) => (ParsedArgumentShape::Copy, inner.as_ref()), + _ => return None, + }; + if shape != site.shape + || site.span.source_id != rejection.provenance.callee_span.source_id + || site.span.lo < rejection.provenance.callee_span.hi + || site.span.hi >= rejection.provenance.expr_span.hi + { + return None; + } + let local = match inner { + Expr::Var(slot) | Expr::MoveVar(slot) => Some(*slot), + _ => None, + }; + if local != site.local { + return None; + } + match shape { + ParsedArgumentShape::Borrow | ParsedArgumentShape::BorrowMut => { + Some(self.analyze_expr_to_owned(inner, state, line)) + } + ParsedArgumentShape::Copy + if matches!(rejection.reason, + Some(ArgumentRejection::Passing { argument, expected: HostParamPassing::TakeOwned | HostParamPassing::Borrow | HostParamPassing::BorrowMut, + actual: HostParamPassing::Value }) if argument == position) => + { + let slot = local?; + if !self.is_owned_slot(slot) { + return None; + } + // Same invalid copy argument owns the canonical passing error, + // including an already moved local. Preserve state unchanged; + // all other arguments and subsequent uses still run normally. + Some( + self.require_available(slot, state, line) + .map(|()| state.clone()), + ) + } + _ => None, } } fn analyze_function_impl( &self, + function_index: u16, function_impl: FunctionImpl, ) -> Result { let FunctionImpl { @@ -360,18 +500,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 +689,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 +733,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 = @@ -595,7 +750,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)?; @@ -639,7 +794,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 +821,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 +911,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 +927,7 @@ impl AvailabilityAnalyzer { }; let rewritten = Stmt::While { - condition: condition.clone(), + condition, body: rewritten_body, line, }; @@ -817,7 +974,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 +996,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,15 +1065,35 @@ 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. Expr::ModuleCall(_, _, args, _) => self.analyze_args(args, state, line), - Expr::Call(index, _, args, resolution, _) => { + Expr::Call(index, _, args, resolution, semantic_id) => { + if resolution.is_none() + && self + .rejections + .iter() + .any(|rejection| Some(rejection.provenance.call_id) == *semantic_id) + { + let mut out = state.clone(); + for (position, arg) in args.iter().enumerate() { + out = match self.analyze_rejected_arg( + *semantic_id, + position, + arg, + &out, + line, + ) { + Some(result) => result?, + None => self.analyze_expr(arg, &out, line)?, + }; + } + return Ok(out); + } if !self.enable_local_move_semantics { if let Some(root_slot) = self.extract_collection_mutation_root(*index, args) { let mut out = self.analyze_args(args, state, line)?; @@ -1018,14 +1196,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 @@ -1098,6 +1280,44 @@ 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::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, + ok_binding, + ok_expr, + err_binding, + err_expr, + .. + } => { + 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 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 @@ -1201,6 +1421,93 @@ 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)?; + // Each selected arm installs a fresh payload, including on a + // backedge after the previous iteration transferred that binding. + self.clear_local_moved_state(&mut ok_in, slot); + 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.clear_local_moved_state(&mut err_in, slot); + 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 { @@ -1210,6 +1517,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. /// @@ -1228,28 +1578,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 @@ -1480,6 +1834,14 @@ 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 { .. } + | Expr::ResultTry { .. }) => { + let mut found = false; + other.visit_result_children(|child| found |= self.expr_contains_owned_local(child)); + found + } Expr::Block { stmts, expr } => { stmts .iter() @@ -1599,11 +1961,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, @@ -1623,10 +1984,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(); @@ -1674,6 +2035,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, @@ -1683,21 +2063,47 @@ 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() { + if resolution.is_none() + && let Some(result) = + self.analyze_rejected_arg(*source_node_id, position, arg, &arg_state, 1) + { + arg_state = result?; + rewritten_args.push(arg.clone()); + continue; + } + 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(), @@ -1753,35 +2159,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) => { @@ -1797,7 +2222,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(), @@ -1808,7 +2234,7 @@ impl AvailabilityAnalyzer { &closure_state, )?), }, - self.rewrite_call_args(args, state)?, + args, )) } Expr::Add(lhs, rhs) @@ -1821,8 +2247,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)), @@ -1866,31 +2319,112 @@ 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)?), + } => { + 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, + 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::Match { + 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::ResultTry { + value, value_slot, - result_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, - arms, - default, + value_slot, + ok_binding, + ok_expr, + err_binding, + err_expr, + scrutinee_schema, + span, + semantic_id, } => { - 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 { + 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, - 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)?), + 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), @@ -1906,12 +2440,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 @@ -2352,6 +2893,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, @@ -2363,6 +2905,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]; @@ -2376,6 +2919,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 @@ -2404,6 +2953,7 @@ impl AvailabilityAnalyzer { moved_definite, moved_possible, copyable_fields, + current_owned, } } } @@ -2498,6 +3048,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 f5759e48..370f3d48 100644 --- a/src/compiler/lifetime/availability/captures.rs +++ b/src/compiler/lifetime/availability/captures.rs @@ -622,6 +622,14 @@ impl AvailabilityAnalyzer { } self.capture_mode_for_expr(default, captured_slot, context, implicit, scan); } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { + 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..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( @@ -249,6 +274,19 @@ 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 { .. } + | 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 } => { stmts.iter().any(|stmt| stmt_uses_slot(stmt, slot)) || expr_uses_slot(expr, slot) } @@ -427,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, @@ -436,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; }; @@ -581,6 +637,19 @@ pub(super) fn collect_consumed_positions_from_expr( out, ); } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { + 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..b43865dc 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,19 @@ impl LivenessRewriter { } self.add_expr_uses_impl(default, live, conservative); } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | 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 { self.uses_expr(expr) @@ -998,7 +1016,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 +1260,46 @@ 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::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, + 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 +1555,44 @@ 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::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, + 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 +1947,14 @@ 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 { .. } + | Expr::ResultTry { .. }) => { + 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 +2145,42 @@ 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::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, + 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 +2370,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/lifetime/mod.rs b/src/compiler/lifetime/mod.rs index cbec5d19..f75f3691 100644 --- a/src/compiler/lifetime/mod.rs +++ b/src/compiler/lifetime/mod.rs @@ -69,6 +69,7 @@ pub(super) fn enforce_local_availability_with_entry_locals( ) } +#[allow(clippy::too_many_arguments)] // Keep source policy and private rejection provenance explicit. pub(super) fn enforce_source_availability_with_entry_locals( ir: FrontendIr, entry_locals: &[EntryLocalAvailability], @@ -77,6 +78,7 @@ pub(super) fn enforce_source_availability_with_entry_locals( policies: &std::collections::HashMap, default_policy: bool, sources: &super::source_map::SourceMap, + rejections: &[super::typing::CanonicalHostRejection], ) -> Result { availability::enforce_source_availability( ir, @@ -86,6 +88,7 @@ pub(super) fn enforce_source_availability_with_entry_locals( owned_local_slots, Some(policies), Some(sources), + rejections, ) } diff --git a/src/compiler/linker.rs b/src/compiler/linker.rs index 25aa3bd9..b8ab97ee 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,37 @@ 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::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, + 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 +1521,90 @@ 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::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, + 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) @@ -1796,6 +1924,18 @@ fn rebase_parsed_semantic_index( Ok(ParsedSemanticIndex { type_provenance: unit.type_provenance.clone(), + call_arguments: unit + .call_arguments + .iter() + .map(|site| { + let mut site = site.clone(); + site.call_id = remap_node(site.call_id); + for arg in &mut site.arguments { + arg.local = arg.local.map(&remap_slot).transpose()?; + } + Ok(site) + }) + .collect::, SourcePathError>>()?, call_sites, local_decls, local_refs, @@ -1842,6 +1982,7 @@ fn merge_parsed_semantic_index(merged: &mut ParsedSemanticIndex, unit: ParsedSem debug_assert!(merged.next_node_id <= unit.next_node_id); debug_assert!(merged.next_scope_id <= unit.next_scope_id); merged.call_sites.extend(unit.call_sites); + merged.call_arguments.extend(unit.call_arguments); merged.local_decls.extend(unit.local_decls); merged.local_refs.extend(unit.local_refs); merged.func_decls.extend(unit.func_decls); @@ -2628,6 +2769,7 @@ mod linker_provenance_merge_tests { stmt_spans: Vec::new(), struct_decls: Vec::new(), type_provenance: Vec::new(), + call_arguments: Vec::new(), next_node_id, next_scope_id, } @@ -2782,6 +2924,7 @@ mod linker_provenance_merge_tests { stmt_spans: Vec::new(), struct_decls: Vec::new(), type_provenance: Vec::new(), + call_arguments: Vec::new(), next_node_id: 3, next_scope_id: 1, }; @@ -2808,6 +2951,7 @@ mod linker_provenance_merge_tests { stmt_spans: Vec::new(), struct_decls: Vec::new(), type_provenance: Vec::new(), + call_arguments: Vec::new(), next_node_id: 1, next_scope_id: 1, }; @@ -2925,6 +3069,7 @@ mod linker_provenance_merge_tests { func_decls: Vec::new(), struct_decls: Vec::new(), type_provenance: Vec::new(), + call_arguments: Vec::new(), func_refs: Vec::new(), scopes: Vec::new(), stmt_spans: Vec::new(), @@ -2946,6 +3091,7 @@ mod linker_provenance_merge_tests { func_decls: Vec::new(), struct_decls: Vec::new(), type_provenance: Vec::new(), + call_arguments: Vec::new(), func_refs: Vec::new(), scopes: Vec::new(), stmt_spans: Vec::new(), @@ -3017,6 +3163,7 @@ mod linker_provenance_merge_tests { func_decls: Vec::new(), struct_decls: Vec::new(), type_provenance: Vec::new(), + call_arguments: Vec::new(), func_refs: Vec::new(), scopes: Vec::new(), stmt_spans: Vec::new(), @@ -3491,4 +3638,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..0c3a2431 100644 --- a/src/compiler/materialization.rs +++ b/src/compiler/materialization.rs @@ -488,6 +488,12 @@ impl Classifier { } self.expr(frame, default); } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { + 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..b5cedde2 100644 --- a/src/compiler/mod.rs +++ b/src/compiler/mod.rs @@ -23,9 +23,11 @@ mod materialization; mod modules; mod parser; mod pipeline; +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 42c7c1c1..2f0a66c4 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 { @@ -166,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) } } @@ -359,6 +362,11 @@ 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() + { + 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)? { @@ -828,6 +836,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 +934,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 +949,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 +1053,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 +1347,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); @@ -1200,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 @@ -2272,6 +2510,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(), } } @@ -2812,9 +3054,31 @@ impl Parser { pub(super) fn parse_call_args(&mut self) -> Result, ParseError> { let mut args = Vec::new(); + let mut arguments = Vec::new(); if !self.check(&TokenKind::RParen) { loop { - args.push(self.parse_expr()?); + let start = self.tokens[self.pos].span; + let arg = self.parse_expr()?; + let end = self.tokens[self.pos - 1].span; + use super::super::ir::{ParsedArgumentShape, ParsedCallArgument}; + let (shape, inner) = match &arg { + Expr::Borrow(inner) => (ParsedArgumentShape::Borrow, Some(inner.as_ref())), + Expr::BorrowMut(inner) => { + (ParsedArgumentShape::BorrowMut, Some(inner.as_ref())) + } + Expr::ToOwned(inner) => (ParsedArgumentShape::Copy, Some(inner.as_ref())), + _ => (ParsedArgumentShape::Other, None), + }; + let local = match inner { + Some(Expr::Var(slot)) => Some(*slot), + _ => None, + }; + arguments.push(ParsedCallArgument { + span: Span::new(start.source_id, start.lo, end.hi), + shape, + local, + }); + args.push(arg); if self.match_kind(&TokenKind::Comma) { continue; } @@ -2822,6 +3086,8 @@ impl Parser { } } self.expect(&TokenKind::RParen, "expected ')' after arguments")?; + self.call_args_by_end + .insert(self.tokens[self.pos - 1].span.hi, arguments); Ok(args) } } 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/src/compiler/parser/mod.rs b/src/compiler/parser/mod.rs index ea792b26..dd30881a 100644 --- a/src/compiler/parser/mod.rs +++ b/src/compiler/parser/mod.rs @@ -102,6 +102,17 @@ pub trait ParserDialect { fn allow_for_in_loop(&self) -> bool { false } + + 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( @@ -186,6 +197,7 @@ pub(super) struct Parser { catalog_function_decls: HashMap<(String, u8), FunctionDecl>, /// Parser-produced semantic provenance index tracked during parse. parsed_semantic_index: ParsedSemanticIndex, + call_args_by_end: HashMap>, /// Parser scope stack for tracking current scope during parse. parser_scope_stack: Vec, } @@ -252,6 +264,7 @@ impl Parser { host_api_metadata: None, catalog_function_decls: HashMap::new(), parsed_semantic_index: ParsedSemanticIndex::default(), + call_args_by_end: HashMap::new(), parser_scope_stack: vec![0], }) } @@ -670,6 +683,22 @@ impl Parser { ) -> Option { let id = self.parsed_semantic_index.alloc_node_id(); let scope_id = self.current_scope_id(); + if let Some(arguments) = self.call_args_by_end.get(&expr_span.hi) + && arguments.iter().all(|arg| { + arg.span.source_id == expr_span.source_id + && arg.span.lo >= callee_span.hi + && arg.span.hi < expr_span.hi + }) + { + self.parsed_semantic_index + .call_arguments + .push(super::ir::ParsedCallArguments { + call_id: id, + callee_span, + expr_span, + arguments: arguments.clone(), + }); + } self.parsed_semantic_index.call_sites.push(ParsedCallSite { id, callee_span, 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..e14652d4 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, runtime_rejections) = 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 { @@ -204,6 +212,7 @@ fn prepare_typed_units_with_entry( behavior, &sources, &runtime_availability, + &runtime_rejections, ) .map_err(|error| SourcePathError::SourceWithMap { error: SourceError::Parse(error), @@ -351,8 +360,16 @@ 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, rejections) = 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)?; + } + 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(); @@ -370,15 +387,18 @@ fn prepare_owned_typed_ir( entry.types, source_name, host_error.as_ref(), + &rejections, ) .map_err(SourceError::Compile)?, ); } else { - typing::validate_source_type_consistency( + typing::validate_source_type_consistency_with_host_error( &parsed, owner.policy.typing_mode, entry.types, source_name, + host_error.as_ref(), + &rejections, ) .map_err(SourceError::Compile)?; } @@ -402,28 +422,19 @@ fn prepare_owned_typed_ir( } let logical_schemas = type_info.logical_schemas(); parsed.semantic_index = Some(SemanticIndex::build(logical_schemas.clone(), &parsed)); - let owned_ir = match enforce_owned_sources( + let owned_ir = enforce_owned_sources( parsed.clone(), &type_info, owners, behavior, sources, entry.availability, - ) { - Ok(owned) => owned, - Err(error) - if !behavior.recover_host_errors - && host_error.as_ref().is_some_and(|host| { - resource_copy_duplicates_host_call(&parsed, host, &error) - }) => - { - // The resolver already rejected this exact call's argument passing. - // Retain its primary diagnostic without bypassing source hooks. This - // admission-only IR cannot reach codegen: host_error is returned below. - parsed.clone() - } - Err(error) => return Err(SourceError::Parse(error)), - }; + &rejections, + ) + .map_err(SourceError::Parse)?; + 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 { @@ -450,47 +461,6 @@ fn prepare_owned_typed_ir( Ok((parsed, type_info, owned_ir, analysis_errors)) } -fn resource_copy_duplicates_host_call( - ir: &FrontendIr, - host: &CompileError, - ownership: &ParseError, -) -> bool { - let CompileError::HostCallResolve { - span: Some(callee), .. - } = host - else { - return false; - }; - if ownership.code.as_deref() != Some("E_OWNERSHIP_COPY_RESOURCE") { - return false; - } - let Some(owned_span) = ownership.span else { - return false; - }; - // Only a standalone failed call at the very same source site can duplicate - // the copy rejection. Line equality alone would alias independent calls, - // statements or module owners; missing provenance must remain fail-closed. - ir.parsed_semantic_index.as_ref().is_some_and(|index| { - // Availability can fall back to a statement span by source and line. - // Such a fallback is not exact enough to prioritize a call if more - // than one statement occupies that line. - index - .stmt_spans - .iter() - .filter(|site| { - site.span.source_id == owned_span.source_id && site.line as usize == ownership.line - }) - .count() - == 1 - && index.call_sites.iter().any(|site| { - site.callee_span == *callee - && site.expr_span.source_id == owned_span.source_id - && site.expr_span.lo == owned_span.lo - && site.expr_span.hi <= owned_span.hi - }) - }) -} - fn enforce_owned_sources( ir: FrontendIr, facts: &typing::TypeInferenceResult, @@ -498,6 +468,7 @@ fn enforce_owned_sources( behavior: CompileBehavior, sources: &SourceMap, entry_locals: &[lifetime::EntryLocalAvailability], + rejections: &[typing::CanonicalHostRejection], ) -> Result { let owned_slots = facts .local_schemas @@ -522,6 +493,7 @@ fn enforce_owned_sources( .first() .is_some_and(|owner| owner.policy.ownership_checking), sources, + rejections, ) } @@ -584,7 +556,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 +726,13 @@ fn record_expr_local_debug_ranges( } record_expr_local_debug_ranges(default, line, ranges); } + other @ (Expr::ResultConstruct { .. } + | Expr::ResultQuery { .. } + | Expr::ResultMatch { .. } + | Expr::ResultTry { .. }) => { + 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,8 +865,13 @@ 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 parsed = typing::legalize_builtins_and_bind_types(parsed, typing_mode, entry_local_types) + let mut parsed = parsed; + super::result::resolve_result_types(&mut parsed, 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); @@ -912,6 +896,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, @@ -1396,6 +1387,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), } } @@ -3298,6 +3290,7 @@ mod tests { &prepared.capture_source_policies, true, &prepared.sources, + &[], ) .unwrap(); assert_origins(&owned); @@ -3466,6 +3459,160 @@ mod tests { assert!(record.get(&crate::Value::string("note")).is_none()); } + #[test] + #[cfg(feature = "http-client")] + fn ordered_argument_provenance_reaches_rejected_call_consumer() { + let source = "use http; let request = http::request::new(\"GET\", \"http://127.0.0.1:1/x\"); http::response::status(&request);"; + let options = CompileSourceFileOptions::default() + .with_host_api_catalog(crate::standard_host_catalog()); + let parsed = + crate::compiler::frontends::parse_source(source, SourceFlavor::RustScript, &options) + .unwrap(); + let index = parsed.parsed_semantic_index.as_ref().unwrap(); + let site = index + .call_sites + .iter() + .find(|site| site.name == "http::response::status") + .unwrap(); + let arguments = index + .call_arguments + .iter() + .find(|args| args.call_id == site.id) + .expect("ordered parser argument provenance"); + assert_eq!( + &source[arguments.arguments[0].span.lo..arguments.arguments[0].span.hi], + "&request" + ); + assert_eq!( + arguments.arguments[0].shape, + super::super::ir::ParsedArgumentShape::Borrow + ); + let (_, error, rejected) = + typing::legalize_for_admission(parsed, TypingMode::DynamicHints, &[]); + assert!(error.is_some()); + assert_eq!( + rejected.len(), + 1, + "one actual rejected host call: {rejected:?}; error={error:?}" + ); + } + + #[test] + #[cfg(feature = "http-client")] + fn rejected_argument_missing_ambiguous_or_foreign_provenance_fails_closed() { + let source = "use http; let request = http::request::new(\"GET\", \"http://127.0.0.1:1/x\"); http::response::status(&request);"; + let options = CompileSourceFileOptions::default() + .with_host_api_catalog(crate::standard_host_catalog()); + for mode in 0..4 { + let mut ir = crate::compiler::frontends::parse_source( + source, + SourceFlavor::RustScript, + &options, + ) + .unwrap(); + ir.stmt_sources = vec![Some("main.rss".into()); ir.stmts.len()]; + let (mut ir, error, rejections) = + typing::legalize_for_admission(ir, TypingMode::DynamicHints, &[]); + assert!(error.is_some()); + let facts = typing::infer_types(&ir, TypingMode::DynamicHints, &[]); + let owned = facts + .local_schemas + .iter() + .map(|schema| schema.as_ref().is_some_and(TypeSchema::contains_resource)) + .collect::>(); + let index = ir.parsed_semantic_index.as_mut().unwrap(); + match mode { + 0 => {} + 1 => index.call_arguments.clear(), + 2 => { + let duplicate = index.call_arguments.last().unwrap().clone(); + index.call_arguments.push(duplicate); + } + _ => { + for site in &mut index.call_arguments { + site.callee_span.source_id = 42; + } + } + } + let mut sources = SourceMap::new(); + sources.add_source("main.rss", source); + let result = lifetime::enforce_source_availability_with_entry_locals( + ir, + &[], + false, + &owned, + &HashMap::new(), + true, + &sources, + &rejections, + ); + if mode == 0 { + assert!(result.is_ok(), "exact provenance: {result:?}"); + } else { + assert_eq!( + result.unwrap_err().code.as_deref(), + Some("E_OWNERSHIP_BORROW_ESCAPE"), + "mode {mode}" + ); + } + } + } + + #[test] + #[cfg(feature = "http-client")] + fn parser_argument_ordinals_wrappers_and_nested_ids_use_original_source_spans() { + let source = "use http; let mut request = http::request::new(\"GET\", \"http://127.0.0.1:1/x\"); http::request::set_header(&mut request, \"é\", http::response::status(&request));"; + let options = CompileSourceFileOptions::default() + .with_host_api_catalog(crate::standard_host_catalog()); + let parsed = + crate::compiler::frontends::parse_source(source, SourceFlavor::RustScript, &options) + .unwrap(); + let index = parsed.parsed_semantic_index.unwrap(); + let outer = index + .call_sites + .iter() + .find(|site| site.name == "http::request::set_header") + .unwrap(); + let inner = index + .call_sites + .iter() + .find(|site| site.name == "http::response::status") + .unwrap(); + assert_ne!(outer.id, inner.id); + let args = index + .call_arguments + .iter() + .find(|args| args.call_id == outer.id) + .unwrap(); + assert_eq!(args.callee_span, outer.callee_span); + assert_eq!(args.expr_span, outer.expr_span); + assert_eq!( + args.arguments + .iter() + .map(|arg| &source[arg.span.lo..arg.span.hi]) + .collect::>(), + vec!["&mut request", "\"é\"", "http::response::status(&request)"] + ); + assert!( + args.arguments + .iter() + .all(|arg| arg.span.source_id == outer.callee_span.source_id) + ); + assert_eq!( + args.arguments[0].shape, + super::super::ir::ParsedArgumentShape::BorrowMut + ); + let args = index + .call_arguments + .iter() + .find(|args| args.call_id == inner.id) + .unwrap(); + assert_eq!( + &source[args.arguments[0].span.lo..args.arguments[0].span.hi], + "&request" + ); + } + #[test] fn strict_non_concrete_struct_diagnostic_carries_exact_decl_span() { // Strict typing rejects struct schemas whose fields are not fully diff --git a/src/compiler/result.rs b/src/compiler/result.rs new file mode 100644 index 00000000..11880b29 --- /dev/null +++ b/src/compiler/result.rs @@ -0,0 +1,907 @@ +//! 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, + /// 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( + 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(), + lexical_return: None, + }; + 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(); + 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() + .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; + env.lexical_return = saved_return; + 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::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) + } + 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 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(|| { + 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 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 +} + +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..576c79fd 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,25 @@ 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::ResultTry { 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/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.rs b/src/compiler/typing.rs index a6644fbe..bd4d72e0 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 @@ -227,20 +254,32 @@ pub(super) fn legalize_builtins_and_bind_types( typing_mode: TypingMode, entry_local_types: &[EntryLocalType], ) -> Result { - let (ir, error) = legalize_for_admission(ir, typing_mode, entry_local_types); + let (ir, error, _) = legalize_for_admission(ir, typing_mode, entry_local_types); match error { Some(error) => Err(error), None => Ok(ir), } } +/// Admission-only identity of an actually rejected catalog call. It never +/// supplies a successful host resolution or permits runtime transfer. +#[derive(Clone, Debug)] +pub(crate) struct CanonicalHostRejection { + pub provenance: super::ir::ParsedCallArguments, + pub reason: Option, +} + // Retain all successfully resolved facts even when another host call fails. // This view is admission/diagnostic-only until the error is discharged. pub(super) fn legalize_for_admission( mut ir: FrontendIr, typing_mode: TypingMode, entry_local_types: &[EntryLocalType], -) -> (FrontendIr, Option) { +) -> ( + FrontendIr, + Option, + Vec, +) { // Exact callee spans for every parsed call site, keyed by the // [`SemanticNodeId`] carried on the typed [`Expr::Call`] nodes. The // host-call resolver attaches these to its failure diagnostic so the @@ -257,11 +296,16 @@ pub(super) fn legalize_for_admission( }) .unwrap_or_default(); + let argument_sites = ir + .parsed_semantic_index + .as_ref() + .map(|index| index.call_arguments.clone()) + .unwrap_or_default(); let Some(metadata) = ir.host_api_metadata.clone() else { let mut pass = HostCallResolutionPass::new(None, HostCallResolutionPhase::Disabled) .with_call_site_spans(&call_site_spans); run_legalize_round(&mut ir, typing_mode, entry_local_types, &mut pass); - return (ir, None); + return (ir, None, Vec::new()); }; loop { @@ -273,18 +317,19 @@ pub(super) fn legalize_for_admission( continue; } if refine.unresolved() == 0 { - return (ir, None); + return (ir, None, Vec::new()); } let mut final_pass = HostCallResolutionPass::new(Some(&metadata), HostCallResolutionPhase::Final) - .with_call_site_spans(&call_site_spans); + .with_call_site_spans(&call_site_spans) + .with_argument_sites(&argument_sites); run_legalize_round(&mut ir, typing_mode, entry_local_types, &mut final_pass); if final_pass.changed() > 0 { continue; } if final_pass.unresolved() == 0 { - return (ir, None); + return (ir, None, Vec::new()); } return ( ir, @@ -293,6 +338,7 @@ pub(super) fn legalize_for_admission( .take_error() .expect("a final unresolved catalog call must record a compile error"), ), + final_pass.rejections, ); } } @@ -565,18 +611,47 @@ pub(super) fn validate_source_type_consistency( entry_local_types: &[EntryLocalType], owner: Option<&str>, ) -> Result<(), CompileError> { - check_source_type_consistency(ir, typing_mode, entry_local_types, owner, false, None) + check_source_type_consistency(ir, typing_mode, entry_local_types, owner, false, None, &[]) .map(|_| ()) } +pub(super) fn validate_source_type_consistency_with_host_error( + ir: &FrontendIr, + typing_mode: TypingMode, + entry_local_types: &[EntryLocalType], + owner: Option<&str>, + host_error: Option<&CompileError>, + rejections: &[CanonicalHostRejection], +) -> Result<(), CompileError> { + check_source_type_consistency( + ir, + typing_mode, + entry_local_types, + owner, + false, + host_error, + rejections, + ) + .map(|_| ()) +} + pub(super) fn analyze_source_type_consistency( ir: &FrontendIr, typing_mode: TypingMode, entry_local_types: &[EntryLocalType], owner: Option<&str>, host_error: Option<&CompileError>, + rejections: &[CanonicalHostRejection], ) -> Result, CompileError> { - check_source_type_consistency(ir, typing_mode, entry_local_types, owner, true, host_error) + check_source_type_consistency( + ir, + typing_mode, + entry_local_types, + owner, + true, + host_error, + rejections, + ) } fn check_source_type_consistency( @@ -586,6 +661,7 @@ fn check_source_type_consistency( owner: Option<&str>, recover_typing_diagnostics: bool, host_error: Option<&CompileError>, + rejections: &[CanonicalHostRejection], ) -> Result, CompileError> { let mut diagnostics = Vec::new(); let dependent_host_returns = host_error @@ -608,6 +684,8 @@ fn check_source_type_consistency( typing_mode, ir.parsed_semantic_index.as_ref(), ); + context.retain_catalog_rejection(host_error); + context.retain_catalog_rejection_contexts(rejections); for (index, stmt) in ir.stmts.iter().enumerate() { if owner.is_some_and(|owner| { ir.stmt_sources @@ -909,6 +987,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; @@ -1516,6 +1827,28 @@ mod catalog_call_resolution_tests { let configured = || CompileSourceFileOptions::default().with_host_api_catalog(Arc::clone(&catalog)); + let model = crate::compiler::analyze_source_from_string_with_options( + "main.rss", + source, + configured(), + ) + .expect("same-argument recovery retains the canonical catalog diagnostic"); + assert_eq!(model.diagnostics().len(), 1); + assert!( + model.diagnostics()[0] + .message + .contains("expected passing take_owned, found passing value"), + "{:?}", + model.diagnostics() + ); + let span = model.diagnostics()[0].span.unwrap(); + assert_eq!(model.sources().span_text(span), Some("acme::consume")); + assert_eq!(model.sources().file_name(span.source_id), Some("main.rss")); + assert_eq!( + model.sources().line_col_for_offset(span.source_id, span.lo), + Some((3, 1)) + ); + let valid = "use acme;\nlet file = acme::open(\"x\");\nacme::consume(file);\n"; crate::compiler::compile_source_with_flavor_and_options( valid, @@ -1541,7 +1874,6 @@ mod catalog_call_resolution_tests { // use-after-move is independent of an invalid catalog call, including // when the independent statement shares the same source line. for invalid in [ - source, "use acme;\nlet file = acme::open(\"x\");\nfile.copy(); acme::consume(42);\n", "use acme;\nlet file = acme::open(\"x\");\nacme::consume(42); file.copy();\n", "use acme;\nlet file = acme::open(\"x\");\nacme::consume(file);\nfile;\nacme::consume(42);\n", @@ -1554,25 +1886,23 @@ mod catalog_call_resolution_tests { .err() .expect("host recovery must retain ownership rejection"); let mut errors = vec![analyzed]; - if invalid != source { - errors.push( - crate::compiler::compile_source_with_flavor_and_options( - invalid, - SourceFlavor::RustScript, - configured(), - ) - .err() - .expect("independent ownership rejection in compile"), - ); - errors.push( - crate::compiler::collect_inferred_local_type_hints_with_options( - invalid, - SourceFlavor::RustScript, - configured(), - ) - .expect_err("independent ownership rejection in hints"), - ); - } + errors.push( + crate::compiler::compile_source_with_flavor_and_options( + invalid, + SourceFlavor::RustScript, + configured(), + ) + .err() + .expect("independent ownership rejection in compile"), + ); + errors.push( + crate::compiler::collect_inferred_local_type_hints_with_options( + invalid, + SourceFlavor::RustScript, + configured(), + ) + .expect_err("independent ownership rejection in hints"), + ); for error in errors { let crate::compiler::SourcePathError::SourceWithMap { error: crate::compiler::SourceError::Parse(error), diff --git a/src/compiler/typing/collect.rs b/src/compiler/typing/collect.rs index 8234f350..3312bec1 100644 --- a/src/compiler/typing/collect.rs +++ b/src/compiler/typing/collect.rs @@ -384,6 +384,19 @@ fn collect_root_expr( | Expr::ModuleFunctionRef(..) | Expr::UnresolvedFunctionRef { .. } | 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, + 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 +1199,137 @@ 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::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, + 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..52ebc059 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,12 @@ 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, + /// A catalog rejection already owns this exact parser-proven call site. + /// Skip only its flat legacy signature check, never child/body validation. + rejected_host_calls: Vec<(Span, Span)>, } struct CallableBody<'a> { @@ -170,6 +179,60 @@ impl<'a> TypeContext<'a> { active_observed_returns: Vec::new(), active_optional_returns: Vec::new(), parsed, + result_payload_strict: false, + rejected_host_calls: Vec::new(), + } + } + + pub(super) fn retain_catalog_rejection(&mut self, error: Option<&CompileError>) { + let Some(CompileError::HostCallResolve { + span: Some(callee), .. + }) = error + else { + return; + }; + if let Some(site) = self.parsed.and_then(|index| { + index + .call_sites + .iter() + .find(|site| site.callee_span == *callee) + .map(|site| (site.callee_span, site.expr_span)) + }) { + self.rejected_host_calls.push(site); + } + } + + pub(super) fn retain_catalog_rejection_contexts( + &mut self, + rejections: &[super::CanonicalHostRejection], + ) { + let Some(parsed) = self.parsed else { + return; + }; + for rejection in rejections { + let site = &rejection.provenance; + // Same span-exact flat-signature deferral, restricted to actual + // parser-proven resolver failures. No child/body check is skipped. + if parsed + .call_arguments + .iter() + .filter(|args| *args == site) + .count() + == 1 + && parsed + .call_sites + .iter() + .filter(|call| { + call.id == site.call_id + && call.callee_span == site.callee_span + && call.expr_span == site.expr_span + }) + .count() + == 1 + { + self.rejected_host_calls + .push((site.callee_span, site.expr_span)); + } } } @@ -294,6 +357,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 +534,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; @@ -543,6 +623,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)) } @@ -758,6 +859,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, } } @@ -829,6 +937,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), } } @@ -860,6 +969,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), } } @@ -1307,6 +1423,52 @@ impl<'a> TypeContext<'a> { self.apply_stmts(stmts, &mut nested); 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)), + }, + 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 +1596,66 @@ 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::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, + 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); @@ -2459,6 +2681,15 @@ impl<'a> TypeContext<'a> { source_name: Option<&str>, span: Option, ) -> Result<(), CompileError> { + if self + .rejected_host_calls + .iter() + .any(|(callee, expression)| span == Some(*callee) || span == Some(*expression)) + { + // The full schema/passing resolver will return its canonical error. + // This does not resolve the call or permit this IR to reach codegen. + return Ok(()); + } for (index, param) in signature.params.iter().enumerate() { let crate::builtins::CallableParamType::Callable(callable) = param.ty else { continue; @@ -2861,7 +3092,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 +3292,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 +3346,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 +3406,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..04158e1a 100644 --- a/src/compiler/typing/helpers.rs +++ b/src/compiler/typing/helpers.rs @@ -7,7 +7,7 @@ use crate::host_api::{HostFunctionSchema, HostParamPassing}; use super::super::CompileError; use super::super::TypingMode; -use super::super::host_call_resolve::{ActualCallArg, resolve_candidate_slice_with_passing}; +use super::super::host_call_resolve::{ActualCallArg, resolve_candidate_slice_for_admission}; use super::super::ir::{ AssignmentKind, Expr, FunctionDecl, FunctionImpl, HostApiIrMetadata, LocalSlot, MatchPattern, ResolvedHostCall, SemanticNodeId, Stmt, StructDecl, TypeSchema, @@ -42,6 +42,8 @@ pub(super) struct HostCallResolutionPass<'a> { changed: usize, unresolved: usize, first_error: Option, + pub(super) rejections: Vec, + argument_sites: &'a [super::super::ir::ParsedCallArguments], /// Exact callee span of the failing call site, recorded when the call /// carried parser provenance ([`SemanticNodeId`] -> callee span). call_site_spans: Option<&'a std::collections::HashMap>, @@ -59,6 +61,8 @@ impl<'a> HostCallResolutionPass<'a> { changed: 0, unresolved: 0, first_error: None, + rejections: Vec::new(), + argument_sites: &[], call_site_spans: None, } } @@ -74,6 +78,14 @@ impl<'a> HostCallResolutionPass<'a> { self } + pub(super) fn with_argument_sites( + mut self, + sites: &'a [super::super::ir::ParsedCallArguments], + ) -> Self { + self.argument_sites = sites; + self + } + pub(super) fn changed(&self) -> usize { self.changed } @@ -103,7 +115,7 @@ impl<'a> HostCallResolutionPass<'a> { let Some(metadata) = self.metadata else { return; }; - let Expr::Call(index, _, args, resolution, _) = expr else { + let Expr::Call(index, _, args, resolution, semantic_id) = expr else { return; }; if resolution.is_some() { @@ -153,8 +165,12 @@ impl<'a> HostCallResolutionPass<'a> { .zip(&schemas) .map(|(arg, schema)| ActualCallArg::new(schema, actual_passing(arg, schema))) .collect::>(); - let result = - resolve_candidate_slice_with_passing(&name, resolver_candidates, &actuals, fingerprint); + let (result, reason) = resolve_candidate_slice_for_admission( + &name, + resolver_candidates, + &actuals, + fingerprint, + ); match result { Ok(resolved) => { if let Err(error) = @@ -168,6 +184,25 @@ impl<'a> HostCallResolutionPass<'a> { } Err(error) => { self.unresolved += 1; + if self.phase == HostCallResolutionPhase::Final { + let matching = self + .argument_sites + .iter() + .filter(|site| Some(site.call_id) == *semantic_id) + .collect::>(); + if let [provenance] = matching.as_slice() + && provenance.arguments.len() == args.len() + && self + .call_site_spans + .and_then(|spans| spans.get(&provenance.call_id)) + == Some(&provenance.callee_span) + { + self.rejections.push(super::CanonicalHostRejection { + provenance: (*provenance).clone(), + reason, + }); + } + } if self.phase == HostCallResolutionPhase::Final && self.first_error.is_none() { // Record the exact callee span of the failing call site // when the call carried parser provenance; the semantic @@ -286,6 +321,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 +1301,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 +1342,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 +1475,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 +1965,16 @@ 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 { .. } + | Expr::ResultTry { .. }) => { + 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 +2047,14 @@ 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 { .. } + | Expr::ResultTry { .. }) => { + 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 +2251,67 @@ 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::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, + 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..cfeb5675 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,105 @@ 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::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, + 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/lib.rs b/src/lib.rs index aca9b61d..6e2ccde1 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::{ @@ -84,7 +88,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/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/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/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..a1e789b5 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 } @@ -1406,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 @@ -1533,10 +1543,23 @@ 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")?; + // Get and other generic value producers retain Tagged even + // when the logical source condition is Bool. Validate/unbox + // the real runtime tag, without trusting absent scalar hints. + if condition.value.repr == AotSsaValueRepr::Tagged { + 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, }); } @@ -2846,11 +2869,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] } @@ -2924,6 +2960,303 @@ mod tests { use super::*; use crate::{BytecodeBuilder, HostImport, Value}; + #[cfg(feature = "cranelift-jit")] + #[test] + fn r4_dynamic_result_aot_ssa_payload_checkpoints_and_host_root_execution() { + let vectors = [ + ("fn(int) -> int", Value::Null), + ("int", Value::Int(-37)), + ("float", Value::Float(-0.0)), + ("float", Value::Float(f64::NAN)), + ("bool", Value::Bool(false)), + ("string", Value::string("ssa-runtime-input")), + ("int?", Value::Null), + ("int?", Value::Int(29)), + ( + "Result", + crate::result::carrier(false, Value::Bool(false)), + ), + ]; + for (ty, input) in vectors { + let source = format!( + "pub fn increment(n: int) -> int {{ n + 1 }} pub fn relay(flag: bool, payload: {ty}) -> Result<{ty}, {ty}> {{ let carrier: Result<{ty}, {ty}> = if flag => {{ Result::Ok(payload) }} else => {{ Result::Err(payload) }}; let selected = match carrier {{ Result::Ok(v) => v, Result::Err(e) => e, }}; if flag => {{ Result::Ok(selected) }} else => {{ Result::Err(selected) }} }} 0;" + ); + let compiled = crate::compile_source(&source).unwrap(); + let program = compiled.program.with_local_count(compiled.locals); + let ssa = build_aot_ssa(&program).unwrap(); + ssa.verify().unwrap(); + let text = ssa.text(); + assert!(!text.contains("InterpreterBoundary")); + let mut getters = Vec::new(); + let mut ip = 0; + while ip < program.code.len() { + let opcode = crate::OpCode::try_from(program.code[ip]).unwrap(); + if opcode == crate::OpCode::Call + && u16::from_le_bytes([program.code[ip + 1], program.code[ip + 2]]) + == crate::BuiltinFunction::Get.call_index() + { + let resume = ip + 1 + opcode.operand_len(); + let checkpoint = ssa + .checkpoint_for_ip(resume) + .expect("real generic Get resume checkpoint"); + assert_eq!(checkpoint.stack.last(), Some(&AotSsaValueRepr::Tagged)); + getters.push((ip, resume, checkpoint.stack.clone())); + } + ip += 1 + opcode.operand_len(); + } + assert!( + getters.len() >= 3, + "tag and both active-arm payload Get sites required" + ); + println!("R4 AOT SSA ty={ty} getters={getters:?}\n{text}"); + let mut machine = crate::Vm::new_with_jit_config( + program, + crate::JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + machine.set_fuel(100_000); + machine.compile_aot().unwrap(); + assert_eq!(machine.run().unwrap(), crate::VmStatus::Halted); + let callable = machine.resolve_exported_callable("relay").unwrap(); + let input = if ty == "fn(int) -> int" { + machine.resolve_exported_callable("increment").unwrap() + } else { + input + }; + for ok in [true, false] { + let before = machine.aot_exec_count(); + let returned = machine + .invoke_callable(callable.clone(), &[Value::Bool(ok), input.clone()]) + .unwrap(); + let (tag, payload) = crate::result::payload(&returned).unwrap(); + assert_eq!(tag, ok); + match (payload, &input) { + (Value::Float(actual), Value::Float(expected)) if expected.is_nan() => { + assert!(actual.is_nan()) + } + (Value::Float(actual), Value::Float(expected)) => { + assert_eq!(actual.to_bits(), expected.to_bits()); + assert_eq!(actual.is_sign_negative(), expected.is_sign_negative()); + } + _ => assert_eq!(payload, &input), + } + assert!(machine.aot_exec_count() > before); + println!( + "R4 AOT SSA runtime ty={ty} ok={ok} payload={payload:?} native_delta={}", + machine.aot_exec_count() - before + ); + if ty == "fn(int) -> int" { + assert_eq!( + machine + .invoke_callable(payload.clone(), &[Value::Int(41)]) + .unwrap(), + Value::Int(42) + ); + } + } + } + } + + #[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/host.rs b/src/vm/host.rs index 984ed576..4b91ddb9 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)); @@ -431,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 @@ -476,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", @@ -489,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 \ @@ -509,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)) }) } @@ -640,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 \ @@ -2055,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, @@ -2421,6 +2507,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/host_extension.rs b/src/vm/host_extension.rs index 6f824f67..8c05ee04 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 = 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/jit/recorder.rs b/src/vm/jit/recorder.rs index d0e07157..fde45d90 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)) } @@ -2788,7 +2789,13 @@ fn select_numeric_compare( lhs: ValueInfo, rhs: ValueInfo, ) -> Result { - if lhs.force_value_eq || rhs.force_value_eq { + if lhs.force_value_eq + || rhs.force_value_eq + || [lhs.known_type, rhs.known_type] + .into_iter() + .flatten() + .any(|ty| !matches!(ty, ValueType::Int | ValueType::Float | ValueType::Unknown)) + { return Err(TraceRecordError::UnsupportedTrace( "SSA recorder requires value equality for known non-numeric operands".to_string(), )); diff --git a/src/vm/jit/trace.rs b/src/vm/jit/trace.rs index 3e4fa193..6ee3456e 100644 --- a/src/vm/jit/trace.rs +++ b/src/vm/jit/trace.rs @@ -1249,6 +1249,8 @@ fn to_nyi(err: TraceRecordError) -> JitNyiReason { fn scan_loop_headers(program: &Program) -> Vec { let code = &program.code; let mut headers = vec![false; code.len()]; + let mut loop_ranges = Vec::new(); + let mut move_continuations = Vec::new(); let mut ip = 0usize; while ip < code.len() { @@ -1267,13 +1269,37 @@ fn scan_loop_headers(program: &Program) -> Vec { let target = target_u32 as usize; if target <= instr_ip && target < headers.len() { headers[target] = true; + loop_ranges.push(target..=instr_ip); } + } else if opcode == OpCode::Call && code.get(instr_ip + 1..instr_ip + 4).is_some() { + move_continuations.push(instr_ip + 4); } // Advance by the full instruction length (opcode plus operands) so // operand bytes are never interpreted as opcodes. ip = instr_ip.saturating_add(1 + opcode.operand_len()); } + // Calls (including owned-move helpers) may finish in the interpreter. + // Their post-call state is nevertheless a valid hot-loop entry: without + // it, the first helper permanently hides all later useful computation. + // Do not admit straight-line successors or execute/replay the call natively. + // Sweep sorted loop intervals rather than scanning every loop per call. + if !move_continuations.is_empty() { + loop_ranges.sort_unstable_by_key(|range| *range.start()); + let mut ranges = loop_ranges.iter().peekable(); + let mut covered_end = None; + for next in move_continuations { + while let Some(range) = ranges.peek() + && *range.start() <= next + { + covered_end = Some(covered_end.unwrap_or(0).max(*range.end())); + ranges.next(); + } + if next < headers.len() && covered_end.is_some_and(|end| next <= end) { + headers[next] = true; + } + } + } headers } @@ -1982,6 +2008,29 @@ mod tests { ); } + #[test] + fn scan_loop_headers_keeps_detach_continuations_inside_loops() { + let detach = crate::BuiltinFunction::DetachLocal.call_index(); + let mut bc = BytecodeBuilder::new(); + bc.call(detach, 1); + let outside = bc.position(); + bc.nop(); + let root = bc.position(); + bc.ldc(0); + bc.call(detach, 1); + let inside = bc.position(); + bc.nop(); + bc.br(root); + let program = Program::new(vec![Value::Int(0)], bc.finish()); + let entries = scan_loop_headers(&program); + assert!(entries[root as usize]); + assert!( + entries[inside as usize], + "hot owned-move continuation must remain eligible" + ); + assert!(!entries[outside as usize], "no new straight-line admission"); + } + #[test] fn scan_loop_headers_finds_backward_targets() { let mut bc = BytecodeBuilder::new(); 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 15d06a4e..dfe3f46c 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() } @@ -600,10 +609,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 +931,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 +1020,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 +1160,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); @@ -1314,6 +1399,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(()) } @@ -1343,18 +1431,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)); } @@ -2115,7 +2219,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 @@ -2853,9 +3092,11 @@ impl Vm { .execution_frames .last() .and_then(|frame| frame.prototype_id); - let target_is_valid = if let Some(prototype_id) = active_prototype - && !self.has_aot_program() - { + // Specialized callable prototypes may share a script function's + // code region. Branch authority is that active function range in + // both backends, not equality with the region's representative + // prototype (which need not be the admitted specialization). + let target_is_valid = if let Some(prototype_id) = active_prototype { self.program .callable_prototypes .get(prototype_id as usize) @@ -3179,7 +3420,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() { @@ -3486,7 +3730,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), @@ -3705,6 +3950,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", @@ -3874,7 +4120,10 @@ impl Vm { if let Some(frame) = self.instance.execution_frames.last_mut() { frame.continuation = FrameContinuation::ReturnToHost; } - match self.run_internal(None, false) { + // Host-root frames use the same typed return continuation and native + // frame ABI as guest calls. Preserve configured backend execution here; + // disabling it silently made standalone callbacks interpreter-only. + match self.run_internal(None, true) { Ok(status) => Ok(status), Err(error) => { self.abort_host_invocation(stack_base, frame_count); @@ -3964,8 +4213,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() @@ -3992,3 +4245,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/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..d063367b --- /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 quarantine_tests; +#[cfg(test)] +mod 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/src/vm/tests.rs b/src/vm/tests.rs index 05775ba2..ae8bf8cb 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,1365 @@ 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); + // The move helper still executes in the interpreter. Generic hot-loop + // call continuations now re-enter native tag/payload reads afterwards; + // Err's cleanup Ret can still finish through the interpreter continuation. + assert!(snapshot.traces.iter().any( + |trace| trace.executions > 0 && trace.op_names().last().is_some_and(|op| op == "call") + )); + assert!(snapshot.traces.iter().any(|trace| trace.executions > 0 + && trace.ssa_text().contains("String(\"tag\")") + && trace.ssa_text().contains("String(\"payload\")") + && trace.op_names().iter().any(|op| op == "map_get"))); + 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 e58c5172..cdc01cae 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}; @@ -18,8 +19,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; @@ -51,6 +50,7 @@ pub enum WireError { CodeTooLong(usize), UnsupportedConstantType(&'static str), LengthTooLarge(&'static str, usize), + InvalidRetCleanup(&'static str), TrailingBytes, } @@ -96,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"), } } @@ -303,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); @@ -344,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) } @@ -360,11 +712,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 +732,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 +741,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 +765,14 @@ 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)?; + let ret_cleanups = read_ret_cleanups( + &mut cursor, + &code, + &callable_prototypes, + &function_regions, + &named_struct_decls, + )?; if !cursor.is_eof() { return Err(WireError::TrailingBytes); @@ -447,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() @@ -1692,6 +2033,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 +2121,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 +2213,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 +2308,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)), } } @@ -2276,3 +2641,352 @@ 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); + deep.extend(std::iter::repeat_n(16, 64)); + 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/aot_artifact_process_tests.rs b/tests/aot_artifact_process_tests.rs index 6426507c..88a9e2df 100644 --- a/tests/aot_artifact_process_tests.rs +++ b/tests/aot_artifact_process_tests.rs @@ -148,6 +148,16 @@ fn roundtrip(name: &str, source: &str, result: i64) { ); } +#[test] +fn fresh_process_result_ok_err_try_artifacts_execute_without_source() { + for (flag, expected) in [("true", 8), ("false", 7)] { + let source = format!( + "fn choose(flag: bool, n: int) -> Result {{ if flag => {{ Result::Ok(n) }} else => {{ Result::Err(n) }} }} fn relay(flag: bool, n: int) -> Result {{ let v = choose(flag, n)?; Result::Ok(v + 1) }} let output = match relay({flag}, 7) {{ Result::Ok(n) => n, Result::Err(e) => e, }}; output;" + ); + roundtrip(&format!("result-{flag}"), &source, expected); + } +} + #[test] fn fresh_process_number_factorial_artifact_is_native_with_and_without_source() { roundtrip( diff --git a/tests/aot_tagged_number_tests.rs b/tests/aot_tagged_number_tests.rs index 444a9f95..86d9a458 100644 --- a/tests/aot_tagged_number_tests.rs +++ b/tests/aot_tagged_number_tests.rs @@ -348,23 +348,25 @@ fn the_same_number_factorial_frame_accepts_multiple_runtime_representations() { } #[test] -fn host_callable_invocation_retains_its_existing_interpreter_policy() { - let mut machine = exported_vm("pub fn below(n: number) -> bool { n < 2 }", true); - let callable = machine.resolve_exported_callable("below").unwrap(); - let before = machine.aot_exec_count(); - assert_eq!( - machine.invoke_callable(callable, &[Value::Int(1)]).unwrap(), - Value::Bool(true) - ); - assert_eq!( - machine.aot_exec_count(), - before, - "host invocation currently disables native execution" - ); - eprintln!( - "host invoke_callable boundary: native returns before={before} after={}", - machine.aot_exec_count() - ); +fn host_callable_invocation_respects_its_configured_native_backend() { + for native in [false, true] { + let mut machine = exported_vm("pub fn below(n: number) -> bool { n < 2 }", native); + let callable = machine.resolve_exported_callable("below").unwrap(); + let before = machine.aot_exec_count(); + assert_eq!( + machine.invoke_callable(callable, &[Value::Int(1)]).unwrap(), + Value::Bool(true) + ); + if native { + assert!(machine.aot_exec_count() > before); + } else { + assert_eq!(machine.aot_exec_count(), before); + } + eprintln!( + "host invoke_callable native={native}: before={before} after={}", + machine.aot_exec_count() + ); + } } #[test] diff --git a/tests/builtins/stdlib_tests.rs b/tests/builtins/stdlib_tests.rs index d6df463d..a40a704e 100644 --- a/tests/builtins/stdlib_tests.rs +++ b/tests/builtins/stdlib_tests.rs @@ -17,7 +17,20 @@ fn run_rustscript_spec(path: &Path) -> Vec { #[cfg(feature = "async")] super::async_test_bridge::install(&mut vm); loop { - let status = vm.run().expect("spec vm should run"); + let status = vm.run().unwrap_or_else(|error| { + let dump = vm + .dump_jit_info() + .lines() + .filter(|line| !line.trim_start().starts_with("code:")) + .collect::>() + .join("\n"); + panic!( + "spec vm should run: {error:?}, ip={}, stack={:?}\n{}\n{dump}", + vm.ip(), + vm.stack(), + vm::disassemble_program(vm.program()) + ); + }); match status { VmStatus::Halted => break, VmStatus::Yielded => continue, diff --git a/tests/compiler/frontend_plugin_fixtures/rejection_policy.rs b/tests/compiler/frontend_plugin_fixtures/rejection_policy.rs index 5e555159..c803057e 100644 --- a/tests/compiler/frontend_plugin_fixtures/rejection_policy.rs +++ b/tests/compiler/frontend_plugin_fixtures/rejection_policy.rs @@ -58,7 +58,10 @@ impl SourcePlugin for RejectionPolicy { } fn validate_typed_ir(&self, typed: &vm::FrontendTypedIr<'_>) -> Result<(), vm::CompileError> { let owner = typed.source_id.unwrap(); - if typed.host_api_catalog.is_some() { + if typed + .host_api_catalog + .is_some_and(|catalog| catalog.function("neutral::probe").is_some()) + { let checked = typed .ir .parsed_semantic_index diff --git a/tests/compiler/frontend_plugin_tests.rs b/tests/compiler/frontend_plugin_tests.rs index 57ba3499..2c905e1c 100644 --- a/tests/compiler/frontend_plugin_tests.rs +++ b/tests/compiler/frontend_plugin_tests.rs @@ -314,3 +314,968 @@ 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)]); +} + +// 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; + +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/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/http_named_struct_contract_tests.rs b/tests/http_named_struct_contract_tests.rs index e1b89f69..b5542ed1 100644 --- a/tests/http_named_struct_contract_tests.rs +++ b/tests/http_named_struct_contract_tests.rs @@ -546,6 +546,169 @@ fn wrong_resource_kind_is_rejected_by_each_accessor_family() { } } +#[test] +fn rejected_argument_contract_survives_expression_contexts() { + let prefix = "use http; let request = http::request::new(\"GET\", \"http://127.0.0.1:1/x\"); "; + for expression in [ + "http::client::request(request.copy());", + "let response = http::client::request(request.copy());", + "let response = if true => { http::client::request(request.copy()) } else => { http::client::request(request.copy()) };", + "println(http::client::request(request.copy()));", + ] { + let message = compile_err(&format!("{prefix}{expression}")); + assert!( + message.contains("expected passing take_owned, found passing value"), + "{expression}: {message}" + ); + } + let message = compile_err( + "use http; fn bad() -> int { let request = http::request::new(\"GET\", \"http://127.0.0.1:1/x\"); http::response::status(&request) } bad();", + ); + assert!( + message.contains("expected resource, found resource"), + "{message}" + ); +} + +#[test] +fn rejected_argument_continues_independent_ownership_on_both_sides() { + let prefix = "use http; let request = http::request::new(\"GET\", \"http://127.0.0.1:1/x\"); let other = http::request::new(\"GET\", \"http://127.0.0.1:1/y\"); "; + let bad = "http::client::request(request.copy());"; + for independent in [ + "other.copy();", + "http::client::request(other); http::client::request(other);", + "let escaped = &other;", + ] { + for body in [ + format!("{independent} {bad}"), + format!("{bad} {independent}"), + ] { + let message = compile_err(&format!("{prefix}{body}")); + let expected = if independent.contains("copy") { + "cannot copy resource value 'other'" + } else if independent.contains("escaped") { + "borrow of resource value 'other'" + } else { + "local 'other' was moved earlier" + }; + assert!( + message.contains(expected), + "independent ownership must remain: {body}: {message}" + ); + } + } +} + +#[test] +fn rejected_argument_recovery_retains_canonical_diagnostics_without_codegen() { + let prefix = "use http; let request = http::request::new(\"GET\", \"http://127.0.0.1:1/x\"); "; + for (body, callee, diagnostic) in [ + ( + "let response = http::client::request(request.copy());", + "http::client::request", + "expected passing take_owned, found passing value", + ), + ( + "http::response::status(&request);", + "http::response::status", + "expected resource, found resource", + ), + ] { + let source = format!("{prefix}{body}"); + let options = + || CompileSourceFileOptions::default().with_host_api_catalog(standard_host_catalog()); + let model = + vm::compiler::analyze_source_from_string_with_options("main.rss", &source, options()) + .unwrap(); + assert_eq!(model.diagnostics().len(), 1); + assert!( + model.diagnostics()[0].message.contains(diagnostic), + "{:?}", + model.diagnostics() + ); + let span = model.diagnostics()[0].span.unwrap(); + assert_eq!(model.sources().span_text(span), Some(callee)); + let error = vm::compiler::collect_inferred_local_type_hints_with_options( + &source, + SourceFlavor::RustScript, + options(), + ) + .unwrap_err(); + assert!(error.to_string().contains(diagnostic), "{error}"); + assert!( + compile_with_standard_catalog(&source).is_err(), + "rejected IR cannot become executable" + ); + } +} + +#[test] +fn rejected_argument_keeps_other_arguments_children_and_module_owners() { + let prefix = "use http; let mut request = http::request::new(\"GET\", \"http://127.0.0.1:1/x\"); let other = http::request::new(\"GET\", \"http://127.0.0.1:1/y\"); "; + for body in [ + "http::request::set_header(&request, other.copy(), \"value\");", + "http::client::request(if true => { other.copy(); request.copy() } else => { request.copy() });", + "http::response::status(&request); let escaped = &other;", + "http::response::status(&request); let child = || if true => { let other = http::request::new(\"GET\", \"http://127.0.0.1:1/z\"); other.copy() } else => { http::request::new(\"GET\", \"http://127.0.0.1:1/z\") }; child();", + ] { + let error = compile_err(&format!("{prefix}{body}")); + assert!( + error.contains("cannot copy resource value 'other'") + || error.contains("borrow of resource value 'other'"), + "{body}: {error}" + ); + } + let source = "use http; use dep as d; let request = http::request::new(\"GET\", \"http://127.0.0.1:1/x\"); http::response::status(&request); d::reject();"; + let dependency = "use http; pub fn reject() { let request = http::request::new(\"GET\", \"http://127.0.0.1:1/y\"); request.copy(); }"; + let configured = CompileSourceFileOptions::default() + .with_host_api_catalog(standard_host_catalog()) + .with_module_override_source("dep.rss", dependency); + let error = + compile_source_with_flavor_and_options(source, SourceFlavor::RustScript, configured) + .err() + .expect("dependency ownership must reject"); + let vm::SourcePathError::SourceWithMap { + error: vm::SourceError::Parse(error), + sources, + } = error + else { + panic!("module ownership diagnostic required"); + }; + assert_eq!(error.code.as_deref(), Some("E_OWNERSHIP_COPY_RESOURCE")); + assert!( + sources + .file_name(error.span.unwrap().source_id) + .unwrap() + .ends_with("dep.rss") + ); + let dependency = "use http; pub fn reject() -> int { let request = http::request::new(\"GET\", \"http://127.0.0.1:1/y\"); http::response::status(&request); 1 }"; + let configured = || { + CompileSourceFileOptions::default() + .with_host_api_catalog(standard_host_catalog()) + .with_module_override_source("dep.rss", dependency) + }; + let error = + compile_source_with_flavor_and_options(source, SourceFlavor::RustScript, configured()) + .err() + .expect("both source-qualified rejected calls remain invalid"); + assert!( + error + .to_string() + .contains("expected resource, found resource"), + "{error}" + ); + let model = + vm::compiler::analyze_source_from_string_with_options("main.rss", source, configured()) + .unwrap(); + assert_eq!(model.diagnostics().len(), 1); + assert_eq!( + model + .sources() + .span_text(model.diagnostics()[0].span.unwrap()), + Some("http::response::status") + ); +} + #[test] fn sse_callback_shape_mismatches_name_the_positional_signature() { let wrong_arity = compile_err( @@ -720,8 +883,8 @@ fn sse_event_timeout_and_open_overloads_compile() { } "#; - // Each overload is exercised in its own module; repeated use of the same - // import name in one module currently reports a duplicate declaration. + // Exercise every overload independently; the mixed-use case below also + // verifies that shared names retain distinct typed import identities. compile_ok(&format!( "{CALLBACKS} let request = http::request::new(\"GET\", \"http://127.0.0.1:1/events\"); @@ -749,8 +912,8 @@ fn sse_event_timeout_and_open_overloads_compile() { } #[test] -fn sse_repeated_import_name_reports_duplicate_declaration() { - let error = compile_err( +fn sse_repeated_import_name_keeps_distinct_typed_overloads() { + let compiled = compile_with_standard_catalog( r#" use http; fn on_event(kind: string, data: string, id: string, retry: string) -> bool { true } @@ -761,11 +924,26 @@ fn sse_repeated_import_name_reports_duplicate_declaration() { let timed = http::client::sse(second, on_event, 5000); http::sse_summary::items(&timed); "#, - ); - assert!( - error.contains("duplicate local declaration 'http::client::sse'"), - "{error}" - ); + ) + .expect("same-name overloads share their catalog identity, not their arity"); + let schemas = compiled + .program + .host_import_schemas() + .iter() + .flatten() + .filter(|schema| schema.name == "http::client::sse") + .collect::>(); + let mut arities = schemas + .iter() + .map(|schema| schema.params.len()) + .collect::>(); + arities.sort_unstable(); + assert_eq!(arities, vec![2, 3]); + let catalog = standard_host_catalog(); + let expected = catalog_import_schemas(&catalog, "http::client::sse"); + for schema in schemas { + assert!(expected.contains(schema)); + } } #[test] @@ -817,12 +995,30 @@ fn guest_struct_declaration_of_a_former_http_name_stays_a_guest_struct() { "#, ) .unwrap_or_else(|err| panic!("a guest SseEvent must compile, got {err}")); - assert!( - compiled - .program - .named_struct_decls() - .contains_key("SseEvent"), - "the guest declaration must own the VMBC struct table entry" + let declaration = compiled + .program + .named_struct_decls() + .get("__guest_type_m0_s1") + .expect("source-scoped guest declaration"); + assert_eq!(declaration.origin, vm::compiler::StructDeclOrigin::Guest); + assert_eq!( + declaration.body_schema, + vm::TypeSchema::Object(std::collections::HashMap::from([( + "kind".into(), + vm::TypeSchema::String + )])) + ); + let program = compiled.program.with_local_count(compiled.locals); + let bytes = vm::encode_program(&program).unwrap(); + let mut machine = Vm::new(vm::decode_program(&bytes).unwrap()); + machine.set_fuel(100_000); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + let Value::Map(value) = machine.stack().last().unwrap() else { + panic!("guest struct result"); + }; + assert_eq!( + value.get(&Value::string("kind")), + Some(&Value::string("event")) ); assert!( http_host_catalog().structs().is_empty(), diff --git a/tests/http_sse_resource_tests.rs b/tests/http_sse_resource_tests.rs index 3991dc16..14e64d20 100644 --- a/tests/http_sse_resource_tests.rs +++ b/tests/http_sse_resource_tests.rs @@ -208,7 +208,11 @@ async fn resource_sse_reset_closes_pending_connection_and_scope() { assert!(stream.read(&mut bytes).unwrap() > 0); stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nTransfer-Encoding: chunked\r\n\r\n").unwrap(); tx.send(()).unwrap(); - assert_eq!(stream.read(&mut bytes).unwrap(), 0); + match stream.read(&mut bytes) { + Ok(0) => {} + Err(error) if error.kind() == std::io::ErrorKind::ConnectionReset => {} + outcome => panic!("reset must close the connection: {outcome:?}"), + } }); let source = format!( r#"use http; diff --git a/tests/jit/jit_nyi_edge_tests.rs b/tests/jit/jit_nyi_edge_tests.rs index 7a85d6a7..bc440d85 100644 --- a/tests/jit/jit_nyi_edge_tests.rs +++ b/tests/jit/jit_nyi_edge_tests.rs @@ -191,11 +191,15 @@ fn jit_blocks_depth_zero_zero_benefit_call_boundary_trace() { "blocked root should have no recorded trace, dump:\n{}", vm.dump_jit_info() ); - assert_eq!( - vm.jit_native_trace_count(), - 0, - "dump:\n{}", - vm.dump_jit_info() + // The rejected pre-call root remains absent. Useful arithmetic after + // the interpreter call is independently eligible inside the loop. + assert!(vm.jit_native_trace_count() > 0, "{}", vm.dump_jit_info()); + assert!(vm.jit_native_exec_count() > 0); + assert!( + snapshot + .traces + .iter() + .any(|trace| trace.executions > 0 && trace.ssa_text().contains("iadd")) ); } } @@ -271,11 +275,15 @@ fn jit_blocks_depth_one_zero_benefit_call_boundary_trace() { "blocked root should have no recorded trace, dump:\n{}", vm.dump_jit_info() ); - assert_eq!( - vm.jit_native_trace_count(), - 0, - "dump:\n{}", - vm.dump_jit_info() + // The rejected pre-call root remains absent. Useful arithmetic after + // the interpreter call is independently eligible inside the loop. + assert!(vm.jit_native_trace_count() > 0, "{}", vm.dump_jit_info()); + assert!(vm.jit_native_exec_count() > 0); + assert!( + snapshot + .traces + .iter() + .any(|trace| trace.executions > 0 && trace.ssa_text().contains("iadd")) ); } } diff --git a/tests/pr24_resource_tests.rs b/tests/pr24_resource_tests.rs index 65d4c980..0c3945fb 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)))) } } @@ -422,6 +432,9 @@ fn block_catalog() -> Arc { #[test] fn compiled_expression_block_detaches_taken_resource_once() { + // This producer shares BLOCK_CLOSES with Result/closure tests. Hold the + // same operation-wide guard through reset, execution, assertions and VM drop. + let _guard = CLOSURE_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); BLOCK_CLOSES.store(0, Ordering::SeqCst); let options = CompileSourceFileOptions::new().with_host_api_catalog(block_catalog()); let source = r#" @@ -459,7 +472,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 +503,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 +620,2011 @@ 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", + )); + 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), + }; + 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::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.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() + .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()))?; + let raw = token.handle().raw(); + LAST_LOCAL_RAW.store(raw, Ordering::SeqCst); + Ok(CallOutcome::Return(CallReturn::one(result_map( + true, + 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(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, + )))) + } +} + +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)); + 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::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] +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); +} + +#[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); + 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/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_cost_tests.rs b/tests/result_cost_tests.rs new file mode 100644 index 00000000..86a500de --- /dev/null +++ b/tests/result_cost_tests.rs @@ -0,0 +1,381 @@ +#![cfg(all(feature = "runtime", feature = "cranelift-jit"))] +//! Finite, opt-in same-binary Result/record/direct cost characterization. +use std::{ + alloc::{GlobalAlloc, Layout, System}, + cell::Cell, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Instant, +}; +use vm::{CallOutcome, CallReturn, HostFunction, Value, Vm, VmResult, VmStatus}; +thread_local! { + static ACTIVE: Cell = const { Cell::new(false) }; + static ALLOCS: Cell = const { Cell::new(0) }; + static BYTES: Cell = const { Cell::new(0) }; + static LIVE: Cell = const { Cell::new(0) }; +} +struct Counting; +#[global_allocator] +static ALLOCATOR: Counting = Counting; +fn account(size: usize, allocating: bool) { + let _ = LIVE.try_with(|v| { + v.set( + v.get() + + if allocating { + size as i64 + } else { + -(size as i64) + }, + ) + }); + if allocating && ACTIVE.try_with(Cell::get).unwrap_or(false) { + ALLOCS.with(|v| v.set(v.get() + 1)); + BYTES.with(|v| v.set(v.get() + size as u64)); + } +} +unsafe impl GlobalAlloc for Counting { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let p = unsafe { System.alloc(layout) }; + if !p.is_null() { + account(layout.size(), true); + } + p + } + unsafe fn dealloc(&self, p: *mut u8, layout: Layout) { + account(layout.size(), false); + unsafe { + System.dealloc(p, layout); + } + } + unsafe fn realloc(&self, p: *mut u8, layout: Layout, size: usize) -> *mut u8 { + let q = unsafe { System.realloc(p, layout, size) }; + if !q.is_null() { + account(layout.size(), false); + account(size, true); + } + q + } +} +struct Input(i64, Arc); +impl HostFunction for Input { + fn call(&mut self, _: &mut Vm, _: &[Value]) -> VmResult { + self.1.fetch_add(1, Ordering::Relaxed); + Ok(CallOutcome::Return(CallReturn::one(Value::Int(self.0)))) + } +} +fn code_metrics(program: &vm::Program) -> (usize, usize, usize) { + let mut ip = 0; + let (mut instructions, mut branches, mut map_new_calls) = (0, 0, 0); + while ip < program.code.len() { + let opcode = vm::OpCode::try_from(program.code[ip]).unwrap(); + instructions += 1; + if matches!(opcode, vm::OpCode::Br | vm::OpCode::Brfalse) { + branches += 1; + } + if opcode == vm::OpCode::Call + && u16::from_le_bytes([program.code[ip + 1], program.code[ip + 2]]) + == vm::BuiltinFunction::MapNew.call_index() + { + map_new_calls += 1; + } + ip += 1 + opcode.operand_len(); + } + (instructions, branches, map_new_calls) +} + +fn source(carrier: &str, workload: &str) -> String { + let prelude = "struct Pair { tag: bool, payload: int }\nfn integer(n: int) -> int { n }\n"; + let (ret, ok, err, take) = match carrier { + "result" => ( + "Result", + "Result::Ok(n)", + "Result::Err(n)", + "match value { Result::Ok(n) => integer(n), Result::Err(e) => integer(e), }", + ), + "map" => ( + "Pair", + "{ tag: true, payload: n }", + "{ tag: false, payload: n }", + "if value.tag => { integer(value.payload) } else => { integer(value.payload) }", + ), + "direct" => ("int", "n", "n", "integer(value)"), + _ => unreachable!(), + }; + if workload == "construct_match" || workload == "function_return" { + let construct = format!("if flag => {{ {ok} }} else => {{ {err} }}"); + let (function, body) = if workload == "function_return" { + ( + format!("fn choose(flag: bool, n: int) -> {ret} {{ {construct} }}"), + "choose(flag, n)".to_string(), + ) + } else { + (String::new(), construct) + }; + return format!( + "{prelude}{function} let n = bench::input(); let flag = bench::fail() == 1000; let mut i = 0; let mut sum = 0; while i < 256 {{ let value: {ret} = {body}; let selected = {take}; sum = sum + integer(selected); i = i + 1; }} sum;" + ); + } + let function = match carrier { + "result" => { + r#"fn work(n: int, fail: int) -> Result { + let mut i = 0; let mut sum = 0; + while i < 256 { + let next: Result = if i == fail => { Result::Err(sum) } else => { Result::Ok(n) }; + let selected = next?; sum = sum + integer(selected); i = i + 1; + } + Result::Ok(sum) + }"# + } + "map" => { + r#"fn work(n: int, fail: int) -> Pair { + let mut i = 0; let mut sum = 0; let mut good = true; + while i < 256 && good { + let next: Pair = if i == fail => { { tag: false, payload: sum } } else => { { tag: true, payload: n } }; + if next.tag { sum = sum + integer(next.payload); i = i + 1; } else { good = false; } + } + let output: Pair = { tag: good, payload: sum }; + output + }"# + } + "direct" => { + r#"fn work(n: int, fail: int) -> int { + let mut i = 0; let mut sum = 0; + while i < 256 && i != fail { sum = sum + integer(n); i = i + 1; } + sum + }"# + } + _ => unreachable!(), + }; + format!( + "{prelude}{function} let value = work(bench::input(), bench::fail()); let observed = {take}; observed;" + ) +} +struct Case { + machine: Vm, + calls: Arc, + carrier: &'static str, + workload: &'static str, + backend: &'static str, + expected: i64, +} +fn rss() -> String { + std::fs::read_to_string("/proc/self/status") + .unwrap() + .lines() + .filter(|l| l.starts_with("VmRSS:") || l.starts_with("VmHWM:")) + .collect::>() + .join("; ") +} +fn execute(case: &mut Case) { + let before = case.calls.load(Ordering::Relaxed); + assert_eq!(case.machine.run().unwrap(), VmStatus::Halted); + assert_eq!( + case.machine.stack().last(), + Some(&Value::Int(case.expected)) + ); + assert_eq!(case.calls.load(Ordering::Relaxed) - before, 2); + assert_eq!(case.machine.host_context().resource_count(), 0); +} +#[test] +#[ignore = "finite opt-in allocation/timing characterization; isolated harness"] +fn measured_result_map_direct_costs() { + eprintln!( + "R4_PERF_PROTOCOL warmup=3 rounds=4 order=forward/reverse backend=interpreter,trace,aot iterations=256 compile_and_reset_outside_timer profile=debug requested_bytes_thread_local=true rss={}", + rss() + ); + let mut builder = vm::HostApiBuilder::new(); + for name in ["bench::input", "bench::fail"] { + builder.function(vm::HostFunctionSchema::with_return( + name, + vec![], + vm::HostTypeSchema::Int, + )); + } + let catalog = Arc::new(builder.build().unwrap()); + let mut cases = Vec::new(); + for backend in ["interpreter", "trace", "aot"] { + for workload in ["construct_match", "function_return", "try_ok", "try_err"] { + for carrier in ["direct", "map", "result"] { + let text = source(carrier, workload); + let start = Instant::now(); + let compiled = vm::compile_source_with_flavor_and_options( + &text, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::new().with_host_api_catalog(catalog.clone()), + ) + .unwrap(); + let compile_ns = start.elapsed().as_nanos(); + let bytecode_bytes = compiled.program.code.len(); + let locals = compiled.locals; + let (instructions, branches, map_new_calls) = code_metrics(&compiled.program); + if carrier == "direct" { + let baseline = vm::compile_source_with_flavor_and_options( + &text.replace("struct Pair { tag: bool, payload: int }\n", ""), + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::new().with_host_api_catalog(catalog.clone()), + ) + .unwrap(); + assert_eq!(compiled.locals, baseline.locals); + assert_eq!(compiled.program.code, baseline.program.code); + assert_eq!( + code_metrics(&compiled.program), + code_metrics(&baseline.program) + ); + assert_eq!( + map_new_calls, 0, + "no hidden carrier construction in direct control" + ); + } + let mut machine = Vm::new_with_jit_config( + compiled.program.with_local_count(locals), + vm::JitConfig { + enabled: backend == "trace", + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + let calls = Arc::new(AtomicUsize::new(0)); + machine.bind_function("bench::input", Box::new(Input(7, calls.clone()))); + machine.bind_function( + "bench::fail", + Box::new(Input( + if workload == "try_err" { 63 } else { 1000 }, + calls.clone(), + )), + ); + machine.set_fuel(1_000_000); + let start = Instant::now(); + if backend == "aot" { + machine.compile_aot().unwrap(); + } + let native_compile_ns = start.elapsed().as_nanos(); + let mut case = Case { + machine, + calls, + carrier, + workload, + backend, + expected: if workload == "try_err" { 441 } else { 1792 }, + }; + for _ in 0..3 { + execute(&mut case); + case.machine.reset_for_reuse().unwrap(); + case.machine.set_fuel(1_000_000); + } + let dump = if backend == "aot" { + case.machine.dump_aot_info() + } else { + case.machine.dump_jit_info() + }; + let sizes = dump + .lines() + .filter(|l| { + l.contains("code_bytes=") + || l.contains("native code bytes:") + || l.contains("native compile time:") + }) + .collect::>() + .join("; "); + eprintln!( + "R4_PERF_SETUP {}", + serde_json::json!({"backend":backend,"workload":workload,"carrier":carrier,"compile_ns":compile_ns,"native_compile_ns":native_compile_ns,"bytecode_bytes":bytecode_bytes,"locals":locals,"instructions":instructions,"branches":branches,"map_new_calls":map_new_calls,"direct_no_carrier_declaration_baseline_equal":carrier == "direct","code":sizes}) + ); + cases.push(case); + } + } + } + let rss_before = rss(); + for round in 0..4 { + let environment = || { + let processes: Vec<_> = std::fs::read_dir("/proc") + .unwrap() + .filter_map(Result::ok) + .filter_map(|entry| { + let pid = entry.file_name().to_string_lossy().parse::().ok()?; + let name = std::fs::read_to_string(entry.path().join("comm")).ok()?; + if !matches!(name.trim(), "cargo" | "rustc" | "rustdoc") { + return None; + } + let cwd = std::fs::read_link(entry.path().join("cwd")).ok(); + Some(serde_json::json!({"pid":pid,"comm":name.trim(),"cwd":cwd})) + }) + .collect(); + serde_json::json!({"loadavg": std::fs::read_to_string("/proc/loadavg").unwrap(), "affinity": std::fs::read_to_string("/proc/self/status").unwrap().lines().find(|l| l.starts_with("Cpus_allowed_list:")).unwrap_or("unknown").to_string(), "unix_seconds": std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs_f64(), "compiler_processes":processes}) + }; + eprintln!( + "R4_PERF_ROUND {}", + serde_json::json!({"round":round,"phase":"before","host":environment()}) + ); + let order: Vec<_> = if round % 2 == 0 { + (0..cases.len()).collect() + } else { + (0..cases.len()).rev().collect() + }; + for index in order { + let case = &mut cases[index]; + case.machine.reset_for_reuse().unwrap(); + case.machine.set_fuel(1_000_000); + let snap = case.machine.jit_snapshot(); + let inventory = ( + snap.attempts.len(), + snap.traces.len(), + case.machine.jit_native_trace_count(), + ); + let calls_before = case.calls.load(Ordering::Relaxed); + let jit_before = case.machine.jit_native_exec_count(); + let aot_before = case.machine.aot_exec_count(); + ALLOCS.with(|v| v.set(0)); + BYTES.with(|v| v.set(0)); + let live_before = LIVE.with(Cell::get); + ACTIVE.with(|v| v.set(true)); + let start = Instant::now(); + let status = case.machine.run().unwrap(); + let ns = start.elapsed().as_nanos(); + ACTIVE.with(|v| v.set(false)); + let allocs = ALLOCS.with(Cell::get); + let bytes = BYTES.with(Cell::get); + let live_delta = LIVE.with(Cell::get) - live_before; + assert_eq!(status, VmStatus::Halted); + assert_eq!( + case.machine.stack().last(), + Some(&Value::Int(case.expected)) + ); + assert_eq!(case.calls.load(Ordering::Relaxed) - calls_before, 2); + assert_eq!(case.machine.host_context().resource_count(), 0); + let snap = case.machine.jit_snapshot(); + assert_eq!( + inventory, + ( + snap.attempts.len(), + snap.traces.len(), + case.machine.jit_native_trace_count() + ), + "warm inventory changed" + ); + let jit = case.machine.jit_native_exec_count() - jit_before; + let aot = case.machine.aot_exec_count() - aot_before; + if case.backend == "trace" { + assert!(jit > 0); + } + if case.backend == "aot" { + assert!(aot > 0); + } + eprintln!( + "R4_PERF_SAMPLE {}", + serde_json::json!({"round":round,"backend":case.backend,"workload":case.workload,"carrier":case.carrier,"ns":ns,"allocs":allocs,"bytes":bytes,"live_delta":live_delta,"jit":jit,"aot":aot,"result":case.expected}) + ); + } + eprintln!( + "R4_PERF_ROUND {}", + serde_json::json!({"round":round,"phase":"after","host":environment()}) + ); + } + let rss_after = rss(); + drop(cases); + eprintln!( + "R4_PERF_MEMORY before={rss_before} after={rss_after} after_drop={}", + rss() + ); +} diff --git a/tests/result_docs_tests.rs b/tests/result_docs_tests.rs new file mode 100644 index 00000000..8a172921 --- /dev/null +++ b/tests/result_docs_tests.rs @@ -0,0 +1,97 @@ +#![cfg(feature = "runtime")] + +use vm::Value; + +#[test] +fn product_result_rss_examples_compile_roundtrip_and_execute() { + let docs = include_str!("../docs/result.md"); + let mut count = 0; + for block in docs.split("```rss\n").skip(1) { + let source = block.split("```").next().unwrap(); + let expected: i64 = source + .lines() + .next() + .unwrap() + .strip_prefix("// expected: ") + .unwrap() + .parse() + .unwrap(); + let compiled = vm::compile_source(source).unwrap(); + let bytes = + vm::encode_program(&compiled.program.with_local_count(compiled.locals)).unwrap(); + let program = vm::decode_program(&bytes).unwrap(); + 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); + assert_eq!(machine.run().unwrap(), vm::VmStatus::Halted); + assert_eq!(machine.stack().last(), Some(&vm::Value::Int(expected))); + count += 1; + } + assert_eq!(count, 2); +} + +mod rust_example { + pub use vm::borrow_arg; + pub mod functions { + include!("../docs/result_host_example.rs"); + + #[test] + fn documented_rust_host_example_is_exact_and_compiled() { + let docs = include_str!("../docs/result.md"); + assert!(docs.contains(include_str!("../docs/result_host_example.rs").trim())); + let descriptor = read_descriptor(); + assert_eq!(descriptor.schema.name, "example::read"); + assert!(matches!( + descriptor.schema.return_type, + vm::HostTypeSchema::Result { .. } + )); + let catalog = std::sync::Arc::new( + vm::HostFunctionDescriptor::collect_catalog(&[descriptor]).unwrap(), + ); + for mode in [0, 1, 2] { + let descriptor = read_descriptor(); + let compiled = vm::compile_source_with_flavor_and_options( + &format!("example::read({mode});"), + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::new().with_host_api_catalog(catalog.clone()), + ) + .unwrap(); + let mut machine = vm::Vm::new(compiled.program.with_local_count(compiled.locals)); + let mut registry = vm::HostFunctionRegistry::empty(); + vm::HostModuleDescriptor::install_descriptors(&mut registry, &[descriptor]) + .unwrap(); + registry.bind_vm_cached(&mut machine).unwrap(); + machine.set_fuel(100_000); + if mode == 2 { + assert!( + machine + .run() + .unwrap_err() + .to_string() + .contains("transport failed") + ); + } else { + assert_eq!(machine.run().unwrap(), vm::VmStatus::Halted); + let result = as vm::ScriptResult>::from_value( + machine.stack().last().unwrap().clone(), + ) + .unwrap(); + assert_eq!( + result, + if mode == 0 { + GuestResult::Ok(7) + } else { + GuestResult::Err("missing".into()) + } + ); + } + } + } + } +} 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_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" + ); + } + } + } +} 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/result_native_tests.rs b/tests/result_native_tests.rs new file mode 100644 index 00000000..e8bb7403 --- /dev/null +++ b/tests/result_native_tests.rs @@ -0,0 +1,542 @@ +#![cfg(all(feature = "runtime", feature = "cranelift-jit"))] + +use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, +}; +use vm::{CallOutcome, CallReturn, HostFunction, HostTypeSchema, Value, Vm, VmResult, VmStatus}; + +struct Input(Value, Arc); +impl HostFunction for Input { + fn call(&mut self, _: &mut Vm, _: &[Value]) -> VmResult { + self.1.fetch_add(1, Ordering::SeqCst); + Ok(CallOutcome::Return(CallReturn::one(self.0.clone()))) + } +} + +fn dynamic_payload(ty: &str, schema: HostTypeSchema, payload: Value, ok: bool, mode: &str) { + let source = format!( + r#" + pub fn increment(n: int) -> int {{ n + 1 }} + pub fn relay(flag: bool, payload: {ty}) -> Result<{ty}, {ty}> {{ + let carrier: Result<{ty}, {ty}> = if flag => {{ Result::Ok(payload) }} else => {{ Result::Err(payload) }}; + let mut i = 0; + let mut selected: {ty} = payload; + while i < 64 {{ + selected = match carrier {{ Result::Ok(v) => v, Result::Err(e) => e, }}; + i = i + 1; + }} + if flag => {{ Result::Ok(selected) }} else => {{ Result::Err(selected) }} + }} + relay(input::flag(), input::value()); + "# + ); + let mut builder = vm::HostApiBuilder::new(); + builder.function(vm::HostFunctionSchema::with_return( + "input::flag", + vec![], + HostTypeSchema::Bool, + )); + builder.function(vm::HostFunctionSchema::with_return( + "input::value", + vec![], + schema, + )); + let compiled = vm::compile_source_with_flavor_and_options( + &source, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::new() + .with_host_api_catalog(Arc::new(builder.build().unwrap())), + ) + .unwrap(); + let mut machine = Vm::new_with_jit_config( + compiled.program.with_local_count(compiled.locals), + vm::JitConfig { + enabled: mode == "trace", + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + let payload = if ty == "fn(int) -> int" { + machine.resolve_exported_callable("increment").unwrap() + } else { + payload + }; + let flag_calls = Arc::new(AtomicUsize::new(0)); + let value_calls = Arc::new(AtomicUsize::new(0)); + machine.bind_function( + "input::flag", + Box::new(Input(Value::Bool(ok), flag_calls.clone())), + ); + machine.bind_function( + "input::value", + Box::new(Input(payload.clone(), value_calls.clone())), + ); + machine.set_fuel(100_000); + if mode == "aot" { + machine + .compile_aot() + .unwrap_or_else(|e| panic!("{ty} ok={ok} AOT admission: {e:?}")); + let dump = machine.dump_aot_info(); + assert!( + dump.contains("lowering=ssa") && !dump.contains("interpreter-boundary"), + "{dump}" + ); + eprintln!("R4 AOT {ty} ok={ok}: {dump}"); + } + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(flag_calls.load(Ordering::SeqCst), 1); + assert_eq!(value_calls.load(Ordering::SeqCst), 1); + let result = machine.stack().last().unwrap(); + let (tag, selected) = vm::result::payload(result).unwrap(); + assert_eq!(tag, ok); + if let (Value::Float(actual), Value::Float(expected)) = (selected, &payload) { + if expected.is_nan() { + assert!(actual.is_nan()); + } else { + assert_eq!(actual.to_bits(), expected.to_bits()); + assert_eq!(actual.is_sign_negative(), expected.is_sign_negative()); + } + } else { + assert_eq!(selected, &payload); + } + if mode == "aot" { + assert!(machine.aot_exec_count() > 0); + } + if mode == "trace" { + let snapshot = machine.jit_snapshot(); + let relevant: Vec<_> = snapshot + .traces + .iter() + .filter(|t| { + t.executions > 0 + && t.op_names().iter().any(|op| op == "map_get") + && t.ssa_text().contains("payload") + }) + .collect(); + eprintln!( + "R4 trace {ty} ok={ok} native={} relevant={}\n{}", + machine.jit_native_exec_count(), + relevant.len(), + machine + .dump_jit_info() + .lines() + .filter(|line| !line.trim_start().starts_with("code:")) + .collect::>() + .join("\n") + ); + assert!( + !relevant.is_empty(), + "required native Result payload region absent: {:?}", + snapshot.attempts + ); + } + eprintln!( + "R4 parity mode={mode} ty={ty} ok={ok} payload={selected:?} host_calls=1/1 aot={} trace={}", + machine.aot_exec_count(), + machine.jit_native_exec_count() + ); + if ty == "fn(int) -> int" { + let alias = selected.clone(); + assert_eq!( + machine.invoke_callable(alias, &[Value::Int(41)]).unwrap(), + Value::Int(42) + ); + } +} + +#[test] +fn dynamic_int_float_bool_optional_and_nested_payloads_all_backends() { + let vectors = [ + ( + "fn(int) -> int", + HostTypeSchema::Callable { + params: vec![HostTypeSchema::Int], + result: Box::new(HostTypeSchema::Int), + }, + Value::Null, + ), + ("int", HostTypeSchema::Int, Value::Int(-37)), + ("float", HostTypeSchema::Float, Value::Float(-0.0)), + ("float", HostTypeSchema::Float, Value::Float(f64::NAN)), + ("bool", HostTypeSchema::Bool, Value::Bool(false)), + ( + "int?", + HostTypeSchema::Optional(Box::new(HostTypeSchema::Int)), + Value::Null, + ), + ( + "int?", + HostTypeSchema::Optional(Box::new(HostTypeSchema::Int)), + Value::Int(29), + ), + ( + "Result", + HostTypeSchema::Result { + ok: Box::new(HostTypeSchema::Int), + err: Box::new(HostTypeSchema::Bool), + }, + vm::result::carrier(false, Value::Bool(false)), + ), + ( + "Result", + HostTypeSchema::Result { + ok: Box::new(HostTypeSchema::Int), + err: Box::new(HostTypeSchema::Bool), + }, + vm::result::carrier(true, Value::Int(0)), + ), + ]; + for (ty, schema, payload) in vectors { + for mode in ["interpreter", "trace", "aot"] { + for ok in [true, false] { + dynamic_payload(ty, schema.clone(), payload.clone(), ok, mode); + } + } + } +} + +#[test] +fn dynamic_callback_try_native_and_malformed_admission_before_body() { + for mode in ["interpreter", "trace", "aot"] { + let mut builder = vm::HostApiBuilder::new(); + builder.function(vm::HostFunctionSchema::with_return( + "input::tick", + vec![], + HostTypeSchema::Int, + )); + let compiled = vm::compile_source_with_flavor_and_options( + r#" + pub fn checked(value: Result) -> Result { + let before = input::tick(); + let mut i = 0; + let mut sum = 0; + while i < 64 { let n = value?; sum = sum + n; i = i + 1; } + let after = input::tick(); + Result::Ok(sum) + } + 0; + "#, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::new() + .with_host_api_catalog(Arc::new(builder.build().unwrap())), + ) + .unwrap(); + let mut machine = Vm::new_with_jit_config( + compiled.program.with_local_count(compiled.locals), + vm::JitConfig { + enabled: mode == "trace", + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + let calls = Arc::new(AtomicUsize::new(0)); + machine.bind_function("input::tick", Box::new(Input(Value::Int(1), calls.clone()))); + machine.set_fuel(100_000); + if mode == "aot" { + machine.compile_aot().expect("standalone callback AOT"); + } + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + let callable = machine.resolve_exported_callable("checked").unwrap(); + for ok in [true, false] { + let before = calls.load(Ordering::SeqCst); + let native_before = (machine.jit_native_exec_count(), machine.aot_exec_count()); + let value = if ok { + vm::result::carrier(true, Value::Int(3)) + } else { + vm::result::carrier(false, Value::string("dynamic-error")) + }; + let returned = machine.invoke_callable(callable.clone(), &[value]).unwrap(); + let native_delta = ( + machine.jit_native_exec_count() - native_before.0, + machine.aot_exec_count() - native_before.1, + ); + if mode == "trace" { + assert!( + native_delta.0 > 0, + "callback {ok} must execute native Result region" + ); + } + if mode == "aot" { + assert!(native_delta.1 > 0, "callback {ok} must execute AOT"); + } + eprintln!("R4 callback branch mode={mode} ok={ok} native_delta={native_delta:?}"); + let (tag, payload) = vm::result::payload(&returned).unwrap(); + assert_eq!(tag, ok); + assert_eq!( + payload, + &if ok { + Value::Int(192) + } else { + Value::string("dynamic-error") + } + ); + assert_eq!( + calls.load(Ordering::SeqCst) - before, + if ok { 2 } else { 1 } + ); + } + for malformed in [ + Value::Null, + Value::map(vec![ + (Value::string("tag"), Value::Int(1)), + (Value::string("payload"), Value::Int(1)), + ]), + vm::result::carrier(true, Value::Bool(false)), + Value::map(vec![(Value::string("tag"), Value::Bool(true))]), + Value::map(vec![ + (Value::string("tag"), Value::Bool(true)), + (Value::string("payload"), Value::Int(1)), + (Value::string("extra"), Value::Int(0)), + ]), + ] { + let before = calls.load(Ordering::SeqCst); + assert!( + machine + .invoke_callable(callable.clone(), &[malformed]) + .is_err() + ); + assert_eq!( + calls.load(Ordering::SeqCst), + before, + "admission must precede guest body" + ); + } + if mode == "aot" { + assert!(machine.aot_exec_count() > 1); + } + if mode == "trace" { + let snapshot = machine.jit_snapshot(); + assert!( + snapshot.traces.iter().any(|t| t.executions > 0 + && t.ssa_text().contains("payload") + && t.op_names().iter().any(|op| op == "map_get")), + "{:?}", + snapshot.attempts + ); + } + eprintln!( + "R4 standalone callback mode={mode} aot={} trace={} ticks={} malformed=5 prebody_rejected", + machine.aot_exec_count(), + machine.jit_native_exec_count(), + calls.load(Ordering::SeqCst) + ); + } +} + +#[test] +fn dynamic_manual_record_bool_branch_aot_checks_tagged_representation() { + let source = "struct Pair { tag: bool, payload: int } fn choose(flag: bool) -> int { let pair: Pair = { tag: flag, payload: 7 }; let n = if pair.tag => { pair.payload } else => { 9 }; n } choose(true);"; + let compiled = vm::compile_source(source).unwrap(); + 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 + .compile_aot() + .expect("ordinary typed record Bool branch must unbox tagged Get"); + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + assert_eq!(machine.stack().last(), Some(&Value::Int(7))); + assert!(machine.aot_exec_count() > 0); +} + +#[test] +fn result_artifact_schema_binding_reset_vm_replacement_and_cross_vm() { + type R = vm::GuestResult; + let source = "pub fn echo(value: Result) -> Result { value } 0;"; + let compiled = vm::compile_source(source).unwrap(); + let bytes = vm::encode_program(&compiled.program.with_local_count(compiled.locals)).unwrap(); + let new_vm = || { + let mut machine = Vm::new_with_jit_config( + vm::decode_program(&bytes).unwrap(), + vm::JitConfig { + enabled: false, + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + machine.set_fuel(100_000); + machine + }; + let mut store = vm::Store::from_vm(new_vm()); + store.vm_mut().compile_aot().unwrap(); + let artifact = store.vm_mut().encode_aot_artifact().unwrap(); + assert_eq!(store.run().unwrap(), VmStatus::Halted); + let callback = store.script_callback_by_name::<(R,), R>("echo").unwrap(); + assert_eq!(callback.call(&mut store, (R::Ok(7),)).unwrap(), R::Ok(7)); + let mut other = vm::Store::from_vm(new_vm()); + other.run().unwrap(); + assert!(callback.call(&mut other, (R::Ok(7),)).is_err()); + let mut loaded = new_vm(); + loaded.load_aot_artifact(&artifact).unwrap(); + let mut loaded = vm::Store::from_vm(loaded); + loaded.run().unwrap(); + let echo = loaded.script_callback_by_name::<(R,), R>("echo").unwrap(); + let before = loaded.vm().aot_exec_count(); + assert_eq!( + echo.call(&mut loaded, (R::Err("loaded".into()),)).unwrap(), + R::Err("loaded".into()) + ); + assert!(loaded.vm().aot_exec_count() > before); + let mismatch = vm::compile_source(&source.replace("string", "bool")).unwrap(); + let original = vm::decode_program(&bytes).unwrap(); + assert_eq!(mismatch.program.code, original.code); + assert_ne!( + mismatch.program.callable_prototypes[0].schema, + original.callable_prototypes[0].schema + ); + let mut mismatch = Vm::new(mismatch.program.with_local_count(mismatch.locals)); + let error = mismatch + .load_aot_artifact(&artifact) + .expect_err("different Result error schema must reject"); + assert!(matches!( + error, + vm::AotArtifactError::IncompatibleProgramHash { .. } + )); + store.reset_for_reuse().unwrap(); + assert!(!store.vm().scope_reset_pending()); + assert!(callback.prepare((R::Ok(7),)).is_err()); + store.vm_mut().set_fuel(100_000); + store.run().unwrap(); + let old = store.script_callback_by_name::<(R,), R>("echo").unwrap(); + let replacement = vm::compile_source("pub fn echo(value: Result) -> Result { Result::Err(\"replacement\") } 0;").unwrap(); + *store.vm_mut() = Vm::new(replacement.program.with_local_count(replacement.locals)); + assert!( + old.call(&mut store, (R::Ok(1),)).is_err(), + "replacement must reject the old callable at Program admission" + ); + store.reset_for_reuse().unwrap(); + assert!(old.prepare((R::Ok(1),)).is_err()); + store.vm_mut().set_fuel(100_000); + store.run().unwrap(); + let fresh = store.script_callback_by_name::<(R,), R>("echo").unwrap(); + assert_eq!( + fresh.call(&mut store, (R::Ok(1),)).unwrap(), + R::Err("replacement".into()) + ); + eprintln!( + "R4 artifact schema mismatch rejected; loaded Result Err executes natively; reset/cross-VM/replacement revoke stale callbacks" + ); +} + +#[test] +fn dynamic_captured_closure_generic_alias_try_all_backends() { + for mode in ["interpreter", "trace", "aot"] { + for ok in [true, false] { + let mut builder = vm::HostApiBuilder::new(); + builder.function(vm::HostFunctionSchema::with_return( + "input::flag", + vec![], + HostTypeSchema::Bool, + )); + builder.function(vm::HostFunctionSchema::with_return( + "input::value", + vec![], + HostTypeSchema::Int, + )); + let source = r#" + fn apply(callback: fn() -> Result) -> Result { callback() } + pub fn runner(flag: bool, seed: int) -> Result { + let callback: fn() -> Result = || if true => { + let mut i = 0; let mut sum = 0; + while i < 64 { + let step: Result = if flag || i != 32 => { Result::Ok(seed) } else => { Result::Err(sum) }; + let n = step?; sum = n; i = i + 1; + } + Result::Ok(sum) + } else => { Result::Err(-1) }; + let alias = callback; + apply::(alias) + } + runner(input::flag(), input::value()); + "#; + let compiled = vm::compile_source_with_flavor_and_options( + source, + vm::SourceFlavor::RustScript, + vm::CompileSourceFileOptions::new() + .with_host_api_catalog(Arc::new(builder.build().unwrap())), + ) + .unwrap(); + if mode == "aot" { + eprintln!( + "R4 captured metadata functions={:?} prototypes={:?} regions={:?}\n{}", + compiled.program.script_functions, + compiled.program.callable_prototypes, + compiled.program.function_regions, + vm::disassemble_program(&compiled.program) + ); + } + let mut machine = Vm::new_with_jit_config( + compiled.program.with_local_count(compiled.locals), + vm::JitConfig { + enabled: mode == "trace", + hot_loop_threshold: 1, + max_trace_len: 512, + }, + ); + let flags = Arc::new(AtomicUsize::new(0)); + let values = Arc::new(AtomicUsize::new(0)); + machine.bind_function( + "input::flag", + Box::new(Input(Value::Bool(ok), flags.clone())), + ); + machine.bind_function( + "input::value", + Box::new(Input(Value::Int(7), values.clone())), + ); + machine.set_fuel(100_000); + if mode == "aot" { + machine + .compile_aot() + .expect("captured Result closure native AOT"); + } + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + let (tag, payload) = vm::result::payload(machine.stack().last().unwrap()).unwrap(); + assert_eq!(tag, ok); + assert_eq!(payload, &Value::Int(7)); + assert_eq!(flags.load(Ordering::SeqCst), 1); + assert_eq!(values.load(Ordering::SeqCst), 1); + if mode == "aot" { + assert!(machine.aot_exec_count() > 0); + } + if mode == "trace" { + assert!( + machine + .jit_snapshot() + .traces + .iter() + .any(|t| t.executions > 0 + && t.ssa_text().contains("payload") + && t.op_names().iter().any(|op| op == "map_get")), + "{}", + machine.dump_jit_info() + ); + } + eprintln!( + "R4 captured generic alias mode={mode} ok={ok} payload={payload:?} host_calls=1/1 trace={} aot={}", + machine.jit_native_exec_count(), + machine.aot_exec_count() + ); + } + } +} + +#[test] +fn dynamic_string_payload_both_branches_all_backends() { + for mode in ["interpreter", "trace", "aot"] { + for ok in [true, false] { + dynamic_payload( + "string", + HostTypeSchema::String, + Value::string("runtime-payload"), + ok, + mode, + ); + } + } +} 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_try_integration_tests.rs b/tests/result_sdk_try_integration_tests.rs new file mode 100644 index 00000000..a17e7f7e --- /dev/null +++ b/tests/result_sdk_try_integration_tests.rs @@ -0,0 +1,565 @@ +#![cfg(feature = "runtime")] + +use pd_host_function::pd_host_function; +use std::sync::{Arc, Mutex}; +use vm::resource::{CloseProgress, HostResource, ResourceCloseReason, ResourceResult}; +use vm::{ + GuestResult, HostResourceType, ResourceOwned, ResourceTypeKey, Value, Vm, VmResult, VmStatus, +}; + +static LOCK: Mutex<()> = Mutex::new(()); +static EVENTS: Mutex> = Mutex::new(Vec::new()); +fn event(kind: &'static str, id: i64) { + EVENTS.lock().unwrap().push((kind, id)); +} +#[derive(Debug)] +struct Token(i64); +impl HostResource for Token { + fn resource_type_key() -> Option { + Some(ResourceTypeKey::new(Self::KEY).unwrap()) + } + fn begin_close(&mut self, _: ResourceCloseReason) -> ResourceResult { + event("close", self.0); + Ok(CloseProgress::Ready) + } +} +impl HostResourceType for Token { + const KEY: &'static str = "joined.token"; + const DESCRIPTION: &'static str = "joined Result SDK and try integration token"; +} +impl Drop for Token { + fn drop(&mut self) { + event("drop", self.0); + } +} +type Owned = GuestResult, ResourceOwned>; +fn owned(ok: bool, id: i64) -> Owned { + if ok { + GuestResult::Ok(ResourceOwned::new(Token(id))) + } else { + GuestResult::Err(ResourceOwned::new(Token(id))) + } +} +fn assert_owned(value: &Owned, ok: bool, id: i64) { + match value { + GuestResult::Ok(value) => { + assert!(ok); + assert_eq!(value.as_ref().0, id); + } + GuestResult::Err(value) => { + assert!(!ok); + assert_eq!(value.as_ref().0, id); + } + } +} +mod generated { + pub use vm::borrow_arg; + pub mod functions { + use super::super::*; + /// Returns the active typed owner, with an observable once-only effect. + #[pd_host_function(name = "joined::make")] + fn make( + ok: bool, + id: i64, + ) -> VmResult, ResourceOwned>> { + event("make", id); + Ok(owned(ok, id)) + } + /// Borrows a fully typed active owner on the success continuation. + #[pd_host_function(name = "joined::read")] + fn read(value: GuestResult, vm::ResourceRef<'_, Token>>) -> i64 { + let id = match value { + GuestResult::Ok(value) | GuestResult::Err(value) => value.get().0, + }; + event("read", id); + id + } + /// Transfers ownership to opaque host state, outside publication reclaim. + #[pd_host_function(name = "joined::keep")] + fn keep( + vm: &mut Vm, + value: GuestResult, ResourceOwned>, + ) -> i64 { + vm.host_context().set_module_state(value); + 1 + } + /// Explicitly closes a native-taken active payload on the normal path. + #[pd_host_function(name = "joined::close")] + fn close(value: GuestResult, ResourceOwned>) -> VmResult { + let mut value = match value { + GuestResult::Ok(value) | GuestResult::Err(value) => value.into_inner(), + }; + value.begin_close(ResourceCloseReason::Requested).unwrap(); + Ok(value.0) + } + /// Separates fatal VM transport from both guest Result branches. + #[pd_host_function(name = "joined::tri")] + fn tri(mode: i64) -> VmResult> { + event("tri", mode); + match mode { + 0 => Ok(GuestResult::Ok(7)), + 1 => Ok(GuestResult::Err("joined error".into())), + _ => Err(vm::VmError::HostError("joined fatal".into())), + } + } + /// Records effects on each side of the function-local propagation boundary. + #[pd_host_function(name = "joined::tick")] + fn tick(id: i64) -> i64 { + event("tick", id); + id + } + } +} +fn machine(source: &str, decoded: bool) -> Vm { + let descriptors = vec![ + generated::functions::make_descriptor(), + generated::functions::read_descriptor(), + generated::functions::keep_descriptor(), + generated::functions::close_descriptor(), + generated::functions::tri_descriptor(), + generated::functions::tick_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 program = compiled.program.with_local_count(compiled.locals); + let program = if decoded { + let bytes = vm::encode_program(&program).unwrap(); + assert_eq!(u16::from_le_bytes([bytes[4], bytes[5]]), 13); + let decoded = vm::decode_program(&bytes).unwrap(); + assert_eq!(decoded.host_import_schemas(), program.host_import_schemas()); + assert_eq!(vm::encode_program(&decoded).unwrap(), bytes); + decoded + } else { + program + }; + 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(program); + registry.bind_vm_cached(&mut machine).unwrap(); + machine.set_fuel(100_000); + machine +} + +#[cfg(feature = "cranelift-jit")] +#[test] +fn dynamic_resource_try_hot_loop_before_teardown_all_backends() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for mode in ["interpreter", "aot", "trace"] { + for ok in [true, false] { + EVENTS.lock().unwrap().clear(); + let source = format!( + r#"use joined; + fn relay(flag: bool) -> Result, resource> {{ + let local = joined::make(true, 101); + let mut i = 0; + while i < 64 {{ + let produced = joined::make(flag || i != 48, i); + let normalized: Result, resource> = match produced {{ + Result::Ok(v) => Result::Ok(v), + Result::Err(e) => Result::Err(e), + }}; + let selected = normalized?; + let temporary: Result, resource> = Result::Ok(selected); + let closed = joined::close(temporary); + i = i + 1; + }} + let local_closed = joined::close(local); + let after = joined::tick(2); + joined::make(true, 501) + }} + let returned = relay(joined::tick({}) == 0); + joined::close(returned);"#, + if ok { 0 } else { 1 } + ); + let mut machine = machine(&source, true); + machine.set_jit_config(vm::JitConfig { + enabled: mode == "trace", + hot_loop_threshold: 1, + max_trace_len: 512, + }); + if mode == "aot" { + machine + .compile_aot() + .expect("required resource AOT admission"); + assert!(machine.dump_aot_info().contains("lowering=ssa")); + } + assert_eq!(machine.run().expect("resource runtime"), VmStatus::Halted); + assert_eq!( + machine.stack().last(), + Some(&Value::Int(if ok { 501 } else { 48 })) + ); + assert_eq!(machine.host_context().resource_count(), 0); + let events = EVENTS.lock().unwrap().clone(); + let iterations = if ok { 64 } else { 49 }; + for id in (0..iterations) + .chain([101]) + .chain(if ok { Some(501) } else { None }) + { + for kind in ["make", "close", "drop"] { + assert_eq!( + events.iter().filter(|e| **e == (kind, id)).count(), + 1, + "{mode} {ok} {id} {kind}: {events:?}" + ); + } + } + assert_eq!(events.contains(&("tick", 2)), ok); + if !ok { + let err_make = events.iter().position(|e| *e == ("make", 48)).unwrap(); + let local_close = events.iter().position(|e| *e == ("close", 101)).unwrap(); + let err_close = events.iter().position(|e| *e == ("close", 48)).unwrap(); + assert!(err_make < local_close && local_close < err_close); + assert!(!events.iter().any(|e| e.0 == "make" && e.1 == 49)); + } + if mode == "aot" { + assert!(machine.aot_exec_count() > 0); + } + if mode == "trace" { + eprintln!( + "R4 resource full trace\n{}", + machine + .dump_jit_info() + .lines() + .filter(|line| !line.trim_start().starts_with("code:")) + .collect::>() + .join("\n") + ); + let snapshot = machine.jit_snapshot(); + let relevant: Vec<_> = snapshot + .traces + .iter() + .filter(|t| { + t.executions > 0 + && t.op_names().iter().any(|op| op == "map_get") + && t.ssa_text().contains("tag") + && t.ssa_text().contains("payload") + }) + .collect(); + for trace in &relevant { + eprintln!( + "R4 resource trace root={} executions={} terminal={:?}\n{}", + trace.root_ip, + trace.executions, + trace.terminal, + trace.ssa_text() + ); + } + assert!( + !relevant.is_empty(), + "required resource tag/payload native region missing: {:?}", + snapshot.attempts + ); + } + eprintln!( + "R4 resource mode={mode} ok={ok} live_before_teardown=0 iterations={iterations} trace={} aot={} events={events:?}", + machine.jit_native_exec_count(), + machine.aot_exec_count() + ); + } + } +} + +#[test] +fn generated_host_try_is_once_only_function_local_and_fatal_across_v13() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for decoded in [false, true] { + for mode in [0, 1, 2] { + EVENTS.lock().unwrap().clear(); + let mut machine = machine( + &format!( + r#"use joined; + fn relay(mode: int) -> Result {{ + let value: int = joined::tri(mode)?; + joined::tick(2); + Result::Ok(value + 1) + }} + fn outer(mode: int) -> Result {{ + let alias = relay; + let value = alias(mode); + joined::tick(3); + value + }} + outer({mode});"# + ), + decoded, + ); + if mode == 2 { + assert!( + machine + .run() + .unwrap_err() + .to_string() + .contains("joined fatal") + ); + assert_eq!(*EVENTS.lock().unwrap(), [("tri", mode)]); + } else { + assert_eq!(machine.run().unwrap(), VmStatus::Halted); + let value = machine.stack().last().unwrap().clone(); + let Value::Map(fields) = &value else { + panic!("Result carrier required") + }; + assert_eq!( + fields.get(&Value::string("tag")), + Some(&Value::Bool(mode == 0)) + ); + let typed = + as vm::ScriptResult>::from_value(value).unwrap(); + assert_eq!( + typed, + if mode == 0 { + GuestResult::Ok(8) + } else { + GuestResult::Err("joined error".into()) + } + ); + assert_eq!( + *EVENTS.lock().unwrap(), + if mode == 0 { + vec![("tri", 0), ("tick", 2), ("tick", 3)] + } else { + vec![("tri", 1), ("tick", 3)] + } + ); + } + eprintln!( + "joined host decoded={decoded} mode={mode} events={:?}", + *EVENTS.lock().unwrap() + ); + } + } +} + +const RELAY: &str = r#"use joined; +pub fn relay(value: Result, resource>, sibling: Result, resource>) -> Result, resource> { + let local = joined::make(true, 101); + let selected: resource = value?; + joined::read(local); + joined::close(local); + Result::Ok(selected) +} +pub fn nested(value: Result, resource>, sibling: Result, resource>) -> Result, resource> { + let alias = relay; + alias(value, sibling) +} +pub fn host_relay(ok: bool) -> Result, resource> { + let local = joined::make(true, 101); + let selected: resource = joined::make(ok, 501)?; + joined::close(local); + Result::Ok(selected) +} +"#; + +#[test] +fn generated_owned_host_result_try_preserves_active_owner_on_v13_callback_return() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for decoded in [false, true] { + for ok in [false, true] { + EVENTS.lock().unwrap().clear(); + let mut store = vm::Store::from_vm(machine(RELAY, decoded)); + store.run().unwrap(); + let callback = store + .vm_script_callback_by_name::<(bool,), Owned>("host_relay") + .unwrap(); + let returned = callback.call_with_vm(&mut store, (ok,)).unwrap(); + assert_owned(&returned, ok, 501); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + let events = EVENTS.lock().unwrap().clone(); + assert_eq!(events.iter().filter(|e| **e == ("make", 501)).count(), 1); + assert_eq!(events.iter().filter(|e| **e == ("close", 101)).count(), 1); + assert_eq!(events.iter().filter(|e| **e == ("drop", 101)).count(), 1); + assert!(!events.contains(&("close", 501))); + assert!(!events.contains(&("drop", 501))); + eprintln!( + "joined host-owner decoded={decoded} ok={ok} returned=501 live=0 events={events:?}" + ); + drop(returned); + } + } +} + +#[test] +fn owned_callback_try_does_not_reclaim_native_taken_sibling() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for ok in [true, false] { + EVENTS.lock().unwrap().clear(); + let source = RELAY.replacen("let local =", "joined::keep(sibling); let local =", 1); + let mut store = vm::Store::from_vm(machine(&source, true)); + store.run().unwrap(); + let callback = store + .vm_script_callback_by_name::<(Owned, Owned), Owned>("relay") + .unwrap(); + let returned = callback + .call_with_vm(&mut store, (owned(ok, 301), owned(!ok, 302))) + .unwrap(); + assert_owned(&returned, ok, 301); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + let retained = store + .vm_mut() + .host_context() + .take_module_state::() + .unwrap(); + assert_owned(&retained, !ok, 302); + let events = EVENTS.lock().unwrap().clone(); + assert_eq!(events.iter().filter(|e| **e == ("close", 101)).count(), 1); + for id in [301, 302] { + assert!(!events.contains(&("close", id))); + assert!(!events.contains(&("drop", id))); + } + eprintln!("joined native-taken ok={ok} returned=301 retained=302 live=0 events={events:?}"); + drop(returned); + drop(retained); + } +} + +#[test] +fn try_callback_preserves_surviving_factory_capture_through_alias() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for decoded in [false, true] { + EVENTS.lock().unwrap().clear(); + let mut store = vm::Store::from_vm(machine( + r#"use joined; + pub fn factory() { + let saved = joined::make(true, 401); + fn callback(value: Result, resource>, sibling: Result, resource>, release: bool) -> Result, resource> { + let local = joined::make(true, 101); + let selected: resource = value?; + if release { joined::close(saved); } else { joined::read(saved); } + joined::close(local); + Result::Ok(selected) + } + callback + } + "#, + decoded, + )); + 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::<(Owned, Owned, bool), Owned>(callable) + .unwrap(); + let alias = callback.clone(); + for ok in [false, true, false, true] { + let before = EVENTS.lock().unwrap().len(); + let returned = alias + .call_with_vm(&mut store, (owned(ok, 301), owned(!ok, 302), false)) + .unwrap(); + assert_owned(&returned, ok, 301); + assert_eq!(store.vm_mut().host_context().resource_count(), 1); + let events = EVENTS.lock().unwrap()[before..].to_vec(); + assert_eq!(events.iter().filter(|e| **e == ("close", 101)).count(), 1); + assert_eq!(events.iter().filter(|e| **e == ("close", 302)).count(), 1); + assert_eq!(events.contains(&("read", 401)), ok); + assert!(!events.contains(&("close", 401))); + assert!(!events.contains(&("drop", 401))); + eprintln!( + "joined capture decoded={decoded} ok={ok} returned=301 saved=401 live=1 events={events:?}" + ); + drop(returned); + } + drop(alias); + callback.unsubscribe(); + // The capture remains a scoped host resource until explicit scope shutdown. + 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_ready() + ); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + assert_eq!( + EVENTS + .lock() + .unwrap() + .iter() + .filter(|e| **e == ("close", 401)) + .count(), + 1 + ); + } +} + +#[test] +fn owned_callback_try_ret_and_publication_reclaim_preserve_return_and_unrelated_v13() { + let _lock = LOCK.lock().unwrap_or_else(|p| p.into_inner()); + for decoded in [false, true] { + for name in ["relay", "nested"] { + for ok in [true, false] { + EVENTS.lock().unwrap().clear(); + let mut store = vm::Store::from_vm(machine(RELAY, decoded)); + store.run().unwrap(); + let unrelated = store + .vm_mut() + .host_context() + .insert_resource(Token(900)) + .unwrap() + .handle(); + let callback = store + .vm_script_callback_by_name::<(Owned, Owned), Owned>(name) + .unwrap(); + let returned = callback + .call_with_vm(&mut store, (owned(ok, 301), owned(!ok, 302))) + .unwrap(); + eprintln!( + "joined before assertions decoded={decoded} name={name} ok={ok} events={:?}", + *EVENTS.lock().unwrap() + ); + assert_owned(&returned, ok, 301); + assert_eq!(store.vm_mut().host_context().resource_count(), 1); + assert_eq!( + store + .vm_mut() + .host_context() + .borrow_resource::(unrelated) + .unwrap() + .get() + .0, + 900 + ); + let events = EVENTS.lock().unwrap().clone(); + for id in [101, 302] { + assert_eq!( + events.iter().filter(|e| **e == ("close", id)).count(), + 1, + "{events:?}" + ); + assert_eq!( + events.iter().filter(|e| **e == ("drop", id)).count(), + 1, + "{events:?}" + ); + } + assert!(!events.contains(&("close", 301))); + assert!(!events.contains(&("drop", 301))); + assert!(!events.contains(&("close", 900))); + assert_eq!(events.contains(&("read", 101)), ok); + eprintln!( + "joined owned decoded={decoded} path={name} ok={ok} returned=301 unrelated=900 live=1 events={events:?}" + ); + drop(returned); + store + .vm_mut() + .host_context() + .close_resource::(unrelated, ResourceCloseReason::Requested) + .unwrap(); + assert_eq!(store.vm_mut().host_context().resource_count(), 0); + } + } + } +} 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/result_try_tests.rs b/tests/result_try_tests.rs new file mode 100644 index 00000000..29f961d7 --- /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; + let hi = span.hi; + 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/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" + ); + } +} 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/vm/http_sse_tests.rs b/tests/vm/http_sse_tests.rs index 2a90f18d..658b36ae 100644 --- a/tests/vm/http_sse_tests.rs +++ b/tests/vm/http_sse_tests.rs @@ -1230,7 +1230,13 @@ async fn sse_total_deadline_releases_the_connection_permit_for_reuse() { .await .expect_err("the first stream should reach its total deadline"); assert!(error.to_string().contains("total deadline"), "{error}"); - vm.reset_for_reuse().expect("SSE reset should complete"); + reset_and_wait(&mut vm) + .await + .expect("SSE reset should reach quiescence"); + assert!(!vm.scope_reset_pending()); + assert!(vm.is_reusable()); + assert_eq!(vm.host_context().operation_count(), 0); + assert_eq!(vm.host_context().resource_count(), 0); drive(&mut vm) .await .expect("the second stream should acquire the released permit"); @@ -1315,7 +1321,13 @@ async fn sse_callback_stop_after_deadline_fails_and_releases_permit_without_anot assert_eq!(vm.host_context().resource_count(), 0); assert_eq!(vm.host_context().operation_count(), 0); - vm.reset_for_reuse().expect("SSE reset should complete"); + reset_and_wait(&mut vm) + .await + .expect("SSE reset should reach quiescence"); + assert!(!vm.scope_reset_pending()); + assert!(vm.is_reusable()); + assert_eq!(vm.host_context().operation_count(), 0); + assert_eq!(vm.host_context().resource_count(), 0); drive(&mut vm) .await .expect("the next stream should acquire the released permit"); diff --git a/tests/wire/wire_tests.rs b/tests/wire/wire_tests.rs index 487a9f39..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, @@ -89,9 +90,13 @@ 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,29 +106,29 @@ 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) { 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 { 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 +1079,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 +1109,19 @@ 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 +1147,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", _)) + )); +}